New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

convex-mcp-gateway

Package Overview
Dependencies
Maintainers
1
Versions
18
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

convex-mcp-gateway

Auth-aware Convex component that exposes selected Convex functions as MCP tools.

latest
Source
npmnpm
Version
2.1.1
Version published
Weekly downloads
14K
53.86%
Maintainers
1
Weekly downloads
 
Created
Source

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.

Convex Component tests npm license

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

Architecture overview

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    # in one terminal
pnpm convex:dev && pnpm convex:run mcp:registerDefaults && pnpm dev

Quickstart

pnpm add convex-mcp-gateway
// convex/convex.config.ts
import { defineApp } from "convex/server";
import mcpGateway from "convex-mcp-gateway/convex.config";

const app = defineApp();
app.use(mcpGateway);
export default app;
// convex/mcp.ts, declare your tools once
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()) },
  }),
];
// convex/http.ts, mount the gateway with your authorize callback
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();
// Pass `tools` and the registry syncs on each connect, no separate
// registration mutation to run.
const mcp = httpAction(async (ctx, req) =>
  gateway.handleMcpRequest(ctx, req, { authorize, tools }),
);
// Mount both /mcp/ and /mcp, some clients (claude.ai) strip the
// trailing slash from the configured URL before POSTing.
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
# No registration step: passing `tools` to handleMcpRequest syncs the
# registry on the initialize below.

# Talk to it (Streamable HTTP, initialize first, then send commands).
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"],           // what a browser may read
  allowedOrigins: ["https://app.example.com"], // what the gateway will serve
});

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, // MCP-unaware mutation
  args: {
    id: v.id("invoices"),
    // Gateway-only: filled with the continuation's idempotency key on a
    // hook-approved retry. Hidden from tools/list, unspoofable.
    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" },
          }),
        },
      );
    // Round one: ask before anything can run. Discriminate on `round`:
    // a state-only retry is a continuation, not a first call.
    if (round === undefined) return ask();
    const confirm = inputResponses?.confirm as { action?: string } | undefined;
    if (confirm === undefined) return ask(); // missing answer: ask again
    if (confirm.action !== "accept") {
      // Declined: finish WITHOUT running the mutation.
      return completeCall({
        content: [{ type: "text", text: "Invoice was not archived." }],
        isError: false,
      });
    }
    return null; // accepted: dispatch, with continuationKey injected
  },
});

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", // or "required"; `true` still reads as "optional"
});

gateway.handleMcpRequest(ctx, req, {
  authorize,
  tools,
  tasks: {}, // built-in durable executor (Convex scheduler, runs once)
});

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:

// convex/mcp.ts
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",
    // Optional icons, advertised verbatim in `resources/list`. Tools and
    // resource templates take the same field. The gateway never fetches an
    // icon; see docs/resources.md.
    icons: [{ src: "https://example.com/invoices.png", sizes: ["48x48"] }],
    // Read handlers receive the resolved caller identity; anonymous
    // resource requests are rejected before this runs.
    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 }),
        },
      ];
    },
  }),
];
// convex/http.ts: add `resources` to the same mount as `tools`
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                              # codegen + typecheck + tests + lint

# Iterate against a local Convex backend:
pnpm local:start                        # downloads the pinned binary, writes .env.local
                                        # runs on :3310 / :3311 (no Docker)

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

Keywords

convex

FAQs

Package last updated on 07 Oct 2026

Related posts