# MCP tool output guard

## Problem

Built-in tools have layered output protections:

- `run_command`: caps capture at 1MB (`MAX_FILE_OUTPUT`), then saves to `.swival/cmd_output_*.txt` if the result exceeds 10KB inline (`MAX_INLINE_OUTPUT`). The model gets a short pointer message and can `read_file` to page through it.
- `read_file`: hard 50KB byte cap (`MAX_OUTPUT_BYTES`), line-number pagination via `offset`/`limit`.
- `list_files`, `grep`: capped at 100 results *and* 50KB bytes, with truncation hints.

MCP tools have **none of this**. `mcp_client.py:call_tool()` returns a normalized string with no size check. `tools.py:dispatch()` returns it as-is. The agent loop appends it directly into `messages`. If an MCP server returns, say, 500KB of text, it goes straight into context.

The only safety net is the *reactive* graduated compaction in the agent loop, which fires after the next `call_llm()` fails with `ContextOverflowError`. But:

1. The `compact_tool_result` generic fallback for MCP tools is `[name: compacted — N chars]` — complete data loss, no file saved.
2. A single enormous result can blow past all compaction levels in one shot if combined with existing context usage.
3. Even if compaction succeeds, the model loses all the data it just requested.

## Design

Apply the same save-to-file pattern that `run_command` uses, but at the MCP dispatch boundary in `tools.py` — right after `mcp_manager.call_tool()` returns and before the result enters the message list.

### 1. Structural error signaling from `call_tool`

**Problem**: Using `result.startswith("error:")` to detect errors is fragile — a legitimate MCP result whose text happens to begin with `"error:"` would bypass the guard.

**Fix**: Change `McpManager.call_tool()` to return `tuple[str, bool]` — `(result_text, is_error)`. The `_normalize_result` function already has a structural `result.isError` flag from the MCP SDK; `call_tool` currently collapses it into the prefix convention too early. By surfacing the boolean, the dispatch guard can decide without string parsing.

```python
# mcp_client.py — call_tool returns (str, bool) now
def call_tool(self, namespaced_name: str, arguments: dict) -> tuple[str, bool]:
    ...
    if namespaced_name not in self._tool_map:
        return (f"error: unknown MCP tool: {namespaced_name}", True)

    if server_name in self._degraded:
        return (f"error: MCP server {server_name!r} is unavailable ...", True)

    session = self._sessions.get(server_name)
    if session is None:
        return (f"error: MCP server {server_name!r} has no active session", True)

    try:
        result = self._run_sync(session.call_tool(original_name, arguments), timeout=120)
        return _normalize_result(result)
    except McpShutdownError:
        raise
    except Exception as e:
        self._degraded.add(server_name)
        return (f"error: MCP server {server_name!r} failed: {e}", True)
```

`_normalize_result` changes to return `tuple[str, bool]`, threading through `result.isError` and the envelope `ok: false` detection:

```python
def _normalize_result(result) -> tuple[str, bool]:
    # Envelope fast-path (unchanged logic, just returns tuple)
    ...
    if payload.get("ok") is False:
        return (f"error: {error_msg}", True)
    if payload.get("ok") is True and "result" in payload:
        return (json.dumps(payload["result"], ensure_ascii=False), False)

    # Block assembly (unchanged logic)
    ...
    if result.isError:
        return (f"error: {text}" if text else "error: MCP tool returned an error", True)
    return (text if text else "(empty result)", False)
```

The dispatch site in `tools.py` adapts:

```python
if name.startswith("mcp__"):
    mcp_manager = kwargs.get("mcp_manager")
    if mcp_manager is None:
        return f"error: MCP tool {name!r} called but no MCP manager is active"
    result, is_error = mcp_manager.call_tool(name, args)
    if not is_error:
        result = _guard_mcp_output(result, base_dir, name)
    return result
```

### 2. Guard function with hard cap before save

```python
def _guard_mcp_output(result: str, base_dir: str, tool_name: str) -> str:
    """Truncate + save MCP output that exceeds the inline limit."""
    size = len(result.encode("utf-8"))
    if size <= MAX_INLINE_OUTPUT:
        return result

    # Hard cap: truncate to MAX_FILE_OUTPUT (1MB) before writing to disk,
    # same as run_command's capture limit. Prevents a rogue server from
    # consuming unbounded memory/disk.
    was_truncated = False
    if size > MAX_FILE_OUTPUT:
        result = result.encode("utf-8")[:MAX_FILE_OUTPUT].decode("utf-8", errors="replace")
        was_truncated = True

    return _save_large_output(result, base_dir, tool_name=tool_name, was_truncated=was_truncated)
```

