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:
18 files changed
+3611
-38
No files matched your search
@@ -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/,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
Vendored
+160
@@ -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();
|
||||
Vendored
+23
@@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
Reference in new issue
Block a user