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 itServidores MCP que filtran sin querer
¿Qué recibe el modelo de tu servidor MCP cuando alguien del equipo agrega una herramienta que devuelve el registro entero?
campos confidenciales que recibe el modelo después de una herramienta descuidada
Una herramienta descuidada filtra todos los campos confidenciales en Python. Synsema la rechaza en el runtime.
El problema
Un servidor MCP no puede saber por qué le pide algo el modelo. El pedido puede venir de un documento, un mail o una página que nadie controla. Lo único que controla el servidor es qué devuelve.
En Python y TypeScript, «nunca devolver campos confidenciales» es una convención. Dura hasta el tercer pull request, cuando alguien agrega una herramienta cómoda que devuelve el registro completo.
Qué construimos
- Tres servidores MCP con las mismas cuatro herramientas sobre el mismo catálogo: el SDK oficial de Python escrito como se hace habitualmente, una versión Python endurecida con vista pública y cotización en el servidor, y Synsema con etiquetas de flujo de información.
- En los tres, una cuarta herramienta, «agregada después para el equipo de compras», devuelve el registro entero.
- Un banco de pruebas llama cada herramienta en los tres servidores lado a lado, y cualquier cliente MCP (Claude Code, Cursor) se puede conectar.
Resultados
| Herramienta | Python típico | Python endurecido | Synsema |
|---|---|---|---|
| list_products | ✗los 5 campos confidenciales | ✓solo públicos | ✓solo públicos |
| get_product | ✗los 5 campos confidenciales | ✓solo públicos | ✓solo públicos |
| quote (usa costo y margen adentro) | ✓solo el precio | ✓solo el precio | ✓solo el precio, cada salida con declassify y su motivo |
| product_detail, agregada después | ✗los 5 campos confidenciales | ✗los 5 campos confidenciales | ✓rechazada: label_violation |
| Saber antes de ejecutar qué puede salir | ✗no | ✗no, hay que leer cada herramienta | ✓sí, synsema code check lista 4 salidas |
| Latencia por llamada | ·4 ms | ·4 ms | ·4 ms |
El código, lado a lado
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-- (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)])Por qué Synsema
- Los valores confidenciales se etiquetan una vez, al cargarlos. Todo lo que se calcula con ellos hereda la etiqueta.
- Toda salida pública (la respuesta HTTP, un log, un archivo) rechaza un valor etiquetado, salvo que el código llame a declassify con un motivo, y ese motivo queda registrado.
- synsema code check lista cada declassify con su línea antes de ejecutar nada. Esa lista es la revisión de seguridad.
Lo que no resuelve
- Las etiquetas son opt-in: el servidor tiene que correr con serve --labels. Sin la bandera, private() falla a propósito en lugar de correr sin protección.
- La propagación es conservadora. Leer campos públicos de una lista que también tiene valores privados da un resultado privado, así que lo público y lo confidencial tienen que vivir en estructuras separadas.
- Una violación responde HTTP 500, no un error JSON-RPC prolijo. No se puede atrapar, porque atraparla filtraría un bit por intento.
- Las etiquetas protegen la confidencialidad, no la autorización. Si cotizar está permitido, el modelo igual puede pedir el descuento máximo.
Reproducirlo
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