Run the MCP Gateway#

Scope: advanced template only. The reference gateways in gateways/ are generated by pgmi init --template advanced and become application code you own. New to this subsystem? Start with the overview .

Quick Start#

1. Deploy the Advanced Template#

pgmi init --template advanced myproject
cd myproject
pgmi deploy . -d mydb --param env=dev   # connects via PGMI_CONNECTION_STRING or PGHOST/PGUSER/PGPASSWORD

2. Start the HTTP Gateway#

pgmi init --template advanced writes five reference gateways into gateways/: Python, TypeScript, Go, C# and Rust. Each serves MCP on /mcp, REST under /api, OIDC sign-in under /auth, and the OAuth resource server with its rate limits. They keep the same wire rules (gateways/PROTOCOL.md), read the same environment variables and pass the same end-to-end tests. Pick the one in your language; its README says how to run it. This page runs the Python one.

The gateway connects as a login in the api role (<db>_api), never as the database owner and never as the postgres superuser: a superuser bypasses row-level security, which keeps one organization’s rows from another’s (see gateways/README.md). DEV_LOGIN=1 adds POST /auth/dev-login, a sign-in without an identity provider that only an env=dev deploy accepts:

psql -d mydb -c "CREATE ROLE mydb_gateway LOGIN PASSWORD 'secret' IN ROLE mydb_api"
cd gateways/python
python3 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
export DATABASE_URL="postgresql://mydb_gateway:secret@localhost:5432/mydb"
DEV_LOGIN=1 python gateway.py

Every gateway listens on 127.0.0.1:8080 by default (HOST, PORT) and serves REST under /api.

3. Sign in on the web, then approve the client#

An MCP caller is the agent of a connection a person approved. It acts for that person, with its own identity and an agent role. The MCP path provisions nobody: a person comes to exist only when they sign in through the web. In a second shell, with curl and jq:

  1. Sign in. The answer (204) sets two cookies: __Host-session, the session, and __Host-csrf, whose value every later POST sends back as X-CSRF-Token.

    curl -s -c jar -X POST http://localhost:8080/auth/dev-login \
      -H "Content-Type: application/json" \
      -d '{"subject": "alice", "email": "alice@example.com", "displayName": "Alice"}'
    CSRF=$(awk '$6 == "__Host-csrf" {print $7}' jar)
  2. Create an organization for the agent to work in (201). This self-serve route exists on env=dev deploys only (api/dev/self-serve-organizations.sql); elsewhere a platform operator creates organizations.

    ORG=$(curl -s -b jar -X POST http://localhost:8080/api/organizations \
      -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \
      -d '{"name": "Acme", "slug": "acme"}' | jq -r .id)
  3. Approve the client, naming the organization and its agent role (an agent.* role from data/authorization.json, never broader than what you hold there):

    HANDLE=$(curl -s -b jar -X POST http://localhost:8080/api/connections \
      -H "Content-Type: application/json" -H "X-CSRF-Token: $CSRF" \
      -d "{\"clientName\": \"Desktop assistant\", \"organizationId\": \"$ORG\", \"roleId\": \"agent.contributor\", \"validDays\": 90}" \
      | jq -r .connection)

    The answer (201) carries connection, the handle, once. Store it in the client’s configuration; it is never shown again. GET /api/connections lists your connections (never their handles); POST /api/connections/{id}/pause, .../resume and DELETE /api/connections/{id} pause, resume and revoke one. Organization managers do the same under /api/agents, and the owner turns every agent off with POST /api/agents/disable.

  4. Connect the client with that handle in the X-Connection header or as Authorization: Bearer cn_.... Listing the tools as the agent shows echo_note, the template’s example tool, which an anonymous caller does not see:

    curl -s -X POST http://localhost:8080/mcp \
      -H "Content-Type: application/json" -H "X-Connection: $HANDLE" \
      -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/list" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' \
      | jq '[.result.tools[].name]'

    A handle that names no live connection (unknown, revoked, expired) is 401 with WWW-Authenticate.

Outside development, people sign in through your identity provider instead: set OIDC_ISSUER and its client settings, drop DEV_LOGIN, and the browser goes through /auth/login. The approval and the handle work the same way.

Or let the client sign in itself, with OAuth (MCP 2026-07-28 “Authorization”). Start the gateway as an OAuth resource server:

export MCP_RESOURCE="https://mcp.example.com/mcp"          # /mcp as clients reach it
export MCP_AUTHORIZATION_SERVERS="https://login.example.com"  # also a web sign-in issuer

The client calls /mcp, gets 401 with the address of the protected-resource metadata, signs in at your identity provider for that resource and calls again with Authorization: Bearer <token>. The gateway verifies the token (signature, issuer, expiry, the audience exactly MCP_RESOURCE) and hands the database only its issuer, subject and client id. The first call answers the JSON-RPC error -31001 with data.code membership.connection.pending and a pairing code such as K7M2QX, which the client shows. Signed in on the web, approve it:

