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-19 09:16:51 +08:00
1 parent 90eded8415
commit 3dc7ca6c80
18 files changed
+3611 -38

No files matched your search

+366
View File
@@ -0,0 +1,366 @@
/**
* The headless editor's document store.
*
* Documents are opened in the language server on demand and kept in an LRU
* set; the least recently used one is closed when the capacity is exceeded.
* Every access re-checks the file on disk and, if it changed, pushes the new
* content as `didChange` + `didSave`, so edits agents make with other tools
* are visible to the server without any explicit synchronization step.
*/
import {
Context,
Duration,
Effect,
Exit,
FileSystem,
Layer,
Option,
Path,
type PlatformError,
Schema,
ScopedCache,
Semaphore,
} from "effect";
import {
ApplyWorkspaceEditRequest,
CreateFile,
DeleteFile,
DidChangeTextDocumentNotification,
DidCloseTextDocumentNotification,
DidOpenTextDocumentNotification,
DidSaveTextDocumentNotification,
RenameFile,
type TextDocumentEdit,
type TextEdit,
type WorkspaceEdit,
} from "vscode-languageserver-protocol";
import { TextDocument } from "vscode-languageserver-textdocument";
import { URI } from "vscode-uri";
import { LanguageServer } from "./LanguageServer.ts";
export interface Options {
/** How many documents stay open in the server before the least recently used is closed. */
readonly capacity: number;
/** Extra file extension (without dot) to LSP `languageId` mappings. */
readonly languages: Readonly<Record<string, string>>;
}
export class DocumentError extends Schema.TaggedError<DocumentError>()(
"DocumentError",
{
file: Schema.String,
message: Schema.String,
},
) {}
export type Change = TextDocumentEdit | CreateFile | RenameFile | DeleteFile;
export class Documents extends Context.Service<
Documents,
{
/** URI for a path that is absolute or relative to the workspace root. */
readonly uri: (file: string) => string;
/** Display path for a URI: relative to the root when inside it. */
readonly file: (uri: string) => string;
/** Open (or refresh) a document in the server and return its current text. */
readonly open: (file: string) => Effect.Effect<TextDocument, DocumentError>;
/** Current content of a URI without opening it in the server. */
readonly peek: (uri: string) => Effect.Effect<TextDocument, DocumentError>;
/** Replace an open document's content on disk and in the server. */
readonly write: (
uri: string,
text: string,
) => Effect.Effect<void, DocumentError>;
/** Apply a server-provided edit to disk, in order, refusing stale versions. */
readonly applyEdit: (
edit: WorkspaceEdit,
) => Effect.Effect<void, DocumentError>;
readonly opened: Effect.Effect<ReadonlyArray<string>>;
}
>()("lsp-mcp/lsp/Documents") {
static readonly layer = (options: Options) =>
Layer.effect(Documents, make(options));
}
/** The ordered list of changes described by a `WorkspaceEdit`. */
export const changes = (edit: WorkspaceEdit): ReadonlyArray<Change> =>
edit.documentChanges ??
Object.entries(edit.changes ?? {}).map(([uri, edits]) => ({
textDocument: { uri, version: null },
edits,
}));
const textEdits = (edit: TextDocumentEdit): Array<TextEdit> =>
edit.edits.filter((e): e is TextEdit => "newText" in e);
interface Entry {
readonly doc: TextDocument;
stamp: string;
}
const make = Effect.fn("Documents.make")(function* (options: Options) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const lsp = yield* LanguageServer;
const lock = yield* Semaphore.make(1);
const sync = lsp.capabilities.textDocumentSync;
const save = typeof sync === "object" ? sync.save : undefined;
const uri = (file: string) =>
URI.file(path.resolve(lsp.root, file)).toString();
const fsPath = (uri: string) => URI.parse(uri).fsPath;
const file = (uri: string) => {
const parsed = URI.parse(uri);
if (parsed.scheme !== "file") return uri;
const relative = path.relative(lsp.root, parsed.fsPath);
return relative.startsWith("..") || path.isAbsolute(relative)
? parsed.fsPath
: relative;
};
const io = (uri: string) => (error: PlatformError.PlatformError) =>
new DocumentError({ file: file(uri), message: error.message });
const read = (uri: string) =>
fs.readFileString(fsPath(uri)).pipe(Effect.mapError(io(uri)));
const stamp = (uri: string) =>
fs.stat(fsPath(uri)).pipe(
Effect.map(
(info) =>
`${Option.getOrUndefined(info.mtime)?.getTime()}:${info.size}`,
),
Effect.mapError(io(uri)),
);
const languageId = (uri: string) => {
const name = path.basename(fsPath(uri)).toLowerCase();
const ext = path.extname(name).slice(1);
return (
options.languages[ext] ?? languages[ext] ?? languages[name] ?? "plaintext"
);
};
const cache = yield* ScopedCache.makeWith<string, Entry, DocumentError>({
capacity: options.capacity,
timeToLive: (exit) =>
Exit.isFailure(exit) ? Duration.zero : Duration.infinity,
lookup: (uri) =>
Effect.gen(function* () {
const [text, current] = yield* Effect.all([read(uri), stamp(uri)]);
const doc = TextDocument.create(uri, languageId(uri), 1, text);
yield* Effect.acquireRelease(
lsp.notify(DidOpenTextDocumentNotification.type, {
textDocument: {
uri,
languageId: doc.languageId,
version: doc.version,
text,
},
}),
() =>
lsp.notify(DidCloseTextDocumentNotification.type, {
textDocument: { uri },
}),
);
return { doc, stamp: current } satisfies Entry;
}),
});
const change = Effect.fn("Documents.change")(function* (
entry: Entry,
text: string,
) {
const { uri } = entry.doc;
const version = entry.doc.version + 1;
TextDocument.update(entry.doc, [{ text }], version);
yield* lsp.notify(DidChangeTextDocumentNotification.type, {
textDocument: { uri, version },
contentChanges: [{ text }],
});
if (save) {
yield* lsp.notify(DidSaveTextDocumentNotification.type, {
textDocument: { uri },
...(typeof save === "object" && save.includeText ? { text } : {}),
});
}
});
/** Bring an open document in line with the file on disk. */
const refresh = Effect.fn("Documents.refresh")(function* (uri: string) {
const entry = yield* ScopedCache.get(cache, uri);
const current = yield* stamp(uri).pipe(
Effect.tapError(() => ScopedCache.invalidate(cache, uri)),
);
if (current !== entry.stamp) {
const text = yield* read(uri);
entry.stamp = current;
if (text !== entry.doc.getText()) yield* change(entry, text);
}
return entry.doc;
});
const open = (file: string) => lock.withPermits(1)(refresh(uri(file)));
const peek = (uri: string) =>
Effect.flatMap(ScopedCache.has(cache, uri), (open) =>
open
? lock.withPermits(1)(refresh(uri))
: Effect.map(read(uri), (text) =>
TextDocument.create(uri, "", 0, text),
),
);
/** Persist new content for an open document and tell the server. */
const store = Effect.fn("Documents.store")(function* (
uri: string,
text: string,
) {
const entry = yield* ScopedCache.get(cache, uri);
yield* fs.writeFileString(fsPath(uri), text).pipe(Effect.mapError(io(uri)));
entry.stamp = yield* stamp(uri);
if (text !== entry.doc.getText()) yield* change(entry, text);
});
const write = (uri: string, text: string) =>
lock.withPermits(1)(store(uri, text));
const forget = (uri: string) => ScopedCache.invalidate(cache, uri);
const apply = Effect.fn("Documents.applyChange")(function* (change: Change) {
if (CreateFile.is(change)) {
const target = fsPath(change.uri);
if (
change.options?.ignoreIfExists &&
(yield* fs.exists(target).pipe(Effect.mapError(io(change.uri))))
)
return;
yield* fs
.makeDirectory(path.dirname(target), { recursive: true })
.pipe(
Effect.andThen(fs.writeFileString(target, "")),
Effect.mapError(io(change.uri)),
);
} else if (RenameFile.is(change)) {
yield* Effect.all([forget(change.oldUri), forget(change.newUri)]);
yield* fs
.rename(fsPath(change.oldUri), fsPath(change.newUri))
.pipe(Effect.mapError(io(change.oldUri)));
} else if (DeleteFile.is(change)) {
yield* forget(change.uri);
yield* fs
.remove(fsPath(change.uri), {
recursive: change.options?.recursive ?? false,
})
.pipe(Effect.mapError(io(change.uri)));
} else {
const { uri, version } = change.textDocument;
const doc = yield* refresh(uri);
if (
version !== null &&
version !== undefined &&
version !== doc.version
) {
return yield* new DocumentError({
file: file(uri),
message: "document changed since the edit was computed",
});
}
yield* store(uri, TextDocument.applyEdits(doc, textEdits(change)));
}
});
const applyEdit = (edit: WorkspaceEdit) =>
lock.withPermits(1)(
Effect.forEach(changes(edit), apply, { discard: true }),
);
yield* lsp.onRequest(ApplyWorkspaceEditRequest.type, ({ edit }) =>
applyEdit(edit).pipe(
Effect.as({ applied: true }),
Effect.catch((error) =>
Effect.succeed({ applied: false, failureReason: error.message }),
),
),
);
return Documents.of({
uri,
file,
open,
peek,
write,
applyEdit,
opened: ScopedCache.keys(cache),
});
});
const languages: Record<string, string> = {
ts: "typescript",
mts: "typescript",
cts: "typescript",
tsx: "typescriptreact",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
jsx: "javascriptreact",
json: "json",
jsonc: "jsonc",
py: "python",
pyi: "python",
rs: "rust",
go: "go",
c: "c",
h: "c",
cc: "cpp",
cpp: "cpp",
cxx: "cpp",
hpp: "cpp",
hh: "cpp",
java: "java",
kt: "kotlin",
kts: "kotlin",
scala: "scala",
cs: "csharp",
fs: "fsharp",
swift: "swift",
rb: "ruby",
php: "php",
lua: "lua",
zig: "zig",
hs: "haskell",
ml: "ocaml",
mli: "ocaml",
ex: "elixir",
exs: "elixir",
erl: "erlang",
clj: "clojure",
cljs: "clojurescript",
dart: "dart",
elm: "elm",
nix: "nix",
r: "r",
jl: "julia",
vue: "vue",
svelte: "svelte",
astro: "astro",
html: "html",
css: "css",
scss: "scss",
less: "less",
md: "markdown",
yaml: "yaml",
yml: "yaml",
toml: "toml",
xml: "xml",
sql: "sql",
sh: "shellscript",
bash: "shellscript",
zsh: "shellscript",
fish: "fish",
tf: "terraform",
proto: "proto",
graphql: "graphql",
gql: "graphql",
tex: "latex",
vim: "vim",
dockerfile: "dockerfile",
makefile: "makefile",
};