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.
mcpConnector does, and it runs the other direction. See
Connectors.
Enable the door
Setmcp: 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.
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.
Consent page
The door renders the consent page for you when your adapter usessession. 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 anauthorize 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.
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:
/.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-cardis an alias)
vendo init generates reconstructs the full path
from req.originalUrl, so the same handler serves both bases:
Set VENDO_BASE_URL
SetVENDO_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 fromVENDO_BASE_URLwhen set, so clients see your public origin instead of an unreachable internal one. Forwarded headers such asX-Forwarded-Hostare 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_URLgives door-first traffic a base to resolve host routes against.
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 sameactAs 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
actAsconfigured, the tool returns anot-implementederror, the same clean degradation as an away automation.
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 withremoteAs 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.
- Tokens must be ES256-signed JWTs with
iss,sub,aud,iat, andexpclaims.issmust equal the configuredissuer,audmust equal the configuredaudience, andexpmust 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 JWTsubon every request, so returningnullstill kills a live session — this remains your revocation point. - The local
/api/vendo/mcp/authorize,/token, and/registerendpoints return404in this mode, and so does RFC 8414 authorization-server metadata. RFC 9728 protected-resource metadata instead advertisesauthorization_servers: [remoteAs.issuer], so compliant clients discover and use the external server directly. oauthis still required — the door skipsoauth.authorizebecause the external server owns the interactive step, but it still callsoauth.principal.
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.
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 examplehttps://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 matchiss.scopes— the string array the external server wants your user to consent to.client_name— the display name shown in your consent UI.
- If your adapter implements
authorize, the door callsoauth.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 callsoauth.session(request, { returnTo })with the federate request URL asreturnTo, 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.
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:
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:
productNamecomes from your MCP server identity, so the CTA always names your product.appNameis 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.
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
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.