Private
Public Access
0
0
Files
manual_slop/docs/guide_mcp_client.md
T
conductor-tier2 161ebb0da6 docs(fix): correct nav link case + relative-path level
Gitea (and any case-sensitive filesystem) was rendering the [Top]
nav links in /docs as broken because of two bugs:

1. Case-sensitivity: 22 links used '../README.md' (all-uppercase)
   but the actual file is 'docs/Readme.md' (capital R, lowercase
   rest). 21 guide_*.md nav bars were affected, plus 1 internal
   cross-link in Readme.md itself. Works on Windows (case-
   insensitive) but broken on Linux/Gitea.

   Fix: 22 occurrences across 22 files changed
   '../README.md' -> '../Readme.md'

2. Wrong relative-path level: 16 links used '../../conductor/...'
   from 'docs/guide_*.md' to reach 'conductor/'. This goes up 2
   levels to 'projects/', which doesn't exist. The correct path
   from 'docs/guide_*.md' to 'conductor/' is 1 level up
   ('../conductor/...'). 12 unique patterns across 10 files
   affected.

   Fix: 16 occurrences across 10 files changed
   '../../conductor/' -> '../conductor/'

3. Bonus: 1 planned-guide link in guide_context_curation.md
   referenced a never-written 'guide_context_presets.md'. The
   ContextPreset schema is now fully covered in the new
   'guide_context_aggregation.md' (per the 2026-06-08 docs
   refresh). Fix: link target updated.

No content was changed, only link paths. 24 files, 37 link
replacements, 37 deletions.

