MCP

Swival can connect to external tool servers via the Model Context Protocol (MCP). When MCP servers are configured, their tools are discovered at startup, converted to the same function-calling format as built-in tools, and exposed alongside them in the agent loop.

Each MCP tool is namespaced as mcp__<server_name>__<tool_name> to avoid collisions with built-in tools and across servers. The model calls them like any other tool. Swival routes the call to the correct server and returns the result as a string following the same conventions as built-in tools.

MCP servers can use stdio transport (local subprocess) or HTTP transport (remote server). Both are configured through swival.toml or .swival/mcp.json.

If an MCP server fails to connect at startup, Swival logs a warning and continues without that server's tools. If a server crashes mid-session, its tools are marked as degraded and return an error message instead of blocking the agent loop. A server that answers a call with a JSON-RPC error, such as invalid parameters, is still running, so it keeps its tools and the model can retry with corrected arguments. The same is true when a server's answer is invalid, for example when it does not match the tool's output schema: the call fails, but the server stays available.

Tool name collisions across servers cause the colliding server's tools to be skipped entirely, with a warning.

When MCP tool schemas consume more than 30% of the context window, Swival warns. At 50%, it iteratively drops the most expensive server's tools until usage is under budget.

Deferred Schemas

Tool schemas describe each tool and the arguments it accepts. Sending all of them with every request can use a lot of context, even when the task needs only a few tools. For example, one browser automation server with 30 tools adds about 6,300 tokens per request, more than all of Swival's built-in tools together.

When the MCP schemas add up to more than about 2,000 tokens, Swival waits to send each schema until the model needs it. Instead, the model gets a tool_search tool that lists the available tool names by server. The system prompt still includes their descriptions, so the model can decide which tools to look up.

Searching for an exact tool name loads only that tool's schema, while a keyword search loads up to five matching schemas. Calling a tool directly by name also loads its schema. Once loaded, a schema is included in every later request in the conversation, including later REPL turns, until /clear resets the conversation. Each subagent keeps its own set of loaded schemas.

Smaller catalogs still send all schemas with every request. The command provider also keeps its full tool catalog in the prompt.

Searching takes an extra model turn. Also, loading a new schema changes the beginning of the request, causing a one-time miss in the provider's prompt cache. If you prefer to send all schemas from the start, set defer_mcp_schemas = false in swival.toml or pass defer_mcp_schemas=False to Session. The 30% and 50% budget rules described above then apply.

TOML Configuration

Add [mcp_servers.<name>] tables to swival.toml. Each server needs either command (for stdio transport) or url (for HTTP transport), but not both.

[mcp_servers.brave-search]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-brave-search"]
env = { BRAVE_API_KEY = "your-key-here" }

[mcp_servers.remote-api]
url = "https://api.example.com/mcp"
headers = { Authorization = "Bearer token123" }

Server names must match [a-zA-Z0-9_-]+ and cannot contain double underscores (since __ is used as the namespacing separator in tool names).

For stdio servers, command is the executable name and args is an optional list of arguments. You can also set env to pass environment variables to the subprocess.

[mcp_servers.my-server]
command = "node"
args = ["server.js"]
env = { API_KEY = "secret" }

Stdio servers inherit a sanitized environment. Swival removes its own bundled venv bin/ directory from PATH before launching the subprocess so that swival's transitive Python dependencies (and the swival entry point itself) cannot shadow tools the server expects to find on the system. If you supply a PATH entry in env, the same sanitization is applied to it. The only exception is when you have deliberately activated swival's venv yourself (VIRTUAL_ENV points at it), in which case the path is left untouched.

For example, you can connect Swival to a browser automation server. See Web Browsing for Chrome DevTools MCP, agent-browser, and Lightpanda setup guides.

For remote servers, url is the endpoint and headers is an optional dictionary of HTTP headers.

Two HTTP transports exist: Streamable HTTP, which the current spec defines, and the older HTTP+SSE transport it deprecated. Swival picks one by running the handshake: Streamable HTTP first, then SSE if that fails. Set type to skip the guess when you already know what the server speaks.

[mcp_servers.ai-memory]
type = "http"                      # or "sse"
url = "http://127.0.0.1:49374/mcp"

transport is accepted as a synonym for type. Pinning the transport also gives you a better error when the connection fails, since Swival reports the failure from the transport you asked for instead of the last one it tried.

The accepted values are http (streamable-http and streamable_http mean the same thing), sse, and stdio, which is there so configs copied from other MCP clients load unchanged. Case and surrounding spaces are ignored. Anything else is a config error rather than a silent guess.

JSON Configuration

Swival also reads .swival/mcp.json. This uses the same format as other MCP-compatible tools:

{
  "mcpServers": {
    "brave-search": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-brave-search"],
      "env": { "BRAVE_API_KEY": "your-key-here" }
    }
  }
}

