lsp-mcp: headless-editor MCP server over a language server

Effect 4 MCP server (stdio/HTTP) bridging one LSP process: on-demand document
sync with LRU and disk change detection, push/pull diagnostics reconciliation,
capability-gated tools, diagnostics resources, tests against a fixture server.
This commit is contained in:
EFLKumo committed 2026-09-20 15:36:30 +08:00
1 parent aa52c92359
commit 1b7678ad3d
19 files changed
+3693 -38

No files matched your search

+171
View File
@@ -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<null, { open: number; closes: number }, void>(
"fixture/state",
);
const Progress = new RequestType<number, string, void>("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/,
);
}),
);
},
);
});
+144
View File
@@ -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<string, string>();
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),
);
});
+160
View File
@@ -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();
+23
View File
@@ -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<string, string>) =>
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,
});
}),
);