MCP servers
MCP servers
MCP (Model Context Protocol) lets an AI agent — Claude, Cursor, any MCP client — call tools and read data you expose. It's JSON-RPC, and Synsema's native serve + JSON make it a one-route server.
-- Doc example: a minimal MCP server (Model Context Protocol) in Synsema. MCP is JSON-RPC;
-- the `serve` wiring is one route (shown in the prose). Here the handler is a pure function,
-- so it's doctestable — press Test.
intent: "doc example: MCP server handler"
task rpc(id, result)
give {"jsonrpc": "2.0", "id": id, "result": result}
-- one tool: greet(name). Arguments come from an agent: check them, and answer a
-- wrong one with an error result the agent can read (never a crash).
task tool_greet(args)
let name be get(args, "name", nothing)
when not is_text(name)
give {"content": [{"type": "text", "text": "greet needs a text argument 'name'"}], "isError": true}
give {"content": [{"type": "text", "text": "Hello, " + name + "!"}]}
-- the JSON-RPC dispatcher: initialize / tools/list / tools/call.
-- A notification (no id, method "notifications/…") gets no answer: nothing.
task mcp(req)
let id be get(req, "id", nothing)
let method be get(req, "method", "")
when starts_with(method, "notifications/")
give nothing
when method == "initialize"
give rpc(id, {"protocolVersion": "2025-06-18", "serverInfo": {"name": "demo", "version": "1.0"}, "capabilities": {"tools": {}}})
otherwise when method == "tools/list"
give rpc(id, {"tools": [{"name": "greet", "description": "Greet someone", "inputSchema": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}]})
otherwise when method == "tools/call"
let p be get(req, "params", {})
when get(p, "name", nothing) == "greet"
give rpc(id, tool_greet(get(p, "arguments", {})))
otherwise
give rpc(id, {"content": [{"type": "text", "text": "unknown tool"}], "isError": true})
otherwise
give rpc(id, {})
test "initialize returns serverInfo + protocol version"
let r be mcp({"id": 1, "method": "initialize", "params": {}})
assert_eq((r["result"])["protocolVersion"], "2025-06-18")
test "tools/call greet returns text content"
let r be mcp({"id": 2, "method": "tools/call", "params": {"name": "greet", "arguments": {"name": "Ada"}}})
assert_eq((((r["result"])["content"])[0])["text"], "Hello, Ada!")
test "a missing or null argument is an error result, not a crash"
let r be mcp({"id": 3, "method": "tools/call", "params": {"name": "greet", "arguments": {}}})
assert_eq(r["result"]["isError"], true)
let r2 be mcp(json_decode("{\"id\": 4, \"method\": \"tools/call\", \"params\": {\"name\": \"greet\", \"arguments\": {\"name\": null}}}"))
assert_eq(r2["result"]["isError"], true)
test "a notification gets no answer"
assert_eq(mcp({"jsonrpc": "2.0", "method": "notifications/initialized"}), nothing)
test "a big JSON-RPC id comes back exact"
let r be mcp(json_decode("{\"jsonrpc\": \"2.0\", \"id\": 18446744073709551616, \"method\": \"tools/list\"}"))
assert_eq(r["id"], 2 ** 64)
assert(contains(json_encode(r), "\"id\": 18446744073709551616"))Wire it to serve (one route)§
require serve(8080)
serve on 8080
route "POST /mcp"
let out be mcp(json of request)
give when out == nothing then respond("", "text/plain", 202) otherwise out
That's a complete MCP server: any MCP client that speaks HTTP connects to http://…/mcp. The core methods are initialize, tools/list and tools/call. A notification (notifications/initialized, no id) gets no JSON-RPC answer: the example's mcp gives nothing and the route answers 202 Accepted with an empty body, as the MCP HTTP transport asks.
json of request is parsed like json_decode (engine v0.6.30), so a numeric JSON-RPC id of any size comes back exact in the answer.
Tools that do real work§
A tool is just a task. Give it a real body — query a DB, compute, call an API — and return {content: [...]}:
task tool_orders(args)
require db("./store.db")
let rows be sql("SELECT id, total FROM orders WHERE customer = ?", [args["customer"]])
give {"content": [{"type": "text", "text": json_encode(rows)}]}
Add it to tools/list (with an inputSchema) and dispatch it in tools/call.
Arguments come from an agent — check them. args["customer"] errors when the key is missing, and a JSON null arrives as nothing, which text will not join ("Hello, " + nothing is an error since engine v0.6.29). Read with get(args, "name", nothing) and answer a wrong argument with an error result the agent can read — the example's tool_greet:
task tool_greet(args)
let name be get(args, "name", nothing)
when not is_text(name)
give {"content": [{"type": "text", "text": "greet needs a text argument 'name'"}], "isError": true}
give {"content": [{"type": "text", "text": "Hello, " + name + "!"}]}
json_encode(rows) in a tool result writes data only: a task or a generator inside the value is an error that names where it is (json_encode: the value["f"] is a task, not data, engine v0.6.30).
Turn an old API into MCP (a converter)§
Wrap an existing REST API as an MCP tool — the agent gets a clean, typed tool; you keep your API untouched:
require net("api.legacy.com")
require secret("LEGACY_KEY")
task tool_weather(args)
let r be http_get("https://api.legacy.com/v1/weather",
{"x-api-key": secret("LEGACY_KEY")}, {"city": args["city"]})
give {"content": [{"type": "text", "text": body of r}]}
Register it, dispatch it — your legacy API is now MCP-native, and the secret stays redacted (see Secrets).
Safety — run untrusted tool input sandboxed§
If your MCP server runs code or input you don't trust, put a host ceiling on the execution (--cap-set). This docs site's own MCP endpoint (/mcp) exposes run_synsema/test_synsema tools that run snippets in exactly that sandbox — so an agent can verify the Synsema it writes, safely.