;
+ readonly notify: (
+ type: NotificationType
,
+ params: P,
+ ) => Effect.Effect;
+ readonly notifications: (type: NotificationType
) => Stream.Stream
;
+ /** Handle a request the server sends to us; unhandled methods answer `MethodNotFound`. */
+ readonly onRequest:
(
+ type: RequestType
,
+ handler: (params: P) => Effect.Effect,
+ ) => Effect.Effect;
+ /** `window/logMessage` and `window/showMessage` traffic. */
+ readonly logs: Stream.Stream;
+ }
+>()("lsp-mcp/lsp/LanguageServer") {
+ static readonly layer = (options: Options) =>
+ Layer.effect(LanguageServer, make(options));
+}
+
+const make = Effect.fn("LanguageServer.make")(function* (options: Options) {
+ const run = yield* FiberSet.makeRuntime();
+ const runPromise = yield* FiberSet.makeRuntimePromise();
+ const exited = yield* Deferred.make();
+ const events = yield* PubSub.unbounded<{
+ readonly method: string;
+ readonly params: unknown;
+ }>();
+ const progress = yield* SubscriptionRef.make(
+ HashSet.empty(),
+ );
+ const exit = (message: string) =>
+ Deferred.doneUnsafe(
+ exited,
+ Effect.fail(new LspError({ method: "connection", message })),
+ );
+
+ const child = yield* Effect.acquireRelease(
+ Effect.sync(() =>
+ spawn(options.command, options.args, {
+ cwd: options.root,
+ stdio: "pipe",
+ }),
+ ),
+ (child) =>
+ Effect.sync(() => void (child.exitCode === null && child.kill())),
+ );
+ child.on("error", (error) =>
+ exit(`failed to start ${options.command}: ${error.message}`),
+ );
+ child.on("exit", (code, signal) =>
+ exit(`${options.command} exited (${signal ?? code})`),
+ );
+ yield* NodeStream.fromReadable({
+ evaluate: () => child.stderr,
+ onError: () => undefined as never,
+ }).pipe(
+ Stream.decodeText(),
+ Stream.splitLines,
+ Stream.runForEach((line) => Effect.logDebug(line)),
+ Effect.annotateLogs("source", `${options.command}:stderr`),
+ Effect.forkScoped,
+ );
+
+ const connection = createMessageConnection(
+ new StreamMessageReader(child.stdout),
+ new StreamMessageWriter(child.stdin),
+ {
+ error: (m) => run(Effect.logError(m)),
+ warn: (m) => run(Effect.logWarning(m)),
+ info: (m) => run(Effect.logInfo(m)),
+ log: (m) => run(Effect.logDebug(m)),
+ },
+ );
+ yield* Effect.addFinalizer(() => Effect.sync(() => connection.dispose()));
+ connection.onClose(() => exit(`${options.command} closed the connection`));
+ connection.onNotification((method, params) => {
+ PubSub.publishUnsafe(events, { method, params });
+ });
+ connection.onUnhandledProgress(({ token, value }) =>
+ run(
+ SubscriptionRef.update(progress, (active) =>
+ value.kind === "begin"
+ ? HashSet.add(active, token)
+ : value.kind === "end"
+ ? HashSet.remove(active, token)
+ : active,
+ ),
+ ),
+ );
+ connection.onRequest(WorkDoneProgressCreateRequest.type, () => undefined);
+ connection.onRequest(RegistrationRequest.type, () => undefined);
+ connection.onRequest(UnregistrationRequest.type, () => undefined);
+ connection.onRequest(ConfigurationRequest.type, ({ items }) =>
+ items.map((item) => section(options.settings, item.section)),
+ );
+ const rootUri = URI.file(options.root).toString();
+ const workspaceFolders = [
+ { uri: rootUri, name: options.root.split(/[\\/]/).pop() ?? options.root },
+ ];
+ connection.onRequest(WorkspaceFoldersRequest.type, () => workspaceFolders);
+ connection.listen();
+
+ const failure = (method: string, error: unknown) =>
+ error instanceof ResponseError
+ ? new LspError({ method, message: error.message, code: error.code })
+ : new LspError({
+ method,
+ message: error instanceof Error ? error.message : String(error),
+ });
+
+ const send = (type: RequestType
, params: P) =>
+ Effect.callback((resume) => {
+ const cancellation = new CancellationTokenSource();
+ try {
+ connection.sendRequest(type, params as never, cancellation.token).then(
+ (result) => resume(Effect.succeed(result)),
+ (error) => resume(Effect.fail(failure(type.method, error))),
+ );
+ } catch (error) {
+ resume(Effect.fail(failure(type.method, error)));
+ }
+ return Effect.ignore(Effect.try(() => cancellation.cancel()));
+ }).pipe(Effect.raceFirst(Deferred.await(exited)));
+
+ const idle = SubscriptionRef.changes(progress).pipe(
+ Stream.filter(HashSet.isEmpty),
+ Stream.runHead,
+ Effect.timeoutOption("15 seconds"),
+ Effect.asVoid,
+ );
+
+ const request = (type: RequestType
, params: P) =>
+ idle.pipe(
+ Effect.andThen(send(type, params)),
+ Effect.retry({
+ while: (error) => error.retryable,
+ schedule: Schedule.exponential("100 millis").pipe(
+ Schedule.upTo({ times: 5 }),
+ ),
+ }),
+ );
+
+ const notify =
(type: NotificationType
, params: P) =>
+ Effect.tryPromise({
+ try: () => connection.sendNotification(type, params as never),
+ catch: (error) => failure(type.method, error),
+ }).pipe(Effect.catch((error) => Effect.logWarning(error.message)));
+
+ const notifications =
(type: NotificationType
): Stream.Stream
=>
+ Stream.fromPubSub(events).pipe(
+ Stream.filter((event) => event.method === type.method),
+ Stream.map((event) => event.params as P),
+ );
+
+ const onRequest =
(
+ type: RequestType
,
+ handler: (params: P) => Effect.Effect,
+ ) =>
+ Effect.acquireRelease(
+ Effect.sync(() =>
+ connection.onRequest(
+ type.method,
+ (params: P): HandlerResult =>
+ runPromise(
+ handler(params).pipe(
+ Effect.mapError((error) =>
+ error instanceof ResponseError
+ ? error
+ : new ResponseError(
+ ErrorCodes.InternalError,
+ error instanceof Error ? error.message : String(error),
+ ),
+ ),
+ ),
+ ) as Promise,
+ ),
+ ),
+ (disposable) => Effect.sync(() => disposable.dispose()),
+ ).pipe(Effect.asVoid);
+
+ const initialized = yield* send(InitializeRequest.type, {
+ processId: process.pid,
+ clientInfo: { name: "lsp-mcp", version: "0.1.0" },
+ locale: "en",
+ rootPath: options.root,
+ rootUri,
+ workspaceFolders,
+ capabilities: clientCapabilities,
+ initializationOptions: options.initializationOptions as LSPAny,
+ });
+ yield* notify(InitializedNotification.type, {});
+ if (options.settings !== undefined) {
+ yield* notify(DidChangeConfigurationNotification.type, {
+ settings: options.settings as LSPAny,
+ });
+ }
+ yield* Effect.addFinalizer(() =>
+ Effect.tryPromise(() => connection.sendRequest(ShutdownRequest.type)).pipe(
+ Effect.timeout("3 seconds"),
+ Effect.andThen(
+ Effect.tryPromise(() =>
+ connection.sendNotification(ExitNotification.type),
+ ),
+ ),
+ Effect.andThen(Deferred.await(exited).pipe(Effect.timeout("2 seconds"))),
+ Effect.ignore,
+ ),
+ );
+
+ const logs = Stream.merge(
+ notifications(LogMessageNotification.type),
+ notifications(ShowMessageNotification.type),
+ ).pipe(
+ Stream.map(
+ ({ type, message }): LogMessage => ({
+ level: logLevels[type] ?? "info",
+ message,
+ }),
+ ),
+ );
+
+ return LanguageServer.of({
+ root: options.root,
+ capabilities: initialized.capabilities,
+ serverInfo: initialized.serverInfo,
+ request,
+ notify,
+ notifications,
+ onRequest,
+ logs,
+ });
+});
+
+const logLevels: Record = {
+ 1: "error",
+ 2: "warning",
+ 3: "info",
+ 4: "debug",
+ 5: "debug",
+};
+
+/** Resolve a dotted `section` path inside the configured settings. */
+const section = (settings: unknown, path: string | undefined): LSPAny =>
+ (path === undefined
+ ? settings
+ : path
+ .split(".")
+ .reduce(
+ (value, key) => (value as Record | null)?.[key],
+ settings,
+ )) as LSPAny;
+
+/**
+ * What we tell the server we can do. Deliberately narrow: no dynamic
+ * registration, no snippets, no watched files (servers fall back to their
+ * own watchers), UTF-16 positions only.
+ */
+const clientCapabilities: ClientCapabilities = {
+ general: {
+ positionEncodings: ["utf-16"],
+ markdown: { parser: "marked", version: "1.1.0" },
+ },
+ workspace: {
+ applyEdit: true,
+ workspaceEdit: {
+ documentChanges: true,
+ resourceOperations: ["create", "rename", "delete"],
+ failureHandling: "abort",
+ },
+ configuration: true,
+ workspaceFolders: true,
+ symbol: { symbolKind: { valueSet: allSymbolKinds() } },
+ didChangeConfiguration: { dynamicRegistration: false },
+ didChangeWatchedFiles: { dynamicRegistration: false },
+ },
+ window: {
+ workDoneProgress: true,
+ showMessage: {},
+ showDocument: { support: false },
+ },
+ textDocument: {
+ synchronization: {
+ didSave: true,
+ willSave: false,
+ willSaveWaitUntil: false,
+ },
+ publishDiagnostics: {
+ relatedInformation: true,
+ versionSupport: true,
+ codeDescriptionSupport: true,
+ dataSupport: true,
+ },
+ diagnostic: { dynamicRegistration: false, relatedDocumentSupport: false },
+ hover: { contentFormat: ["markdown", "plaintext"] },
+ completion: {
+ completionItem: {
+ snippetSupport: false,
+ documentationFormat: ["markdown", "plaintext"],
+ deprecatedSupport: true,
+ },
+ completionItemKind: {
+ valueSet: Array.from({ length: 25 }, (_, i) => (i + 1) as never),
+ },
+ contextSupport: true,
+ },
+ signatureHelp: {
+ signatureInformation: {
+ documentationFormat: ["markdown", "plaintext"],
+ parameterInformation: { labelOffsetSupport: true },
+ activeParameterSupport: true,
+ },
+ },
+ definition: { linkSupport: true },
+ typeDefinition: { linkSupport: true },
+ implementation: { linkSupport: true },
+ declaration: { linkSupport: true },
+ references: {},
+ documentSymbol: {
+ hierarchicalDocumentSymbolSupport: true,
+ symbolKind: { valueSet: allSymbolKinds() },
+ },
+ codeAction: {
+ codeActionLiteralSupport: {
+ codeActionKind: {
+ valueSet: [
+ "",
+ "quickfix",
+ "refactor",
+ "refactor.extract",
+ "refactor.inline",
+ "refactor.rewrite",
+ "source",
+ "source.organizeImports",
+ "source.fixAll",
+ ],
+ },
+ },
+ isPreferredSupport: true,
+ dataSupport: true,
+ resolveSupport: { properties: ["edit"] },
+ },
+ rename: { prepareSupport: true },
+ formatting: {},
+ callHierarchy: {},
+ },
+};
+
+function allSymbolKinds() {
+ return Array.from({ length: 26 }, (_, i) => (i + 1) as never);
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..dafca71
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,126 @@
+#!/usr/bin/env node
+/**
+ * CLI entry point.
+ *
+ * lsp-mcp [--stdio | --http ] [--root ] -- [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 command = Command.make(
+ "lsp-mcp",
+ {
+ transport: Flag.Int("http").pipe(
+ Flag.withDescription(
+ "Serve MCP over Streamable HTTP at http://localhost:/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),
+ ),
+ 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,
+ 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),
+ },
+ 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.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 }),
+);
diff --git a/test/Editor.test.ts b/test/Editor.test.ts
new file mode 100644
index 0000000..caae794
--- /dev/null
+++ b/test/Editor.test.ts
@@ -0,0 +1,171 @@
+import { NodeServices } from "@effect/platform-node";
+import { assert, describe, it } from "@effect/vitest";
+import { Effect, FileSystem, Layer } from "effect";
+import { RequestType } from "vscode-languageserver-protocol";
+import { Diagnostics } from "../src/lsp/Diagnostics.ts";
+import { Documents } from "../src/lsp/Documents.ts";
+import * as Editor from "../src/lsp/Editor.ts";
+import { LanguageServer } from "../src/lsp/LanguageServer.ts";
+import { workspace } from "./fixtures/workspace.ts";
+
+const State = new RequestType(
+ "fixture/state",
+);
+const Progress = new RequestType("fixture/progress");
+
+const TestLayer = Layer.mergeAll(
+ Documents.layer({ capacity: 2, languages: {} }),
+ Diagnostics.layer,
+).pipe(
+ Layer.provideMerge(
+ workspace({
+ "a.ts": "const alpha = 1\nexport const beta = alpha + alpha\n",
+ "b.ts": "// TODO fix\n",
+ "c.ts": "",
+ }),
+ ),
+ Layer.provideMerge(NodeServices.layer),
+);
+
+describe("Editor", () => {
+ it.layer(TestLayer, { excludeTestServices: true })(
+ "with the fixture server",
+ (it) => {
+ it.effect("addresses positions by 1-based line and symbol name", () =>
+ Effect.gen(function* () {
+ const byColumn = yield* Editor.hover({
+ file: "a.ts",
+ line: 2,
+ column: 22,
+ });
+ const bySymbol = yield* Editor.hover({
+ file: "a.ts",
+ line: 2,
+ symbol: "alpha",
+ });
+ assert.strictEqual(byColumn?.contents, "word **alpha**");
+ assert.strictEqual(bySymbol?.contents, "word **alpha**");
+
+ const refs = yield* Editor.references({
+ file: "a.ts",
+ line: 1,
+ symbol: "alpha",
+ });
+ assert.deepStrictEqual(
+ refs.map((r) => [r.line, r.column, r.preview]),
+ [
+ [1, 7, "const alpha = 1"],
+ [2, 21, "export const beta = alpha + alpha"],
+ [2, 29, "export const beta = alpha + alpha"],
+ ],
+ );
+ }),
+ );
+
+ it.effect("sees edits made on disk without being told", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const docs = yield* Documents;
+ const lsp = yield* LanguageServer;
+ const before = yield* Editor.diagnostics({ file: "b.ts" });
+ assert.deepStrictEqual(
+ before.map((d) => [d.severity, d.line]),
+ [["warning", 1]],
+ );
+
+ yield* fs.writeFileString(
+ docs.uri("b.ts").replace("file://", ""),
+ "// done\n// TODO more\n",
+ );
+ const after = yield* Editor.diagnostics({ file: "b.ts" });
+ assert.deepStrictEqual(
+ after.map((d) => [d.severity, d.line]),
+ [["warning", 2]],
+ );
+ assert.strictEqual((yield* lsp.request(State, null)).open, 2);
+ }),
+ );
+
+ it.effect("closes the least recently used document beyond capacity", () =>
+ Effect.gen(function* () {
+ const lsp = yield* LanguageServer;
+ const docs = yield* Documents;
+ yield* docs.open("a.ts");
+ yield* docs.open("b.ts");
+ yield* docs.open("c.ts");
+ yield* Effect.sleep("50 millis");
+ const state = yield* lsp.request(State, null);
+ assert.strictEqual(state.open, 2);
+ assert.isAtLeast(state.closes, 1);
+ assert.deepStrictEqual((yield* docs.opened).map(docs.file).sort(), [
+ "b.ts",
+ "c.ts",
+ ]);
+ }),
+ );
+
+ it.effect("previews a rename, and applies it only when asked", () =>
+ Effect.gen(function* () {
+ const docs = yield* Documents;
+ const preview = yield* Editor.rename({
+ file: "a.ts",
+ line: 1,
+ symbol: "alpha",
+ newName: "first",
+ });
+ assert.isFalse(preview.applied);
+ assert.strictEqual(preview.files[0]?.edits.length, 3);
+ assert.include((yield* docs.open("a.ts")).getText(), "alpha");
+
+ const applied = yield* Editor.rename({
+ file: "a.ts",
+ line: 1,
+ symbol: "alpha",
+ newName: "first",
+ apply: true,
+ });
+ assert.isTrue(applied.applied);
+ const text = (yield* docs.peek(docs.uri("a.ts"))).getText();
+ assert.strictEqual(
+ text,
+ "const first = 1\nexport const beta = first + first\n",
+ );
+ assert.strictEqual(
+ (yield* Editor.hover({ file: "a.ts", line: 1, symbol: "first" }))
+ ?.contents,
+ "word **first**",
+ );
+ }),
+ );
+
+ it.effect("waits for the server's background work before asking", () =>
+ Effect.gen(function* () {
+ const lsp = yield* LanguageServer;
+ yield* lsp.request(Progress, 300);
+ const started = Date.now();
+ const symbols = yield* Editor.documentSymbols({ file: "c.ts" });
+ assert.isAtLeast(Date.now() - started, 250);
+ assert.deepStrictEqual(symbols, []);
+ }),
+ );
+
+ it.effect("explains what the server cannot do", () =>
+ Effect.gen(function* () {
+ const error = yield* Effect.flip(
+ Editor.definition({
+ file: "a.ts",
+ line: 1,
+ symbol: "alpha",
+ kind: "implementation",
+ }),
+ );
+ assert.strictEqual(error._tag, "LspError");
+ assert.match(
+ error.message,
+ /does not support implementationProvider/,
+ );
+ }),
+ );
+ },
+ );
+});
diff --git a/test/Server.test.ts b/test/Server.test.ts
new file mode 100644
index 0000000..6d66527
--- /dev/null
+++ b/test/Server.test.ts
@@ -0,0 +1,144 @@
+import { NodeServices } from "@effect/platform-node";
+import { assert, describe, it } from "@effect/vitest";
+import { Effect, Layer } from "effect";
+import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai";
+import {
+ FetchHttpClient,
+ HttpClient,
+ HttpClientRequest,
+ HttpRouter,
+} from "effect/unstable/http";
+import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
+import { Diagnostics } from "../src/lsp/Diagnostics.ts";
+import { Documents } from "../src/lsp/Documents.ts";
+import * as Resources from "../src/Resources.ts";
+import * as Server from "../src/Server.ts";
+import { workspace } from "./fixtures/workspace.ts";
+
+const mcp = McpServer.layerHttp({
+ name: "test",
+ version: "0.0.0",
+ path: "/mcp",
+ protocols: [McpProtocol.v2025_06_18],
+});
+
+const app = Layer.mergeAll(Server.tools, Resources.layer).pipe(
+ Layer.provide(mcp),
+ Layer.provide(
+ Layer.mergeAll(
+ Documents.layer({ capacity: 4, languages: {} }),
+ Diagnostics.layer,
+ ),
+ ),
+ Layer.provide(workspace({ "a.ts": "const alpha = 1\n" })),
+ Layer.provide(NodeServices.layer),
+);
+
+/** An MCP client talking to the app over in-memory HTTP. */
+const client = Effect.gen(function* () {
+ const { dispose, handler } = HttpRouter.toWebHandler(app, {
+ disableLogger: true,
+ });
+ yield* Effect.addFinalizer(() => Effect.promise(dispose));
+ const headers = new Map();
+ const fetch: typeof globalThis.fetch = Object.assign(
+ async (input: RequestInfo | URL, init?: RequestInit) => {
+ const request = new Request(input, init);
+ for (const [name, value] of headers) request.headers.set(name, value);
+ const response = await handler(request);
+ for (const name of ["Mcp-Session-Id", "Mcp-Protocol-Version"]) {
+ const value = response.headers.get(name);
+ if (value !== null) headers.set(name, value);
+ }
+ return response;
+ },
+ { preconnect() {} },
+ );
+ const transport = RpcClient.layerProtocolHttp({
+ url: "http://localhost/mcp",
+ transformClient: HttpClient.mapRequest(
+ HttpClientRequest.setHeader(
+ "accept",
+ "application/json, text/event-stream",
+ ),
+ ),
+ }).pipe(
+ Layer.provideMerge([
+ FetchHttpClient.layer,
+ RpcSerialization.layerJsonRpc(),
+ ]),
+ Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch)),
+ );
+ const client = yield* RpcClient.make(McpSchema.ClientRpcs).pipe(
+ Effect.provide(transport),
+ );
+ yield* client.initialize({
+ protocolVersion: "2025-06-18",
+ capabilities: {},
+ clientInfo: { name: "test", version: "0" },
+ });
+ return client;
+});
+
+describe("MCP server", () => {
+ it.live(
+ "offers only the tools the language server supports, and serves them",
+ () =>
+ Effect.gen(function* () {
+ const mcp = yield* client;
+ const { tools } = yield* mcp["tools/list"]({});
+ assert.deepStrictEqual(tools.map((t) => t.name).sort(), [
+ "definition",
+ "diagnostics",
+ "document_symbols",
+ "hover",
+ "references",
+ "rename",
+ ]);
+ assert.isTrue(
+ tools.find((t) => t.name === "hover")?.annotations?.readOnlyHint,
+ );
+ assert.isFalse(
+ tools.find((t) => t.name === "rename")?.annotations?.readOnlyHint,
+ );
+
+ const result = yield* mcp["tools/call"]({
+ name: "hover",
+ arguments: { file: "a.ts", line: 1, symbol: "alpha" },
+ });
+ assert.deepStrictEqual(result.structuredContent, {
+ contents: "word **alpha**",
+ });
+
+ const failure = yield* mcp["tools/call"]({
+ name: "hover",
+ arguments: { file: "missing.ts", line: 1 },
+ });
+ assert.isTrue(failure.isError);
+ assert.match(JSON.stringify(failure.content), /missing\.ts/);
+ }).pipe(Effect.scoped),
+ );
+
+ it.live("exposes the server and its diagnostics as resources", () =>
+ Effect.gen(function* () {
+ const mcp = yield* client;
+ const { resources } = yield* mcp["resources/list"]({});
+ assert.deepStrictEqual(resources.map((r) => r.uri).sort(), [
+ "lsp://diagnostics",
+ "lsp://server",
+ ]);
+ const server = yield* mcp["resources/read"]({ uri: "lsp://server" });
+ const contents = server.contents[0];
+ assert.isTrue(
+ contents !== undefined &&
+ "text" in contents &&
+ JSON.parse(contents.text).name === "fixture",
+ );
+ const { resourceTemplates } = yield* mcp["resources/templates/list"]({});
+ assert.deepStrictEqual(
+ resourceTemplates.map((t) => t.uriTemplate),
+ ["lsp://diagnostics/{param0}"],
+ );
+ }).pipe(Effect.scoped),
+ );
+});
diff --git a/test/fixtures/server.ts b/test/fixtures/server.ts
new file mode 100644
index 0000000..a117a4f
--- /dev/null
+++ b/test/fixtures/server.ts
@@ -0,0 +1,160 @@
+/**
+ * A miniature language server used by the tests. Words are symbols: hover
+ * describes the word under the cursor, definition is its first occurrence,
+ * references and rename cover every occurrence, and each line containing
+ * "TODO" yields a warning. `fixture/state` reports open/close counts.
+ */
+import {
+ createConnection,
+ DiagnosticSeverity,
+ type Position,
+ TextDocumentSyncKind,
+ TextDocuments,
+ WorkDoneProgress,
+} from "vscode-languageserver/node";
+import { TextDocument } from "vscode-languageserver-textdocument";
+
+const connection = createConnection(process.stdin, process.stdout);
+const documents = new TextDocuments(TextDocument);
+let closes = 0;
+let progressToken = 0;
+
+const wordAt = (doc: TextDocument, position: Position) => {
+ const text = doc.getText();
+ const offset = doc.offsetAt(position);
+ let start = offset;
+ let end = offset;
+ while (start > 0 && /\w/.test(text.charAt(start - 1))) start--;
+ while (end < text.length && /\w/.test(text.charAt(end))) end++;
+ return start === end ? undefined : text.slice(start, end);
+};
+
+const occurrences = (doc: TextDocument, word: string) => {
+ const text = doc.getText();
+ const ranges = [];
+ const pattern = new RegExp(`\\b${word}\\b`, "g");
+ for (const match of text.matchAll(pattern)) {
+ ranges.push({
+ start: doc.positionAt(match.index),
+ end: doc.positionAt(match.index + word.length),
+ });
+ }
+ return ranges;
+};
+
+documents.onDidClose(() => closes++);
+documents.onDidChangeContent(({ document }) => {
+ const diagnostics = document
+ .getText()
+ .split("\n")
+ .flatMap((line, i) =>
+ line.includes("TODO")
+ ? [
+ {
+ range: {
+ start: { line: i, character: 0 },
+ end: { line: i, character: line.length },
+ },
+ severity: DiagnosticSeverity.Warning,
+ message: "unresolved TODO",
+ source: "fixture",
+ },
+ ]
+ : [],
+ );
+ void connection.sendDiagnostics({
+ uri: document.uri,
+ version: document.version,
+ diagnostics,
+ });
+});
+
+connection.onInitialize(() => ({
+ serverInfo: { name: "fixture", version: "1.0.0" },
+ capabilities: {
+ textDocumentSync: {
+ openClose: true,
+ change: TextDocumentSyncKind.Full,
+ save: { includeText: true },
+ },
+ hoverProvider: true,
+ definitionProvider: true,
+ referencesProvider: true,
+ renameProvider: true,
+ documentSymbolProvider: true,
+ },
+}));
+
+connection.onHover(({ textDocument, position }) => {
+ const doc = documents.get(textDocument.uri);
+ const word = doc && wordAt(doc, position);
+ return word
+ ? { contents: { kind: "markdown", value: `word **${word}**` } }
+ : null;
+});
+
+connection.onDefinition(({ textDocument, position }) => {
+ const doc = documents.get(textDocument.uri);
+ const word = doc && wordAt(doc, position);
+ const range = word && occurrences(doc, word)[0];
+ return range ? { uri: textDocument.uri, range } : null;
+});
+
+connection.onReferences(({ textDocument, position }) => {
+ const doc = documents.get(textDocument.uri);
+ const word = doc && wordAt(doc, position);
+ return word
+ ? occurrences(doc, word).map((range) => ({ uri: textDocument.uri, range }))
+ : [];
+});
+
+connection.onRenameRequest(({ textDocument, position, newName }) => {
+ const doc = documents.get(textDocument.uri);
+ const word = doc && wordAt(doc, position);
+ if (!word) return null;
+ return {
+ documentChanges: [
+ {
+ textDocument: { uri: textDocument.uri, version: doc.version },
+ edits: occurrences(doc, word).map((range) => ({
+ range,
+ newText: newName,
+ })),
+ },
+ ],
+ };
+});
+
+connection.onDocumentSymbol(({ textDocument }) => {
+ const doc = documents.get(textDocument.uri);
+ if (!doc) return [];
+ const words = new Set(doc.getText().match(/\b[A-Za-z_]\w*\b/g) ?? []);
+ return [...words].flatMap((name) =>
+ occurrences(doc, name)
+ .slice(0, 1)
+ .map((range) => ({ name, kind: 13, range, selectionRange: range })),
+ );
+});
+
+connection.onRequest("fixture/state", () => ({
+ open: documents.all().length,
+ closes,
+}));
+connection.onRequest("fixture/progress", async (ms: number) => {
+ const token = `work-${progressToken++}`;
+ void connection.sendProgress(WorkDoneProgress.type, token, {
+ kind: "begin",
+ title: "indexing",
+ });
+ setTimeout(
+ () =>
+ void connection.sendProgress(WorkDoneProgress.type, token, {
+ kind: "end",
+ }),
+ ms,
+ );
+ return token;
+});
+
+documents.listen(connection);
+connection.listen();
diff --git a/test/fixtures/workspace.ts b/test/fixtures/workspace.ts
new file mode 100644
index 0000000..77a4fa0
--- /dev/null
+++ b/test/fixtures/workspace.ts
@@ -0,0 +1,23 @@
+import { fileURLToPath } from "node:url";
+import { Effect, FileSystem, Layer, Path } from "effect";
+import { LanguageServer } from "../../src/lsp/LanguageServer.ts";
+
+const server = fileURLToPath(new URL("./server.ts", import.meta.url));
+
+/** A temporary workspace with the given files, served by the fixture language server. */
+export const workspace = (files: Record) =>
+ Layer.unwrap(
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const root = yield* fs.makeTempDirectoryScoped({ prefix: "lsp-mcp-" });
+ for (const [name, content] of Object.entries(files)) {
+ yield* fs.writeFileString(path.join(root, name), content);
+ }
+ return LanguageServer.layer({
+ command: process.execPath,
+ args: [server],
+ root,
+ });
+ }),
+ );
diff --git a/tsconfig.json b/tsconfig.json
index b466d7f..f69c328 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,50 +2,27 @@
"$schema": "./node_modules/@effect/tsgo/schema.json",
"compilerOptions": {
"noEmit": true,
- // File Layout
- // "rootDir": "./src",
- // "outDir": "./dist",
-
- // Environment Settings
- // See also https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "esnext",
- "types": [],
- // For nodejs:
- // "lib": ["esnext"],
- // "types": ["node"],
- // and npm install -D @types/node
-
- // Other Outputs
- "sourceMap": true,
- "declaration": true,
- "declarationMap": true,
-
- // Stricter Typechecking Options
+ "types": ["node"],
+ "allowImportingTsExtensions": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
-
- // Style Options
"noImplicitReturns": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
-
- // Recommended Options
"strict": true,
- "jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
+ "erasableSyntaxOnly": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
- "plugins": [
- {
- "name": "@effect/language-service"
- }
- ]
+ "plugins": [{ "name": "@effect/language-service" }]
},
+ "include": ["src", "test", "vitest.config.ts"],
"exclude": ["node_modules", "repos"]
}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..8085e12
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["test/**/*.test.ts"],
+ testTimeout: 20_000,
+ },
+});