Two-tier approach matching `run_command`:
- `MAX_FILE_OUTPUT` (1MB) hard cap on what gets written to disk
- `MAX_INLINE_OUTPUT` (10KB) threshold for save-to-file vs inline

### 3. Error output hardening

Giant error payloads are a vector too. Even when `is_error=True`, apply a hard inline cap before returning:

```python
# In dispatch(), after the tuple destructure:
result, is_error = mcp_manager.call_tool(name, args)
if is_error:
    # Cap error output at MAX_INLINE_OUTPUT to prevent giant error payloads
    # from stuffing context. Errors don't get saved to file — they're
    # diagnostic, not data the model needs to page through.
    result_bytes = result.encode("utf-8")
    if len(result_bytes) > MAX_INLINE_OUTPUT:
        result = result_bytes[:MAX_INLINE_OUTPUT].decode("utf-8", errors="replace")
        result += "\n[error output truncated]"
    return result
result = _guard_mcp_output(result, base_dir, name)
return result
```

### 4. Generic pointer message in `_save_large_output`

Currently `_save_large_output` returns `"Command output too large for context"`. Add a `tool_name` kwarg so MCP saves don't say "command":

```python
def _save_large_output(output: str, base_dir: str, *,
                       tool_name: str | None = None,
                       was_truncated: bool = False) -> str:
    ...
    if tool_name:
        source_label = f"Tool output from {tool_name}"
    else:
        source_label = "Command output"

    saved_label = (
        "Output (possibly truncated) saved to"
        if was_truncated
        else "Full output saved to"
    )
    return (
        f"{source_label} too large for context ({size_kb:.1f}KB).\n"
        f"{saved_label}: {rel_path}\n"
        f"Use read_file to examine the output (supports offset and limit for pagination)."
    )
```

Existing `run_command` callers pass no `tool_name`, so they keep the `"Command output"` label. Existing tests in `tests/test_run_command.py` (lines 595, 649, 677) that assert `"Command output"` wording remain valid because they call `_save_large_output` without `tool_name`.

### 5. File naming: keep `cmd_output_*`, no migration

Reuse `cmd_output_*.txt` for MCP saves — `_save_large_output` already generates these, and `cleanup_old_cmd_outputs` already globs them. No new prefix, no migration risk, no test breakage.

### 6. MCP-aware `compact_tool_result`

Add a branch before the generic fallback so compaction preserves a useful head snippet:

```python
if name.startswith("mcp__"):
    head = content[:300]
    return (
        f"[{name}: {len(content)} chars — compacted]\n"
        f"First 300 chars:\n{head}"
    )
```

This goes after existing named-tool branches (`fetch_url`) and before the generic fallback in `agent.py:compact_tool_result`.

## Files to change

### `swival/mcp_client.py`

- `call_tool()` → return `tuple[str, bool]` instead of `str`
- `_normalize_result()` → return `tuple[str, bool]`, surfacing `result.isError` and envelope `ok: false`
- Every early-return error path in `call_tool` returns `(msg, True)`

### `swival/tools.py`

- `dispatch()` MCP branch → destructure `(result, is_error)`, apply error truncation cap, call `_guard_mcp_output` when not error
- Add `_guard_mcp_output(result, base_dir, tool_name)` near `_save_large_output`
- `_save_large_output` → add `tool_name: str | None = None` kwarg, use it in the pointer message label

### `swival/agent.py`

- `compact_tool_result()` → add `mcp__` branch before generic fallback

### `tests/test_mcp.py` — `TestNormalizeResult` (tuple return)

Every existing assertion changes from `== "string"` to `== ("string", bool)`:

| Test (line) | Current assertion | New assertion |
|---|---|---|
| `test_single_text` (153) | `== "hello world"` | `== ("hello world", False)` |
| `test_multiple_text_blocks` (162) | `== "line 1\nline 2"` | `== ("line 1\nline 2", False)` |
| `test_image_block` (170) | `== "[image: ...]"` | `== ("[image: ...]", False)` |
| `test_audio_block` (178) | `== "[audio: ...]"` | `== ("[audio: ...]", False)` |
| `test_resource_with_text` (183) | `== "resource content"` | `== ("resource content", False)` |
| `test_resource_without_text` (188) | `== "[resource: ...]"` | `== ("[resource: ...]", False)` |
| `test_is_error` (195) | `== "error: something went wrong"` | `== ("error: something went wrong", True)` |
| `test_is_error_empty` (199) | `== "error: MCP tool returned an error"` | `== ("error: MCP tool returned an error", True)` |
| `test_empty_result` (203) | `== "(empty result)"` | `== ("(empty result)", False)` |
| `test_unknown_block_type` (207) | `== "[video: ...]"` | `== ("[video: ...]", False)` |
| `test_mixed_content` (216) | `text = _normalize_result(result)` | `text, is_err = _normalize_result(result)` + `assert not is_err` |