curl -X POST http://localhost:8080/api/connections/approve \
  -H "Content-Type: application/json" -H "X-CSRF-Token: ..." --cookie "__Host-session=..." \
  -d '{"pairingCode": "K7M2QX", "organizationId": "<uuid>", "roleId": "agent.reader"}'

From then on the same client acts as the connection’s agent. The provider must put MCP_RESOURCE in the token’s aud (RFC 8707 resource indicators); one that cannot is not supported. With MCP_RESOURCE set, a call with neither a token nor a connection handle is 401: the public tools are not served anonymously. The wire rules are in gateways/PROTOCOL.md, “MCP clients with OAuth”.

A transport of your own instead of the reference gateway calls api.rest_invoke with the same routes; the session and CSRF rules are in the reference transport .

4. Test the Connection#

# MCP 2026-07-28: discover the server
curl -X POST http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: server/discover" \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'

# Call a tool as the connection's agent, for the person who approved it
# (register your own first — see MCP-HANDLERS.md)
curl -X POST http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" -H "Mcp-Method: tools/call" -H "Mcp-Name: your_tool" \
  -H "X-Connection: cn_..." \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"your_tool","arguments":{},
       "_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'

# A legacy client: initialize, then requests without _meta
curl -X POST http://localhost:8080/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2025-11-25"}}'

Both eras are served on the same endpoint; see the protocol page .

5. Configure Your AI Client#

Point an HTTP MCP client at the gateway. With OAuth configured it needs nothing else; otherwise give it the handle as a header. For example, a project .mcp.json for Claude Code:

{
  "mcpServers": {
    "my-database": {
      "type": "http",
      "url": "http://localhost:8080/mcp",
      "headers": { "X-Connection": "cn_..." }
    }
  }
}

Without the header, and with OAuth not configured, the client sees only access: public tools.

Endpoints#

EndpointMethodPurpose
/mcpPOSTMCP JSON-RPC endpoint, both eras. JSON answers only; the database checks the Mcp-Method, Mcp-Name and MCP-Protocol-Version headers against the body (api.mcp_request_policy)
/mcpany other405, Allow: POST — no SSE stream, no session
/healthGETHealth check (for load balancers)
/.well-known/oauth-protected-resource, .../mcpGETProtected-resource metadata, with MCP_RESOURCE set

Transport-level behavior (the header checks, the status of each answer, the retryable error, what is intentionally not implemented) is specified on the protocol page . The end-to-end tests are the mcp suite of internal/scaffold/testdata/gateways/e2e/ in the pgmi repository.

MCP Identity#

The gateway puts one credential into the MCP context:

RequestMaps to
Authorization: Bearer (with MCP_RESOURCE set), verified by the gatewaycontext.client: {issuer, subject, clientId}, never the token
Authorization: Bearer cn_..., or X-Connection when no other Authorization is sent (OAuth on or off; a cn_ handle is not an OAuth token and never reaches the verifier)context.connection

The database resolves either (internal.resolve_client_context, internal.resolve_connection_context) into three things:

  • the effective principal: the person who approved the connection. Row-level security and api.current_principal_id() see them.
  • the actual principal: the connection’s own agent principal, created at approval. api.current_actual_principal_id() returns it, and api.current_actor_kind() is agent.
  • the organization the person chose at approval.

An OAuth client’s person is the one its issuer and subject signed in as on the web. The audit log, change tracking, the access-denial log and the exchange log record both principals and the actor kind, so an agent’s change is never mistaken for its person’s. The rules:

  • Only an approved connection gives an identity. No other header or context key counts: not X-User-Id, an email, X-Tenant-Id, an organization id, or a login or organization session handle. The gateway does not forward them, and the database ignores them if a client puts them in the context.
  • The organization comes from the server. It is fixed when the person approves the connection; a client cannot name another one. To work in a second organization, approve a second connection there.
  • Nobody is provisioned on MCP. An unknown, revoked or expired handle is 401 (the database answers -31001 pgmi.connection_unknown, which the gateway sends as 401 with WWW-Authenticate); a token of someone who never signed in on the web answers -31001 membership.connection.not_admitted.
  • One connection per person and OAuth client. Its first call makes a pending connection with a pairing code valid for an hour; only that person approves it (POST /connections/approve). A paused connection answers -31001 membership.connection.paused, through a handle or a token, switched-off agents membership.connection.disabled; an expired one pairs again, and approving it revokes the old one.
  • A connection ends when it is revoked, when it expires (validDays, 1 to 365, default 90), when the person is disabled, or when their membership in that organization ends. Rejoining does not revive it.
  • No platform permissions. A connection carries no second factor, so it never holds a platform permission such as platform.operate.
  • Its permissions are its role’s, intersected with the person’s in that organization, recomputed on every request. The role is one of the agent.* roles in data/authorization.json (agent.reader, agent.contributor, agent.delegate); approval refuses one broader than the person (422 membership.connection.role_too_broad). A person who loses a permission takes it from their agents on the next call.
  • Only people change connections, signed in as themselves: the person under /connections, a manager (members.manage) under /agents. An agent never approves, extends or resumes an agent, and a manager’s pause is lifted only by a manager.