Verification:
- All .md links in docs/ now resolve to existing files
  (validated by path-resolution check from each file's directory)
- The 3 new guides from the previous docs refresh commit
  (guide_discussions.md, guide_state_lifecycle.md,
  guide_context_aggregation.md) had the case bug inherited from
  guide_architecture.md's existing nav pattern; their top-of-file
  nav bars are now correct
- The 21 pre-existing guide nav bars that had the same bug
  (all 21 of them, except the 3 that used the correct case:
  guide_mma.md, guide_simulations.md, guide_tools.md) are now
  also fixed
- Inter-guide links (e.g. [Discussions](guide_discussions.md))
  were not affected; they were always correct because both the
  link text and the actual filename are lowercase

This is a docs-only fix. No code modified.
2026-06-08 19:51:55 -04:00

15 KiB

src/mcp_client.py — MCP Tools (45 tools, 3-layer security)

Top | Architecture | Tools & IPC | Testing


Overview

src/mcp_client.py (~81KB) is the MCP (Model Context Protocol) tool implementation for Manual Slop. It provides 45 tools that the AI can invoke to read/write files, analyze code structure, search symbols, and more.

The module implements the client side of MCP — it provides the tools that an AI model can call during a conversation. It also implements the project's strict filesystem security model.


Architecture

┌─────────────────────────────────────────────────┐
│  ai_client.send(...)                              │
│  AI returns: { "name": "read_file", "args": {...} } │
└─────────────────┬───────────────────────────────┘
                  │ calls
                  ▼
┌─────────────────────────────────────────────────┐
│  mcp_client.dispatch(tool_name, tool_input)      │
│  Routes to the registered tool function           │
│  Returns tool result as string                    │
└─────────────────┬───────────────────────────────┘
                  │ calls (with security check)
                  ▼
┌─────────────────────────────────────────────────┐
│  3-Layer Security: Allowlist → Validate → Resolve │
└─────────────────┬───────────────────────────────┘
                  │ passes
                  ▼
┌─────────────────────────────────────────────────┐
│  The actual tool function (45 of them)            │
└─────────────────────────────────────────────────┘

The 3-Layer Security Model

Every filesystem access passes through 3 layers:

Layer 1: Allowlist Construction (configure)

Called by ai_client before each send cycle (and on project load):

def configure(file_items: list[dict], base_dirs: list[str]) -> None:
    """Build the allowlist from the project's tracked files and base dirs."""
    _allowed_paths.clear()
    _base_dirs.clear()
    if base_dirs:
        _primary_base_dir = Path(base_dirs[0]).resolve()
    for f in file_items:
        if isinstance(f, dict) and "path" in f:
            _allowed_paths.add(Path(f["path"]).resolve())
    # Blacklist: history.toml and config.toml are NEVER allowed

After this call, _allowed_paths contains every file the AI is allowed to touch.

Layer 2: Path Validation (_is_allowed)

Called on every path before any I/O:

def _is_allowed(path: Path) -> bool:
    """Return True if `path` is within the allowlist."""
    if path.name in {"history.toml", "config.toml", "credentials.toml"}:
        return False
    if "*_history.toml" in path.name:
        return False
    # ... checks against _allowed_paths and _base_dirs

Returns False for any path the AI is not allowed to touch.

Layer 3: Resolution Gate (_resolve_and_check)

The final gate. Resolves the path (handling symlinks, relative paths) and re-checks.

def _resolve_and_check(raw_path: str) -> tuple[Path | None, str]:
    """Resolve raw_path and verify it passes the allowlist check."""
    p = Path(raw_path).resolve()
    if not _is_allowed(p):
        return None, f"ERROR: path not in allowlist: {raw_path}"
    return p, ""

Every tool function calls this first. If it returns an error, the tool returns the error string to the AI.


The 45 Tools

The tools are organized by category. The full registered count is 45.

File I/O Tools (4)

Tool Parameters Description
read_file path UTF-8 file content extraction
list_directory path Compact table: [file/dir] name size. Applies blacklist filter.
search_files path, pattern Glob pattern matching within an allowed directory
get_file_summary path Heuristic summary via summarize.py (imports, classes, etc.)

File Edit Tools (3)

Tool Parameters Description
get_file_slice path, start_line, end_line Returns specific line range (1-based, inclusive)
set_file_slice path, start_line, end_line, new_content Replaces a line range with new content
edit_file path, old_string, new_string, replace_all Exact string match replace. Preserves indentation.

Python AST Tools (15)

Tool Parameters Description
py_get_skeleton path Skeleton: signatures + docstrings, bodies replaced with ...
py_get_code_outline path Hierarchical outline: classes, functions, methods with line ranges
py_get_symbol_info path, name (source, line_number) for a class/function/method
py_get_definition path, name Full source for a class/function/method
py_update_definition path, name, new_content Surgical replacement (locates via ast, delegates to set_file_slice)
py_get_signature path, name Just the def line through the colon
py_set_signature path, name, new_signature Replaces only the signature, preserving body
py_get_class_summary path, name Class docstring + method signatures
py_get_var_declaration path, name Module/class-level variable assignment line(s)
py_set_var_declaration path, name, new_declaration Surgical variable replacement
py_get_hierarchy path, class_name Subclasses of a given class
py_get_docstring path, name Docstring for module/class/function
py_get_imports path AST-parsed dependency list
py_find_usages path, name Exact string match search
py_check_syntax path Syntax validation via ast.parse()
py_remove_def path, name Excises a definition
py_add_def path, name, new_content, anchor_type, anchor_symbol Inserts with 1-space indent normalization
py_move_def src_path, dest_path, name, dest_name, anchor_type, anchor_symbol Relocates code
py_region_wrap path, start_line, end_line, region_name Wraps line range in #region / #endregion

C/C++ AST Tools (10)

Tool Parameters Description
ts_c_get_skeleton path C function signatures and struct definitions, bodies replaced
ts_cpp_get_skeleton path C++ class/struct/method signatures, inheritance
ts_c_get_code_outline path C outline
ts_cpp_get_code_outline path C++ outline with classes and inheritance
ts_c_get_definition path, name C struct or function source
ts_cpp_get_definition path, name C++ class, struct, or method source (supports Class::method)
ts_c_update_definition path, name, new_content Surgical C replacement
ts_cpp_update_definition path, name, new_content Surgical C++ replacement
ts_c_get_signature path, name C function/struct declaration
ts_cpp_get_signature path, name C++ method/function declaration

All C/C++ tools use tree-sitter (via src/file_cache.py's ASTParser).

Analysis Tools (3)

Tool Parameters Description
derive_code_path target, max_depth Traces execution path of a function across multiple files
py_get_imports path AST-parsed dependency list
py_find_usages path, name String match search

Network Tools (2)

Tool Parameters Description
web_search query DuckDuckGo HTML scrape via _DDGParser (HTMLParser subclass). Returns top 5 results.
fetch_url url Fetches URL, strips HTML via _TextExtractor

Runtime Tools (1)

Tool Parameters Description
get_ui_performance (none) FPS, Frame Time, CPU, Input Lag via injected perf_monitor_callback

Beads Tools (4)

Tool Parameters Description
bd_list (none) Lists all beads in active .beads/ repo
bd_create title, description Creates a new bead
bd_update bead_id, status Updates bead status
bd_ready (none) Lists beads with no unresolved dependencies

The dispatch Function

The single entry point for all tool calls.

def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str:
    """Dispatch an MCP tool call by name. Returns the result as a string."""

Returns the result as a string (errors included). The AI client receives this string and appends it to the conversation history.

Dispatch Flow

def dispatch(tool_name, tool_input):
    if tool_name.startswith("bd_"):
        return _dispatch_beads(tool_name, tool_input)
    if tool_name == "read_file":
        return _read_file(tool_input["path"])
    if tool_name == "py_get_skeleton":
        return _py_get_skeleton(tool_input["path"])
    # ... etc, one branch per tool ...
    return f"ERROR: unknown tool: {tool_name}"

The bd_* tools are dispatched separately because they require an active .beads/ repository.

Async Dispatch

async def async_dispatch(tool_name, tool_input) -> str:
    """Async version of dispatch. Uses asyncio.to_thread for blocking I/O."""
    return await asyncio.to_thread(dispatch, tool_name, tool_input)

For concurrent tool execution (when the AI emits multiple calls in one turn), the AI client uses asyncio.gather over async_dispatch.


The 3-Layer Security Details

Blacklist

Always blocked, regardless of allowlist:

  • history.toml
  • *_history.toml
  • config.toml
  • credentials.toml

These are matched by exact filename (no path component) in _is_allowed.

Allowlist Construction Order

def configure(file_items, base_dirs):
    # Reset
    _allowed_paths.clear()
    _base_dirs.clear()

    # Primary base dir from first entry
    if base_dirs:
        _primary_base_dir = Path(base_dirs[0]).resolve()
        _base_dirs.add(_primary_base_dir)

    # Add all file item paths
    for f in file_items:
        if isinstance(f, dict) and "path" in f:
            try:
                _allowed_paths.add(Path(f["path"]).resolve())
            except (OSError, ValueError):
                pass

_primary_base_dir is the first base_dir; it's used for relative-path resolution.

Resolution Edge Cases

_resolve_and_check handles:

  • Absolute paths (used as-is)
  • Relative paths (resolved against _primary_base_dir)
  • Symlinks (resolved via Path.resolve())
  • Windows path separators
  • UNC paths

If resolution fails (e.g., path doesn't exist), returns the error to the AI.


External MCP Servers

In addition to the 45 native tools, mcp_client.py manages external MCP servers via ExternalMCPManager:

StdioMCPServer (in src/mcp_client.py)

Manages a local MCP server via subprocess (stdin/stdout).

server = StdioMCPServer(
    name="my_server",
    command=["python", "-m", "my_mcp_server"],
    cwd="/path/to/server",
)
server.start()
tools = server.list_tools()  # Get the server's tool schemas
result = server.call_tool("tool_name", {"arg": "value"})
server.stop()

RemoteMCPServer (in src/mcp_client.py)

SSE-based remote MCP server integration. Foundation for connecting to remote MCP services.

ExternalMCPManager

Manages multiple server lifecycles:

manager = ExternalMCPManager()
manager.add_server(server_config)  # Stdio or Remote
tools = manager.get_all_tools()  # All tools from all servers
manager.stop_all()

The dispatch function transparently routes calls to external server tools as well as native ones.

JSON-RPC 2.0 Engine

External MCP servers use JSON-RPC 2.0 over their respective transports (stdio or SSE). The MCP client implements:

  • Request ID generation
  • Async request/response matching
  • Timeout handling
  • Error code mapping (JSON-RPC error codes → string error messages)

Public API

Function Purpose
configure(file_items, base_dirs) Build the allowlist
dispatch(tool_name, tool_input) Call a tool by name
async_dispatch(tool_name, tool_input) Async version
get_tool_schemas() -> list[dict] All tool schemas (for AI capability declaration)
is_allowed(path: str) -> bool Check if a path is allowed (for testing)
get_external_mcp_manager() -> ExternalMCPManager Get the singleton manager

Configuration

# config.toml
[mcp]
enabled = true
blacklist = ["*.pem", "*.key", ".env"]  # Additional patterns to always block
allow_symlinks = false

External MCP server config (mcp_config.json, standard format):

{
    "servers": [
        {
            "name": "filesystem",
            "command": ["python", "-m", "filesystem_mcp"],
            "env": {}
        }
    ]
}

Located at <user_config>/mcp_config.json or <project_root>/mcp_config.json.


Testing

Unit Tests

tests/test_arch_boundary_phase1.py::test_mcp_client_whitelist_enforcement — tests the security model.

tests/test_mcp_ts_integration.py — tests tree-sitter integration.

Mocking MCP

Tests that don't want real filesystem access can mock the dispatch:

from unittest.mock import patch

def test_my_code(monkeypatch):
    def fake_dispatch(name, args):
        if name == "read_file":
            return "mocked content"
        return ""
    monkeypatch.setattr("src.mcp_client.dispatch", fake_dispatch)
    # ... test code ...

Performance

  • File I/O: synchronous (blocks the calling thread). Use async_dispatch for parallel calls.
  • Tree-sitter parsing: ~10-50ms per file for typical Python files. Cached in _ast_cache (mtime-based).
  • Network tools (web_search, fetch_url): 100ms-2s depending on the network.

See Also