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

+134
View File
@@ -0,0 +1,134 @@
/**
* Diagnostics reconcile LSP's push model with the agent's pull model.
*
* Published diagnostics are accumulated per URI. Asking for a document's
* diagnostics either pulls them (when the server supports
* `textDocument/diagnostic`) or waits for the push that follows the document's
* latest version to settle, bounded by a timeout.
*/
import {
Context,
Effect,
HashMap,
Layer,
Option,
PubSub,
Ref,
Stream,
} from "effect";
import {
type Diagnostic,
DocumentDiagnosticReportKind,
DocumentDiagnosticRequest,
PublishDiagnosticsNotification,
type PublishDiagnosticsParams,
WorkspaceDiagnosticRequest,
} from "vscode-languageserver-protocol";
import type { TextDocument } from "vscode-languageserver-textdocument";
import { LanguageServer, type LspError } from "./LanguageServer.ts";
/** Quiet period after a publish before diagnostics count as settled. */
const settle = "250 millis";
/** How long to wait for a server that never publishes for a change. */
const patience = "5 seconds";
const make = Effect.gen(function* () {
const lsp = yield* LanguageServer;
const state = yield* Ref.make(
HashMap.empty<string, PublishDiagnosticsParams>(),
);
const updates = yield* PubSub.unbounded<string>();
const settled = new Map<string, number>();
const provider = lsp.capabilities.diagnosticProvider;
const publish = (params: PublishDiagnosticsParams) =>
Ref.update(state, HashMap.set(params.uri, params)).pipe(
Effect.andThen(PubSub.publish(updates, params.uri)),
);
yield* lsp
.notifications(PublishDiagnosticsNotification.type)
.pipe(Stream.runForEach(publish), Effect.forkScoped);
const current = (uri: string) =>
Ref.get(state).pipe(
Effect.map((map) => Option.getOrUndefined(HashMap.get(map, uri))),
);
const pull = Effect.fn("Diagnostics.pull")(function* (doc: TextDocument) {
const report = yield* lsp.request(DocumentDiagnosticRequest.type, {
textDocument: { uri: doc.uri },
});
if (report.kind === DocumentDiagnosticReportKind.Full) {
yield* publish({
uri: doc.uri,
version: doc.version,
diagnostics: report.items,
});
}
return (yield* current(doc.uri))?.diagnostics ?? [];
});
const awaitPush = Effect.fn("Diagnostics.awaitPush")(function* (
doc: TextDocument,
) {
if (settled.get(doc.uri) !== doc.version) {
yield* Effect.scoped(
Effect.gen(function* () {
const subscription = yield* PubSub.subscribe(updates);
const fresh = (yield* current(doc.uri))?.version === doc.version;
yield* Stream.fromSubscription(subscription).pipe(
Stream.filter((uri) => uri === doc.uri),
Stream.debounce(settle),
Stream.runHead,
Effect.timeoutOption(fresh ? settle : patience),
);
}),
);
settled.set(doc.uri, doc.version);
}
return (yield* current(doc.uri))?.diagnostics ?? [];
});
const all = Effect.gen(function* () {
if (provider?.workspaceDiagnostics) {
const report = yield* lsp.request(WorkspaceDiagnosticRequest.type, {
previousResultIds: [],
});
for (const item of report.items) {
if (item.kind === DocumentDiagnosticReportKind.Full) {
yield* publish({
uri: item.uri,
diagnostics: item.items,
...(item.version === null ? {} : { version: item.version }),
});
}
}
}
return Array.from(HashMap.values(yield* Ref.get(state)));
});
return Diagnostics.of({
all,
forDocument: provider ? pull : awaitPush,
changes: Stream.fromPubSub(updates),
});
});
export class Diagnostics extends Context.Service<
Diagnostics,
{
/** Everything the server has reported, per URI. */
readonly all: Effect.Effect<
ReadonlyArray<PublishDiagnosticsParams>,
LspError
>;
/** Diagnostics matching the document's current version. */
readonly forDocument: (
doc: TextDocument,
) => Effect.Effect<ReadonlyArray<Diagnostic>, LspError>;
/** URIs whose diagnostics changed. */
readonly changes: Stream.Stream<string>;
}
>()("lsp-mcp/lsp/Diagnostics") {
static readonly layer = Layer.effect(Diagnostics, make);
}
+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",
};
+608
View File
@@ -0,0 +1,608 @@
/**
* The operations an agent performs on the headless editor.
*
* Each function resolves agent-facing input (paths, 1-based positions,
* symbols on a line) to LSP requests and presents the results in the
* `Model` vocabulary. These functions are the MCP tool handlers.
*/
import { Effect, Option } from "effect";
import {
CallHierarchyIncomingCallsRequest,
CallHierarchyOutgoingCallsRequest,
CallHierarchyPrepareRequest,
CodeAction,
CodeActionRequest,
CodeActionResolveRequest,
CodeActionTriggerKind,
type Command,
CompletionItemKind,
CompletionRequest,
CompletionTriggerKind,
DeclarationRequest,
DefinitionRequest,
type Diagnostic,
DocumentFormattingRequest,
type DocumentSymbol,
DocumentSymbolRequest,
ExecuteCommandRequest,
type Hover,
HoverRequest,
ImplementationRequest,
type Location,
type LocationLink,
type MarkupContent,
type Position,
type Range,
ReferencesRequest,
RenameRequest,
type ServerCapabilities,
SignatureHelpRequest,
SymbolKind,
TextDocumentEdit,
TypeDefinitionRequest,
type WorkspaceEdit,
WorkspaceSymbolRequest,
} from "vscode-languageserver-protocol";
import { TextDocument } from "vscode-languageserver-textdocument";
import type * as Model from "../Model.ts";
import { Diagnostics } from "./Diagnostics.ts";
import { changes, Documents } from "./Documents.ts";
import { LanguageServer, LspError } from "./LanguageServer.ts";
// --- Presentation ----------------------------------------------------------
const range = (r: Range): Model.Range => ({
line: r.start.line + 1,
column: r.start.character + 1,
endLine: r.end.line + 1,
endColumn: r.end.character + 1,
});
const lineText = (doc: TextDocument, line: number) =>
doc
.getText()
.slice(
doc.offsetAt({ line, character: 0 }),
doc.offsetAt({ line: line + 1, character: 0 }),
)
.replace(/\r?\n$/, "");
const preview = (doc: TextDocument | undefined, line: number) =>
doc === undefined ? "" : lineText(doc, line).trim();
const markup = (content: Hover["contents"] | MarkupContent | string): string =>
typeof content === "string"
? content
: Array.isArray(content)
? content.map(markup).join("\n\n")
: "kind" in content
? content.value
: `\`\`\`${content.language}\n${content.value}\n\`\`\``;
/** `{ Class: 5 }` → `{ 5: "class" }`, `{ TypeParameter: 26 }` → `{ 26: "typeParameter" }`. */
const kindNames = (kinds: Record<string, number>): Record<number, string> =>
Object.fromEntries(
Object.entries(kinds).map(([name, value]) => [
value,
name[0]?.toLowerCase() + name.slice(1),
]),
);
const symbolKinds = kindNames(SymbolKind);
const completionKinds = kindNames(CompletionItemKind);
const severities = [
"error",
"warning",
"information",
"hint",
] as const satisfies ReadonlyArray<Model.Severity>;
// --- Resolution ------------------------------------------------------------
const unsupported = (feature: string) =>
new LspError({
method: feature,
message: `the language server does not support ${feature}`,
});
const ensure = (
capabilities: ServerCapabilities,
key: keyof ServerCapabilities,
) => (capabilities[key] ? Effect.void : Effect.fail(unsupported(key)));
/** Turn agent-facing `Where` into an LSP text document position. */
const locate = Effect.fn("Editor.locate")(function* (where: Model.Where) {
const docs = yield* Documents;
const doc = yield* docs.open(where.file);
const line = where.line - 1;
if (line >= doc.lineCount) {
return yield* new LspError({
method: "locate",
message: `${where.file} has only ${doc.lineCount} lines`,
});
}
const text = lineText(doc, line);
const character =
where.column !== undefined
? where.column - 1
: where.symbol !== undefined
? yield* Effect.orElseSucceed(findSymbol(text, where.symbol), () => -1)
: Math.max(0, text.search(/\S/));
if (character < 0) {
return yield* new LspError({
method: "locate",
message: `"${where.symbol}" does not occur on line ${where.line}`,
});
}
const position: Position = {
line,
character: Math.min(character, text.length),
};
return { doc, position, textDocument: { uri: doc.uri } };
});
const findSymbol = (text: string, symbol: string) => {
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const index = text.search(
new RegExp(`(?<![\\p{L}\\p{N}_$])${escaped}(?![\\p{L}\\p{N}_$])`, "u"),
);
const found = index >= 0 ? index : text.indexOf(symbol);
return found >= 0 ? Effect.succeed(found) : Effect.fail(found);
};
const locations = Effect.fn("Editor.locations")(function* (
items: ReadonlyArray<Location | LocationLink>,
) {
const docs = yield* Documents;
const targets = items.map((item) =>
"targetUri" in item
? { uri: item.targetUri, range: item.targetSelectionRange }
: item,
);
const previews = new Map(
yield* Effect.forEach([...new Set(targets.map((t) => t.uri))], (uri) =>
docs.peek(uri).pipe(
Effect.option,
Effect.map((doc) => [uri, Option.getOrUndefined(doc)] as const),
),
),
);
return targets.map(
({ uri, range: r }): Model.Location => ({
file: docs.file(uri),
...range(r),
preview: preview(previews.get(uri), r.start.line),
}),
);
});
const workspaceEdit = Effect.fn("Editor.workspaceEdit")(function* (
edit: WorkspaceEdit,
apply: boolean,
) {
const docs = yield* Documents;
const files: Array<typeof Model.FileEdits.Type> = [];
const operations: Array<string> = [];
for (const change of changes(edit)) {
if (TextDocumentEdit.is(change)) {
files.push({
file: docs.file(change.textDocument.uri),
edits: change.edits.flatMap((e) =>
"newText" in e ? [{ ...range(e.range), newText: e.newText }] : [],
),
});
} else if (change.kind === "rename") {
operations.push(
`rename ${docs.file(change.oldUri)} -> ${docs.file(change.newUri)}`,
);
} else {
operations.push(`${change.kind} ${docs.file(change.uri)}`);
}
}
if (apply) yield* docs.applyEdit(edit);
return { applied: apply, files, operations } satisfies Model.WorkspaceEdit;
});
// --- Operations ------------------------------------------------------------
export const hover = Effect.fn("Editor.hover")(function* (where: Model.Where) {
const lsp = yield* LanguageServer;
const { textDocument, position } = yield* locate(where);
const result = yield* lsp.request(HoverRequest.type, {
textDocument,
position,
});
return result === null
? null
: ({
contents: markup(result.contents),
range: result.range && range(result.range),
} satisfies Model.Hover);
});
const gotoRequests = {
definition: DefinitionRequest.type,
typeDefinition: TypeDefinitionRequest.type,
implementation: ImplementationRequest.type,
declaration: DeclarationRequest.type,
};
export const definition = Effect.fn("Editor.definition")(function* ({
kind = "definition",
...where
}: Model.Goto) {
const lsp = yield* LanguageServer;
yield* ensure(lsp.capabilities, `${kind}Provider`);
const { textDocument, position } = yield* locate(where);
const result = yield* lsp.request(
gotoRequests[kind] as typeof DefinitionRequest.type,
{ textDocument, position },
);
return yield* locations(
result === null ? [] : Array.isArray(result) ? result : [result],
);
});
export const references = Effect.fn("Editor.references")(function* ({
includeDeclaration = true,
...where
}: Model.References) {
const lsp = yield* LanguageServer;
const { textDocument, position } = yield* locate(where);
const result = yield* lsp.request(ReferencesRequest.type, {
textDocument,
position,
context: { includeDeclaration },
});
return yield* locations(result ?? []);
});
export const documentSymbols = Effect.fn("Editor.documentSymbols")(function* ({
file,
}: {
readonly file: string;
}) {
const lsp = yield* LanguageServer;
const docs = yield* Documents;
const doc = yield* docs.open(file);
const result = yield* lsp.request(DocumentSymbolRequest.type, {
textDocument: { uri: doc.uri },
});
const present = (symbol: DocumentSymbol): Model.DocumentSymbol => ({
name: symbol.name,
kind: symbolKinds[symbol.kind] ?? "unknown",
detail: symbol.detail || undefined,
...range(symbol.range),
children: symbol.children?.length
? symbol.children.map(present)
: undefined,
});
return (result ?? []).map(
(symbol): Model.DocumentSymbol =>
"range" in symbol
? present(symbol)
: {
name: symbol.name,
kind: symbolKinds[symbol.kind] ?? "unknown",
container: symbol.containerName || undefined,
...range(symbol.location.range),
},
);
});
export const workspaceSymbols = Effect.fn("Editor.workspaceSymbols")(
function* ({ query, limit = 50 }: Model.WorkspaceQuery) {
const lsp = yield* LanguageServer;
const docs = yield* Documents;
const result = yield* lsp.request(WorkspaceSymbolRequest.type, { query });
return (result ?? []).slice(0, limit).map(
(symbol): Model.WorkspaceSymbol => ({
name: symbol.name,
kind: symbolKinds[symbol.kind] ?? "unknown",
container: symbol.containerName || undefined,
file: docs.file(symbol.location.uri),
...("range" in symbol.location
? {
line: symbol.location.range.start.line + 1,
column: symbol.location.range.start.character + 1,
}
: {}),
}),
);
},
);
export const diagnostics = Effect.fn("Editor.diagnostics")(function* ({
file,
severity = "hint",
}: Model.DiagnosticsQuery) {
const docs = yield* Documents;
const diags = yield* Diagnostics;
const present =
(uri: string) =>
(d: Diagnostic): Model.Diagnostic => ({
file: docs.file(uri),
...range(d.range),
severity: severities[(d.severity ?? 1) - 1] ?? "error",
code: d.code === undefined ? undefined : String(d.code),
source: d.source,
message: markup(d.message),
related: d.relatedInformation?.map((r) => ({
file: docs.file(r.location.uri),
line: r.location.range.start.line + 1,
column: r.location.range.start.character + 1,
message: r.message,
})),
});
const items =
file === undefined
? (yield* diags.all).flatMap((p) => p.diagnostics.map(present(p.uri)))
: yield* docs
.open(file)
.pipe(
Effect.flatMap((doc) =>
Effect.map(diags.forDocument(doc), (ds) =>
ds.map(present(doc.uri)),
),
),
);
const threshold = severities.indexOf(severity);
return items
.filter((d) => severities.indexOf(d.severity) <= threshold)
.sort(
(a, b) =>
a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column,
);
});
export const completions = Effect.fn("Editor.completions")(function* ({
prefix,
limit = 20,
...where
}: Model.Completions) {
const lsp = yield* LanguageServer;
const { textDocument, position } = yield* locate(where);
const result = yield* lsp.request(CompletionRequest.type, {
textDocument,
position,
context: { triggerKind: CompletionTriggerKind.Invoked },
});
const items = Array.isArray(result) ? result : (result?.items ?? []);
const lower = prefix?.toLowerCase();
return items
.filter(
(item) =>
lower === undefined ||
(item.filterText ?? item.label).toLowerCase().startsWith(lower),
)
.sort((a, b) =>
(a.sortText ?? a.label).localeCompare(b.sortText ?? b.label),
)
.slice(0, limit)
.map(
(item): Model.Completion => ({
label: item.label,
kind: completionKinds[item.kind ?? 1] ?? "text",
detail: item.detail,
documentation:
item.documentation === undefined
? undefined
: markup(item.documentation),
}),
);
});
export const signatureHelp = Effect.fn("Editor.signatureHelp")(function* (
where: Model.Where,
) {
const lsp = yield* LanguageServer;
const { textDocument, position } = yield* locate(where);
const result = yield* lsp.request(SignatureHelpRequest.type, {
textDocument,
position,
});
return {
active: result?.activeSignature ?? 0,
signatures: (result?.signatures ?? []).map((s) => ({
label: s.label,
documentation:
s.documentation === undefined ? undefined : markup(s.documentation),
parameters: (s.parameters ?? []).map((p) =>
typeof p.label === "string"
? p.label
: s.label.slice(p.label[0], p.label[1]),
),
activeParameter:
s.activeParameter ?? result?.activeParameter ?? undefined,
})),
} satisfies Model.SignatureHelp;
});
export const rename = Effect.fn("Editor.rename")(function* ({
newName,
apply = false,
...where
}: Model.Rename) {
const lsp = yield* LanguageServer;
const { textDocument, position } = yield* locate(where);
const edit = yield* lsp.request(RenameRequest.type, {
textDocument,
position,
newName,
});
if (edit === null)
return yield* new LspError({
method: "rename",
message: "nothing to rename at this position",
});
return yield* workspaceEdit(edit, apply);
});
const selection = Effect.fn("Editor.selection")(function* ({
file,
line,
column,
endLine,
endColumn,
}: Model.Selection) {
const docs = yield* Documents;
const doc = yield* docs.open(file);
const end = (endLine ?? line) - 1;
const range: Range = {
start: { line: line - 1, character: column === undefined ? 0 : column - 1 },
end: {
line: end,
character:
endColumn === undefined ? lineText(doc, end).length : endColumn - 1,
},
};
return { doc, textDocument: { uri: doc.uri }, range };
});
const overlaps = (a: Range, b: Range) =>
(a.start.line < b.end.line ||
(a.start.line === b.end.line && a.start.character <= b.end.character)) &&
(b.start.line < a.end.line ||
(b.start.line === a.end.line && b.start.character <= a.end.character));
const codeActionsAt = Effect.fn("Editor.codeActionsAt")(function* ({
kind,
...where
}: Model.CodeActions) {
const lsp = yield* LanguageServer;
const diags = yield* Diagnostics;
const { doc, textDocument, range } = yield* selection(where);
const diagnostics = (yield* diags.forDocument(doc)).filter((d) =>
overlaps(d.range, range),
);
const actions = yield* lsp.request(CodeActionRequest.type, {
textDocument,
range,
context: {
diagnostics,
triggerKind: CodeActionTriggerKind.Invoked,
...(kind === undefined ? {} : { only: [kind] }),
},
});
return actions ?? [];
});
export const codeActions = Effect.fn("Editor.codeActions")(function* (
params: Model.CodeActions,
) {
return (yield* codeActionsAt(params)).map(
(action): Model.CodeAction => ({
title: action.title,
kind: CodeAction.is(action) ? action.kind : undefined,
preferred: CodeAction.is(action) && action.isPreferred === true,
fixes: CodeAction.is(action)
? (action.diagnostics ?? []).map((d) => markup(d.message))
: [],
}),
);
});
export const applyCodeAction = Effect.fn("Editor.applyCodeAction")(function* ({
title,
...params
}: Model.ApplyCodeAction) {
const lsp = yield* LanguageServer;
const action = (yield* codeActionsAt(params)).find((a) => a.title === title);
if (action === undefined)
return yield* new LspError({
method: "codeAction",
message: `no code action titled "${title}"`,
});
const execute = (command: Command) =>
lsp
.request(ExecuteCommandRequest.type, {
command: command.command,
...(command.arguments && { arguments: command.arguments }),
})
.pipe(Effect.as(`command ${command.command}`));
if (!CodeAction.is(action)) {
return {
applied: true,
files: [],
operations: [yield* execute(action)],
} satisfies Model.WorkspaceEdit;
}
const resolved =
action.edit === undefined &&
action.command === undefined &&
lsp.capabilities.codeActionProvider
? yield* lsp.request(CodeActionResolveRequest.type, action)
: action;
const result = yield* workspaceEdit(resolved.edit ?? {}, true);
const operations = resolved.command
? [...result.operations, yield* execute(resolved.command)]
: result.operations;
return { ...result, operations };
});
export const format = Effect.fn("Editor.format")(function* ({
file,
tabSize = 4,
insertSpaces = true,
apply = false,
}: Model.Format) {
const lsp = yield* LanguageServer;
const docs = yield* Documents;
const doc = yield* docs.open(file);
const edits =
(yield* lsp.request(DocumentFormattingRequest.type, {
textDocument: { uri: doc.uri },
options: { tabSize, insertSpaces },
})) ?? [];
if (apply && edits.length > 0)
yield* docs.write(doc.uri, TextDocument.applyEdits(doc, edits));
return {
applied: apply,
files: [
{
file: docs.file(doc.uri),
edits: edits.map((e) => ({ ...range(e.range), newText: e.newText })),
},
],
operations: [],
} satisfies Model.WorkspaceEdit;
});
export const callHierarchy = Effect.fn("Editor.callHierarchy")(function* ({
direction = "incoming",
...where
}: Model.CallHierarchy) {
const lsp = yield* LanguageServer;
const docs = yield* Documents;
const { textDocument, position } = yield* locate(where);
const item = (yield* lsp.request(CallHierarchyPrepareRequest.type, {
textDocument,
position,
}))?.[0];
if (item === undefined) return [];
const calls =
direction === "incoming"
? (
(yield* lsp.request(CallHierarchyIncomingCallsRequest.type, {
item,
})) ?? []
).map((c) => ({ item: c.from, sites: c.fromRanges }))
: (
(yield* lsp.request(CallHierarchyOutgoingCallsRequest.type, {
item,
})) ?? []
).map((c) => ({ item: c.to, sites: c.fromRanges }));
return calls.map(
({ item, sites }): Model.Call => ({
name: item.name,
kind: symbolKinds[item.kind] ?? "unknown",
detail: item.detail,
file: docs.file(item.uri),
line: item.selectionRange.start.line + 1,
column: item.selectionRange.start.character + 1,
sites: sites.map((r) => ({
line: r.start.line + 1,
column: r.start.character + 1,
})),
}),
);
});
+444
View File
@@ -0,0 +1,444 @@
/**
* A running language server: process, JSON-RPC connection and the LSP
* lifecycle, exposed as an Effect service.
*
* `request` waits for the server to become idle (no `$/progress` work in
* flight) and retries transient `ContentModified` / `ServerCancelled`
* failures, so callers can treat the server as if it were always ready.
*/
import { spawn } from "node:child_process";
import { NodeStream } from "@effect/platform-node";
import {
Context,
Deferred,
Effect,
FiberSet,
HashSet,
Layer,
PubSub,
Schedule,
Schema,
type Scope,
Stream,
SubscriptionRef,
} from "effect";
import {
CancellationTokenSource,
type ClientCapabilities,
ConfigurationRequest,
createMessageConnection,
DidChangeConfigurationNotification,
ErrorCodes,
ExitNotification,
type HandlerResult,
InitializedNotification,
InitializeRequest,
LogMessageNotification,
type LSPAny,
LSPErrorCodes,
type MessageType,
type NotificationType,
RegistrationRequest,
type RequestType,
ResponseError,
type ServerCapabilities,
ShowMessageNotification,
ShutdownRequest,
StreamMessageReader,
StreamMessageWriter,
UnregistrationRequest,
WorkDoneProgressCreateRequest,
WorkspaceFoldersRequest,
} from "vscode-languageserver-protocol/node";
import { URI } from "vscode-uri";
export interface Options {
readonly command: string;
readonly args: ReadonlyArray<string>;
/** Absolute path of the workspace root. */
readonly root: string;
readonly initializationOptions?: unknown;
/** Served as `workspace/configuration` and pushed via `didChangeConfiguration`. */
readonly settings?: unknown;
}
export class LspError extends Schema.TaggedError<LspError>()("LspError", {
method: Schema.String,
message: Schema.String,
code: Schema.optional(Schema.Int),
}) {
get retryable() {
return (
this.code === LSPErrorCodes.ContentModified ||
this.code === LSPErrorCodes.ServerCancelled
);
}
}
export type LogLevel = "error" | "warning" | "info" | "debug";
export interface LogMessage {
readonly level: LogLevel;
readonly message: string;
}
export class LanguageServer extends Context.Service<
LanguageServer,
{
readonly root: string;
readonly capabilities: ServerCapabilities;
readonly serverInfo:
| { readonly name: string; readonly version?: string | undefined }
| undefined;
readonly request: <P, R>(
type: RequestType<P, R, unknown>,
params: P,
) => Effect.Effect<R, LspError>;
readonly notify: <P>(
type: NotificationType<P>,
params: P,
) => Effect.Effect<void>;
readonly notifications: <P>(type: NotificationType<P>) => Stream.Stream<P>;
/** Handle a request the server sends to us; unhandled methods answer `MethodNotFound`. */
readonly onRequest: <P, R>(
type: RequestType<P, R, unknown>,
handler: (params: P) => Effect.Effect<R, unknown>,
) => Effect.Effect<void, never, Scope.Scope>;
/** `window/logMessage` and `window/showMessage` traffic. */
readonly logs: Stream.Stream<LogMessage>;
}
>()("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<never>();
const runPromise = yield* FiberSet.makeRuntimePromise<never>();
const exited = yield* Deferred.make<never, LspError>();
const events = yield* PubSub.unbounded<{
readonly method: string;
readonly params: unknown;
}>();
const progress = yield* SubscriptionRef.make(
HashSet.empty<string | number>(),
);
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<Uint8Array, never>({
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 = <P, R>(type: RequestType<P, R, unknown>, params: P) =>
Effect.callback<R, LspError>((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 = <P, R>(type: RequestType<P, R, unknown>, 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 = <P>(type: NotificationType<P>, 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 = <P>(type: NotificationType<P>): Stream.Stream<P> =>
Stream.fromPubSub(events).pipe(
Stream.filter((event) => event.method === type.method),
Stream.map((event) => event.params as P),
);
const onRequest = <P, R>(
type: RequestType<P, R, unknown>,
handler: (params: P) => Effect.Effect<R, unknown>,
) =>
Effect.acquireRelease(
Effect.sync(() =>
connection.onRequest(
type.method,
(params: P): HandlerResult<R, unknown> =>
runPromise(
handler(params).pipe(
Effect.mapError((error) =>
error instanceof ResponseError
? error
: new ResponseError(
ErrorCodes.InternalError,
error instanceof Error ? error.message : String(error),
),
),
),
) as Promise<never>,
),
),
(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<MessageType, LogLevel> = {
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<unknown>(
(value, key) => (value as Record<string, unknown> | 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);
}