Skip to content

letscode-mcp

MCP (Model Context Protocol) client for letscode, shipped as a plugin. Configure servers in ~/.letscode/config.toml; their tools show up in /tools and the model can call them, same shape as any built-in tool.

For using the plugin, see the MCP guide. This page is about how it's built: the third worked example of the extension model, after letscode-memory (hook-based) and letscode-textual (out-of-tree frontend). It proves the contract for an external-service wrapper.

Install

See the user guide.

What it registers

One hook: letscode_register_tools. It reads config.sections["mcp"]["servers"], spawns each configured server, discovers their tools, and registers each as a letscode.agent.tools.Tool with a synthesised Pydantic input model.

The letscode_register_tools hook is the plugin's only extension surface: it adds no commands, skills, or lifecycle hooks. It uses the same hook the built-in read / write / edit / bash tools use.

The async-to-sync bridge

MCP is async by design: tool discovery requires await session.list_tools() before the plugin can call registry.add(...). The pluggy letscode_register_tools hook is sync (pluggy contract, no core change planned).

The plugin bridges the two with a single daemon thread owning a dedicated asyncio event loop:

  • At register time, for each [mcp.servers.<name>], an async coroutine on the bg loop spawns the stdio subprocess (stdio_client from mcp), creates a ClientSession, runs initialize() and list_tools(), bounded by the per-server timeout (default 10s). The sync register hook blocks on the bg loop until all servers finish or time out.
  • Each discovered MCP tool wraps as a Tool whose execute marshals session.call_tool(...) back to the bg loop via asyncio.run_coroutine_threadsafe.
  • The bg loop + all ClientSessions live for the process lifetime. An atexit handler closes sessions; on hard kill the subprocesses die with the parent (they're stdio children).

Why a thread + bg loop over asyncio.run() in the register hook: asyncio.run creates and tears down a loop, so any ClientSession opened during registration would be dead by the time the agent's loop tried to use it. A persistent bg loop is the simplest correct pattern; the cross-loop marshalling is one well-known idiom (run_coroutine_threadsafe).

Pydantic input models from JSON Schema

MCP tools declare their input as JSON Schema (Tool.inputSchema). letscode tools declare their input as a Pydantic BaseModel. The plugin synthesises a permissive Pydantic model at register time whose model_json_schema() returns the MCP tool's schema verbatim. The LLM sees the real schema, the MCP server validates on receive, and letscode doesn't double-validate:

class _McpParams(BaseModel):
    model_config = ConfigDict(extra="allow")

    @classmethod
    def model_json_schema(cls, **_kw) -> dict[str, Any]:
        return fixed_mcp_schema

The permissive extra="allow" accepts anything the LLM sends; the MCP server owns validation on the wire. That avoids a second source of truth that could disagree with the server.

Failure discipline

Same rule as the bash tool and T82's config loader: failures degrade, they never surface as a traceback. See the user guide's failure-modes table for the full list.

Source

The plugin lives at plugins/letscode-mcp/:

plugins/letscode-mcp/
├── pyproject.toml          # entry point + dev-only [tool.uv.sources] + mcp>=1.0
├── README.md
├── src/letscode_mcp/
│   ├── __init__.py
│   └── plugin.py           # one file: config read + bg loop + discovery + tool wrap (~250 lines)
└── tests/test_plugin.py    # 9 tests against the SDK mocked at the _spawn_server seam

The bg-loop + JSON-Schema-to-Pydantic shape is ~250 lines. Cross-referenced with: