Skip to content

Commit 832e198

Browse files
committed
feat: named pipe
1 parent 95ffa5a commit 832e198

File tree

9 files changed

+1046
-11
lines changed

9 files changed

+1046
-11
lines changed

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ npm install mcp-lsp-driver
1616
pnpm add mcp-lsp-driver
1717
```
1818

19+
The package ships dual ESM and CJS builds, so both `import` and `require` work out of the box.
20+
1921
## Quick Start
2022

2123
```typescript
@@ -582,6 +584,53 @@ type EditResult =
582584
| { success: false; message: string; reason: 'UserRejected' | 'IOError' | 'ValidationFailed' }
583585
```
584586
587+
## Pipe IPC (Out-of-Process)
588+
589+
When the MCP server runs in a separate process from the IDE plugin (e.g., spawned via stdio transport), the Pipe IPC layer lets the two communicate over a named pipe.
590+
591+
**IDE plugin side** — expose capabilities:
592+
593+
```typescript
594+
import { serveLspPipe, type IdeCapabilities } from 'mcp-lsp-driver'
595+
596+
const capabilities: IdeCapabilities = {
597+
fileAccess: { /* ... */ },
598+
definition: { /* ... */ },
599+
// ...
600+
}
601+
602+
const server = await serveLspPipe({
603+
pipeName: 'my-ide-lsp',
604+
capabilities,
605+
})
606+
// server.pipePath — the resolved pipe path
607+
// server.connectionCount — number of connected clients
608+
// await server.close() — shut down
609+
```
610+
611+
**MCP server side** — connect and use proxy capabilities:
612+
613+
```typescript
614+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
615+
import { connectLspPipe, installMcpLspDriver } from 'mcp-lsp-driver'
616+
617+
const conn = await connectLspPipe({
618+
pipeName: 'my-ide-lsp',
619+
connectTimeout: 5000, // optional, default 5000ms
620+
})
621+
622+
// conn.capabilities is a full IdeCapabilities proxy
623+
// conn.availableMethods lists the methods the server exposes
624+
625+
const mcpServer = new McpServer({ name: 'my-mcp', version: '1.0.0' })
626+
installMcpLspDriver({ server: mcpServer, capabilities: conn.capabilities })
627+
628+
// When done:
629+
conn.disconnect()
630+
```
631+
632+
The handshake automatically discovers which providers the server exposes and builds typed proxies. Diagnostics change notifications (`onDiagnosticsChanged`) are forwarded as push notifications to all connected clients. Multiple clients can connect to the same pipe simultaneously.
633+
585634
## Development
586635

587636
```bash

