How to Build an MCP Language Server Bridge
Connect an MCP AI client to an LSP language server with a practical TypeScript bridge, explicit schemas, secure transports, and validation steps.
Direct answer: Build a small MCP server that launches or connects to an LSP server, exposes a focused set of language features as MCP tools, translates validated tool arguments into LSP requests, and converts LSP responses into stable MCP results. Keep workspace, document version, and authorization context explicit in every request. Start with read-only operations such as hover, definition lookup, or diagnostics, then validate the bridge with MCP Inspector before adding edits.
What the bridge connects
The Language Server Protocol (LSP) standardizes communication between an editor or IDE and a language server. A language server supplies features such as completion, navigation, references, hover information, and diagnostics. The published specification is version 3.18.
The Model Context Protocol (MCP) is a separate client-server protocol for AI applications. Its JSON-RPC data layer lets a server expose tools, resources, and prompts over transports such as local stdio or remote Streamable HTTP.
An MCP-LSP bridge is an adapter. It is not a protocol feature mandated by either standard. Your bridge chooses which LSP capabilities to expose, defines MCP tool schemas, manages the language-server process and document state, and presents results that an AI host can use safely.
Architecture
AI host (Claude, Cursor, or another MCP client)
|
| MCP JSON-RPC over stdio or Streamable HTTP
v
MCP bridge
- validates tool input
- checks authorization
- selects workspace and document version
- translates MCP arguments to LSP messages
|
| LSP JSON-RPC over the language server's stdio
v
Language server
|
v
source files, indexes, diagnostics, symbols
Keep the boundary deliberate. A tool named hover should accept a URI, line, character, workspace identifier, and optional document version. It should return the hover contents and source range in a predictable shape. Do not expose arbitrary JSON-RPC forwarding unless you can validate every method and authorization decision.
Choose the first feature set
Begin with one language and one recognizable task. Focused tools have clearer schemas and produce more useful model decisions than a generic pass-through interface.
| MCP tool | LSP operation | Useful first result | Failure to define |
|---|---|---|---|
language_hover |
textDocument/hover |
Documentation and type at a position | No hover support or position outside the document |
language_definition |
textDocument/definition |
Target file and range | No definition or multiple targets |
language_diagnostics |
textDocument/publishDiagnostics notifications |
Current diagnostics for a document | Diagnostics have not arrived yet |
language_symbols |
textDocument/documentSymbol |
Symbols grouped by document | Server does not advertise symbols |
Read the server’s initialization result and capabilities. If a capability is absent, return a clear unsupported response. Never manufacture an empty success that looks like a valid language result.
Project setup in TypeScript
The official MCP implementation guide lists TypeScript and Python SDKs. The TypeScript SDK v2 documentation uses McpServer, schema-validated tool registration, and serveStdio. The example below uses those building blocks plus JSON-RPC messages over a language server process.
mkdir mcp-lsp-bridge
cd mcp-lsp-bridge
npm init -y
npm install @modelcontextprotocol/sdk zod vscode-jsonrpc
npm install -D typescript tsx @types/node
npx tsc --init --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
Create a language-server command that accepts LSP messages on stdin and writes them to stdout. The command varies by language server; the bridge should receive it from configuration rather than hard-code a path.
Minimal bridge implementation
This example exposes a read-only hover tool. It illustrates the lifecycle and translation points you should expand for definitions, references, diagnostics, and symbols.
import { ChildProcess, spawn } from "node:child_process";
import { once } from "node:events";
import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url";
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
type MessageConnection,
type InitializeResult,
} from "vscode-jsonrpc/node";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { serveStdio } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
type LspPosition = { line: number; character: number };
type LspHover = {
contents: unknown;
range?: { start: LspPosition; end: LspPosition };
};
type BridgeConfig = {
command: string;
args: string[];
workspaceUri: string;
};
class LspClient {
private process: ChildProcess;
private connection: MessageConnection;
private initialized = false;
private constructor(process: ChildProcess, connection: MessageConnection) {
this.process = process;
this.connection = connection;
}
static async start(config: BridgeConfig): Promise<LspClient> {
const child = spawn(config.command, config.args, {
stdio: ["pipe", "pipe", "inherit"],
env: process.env,
});
if (!child.stdin || !child.stdout) throw new Error("Language server has no stdio");
const connection = createMessageConnection(
new StreamMessageReader(child.stdout),
new StreamMessageWriter(child.stdin),
);
connection.listen();
const client = new LspClient(child, connection);
const result = await connection.sendRequest<InitializeResult>("initialize", {
processId: process.pid,
rootUri: config.workspaceUri,
workspaceFolders: [{ uri: config.workspaceUri, name: "workspace" }],
capabilities: {},
});
client.initialized = true;
await connection.sendNotification("initialized", {});
return client;
}
async open(uri: string, languageId: string, text: string, version: number) {
if (!this.initialized) throw new Error("Language server is not initialized");
await this.connection.sendNotification("textDocument/didOpen", {
textDocument: { uri, languageId, version, text },
});
}
hover(uri: string, position: LspPosition) {
return this.connection.sendRequest<LspHover | null>("textDocument/hover", {
textDocument: { uri },
position,
});
}
async stop() {
try {
await this.connection.sendRequest("shutdown");
await this.connection.sendNotification("exit");
} finally {
this.connection.dispose();
this.process.kill();
}
}
}
const config: BridgeConfig = {
command: process.env.LSP_COMMAND ?? "typescript-language-server",
args: (process.env.LSP_ARGS ?? "--stdio").split(" ").filter(Boolean),
workspaceUri: process.env.WORKSPACE_URI ?? "file:///workspace",
};
const mcp = new McpServer({ name: "mcp-lsp-bridge", version: "0.1.0" });
const lspPromise = LspClient.start(config);
mcp.tool(
"language_hover",
"Return language-server hover information at a document position.",
{
uri: z.string().url(),
languageId: z.string().min(1),
text: z.string(),
version: z.number().int().nonnegative(),
line: z.number().int().nonnegative(),
character: z.number().int().nonnegative(),
},
async ({ uri, languageId, text, version, line, character }) => {
try {
const lsp = await lspPromise;
await lsp.open(uri, languageId, text, version);
const result = await lsp.hover(uri, { line, character });
if (!result) {
return { content: [{ type: "text", text: "No hover information at this position." }] };
}
return {
content: [{ type: "text", text: JSON.stringify(result) }],
};
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown language-server error";
return {
isError: true,
content: [{ type: "text", text: `language_hover failed: ${message}` }],
};
}
},
);
await serveStdio(mcp);
Run it with a language server command available on your PATH:
LSP_COMMAND=typescript-language-server \
LSP_ARGS="--stdio" \
WORKSPACE_URI="file:///absolute/path/to/project" \
npx tsx bridge.ts
For production, add didChange and didClose handling, document version checks, workspace validation, request cancellation, process restart policy, and capability inspection. If the language server expects a different initialization shape, adapt the initialization request to that server’s documented behavior.
Document and workspace handling
Make context explicit
The MCP basic specification states: “The Model Context Protocol (MCP) is a stateless protocol: all the information needed to process a request is contained in the request itself.” Do not infer a workspace from the identity of a connection or stdio process. Pass a validated workspace identifier, URI, document URI, and document version with each operation, or use an explicit server-side identifier whose authorization and lifetime are defined.
Synchronize document contents
- Normalize the URI and verify it belongs to an allowed workspace.
- Send
didOpenonce with the full text and a starting version. - For changed files, send
didChangewith monotonically increasing versions or the synchronization mode advertised by the server. - Reject stale versions instead of silently querying an older buffer.
- Send
didClosewhen the bridge releases a document.
Some servers index files from disk and others depend on the open-document text. Treat the server’s advertised synchronization capabilities as authoritative.
Route multiple languages safely
A multi-language bridge can maintain one client per language and workspace, but every route must enforce workspace isolation and capability checks. Keep tool names stable while routing internally, or expose language-specific tools when behavior differs enough to confuse callers.
Transport choices
| Transport | Use it when | Operational concerns |
|---|---|---|
| stdio | The AI host launches the bridge locally | Simple process communication; secure local boundaries still require filesystem and process permissions |
| Streamable HTTP | Several clients or a remote deployment need access | HTTPS, authorization on every request, streaming behavior, reachability, secrets, logs, tracing, and rollback |
The MCP architecture keeps the JSON-RPC message format independent from the transport. Choose stdio for a local bridge with no network hop. Choose Streamable HTTP when remote reachability is required, then enforce the MCP authorization framework and stable HTTPS deployment guidance.
Security requirements
- Authorize every request. Do not rely on the model to decide whether a tool call is permitted. Validate credentials before routing to an LSP workspace.
- Constrain file access. Resolve URIs, reject path traversal, and allow only configured workspace roots.
- Separate projects. Never let a document URI from one tenant reach another tenant’s language-server process.
- Protect secrets. Keep tokens out of tool results, logs, diagnostics, and exception messages.
- Keep read and write tools distinct. Read-only hover and diagnostics need narrower authorization than edits. Tool annotations must reflect actual behavior and do not replace authorization.
- Limit resource use. Bound document sizes, concurrent requests, subprocess counts, and request duration.
- Validate subprocess configuration. Do not accept an arbitrary command from an untrusted MCP caller.
Errors, cancellation, and capability gaps
Map failures into actionable MCP results. Include a stable error category and a human-readable message, but omit credentials and internal paths.
| Condition | Bridge behavior |
|---|---|
| Language server cannot start | Return language_server_unavailable; include installation or configuration guidance |
| Initialization fails | Mark the client unusable, record the server error, and restart only under a bounded policy |
| Capability is absent | Return unsupported_operation, not an empty success |
| Invalid URI or position | Reject before sending LSP traffic |
| Request timeout | Cancel when supported, return timeout, and keep the process healthy |
| Malformed LSP response | Return invalid_language_server_response and capture a redacted diagnostic |
| Stale document version | Ask the caller to resynchronize or send the current document |
Use deadlines for initialization and every tool call. A stuck language server should not block all MCP requests indefinitely. Decide whether to restart after repeated timeouts and expose health state to operators.
Validation with MCP Inspector
The OpenAI MCP server guide recommends inspecting initialization, instructions, advertised tools, representative and invalid inputs, schemas, results, errors, annotations, and authorization. Add bridge-specific cases:
- Start with the expected language-server command and verify initialization completes.
- Confirm the bridge advertises only the intended tools and schemas.
- Call hover on a valid position in an opened document.
- Call it with a malformed URI, negative position, oversized text, and stale version.
- Test a language server that lacks the requested capability.
- Kill the subprocess and verify the bridge reports and recovers from unavailability.
- Force a timeout and confirm cancellation or bounded cleanup.
- For HTTP, test missing, expired, and insufficient credentials on every request.
- Check that results contain no access tokens, unapproved file contents, or internal secrets.
Performance and reliability
- Process reuse: Keep a warm language-server process when the host and authorization boundary allow it. Startup and indexing can dominate latency.
- Workspace pools: Bound the number of concurrent processes. Evict idle workspaces deliberately and close documents before shutdown.
- Request limits: Cap text size, result size, and execution time. Prefer pagination or focused tools for large symbol sets.
- Cache carefully: Cache only results keyed by workspace, URI, document version, operation, and relevant settings. Invalidate on edits.
- Backpressure: Queue or reject requests when a server is indexing or at its concurrency limit.
- Observability: Log request category, duration, result status, and server lifecycle events with sensitive fields redacted. Add tracing around MCP validation, LSP translation, and the language-server call.
- Deployments: For remote bridges, plan for reachability, streaming, secrets, rollback, and service health. A stable HTTPS endpoint is part of the client contract.
Common implementation mistakes
Exposing every LSP method
A generic proxy produces unclear schemas and broad authorization. Start with focused operations and add methods only when their input and output contracts are understood.
Using process identity as state
MCP requests are independent. A long-lived process may be an optimization, but it cannot be the only place where workspace or user context is defined.
Returning raw protocol noise
Raw LSP objects can be large and inconsistent. Normalize ranges, locations, markup, and diagnostics into stable result shapes while retaining enough detail for the caller.
Ignoring notifications
Diagnostics commonly arrive as notifications. If your tool promises current diagnostics, maintain a versioned notification store or document that the result is best effort and may lag.
Trusting annotations as authorization
Read-only or destructive labels help clients understand behavior, but authorization must still be enforced by the server.
Or skip the browser setup
If your bridge needs screenshots of documentation, source previews, or generated reports, ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns a PNG, JPEG, WebP, or PDF. Cookie and consent banners are accepted and removed before capture, along with more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for AI clients.
See the ScreenshotNeo API documentation for all options.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(fs => fs.writeFile('shot.webp', data));
ScreenshotNeo also supports full-page capture with lazy images loaded, CSS-selector element capture, dark mode, device presets and custom viewports, retina scale, PDF paper sizes and page ranges, custom CSS and JavaScript, clicks, selector or network-idle waits, request blocking, headers, cookies, user agents, Authorization, timezone, geolocation, transparent backgrounds, resizing, configurable caching, signed links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, usage data, and an OpenAPI specification.
Plans include 1,000 screenshots a month free with no card. Paid plans start at $5 for 3,000 screenshots; every feature is on every plan. Create a free ScreenshotNeo account.
FAQ
Does LSP provide the MCP bridge automatically?
No. LSP and MCP solve different protocol roles. The bridge’s tool schemas, translation, lifecycle, and authorization are application code.
Should the bridge use stdio or HTTP?
Use stdio when the AI host launches a local process. Use Streamable HTTP when clients need a remote endpoint and you can operate HTTPS, authentication, streaming, and service availability.
Can one bridge support several languages?
Yes, if it routes requests to isolated language-server clients and validates each workspace, document, and capability. Start with one language to keep contracts clear.
Can an MCP tool edit files?
It can, but edits require stronger authorization, accurate annotations, conflict handling, and validation. A read-only first release is easier to secure.
How do I handle a language server that has no hover support?
Inspect initialization capabilities and return an explicit unsupported-operation result. Do not return a misleading empty hover response.


