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_clientfrommcp), creates aClientSession, runsinitialize()andlist_tools(), bounded by the per-servertimeout(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
Toolwhoseexecutemarshalssession.call_tool(...)back to the bg loop viaasyncio.run_coroutine_threadsafe. - The bg loop + all
ClientSessions live for the process lifetime. Anatexithandler 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:
- Design spec (T84): the seven decisions the impl executed against.
- Integration test (T86): drives a real in-tree MCP JSON-RPC server end-to-end via
FakeLLMClient. plugins/letscode-memory/: the sibling plugin (hook-based, in-tree, ~200 lines). Shows the same skeleton without the async bridge.