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:
19 files changed
+3693
-38
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user