mcp: true is real, guard-bound, and covered by
protocol-level e2e (fixtures/mcp-e2e). The attended live-client matrix is the
open item: fixtures/mcp-e2e/tests/live-claude.e2e.test.ts (env-gated behind
VENDO_LIVE_MCP=1) is the Claude leg, and ChatGPT and Cursor are outstanding.
When to use it
The door is the third-party-agent story: an installable MCP server that exposes your product’s tools to agents you do not run, whose users act as themselves in your product under the same policy and approvals you enforce in-product. Two adjacent cases are not the door:- Pulling remote MCP tools into your own agent runs the other direction.
That is
mcpConnector— see Connectors. - Your own agent, in process with
createVendoin your backend (an AI SDK or Mastra loop you already ship) needs no OAuth dance and no door. Spread the guarded tool pack into your loop through the@vendoai/vendo/ai-sdkor@vendoai/vendo/mastrasubpath — see Use with your existing agent.
mcp: { serviceAuth: { keys } } opens first-party service auth at the door’s own
token endpoint for exactly that: your backend posts a key you generated
(openssl rand -hex 32) plus one of your user ids and receives a ten-minute
token bound to that user — no refresh token — then talks MCP with it like any
other client. Nothing downstream changes: the same guard, approvals, and audit,
with the call attributed to the person and to the key as svc:<hash8> (the
presented key’s digest, so the audit row names which key acted without the value
going near it). A service key is for code you deploy and can name any user, so
it is exactly as powerful as your backend already is; per-user OAuth stays the
story for anyone else’s agent.
Two edges. While the door lists a key, a wrong client_id, an unknown key, and
a retired one all answer the same invalid_client — nothing tells a guesser
which half of the credential they have right. A door that lists no key answers
every attempt, a valid key included, with unsupported_grant_type: it is not
refusing the exchange, it is not offering one. So closing the exchange means
removing serviceAuth, not emptying it — keys: [], or a blank entry (the
usual sign of an unset environment variable), is a composition error, because a
key nothing can ever match would advertise the grant and then refuse every
attempt. The exchange itself is
below.
Enable the door
Two keys open the door:mcp (true for defaults, or the object form under
Configure the door through the umbrella)
and oauth, a HostOAuthAdapter. oauth is required whenever mcp is
enabled — the door mints its principals through that adapter, so createVendo
throws at composition without one. An auth preset carrying an oauth half
satisfies the same seam.
The recommended shape is 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. With session defined the door renders
the consent page itself. The full contract, including the legacy authorize-only
mode, is in HostOAuthAdapter.
Curate the tool menu
By default the door offers every merged, enabled tool whoseaudience is
end-user or unset: an MCP client speaks for a person, so the door offers what
that person’s own auth admits. Operator and internal tools stay off it.
A shorter, deliberate menu is named in .vendo/overrides.json under
surfaces.mcp:
surfaces.agent does
the same for your in-product agent’s loadout. Both keys are optional and
independent; surfaces accepts only agent and mcp, so a misspelled surface
name fails loudly when the file parses.
A menu is curation, not a permission boundary. Your policy, approvals, audit,
disabled, and audience exclusions decide what may run, and none of them read
this block. A destructive tool can stay on the menu: the guard still parks it.
An entry naming a tool that does not exist or is disabled warns once at boot
and is skipped. The rest of the menu still applies, because a stale name in a
hand-edited file must not take your product down.
Vendo’s own vendo_* tools, including the saved-apps viewer, are never
curated away. They are the runtime’s plumbing, not your API.
Title your tools
Tool names are wire identifiers and extracted descriptions are often raw route text. A tool’stitle is what every surface that shows it to a person uses
instead: the door’s tools/list, and your approval cards.
vendo sync’s AI enrichment pass proposes titles while it reads your handlers.
A title in .vendo/overrides.json always wins over what it wrote.
Annotations on the wire
Each listed tool carries MCPannotations derived from its Vendo risk label,
so a client can warn a person before a write without re-reading prose:
An
ungraded tool asserts neither hint. MCP’s own default for
destructiveHint is true, so emitting false would be an active claim of
safety about a tool nobody has graded — and true would be the opposite guess.
Omitting them leaves the client on the spec’s conservative defaults. Grade the
tool (vendo sync, or .vendo/overrides.json) and the hints appear.
Annotations are presentation hints — the guard is what decides — and they ride
every listing whether or not you curate a menu. One consequence: some clients
use readOnlyHint: true to skip their own confirmation prompt, so if a
read-labelled tool is not actually side-effect free, fix its risk in
.vendo/overrides.json; that label was already driving your policy and
approvals.
Consent page
The door renders the consent page itself 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.
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-*. createVendo hands your resolved
.vendo/theme.json to the door automatically, so .vendo/theme.json is the
only place to edit; there is no consent-specific configuration.
The same theme carries into apps rendered inside MCP clients. See
Saved apps ride along.
Replace the page
Anauthorize method alongside session replaces the rendered page — for
different copy, a compliance disclosure, or a branded layout the tokens cannot
express. The door still owns CSRF, single-use replay protection, and the OAuth
redirect; you own only the HTML. It supplies ctx.consent, and your page posts
transaction, csrf_token, and decision=approve|deny to the action URL
there. The signature is in HostOAuthAdapter.
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.
Connect a client
The door’s MCP endpoint is themcp path under the wire, for example
https://your-app.example.com/api/vendo/mcp. A client discovers OAuth from the
WWW-Authenticate challenge on its first 401, walks the user through your
authorize step, and then calls tools.
GET /status reports the door as blocks.mcp: true. vendo doctor, run from
the host root, verifies that both OAuth metadata documents resolve, that the
server card parses, and — if registry paperwork has started — that your
server.json and mcp-registry-auth challenge match the live door. See
HTTP routes for the door’s route table and
Handler options for the mcp and oauth options.
Making a deployed door discoverable through the official registry is
below.
The connect page
The door serves a themed page at{mount}/connect, for example
https://your-app.example.com/api/vendo/mcp/connect. It shows your product
name, the exact MCP URL to paste, and per-client setup steps for Claude,
ChatGPT, and Cursor, including a one-click Cursor install link.
The page is unauthenticated and reads no user data: it shows only what your
public server card already advertises. Like the consent page it ships zero
JavaScript and it themes itself from .vendo/theme.json.
Route the discovery paths
The door’s transport and OAuth endpoints mount under the wire at/api/vendo/mcp, so an existing catch-all handler already serves them. The
OAuth discovery documents live at the origin root, outside /api/vendo, so the
catch-all never sees them; wellKnownVendoHandler (exported from
@vendoai/vendo/server) is the handler for those, mounted at the origin root —
app/.well-known/[...vendo]/route.ts on Next.js, a second mountVendo() at
/.well-known on Express. examples/demo-bank has the reference wiring.
An app that serves its own well-known documents must register those routes
separately — and, on Express, before that second mount. The exact paths the door
owns are in Well-known discovery paths.
Set VENDO_BASE_URL
VENDO_BASE_URL is your product’s full public URL, path prefix included, and
every real deployment needs it. Behind a reverse proxy (Railway, Fly, any TLS
terminator) the request reaching your process carries the proxy-internal origin,
so without it the door publishes discovery documents that point at an
unreachable origin. The full list of what it controls is in
VENDO_BASE_URL duties.
When the door is served on a different origin than the one host routes resolve
against, mcp.baseUrl carries the door’s public base explicitly:
createVendo({ mcp: { baseUrl: "https://app.example.com" }, oauth }).
vendo doctor fails E-MCP-009 on an MCP-wired composition with neither — a
static check, so it catches this with no dev server and no network.
Configure the door through the umbrella
The object form carries door-specific settings throughcreateVendo — a
different origin, an external authorization server, federation:
baseUrl, remoteAs, federation, and serviceAuth live under mcp: { … };
they are not top-level createVendo keys.
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.
cautious preset that means reads answer straight
away while writes, vendo_apps_pin and vendo_apps_unpin among them, wait for
the person.
Delegate to an external authorization server
mcp.remoteAs points the door at an authorization server you already run, or an
identity provider that issues bearer tokens for your APIs, 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. It takes an
issuer, an audience, and an optional jwksUri — omitted, the door discovers
the JWKS from the issuer’s RFC 8414 metadata. The exact token requirements and
endpoint behavior in this mode are in
remoteAs token requirements.
Use remoteAs when the external server is authoritative for user identity;
skip it when the door should run its own OAuth surface. A door in this mode
serves no token endpoint of its own, so a service-key exchange lives at that
issuer’s own token_endpoint instead; passing mcp.serviceAuth alongside an
explicit mcp.remoteAs warns at composition and does nothing.
A hosted broker in front of the door needs no object form at all:
VENDO_MCP_BROKER_URL set to your tenant’s MCP endpoint, with mcp: true, is
the whole switch.
/authorize, /token, and /register. Nothing is registered
anywhere and nothing is fetched at boot, so no deploy can repoint another one; a
URL the door cannot verify tokens against fails loudly rather than quietly
reverting to a local OAuth surface. An explicit mcp.remoteAs still wins over
it.
So does an explicit mcp.serviceAuth: its exchange exists only at the door’s own
/token, so configuring it is a choice of local authorization server, and this
variable — a default — leaves it alone. To front a serviceAuth door with a
broker, move the exchange to the broker and drop serviceAuth. The broker’s own
exchange answers field for field the same as the door’s, except that its
access_token is a signed JWT rather than an opaque string. It also forwards
only to a public HTTPS address, so it can never reach localhost — a laptop is
the self-hosted path whatever the account.
Federate login from an external authorization server
mcp.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. It applies 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 is the high-entropy secret shared with that
server, and the composition reads it directly — the mcp: { federation } object
is only needed when the secret comes from somewhere other than that variable.
The external server crafts an HS256-signed request JWT and redirects the user’s
browser to GET /api/vendo/mcp/federate?request=<compact JWS>. The claims that
JWT must carry are in Federation JWT claims.
The door verifies the request, then authenticates the user through your
adapter:
- If your adapter implements
authorize, the door callsoauth.authorize(request, { clientName, scopes })— the same authorize step your local flow uses, so no separate consent UI is needed. - 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.
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:
null from your oauth.principal(subject) is the account-level kill
switch: the door re-resolves the principal on every transport request and closes
live MCP sessions the moment the lookup fails — the fastest way to sever every
client for a user from your product’s settings UI.
What each revocation retires, and how the endpoint answers, is in
Revocation semantics.
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 also makes them. vendo_make is on the same list, so an outside
agent asks for a screen in plain language exactly as your own agent does, and
vendo_apps_pin / vendo_apps_unpin put a saved app into one of your product’s
slots. None of it hands the agent any UI: vendo_make answers with a four-field
receipt of words, and the screen arrives on the person’s own page in your
product, on a channel the agent is not on.
A build can come back failed, with a reason: the checks floor rejects a screen
whose bindings claim data your host does not return, or whose props do not
type-check. Nothing is painted and whatever held the slot stays. The reason is
the agent’s to act on — a narrower retry on the same app beats rebuilding from
scratch.
To do it rather than read it: the MCP quickstart connects a
client, and the tool-pack path covers what vendo_make
answers with and where the screen lands.
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.
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.
There is no per-app configuration: the door serves your validated
.vendo/theme.json in the shim, so one edit restyles the consent page and
MCP-rendered apps alike.
Service-key token exchange
A nightly job or queue worker has no browser to bounce. Your backend exchanges a service key plus one of your user ids for a short-lived token bound to that user; nothing downstream changes. Mint a key — any opaque string; the door never parses one:VENDO_MCP_BROKER_URL set, which a
Cloud key alone does not do — moves the exchange there instead: drop
serviceAuth, create the key on the project’s keys page in the console under
Service keys, and post to https://<your tenant>.mcp.vendo.run/token.
Then one RFC 8693 form post, from any language:
subject_token is one of your user ids, in your own spelling. The answer
is an ordinary OAuth token response:
authorization: Bearer <access_token>; the MCP session is an
ordinary one. Ten minutes, no refresh token — mint one per job. The exchange
never checks the user id, so an id your product does not recognize mints a
valid-looking token that dies on the first MCP request with
401 invalid_token: check the id against your own records before you suspect
the key.
Door reference
The wire contracts behind the sections above. Nothing here is required for a standard install.HostOAuthAdapter
session(request, { returnTo })returns{ subject }for the current host user, or aResponsethat redirects the browser to your login. Send the user back throughreturnToso the door resumes the exact authorization request without a login → authorize → login loop.principal(subject)is re-resolved on every bearer-authenticated MCP request. Returnnullto revoke; this is your account-level kill switch.authorize(request, { clientName, scopes, consent })is optional. Whensessionis defined,consentis present and the door keeps CSRF, single-use replay protection, and the OAuth redirect; you own only the rendered HTML.- If your adapter omits
sessionand only implements the legacyauthorize, the door hands you the request without aconsentcontext. You then own the entire flow: CSRF, replay protection, and the OAuth redirect. Prefersessionplus the prebuilt page unless you have a reason not to. - Import the
HostOAuthAdaptertype from@vendoai/vendo. The umbrella re-exports it so you never have to depend on@vendoai/mcpdirectly.
Well-known discovery paths
The door owns exactly these origin-root paths and returns 404 for every other path under/.well-known:
/.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(alias for the server card)
wellKnownVendoHandler matches the SAME allowlist the wire itself matches, so
the two can never drift. With mcp left unconfigured it still recognizes
those paths but has no door to serve them, so the request falls through to an
ordinary 404 rather than a 500.
VENDO_BASE_URL duties
- Discovery and audience binding. The door derives its OAuth discovery
documents (issuer, endpoint URLs, the protected-resource
resource) and its token audience binding fromVENDO_BASE_URLwhen set; forwarded headers such asX-Forwarded-Hostare never trusted. Without it the door falls back to the request URL — fine for local development, 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.
remoteAs token requirements
- Tokens must be JWTs with
iss,sub,aud,iat, andexpclaims, signed with one ofRS256,RS384,RS512,PS256,PS384,PS512,ES256,ES384, orES512. Symmetric algorithms andnoneare rejected.issmust equal the configuredissuer,audmust equal the configuredaudience, andexpmust be in the future. Neitherissuernoraudiencemay be blank — the door refuses to start if either is. - The door caches the JWKS in memory and refetches it when a bearer arrives
with an unfamiliar
kid, which covers ordinary key rotation.jwksUriis optional; the door discovers it from the issuer’s RFC 8414 metadata when omitted. - 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.
Federation JWT claims
The request JWT (HS256, signed with the shared secret, delivered asGET /api/vendo/mcp/federate?request=<compact JWS>) 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.
redirect_uri 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 federate endpoint renders no
HTML of its own: it either returns your authorize response verbatim or
issues a 302 back to the external authorization server.
Revocation semantics
Per RFC 7009, the endpoint always returns an empty200; 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.
In remoteAs mode the external authorization server owns revocation and the
door’s local /revoke path returns 404.
Publish to the MCP registry
Publish your deployed Vendo MCP door to the official registry atregistry.modelcontextprotocol.io. The registry is still in preview, so its
data may reset. If your listing disappears, authenticate and publish it again.
Before you start, deploy the MCP door at its final public URL and choose a
private-key path outside the repository. The examples below use
example.com, @acme/example-product, and
https://mcp.example.com/api/vendo/mcp.
1. Choose the namespace
The registry name combines the reverse-DNS form of your domain with the final segment ofpackage.json’s name:
description, version, and optional homepage from
package.json. Set those fields to the identity customers should see before
you generate the listing.
Required namespace and URL binding: A
com.example/* name may list
remote URLs only on example.com or one of its subdomains. For example,
https://mcp.example.com/api/vendo/mcp is valid, but a hosting-provider URL
on another domain is not.2. Prove domain ownership
Choose either DNS or HTTP. Eachvendo mcp verify-domain run creates a new
Ed25519 keypair, so run only the variant you intend to use. --key-out is
required. Keep that file secret, outside the repository, and backed up. Do not
regenerate it after publishing the proof unless you also replace the proof.
DNS TXT record
@ by DNS providers,
with the exact v=MCPv1; k=ed25519; p=... value printed by the command.
HTTPS challenge
Pass your framework’s public static directory with--write-well-known:
3. Generate server.json
Run this from the host root:
2025-12-11:
--force when you
intend to regenerate the file.
4. Validate the live deployment
Pass the deployed Vendo wire base to doctor. This is the URL before the final/mcp segment:
/status reports the MCP door open, both OAuth
metadata documents resolve, and the server card has a name and transports. If
server.json exists, it validates the pinned schema, the reversible namespace,
the namespace-to-remote-domain binding, and exact agreement between the listed
remote and the live door. When an HTTP challenge exists locally or at the live
origin, doctor also checks that it starts with v=MCPv1.
5. Authenticate and publish
The externalmcp-publisher CLI expects the private key’s hex contents, not a
file path. Use the login method that matches the proof you published.
For DNS:
./server.json from the host root:
mcp-publisher uses https://registry.modelcontextprotocol.io by default.
Publishing to the registry is self-serve.
6. Give customers an install path
Registry publication makes the server identity machine-readable. It does not configure a client automatically, so link customers to the appropriate client flow from your setup page:-
Claude.ai: Add the remote MCP URL as a custom connector. Customers see a
Customconnector, complete your OAuth flow, and can enable its tools in a conversation. See Claude custom connectors. -
ChatGPT: In developer mode, create a custom app with the remote MCP
endpoint and scan its tools. It appears with a
Devlabel while testing and in the workspace’s app list after an admin publishes it. See ChatGPT developer mode. -
Cursor: Publish an Add to Cursor deeplink. The user sees the MCP install
prompt, then connects through OAuth and gets the server’s tools. For this
example, the config in the link decodes to
{"url":"https://mcp.example.com/api/vendo/mcp"}:See Cursor MCP installation.
Directory submissions (follow-up)
This is a separate later step and is out of scope for registry publishing. The registry flow above is self-serve. The Claude Connectors Directory and the ChatGPT app directory are curated submission queues, and a registry listing does not add your product to either one.- Claude directory submission requires an organization account and Anthropic review.
- ChatGPT app directory submission requires an organization account, OpenAI business verification, and OpenAI review.