convex-mcp-gateway
Auth-aware MCP server for Convex. Expose selected
Convex functions as MCP tools, bring your own JWT issuer, declare
scopes/roles per tool, get an audit log and OAuth 2.1 protected-resource
discovery for free.
Built as a Convex Component.

Features
- Type-safe tool registration:
defineMcpQuery / defineMcpMutation /
defineMcpAction declare a Convex function as an MCP tool with end-to-
end-typed args and (optional) returns validators. Declare the list
once and pass it as tools to handleMcpRequest, the registry
auto-syncs on connect (no separate registration mutation), or call
gateway.register(...) for imperative/dynamic catalogs
- MCP dual-era Streamable HTTP: legacy 2025-03-26/2025-06-18/2025-11-25
sessions remain supported alongside stateless 2026-07-28 requests,
discovery, routing-header validation, and private cache hints. A client
requesting an unknown revision negotiates down to 2025-11-25 (what the
current official TypeScript SDK pins as latest); all three legacy
revisions share one transport wire contract (2025-11-25's SSE
resumability framing needs an event store the gateway doesn't have, so
it isn't emitted, a deliberate SHOULD deviation)
- Stateless multi-round trips: declarative stateless-era tools use a host-side
beforeCall hook to request input before any Convex function runs, then
receive HMAC-verified continuation state plus an idempotency key on retry;
resources/read gets the same treatment through beforeResourceRead, so
a read can ask a question before serving content. See
Multi-round-trip requests and
Resources
- MCP Tasks (poll-first): opt-in
io.modelcontextprotocol/tasks
support; task-augmented tools/call returns a handle, clients poll
tasks/get, owner-bound with TTL retention and lifecycle audit.
Built-in durable executor via the Convex scheduler, or bring
@convex-dev/workflow for retries and input_required rounds.
See Tasks
- MCP resources:
defineMcpResource / defineMcpResourceTemplate
serve resources/list, resources/read, and resources/templates/list
(RFC 6570). Central authorizeResource hook, opt-in resource audit,
runtime shape validation, opt-in resources/subscribe capability, and
opt-in anonymous access for public content.
See Resources & templates
- MCP prompts:
defineMcpPrompt serves prompts/list and
prompts/get, with argument checks, result validation, an
authorizePrompt hook, and opt-in anonymous access.
See Prompts
- One authorize callback: gates
tools/call and filters tools/list
with mode: "list" | "call"; uses your existing ctx.auth.getUserIdentity()
- OAuth 2.1 protected-resource discovery: RFC 9728 metadata,
RFC 6750
WWW-Authenticate headers, multi-tenant ready
- Optional OAuth bridge: RFC 8414 AS metadata wrap + RFC 7591 DCR
for browser MCP clients (claude.ai) against IdPs without DCR support, with
opt-in CIMD advertisement when the upstream authorization server supports it
requireAuth for all-private servers: opt-in 401-challenge on
anonymous requests so browser clients (claude.ai) begin the OAuth flow
instead of seeing an empty tools/list and never prompting a login
initializeInstructions: optional server-level guidance returned in
the MCP initialize result's instructions field, surfaced to the LLM
without bloating individual tool descriptions
- Audit log: one row per call with per-tool argument redaction
(verbatim / dropped / dotted-path redacted)
- Wire-error sanitization: generic message on the wire, full detail
in audit;
ConvexError passes through for deliberate user-facing
messages
convex-test helper: convex-mcp-gateway/test exports a one-line
register(t) that hooks the component into a convexTest instance so
your host tests can exercise the full /mcp/ round-trip in-process.
See Testing
What it does

You mount the gateway in a single httpAction and pass an authorize
JS callback that decides per call whether the request goes through.
The gateway handles the JSON-RPC envelope, legacy Streamable-HTTP session
lifecycle, stateless 2026-07-28 requests, the OAuth discovery doc, the
WWW-Authenticate headers, and the audit log. Your existing Convex auth
(Clerk, Auth0, Pocket-ID, custom JWT issuer) just works.
A standalone editorial-styled version of the diagram is at
docs/diagrams/architecture.html;
in-depth sequence and data-flow diagrams live in
docs/architecture.md.
Try it
The companion repo
convex-mcp-gateway-demo
is a notes app that registers five tools (public, identity-gated,
role-gated) and shows the audit log live. Three run modes including
a local-backend setup that needs no Convex account:
git clone https://github.com/tfohlmeister/convex-mcp-gateway-demo
cd convex-mcp-gateway-demo
pnpm install && pnpm local:start
pnpm convex:dev && pnpm convex:run mcp:registerDefaults && pnpm dev
Quickstart
pnpm add convex-mcp-gateway
import { defineApp } from "convex/server";
import mcpGateway from "convex-mcp-gateway/convex.config";
const app = defineApp();
app.use(mcpGateway);
export default app;
import { v } from "convex/values";
import { defineMcpQuery } from "convex-mcp-gateway";
import { api } from "./_generated/api.js";
export const tools = [
defineMcpQuery({
name: "invoices_summary",
description: "Public invoice counter.",
fn: api.invoices.summary,
args: {},
metadata: { public: true },
}),
defineMcpQuery({
name: "invoices_list",
description: "List invoices for the authenticated user.",
fn: api.invoices.list,
args: { status: v.optional(v.string()) },
}),
];
import { httpRouter } from "convex/server";
import { McpGateway, type McpAuthorizerHandler } from "convex-mcp-gateway";
import { components } from "./_generated/api.js";
import { httpAction } from "./_generated/server.js";
import { tools } from "./mcp.js";
const gateway = new McpGateway(components.mcpGateway);
const authorize: McpAuthorizerHandler = async (ctx, { toolMetadata }) => {
const meta = (toolMetadata ?? {}) as { public?: boolean };
if (meta.public) return { allowed: true };
const identity = await ctx.auth.getUserIdentity();
if (!identity) return { allowed: false, reason: "Unauthorized" };
return { allowed: true };
};
const http = httpRouter();
const mcp = httpAction(async (ctx, req) =>
gateway.handleMcpRequest(ctx, req, { authorize, tools }),
);
for (const path of ["/mcp/", "/mcp"]) {
http.route({ path, method: "POST", handler: mcp });
http.route({ path, method: "GET", handler: mcp });
http.route({ path, method: "DELETE", handler: mcp });
}
export default http;
npx convex dev --once
SESSION=$(curl -sSD - -X POST "$CONVEX_SITE_URL/mcp/" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
| awk '/^[Mm]cp-[Ss]ession-[Ii]d:/ {print $2}' | tr -d '\r')
curl -X POST "$CONVEX_SITE_URL/mcp/" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H "mcp-session-id: $SESSION" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
The anonymous client sees only invoices_summary (the public: true
tool); calling invoices_list without a Bearer returns HTTP 401 with a
WWW-Authenticate header pointing at your discovery endpoint. Any
spec-compliant MCP client (the official MCP Inspector, IDE plugins,
agent runtimes) handles the session and OAuth handshakes
automatically. See Getting Started for
the full walkthrough.
If your server has no public tools and you connect it to a browser
client like claude.ai, add requireAuth: true to the
handleMcpRequest options. Without it, anonymous initialize /
tools/list return 200 (empty) and the client never starts OAuth, it
only reacts to a 401. See
OAuth: all-private servers.
To give the model server-level guidance (how to use the server as a whole,
not any single tool), pass initializeInstructions to handleMcpRequest. It
populates the initialize result's instructions field and is omitted when
unset, so the default response shape is unchanged. It's a best-effort hint
(per the spec clients MAY use it; some ignore it), so keep it short and don't
rely on it for hard constraints.
MCP 2026-07-28 clients
The same endpoint also accepts stateless 2026-07-28 requests. Send
MCP-Protocol-Version: 2026-07-28, an exactly matching
params._meta["io.modelcontextprotocol/protocolVersion"], and an
Mcp-Method header matching the JSON-RPC method. The _meta object must carry
clientCapabilities; when supplied, clientInfo must include a name and
version. tools/call, resources/read,
and prompts/get require Mcp-Name to match the requested name or URI. Start
with server/discover; stateless requests never create or return
Mcp-Session-Id. Discovery, list, and read results are explicitly
non-shareable with ttlMs: 0 and cacheScope: "private".
When a tool input schema marks a string, safe integer, or boolean property
with x-mcp-header, the matching Mcp-Param-<name> header must carry the
same value; integers are compared numerically, so 42.0 matches 42.
Schemas that place this extension anywhere but a plain chain of properties
keys are rejected at registration time, with the tool name in the error.
Base64-encoded routing headers must decode to at most 8 KiB of valid UTF-8
without control characters.
Tool schemas may use local #/$defs/<name> references: registration
resolves them within hard budgets (traversal depth 64, one unit per
nesting level of the schema tree; 64 $ref expansions; and 64 KiB of
UTF-8 in the resolved result; cycles rejected by name) and
stores an inlined, self-contained internal copy so the runtime
Mcp-Param-* walk sees exactly what registration validated. An
x-mcp-header annotation authored behind such a reference therefore
works, as long as it lands on a plain properties chain after inlining.
tools/list still serves the authored schema, including its $defs and
$ref keywords.
Resolution is reachability-driven: definition containers ($defs,
definitions) are pulled from only when referenced and dropped from the
output, so an unused authoring artefact in a generated bundle (a
self-referential type, a remote $ref, or simply many definitions)
cannot fail a schema whose resolved form is fine, and the expansion
budget counts only the resolved internal copy. Everything else stays
deliberately out: remote (https:) references are never fetched, $ref
with adjacent keywords is rejected (2020-12 gives it allOf semantics,
and composition is where static reachability ends), and annotations under
anyOf/oneOf/allOf remain rejected because a branched value is not
guaranteed present at its path.
$ref counts as a reference only where a schema is expected, so
reference-shaped values in data positions are not silently rewritten;
once resolution runs, though, any reference that survives anywhere in the
output is rejected by path rather than shipped dangling (its definitions
are gone). Schemas with no schema-position reference are returned
verbatim. Note that Convex rejects $-prefixed field names at the
storage boundary, so a schema declaring a property literally named
$ref is unstorable regardless of how this resolver treats it. Budget or
resolution failures fail the registration/sync loudly with the tool
named, never as a per-request error.
Deep resolved schemas are JSON-encoded before crossing the Convex component
boundary and decoded host-side, avoiding Convex's document nesting limit.
This does not change the schemas advertised by tools/list, header
validation, or what gateway.listTools() returns. Existing object-based
registry rows remain readable. Versions before this encoding do not decode
it: after a downgrade, re-register tools with deep schemas that were
registered imperatively (register/registerTool), or their
Mcp-Param-* bindings go unchecked. See
the schema storage details.
Origin validation
MCP requires servers to validate the Origin header to prevent DNS-rebinding
attacks. Pass allowedOrigins to turn that gate on:
gateway.handleMcpRequest(ctx, req, {
authorize,
cors: ["https://app.example.com"],
allowedOrigins: ["https://app.example.com"],
});
A request whose Origin is present but not allowed gets HTTP 403 before
identity resolution, authorization, auditing, or dispatch. This applies to
both protocol eras. Requests without an Origin header (every CLI and
server-to-server client) are unaffected.
allowedOrigins accepts a string, a string array, or an
(origin: string) => boolean matcher. It is deliberately separate from
cors: CORS decides what a browser may read, allowedOrigins decides what
the gateway is willing to serve. Deriving one from the other means the
permissive cors: true silently disables the gate.
Omitting allowedOrigins disables origin validation. That is the default
because a Convex deployment lives at a fixed public URL rather than on
localhost, which is the scenario DNS rebinding targets. Set it for any
deployment that serves browser clients.
Multi-round-trip requests
Modern declarative tools can request elicitation, sampling, or roots input
without a protocol session. Enable mrtr with stable private key material and
give the tool a beforeCall hook: it is the gateway-side state machine, run
before the underlying Convex function on the first call AND on every verified
continuation, so accept/decline/ask-again decisions never leak into your
business logic:
import { completeCall, inputRequired } from "convex-mcp-gateway";
defineMcpMutation({
name: "invoices_archiveAfterConfirmation",
fn: api.invoices.archiveAfterConfirmation,
args: {
id: v.id("invoices"),
continuationKey: v.optional(v.string()),
},
mrtrArgs: { idempotencyKey: "continuationKey" },
beforeCall: async (_ctx, { args, inputResponses, round }) => {
const ask = () =>
inputRequired(
{
confirm: {
method: "elicitation/create",
params: { mode: "form", message: "Archive this invoice?" },
},
},
{ invoiceId: args.id },
{
onUnsupported: completeCall({
content: [{
type: "text",
text: "Confirmation must be collected by the calling agent.",
}],
structuredContent: { outcome: "interaction_required" },
}),
},
);
if (round === undefined) return ask();
const confirm = inputResponses?.confirm as { action?: string } | undefined;
if (confirm === undefined) return ask();
if (confirm.action !== "accept") {
return completeCall({
content: [{ type: "text", text: "Invoice was not archived." }],
isError: false,
});
}
return null;
},
});
gateway.handleMcpRequest(ctx, request, {
authorize,
mrtr: { secret: process.env.MCP_MRTR_SECRET! },
});
The hook declares the interaction it wants and an optional ordinary result for
clients that cannot satisfy it. The gateway applies the MCP capability rules,
including elicitation mode semantics, before choosing between input_required
and that fallback. Without onUnsupported, the existing fail-closed protocol
error is returned. A selected fallback completes the call before dispatch, so
the Convex function is never run.
The gateway HMAC-signs the opaque requestState with a five-minute default
TTL, binding it to the tool name, original public arguments, authenticated
caller subject, and a per-round continuation id. Each continuation is
additionally redeemed once server-side: re-sending the same responses is an
idempotent replay, but that same continuation replayed with a different answer
(decline to accept) is rejected. On top of that a chain resolves exactly
once: the gateway claims the chain before it dispatches or finishes the call,
recording which continuation resolved it and with which answer. Re-sending that
same continuation and answer repeats the outcome, so a lost response still
retries cleanly: an accepted call dispatches again under the same idempotency
key, and a completed one re-runs the hook rather than erroring. Nothing is
replayed from storage, so keep hooks deterministic in decision class over the
same state and responses: a hook that completes on the repeat where it
dispatched the first time is refused. Every other continuation of the chain is refused, including one
re-sent byte-identically, and asking again is refused outright, so a branch
forked by replaying an earlier round is never handed out and a settled decline
cannot be turned into a dispatch. Chains may run multiple rounds (asking again for missing input, per
the spec's error-handling guidance) up to a hard ceiling.
The hook receives the client's untrusted inputResponses and decoded state;
neither is ever injected into the Convex function. Only the chain's stable
idempotency key is, via mrtrArgs, and it is audited like any other argument.
Persist it around the tool's side effect for durable replay protection. The
state is signed, not encrypted, so never put credentials or other secrets in
it. Wire gateway.pruneMrtrRedemptions into a cron to drop expired
redemption rows and resolved-chain claims.
Two catalog rules fail the sync loudly: a mutation or action with a hook must
declare mrtrArgs, and one Convex function may not be registered both with and
without a gate (an ungated alias would let the model skip the confirmation).
Queries are exempt from both. Two failures follow from them: re-registering a
name may not drop its gate (unregisterTool first if that is intended), and a
tool whose hook is configured while its registration lost mrtrArgs fails the
call closed rather than dispatching without the key.
Safety properties: the hook runs only for tools passed to
handleMcpRequest, and a registry row registered as MRTR-gated (a hook, or
mrtrArgs) fails closed when served by a handler without the matching
hook, so imperative registrations and stale catalogs can never dispatch
unconfirmed. MRTR tools require an authenticated caller on every transport
(anonymous calls get the real 401 + WWW-Authenticate challenge and an audit
row). The gateway returns -32021 (listing only the missing entries, per
mode for elicitation) rather than sending input requests for a capability
absent from that request's clientCapabilities, and supports state-only
retries without inputResponses: such a retry decides nothing, so it is not
redeemed and never burns the continuation for the answer that follows.
Required input is never silently bypassed:
the hook also runs for legacy 2025-era requests, and when it demands input
there it uses onUnsupported when supplied; otherwise (or when mrtr is not
configured) the call fails closed instead of dispatching. Note that rounds
that end gateway-side (an input_required
response, a declined confirmation, a -32021 rejection) write no audit rows;
the audit log records authorization denials and dispatches. See the runnable
example and its durable idempotency record in
invoices.ts. subscriptions/listen, Tasks,
MCP Apps, and Enterprise Managed Authorization are not advertised until a
host provides their required durable state or long-lived delivery
infrastructure.
Tasks (deferred tool calls)
Modern clients can run a tool as an MCP task
(io.modelcontextprotocol/tasks): tools/call with a task request
returns a handle immediately, and the client polls tasks/get for the
outcome; tasks/update answers input_required rounds and
tasks/cancel cancels.
Doubly opt-in: register the tool with taskSupport: "optional" (or
"required") AND configure
the tasks option; the capability is not advertised otherwise.
defineMcpMutation({
name: "invoices_recount",
fn: api.invoices.recount,
args: {},
taskSupport: "optional",
});
gateway.handleMcpRequest(ctx, req, {
authorize,
tools,
tasks: {},
});
Tasks are owner-bound to the authenticated caller (foreign, unknown, and
expired ids answer identically), retained 24 hours by default (clamped to
[1 minute, 7 days], prunable via gateway.pruneTasks), audited per
lifecycle transition, and rejected loudly on the legacy protocol instead
of silently running inline. Mount the gateway more than once with
different authorize policies and you also want tasks.scope, so a task
started on one mount cannot be polled through another. Hosts that need retries, delays, or
input_required rounds plug in @convex-dev/workflow through
tasks.execute and finalize with gateway.completeTask / failTask /
requireTaskInput. See Tasks for the full contract,
execution models, idempotency rules, and size limits.
Resources
Alongside tools, the gateway serves MCP resources (read-only content).
Declare a concrete resource with defineMcpResource and pass it to the
same handleMcpRequest call as your tools:
import { defineMcpResource } from "convex-mcp-gateway";
import { api } from "./_generated/api.js";
export const resources = [
defineMcpResource({
uri: "invoices://summary",
name: "invoice-summary",
title: "Invoice summary",
mimeType: "application/json",
icons: [{ src: "https://example.com/invoices.png", sizes: ["48x48"] }],
read: async (ctx, { uri, identity }) => {
const summary = await ctx.runQuery(api.invoices.summary, {});
return [
{
uri,
mimeType: "application/json",
text: JSON.stringify({ ...summary, caller: identity.subject }),
},
];
},
}),
];
const mcp = httpAction(async (ctx, req) =>
gateway.handleMcpRequest(ctx, req, { authorize, tools, resources }),
);
Supported methods: resources/list, resources/read,
resources/templates/list (RFC 6570 templates via
defineMcpResourceTemplate), and opt-in resources/subscribe /
resources/unsubscribe. A central authorizeResource hook gates
list/read, resource operations are auditable, and reads run only for
authenticated callers unless the mount opts into anonymousResources,
which hands that decision to the same hook. A complete, runnable example
(concrete resource +
template + per-resource auth + audit + subscription) is wired into
example/convex; see
Resources & templates for the full guide.
Documentation
- Getting Started: install, register,
call, in five minutes
- Architecture: component model, data
flow, sequence diagrams
- Authorization: authorizer contract,
mode: "list" vs "call", scope/role recipes
- Resources & templates: concrete resources
vs RFC 6570 templates,
resources/templates/list, read resolution
- Prompts:
defineMcpPrompt, argument and
result validation, authorizePrompt, public prompts
- OAuth 2.1 setup: RFC 9728 discovery, host-side
mount, multi-tenant,
requireAuth for all-private servers
- OAuth bridge mode: opt-in DCR + AS
metadata wrap + userinfo token validation, for browser MCP clients
(claude.ai) against IdPs that don't support Dynamic Client
Registration (Pocket-ID, etc.)
- Client interoperability: Claude and
Codex OAuth setup, issuer mismatches, and HTTP versus WebSocket support
- Audit log: tool / resource / task row
shapes, reading, filtering, redacting, pruning
- Recipe: Better Auth on Convex:
opaque-token
resolveIdentity, split-domain discovery, and browser
login continuation for @convex-dev/better-auth as the IdP
- Testing: convex-test patterns, identity
injection, swappable authorizers
- Conformance: running the official MCP
suite against the example, what it measured per spec revision, and
what each remaining failure is
Design choices, briefly
- One authorize callback, deny-by-default. The component has no
notion of scopes, roles, JWT claims, or tenants. You pass a single JS
callback to
handleMcpRequest that decides per call (it runs host-side
where ctx.auth works). Until you pass it, nothing reaches your tools.
- Scope-aware
tools/list. The catalog visible to a caller equals
the set of tools they could actually invoke; the same authorizer
filters both with a mode: "list" discriminator.
- Audit never alters dispatch. Every audit write is best-effort;
failures log and swallow. A successful tool mutation always returns
ok: true.
- Identity propagation is free. Convex validates inbound Bearer
tokens against your
auth.config.ts before any function runs; the
same identity flows into the authorizer and the tool handler.
- OAuth discovery is opt-in. Configure an authorization server, and
401s carry
WWW-Authenticate headers per RFC 6750 and the
RFC 9728 path-prefix metadata URL.
Local development
pnpm install
pnpm check
pnpm local:start
The local backend uses upstream test fixture credentials checked into
get-convex/convex-backend, public, deterministic, safe to commit.
See docs/testing.md for convex-test patterns and
CONTRIBUTING.md for the contribution workflow.
Roadmap
By design the gateway is the resource-server half of MCP's OAuth
profile only; it will not ship a bundled authorization server (that
duplicates what every IdP already does and would put two
security-critical surfaces in one component). Bring your own OAuth 2.1
/ OIDC issuer. On the radar beyond that:
- Capability tokens for agent-spawning workflows (small JWT helper,
not a full AS:
gateway.signCapabilityToken({ tools, runId, ttl })
plus authorizer-side validation)
mcpPrompt MCP primitive (the mcpResource primitive has shipped, see
Resources & templates)
- Pre-baked multi-tenant patterns (per-tenant URL, RFC 8707 audience
binding)
- RFC 8693 token exchange for cross-domain identity
See the repo's GitHub issues for finer-grained tracking.
Security
If you discover a vulnerability, please follow SECURITY.md
(do not file a public issue).
License
Apache-2.0