package.json

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
{
22
"name": "mcp-lsp-driver",
3-
"version": "1.0.2",
3+
"version": "1.1.0",
44
"description": "MCP LSP Driver for IDE plugins to easily expose LSP features via an MCP server.",
55
"type": "module",
6-
"main": "dist/index.js",
6+
"main": "dist/index.cjs",
77
"types": "dist/index.d.ts",
88
"exports": {
99
".": {
10-
"import": "./dist/index.js",
11-
"types": "./dist/index.d.ts"
10+
"import": {
11+
"types": "./dist/index.d.ts",
12+
"default": "./dist/index.js"
13+
},
14+
"require": {
15+
"types": "./dist/index.d.cts",
16+
"default": "./dist/index.cjs"
17+
}
1218
}
1319
},
14-
"files": ["dist/**/*.js", "dist/**/*.d.ts", "LICENSE"],
20+
"files": [
21+
"dist/**/*.js",
22+
"dist/**/*.cjs",
23+
"dist/**/*.d.ts",
24+
"dist/**/*.d.cts",
25+
"LICENSE"
26+
],
1527
"scripts": {
1628
"build": "tsup",
1729
"test": "vitest run",

src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,15 @@ export type {
2828
EditProvider,
2929
FileAccessProvider,
3030
} from './interfaces.js'
31+
export type { ConnectLspPipeOptions, LspPipeConnection } from './pipe-client.js'
32+
export { connectLspPipe } from './pipe-client.js'
33+
export type { LspPipeServer, ServeLspPipeOptions } from './pipe-server.js'
34+
// Pipe IPC
35+
export { serveLspPipe } from './pipe-server.js'
3136
export type { ResolverConfig } from './resolver.js'
32-
3337
// Symbol Resolver
3438
export { SymbolResolutionError, SymbolResolver } from './resolver.js'
3539
export type { McpLspDriverConfig } from './server.js'
36-
3740
// Driver
3841
export { installMcpLspDriver } from './server.js'
3942
// Core Data Models

src/pipe-client.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { connect } from 'node:net'
2+
import type { IdeCapabilities } from './capabilities.js'
3+
import { PipeTransport, PROVIDER_METHODS, toPipePath } from './pipe-protocol.js'
4+
5+
export interface ConnectLspPipeOptions {
6+
pipeName: string
7+
connectTimeout?: number
8+
}
9+
10+
export interface LspPipeConnection {
11+
readonly capabilities: IdeCapabilities
12+
readonly availableMethods: string[]
13+
disconnect(): void
14+
}
15+
16+
export function connectLspPipe(
17+
options: ConnectLspPipeOptions,
18+
): Promise<LspPipeConnection> {
19+
const { pipeName, connectTimeout = 5000 } = options
20+
const pipePath = toPipePath(pipeName)
21+
22+
return new Promise((resolve, reject) => {
23+
const socket = connect(pipePath)
24+
let settled = false
25+
26+
const timer = setTimeout(() => {
27+
if (!settled) {
28+
settled = true
29+
socket.destroy()
30+
reject(new Error(`Connection timeout after ${connectTimeout}ms`))
31+
}
32+
}, connectTimeout)
33+
34+
socket.on('error', (err) => {
35+
if (!settled) {
36+
settled = true
37+
clearTimeout(timer)
38+
reject(err)
39+
}
40+
})
41+
42+
socket.on('connect', () => {
43+
clearTimeout(timer)
44+
if (settled) return
45+
46+
let diagnosticsCallback: ((uri: string) => void) | undefined
47+
48+
const transport = new PipeTransport(socket, {
49+
onNotification: (method, params) => {
50+
if (method === 'onDiagnosticsChanged' && diagnosticsCallback) {
51+
diagnosticsCallback(params[0] as string)
52+
}
53+
},
54+
})
55+
56+
// Perform handshake
57+
transport
58+
.sendRequest('_handshake', [])
59+
.then((raw) => {
60+
if (settled) return
61+
const handshakeResult = raw as { methods: string[] }
62+
const availableMethods = handshakeResult.methods
63+
64+
// Validate required methods
65+
if (
66+
!availableMethods.includes('fileAccess.readFile') ||
67+
!availableMethods.includes('fileAccess.readDirectory')
68+
) {
69+
transport.destroy()
70+
throw new Error(
71+
'Server missing required methods: fileAccess.readFile, fileAccess.readDirectory',
72+
)
73+
}
74+
75+
// Build proxy capabilities
76+
const capabilities = {} as IdeCapabilities
77+
78+
for (const { providerKey, methods } of PROVIDER_METHODS) {
79+
const present = methods.filter((m) =>
80+
availableMethods.includes(`${providerKey}.${m}`),
81+
)
82+
if (present.length === 0) continue
83+
84+
const provider: Record<
85+
string,
86+
(...args: unknown[]) => Promise<unknown>
87+
> = {}
88+
for (const method of present) {
89+
provider[method] = (...args: unknown[]) =>
90+
transport.sendRequest(`${providerKey}.${method}`, args)
91+
}
92+
;(capabilities as unknown as Record<string, unknown>)[providerKey] =
93+
provider
94+
}
95+
96+
// Handle onDiagnosticsChanged
97+
if (availableMethods.includes('onDiagnosticsChanged')) {
98+
capabilities.onDiagnosticsChanged = (callback) => {
99+
diagnosticsCallback = callback
100+
}
101+
}
102+
103+
settled = true
104+
resolve({
105+
capabilities,
106+
availableMethods,
107+
disconnect() {
108+
transport.destroy()
109+
},
110+
})
111+
})
112+
.catch((err: unknown) => {
113+
if (!settled) {
114+
settled = true
115+
transport.destroy()
116+
reject(err instanceof Error ? err : new Error(String(err)))
117+
}
118+
})
119+
})
120+
})
121+
}

0 commit comments

Comments
 (0)