Files
lsp-mcp/src/main.ts
T
EFLKumo 1463527870 Restart a crashed language server per --restart never|always|N
The process is supervised: on exit after a successful initialize it is
spawned again with exponential backoff (500ms to 30s), open documents are
re-sent as didOpen before the new session is announced, and requests caught
by the crash wait for it and retry. A crash more than a minute after the
previous one starts a fresh streak; reaching the streak limit (default 3)
ends restarting for good, after which every request fails with an LspError
saying so. Restarts and give-ups are logged and forwarded to the MCP client.

Writes to the child's stdin go through a PassThrough so a broken pipe ends
the session instead of surfacing as an unhandled rejection.
2026-09-20 15:36:30 +08:00

149 lines
4.5 KiB
JavaScript

#!/usr/bin/env node
/**
* CLI entry point.
*
* lsp-mcp [--stdio | --http <port>] [--root <dir>] -- <server> [args...]
*/
import { NodeRuntime, NodeServices } from "@effect/platform-node";
import { Cause, Effect, Layer, Option, Path, References, Schema } from "effect";
import { Argument, Command, Flag } from "effect/unstable/cli";
import * as Server from "./Server.ts";
const Json = Flag.withSchema(Schema.fromJsonString(Schema.Json));
const crashes = { message: "expected a whole number of crashes" };
const Restart = Schema.Union([
Schema.Literals(["never", "always"]),
Schema.NumberFromString.check(
Schema.isInt(crashes),
Schema.isGreaterThanOrEqualTo(0, crashes),
),
]);
const command = Command.make(
"lsp-mcp",
{
transport: Flag.Int("http").pipe(
Flag.withDescription(
"Serve MCP over Streamable HTTP at http://localhost:<port>/mcp",
),
Flag.map((port) => ({ _tag: "http", port }) as const),
Flag.orElse(() =>
Flag.Boolean("stdio").pipe(
Flag.withDescription("Serve MCP over stdio (the default)"),
Flag.withDefault(false),
Flag.map(() => ({ _tag: "stdio" }) as const),
),
),
),
root: Flag.Directory("root", { mustExist: true }).pipe(
Flag.withAlias("r"),
Flag.withDescription("Workspace root handed to the language server"),
Flag.withDefault("."),
),
initOptions: Flag.String("init-options").pipe(
Json,
Flag.withDescription(
"JSON passed to the language server as initializationOptions",
),
Flag.optional,
),
settings: Flag.String("settings").pipe(
Json,
Flag.withDescription(
"JSON served as workspace/configuration and sent as didChangeConfiguration",
),
Flag.optional,
),
languages: Flag.KeyValuePair("language").pipe(
Flag.withDescription(
"Extra file extension to languageId mapping, e.g. --language vue=vue",
),
Flag.withDefault({}),
),
capacity: Flag.Int("open-documents").pipe(
Flag.withDescription(
"Documents kept open in the language server before the least recently used is closed",
),
Flag.withDefault(32),
),
restart: Flag.String("restart").pipe(
Flag.withSchema(Restart),
Flag.withMetavar("never|always|N"),
Flag.withDescription(
"When the language server crashes: never start it again, always start it again, or give up after N crashes in a row (default 3)",
),
Flag.withDefault(3),
),
command: Argument.String("server").pipe(
Argument.withDescription("Language server executable"),
),
args: Argument.String("args").pipe(
Argument.withDescription(
"Arguments for the language server; put `--` before them",
),
Argument.variadic(),
),
},
Effect.fn(function* ({
transport,
root,
initOptions,
settings,
languages,
capacity,
restart,
command,
args,
}) {
const path = yield* Path.Path;
return yield* Layer.launch(
Server.layer({
transport,
server: {
// a relative executable path is relative to where lsp-mcp was started, not to the root
command: command.includes("/") ? path.resolve(command) : command,
args,
root: path.resolve(root),
initializationOptions: Option.getOrUndefined(initOptions),
settings: Option.getOrUndefined(settings),
restart,
},
documents: { capacity, languages },
}),
);
}),
).pipe(
Command.withDescription(
"Expose a Language Server to MCP clients as a headless editor",
),
Command.withExamples([
{
command: "lsp-mcp -- typescript-language-server --stdio",
description: "TypeScript over stdio",
},
{
command: "lsp-mcp --http 3000 --root ./crate -- rust-analyzer",
description: "Rust over HTTP",
},
{
command: `lsp-mcp --settings '{"gopls":{"staticcheck":true}}' -- gopls`,
description: "Go with workspace settings",
},
{
command: "lsp-mcp --restart always -- clangd",
description: "Keep bringing the server back, however often it crashes",
},
]),
);
command.pipe(
Command.run({ version: "0.1.0" }),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logError(cause),
),
// stdout may be the MCP channel, so every log line goes to stderr
Effect.provideService(References.LogToStderr, true),
Effect.provide(NodeServices.layer),
NodeRuntime.runMain({ disableErrorReporting: true }),
);