Skip to content
synsema why?
← All cases
Case 1 · Measured on 2026-10-16 · Synsema 0.6.44 · same data, same model on both sides

MCP servers that leak by accident

What does the model receive from your MCP server when a teammate adds a tool that returns the whole record?

5 vs 0

confidential fields the model receives after one careless tool

One careless tool leaks every confidential field in Python. Synsema refuses it at runtime.

Python, typical: 55Python, typicalPython, hardened: 55Python, hardened0Synsema
Confidential fields handed to the model by the careless toolSame four tools, same catalog: cost, supplier, minimum margin, maximum discount, internal note.

The problem

An MCP server cannot know why the model is asking. The request may come from a document, an email or a web page nobody controls. The only thing the server controls is what it returns.

In Python and TypeScript, "never return confidential fields" is a convention. It holds until the third pull request, when someone adds a convenience tool that returns the full record.

What we built

  • Three MCP servers with the same four tools over the same product catalog: the official Python SDK written the usual way, a hardened Python version with a public view and server-side quoting, and Synsema with information-flow labels.
  • In all three, a fourth tool, "added later for the purchasing team", returns the whole record.
  • A side-by-side test bench calls every tool on the three servers, and any MCP client (Claude Code, Cursor) can connect to them.

Results

What the model receives
ToolPython, typicalPython, hardenedSynsema
list_productsall 5 confidential fieldspublic fields onlypublic fields only
get_productall 5 confidential fieldspublic fields onlypublic fields only
quote (uses cost and margin inside)price onlyprice onlyprice only, each output declassified with a written reason
product_detail, added laterall 5 confidential fieldsall 5 confidential fieldsrefused: label_violation
Know before running what can leavenono, read every toolyes, synsema code check lists 4 exits
Latency per call4 ms4 ms4 ms

The code, side by side

The usual way
Hardened Python: the careless toolNothing in Python stops it from returning the whole record.
def product_detail(id: int) -> dict:
    """(Added later, for the purchasing team.) Full product detail."""
    return c.find(id)   # the slip: returns the whole record, and nothing prevents it
cases/01-mcp-servers/servers/py_hardened.py · from line 29
Synsema
Synsema: confidential fields labeled once, at loadFrom here on, everything computed from these values carries the label.
task load_confidential()
    let raw be json_decode(read_file("./cases/01-mcp-servers/data/catalog.json"))
    let conf be {}
    each p in raw
        set conf[text(p.id)] to {"cost_usd": private(p.cost_usd, "company"), "supplier": private(p.supplier, "company"),
            "min_margin_pct": private(p.min_margin_pct, "company"), "max_discount_pct": private(p.max_discount_pct, "company"),
            "internal_note": private(p.internal_note, "company")}
    give conf
cases/01-mcp-servers/servers/mcp.syn · from line 18
Synsema: the same careless toolSame mistake. The HTTP response is a public exit, so the runtime refuses it.
-- (Added later, for the purchasing team.) The same slip as in Python: returns the whole record.
-- In Synsema it never reaches the model: the HTTP response is a public output and refuses labeled fields.
task t_product_detail(args)
    give merge(find(args.id), CONFIDENTIAL[text(args.id)])
cases/01-mcp-servers/servers/mcp.syn · from line 62

Why Synsema

  • Confidential values are labeled once, when loaded. Every value computed from them carries the label.
  • Any public exit (the HTTP response, a log, a file) refuses a labeled value unless the code calls declassify with a reason, and that reason is logged.
  • synsema code check lists every declassify with its line before anything runs. That list is the security review.

What it does not solve

  • Labels are opt-in: the server must run with serve --labels. Without the flag, private() fails on purpose instead of running unprotected.
  • Propagation is conservative. Reading public fields from a list that also holds private values gives a private result, so public and confidential data must live in separate structures.
  • A violation returns HTTP 500, not a tidy JSON-RPC error. It cannot be caught, because catching it would leak one bit per try.
  • Labels protect confidentiality, not authorization. If quoting is allowed, the model can still ask for the maximum discount.

Reproduce it

git clone https://github.com/synsema/why-synsema && cd why-synsema
# cases/01-mcp-servers
bash cases/01-mcp-servers/start.sh                       # three MCP servers on 8101, 8102, 8103
python cases/01-mcp-servers/bench/compare.py             # same calls on the three, saved to results/
synsema code check cases/01-mcp-servers/servers/mcp.syn --json

github.com/synsema/why-synsema/tree/main/cases/01-mcp-servers

Share