How handlers declare who may call them is covered in Author MCP handlers .

Transaction Policy#

A handler may declare a minimum transaction isolation level with the minTransactionIsolation metadata key (read committed | repeatable read | serializable; case- and separator-insensitive). readOnly is refused on MCP handlers: every MCP exchange is logged, and a READ ONLY transaction cannot write the log.

Every reference gateway resolves the policy before opening the dispatch transaction (api.mcp_request_policy(request, requested_level, <the three MCP headers>) on a short autocommit connection) and opens with the resolved characteristics: max(route floor, client-requested) isolation. Routes therefore just work for callers that send nothing; the X-PGMI-Transaction-Isolation header is escalation, not obligation — an unsupported value is rejected client-side with HTTP 400 (pgmi.transaction_isolation_unsupported).

The database gateway can only validate the characteristics — SET TRANSACTION is illegal inside functions — so its checks remain as the fail-closed invariant for clients that skip the lookup: a too-weak transaction is rejected with a -32600 error carrying data.code = 'pgmi.transaction_isolation_too_weak' and data.requiredIsolation. See lib/api/00-transaction-isolation.sql for the full contract.

Production Deployment#

The reference gateways are examples to read and port, not packages to deploy unchanged. Each marks the decisions a project makes with ADAPT. For production:

  1. TLS. Serve the gateway behind a reverse proxy or load balancer that terminates TLS. Connection handles, access tokens and session cookies are bearer secrets.
  2. The client address. Behind a proxy, the address a gateway sees is the proxy’s. Key the per-IP rate limit on the forwarded address your proxy sets (ADAPT in each resource server).
  3. Several processes. Rate limits and pending sign-ins live in each process: N processes allow N times the limit, and /auth/login and /auth/callback need sticky routing or a shared store. Set the same LOGIN_CSRF_SECRET on every process.
  4. Connections. Each process holds its own bounded pool (POOL_MAX_SIZE); the server’s max_connections is shared by all of them. Put PgBouncer (transaction mode) in front when that is tight.

The Python example is a threaded standard-library http.server: fine to read and to run small, but adapt it to your own ASGI or WSGI server before you scale it. The TypeScript, Go, C# and Rust examples are written on their languages’ usual production servers.

Server Configuration#

Configure server identity via session settings:

SET mcp.server_name = 'my-database-server';
SET mcp.server_version = '2.0.0';

Or set in postgresql.conf for persistence:

mcp.server_name = 'production-db'
mcp.server_version = '1.0.0'

Troubleshooting#

Common Issues#

-32601 “Method not found”

The JSON-RPC method itself is unknown (tools/calls), or the client sent a legacy-only method (initialize, ping) with protocol version 2026-07-28. An unregistered tool, prompt or resource answers -32602, below.

tools/list is empty, or tools/call answers -32602 for a tool you registered

The caller has no identity, so it sees only access: public tools and every other tool answers like an unknown name. Check, in order:

  • The client sends a connection handle or, with OAuth, a token (an X-User-Id, email or session header is ignored).
  • The person signed in through the web at least once; MCP never creates them.
  • The connection is approved, not paused, revoked or expired, the person is still a member of its organization and its agents are on: GET /connections shows its status.
  • If it is paused, resume it; otherwise sign in and approve the client again (POST /connections for a handle; for an OAuth client, POST /connections/approve with the new pairing code).

In SQL tests, pg_temp.test_mcp_context('issuer|subject', organization_id[, role]) approves a connection for the person’s membership and returns the context.

-31001

The client is known but cannot act yet; data.code says why. pending: approve data.pairingCode on the web. paused: resume the connection. disabled: the owner switched the organization’s agents off. not_admitted: the person behind the token never signed in on the web.

401 with WWW-Authenticate

The connection handle names no live connection: it is unknown, revoked or expired, its person is disabled or their membership ended. Approve the client again. Or /mcp runs as an OAuth resource server and the token is missing or refused: another audience than MCP_RESOURCE (the client must ask the provider for that resource), an issuer not in MCP_AUTHORIZATION_SERVERS, an expired token, or a token issued to the web sign-in’s own client. The gateway logs the reason, never the token.

tools/call answers -31003

The caller is known but lacks the tool’s permission in the connection’s organization, or the tool needs a platform permission, which a connection never holds. The agent holds only what both its agent.* role and its person hold: grant the person a role that carries the permission, or approve the client again with a broader agent role.

Handler not appearing in discovery

  • Check the handler was created: SELECT * FROM api.handler WHERE handler_type IN ('mcp_tool', 'mcp_resource', 'mcp_prompt');
  • Verify MCP route exists: SELECT * FROM api.mcp_route;

Debugging#

Enable debug logging:

SET client_min_messages = DEBUG;
SELECT (api.mcp_handle_request('{"jsonrpc":"2.0","id":"1","method":"tools/call","params":{"name":"my_tool","arguments":{}}}'::jsonb)).envelope;

Check exchange logs:

SELECT * FROM api.mcp_exchange ORDER BY enqueued_at DESC LIMIT 10;

See Also#