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.py

3. 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 /mcp endpoint
  • Authentication: Context parameter (not HTTP headers)

Core Functions#

FunctionPurpose
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.

MethodHandler
initializeapi.mcp_initialize(params, id)
notifications/initialized / any notifications/*returns NULL envelope (no response)
pingapi.mcp_ping(id)
tools/listapi.mcp_list_tools()
tools/callapi.mcp_call_tool(name, args, context, id)
resources/listapi.mcp_list_resources()
resources/templates/listapi.mcp_list_resource_templates()
resources/readapi.mcp_read_resource(uri, context, id)
prompts/listapi.mcp_list_prompts()
prompts/getapi.mcp_get_prompt(name, args, context, id)

Schema Contracts and Tags#

Handler registration metadata accepts the following optional fields:

  • inputSchema — JSON Schema (api.json_schema domain) describing arguments. Rejected if empty {} or malformed.
  • outputSchema — JSON Schema describing results. For MCP tools, surfaces in tools/list and enables spec-compliant structuredContent emission (see below). For REST/RPC, triggers $schema merge when responseHeaders.x-include-schema='true' — REST merges into body (wrapping arrays/scalars as {data, $schema}), RPC merges into result.$schema (never top-level, to keep JSON-RPC 2.0 compliant).
  • responseHeaders (jsonb) — arbitrary headers merged into the wire response (keys lowercased). The key x-include-schema is a directive, not a header; it controls $schema merge and is stripped before the response reaches the client.
  • tags (MCP only, text[]) — surfaces in tools/list under _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#

TypePurposeKey Metadata
ToolExecutable actionsname, description, inputSchema
ResourceData access via URIname, uriTemplate, mimeType
PromptMessage templatesname, 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_response

Error 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_response

Content 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#

CodeMeaning
-32700Parse error (invalid JSON)
-32600Invalid Request (missing jsonrpc, method)
-32601Method not found
-32602Invalid params
-32603Internal error
-32001Authentication 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 in tools/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.py

Endpoints#

EndpointMethodPurpose
/mcpPOSTMCP JSON-RPC endpoint. Honors Accept (responds application/json; no SSE), validates and echoes the MCP-Protocol-Version header
/mcpGET405 Method Not Allowed — the gateway offers no server-initiated SSE stream
/healthGETHealth 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:

HeaderMaps to
X-User-Idcontext.user_id
X-Tenant-Idcontext.tenant_id

Production Deployment#

The shipped gateway is a single-process http.server reference implementation. For production, consider:

  1. Reverse Proxy: Place behind nginx/Caddy that validates JWTs and injects X-User-Id
  2. Connection Pooling: Use PgBouncer for connection management
  3. TLS: Terminate SSL at the load balancer
  4. Scaling: run multiple gateway processes behind the proxy, or adapt the handler to your own ASGI/WSGI server — the gateway exposes no WSGI app object, so it cannot be served with gunicorn as-is.

Built-in Example Tools#

The advanced template includes these example handlers:

NameTypeDescription
database_infoToolGet database version and connection info
list_tablesToolList tables in a schema with row counts
describe_tableToolGet column definitions for a table
table_schemaResourceGet table column metadata via URI
sql_assistantPromptGenerate 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: false for 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