Skip to content

Adapters

An adapter is any object with a register(server, prefix="") method. The three here cover the systems that come up most often; writing your own is a dozen lines.

SQL

from mcp_toolkit import MCPServer, SqlAdapter
import psycopg

server = MCPServer("warehouse")
server.mount(
    SqlAdapter(
        psycopg.connect(DSN),
        allowed_tables=["orders", "order_lines"],
        max_rows=100,
        dialect_placeholder="%s",
    ),
    prefix="db.",
)

Registers db.query and db.list_tables. Works with any PEP 249 driver.

What the guard rejects

Query Reason
DELETE FROM orders not a read
SELECT 1; DROP TABLE orders more than one statement
SELECT 1 -- comment comments can hide a second statement from a naive parser
PRAGMA table_info(orders) not SELECT or WITH
SELECT token FROM api_keys table not in allowed_tables

WITH ... SELECT is allowed. The allowlist is checked against every FROM and JOIN target, so a join into an unexposed table fails like a direct read of it.

This is a keyword and structure guard, not a SQL parser. It is the second line of defence. The first is a database user with SELECT and nothing else — set that up regardless.

Rows

max_rows is a hard cap. The adapter fetches one row past it to set truncated, so the agent can tell a complete answer from a page:

{"columns": ["id"], "rows": [{"id": "ORD-1"}], "row_count": 1, "truncated": true}

Parameters are bound by the driver:

adapter.query("SELECT id FROM orders WHERE customer = ?", ["ops@corp.example"])

The tool description says so, which is what keeps models from formatting values into the SQL string instead.

REST

api = RestAdapter(
    "https://orders.internal",
    headers={"X-Api-Key": os.environ["ORDERS_API_KEY"]},
    timeout=8.0,
)

api.endpoint(
    "lookup_order", "GET", "/v1/orders/{order_id}",
    description="Fetch one order by its internal identifier.",
    path_params={"order_id": 'Internal identifier, e.g. "ORD-8812".'},
    query_params={"expand": 'Relations to expand, e.g. "lines".'},
)

server.mount(api)

Each endpoint() call becomes one tool with a schema built from the declared parameters. Path parameters are required; query parameters are optional.

Credentials belong in headers. They are attached per request and never appear in a tool schema, so the model cannot read them, cannot leak them, and cannot substitute a different value.

Path parameters are URL-quoted. order_id="../admin/keys" requests /v1/orders/..%2Fadmin%2Fkeys, which your router will reject, rather than /v1/admin/keys, which it might not.

Write methods are opt-in. POST, PUT, PATCH and DELETE raise ValueError at declaration time unless you pass allow_write_methods=True. The friction is intentional: it makes "this agent can mutate state" a decision someone made rather than a default someone inherited.

For a write endpoint, declare the body:

api = RestAdapter(BASE, allow_write_methods=True)
api.endpoint(
    "create_note", "POST", "/v1/orders/{order_id}/notes",
    description="Attach an internal note to an order.",
    path_params={"order_id": "Order identifier."},
    body_schema={
        "type": "object",
        "properties": {"text": {"type": "string", "description": "Note body."}},
        "required": ["text"],
    },
)

Responses are parsed as JSON when the content type says so or the body starts with { or [; otherwise you get the text. HTTP errors become ToolError carrying the status code, so the model sees lookup_order returned HTTP 404 rather than a stack trace.

Files

server.mount(
    FileAdapter(
        root="/srv/runbooks",
        include=["*.md", "*.txt"],
        exclude=[".git/*", "*.pem", "drafts/*"],
        max_bytes=200_000,
    ),
    prefix="fs.",
)

Registers fs.list_files, fs.read_file and fs.search.

Every path is resolved with Path.resolve() and then checked against the resolved root, which handles ../, absolute paths and symlinks pointing outside the sandbox in one step. Excluded files are invisible to listing, reading and search alike — not filtered out of the listing and then readable by name.

max_bytes matters more than it looks. One large file is enough to fill a context window, and the agent has no way to know that before asking. The adapter refuses with a message that names the size and the limit, so the model can pick a different file instead of retrying.

fs.search is a case-insensitive literal substring scan returning path, line number and the matching line, capped at max_matches. It skips files over max_bytes and anything that is not valid UTF-8.

Writing your own

class MetricsAdapter:
    def __init__(self, client):
        self.client = client

    def query_metric(self, name: str, window: str = "1h") -> dict:
        """Read a time series from the metrics backend.

        Args:
            name: Metric name, e.g. "http.requests.p99".
            window: Lookback window, e.g. "15m", "1h", "7d".
        """
        return self.client.range(name, window)

    def register(self, server, prefix=""):
        server.add_tool(f"{prefix}query_metric", self.query_metric)


server.mount(MetricsAdapter(client), prefix="metrics.")

add_tool runs the same schema generation as the decorator, so bound methods get their annotations and docstrings read the same way plain functions do.