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.
149 lines
4.5 KiB
JavaScript
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 }),
|
|
);
|