The JSON format supports the same fields as TOML: command, args, env for stdio, and url, type, headers for HTTP.

{
  "mcpServers": {
    "ai-memory": {
      "type": "http",
      "url": "http://127.0.0.1:49374/mcp"
    }
  }
}

Config Precedence

When both swival.toml and .swival/mcp.json define servers, the TOML config takes precedence by server name. Servers defined only in .swival/mcp.json are merged in.

The rules are:

--no-mcp disables everything, regardless of what is configured. Otherwise TOML servers (swival.toml [mcp_servers.*]) always win by server name over JSON servers, and JSON-only servers are merged in. The JSON source is --mcp-config FILE when that flag is set, or .swival/mcp.json otherwise. The --mcp-config path does not outrank TOML: it only replaces which JSON file is read.

When the project and global config both define MCP servers, project-level servers win by name, and global-only servers are merged in.

CLI Flags

--no-mcp disables MCP server connections entirely, even if servers are configured in swival.toml or .swival/mcp.json.

--mcp-config FILE provides an explicit path to an MCP JSON config file. When set, this replaces the default .swival/mcp.json lookup.

Output Handling

MCP tool outputs are size-guarded similarly to run_command, with higher thresholds to accommodate richer external tool results. Results up to 20 KB are returned inline.

Larger results are saved to .swival/cmd_output_*.txt and the model receives a pointer message telling it to use read_file for paginated access. Output is hard-capped at 10 MB before writing to disk, so a misbehaving server cannot consume unbounded memory or storage.

Text content blocks are passed through as they are. When a result has no content blocks but carries structuredContent, the model gets that value as JSON instead, including empty objects, zero, false, and null. Results that include both are shown through their content blocks only, since servers usually repeat the structured value there as text. Resource links are shown with their URI and name, and images and audio are summarized by MIME type and size.

Error outputs from MCP tools are kept inline but truncated at 20 KB. Errors are diagnostic, not data the model needs to page through, so they are never saved to file. A failure always starts with an error: line that Swival writes itself. Any text the server wrote or influenced, such as its error message or a validation error quoting a malformed result, follows under an [UNTRUSTED EXTERNAL CONTENT] header. A successful result is never treated as a failure, even when its text happens to start with error:.

During context compaction, MCP tool results receive head-preserving summaries that retain the first 300 characters of content, unlike the generic fallback which discards content entirely.

Calling Tools From run_python

This feature is experimental.

A server can let the run_python tool call some of its tools from code. A snippet can then page through results, join data from two servers, or filter a large response. Only what it prints goes back to the model.

List the tools a snippet may call, using the names the server gives them:

[mcp_servers.tracker]
command = "tracker-mcp"
python_tools = ["list_issues", "get_issue"]

In .swival/mcp.json, use the same python_tools key. Swival warns at startup about names the server does not offer.

This only works when run_python is available, which needs --commands all and a context window of at least 100,000 tokens. It also needs Linux or macOS, and it is turned off by network = "provider-only".

The run_python description tells the model which tools it can call. A snippet uses them like this:

import swival_tools

swival_tools.ALL_TOOLS                        # tools this snippet may call
swival_tools.help("mcp__tracker__get_issue")  # description and schemas
page = swival_tools.call("mcp__tracker__list_issues", {"page": 2}, timeout=10)

call() returns the server's structured result when there is one, the parsed value when the server sent JSON text, and plain text otherwise. Arguments follow the server's own schema. A failed call raises swival_tools.ToolError, which the snippet can catch. Calls go through the same checks as direct tool calls.

Each call must finish within the run_python timeout, and within 120 seconds or the timeout the snippet passes, whichever is shorter. A snippet can make up to 500 calls, get up to 10 MB from one call, and exchange up to 64 MB in total.

When the timeout expires, a limit is reached, the user cancels, or the goal's budget runs out, the call raises swival_tools.BridgeError. Every later call in that run fails the same way. Calls stop a little before the snippet is killed, so it can still print what it gathered.

Giving up on a call does not stop the server, so a call that timed out or was cancelled may still take effect. When that happens, the error says so, and the run_python result ends with a note.

Snippets can call tools from several threads; the calls are made one at a time.

Output that may contain tool results, or tool descriptions from help(), is marked as untrusted external content. JSON reports list these runs under stats.python_bridge, with a python_bridge timeline event for each run.

Library API

The Session class accepts mcp_servers as a constructor argument. Pass a dictionary mapping server names to config dicts:

from swival import Session

session = Session(
    mcp_servers={
        "brave-search": {
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-brave-search"],
            "env": {"BRAVE_API_KEY": "your-key-here"},
        }
    }
)
result = session.run("Search for Python async best practices")

Use Session as a context manager to ensure MCP connections are cleaned up:

with Session(mcp_servers={"fs": {"command": "..."}}) as session:
    result = session.run("task")