Model Context Protocol (MCP) Integration#
pgmi’s advanced template includes a complete MCP server implementation, enabling AI assistants (Claude Desktop, VS Code Copilot, etc.) to interact with your PostgreSQL database through tools, resources, and prompts.
Overview#
The Model Context Protocol (MCP) is an open standard that allows AI applications to connect to external systems. pgmi implements MCP entirely in PostgreSQL, with a thin HTTP gateway for transport.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────────────┐
│ AI Client │────▶│ HTTP Gateway │────▶│ PostgreSQL │
│ (Claude, etc.) │ │ (Python/Go) │ │ │
│ │◀────│ │◀────│ api.mcp_handle_request() │
└─────────────────┘ └─────────────────┘ │ ├── tools (execute actions) │
│ ├── resources (read data) │
│ └── prompts (message templates)│
└─────────────────────────────────┘Quick Start#
1. Deploy the Advanced Template#
pgmi init --template advanced myproject
cd myproject
pgmi deploy --connection "postgresql://user:pass@localhost:5432/mydb"2. Start the HTTP Gateway#
The pgmi init --template advanced command generates a tools/ directory with the Python gateway:
cd tools
pip install -r requirements.txt
export DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"
python mcp-gateway.py3. Test the Connection#
# Initialize handshake
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2024-11-05"}}'
# List available tools
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"2","method":"tools/list"}'
# Call a tool
curl -X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"database_info","arguments":{}}}'4. Configure Your AI Client#
For Claude Desktop, add to ~/.config/claude/claude_desktop_config.json:
{
"mcpServers": {
"my-database": {
"url": "http://localhost:8080/mcp"
}
}
}Architecture#
Protocol Compliance#
pgmi implements MCP using JSON-RPC 2.0:
- Supported protocol versions:
2024-11-05,2025-03-26,2025-06-18,2025-11-25(an unknown-newer proposal negotiates down to the server’s best supported version) - Transport: HTTP POST to
/mcpendpoint - Authentication: Context parameter (not HTTP headers)
Core Functions#
| Function | Purpose |
|---|---|
api.mcp_handle_request(request, context) | Unified dispatcher — returns NULL envelope for JSON-RPC notifications (no response sent) |
api.mcp_initialize(params, request_id jsonb) | Handshake handler |
api.mcp_ping(request_id jsonb) | Keepalive response |
api.mcp_call_tool(name, args, context, id jsonb) | Invoke a tool; handler exceptions → result.isError=true (not JSON-RPC error) |
api.mcp_read_resource(uri, context, id jsonb) | Read a resource |
api.mcp_get_prompt(name, args, context, id jsonb) | Expand a prompt |
api.mcp_list_tools(p_tags text[] DEFAULT NULL) | Tool discovery; hides requires_auth tools when auth.user_id is unset; surfaces tags as _meta.tags; p_tags filter matches by overlap (NULL or empty array = no filter) |
api.mcp_list_resources() | Concrete-resource discovery (resources/list); emits uri objects only; same auth-hide semantics |
api.mcp_list_resource_templates() | Template discovery (resources/templates/list); emits uriTemplate objects (RFC 6570); same auth-hide semantics |
api.mcp_list_prompts() | Prompt discovery; same auth-hide semantics |
request_id is jsonb across the MCP API so JSON-RPC 2.0 id types (string, integer, null) round-trip verbatim. Passing a raw text literal ('req-1') fails domain parsing — use '"req-1"'::jsonb or '42'::jsonb.
No pagination: mcp_list_* return the full list in one call. Clients MUST NOT rely on cursor behaviour. Keyset pagination is planned post-v1.
No listChanged notifications yet: pgmi does not emit notifications/{tools,resources,prompts}/list_changed when the registry mutates. mcp_server_capabilities stays silent on listChanged in lockstep. See lib/api/09-gateways.sql for the integration path (LISTEN/NOTIFY sketch).
Method Routing#
The dispatcher (api.mcp_handle_request) routes requests by method. Any method in the notifications/* family (or any request missing an id member) returns a NULL envelope — callers MUST NOT write anything to the wire for notifications per JSON-RPC 2.0.
| Method | Handler |
|---|---|
initialize | api.mcp_initialize(params, id) |
notifications/initialized / any notifications/* | returns NULL envelope (no response) |
ping | api.mcp_ping(id) |
tools/list | api.mcp_list_tools() |
tools/call | api.mcp_call_tool(name, args, context, id) |
resources/list | api.mcp_list_resources() |
resources/templates/list | api.mcp_list_resource_templates() |
resources/read | api.mcp_read_resource(uri, context, id) |
prompts/list | api.mcp_list_prompts() |
prompts/get | api.mcp_get_prompt(name, args, context, id) |
Schema Contracts and Tags#
Handler registration metadata accepts the following optional fields:
inputSchema— JSON Schema (api.json_schemadomain) describing arguments. Rejected if empty{}or malformed.outputSchema— JSON Schema describing results. For MCP tools, surfaces intools/listand enables spec-compliantstructuredContentemission (see below). For REST/RPC, triggers$schemamerge whenresponseHeaders.x-include-schema='true'— REST merges into body (wrapping arrays/scalars as{data, $schema}), RPC merges intoresult.$schema(never top-level, to keep JSON-RPC 2.0 compliant).responseHeaders(jsonb) — arbitrary headers merged into the wire response (keys lowercased). The keyx-include-schemais a directive, not a header; it controls$schemamerge and is stripped before the response reaches the client.tags(MCP only,text[]) — surfaces intools/listunder_meta.tags(MCP spec extension slot).mcp_list_tools(p_tags)filters by overlap; NULL or empty array = no filter.
-- Tool with outputSchema and tags, emitting structuredContent
SELECT api.create_or_replace_mcp_handler(
jsonb_build_object(
'id', 'a0000001-0001-4000-8000-000000000001'::uuid,
'type', 'tool',
'name', 'weather_at',
'description', 'Get weather for a location',
'inputSchema', jsonb_build_object(
'type', 'object',
'properties', jsonb_build_object('location', jsonb_build_object('type', 'string')),
'required', jsonb_build_array('location')
),
'outputSchema', jsonb_build_object(
'type', 'object',
'properties', jsonb_build_object('temp_c', jsonb_build_object('type', 'number'))
),
'tags', jsonb_build_array('weather', 'read-only')
),
$body$
DECLARE v_structured jsonb;
BEGIN
v_structured := jsonb_build_object('temp_c', 21.5);
RETURN api.mcp_tool_result(
jsonb_build_array(api.mcp_text(v_structured::text)),
(request).request_id,
false,
v_structured
);
END;
$body$
);api.mcp_tool_result(content, request_id, is_error, structured_content) — when
a structured_content jsonb is passed, MCP clients that support outputSchema can
validate the structured payload directly instead of re-parsing the text content.
Handler names are validated against ^[a-zA-Z][a-zA-Z0-9_.-]{0,48}$ at
registration. Names over 49 chars are rejected to prevent PostgreSQL 63-byte
identifier truncation collisions on the generated function
(mcp_tool_<name>, rpc_<name>, etc.).
Creating Handlers#
MCP Types#
| Type | Purpose | Key Metadata |
|---|---|---|
| Tool | Executable actions | name, description, inputSchema |
| Resource | Data access via URI | name, uriTemplate, mimeType |
| Prompt | Message templates | name, description, arguments |
Request and Response Types#
-- Request type (passed to your handler)
CREATE TYPE api.mcp_request AS (
arguments jsonb, -- Tool/prompt arguments
uri text, -- Resource URI
context jsonb, -- Auth: {"user_id": "...", "tenant_id": "..."}
request_id text -- Must echo in response
);
-- Response type (JSON-RPC 2.0 envelope)
CREATE TYPE api.mcp_response AS (
envelope jsonb -- {jsonrpc, id, result} or {jsonrpc, id, error}
);Tool Example#
SELECT api.create_or_replace_mcp_handler(
jsonb_build_object(
'id', 'e3000001-0001-4000-8000-000000000001',
'type', 'tool',
'name', 'database_info',
'description', 'Get database version and connection info',
'inputSchema', jsonb_build_object(
'type', 'object',
'properties', jsonb_build_object(),
'required', jsonb_build_array()
)
),
$body$
BEGIN
RETURN api.mcp_tool_result(
jsonb_build_array(api.mcp_text(format(
'Database: %s, Version: %s, User: %s',
current_database(),
version(),
current_user
))),
(request).request_id
);
END;
$body$
);Resource Example#
SELECT api.create_or_replace_mcp_handler(
jsonb_build_object(
'id', 'e3000001-0002-4000-8000-000000000001',
'type', 'resource',
'name', 'table_schema',
'description', 'Get table schema information',
'uriTemplate', 'postgres:///{schema}/{table}',
'mimeType', 'application/json'
),
$body$
DECLARE
v_uri_parts text[];
v_schema text;
v_table text;
v_columns jsonb;
BEGIN
-- Parse URI: postgres:///public/users -> ['public', 'users']
v_uri_parts := string_to_array(
regexp_replace((request).uri, '^postgres:///', ''), '/'
);
v_schema := v_uri_parts[1];
v_table := v_uri_parts[2];
-- Query column metadata
SELECT jsonb_agg(jsonb_build_object(
'column_name', column_name,
'data_type', data_type,
'is_nullable', is_nullable
))
INTO v_columns
FROM information_schema.columns
WHERE table_schema = COALESCE(v_schema, 'public')
AND table_name = v_table;
RETURN api.mcp_resource_result(
jsonb_build_array(jsonb_build_object(
'uri', (request).uri,
'mimeType', 'application/json',
'text', COALESCE(v_columns, '[]'::jsonb)::text
)),
(request).request_id
);
END;
$body$
);Prompt Example#
SELECT api.create_or_replace_mcp_handler(
jsonb_build_object(
'id', 'e3000001-0003-4000-8000-000000000001',
'type', 'prompt',
'name', 'sql_assistant',
'description', 'Generate a SQL query assistant prompt',
'arguments', jsonb_build_array(
jsonb_build_object('name', 'task', 'description', 'Task description', 'required', true),
jsonb_build_object('name', 'tables', 'description', 'Relevant tables', 'required', false)
)
),
$body$
DECLARE
v_task text;
v_tables text;
BEGIN
v_task := (request).arguments->>'task';
v_tables := COALESCE((request).arguments->>'tables', 'any relevant tables');
RETURN api.mcp_prompt_result(
jsonb_build_array(
jsonb_build_object(
'role', 'user',
'content', jsonb_build_object(
'type', 'text',
'text', format(
'You are a PostgreSQL expert. Help me write a SQL query for: %s. Consider using: %s.',
v_task, v_tables
)
)
)
),
(request).request_id
);
END;
$body$
);Response Builders#
Success Responses#
-- Tool result (content array)
api.mcp_tool_result(content jsonb, request_id text) RETURNS api.mcp_response
-- Resource result (contents array)
api.mcp_resource_result(contents jsonb, request_id text) RETURNS api.mcp_response
-- Prompt result (messages array)
api.mcp_prompt_result(messages jsonb, request_id text) RETURNS api.mcp_response
-- Generic success
api.mcp_success(result jsonb, request_id text) RETURNS api.mcp_responseError Responses#
-- Generic JSON-RPC error
api.mcp_error(code integer, message text, request_id text) RETURNS api.mcp_response
-- Convenience wrappers (use -32603 Internal Error)
api.mcp_tool_error(message text, request_id text) RETURNS api.mcp_response
api.mcp_resource_error(message text, request_id text) RETURNS api.mcp_response
api.mcp_prompt_error(message text, request_id text) RETURNS api.mcp_responseContent Helpers#
-- Text content block
api.mcp_text('Hello, world!')
-- Returns: {"type": "text", "text": "Hello, world!"}Authentication#
MCP uses the context parameter for authentication, not HTTP headers:
-- Gateway extracts from HTTP headers and passes as context:
SELECT api.mcp_handle_request(
'{"jsonrpc":"2.0","id":"1","method":"tools/call",...}'::jsonb,
'{"user_id": "auth0|12345", "tenant_id": "org_abc"}'::jsonb
);
-- Inside handlers, access via session variables:
DECLARE
v_user_id text := current_setting('auth.user_id', true);
v_tenant_id text := current_setting('auth.tenant_id', true);
BEGIN
-- Use for RLS, audit logging, etc.
END;Requiring Authentication#
SELECT api.create_or_replace_mcp_handler(
jsonb_build_object(
...
'requiresAuth', true -- Default: true
),
$body$...
);If requiresAuth is true and user_id is missing from context, the gateway returns:
{"jsonrpc": "2.0", "id": "...", "error": {"code": -32001, "message": "Authentication required"}}Error Handling#
JSON-RPC Error Codes#
| Code | Meaning |
|---|---|
| -32700 | Parse error (invalid JSON) |
| -32600 | Invalid Request (missing jsonrpc, method) |
| -32601 | Method not found |
| -32602 | Invalid params |
| -32603 | Internal error |
| -32001 | Authentication required (custom) |
Example Error Response#
{
"jsonrpc": "2.0",
"id": "req-1",
"error": {
"code": -32602,
"message": "Tool not found: unknown_tool"
}
}Testing#
Direct SQL Testing#
-- Test initialize
SELECT (api.mcp_handle_request(
'{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2024-11-05"}}'::jsonb
)).envelope;
-- Test tools/list
SELECT (api.mcp_handle_request(
'{"jsonrpc":"2.0","id":"2","method":"tools/list"}'::jsonb
)).envelope;
-- Test tools/call
SELECT (api.mcp_handle_request(
'{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"database_info","arguments":{}}}'::jsonb
)).envelope;
-- Test with authentication context
SELECT (api.mcp_handle_request(
'{"jsonrpc":"2.0","id":"4","method":"tools/call","params":{"name":"execute_query","arguments":{"query":"SELECT 1"}}}'::jsonb,
'{"user_id":"test|123"}'::jsonb
)).envelope;Testing Handlers in Isolation#
DO $$
DECLARE
v_response api.mcp_response;
v_envelope jsonb;
BEGIN
-- Register test handler
PERFORM api.create_or_replace_mcp_handler(
jsonb_build_object(
'id', 'ffffffff-test-4000-8000-000000000001',
'type', 'tool',
'name', 'test_tool',
'description', 'Test',
'inputSchema', '{}'::jsonb,
'requiresAuth', false
),
$body$
BEGIN
RETURN api.mcp_tool_result(
jsonb_build_array(api.mcp_text('success')),
(request).request_id
);
END;
$body$
);
-- Invoke
v_response := api.mcp_call_tool('test_tool', '{}'::jsonb, NULL, 'test-1');
v_envelope := (v_response).envelope;
-- Verify
IF v_envelope->>'jsonrpc' != '2.0' THEN
RAISE EXCEPTION 'Missing jsonrpc 2.0';
END IF;
IF v_envelope->>'id' != 'test-1' THEN
RAISE EXCEPTION 'request_id not preserved';
END IF;
IF v_envelope->'error' IS NOT NULL THEN
RAISE EXCEPTION 'Unexpected error';
END IF;
RAISE NOTICE 'Test passed';
END $$;HTTP Gateway#
The advanced template includes a Python gateway (tools/mcp-gateway.py) that bridges HTTP to PostgreSQL. This file is generated when you run pgmi init --template advanced.
Requirements:
- Python 3.8+
psycopg[binary]>=3.0(psycopg 3) — the only dependency, pinned intools/requirements.txt
The gateway uses the Python standard library’s http.server; it does not
use Flask, gunicorn, or psycopg2.
Starting the Gateway#
cd myproject/tools
pip install -r requirements.txt
export DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"
python mcp-gateway.pyEndpoints#
| Endpoint | Method | Purpose |
|---|---|---|
/mcp | POST | MCP JSON-RPC endpoint. Honors Accept (responds application/json; no SSE), validates and echoes the MCP-Protocol-Version header |
/mcp | GET | 405 Method Not Allowed — the gateway offers no server-initiated SSE stream |
/health | GET | Health check (for load balancers) |
The gateway implements the Streamable HTTP transport’s request/response path:
a POST whose MCP-Protocol-Version header names an unsupported revision is
rejected with 400; an Accept header that admits neither application/json
nor a wildcard is rejected with 406. A server→client SSE stream on GET /mcp
is intentionally not implemented (the gateway emits no server-initiated
notifications).
Authentication Headers#
The gateway extracts authentication from HTTP headers:
| Header | Maps to |
|---|---|
X-User-Id | context.user_id |
X-Tenant-Id | context.tenant_id |
Production Deployment#
The shipped gateway is a single-process http.server reference implementation.
For production, consider:
- Reverse Proxy: Place behind nginx/Caddy that validates JWTs and injects X-User-Id
- Connection Pooling: Use PgBouncer for connection management
- TLS: Terminate SSL at the load balancer
- Scaling: run multiple gateway processes behind the proxy, or adapt the
handler to your own ASGI/WSGI server — the gateway exposes no WSGI
appobject, so it cannot be served withgunicornas-is.
Built-in Example Tools#
The advanced template includes these example handlers:
| Name | Type | Description |
|---|---|---|
database_info | Tool | Get database version and connection info |
list_tables | Tool | List tables in a schema with row counts |
describe_table | Tool | Get column definitions for a table |
table_schema | Resource | Get table column metadata via URI |
sql_assistant | Prompt | Generate SQL query assistant prompts |
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#
“Method not found” error
- Check that your handler is registered:
SELECT * FROM api.mcp_route; - Verify the handler type matches the method (tool for tools/call, etc.)
“Authentication required” error
- Pass context with user_id:
'{"user_id":"test|123"}'::jsonb - Or set
requiresAuth: falsefor public tools
Handler not appearing in discovery
- Check the handler was created:
SELECT * FROM api.handler WHERE handler_type LIKE 'mcp_%'; - 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 created_at DESC LIMIT 10;See Also#
- Session API — Session tables and helper functions
- Security — Secrets handling and security patterns
- Testing — Database testing with automatic rollback