Compare commits

...
3 Commits
Author SHA1 Message Date
EFLKumo ac04a87e53 Name the diagnostics resource parameter; make symbol lookup pure 2026-09-19 09:16:51 +08:00
EFLKumo 3dc7ca6c80 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.
2026-09-19 09:16:51 +08:00
EFLKumo 90eded8415 goal.md 2026-09-19 09:16:51 +08:00
19 changed files with 3655 additions and 38 deletions

No files matched your search

+60
View File
@@ -0,0 +1,60 @@
# lsp-mcp
An MCP server that exposes one Language Server to agents as a headless editor.
Effect 4 (`effect/unstable/ai` for MCP, `effect/unstable/cli` for the CLI) plus
`vscode-languageserver-protocol` for LSP. TypeScript runs directly on Node 26
(type stripping); there is no build step.
## Commands
- `pnpm tsc` — type-check (tsgo with the Effect language-service plugin)
- `pnpm test` — vitest; integration tests spawn `test/fixtures/server.ts`, a real LSP server
- `pnpm biome check --write --unsafe` — lint/format (run before committing; its rewrites are safe)
- `node src/main.ts --help` — the CLI; smoke-test with `node src/main.ts --root <dir> -- <server> [args]`
- Commit with `jj commit -m "..."`, never `git`
The shell may have an HTTP proxy configured; when curling the `--http` transport locally, pass `--noproxy '*'`.
## Layout
```
src/Model.ts agent-facing Schemas (1-based positions); drives tool input/output schemas
src/Tools.ts Toolkit definition + handlers wiring + capability gating (supportedBy)
src/Resources.ts MCP resources (lsp://server, lsp://diagnostics[/{file}]) and notifications
src/Server.ts layer composition: LanguageServer → Documents+Diagnostics → transport → tools/resources
src/main.ts CLI entry (Command/Flag/Argument), logs to stderr, NodeRuntime.runMain
src/lsp/ the ONLY place vscode-* packages are used at runtime
LanguageServer.ts spawn + JSON-RPC + initialize/shutdown; request() waits for $/progress idle and retries ContentModified
Documents.ts on-demand didOpen, LRU via ScopedCache, disk change detection → didChange+didSave, applyEdit
Diagnostics.ts publishDiagnostics accumulation; pull (textDocument/diagnostic) or settled push per doc version
Editor.ts the operations = MCP tool handlers; LSP results → Model values
test/fixtures/server.ts fixture language server (hover/definition/references/rename/symbols/TODO diagnostics)
test/fixtures/workspace.ts temp workspace layer running the fixture server
```
Rule: outside `src/lsp/`, `vscode-*` may only be imported as types.
## Design decisions worth knowing
- Positions handed to agents are 1-based; `Where` accepts `column` or a `symbol` name to find on the line.
- Client capabilities are deliberately narrow (no dynamic registration, no watched files, UTF-16 only) so
servers fall back to their own file watching. Settings come from `--settings` via `workspace/configuration`.
- Tools are registered after LSP `initialize` so unsupported ones are never offered
(`Tools.supportedBy`). `diagnostics` is always offered.
- Tool failures are `LspError | DocumentError` (declared `failure` schema), so their messages reach the agent.
- Optional fields in `Model` use a local `optional` helper that omits the key when encoding, because MCP
`structuredContent` must be JSON (no `undefined`).
- Diagnostics waiting: pull if the server supports it; otherwise wait for a publish for the doc's version
plus a 250ms quiet period, bounded by 5s, memoized per (uri, version).
- Tests that talk to the fixture server use `it.layer(..., { excludeTestServices: true })` so the real
clock is used (Stream.debounce / timeouts would hang under TestClock).
## Status
Complete for the first release: 13 tools, 2 resources + 1 template, stdio and HTTP transports,
verified against the fixture server and `typescript-language-server`. `goal.md` is the original brief and
no longer authoritative; the code is.
Ideas not done: inlay hints, type hierarchy, semantic tokens, partial results, workspace/didChangeWatchedFiles
when a server insists on it, restarting a crashed language server (currently requests fail with a clear
`LspError` until the MCP client restarts the process).
+42
View File
@@ -0,0 +1,42 @@
Build an MCP (Model Context Protocol) server dedicated to providing LSP (Language Server Protocol) capabilities to Agents. Users can specify the LSP executable path and additional arguments via parameters; the MCP server is responsible for launching the LSP server and fully utilizing and exposing MCP capabilities.
### I. General Architecture
Use Effect and TypeScript. Specifically, `effect/unstable/ai` and `effect/unstable/cli` provide ready-to-use implementations of MCP and CLI functionality. For LSP, you will progressively implement a bridge between `vscode-language*` and Effect inside `lsp/`. Users can launch the MCP server in stdio mode via `--stdio`, or in HTTP mode via `--http <port>`. Users directly specify the remaining arguments required to run the LSP server via command-line flags; one MCP process corresponds to one LSP server instance.
### II. LSP and MCP Integration Paradigm
There is a fundamental paradigm conflict between LSP and MCP. The mental model of LSP is built upon a persistent, stateful rich client, whereas MCP / Agents operate on discrete, stateless, intent-oriented tool calls and capabilities. Therefore, this MCP server cannot act as a mere proxy or forwarder; it must function as a headless virtual editor without sacrificing a lightweight architecture, MCP's native mental model, or the actual needs of Agents.
For example, regarding document synchronization, the MCP server may need to implement on-demand `didOpen`, change detection, and LRU eviction mechanisms. For core capabilities, it must reconcile the tension between LSP's "push-oriented" nature and MCP/Agent's "pull-oriented" nature, handling listeners and subscriptions appropriately. Furthermore, it must smooth over complex client capability negotiations, disable unnecessary capabilities, and carefully manage positions and character encodings. This is precisely why Effect was chosen: Effect makes it possible to keep complex architectures elegant and concise.
In this regard, you have full decision-making authority. Strive to design a well-engineered LSP MCP, bring your maximum intellect to bear, deliver first-class quality, and blow away the dime-a-dozen, subpar LSP MCPs out there.
### III. Library Reuse
Effect already exports `effect/unstable/ai` and `effect/unstable/cli` for building CLI and MCP servers. For LSP, you should use packages like `vscode-language*`, strictly confining the boundary of conversions between `vscode-language*` and Effect to `src/lsp/`, keeping the outside pure Effect code. Inside `lsp/`, you only need to progressively implement what is actually used, rather than building an exhaustive glue layer or converter from `vscode-language*` to Effect. Only write what you need.
I have no doubt in your ability to demonstrate consummate mastery of Effect: maximize its features to build a highly robust system. Make the fullest use of MCP's Tools and Resources features (do not touch the Prompts feature; I consider it useless).
### IV. Requirements
The following requirements are non-negotiable baselines and must be strictly followed:
1. **Architectural Requirements:** The overall architecture must be concise. If architectural components sum to zero in a "vector sum", that indicates a dirty, over-complicated design that should be discarded. We always pursue "minimum energy"—here, "minimum energy" means the simplest interface design required to solve the problem. Some people foolishly take instructions too literally: when told not to touch the Prompts feature, they engineer an elaborate architecture just to ensure Prompts cannot be introduced. That shows zero grasp of how to design the simplest, Occam's-razor-aligned, first-principles, KISS-oriented architecture. You are different: as a state-of-the-art assistant, you possess fundamental, exceptional insight into this.
2. **Code Style Requirements:** This is a fundamental requirement. All code must follow Effect best practices, upholding extreme conciseness, elegance, and code aesthetics—this is your moment to shine. Once the architecture is solved, writing code is easy; truly reflecting deep insight and pursuit of code aesthetics in every line is what demonstrates your true capability.
3. **Reuse Requirements:** Be cautious about reinventing the wheel. In reality, much of the groundwork has already been laid: you do not need to dive deep into implementing LSP specs or MCP specs from scratch, as libraries already handle them. Your primary intellectual effort should be spent on elegantly reusing and composing these libraries.
4. **Testing Requirements:** Unit tests must not be bloated and tedious. A unit test that merely re-verifies the literal step-by-step execution written in code is not a good unit test—if code didn't execute literally, the world would crumble. The true purpose of unit tests is to verify code intent, not implementation steps; meaningful unit tests have never been about sheer breadth of coverage.
### V. Context
Our repository uses `scripts/repos/` to maintain the `effect` codebase directly under `repos/effect/` (the officially recommended approach by Effect), allowing you to easily read the Effect source code and understand how it works without digging into `node_modules` and wasting intellect de-obfuscating code. The repository `microsoft/language-server-protocol` is also prepared under `repos/lsp`, where specifications can be viewed under `repos/lsp/_specifications`. In addition, you are allowed to use Exa tools for web searches (do not use other web search tools) and use `curl` to directly read web page content. For the latter, a handy tip is prefixing URLs with `https://defuddle.md/` to get clean Markdown versions of web pages. Avoid scraping raw HTML pages as much as possible to save context window tokens.
If there are any open-ended questions or unknown unknowns, feel free to ask me via user questions before making decisions.
### VI. Workflow
I encourage you to create and maintain `CLAUDE.md` to reduce the ramp-up cost for agents on each task launch, e.g., telling upcoming agents what stuff like files or documents worth or not worth reading and what's the status of this repo. I do not want you to use memory features. I do not want you to use subagents or plan mode—just do the work yourself. When a milestone or phase is completed, you may provide a prompt for me to pass to a fresh agent with a blank context window to take over your work; you do not need to finish everything in a single session. Before this MCP is completed, this document may serve as a partial source of truth. However, once everything is finished, this document should no longer be a source of truth. Good code always serves as a sufficiently comprehensive source of truth that can be easily understood without relying on external state.
Use `jj commit -m` to create commits for each chunk of changes. The `jj-vcs` skill is for reference only; in most cases, you will not need it. Do not use `git`. Before committing, in addition to running unit tests and type checks, run `pnpm biome check --write --unsafe`. It might modify your code; despite the `--unsafe` flag, these changes are always safe, so you do not need to double-check what it modified.
You do not need to worry about packaging or bundling the project.
+20 -10
View File
@@ -1,15 +1,19 @@
{ {
"name": "lsp-mcp", "name": "lsp-mcp",
"version": "1.0.0", "version": "0.1.0",
"description": "", "description": "MCP server exposing a Language Server's capabilities to agents as a headless editor",
"main": "index.js", "type": "module",
"license": "MIT",
"bin": {
"lsp-mcp": "./src/main.ts"
},
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "start": "node src/main.ts",
"check": "tsc",
"test": "vitest run",
"lint": "biome check --write --unsafe",
"prepare": "effect-tsgo patch --typescript --no-oxlint" "prepare": "effect-tsgo patch --typescript --no-oxlint"
}, },
"keywords": [],
"author": "",
"license": "ISC",
"devEngines": { "devEngines": {
"packageManager": { "packageManager": {
"name": "pnpm", "name": "pnpm",
@@ -18,14 +22,20 @@
} }
}, },
"packageManager": "pnpm@12.4.2", "packageManager": "pnpm@12.4.2",
"type": "module",
"dependencies": { "dependencies": {
"@effect/platform-node": "4.0.0-rc.115", "@effect/platform-node": "4.0.0-rc.115",
"effect": "4.0.0-rc.115" "effect": "4.0.0-rc.115",
"vscode-languageserver-protocol": "^3.18.3",
"vscode-languageserver-textdocument": "^1.0.14",
"vscode-uri": "^3.2.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.14", "@biomejs/biome": "2.5.14",
"@effect/tsgo": "0.45.0", "@effect/tsgo": "0.45.0",
"typescript": "^7.0.2" "@effect/vitest": "4.0.0-rc.115",
"@types/node": "^26.6.1",
"typescript": "^7.0.2",
"vitest": "^5.0.1",
"vscode-languageserver": "^10.1.1"
} }
} }
+697
View File
@@ -172,6 +172,15 @@ importers:
effect: effect:
specifier: 4.0.0-rc.115 specifier: 4.0.0-rc.115
version: 4.0.0-rc.115 version: 4.0.0-rc.115
vscode-languageserver-protocol:
specifier: ^3.18.3
version: 3.18.3
vscode-languageserver-textdocument:
specifier: ^1.0.14
version: 1.0.14
vscode-uri:
specifier: ^3.2.0
version: 3.2.0
devDependencies: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: 2.5.14 specifier: 2.5.14
@@ -179,9 +188,21 @@ importers:
'@effect/tsgo': '@effect/tsgo':
specifier: 0.45.0 specifier: 0.45.0
version: 0.45.0 version: 0.45.0
'@effect/vitest':
specifier: 4.0.0-rc.115
version: 4.0.0-rc.115(effect@4.0.0-rc.115)(vitest@5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1)))
'@types/node':
specifier: ^26.6.1
version: 26.6.1
typescript: typescript:
specifier: ^7.0.2 specifier: ^7.0.2
version: 7.0.2 version: 7.0.2
vitest:
specifier: ^5.0.1
version: 5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1))
vscode-languageserver:
specifier: ^10.1.1
version: 10.1.1
packages: packages:
@@ -294,6 +315,25 @@ packages:
resolution: {integrity: sha512-gPG0ixVHgtYBUe5DbKX9vzHlhqd9+ZEfs4m/XWy0808av0A2XqMzaG3F5rnPBN7EUJRPlo9TluIi8WHx/pBV+A==} resolution: {integrity: sha512-gPG0ixVHgtYBUe5DbKX9vzHlhqd9+ZEfs4m/XWy0808av0A2XqMzaG3F5rnPBN7EUJRPlo9TluIi8WHx/pBV+A==}
hasBin: true hasBin: true
'@effect/vitest@4.0.0-rc.115':
resolution: {integrity: sha512-DJR0hfdr3HNXAkzW8AzfvUz6VmrNFLrKk9tZGDURi33faMyxkc7ZewxELMMZwGpOTGN4mfqYVbX2CXgI+ZJBeQ==}
peerDependencies:
effect: ^4.0.0-rc.115
vitest: '>=5.0.0 <6.0.0'
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.6.0':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@oxc-project/types@0.150.0':
resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==}
'@redis/bloom@6.2.1': '@redis/bloom@6.2.1':
resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==} resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==}
engines: {node: '>= 20.0.0'} engines: {node: '>= 20.0.0'}
@@ -330,6 +370,114 @@ packages:
peerDependencies: peerDependencies:
'@redis/client': ^6.2.1 '@redis/client': ^6.2.1
'@rolldown/binding-android-arm-eabi@1.2.9':
resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@rolldown/binding-android-arm64@1.2.9':
resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.2.9':
resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.2.9':
resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.2.9':
resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.2.9':
resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.2.9':
resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.2.9':
resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.2.9':
resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.2.9':
resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.2.9':
resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.2.9':
resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.2.9':
resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-win32-arm64-msvc@1.2.9':
resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.2.9':
resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/node@26.6.1': '@types/node@26.6.1':
resolution: {integrity: sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==} resolution: {integrity: sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==}
@@ -456,17 +604,194 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@vitest/mocker@5.0.1':
resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/spy@5.0.1':
resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
cluster-key-slot@1.1.2: cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
effect@4.0.0-rc.115: effect@4.0.0-rc.115:
resolution: {integrity: sha512-ogYulZ5ffeOzrJqQrG0XOkDO5lKn2s9KNHhoxJy/6wKUkF0i12dlVXWA9TgsgQ+zV/JzizyG0+XMPepZEQX6vw==} resolution: {integrity: sha512-ogYulZ5ffeOzrJqQrG0XOkDO5lKn2s9KNHhoxJy/6wKUkF0i12dlVXWA9TgsgQ+zV/JzizyG0+XMPepZEQX6vw==}
es-module-lexer@2.3.2:
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.33.0:
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.33.0:
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.33.0:
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.33.0:
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.33.0:
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.33.0:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.33.0:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.33.0:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.33.0:
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.33.0:
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.33.0:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
magic-string@1.4.1:
resolution: {integrity: sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==}
nanoid@3.3.19:
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
obug@2.2.1:
resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
engines: {node: '>=12.20.0'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
redis@6.2.1: redis@6.2.1:
resolution: {integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==} resolution: {integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==}
engines: {node: '>= 20.0.0'} engines: {node: '>= 20.0.0'}
rolldown@1.2.9:
resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
tinybench@6.1.4:
resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==}
engines: {node: '>=20.0.0'}
tinyexec@1.3.0:
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
engines: {node: '>=18'}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
typescript@7.0.2: typescript@7.0.2:
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
engines: {node: '>=16.20.0'} engines: {node: '>=16.20.0'}
@@ -479,6 +804,115 @@ packages:
resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
engines: {node: '>=22.19.0'} engines: {node: '>=22.19.0'}
vite@8.3.0:
resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.7.1
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vitest@5.0.1:
resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.1
'@vitest/browser-preview': 5.0.1
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.1
'@vitest/coverage-v8': 5.0.1
'@vitest/ui': 5.0.1
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-jsonrpc@9.0.2:
resolution: {integrity: sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==}
engines: {node: '>=14.0.0'}
vscode-languageserver-protocol@3.18.3:
resolution: {integrity: sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==}
vscode-languageserver-textdocument@1.0.14:
resolution: {integrity: sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ==}
vscode-languageserver-types@3.18.3:
resolution: {integrity: sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==}
vscode-languageserver@10.1.1:
resolution: {integrity: sha512-/cG04Fiq99rEtoDrfPpv0k6NyUFlck5GNtZLQZv1rxkBkc/4FrJNvEKTN5k1Si3O1hRguP++ciYlX9cdjdYxFQ==}
hasBin: true
vscode-uri@3.2.0:
resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
ws@8.21.3: ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@@ -578,6 +1012,22 @@ snapshots:
'@effect/tsgo-win32-arm64': 0.45.0 '@effect/tsgo-win32-arm64': 0.45.0
'@effect/tsgo-win32-x64': 0.45.0 '@effect/tsgo-win32-x64': 0.45.0
'@effect/vitest@4.0.0-rc.115(effect@4.0.0-rc.115)(vitest@5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1)))':
dependencies:
effect: 4.0.0-rc.115
vitest: 5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1))
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.6.0': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
'@oxc-project/types@0.150.0': {}
'@redis/bloom@6.2.1(@redis/client@6.2.1)': '@redis/bloom@6.2.1(@redis/client@6.2.1)':
dependencies: dependencies:
'@redis/client': 6.2.1 '@redis/client': 6.2.1
@@ -598,6 +1048,62 @@ snapshots:
dependencies: dependencies:
'@redis/client': 6.2.1 '@redis/client': 6.2.1
'@rolldown/binding-android-arm-eabi@1.2.9':
optional: true
'@rolldown/binding-android-arm64@1.2.9':
optional: true
'@rolldown/binding-darwin-arm64@1.2.9':
optional: true
'@rolldown/binding-darwin-x64@1.2.9':
optional: true
'@rolldown/binding-freebsd-x64@1.2.9':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.2.9':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.2.9':
optional: true
'@rolldown/binding-linux-arm64-musl@1.2.9':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.2.9':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.2.9':
optional: true
'@rolldown/binding-linux-x64-gnu@1.2.9':
optional: true
'@rolldown/binding-linux-x64-musl@1.2.9':
optional: true
'@rolldown/binding-openharmony-arm64@1.2.9':
optional: true
'@rolldown/binding-win32-arm64-msvc@1.2.9':
optional: true
'@rolldown/binding-win32-x64-msvc@1.2.9':
optional: true
'@rolldown/pluginutils@1.0.1': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {}
'@types/node@26.6.1': '@types/node@26.6.1':
dependencies: dependencies:
undici-types: 8.9.0 undici-types: 8.9.0
@@ -666,10 +1172,109 @@ snapshots:
'@typescript/typescript-win32-x64@7.0.2': '@typescript/typescript-win32-x64@7.0.2':
optional: true optional: true
'@vitest/mocker@5.0.1(vite@8.3.0(@types/node@26.6.1))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.1
estree-walker: 3.0.3
magic-string: 1.4.1
optionalDependencies:
vite: 8.3.0(@types/node@26.6.1)
'@vitest/spy@5.0.1': {}
assertion-error@2.0.1: {}
chai@6.2.2: {}
cluster-key-slot@1.1.2: {} cluster-key-slot@1.1.2: {}
detect-libc@2.1.2: {}
effect@4.0.0-rc.115: {} effect@4.0.0-rc.115: {}
es-module-lexer@2.3.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
expect-type@1.4.0: {}
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
fsevents@2.3.3:
optional: true
lightningcss-android-arm64@1.33.0:
optional: true
lightningcss-darwin-arm64@1.33.0:
optional: true
lightningcss-darwin-x64@1.33.0:
optional: true
lightningcss-freebsd-x64@1.33.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.33.0:
optional: true
lightningcss-linux-arm64-gnu@1.33.0:
optional: true
lightningcss-linux-arm64-musl@1.33.0:
optional: true
lightningcss-linux-x64-gnu@1.33.0:
optional: true
lightningcss-linux-x64-musl@1.33.0:
optional: true
lightningcss-win32-arm64-msvc@1.33.0:
optional: true
lightningcss-win32-x64-msvc@1.33.0:
optional: true
lightningcss@1.33.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.33.0
lightningcss-darwin-arm64: 1.33.0
lightningcss-darwin-x64: 1.33.0
lightningcss-freebsd-x64: 1.33.0
lightningcss-linux-arm-gnueabihf: 1.33.0
lightningcss-linux-arm64-gnu: 1.33.0
lightningcss-linux-arm64-musl: 1.33.0
lightningcss-linux-x64-gnu: 1.33.0
lightningcss-linux-x64-musl: 1.33.0
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
magic-string@1.4.1:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
nanoid@3.3.19: {}
obug@2.2.1: {}
picocolors@1.1.1: {}
picomatch@4.0.7: {}
postcss@8.5.28:
dependencies:
nanoid: 3.3.19
picocolors: 1.1.1
source-map-js: 1.2.1
redis@6.2.1: redis@6.2.1:
dependencies: dependencies:
'@redis/bloom': 6.2.1(@redis/client@6.2.1) '@redis/bloom': 6.2.1(@redis/client@6.2.1)
@@ -681,6 +1286,44 @@ snapshots:
- '@node-rs/xxhash' - '@node-rs/xxhash'
- '@opentelemetry/api' - '@opentelemetry/api'
rolldown@1.2.9:
dependencies:
'@oxc-project/types': 0.150.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm-eabi': 1.2.9
'@rolldown/binding-android-arm64': 1.2.9
'@rolldown/binding-darwin-arm64': 1.2.9
'@rolldown/binding-darwin-x64': 1.2.9
'@rolldown/binding-freebsd-x64': 1.2.9
'@rolldown/binding-linux-arm-gnueabihf': 1.2.9
'@rolldown/binding-linux-arm64-gnu': 1.2.9
'@rolldown/binding-linux-arm64-musl': 1.2.9
'@rolldown/binding-linux-ppc64-gnu': 1.2.9
'@rolldown/binding-linux-s390x-gnu': 1.2.9
'@rolldown/binding-linux-x64-gnu': 1.2.9
'@rolldown/binding-linux-x64-musl': 1.2.9
'@rolldown/binding-openharmony-arm64': 1.2.9
'@rolldown/binding-win32-arm64-msvc': 1.2.9
'@rolldown/binding-win32-x64-msvc': 1.2.9
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
stackback@0.0.2: {}
std-env@4.2.0: {}
tinybench@6.1.4: {}
tinyexec@1.3.0: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
typescript@7.0.2: typescript@7.0.2:
optionalDependencies: optionalDependencies:
'@typescript/typescript-aix-ppc64': 7.0.2 '@typescript/typescript-aix-ppc64': 7.0.2
@@ -708,4 +1351,58 @@ snapshots:
undici@8.10.2: {} undici@8.10.2: {}
vite@8.3.0(@types/node@26.6.1):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
postcss: 8.5.28
rolldown: 1.2.9
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.6.1
fsevents: 2.3.3
vitest@5.0.1(@types/node@26.6.1)(vite@8.3.0(@types/node@26.6.1)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.6.1))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
magic-string: 1.4.1
obug: 2.2.1
picomatch: 4.0.7
std-env: 4.2.0
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.3.0(@types/node@26.6.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.6.1
transitivePeerDependencies:
- msw
vscode-jsonrpc@9.0.2: {}
vscode-languageserver-protocol@3.18.3:
dependencies:
vscode-jsonrpc: 9.0.2
vscode-languageserver-types: 3.18.3
vscode-languageserver-textdocument@1.0.14: {}
vscode-languageserver-types@3.18.3: {}
vscode-languageserver@10.1.1:
dependencies:
vscode-languageserver-protocol: 3.18.3
vscode-uri@3.2.0: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
ws@8.21.3: {} ws@8.21.3: {}
+319
View File
@@ -0,0 +1,319 @@
/**
* The agent-facing vocabulary of the server.
*
* Everything an agent sends or receives is described here as `Schema`, so the
* same definitions drive MCP tool input/output schemas and the values the
* `lsp/` bridge produces. Positions are 1-based (line and column), the way
* agents and editors display them; the bridge converts to LSP's 0-based form.
*/
import { Option, Predicate, Schema, SchemaGetter } from "effect";
/** An optional field whose key is omitted, rather than set to `undefined`, in JSON output. */
const optional = <S extends Schema.Top>(schema: S) =>
Schema.optional(schema).pipe(
Schema.encodeTo(Schema.optionalKey(schema), {
decode: SchemaGetter.passthrough(),
encode: SchemaGetter.transformOptional(
Option.filter(Predicate.isNotUndefined),
),
}),
);
const positive = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
export const File = Schema.String.annotate({
description: "File path, absolute or relative to the workspace root",
});
export const Line = positive.annotate({ description: "1-based line number" });
export const Column = positive.annotate({
description:
"1-based column, counted in UTF-16 code units (plain characters for ASCII)",
});
export const Where = Schema.Struct({
file: File,
line: Line,
column: optional(
positive.annotate({
description:
"1-based column. Omit to place the cursor on `symbol`, or on the first non-blank character of the line",
}),
),
symbol: optional(
Schema.String.annotate({
description:
"Identifier on the line to place the cursor at; used when `column` is omitted",
}),
),
});
export type Where = typeof Where.Type;
export const Range = Schema.Struct({
line: Line,
column: Column,
endLine: Line,
endColumn: Column,
});
export type Range = typeof Range.Type;
export const Location = Schema.Struct({
file: Schema.String,
...Range.fields,
preview: Schema.String.annotate({
description: "Trimmed source text of the starting line",
}),
});
export type Location = typeof Location.Type;
export const Hover = Schema.Struct({
contents: Schema.String.annotate({ description: "Markdown" }),
range: optional(Range),
});
export type Hover = typeof Hover.Type;
export const GotoKind = Schema.Literals([
"definition",
"typeDefinition",
"implementation",
"declaration",
]);
export const Goto = Schema.Struct({
...Where.fields,
kind: optional(
GotoKind.annotate({
description: "What to navigate to (default: definition)",
}),
),
});
export type Goto = typeof Goto.Type;
export const References = Schema.Struct({
...Where.fields,
includeDeclaration: optional(
Schema.Boolean.annotate({ description: "Default: true" }),
),
});
export type References = typeof References.Type;
export interface DocumentSymbol extends Range {
readonly name: string;
readonly kind: string;
readonly detail?: string | undefined;
readonly container?: string | undefined;
readonly children?: ReadonlyArray<DocumentSymbol> | undefined;
}
export const DocumentSymbol: Schema.Codec<DocumentSymbol> = Schema.Struct({
name: Schema.String,
kind: Schema.String,
detail: optional(Schema.String),
container: optional(Schema.String),
...Range.fields,
children: optional(
Schema.Array(
Schema.suspend((): Schema.Codec<DocumentSymbol> => DocumentSymbol),
),
),
});
export const WorkspaceSymbol = Schema.Struct({
name: Schema.String,
kind: Schema.String,
container: optional(Schema.String),
file: Schema.String,
line: optional(Line),
column: optional(Column),
});
export type WorkspaceSymbol = typeof WorkspaceSymbol.Type;
export const WorkspaceQuery = Schema.Struct({
query: Schema.String.annotate({
description:
"Symbol name or fragment; matching is fuzzy and server-defined",
}),
limit: optional(positive.annotate({ description: "Default: 50" })),
});
export type WorkspaceQuery = typeof WorkspaceQuery.Type;
export const Severity = Schema.Literals([
"error",
"warning",
"information",
"hint",
]);
export type Severity = typeof Severity.Type;
export const Diagnostic = Schema.Struct({
file: Schema.String,
...Range.fields,
severity: Severity,
code: optional(Schema.String),
source: optional(Schema.String),
message: Schema.String,
related: optional(
Schema.Array(
Schema.Struct({
file: Schema.String,
line: Line,
column: Column,
message: Schema.String,
}),
),
),
});
export type Diagnostic = typeof Diagnostic.Type;
export const DiagnosticsQuery = Schema.Struct({
file: optional(
File.annotate({
description:
"Restrict to one file; omit for every file the server has reported on",
}),
),
severity: optional(
Severity.annotate({
description:
"Least severe level to include (default: hint, i.e. everything)",
}),
),
});
export type DiagnosticsQuery = typeof DiagnosticsQuery.Type;
export const Completions = Schema.Struct({
...Where.fields,
prefix: optional(
Schema.String.annotate({
description: "Keep only items whose label starts with this",
}),
),
limit: optional(positive.annotate({ description: "Default: 20" })),
});
export type Completions = typeof Completions.Type;
export const Completion = Schema.Struct({
label: Schema.String,
kind: Schema.String,
detail: optional(Schema.String),
documentation: optional(Schema.String),
});
export type Completion = typeof Completion.Type;
export const Signature = Schema.Struct({
label: Schema.String,
documentation: optional(Schema.String),
parameters: Schema.Array(Schema.String),
activeParameter: optional(Schema.Int),
});
export const SignatureHelp = Schema.Struct({
signatures: Schema.Array(Signature),
active: Schema.Int,
});
export type SignatureHelp = typeof SignatureHelp.Type;
export const TextEdit = Schema.Struct({
...Range.fields,
newText: Schema.String,
});
export const FileEdits = Schema.Struct({
file: Schema.String,
edits: Schema.Array(TextEdit),
});
export const WorkspaceEdit = Schema.Struct({
applied: Schema.Boolean.annotate({
description: "Whether the edits were written to disk",
}),
files: Schema.Array(FileEdits),
operations: Schema.Array(Schema.String).annotate({
description: "File creations, renames, deletions and executed commands",
}),
});
export type WorkspaceEdit = typeof WorkspaceEdit.Type;
const apply = optional(
Schema.Boolean.annotate({
description:
"Write the resulting edits to disk (default: false, preview only)",
}),
);
export const Rename = Schema.Struct({
...Where.fields,
newName: Schema.String,
apply,
});
export type Rename = typeof Rename.Type;
export const Selection = Schema.Struct({
file: File,
line: Line,
column: optional(Column.annotate({ description: "Default: start of line" })),
endLine: optional(Line.annotate({ description: "Default: same as line" })),
endColumn: optional(
Column.annotate({ description: "Default: end of endLine" }),
),
});
export type Selection = typeof Selection.Type;
export const CodeActions = Schema.Struct({
...Selection.fields,
kind: optional(
Schema.String.annotate({
description:
"Only actions of this kind prefix, e.g. quickfix, refactor, refactor.extract, source.organizeImports",
}),
),
});
export type CodeActions = typeof CodeActions.Type;
export const CodeAction = Schema.Struct({
title: Schema.String,
kind: optional(Schema.String),
preferred: Schema.Boolean,
fixes: Schema.Array(Schema.String).annotate({
description: "Messages of the diagnostics this action addresses",
}),
});
export type CodeAction = typeof CodeAction.Type;
export const ApplyCodeAction = Schema.Struct({
...CodeActions.fields,
title: Schema.String.annotate({
description:
"Exact title of an action returned by code_actions for the same selection",
}),
});
export type ApplyCodeAction = typeof ApplyCodeAction.Type;
export const Format = Schema.Struct({
file: File,
tabSize: optional(positive.annotate({ description: "Default: 4" })),
insertSpaces: optional(
Schema.Boolean.annotate({ description: "Default: true" }),
),
apply,
});
export type Format = typeof Format.Type;
export const CallDirection = Schema.Literals(["incoming", "outgoing"]);
export const CallHierarchy = Schema.Struct({
...Where.fields,
direction: optional(
CallDirection.annotate({
description:
"incoming: who calls this; outgoing: what this calls (default: incoming)",
}),
),
});
export type CallHierarchy = typeof CallHierarchy.Type;
export const Call = Schema.Struct({
name: Schema.String,
kind: Schema.String,
detail: optional(Schema.String),
file: Schema.String,
line: Line,
column: Column,
sites: Schema.Array(Schema.Struct({ line: Line, column: Column })).annotate({
description: "Call sites, located in the caller's file",
}),
});
export type Call = typeof Call.Type;
+92
View File
@@ -0,0 +1,92 @@
/**
* MCP resources and notifications: the server's identity and capabilities,
* live diagnostics (subscribable), and the language server's log messages
* forwarded as MCP log notifications.
*/
import { Effect, Layer, Schema, Stream } from "effect";
import { McpSchema, McpServer } from "effect/unstable/ai";
import { Diagnostics } from "./lsp/Diagnostics.ts";
import { Documents } from "./lsp/Documents.ts";
import * as Editor from "./lsp/Editor.ts";
import { LanguageServer } from "./lsp/LanguageServer.ts";
const json = (value: unknown) => JSON.stringify(value, null, 2);
const diagnosticsUri = "lsp://diagnostics";
const fileDiagnosticsUri = (file: string) =>
`${diagnosticsUri}/${encodeURIComponent(file)}`;
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const lsp = yield* LanguageServer;
const docs = yield* Documents;
const diagnostics = yield* Diagnostics;
const mcp = yield* McpServer.McpServer;
yield* McpServer.registerResource({
uri: "lsp://server",
name: "Language server",
description:
"Name, version, workspace root and capabilities of the connected language server",
mimeType: "application/json",
content: Effect.succeed(
json({
...lsp.serverInfo,
root: lsp.root,
capabilities: lsp.capabilities,
}),
),
});
yield* McpServer.registerResource({
uri: diagnosticsUri,
name: "Diagnostics",
description:
"All diagnostics the language server has reported. Subscribe to be told when they change.",
mimeType: "application/json",
content: Effect.map(Editor.diagnostics({}), json),
});
yield* McpServer.registerResource`lsp://diagnostics/${McpSchema.param("file", Schema.String)}`(
{
name: "File diagnostics",
description:
"Diagnostics of one file (path URL-encoded, relative to the workspace root)",
mimeType: "application/json",
completion: {
file: (input) =>
Effect.map(docs.opened, (uris) =>
uris.map(docs.file).filter((file) => file.startsWith(input)),
),
},
content: (_, file) =>
Effect.map(
Editor.diagnostics({ file: decodeURIComponent(file) }),
json,
),
},
);
const updated = (uri: string) =>
mcp.notifications["notifications/resources/updated"]({ uri });
yield* diagnostics.changes.pipe(
Stream.runForEach((uri) =>
Effect.all([
updated(diagnosticsUri),
updated(fileDiagnosticsUri(docs.file(uri))),
]),
),
Effect.forkScoped,
);
yield* lsp.logs.pipe(
Stream.runForEach(({ level, message }) =>
mcp.notifications["notifications/message"]({
level,
logger: "lsp",
data: message,
}),
),
Effect.forkScoped,
);
}),
);
+69
View File
@@ -0,0 +1,69 @@
/**
* Wires the language server, the headless editor and the MCP server together.
*
* Build order matters: the language server initializes first (so tool
* registration can consult its capabilities), then the editor services, then
* the MCP transport, then tools and resources are registered.
*/
import { createServer } from "node:http";
import { NodeHttpServer } from "@effect/platform-node";
import { Effect, Layer } from "effect";
import { McpProtocol, McpServer } from "effect/unstable/ai";
import { HttpRouter } from "effect/unstable/http";
import { Diagnostics } from "./lsp/Diagnostics.ts";
import * as Documents from "./lsp/Documents.ts";
import * as LanguageServer from "./lsp/LanguageServer.ts";
import * as Resources from "./Resources.ts";
import * as Tools from "./Tools.ts";
export type Transport =
| { readonly _tag: "stdio" }
| { readonly _tag: "http"; readonly port: number };
export interface Options {
readonly transport: Transport;
readonly server: LanguageServer.Options;
readonly documents: Documents.Options;
}
const info = {
name: "lsp-mcp",
version: "0.1.0",
protocols: [
McpProtocol.v2025_11_25,
McpProtocol.v2025_06_18,
McpProtocol.v2025_03_26,
McpProtocol.v2024_11_05,
],
} as const;
const transport = (transport: Transport) =>
transport._tag === "stdio"
? McpServer.layerStdio(info)
: HttpRouter.serve(McpServer.layerHttp({ ...info, path: "/mcp" }), {
disableLogger: true,
}).pipe(
Layer.provide(
NodeHttpServer.layer(createServer, { port: transport.port }),
),
);
/** Registers the tools the connected server can serve. */
export const tools = Layer.effectDiscard(
Effect.gen(function* () {
const { capabilities } = yield* LanguageServer.LanguageServer;
yield* McpServer.registerToolkit(Tools.supportedBy(capabilities));
}),
).pipe(Layer.provide(Tools.layer));
export const layer = (options: Options) =>
Layer.mergeAll(tools, Resources.layer).pipe(
Layer.provide(transport(options.transport)),
Layer.provide(
Layer.mergeAll(
Documents.Documents.layer(options.documents),
Diagnostics.layer,
),
),
Layer.provide(LanguageServer.LanguageServer.layer(options.server)),
);
+167
View File
@@ -0,0 +1,167 @@
/**
* The MCP tools: one per editor operation, described for agents. Tools whose
* capability the connected language server lacks are not offered.
*/
import { Schema } from "effect";
import { Tool, Toolkit } from "effect/unstable/ai";
import type { ServerCapabilities } from "vscode-languageserver-protocol";
import { Diagnostics } from "./lsp/Diagnostics.ts";
import { DocumentError, Documents } from "./lsp/Documents.ts";
import * as Editor from "./lsp/Editor.ts";
import { LanguageServer, LspError } from "./lsp/LanguageServer.ts";
import * as Model from "./Model.ts";
const Failure = Schema.Union([LspError, DocumentError]);
const define = <
const Name extends string,
Parameters extends Schema.Constraint,
Success extends Schema.Constraint,
>(
name: Name,
options: {
readonly description: string;
readonly parameters: Parameters;
readonly success: Success;
readonly mutates?: boolean;
},
) =>
Tool.make(name, {
description: options.description,
parameters: options.parameters,
success: options.success,
failure: Failure,
dependencies: [LanguageServer, Documents, Diagnostics],
})
.annotate(Tool.Readonly, !options.mutates)
.annotate(Tool.Destructive, false)
.annotate(Tool.Idempotent, !options.mutates)
.annotate(Tool.OpenWorld, false);
const positions =
"Positions are 1-based lines and columns; instead of a column you may name the `symbol` on that line to point at.";
const preview =
"Without `apply` the edits are only previewed; with `apply: true` they are written to disk.";
export const Tools = Toolkit.make(
define("hover", {
description: `Type signature and documentation of the symbol at a position, as an editor shows on hover. ${positions}`,
parameters: Model.Where,
success: Schema.NullOr(Model.Hover),
}),
define("definition", {
description: `Where the symbol at a position is defined (or its type definition, implementations, or declaration). Returns locations with a preview of each target line. ${positions}`,
parameters: Model.Goto,
success: Schema.Array(Model.Location),
}),
define("references", {
description: `Every place the symbol at a position is used across the workspace, with a preview of each line. ${positions}`,
parameters: Model.References,
success: Schema.Array(Model.Location),
}),
define("document_symbols", {
description:
"Outline of a file: its functions, classes, methods, variables and so on, with their ranges, as a tree.",
parameters: Schema.Struct({ file: Model.File }),
success: Schema.Array(Model.DocumentSymbol),
}),
define("workspace_symbols", {
description:
"Find symbols by name across the whole workspace, including files you have not opened.",
parameters: Model.WorkspaceQuery,
success: Schema.Array(Model.WorkspaceSymbol),
}),
define("diagnostics", {
description:
"Errors, warnings and hints from the language server. Files are re-read from disk first, so call this right after editing to check your changes. Without `file`, returns everything reported so far.",
parameters: Model.DiagnosticsQuery,
success: Schema.Array(Model.Diagnostic),
}),
define("completions", {
description: `Completion items at a position: discover members, imports, or valid identifiers the server suggests. ${positions}`,
parameters: Model.Completions,
success: Schema.Array(Model.Completion),
}),
define("signature_help", {
description: `Signatures and parameters of the call surrounding a position. ${positions}`,
parameters: Model.Where,
success: Model.SignatureHelp,
}),
define("rename", {
description: `Rename the symbol at a position everywhere it is referenced, across files. ${positions} ${preview}`,
parameters: Model.Rename,
success: Model.WorkspaceEdit,
mutates: true,
}),
define("code_actions", {
description:
"Quick fixes and refactorings the server offers for a selection (whole lines unless columns are given), including fixes for the diagnostics there. Apply one with apply_code_action.",
parameters: Model.CodeActions,
success: Schema.Array(Model.CodeAction),
}),
define("apply_code_action", {
description:
"Apply a code action by title, for the same selection given to code_actions. Edits are written to disk.",
parameters: Model.ApplyCodeAction,
success: Model.WorkspaceEdit,
mutates: true,
}),
define("format", {
description: `Format a whole file with the language server's formatter. ${preview}`,
parameters: Model.Format,
success: Model.WorkspaceEdit,
mutates: true,
}),
define("call_hierarchy", {
description: `Callers of the function at a position (incoming) or the functions it calls (outgoing). ${positions}`,
parameters: Model.CallHierarchy,
success: Schema.Array(Model.Call),
}),
);
export const layer = Tools.toLayer({
hover: Editor.hover,
definition: Editor.definition,
references: Editor.references,
document_symbols: Editor.documentSymbols,
workspace_symbols: Editor.workspaceSymbols,
diagnostics: Editor.diagnostics,
completions: Editor.completions,
signature_help: Editor.signatureHelp,
rename: Editor.rename,
code_actions: Editor.codeActions,
apply_code_action: Editor.applyCodeAction,
format: Editor.format,
call_hierarchy: Editor.callHierarchy,
});
const requirements: Record<
keyof typeof Tools.tools,
(c: ServerCapabilities) => unknown
> = {
hover: (c) => c.hoverProvider,
definition: (c) =>
c.definitionProvider ??
c.typeDefinitionProvider ??
c.implementationProvider ??
c.declarationProvider,
references: (c) => c.referencesProvider,
document_symbols: (c) => c.documentSymbolProvider,
workspace_symbols: (c) => c.workspaceSymbolProvider,
diagnostics: () => true,
completions: (c) => c.completionProvider,
signature_help: (c) => c.signatureHelpProvider,
rename: (c) => c.renameProvider,
code_actions: (c) => c.codeActionProvider,
apply_code_action: (c) => c.codeActionProvider,
format: (c) => c.documentFormattingProvider,
call_hierarchy: (c) => c.callHierarchyProvider,
};
/** The subset of `Tools` the server can serve; handled by the same `layer`. */
export const supportedBy = (capabilities: ServerCapabilities): typeof Tools =>
Toolkit.make(
...Object.values(Tools.tools).filter((tool) =>
requirements[tool.name](capabilities),
),
) as typeof Tools;
+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
? findSymbol(text, where.symbol)
: 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 } };
});
/** Index of `symbol` as a whole word on the line, else its first occurrence, else -1. */
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"),
);
return index >= 0 ? index : text.indexOf(symbol);
};
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);
}
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env node
/**
* CLI entry point.
*
* lsp-mcp [--stdio | --http <port>] [--root <dir>] -- <server> [args...]
*/
import { NodeRuntime, NodeServices } from "@effect/platform-node";
import { Cause, Effect, Layer, Option, Path, References, Schema } from "effect";
import { Argument, Command, Flag } from "effect/unstable/cli";
import * as Server from "./Server.ts";
const Json = Flag.withSchema(Schema.fromJsonString(Schema.Json));
const command = Command.make(
"lsp-mcp",
{
transport: Flag.Int("http").pipe(
Flag.withDescription(
"Serve MCP over Streamable HTTP at http://localhost:<port>/mcp",
),
Flag.map((port) => ({ _tag: "http", port }) as const),
Flag.orElse(() =>
Flag.Boolean("stdio").pipe(
Flag.withDescription("Serve MCP over stdio (the default)"),
Flag.withDefault(false),
Flag.map(() => ({ _tag: "stdio" }) as const),
),
),
),
root: Flag.Directory("root", { mustExist: true }).pipe(
Flag.withAlias("r"),
Flag.withDescription("Workspace root handed to the language server"),
Flag.withDefault("."),
),
initOptions: Flag.String("init-options").pipe(
Json,
Flag.withDescription(
"JSON passed to the language server as initializationOptions",
),
Flag.optional,
),
settings: Flag.String("settings").pipe(
Json,
Flag.withDescription(
"JSON served as workspace/configuration and sent as didChangeConfiguration",
),
Flag.optional,
),
languages: Flag.KeyValuePair("language").pipe(
Flag.withDescription(
"Extra file extension to languageId mapping, e.g. --language vue=vue",
),
Flag.withDefault({}),
),
capacity: Flag.Int("open-documents").pipe(
Flag.withDescription(
"Documents kept open in the language server before the least recently used is closed",
),
Flag.withDefault(32),
),
command: Argument.String("server").pipe(
Argument.withDescription("Language server executable"),
),
args: Argument.String("args").pipe(
Argument.withDescription(
"Arguments for the language server; put `--` before them",
),
Argument.variadic(),
),
},
Effect.fn(function* ({
transport,
root,
initOptions,
settings,
languages,
capacity,
command,
args,
}) {
const path = yield* Path.Path;
return yield* Layer.launch(
Server.layer({
transport,
server: {
// a relative executable path is relative to where lsp-mcp was started, not to the root
command: command.includes("/") ? path.resolve(command) : command,
args,
root: path.resolve(root),
initializationOptions: Option.getOrUndefined(initOptions),
settings: Option.getOrUndefined(settings),
},
documents: { capacity, languages },
}),
);
}),
).pipe(
Command.withDescription(
"Expose a Language Server to MCP clients as a headless editor",
),
Command.withExamples([
{
command: "lsp-mcp -- typescript-language-server --stdio",
description: "TypeScript over stdio",
},
{
command: "lsp-mcp --http 3000 --root ./crate -- rust-analyzer",
description: "Rust over HTTP",
},
{
command: `lsp-mcp --settings '{"gopls":{"staticcheck":true}}' -- gopls`,
description: "Go with workspace settings",
},
]),
);
command.pipe(
Command.run({ version: "0.1.0" }),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logError(cause),
),
// stdout may be the MCP channel, so every log line goes to stderr
Effect.provideService(References.LogToStderr, true),
Effect.provide(NodeServices.layer),
NodeRuntime.runMain({ disableErrorReporting: true }),
);
+171
View File
@@ -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/,
);
}),
);
},
);
});
+144
View File
@@ -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/{file}"],
);
}).pipe(Effect.scoped),
);
});
+160
View File
@@ -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();
+23
View File
@@ -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,
});
}),
);
+5 -28
View File
@@ -2,50 +2,27 @@
"$schema": "./node_modules/@effect/tsgo/schema.json", "$schema": "./node_modules/@effect/tsgo/schema.json",
"compilerOptions": { "compilerOptions": {
"noEmit": true, "noEmit": true,
// File Layout
// "rootDir": "./src",
// "outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext", "module": "nodenext",
"target": "esnext", "target": "esnext",
"types": [], "types": ["node"],
// For nodejs: "allowImportingTsExtensions": true,
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true, "exactOptionalPropertyTypes": true,
// Style Options
"noImplicitReturns": true, "noImplicitReturns": true,
"noImplicitOverride": true, "noImplicitOverride": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true, "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true, "strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"isolatedModules": true, "isolatedModules": true,
"erasableSyntaxOnly": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
"moduleDetection": "force", "moduleDetection": "force",
"skipLibCheck": true, "skipLibCheck": true,
"plugins": [ "plugins": [{ "name": "@effect/language-service" }]
{
"name": "@effect/language-service"
}
]
}, },
"include": ["src", "test", "vitest.config.ts"],
"exclude": ["node_modules", "repos"] "exclude": ["node_modules", "repos"]
} }
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
testTimeout: 20_000,
},
});