Before deploying the Vendure MCP server to production, configure the following:
Each option is documented in detail in
McpPluginOptions.
Tool-call logging and monitoring are covered separately in Logging & Observability.
oauth.tokenSecret is used to HMAC-hash MCP OAuth tokens before they are stored. Authorization request tokens,
authorization codes, access tokens, and refresh tokens are stored as hashes rather than plaintext.
Generate the secret once and keep it stable:
Store it in an environment variable or secret manager, not in source control. Rotating the secret invalidates every stored MCP OAuth token, so all agents must re-authorize.
In production, oauth.issuer must be a public HTTPS origin.
Keep the issuer stable once clients have been connected. Changing it invalidates all existing grants — see Rotating the secret or the issuer.
| Your deployment | What to set |
|---|---|
| Only administrators authorize MCP clients | oauth.issuer alone. storefrontConsentUrl has no default and is not needed. Admin authorization redirects to oauth.adminConsentPath on the issuer. |
| Customers authorize MCP clients | Also set oauth.storefrontConsentUrl. It must not be a loopback address, and it must use HTTPS: a consent page carries a customer's session, so it cannot be served over plain HTTP. |
DNS-rebinding protection prevents an attacker from using a browser to reach your MCP server through an
unexpected hostname or origin. Set
dnsRebinding to reject requests whose
Host or Origin header is not in an allow-list, before they reach the MCP handler:
Both lists hold bare hostnames, not URLs. The two lists are independent: supplying only one leaves the other
header unchecked, and an empty list disables that check. The guard is off when dnsRebinding is omitted. Enable
this for any internet-facing deployment.
Make sure your reverse proxy, CDN, or WAF forwards the browser's Origin header to Vendure instead of stripping
it.
Vendure allows consent requests without an Origin because server-rendered consent pages can legitimately have
no Origin header. If your proxy strips an Origin header that was sent by the browser, however, cross-site
approval requests could bypass the origin check.
Also enable Vendure's trustProxy. This allows
Vendure to determine the real client IP from the proxy headers. Without it, all clients may appear to come from
the proxy's IP, causing them to share the same rate-limit bucket.
Use HTTPS. MCP OAuth credentials and session-related requests must not be sent over an unencrypted connection. Terminate TLS at your reverse proxy or load balancer and ensure HTTPS is used for the public Vendure URL.
rateLimits applies per-minute request caps across
six scopes. The defaults are sensible. Override only what you need:
| Scope | Default | Notes |
|---|---|---|
perSession | 60 rpm | Per authenticated session, anonymous client IP, or in-process Vendure session |
perUser | 120 rpm | Per signed-in user, across every session and OAuth client acting for them. Authorizing again does not reset it |
perClient | 3000 rpm | Per registered OAuth client, across every user of that client |
anonymousIp | 60 rpm | Per client IP for anonymous /mcp/shop calls. Set false to disable |
oauthIp | 60 rpm | Per client IP across the OAuth endpoints and failed bearer-token authentications. Set false to disable |
perTool | place_order: 5, create_product: 10, refund_order: 5, cancel_order: 5 | Keyed by tool name. Entries you set are merged over these defaults. Set { rpm: 0 } to lift one |
The exact semantics of each scope are on
McpRateLimitOptions.
Choosing rate limits:
| Situation | Recommendation |
|---|---|
| A single shopper hits the limit | Raise perUser |
Your application approaches the 3,000 rpm perClient limit | Raise perClient. A first-party assistant registers once, so all its shoppers share this bucket |
| A tool is expensive or sensitive | Add a perTool limit. A tool with no entry (or an entry of 0) is unlimited |
| Your proxy already rate-limits by IP | Consider disabling anonymousIp |
The built-in rate limits are not a replacement for proxy/CDN-level rate limiting. A client can distribute requests across multiple IP addresses to bypass an IP-based limit.
A caller that exceeds a limit receives an HTTP 429 — see What a rate-limited caller sees.
shopAccess decides who may call POST /mcp/shop.
| Value | What it means |
|---|---|
anonymous (default) | Any caller with no token may use every shop tool. That includes the eight mutating cart and checkout tools (add_to_cart, update_cart_line, remove_from_cart, apply_coupon_code, remove_coupon_code, set_checkout_details, set_shipping_method, place_order). The only bounds are the per-IP limits, 60 requests per minute by default and 5 per minute for place_order |
authenticated | Every call needs a customer Bearer token. Token-less calls get 401 with the OAuth challenge. This value requires an oauth block, and the plugin refuses to start without one |
disabled | The endpoint answers 404. Customer authorization requests are refused |
The default is anonymous.
Set authenticated or disabled before going live unless you want anonymous agents to build carts and place
orders.
Each OAuth grant creates a dedicated Vendure session. Vendure stores session tokens in plaintext, unlike OAuth tokens, which are stored as hashes. This means a session token can be used as a bearer credential for the regular Admin and Shop GraphQL APIs, without the grant's expiry check being applied.
The session token is never sent to the client, but anyone who can access the database can potentially use it. Treat the session table as sensitive data.
If you need to invalidate a session immediately, revoke the corresponding grant. When each session is deleted is covered in What ends a session.
If you run multiple server instances, there is one additional consideration: the default session cache is in-memory.
When a grant is revoked, only the instance that handled the revocation clears its cached session. Other instances
may continue accepting the session for up to 5 minutes, based on sessionCacheTTL.
For multi-instance deployments, use a shared cache such as Redis or SQL to ensure session invalidation is propagated across all instances.
See Horizontal scaling for configuration details.
NODE_ENV is the only trigger, so a deployment that never sets it never gets the guard. The check also runs in
the server process only, not the worker, and only when an oauth block is configured.
A caller that omits the session-token header gets a new session on every request, so sessions cannot be used to
count anonymous traffic. For anonymous /mcp/shop requests, the perSession and perTool limits therefore
count by client IP. Callers sharing one network (an office, a campus) share those allowances.
The counters are stored in Vendure's CacheService. The default store is in-memory and holds at most 10,000
entries for the whole application. On a busy store, other cache activity pushes rate-limit counters out and the
limits quietly stop working. The SQL and Redis cache plugins do not have this problem. With them, all instances
share one set of counters instead of each counting separately. One caveat: counting is exact within a single
instance only. Two instances updating the same counter at the same moment can overwrite each other's update. A shared
counter can therefore still admit somewhat more than the configured number. Treat these limits as abuse
protection, not a precise quota. The session cache works the same way. See
Horizontal scaling.
What a caller sees when it hits a limit depends on how far the request got.
Most refusals happen before any tool runs. The anonymous-IP check runs before Vendure creates a session
for the request. Setup calls like initialize and tools/list are checked right after. These refusals
are HTTP 429 responses with a Retry-After header giving the seconds to wait. The body is a JSON-RPC
error with code -31029, and error.data.retryAfterSeconds carries the same wait time for
programmatic use.
error.data.scope names the limit that refused the request.
A request that already reached a tool is refused inside the tool call. The client gets a failed tool result rather than a protocol error, which is what the MCP spec asks for. It carries a text message and no structured retry hint.
Notifications expect no reply, so a rate-limited notification gets the same 429 and Retry-After
with id: null in the error body.
A client should handle a refusal rather than fail, and show the person chatting a "try again in a moment" message:
retryAfterSeconds defensively, and treat a missing value as "wait a few seconds".The MCP endpoints are ordinary Vendure routes, so they inherit the app-wide settings. The plugin adds no
bespoke CORS or body parser. CORS follows apiOptions.cors. The request body follows the framework's default
limit (100 KB). To raise the body limit for a tool that accepts large input, add a route-scoped middleware on
apiOptions.middleware for /mcp. Set beforeListen: true on that middleware entry. A body parser registered
without it does not take effect.
| What you see | Cause | Fix |
|---|---|---|
The server refuses to boot with NODE_ENV=production | oauth.issuer is not public HTTPS, oauth.storefrontConsentUrl is not public HTTPS, or oauth.allowLoopbackCimdDocuments is enabled | Use public HTTPS URLs and turn off allowLoopbackCimdDocuments |
| The DNS-rebinding guard rejects requests even though the host is in your allow-list | The entry is written as a URL or carries a port; the guard reduces the incoming header to its hostname before comparing, so such entries never match | Write bare hostnames in allowedHosts and allowedOrigins |
| Rate limits never trigger on a busy store | The default in-memory cache holds at most 10,000 entries for the whole application, and other cache activity evicts the counters | Use the SQL or Redis cache plugin |
| Anonymous callers get 429s at low traffic | Behind a proxy, req.ip is the proxy's address, so every anonymous caller shares one allowance | Enable Vendure's trustProxy |
synchronize: false deployments.McpPluginOptions — every option on this page, in full.Architecture reviews, custom plugin work, migrations, ongoing support. Get a hand from the team that builds Vendure.
Talk to the teamoauth.storefrontConsentUrl is subject to the same loopback check when configured: