# Plan: MCP Server Support for Swival

## Goal

Allow swival to connect to MCP servers and expose their tools to the LLM alongside swival's built-in tools. This makes swival interoperable with the growing MCP ecosystem (filesystem servers, database tools, API connectors, etc.) without custom integration code for each.

## Scope

- **In scope**: MCP client (connecting to servers), stdio + streamable HTTP transports, tool primitive, config via `swival.toml` and `.mcp.json`
- **Out of scope (for now)**: MCP resources, prompts, server-side (swival as an MCP server), OAuth/authentication flows beyond static headers/env vars

## Design Decisions

### Async boundary

The MCP Python SDK (`mcp` package) is async-only. Swival is currently fully synchronous. Options:

1. **Wrap async in `asyncio.run()` at the boundary** — Keep swival sync, call async MCP operations through a thin sync wrapper. Simplest, least invasive.
2. **Make the agent loop async** — Large refactor, touches everything.
3. **Use a background thread with its own event loop** — More complex lifecycle management.

**Choice: Background thread with persistent event loop (option 3).** MCP connections are long-lived (servers are subprocesses that stay alive for the session), so we need a persistent event loop. The `McpManager` spawns a daemon thread running `asyncio.new_event_loop()`. All public methods are synchronous — they submit coroutines via `asyncio.run_coroutine_threadsafe()` and block on the future with a timeout.

### Lifecycle and teardown semantics

`close()` is the only cleanup path. It must be idempotent and safe to call from any state:

1. Set an internal `_closing` flag (prevents new `call_tool` submissions)
2. Cancel all pending futures with a 5-second grace period
3. Submit a shutdown coroutine that closes each `ClientSession` and terminates each subprocess (SIGTERM, then SIGKILL after 3s)
4. `loop.call_soon_threadsafe(loop.stop)` to exit the loop
5. `thread.join(timeout=10)` — if join times out, log a warning naming the residual thread and any servers that didn't shut down cleanly, then move on. The daemon thread will die with the process, but the warning ensures visibility into leaks during long-running sessions (e.g., REPL mode). This is a known residual risk documented in the Risks table.
6. Set `_closed = True`

If `call_tool()` is in-flight when `close()` is called, the pending future gets cancelled, and `call_tool()` raises a well-defined `McpShutdownError` that the agent loop treats as a tool error (not a crash).

`Session` becomes a context manager (`__enter__`/`__exit__`) and calls `manager.close()` in `__exit__`. An `atexit` handler is registered as a last-resort fallback. No `__del__` — it's unreliable and not worth the complexity.

### Tool namespacing

MCP tools from different servers may collide with each other or with built-in tool names. Convention from other agents: prefix with `mcp__<server-name>__<tool-name>`.

**Choice: Namespace with validated names and internal routing table.** Tool routing uses a `dict[str, tuple[str, str]]` mapping `namespaced_name → (server_name, original_tool_name)` built at discovery time — no string splitting at dispatch time. Server names are validated at config parse time: must match `[a-zA-Z0-9_-]+` (no double underscores allowed). Tool names from MCP servers are sanitized: any `__` sequences are collapsed to `_`, and non-alphanumeric/underscore/hyphen characters are replaced with `_`.

After sanitization, `_tool_map` construction checks for collisions. If two distinct `(server, original_tool_name)` pairs produce the same namespaced key, `start()` raises `ConfigError` listing both originals and the colliding key. This is a fatal error for that server — all its tools are skipped with a clear message. Collisions with built-in tool names (e.g., an MCP tool that would namespace to `mcp__fs__read_file` colliding with swival's `read_file`) are not possible since MCP names always carry the `mcp__` prefix, but the check is still performed defensively.

### Configuration format and precedence

Two formats to support:

1. **`swival.toml`** — Native config. New `[mcp_servers.<name>]` sections.
2. **`.mcp.json`** — Ecosystem-standard file (used by Claude Code, Cursor, VS Code). Read from project root if present.

**Precedence (highest to lowest):**
1. `--no-mcp` — disables MCP entirely, overrides everything
2. `--mcp-config <path>` — use only this file, ignore `.mcp.json` default location
3. `swival.toml` `[mcp_servers.*]` — per-server, wins over `.mcp.json` for same-named servers
4. `.mcp.json` in project root — ecosystem default, lowest priority

When `--mcp-config` is given, `.mcp.json` from the project root is not loaded. Servers from `swival.toml` and the JSON source are merged by name; toml wins on collision. This is documented in `--help` output and `swival init` template comments.

### MCP tool result handling

MCP `tools/call` returns a `result` with a `content` array of typed blocks and an `isError` flag. Content blocks can be:
- `text` — plain text
- `image` — base64-encoded image with MIME type
- `resource` — embedded resource with URI and text/blob content
- `audio` — base64-encoded audio (rare)

**Conversion rules:**

```
text blocks     → concatenated with newline separators
image blocks    → "[image: {mimeType}, {len(data)} bytes]" placeholder
resource blocks → include text content if present, else "[resource: {uri}]"
audio blocks    → "[audio: {mimeType}, {len(data)} bytes]" placeholder
isError=true    → prefix result with "error: " so it follows swival's tool error convention
```

Tool execution errors (isError=true) are surfaced as `"error: <content>"` strings — the same format built-in tools use. This means the LLM sees them as tool failures and can self-correct. Protocol-level errors (server crashed, timeout, invalid response) are surfaced as `"error: MCP server '<name>' failed: <details>"`.

### JSON Schema conversion

MCP `inputSchema` maps to OpenAI `parameters` with these rules:

1. Copy the schema as-is (preserving `required`, `enum`, `default`, `description`, `oneOf`/`anyOf`/`allOf`, nested `$ref` — everything)
2. Ensure top-level has `"type": "object"` (required by OpenAI)
3. Ensure top-level has `"properties"` key (add `{}` if missing)
4. Strip `$schema` and `$id` keys if present (OpenAI rejects them)
5. If `additionalProperties` is present, keep it (some providers handle it, some ignore it — harmless either way)

This is a whitelist-of-removals approach: keep everything by default, only strip what's known to cause provider rejections. Add a test suite with real-world MCP schemas (from filesystem, GitHub, Slack servers) to catch conversion issues.

### Token budget management

MCP tool schemas are included in the `tools` list and counted by `estimate_tokens()`. To prevent MCP tools from consuming excessive context:

1. After collecting all tools (built-in + MCP), estimate their combined token cost
2. If tool schemas exceed 30% of `context_length`, emit a stderr warning with breakdown by server
3. If tool schemas exceed 50% of `context_length`, enter a drop loop: repeatedly remove the MCP server contributing the most tool-schema tokens, recompute, and continue until under 50% or no MCP servers remain. Each drop emits a stderr error naming the server and reason.
4. The `--max-mcp-tools` flag (optional, not in initial implementation) could let users set explicit limits

### Runtime server failure policy

If an MCP server crashes or becomes unresponsive after startup:

1. `call_tool()` catches the transport error and returns `"error: MCP server '<name>' is unavailable: <reason>"`
2. The server is marked as `degraded` in the manager's internal state
3. Subsequent calls to that server's tools return the error immediately (no retry/reconnect)
4. The agent loop sees this as a normal tool error — the LLM can choose to work around it
5. No automatic reconnection in v1 (future extension)

## Implementation Plan

### Phase 1: New module `mcp_client.py`

Create `swival/mcp_client.py` with:

```python
class McpShutdownError(Exception):
    """Raised when call_tool() is invoked during or after shutdown."""

class McpManager:
    """Manages connections to multiple MCP servers."""

    def __init__(self, server_configs: dict[str, dict]):
        """
        server_configs: {
            "server-name": {
                "command": "npx",
                "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
                "env": {"KEY": "val"},          # optional, for stdio
                # OR for HTTP:
                "url": "http://localhost:8080/mcp",
                "headers": {"Authorization": "Bearer ..."},  # optional
            }
        }
        """
        # Internal state:
        # self._loop: asyncio.AbstractEventLoop
        # self._thread: threading.Thread (daemon=True)
        # self._sessions: dict[str, ClientSession]
        # self._tool_map: dict[str, tuple[str, str]]  # namespaced → (server, original_name)
        # self._closing: bool
        # self._closed: bool

    def start(self) -> None:
        """Start background event loop, connect to all servers, run initialize + list_tools.
        Servers that fail to start are logged and skipped (non-fatal)."""

    def list_tools(self) -> list[dict]:
        """Return all MCP tools in OpenAI function-calling format, namespaced.
        Uses self._tool_map for routing, not string parsing."""

    def call_tool(self, namespaced_name: str, arguments: dict) -> str:
        """Dispatch to the correct server via self._tool_map lookup.
        Returns normalized string result (see result handling rules).
        Raises McpShutdownError if manager is closing/closed."""

    def close(self) -> None:
        """Idempotent shutdown. See teardown semantics above."""
```

Internal details:
- Background daemon thread runs `asyncio.new_event_loop()` + `loop.run_forever()`
- `start()` connects to each server: spawns stdio subprocess or opens HTTP session, runs `initialize`, calls `list_tools`, populates `_tool_map`
- Schema conversion follows the rules in "JSON Schema conversion" above
- Result conversion follows the rules in "MCP tool result handling" above
- `atexit.register(self.close)` for last-resort cleanup
- Servers that fail to start: log warning via `fmt.mcp_server_error()`, skip, continue with remaining servers

### Phase 2: Configuration

**In `config.py`:**

Add `mcp_servers` to `CONFIG_KEYS` as type `dict`. Unlike other config keys, this is a nested structure (TOML table of tables).

Add `load_mcp_json(base_dir)` function to parse `.mcp.json` from project root.

Add `merge_mcp_configs(toml_servers, json_servers)` — toml wins on name collision.

Add `validate_mcp_server_names(configs)` — enforce `[a-zA-Z0-9_-]+` pattern, reject names containing `__`.

**`swival.toml` format:**

```toml
[mcp_servers.filesystem]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/docs"]

[mcp_servers.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
env = { GITHUB_TOKEN = "ghp_..." }

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

**`.mcp.json` format (ecosystem standard):**

```json
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
    }
  }
}
```

**CLI flags:**
- `--mcp-config <path>` — alternate JSON config file (replaces `.mcp.json` default, does not replace `swival.toml` servers)
- `--no-mcp` — disable MCP entirely (highest precedence)

**Error handling for config sources:**
- `--mcp-config <path>` pointing to a missing or malformed file is a **fatal `ConfigError`** — the user explicitly asked for this file, so silently falling back would hide a misconfiguration.
- `.mcp.json` in the project root missing: silently ignored (it's an optional convention).
- `.mcp.json` in the project root present but malformed (invalid JSON, wrong structure): **fatal `ConfigError`** with the parse error — a broken file the user placed in their project should not be silently skipped.
- `swival.toml` `[mcp_servers.*]` with invalid structure: fatal `ConfigError` (same as any other config validation failure).

### Phase 3: Integration with agent loop

**In `session.py` (`_setup()`):**

1. Load MCP server configs (from toml config + `.mcp.json` / `--mcp-config`, respecting precedence)
2. If configs are non-empty and `--no-mcp` is not set:
   a. Create `McpManager(server_configs)`
   b. Call `manager.start()` — connects to all servers
   c. Get MCP tools via `manager.list_tools()`
   d. Append MCP tools to the built-in `self._tools` list
   e. Check token budget (warn/drop per token budget rules)
3. Store `self._mcp_manager` for dispatch and cleanup

**Make `Session` a context manager:**

```python
def __enter__(self):
    return self

def __exit__(self, *exc):
    if self._mcp_manager:
        self._mcp_manager.close()
```

Register `atexit` handler in `_setup()` as fallback for non-context-manager usage.

**In `tools.py` (`dispatch()`):**

Add early check: if tool name starts with `mcp__`, delegate to `mcp_manager.call_tool(name, args)`. The `mcp_manager` instance is passed through `dispatch()` kwargs.

```python
def dispatch(name, args, base_dir, **kwargs):
    mcp_manager = kwargs.get("mcp_manager")
    if name.startswith("mcp__") and mcp_manager:
        return mcp_manager.call_tool(name, args)
    # ... existing dispatch logic
```

**In `agent.py` (`handle_tool_call()` and `run_agent_loop()`):**

Thread `mcp_manager` through `run_agent_loop()` → `handle_tool_call()` → `dispatch()`. Catch `McpShutdownError` in `handle_tool_call()` and convert to a tool error result.

### Phase 4: System prompt integration

Add a section to the system prompt listing available MCP servers and their tools, similar to the skills catalog:

```
## MCP Tools

The following tools are provided by external MCP servers:

**filesystem** (local):
- mcp__filesystem__read_file: Read a file from the filesystem
- mcp__filesystem__write_file: Write content to a file

**github** (local):
- mcp__github__create_issue: Create a GitHub issue
```

Group by server so the LLM understands provenance.

### Phase 5: Token estimation and budget enforcement

MCP tools are included in the `tools` list passed to `estimate_tokens()`, so they're counted automatically. Budget enforcement (see "Token budget management" above):

1. After `list_tools()`, estimate token cost of all MCP tool schemas
2. At >30% of context: stderr warning with per-server breakdown
3. At >50% of context: drop largest server's tools, stderr error explaining which and why

### Phase 6: Compaction and tool results

MCP tool results flow through the same `compact_tool_result()` path as built-in tools. The generic fallback (truncate at 2000 chars) applies. Since MCP results are normalized to strings (see result handling rules), they compact identically to built-in tool results.

### Phase 7: Formatting and output

**In `fmt.py`:**

Add MCP-specific formatting:
- `mcp_server_start(name)` — shown during startup as servers connect
- `mcp_server_error(name, error)` — shown when a server fails to start or crashes
- `mcp_tool_call(server_name, tool_name, args)` — tool call header showing server provenance
- `mcp_tool_result(server_name, tool_name, result)` — tool result with server context

## File Changes Summary

| File | Change |
|------|--------|
| `swival/mcp_client.py` | **New.** `McpManager`, `McpShutdownError`, async loop management, schema conversion, result normalization, teardown |
| `swival/config.py` | Add `mcp_servers` config key, `load_mcp_json()`, `merge_mcp_configs()`, `validate_mcp_server_names()`, `--no-mcp` / `--mcp-config` |
| `swival/session.py` | Init `McpManager` in `_setup()`, `__enter__`/`__exit__` context manager, `atexit` fallback, pass to loop |
| `swival/tools.py` | Route `mcp__*` names in `dispatch()` |
| `swival/agent.py` | Thread `mcp_manager` through `run_agent_loop()` → `handle_tool_call()`, catch `McpShutdownError` |
| `swival/fmt.py` | MCP formatting helpers |
| `pyproject.toml` | Add `mcp` dependency |
| `tests/test_mcp.py` | **New.** Tests (see testing strategy) |

## Dependencies

Add to `pyproject.toml`:

```toml
[project.dependencies]
mcp = ">=1.26,<2"
```

Pin below 2.0 since the v2 pre-alpha has breaking API changes. Transitive deps: `anyio`, `pydantic`, `httpx`. Run `uv lock` and verify no conflicts with existing deps (litellm already pulls in httpx and pydantic, so overlap is likely harmless).

## Testing Strategy

- **Schema conversion tests**: Feed real-world MCP schemas (filesystem server, GitHub server, a server with `oneOf`/nested objects/enums) through conversion and verify valid OpenAI function-calling output. Include edge cases: missing `properties`, `$schema` present, `additionalProperties`, empty `required` list.
- **Result normalization tests**: Test each content block type (text, image, resource, audio), multi-block results, `isError=true`, empty content array, protocol errors.
- **Namespacing tests**: Server name validation (reject `__`, non-alphanumeric), tool name sanitization, routing table lookup, collision detection between MCP and built-in tool names. Post-sanitization collision: two distinct tool names that normalize to the same key should raise `ConfigError`.
- **Lifecycle tests**: Start → call_tool → close sequence, close during pending call_tool, double-close idempotency, close timeout behavior, atexit registration.
- **Config tests**: TOML parsing, `.mcp.json` parsing, precedence ordering (`--no-mcp` > `--mcp-config` > TOML > `.mcp.json`), name validation errors, merge behavior on collision. Error policy: `--mcp-config` pointing to missing file → `ConfigError`, malformed `.mcp.json` in project root → `ConfigError`, missing `.mcp.json` → silently ignored.
- **Dispatch integration tests**: Verify `mcp__*` routing in `dispatch()`, verify non-MCP tools unaffected, verify `McpShutdownError` handling.
- **Integration tests**: Spin up a minimal stdio MCP server (Python script implementing the protocol) and test full connect → list_tools → call_tool → close cycle.
- **Token budget tests**: Verify warning at >30%, verify iterative drop loop at >50% (drop largest, recompute, drop next if still over), verify all MCP servers dropped if none fit, verify built-in tools are never dropped.
- **Server failure tests**: Server fails to start (skipped), server crashes mid-session (degraded), malformed tool results (error string).

## Risks and Mitigations

| Risk | Mitigation |
|------|-----------|
| Async teardown hangs | Idempotent close with timeouts at every stage, daemon thread dies with process. Join timeout logged with residual server names for leak visibility. |
| MCP SDK is heavy (pydantic, httpx) | Already pulled in transitively by litellm; minimal incremental cost |
| Subprocess leaks on crash | `atexit` handler + `Session.__exit__` + SIGTERM→SIGKILL escalation |
| Tool name collisions | Validated namespacing with routing table + post-sanitization collision detection (fatal per-server) |
| Malicious MCP servers | Same trust model as `--yolo` / allowed commands — user configures what to trust |
| Large tool lists eating context | Iterative drop loop at 50% removes servers by descending token cost until under budget |
| Schema incompatibility with providers | Whitelist-of-removals approach, tested against real MCP server schemas |
| mcp SDK breaking changes | Pinned `<2`, tested in CI |
| Non-text tool results lost silently | Explicit placeholders for image/audio/resource blocks, never silently dropped |

## Open Questions — Resolved

**Q: Can swival assume MCP servers provide deterministic OpenAI-compatible schemas?**
A: No. The schema conversion layer handles the differences (missing `properties`, `$schema` keys, etc.). Tested against real server schemas.

**Q: Will MCP tool execution errors be surfaced as structured tool errors to the model?**
A: Yes. `isError=true` results get the `"error: "` prefix, matching swival's built-in convention. Protocol errors also produce `"error: "` strings. The LLM sees them the same way as built-in tool failures.

**Q: Should there be a policy for disabled MCP at runtime when a server crashes?**
A: Yes. Crashed servers enter a `degraded` state — their tools return immediate errors, no retry/reconnect. The LLM can work around it. Reconnection is a future extension.

## Future Extensions (not in this plan)

- MCP resources (read external data)
- MCP prompts (reusable templates)
- Dynamic tool refresh (`notifications/tools/list_changed`)
- Automatic reconnection for crashed servers
- OAuth authentication flow for remote servers
- `swival init --mcp` to scaffold config
- `--max-mcp-tools` flag for explicit tool count limits
- Making swival itself an MCP server
