Synsema docsENES

Your DB as MCP tools

MCP servers shows the protocol. This page applies it to your data: the same whitelist + row-level-security declaration that powers your REST API becomes a set of MCP tools an agent (Claude, Cursor, any client) can call — and RLS filters rows for the agent's user identically to REST.

db-mcp.syn
-- Doc example: the same DB exposed as MCP tools — one pure task speaks JSON-RPC.
-- REST and MCP share ONE userland "declaration" (whitelist + RLS): the mcp() task here
-- is what the page (45-db-mcp) wires to `route "POST /mcp"`. The `user` is threaded
-- EXPLICITLY into every tool call — RLS works identically over MCP.
intent: "doc example: DB-as-MCP — data tools with row-level security"
require db(":memory:")

db_open(":memory:")
sql_exec("CREATE TABLE carts (id INTEGER PRIMARY KEY, customer_phone TEXT, status TEXT)")
sql_exec("INSERT INTO carts (customer_phone, status) VALUES ('+549115555', 'open')")
sql_exec("INSERT INTO carts (customer_phone, status) VALUES ('+549119999', 'open')")

let EXPOSED be ["carts"]

task rls_allow(t, row, user)
    when t == "carts"
        give user.role == "admin" or row.customer_phone == user.phone
    give true

task table_rows(t, user)
    when not contains(EXPOSED, t)
        give nothing
    give where(sql("SELECT * FROM " + t), (r) => rls_allow(t, r, user))

-- One task = the whole MCP server (initialize / tools/list / tools/call).
task mcp(req, u)
    let m be req["method"]
    let rid be when contains(req, "id") then req["id"] otherwise nothing
    when m == "initialize"
        give {"jsonrpc": "2.0", "id": rid, "result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "my-db", "version": "0.1"}}}
    when m == "tools/list"
        give {"jsonrpc": "2.0", "id": rid, "result": {"tools": [
            {"name": "table_rows", "description": "Rows of an exposed table, filtered by row-level security.", "inputSchema": {"type": "object", "properties": {"table": {"type": "string"}}, "required": ["table"]}}
        ]}}
    when m == "tools/call"
        let p be req["params"]
        when p["name"] == "table_rows"
            let rows be table_rows(p["arguments"]["table"], u)
            when rows == nothing
                give {"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": "no such resource"}}
            give {"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": json_encode(rows)}]}}
        give {"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": "unknown tool"}}
    give {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "method not found"}}

let ADMIN be {"user": "root", "role": "admin", "phone": ""}
let MARIA be {"user": "maria", "role": "customer", "phone": "+549115555"}

print("tools: " + text(length(mcp({"method": "tools/list", "id": 1}, ADMIN)["result"]["tools"])))

test "initialize answers the MCP handshake"
    let r be mcp({"method": "initialize", "id": 1}, ADMIN)
    assert_eq(r["result"]["protocolVersion"], "2024-11-05")

test "tools/call threads the user: RLS filters rows identically to REST"
    let all be mcp({"method": "tools/call", "id": 2, "params": {"name": "table_rows", "arguments": {"table": "carts"}}}, ADMIN)
    assert_eq(length(json_decode(all["result"]["content"][0]["text"])), 2)
    let mine be mcp({"method": "tools/call", "id": 3, "params": {"name": "table_rows", "arguments": {"table": "carts"}}}, MARIA)
    assert_eq(length(json_decode(mine["result"]["content"][0]["text"])), 1)

test "unknown tool and unknown method are JSON-RPC errors, not crashes"
    let e1 be mcp({"method": "tools/call", "id": 4, "params": {"name": "nope", "arguments": {}}}, ADMIN)
    assert_eq(e1["error"]["code"], 0 - 32602)
    let e2 be mcp({"method": "resources/list", "id": 5}, ADMIN)
    assert_eq(e2["error"]["code"], 0 - 32601)

One pure task is the whole server

mcp(req, u) handles initialize, tools/list and tools/call and returns JSON-RPC maps. Being a pure task, it's testable without a server — the wiring is one route:

serve on 8080
    auth with check_token

    route "POST /mcp" requires auth
        give mcp(json of request, user of request)

Thread the user explicitly

The critical line is mcp(json of request, user of request): the authenticated user travels as a parameter into every tool call, and each tool passes it down to table_rows(t, u) — the same RLS gate as REST. There is no ambient "current user" to forget; if a tool doesn't receive u, it can't read anything.

when p["name"] == "table_rows"
    let rows be table_rows(p["arguments"]["table"], u)   -- RLS filters for THIS user

A customer calling table_rows over MCP sees exactly the rows they'd see over REST — one declaration, two protocols.

Failure semantics

Unknown tool → JSON-RPC error -32602; unknown method → -32601; a non-exposed table → an error result, not a crash. Agents read errors, so keep them structured and self-contained.

The same honest warning as the REST page applies: RLS is discipline — every tool must read through the gate. The advantage of the shared declaration is that there's exactly one gate to review.