Both MCP and LSP speak JSON-RPC 2.0 over a subprocess’s stdio, so they share one
transport (src/core/rpc.ts). They differ only in framing: LSP uses
Content-Length headers, MCP uses one JSON object per line.
{
"mcp": { "github": { "command": "mcp-server-github", "args": [] } },
"lsp": {
"ts": {
"command": "typescript-language-server",
"args": ["--stdio"],
"extensions": [".ts", ".tsx"]
}
}
}
MCP: stdio only, tools only
That is what closes the gap, and saying so is better than half-building everything. Resources, prompts and the other transports are not implemented.
Tools are namespaced <server>_<tool>, because two servers both offering
search is ordinary and the second would otherwise silently replace the first.
They sit on their own mcp permission axis. An MCP server is code Titah did
not write and cannot classify, so forcing it onto edit or bash would ask you
to approve something other than what happens.
A server that fails to start loses its tools and says why — it never brings the turn down with it.
{ "permission": { "mcp": "ask" } }
A server that dies mid-request
The same holds for one that dies while it is being written to, which used to
take the whole of Titah with it. A write checks first whether the child is still
alive, but that is a snapshot: a payload larger than the 64 KB pipe buffer — an
MCP tool result, a didOpen carrying a whole file — lands after the check, and
its EPIPE arrives asynchronously as an error event on the stream, which Node
treats as unhandled if nothing is listening.
The request is now rejected with a real reason instead of hanging for the thirty-second timeout. The message carries the server’s exit code and stderr, and says whether a request was in flight when it died — which is what explains why the answer never came.
Language servers: a checker, not an editor
Language servers give automatic diagnostics: after edit, patch, write
or move, the diagnostics for that file are appended to the tool result, so the
model sees the error it just introduced without having to remember to look.
Only initialize, didOpen and publishDiagnostics are used. A full LSP client
would be an editor; what is needed here is a checker.
No diagnostics at all is reported as unknown, deliberately distinct from an
empty list — which means checked and clean. Collapsing the two would let a
language server that never started look exactly like a file with no problems.
Configuring one
{
"lsp": {
"ts": {
"command": "typescript-language-server",
"args": ["--stdio"],
"extensions": [".ts", ".tsx"]
},
"py": {
"command": "pyright-langserver",
"args": ["--stdio"],
"extensions": [".py"]
}
}
}
extensions decides which server is asked about which file. A file whose
extension matches nothing configured gets no diagnostics, reported as unknown.
LSP versus the diagnostics tool
They overlap on purpose and answer different questions.
- LSP is per-file and automatic, after every write. It catches the error you just introduced, in the file you just touched, without anyone asking.
diagnosticsruns your whole project’s checker (npm run typecheck,cargo check, …) when the model decides it is worth doing. It catches the error you introduced somewhere else.
Use both. Neither replaces the other, and diagnostics never guesses its command
— see Tools.
Checking what loaded
titah doctor
Reports which MCP servers started, which tools they contributed, which failed
and why, and which language servers were found on PATH.