Skip to content

Tools, Skills & MCP

Giving a model capabilities — calling your functions, loading Markdown skills, talking to MCP servers — is something every harness re-implements. Sema makes all three first-class, and it does so without new grammar: a function is a tool, and skills and MCP tools are data loaded by a handful of verbs. Crucially, because a tool is a governed Sema function, its effect row still applies when the agent calls it — tool calling inherits the language’s governance rather than being an ungoverned side channel.

This guide covers native tool calling, Markdown skills, and MCP (one-shot and persistent). It pairs with Building an Agent Loop, where these tools drive a bounded loop.

Pass functions to tools.run. The runtime introspects each one — its name, typed parameters, and a leading sem "…" as the description — into a schema, drives the agentic loop, executes the real functions, and returns the answer plus a trace:

import tools
def get_weather(city: str) -> str !{net.connect}:
sem "Get the current weather for a city"
return fetch_weather(city)
result = tools.run("what's the weather in Berlin?", [get_weather, add], max_steps=6)
# result.answer, result.steps, result.status, result.trace

No separate schema DSL: the function already declares its name, params, and effects, so the runtime introspects it. The sem "…" line is doing double duty — it is the tool’s description the model sees.

Because get_weather carries !{net.connect}, the agent can only call it where that capability is granted — the tool cannot reach the network unless the policy envelope allows it. On top of that the loop is guarded (drawn from the prior-art gap list):

  • Bounded max_steps — every real agent caps iterations.
  • Unknown-tool and tool-error recovery — logged and recovered, never a crash.
  • Same-tool-same-args loop detection — stops the agent spinning.
  • Tool-result truncation with a marker — a huge result can’t blow the context.

Skills load from Markdown with YAML frontmatter (name, description, body = instructions) — exactly the format today’s tools ship, so existing skill libraries work unchanged. Import skills and load a folder or a file:

import skills
docs_skills = skills.dir("skills") # a folder of .md skills
one = skills.load("skills/summarize.md")

skills.context([...]) merges several skills into one instruction block; skills.register(model, [...]) attaches that to a model value’s context so its invocations carry the skills.

import mcp gives you a real stdio JSON-RPC client. The one-shot form spawns a server per call — fine for a single lookup:

import mcp
tools = mcp.tools("npx @modelcontextprotocol/server-filesystem /data")
out = mcp.call("npx ...server-weather", "forecast", {"city": "Berlin"})

mcp.tools(cmd) runs the initializetools/list handshake and returns the server’s tools; mcp.call(cmd, tool, args) invokes one.

Spawning per call is wasteful in a loop. mcp.connect opens a persistent session and returns a handle; subsequent calls reuse the one live process, and mcp.close ends it (any still-open sessions are killed at program exit):

import mcp
s = mcp.connect("npx @modelcontextprotocol/server-filesystem /data")
mcp.tools(s) # list once
mcp.call(s, "read_file", {"path": "a.txt"})
mcp.call(s, "read_file", {"path": "b.txt"}) # same process, no re-spawn
mcp.close(s)

The handle is an opaque integer index into the runtime’s session registry — the live child and its stdio live in the runtime, not in a Sema value. Passing a string command to mcp.call still works as the one-shot form.

Skills and MCP tools register through the same path. mcp.as_skills(cmd) exposes an MCP server’s tools as skill dicts, so Markdown skills and MCP tools merge with + and attach in one call — the model doesn’t care where a capability came from:

agent = skills.register(model, docs_skills + mcp.as_skills("npx ...server-weather"))

And because tools.run (§“A function is a tool”) also folds MCP tools and skills into its loop, native Sema functions, Markdown skills, and MCP tools all reach the model through one governed mechanism.

Tools, skills, and MCP are stdlib modules, so any project using them checks the same way:

Terminal window
sema check <your-project>
SEMA_STRICT=1 sema run <your-project>

sema check verifies the imports and effect rows; SEMA_STRICT=1 turns an unknown-tool recovery or a truncation into a hard error while you debug.

  • Give an agent loop its tools. Feed a toolset to tools.run inside a loop until — see Building an Agent Loop.
  • Reuse an MCP server across a run. mcp.connect once at startup, mcp.close at shutdown; call as many times as you like in between.
  • Ship reflected docs as skills. sema doc --skills your library, then skills.dir the output — your API becomes model context.