### `tests/test_mcp.py` — `TestMcpManagerLifecycle` (tuple return)

| Test (line) | Current assertion | New assertion |
|---|---|---|
| `test_call_tool_unknown_name` (586) | `result = mgr.call_tool(...)` then `result.startswith("error:")` | `result, is_err = mgr.call_tool(...)` then `assert is_err` and `assert "unknown" in result` |
| `test_call_tool_degraded_server` (593) | `result = mgr.call_tool(...)` then `"unavailable" in result` | `result, is_err = mgr.call_tool(...)` then `assert is_err` and `assert "unavailable" in result` |

### `tests/test_mcp.py` — dispatch tests (tuple mock)

| Test (line) | Current mock | New mock |
|---|---|---|
| `test_mcp_prefix_routes_to_manager` (518) | `manager.call_tool.return_value = "tool result"` | `manager.call_tool.return_value = ("tool result", False)` |
| assertion (526) | `assert result == "tool result"` | unchanged (dispatch still returns `str`) |

### `tests/test_mcp.py` — new guard tests

All byte-boundary tests use explicit `len(s.encode("utf-8"))` assertions to avoid false positives with non-ASCII:

- **`test_guard_small_mcp_result_passthrough`**: result under `MAX_INLINE_OUTPUT` bytes → returned unchanged
- **`test_guard_at_limit_stays_inline`**: result of exactly `MAX_INLINE_OUTPUT` bytes → returned unchanged (use `"x" * MAX_INLINE_OUTPUT`, verify `len(result.encode("utf-8")) == MAX_INLINE_OUTPUT`)
- **`test_guard_over_limit_saves_to_file`**: result of `MAX_INLINE_OUTPUT + 1` bytes → saved, pointer returned containing tool name and `"read_file"`. Verify file exists in `.swival/`
- **`test_guard_large_result_pointer_message`**: result of 50KB → pointer says `"Tool output from mcp__server__tool"`, not `"Command output"`
- **`test_guard_over_max_file_truncates`**: result of `MAX_FILE_OUTPUT + 1000` bytes → file written contains exactly `MAX_FILE_OUTPUT` bytes, pointer says `"possibly truncated"`
- **`test_guard_error_passthrough_regardless_of_size`**: `is_error=True` + 50KB payload → result is inline (truncated at `MAX_INLINE_OUTPUT`), ends with `"[error output truncated]"`
- **`test_guard_error_small_passthrough`**: `is_error=True` + small payload → returned unchanged, no truncation suffix
- **`test_guard_disk_failure_fallback`**: mock `.swival/` creation to fail → result is inline-truncated to `MAX_INLINE_OUTPUT` bytes
- **`test_guard_non_ascii_boundary`**: use multibyte UTF-8 content near `MAX_INLINE_OUTPUT` → verify byte-based (not char-based) threshold behavior and clean UTF-8 decode at boundary

### `tests/test_compact.py` — new MCP compaction tests

- **`test_mcp_tool_compacted_with_head`**: `compact_tool_result("mcp__server__tool", {}, "x" * 2000)` → starts with `"[mcp__server__tool: 2000 chars"`, contains `"First 300 chars"`, contains first 300 chars of content
- **`test_mcp_tool_exactly_1000_unchanged`**: `compact_tool_result("mcp__server__tool", {}, "x" * 1000)` → returned unchanged (threshold is <=1000)
- **`test_mcp_tool_1001_compacted`**: `compact_tool_result("mcp__server__tool", {}, "x" * 1001)` → returns compacted form, not raw content

### `tests/test_run_command.py` — no changes needed

Existing tests (lines 595, 649, 677) call `_save_large_output` without `tool_name`, so they continue to assert `"Command output"` wording. The new `tool_name` kwarg defaults to `None`.

## Not in scope

- Per-server or per-tool configurable limits
- Streaming/chunked MCP output (the MCP SDK doesn't support this)
