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

+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);
}