Run the MCP Gateway#
Scope: advanced template only. The reference gateways in
gateways/are generated bypgmi init --template advancedand 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/PGPASSWORD2. 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.pyEvery 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:
Sign in. The answer (204) sets two cookies:
__Host-session, the session, and__Host-csrf, whose value every later POST sends back asX-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)Create an organization for the agent to work in (201). This self-serve route exists on
env=devdeploys 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)Approve the client, naming the organization and its agent role (an
agent.*role fromdata/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/connectionslists your connections (never their handles);POST /api/connections/{id}/pause,.../resumeandDELETE /api/connections/{id}pause, resume and revoke one. Organization managers do the same under/api/agents, and the owner turns every agent off withPOST /api/agents/disable.Connect the client with that handle in the
X-Connectionheader or asAuthorization: Bearer cn_.... Listing the tools as the agent showsecho_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 issuerThe 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#
| Endpoint | Method | Purpose |
|---|---|---|
/mcp | POST | MCP 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) |
/mcp | any other | 405, Allow: POST — no SSE stream, no session |
/health | GET | Health check (for load balancers) |
/.well-known/oauth-protected-resource, .../mcp | GET | Protected-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:
| Request | Maps to |
|---|---|
Authorization: Bearer (with MCP_RESOURCE set), verified by the gateway | context.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, andapi.current_actor_kind()isagent. - 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
-31001pgmi.connection_unknown, which the gateway sends as 401 withWWW-Authenticate); a token of someone who never signed in on the web answers-31001membership.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-31001membership.connection.paused, through a handle or a token, switched-off agentsmembership.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 indata/authorization.json(agent.reader,agent.contributor,agent.delegate); approval refuses one broader than the person (422membership.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:
- TLS. Serve the gateway behind a reverse proxy or load balancer that terminates TLS. Connection handles, access tokens and session cookies are bearer secrets.
- 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 (
ADAPTin each resource server). - Several processes. Rate limits and pending sign-ins live in each
process: N processes allow N times the limit, and
/auth/loginand/auth/callbackneed sticky routing or a shared store. Set the sameLOGIN_CSRF_SECRETon every process. - Connections. Each process holds its own bounded pool
(
POOL_MAX_SIZE); the server’smax_connectionsis 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 /connectionsshows itsstatus. - If it is paused, resume it; otherwise sign in and approve the client again
(
POST /connectionsfor a handle; for an OAuth client,POST /connections/approvewith 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#
- Author MCP handlers — write the tools the gateway serves
- MCP SQL API reference — the dispatcher and builders
- Security — secrets handling and security patterns