Skip to content
titah

Capabilities

MCP & language servers

Two protocols, one transport — what is supported, what deliberately is not, and why diagnostics are appended automatically.

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.
  • diagnostics runs 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.