Writing a Visual Studio Code extension that adds real language intelligence, things like autocomplete, hover documentation, and squiggly-line diagnostics, means stepping past the basic “Hello World” command extension and into the Language Server Protocol (LSP). LSP is the same open specification that powers the built-in TypeScript, Python, and Go tooling in VS Code, and it happens to be editor-agnostic by design. That matters more than it used to, because a growing share of developers now split their time between VS Code and Cursor, the AI-focused fork built on the same open-source core. An extension built the right way runs in both without a rewrite.
If you’re deciding which editor to build for in the first place, our VS Code vs Cursor vs Zed comparison breaks down the differences. This tutorial walks through building a working language server extension from an empty folder to a packaged, installable extension that behaves identically in VS Code 1.139.1 and the current Cursor release. Along the way you will write a real server process, wire it to a client extension with vscode-languageclient, register diagnostics and completions, debug both processes at once, and package the result for distribution through the Visual Studio Code Marketplace and the Open VSX registry that Cursor pulls from. By the end you will have a complete, working project you can adapt to your own language, config file format, or domain-specific syntax.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why build a language server instead of a regular extension
Most VS Code extensions run entirely inside the extension host process, the same Node.js process that hosts every other installed extension. That works fine for commands, sidebar views, and simple text manipulation. It falls apart once you need real language analysis: parsing a file, tracking symbols across a project, or running expensive validation on every keystroke. Put that work in the shared extension host and one slow extension can freeze the whole editor.
A language server solves this by moving the actual analysis into its own separate process, communicating with the editor over JSON-RPC using the Language Server Protocol specification, a standardized message format. The editor side (the “client”) stays thin: it starts the server, forwards document changes, and renders whatever the server sends back as diagnostics, completions, or hover text. Because the protocol itself is standardized rather than tied to VS Code’s internal APIs, the same server binary can also talk to Neovim, Emacs, Sublime Text, or any other editor with an LSP client implementation. For this tutorial, the audience is developers targeting VS Code and Cursor specifically, but the architecture you build here isn’t locked to either one.
The other reason to reach for LSP: Cursor’s extension host is a fork of the same VS Code core, so anything built against the standard vscode and vscode-languageclient APIs runs there without modification. You are not writing two extensions or maintaining a compatibility shim. You are writing one extension against a stable, well-documented API surface that both editors implement. For a broader look at the tools shaping this space, see our AI coding tools guide.
Prerequisites and exact versions
Confirm these before starting. Version mismatches are the single most common source of confusing errors in this workflow, so pin your tooling rather than guessing.
| Tool | Minimum version | Notes |
|---|---|---|
| Node.js | 20.x LTS or newer | Required by both the extension host and the language server process |
| npm | 10.x or newer | Ships with Node.js 20 |
| Visual Studio Code | 1.139.1 | Current stable release as of late September 2026 |
| Cursor | Latest stable build | Built on the same extension host APIs; check Help > About for your build number |
| TypeScript | 5.6 or newer | Installed as a project dependency, not globally |
| Yeoman + generator-code | Latest | Scaffolds the initial extension skeleton |
| @vscode/vsce | Latest | Packages and publishes to the VS Code Marketplace |
| ovsx | Latest | Publishes the same package to Open VSX for Cursor users |
You will also need a free publisher account on the Visual Studio Marketplace (via Azure DevOps) and, separately, a namespace on Open VSX if you intend to publish rather than just run the extension locally. Both are optional if you only want to build and test locally, which is exactly what steps 1 through 9 below cover before publishing comes into play.
Step 1: Scaffold the extension with Yeoman
Install the generator globally once, then scaffold a new extension project:
npm install -g yo generator-code
yo code
Choose “New Extension (TypeScript)” when prompted, name it something like demo-lang-server, and accept the default bundler prompt (esbuild) if offered. This gives you a working extension shell with an activation command, a package.json, and a compiled tsconfig. Open the folder in VS Code once scaffolding finishes.
Step 2: Add the client and server workspace structure
A language server extension is really two separate npm packages living in one repository: the client, which is the VS Code extension itself, and the server, which is a standalone Node.js program. Restructure the scaffolded project into a client/server split:
mkdir client server
mv src client/src
mv package.json client/package.json
Create a root package.json that ties both together as npm workspaces:
{
"name": "demo-lang-server-workspace",
"private": true,
"workspaces": ["client", "server"],
"scripts": {
"compile": "npm run compile --workspaces",
"watch": "npm run watch --workspaces"
}
}
Initialize the server package:
cd server
npm init -y
npm install vscode-languageserver vscode-languageserver-textdocument
npm install --save-dev typescript @types/node
cd ../client
npm install vscode-languageclient
vscode-languageserver and vscode-languageserver-textdocument, both part of the vscode-languageserver-node project, give you the server-side building blocks (document tracking, connection handling, capability negotiation). vscode-languageclient is the client-side counterpart that lives inside the extension host and starts, stops, and communicates with your server process.
Step 3: Write the server entry point
Create server/src/server.ts. This minimal server validates a plain-text config-style language, flags lines longer than 80 characters, and offers one hardcoded completion item as a proof of concept:
import {
createConnection,
TextDocuments,
ProposedFeatures,
InitializeParams,
TextDocumentSyncKind,
CompletionItem,
CompletionItemKind,
Diagnostic,
DiagnosticSeverity
} from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
const connection = createConnection(ProposedFeatures.all);
const documents = new TextDocuments(TextDocument);
connection.onInitialize((_params: InitializeParams) => {
return {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
completionProvider: { resolveProvider: false },
hoverProvider: true
}
};
});
documents.onDidChangeContent(change => {
validateTextDocument(change.document);
});
function validateTextDocument(textDocument: TextDocument): void {
const text = textDocument.getText();
const lines = text.split(/\r?\n/g);
const diagnostics: Diagnostic[] = [];
lines.forEach((line, i) => {
if (line.length > 80) {
diagnostics.push({
severity: DiagnosticSeverity.Warning,
range: {
start: { line: i, character: 80 },
end: { line: i, character: line.length }
},
message: `Line exceeds 80 characters (${line.length}).`,
source: 'demo-lang-server'
});
}
});
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}
connection.onCompletion(() => {
const item: CompletionItem = {
label: 'TODO',
kind: CompletionItemKind.Keyword,
detail: 'Insert a TODO marker'
};
return [item];
});
connection.onHover(() => {
return { contents: 'demo-lang-server is active on this document.' };
});
documents.listen(connection);
connection.listen();
Compile it with a small tsconfig targeting CommonJS output, then confirm it builds cleanly with npx tsc -p server before wiring up the client.
Step 4: Write the client extension entry point
Replace the scaffolded client/src/extension.ts with code that launches the server as a child process and connects to it via LanguageClient:
import * as path from 'path';
import { ExtensionContext } from 'vscode';
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind
} from 'vscode-languageclient/node';
let client: LanguageClient;
export function activate(context: ExtensionContext) {
const serverModule = context.asAbsolutePath(
path.join('..', 'server', 'out', 'server.js')
);
const serverOptions: ServerOptions = {
run: { module: serverModule, transport: TransportKind.ipc },
debug: {
module: serverModule,
transport: TransportKind.ipc,
options: { execArgv: ['--nolazy', '--inspect=6009'] }
}
};
const clientOptions: LanguageClientOptions = {
documentSelector: [{ scheme: 'file', language: 'plaintext' }],
synchronize: {
fileEvents: undefined
}
};
client = new LanguageClient(
'demoLangServer',
'Demo Language Server',
serverOptions,
clientOptions
);
client.start();
}
export function deactivate(): Thenable | undefined {
return client ? client.stop() : undefined;
}
Update client/package.json so its main field points at the compiled output and its engines.vscode field matches a version both VS Code and Cursor satisfy, typically ^1.90.0 is a safe conservative floor unless you rely on a genuinely new API.
Step 5: Run the extension in the Extension Development Host
Press F5 inside the client workspace (or use the Run and Debug panel) to launch the Extension Development Host, a second VS Code window with your unpublished extension loaded. Open a plain-text file, type a line past 80 characters, and confirm a warning squiggle appears. Trigger completion (Ctrl+Space) and confirm the TODO item shows up. Hover over any word and confirm the static hover text renders.
This same F5 workflow works identically inside Cursor, since Cursor ships the same Extension Development Host debugging infrastructure inherited from the upstream VS Code codebase. Open the same project folder in Cursor, press F5, and the same second-window debug session launches there.
Step 6: Debug the server process separately
The client and server are two separate OS processes, so debugging the extension host alone will not let you set breakpoints inside server.ts. Add a second launch configuration in .vscode/launch.json that attaches to the debug port opened by the –inspect=6009 flag set in Step 4:
{
"name": "Attach to Server",
"type": "node",
"request": "attach",
"port": 6009,
"restart": true,
"outFiles": ["${workspaceRoot}/server/out/**/*.js"]
}
With the Extension Development Host already running from Step 5, switch to this “Attach to Server” configuration and launch it. Set a breakpoint inside validateTextDocument, edit the test document again, and confirm execution pauses there. Both VS Code and Cursor expose the same Run and Debug view, so this configuration works unchanged in either editor.
Step 7: Turn on protocol tracing to inspect the JSON-RPC messages
When behavior looks wrong and you can’t tell whether the bug is in the client or the server, add a setting that logs every message crossing the wire. In the Extension Development Host’s settings, add:
{
"demoLangServer.trace.server": "verbose"
}
Then read the trace from the “Demo Language Server” output channel (View > Output, select the channel from the dropdown). You’ll see every textDocument/didChange, textDocument/publishDiagnostics, and textDocument/completion message, timestamped, in both directions. This is the fastest way to confirm whether the server is receiving what you expect or the client is misrouting a request, and it works exactly the same way in Cursor’s Output panel.
Step 8: Register a real language ID instead of plaintext
Targeting the built-in plaintext language ID was fine for testing, but a real extension usually defines its own file type. Add a language contribution to client/package.json:
"contributes": {
"languages": [{
"id": "demolang",
"extensions": [".demo"],
"aliases": ["Demo Language"]
}]
}
Update the documentSelector in Step 4’s client code to { scheme: ‘file’, language: ‘demolang’ }, reload the Extension Development Host, and open a file with a .demo extension. VS Code and Cursor both pick up custom language contributions the same way, since the contributes.languages field is part of the shared extension manifest schema rather than a VS Code-specific extra.
Step 9: Add configuration options the user can change
Hardcoding the 80-character limit is fine for a demo, but real extensions expose settings. Add a configuration contribution:
"contributes": {
"configuration": {
"title": "Demo Language Server",
"properties": {
"demoLangServer.maxLineLength": {
"type": "number",
"default": 80,
"description": "Maximum line length before a warning is raised."
}
}
}
}
Read that setting inside the server using connection.workspace.getConfiguration(), and re-run validation whenever onDidChangeConfiguration fires. This keeps user-facing options in the standard settings UI both editors already ship, rather than inventing a custom settings panel.
Step 10: Install and configure @vscode/vsce for packaging
Install the packaging CLI as a dev dependency at the root of the workspace:
npm install --save-dev @vscode/vsce
Because your extension now spans a client and a server directory, add a build step that compiles both before packaging, and a .vscodeignore file that excludes source TypeScript, node_modules dev dependencies, and the server’s own package-lock.json from the final package. Forgetting this step is one of the most common reasons a packaged extension balloons to tens of megabytes or fails to activate because compiled server output wasn’t included.
Step 11: Package into a VSIX and test the packaged build
From the client directory, run:
cd client
npx vsce package
This produces a .vsix file. Install it directly to confirm the packaged build behaves the same as the F5 debug session, since packaging bugs (missing files, wrong main path, unbundled dependencies) only surface once you install the real artifact rather than running from source:
code --install-extension demo-lang-server-0.0.1.vsix
Repeat the equivalent install inside Cursor (or use the Extensions panel’s “Install from VSIX” option) to confirm parity before you publish anywhere.
Step 12: Publish to the VS Code Marketplace and Open VSX
Publishing to the Microsoft Marketplace alone will not make your extension visible inside Cursor, because Cursor’s extension panel queries the Open VSX registry rather than Microsoft’s marketplace API. To reach both audiences, publish twice:
# Publish to the VS Code Marketplace
npx vsce publish
# Publish the same VSIX to Open VSX
npm install --save-dev ovsx
npx ovsx publish demo-lang-server-0.0.1.vsix -p your-open-vsx-token
Both registries require their own access token: a Personal Access Token from Azure DevOps for the Microsoft Marketplace, and a separate token generated from an Open VSX namespace tied to an Eclipse Foundation account. Keep the version numbers in sync across both publishes so users on either editor are never more than one release apart. For a deeper walkthrough of automating this with GitHub Actions and managing publisher accounts on both sides, see our guide to publishing VS Code extensions to Open VSX.
Step 13: Verify the extension shows up correctly inside Cursor
Once the Open VSX publish completes, search for your extension by name inside Cursor’s Extensions panel, which pulls from the Open VSX registry rather than the Microsoft Marketplace. Propagation to the Open VSX search index can take a few minutes. Confirm the listing shows the correct README, version number, and publisher, then install it fresh (not from a local VSIX) to confirm the full install-from-registry path works end to end, including activation and diagnostics on a real .demo file.
Extending the server: go-to-definition, signature help, and code actions
Diagnostics, completion, and hover cover the basics, but the features developers actually notice day to day are jump-to-definition and inline quick fixes. Both build on the same connection object already wired up in Step 3. Add a definition provider that looks up a symbol table you maintain as documents change:
connection.onDefinition((params) => {
const doc = documents.get(params.textDocument.uri);
if (!doc) return null;
const symbol = getSymbolAtPosition(doc, params.position);
const location = symbolTable.get(symbol);
if (!location) return null;
return {
uri: params.textDocument.uri,
range: location.range
};
});
The symbolTable here is a plain Map you populate inside validateTextDocument whenever you encounter a declaration, keyed by symbol name and storing the range where it was declared. For a real language this table needs to be scoped per document and invalidated on every edit, otherwise stale entries from a deleted symbol will point editors at ranges that no longer exist.
Code actions, the lightbulb menu that offers quick fixes, register similarly. A common pattern is offering a fix directly tied to a diagnostic you already raised, so the user can resolve the exact warning they’re looking at without leaving the editor:
connection.onCodeAction((params) => {
const diagnostics = params.context.diagnostics.filter(
d => d.source === 'demo-lang-server'
);
if (diagnostics.length === 0) return [];
return diagnostics.map(diagnostic => ({
title: 'Wrap line at 80 characters',
kind: 'quickfix',
diagnostics: [diagnostic],
edit: {
changes: {
[params.textDocument.uri]: [{
range: diagnostic.range,
newText: '\n'
}]
}
}
}));
});
Remember to declare codeActionProvider: true and definitionProvider: true in the capabilities object returned from onInitialize in Step 3, or the client will never send these requests in the first place. This is one of the most overlooked steps when adding a new provider: the handler can be fully implemented and still never fire because the capability was never advertised during initialization.
Testing the language server automatically with @vscode/test-electron
Manually reloading the Extension Development Host after every change works for initial development, but it doesn’t scale once the server has more than a handful of rules. @vscode/test-electron launches a real, throwaway instance of the editor from a test script, opens a fixture file, and lets you assert on the diagnostics or completions that come back exactly as a user would see them.
npm install --save-dev @vscode/test-electron mocha @types/mocha
A minimal test runner entry point looks like this:
import * as path from 'path';
import { runTests } from '@vscode/test-electron';
async function main() {
const extensionDevelopmentPath = path.resolve(__dirname, '../../client');
const extensionTestsPath = path.resolve(__dirname, './suite/index');
await runTests({ extensionDevelopmentPath, extensionTestsPath });
}
main();
Inside the suite, open a fixture file with vscode.workspace.openTextDocument, wait for diagnostics to publish (they arrive asynchronously, so poll or await an event rather than asserting immediately), and check the resulting array against expected messages and ranges. This catches regressions that manual testing misses, particularly around edge cases like empty files, files with only whitespace, or documents that get closed mid-validation. Wire this test script into the same CI workflow that runs your marketplace publish step, and a broken diagnostic never reaches either registry.
Performance tuning for large files and monorepos
The demo server in this tutorial re-scans the entire document on every keystroke, which is fine for files under a few hundred lines but becomes noticeably laggy well before a file reaches a few thousand. Three changes matter most once you move past a proof of concept.
First, debounce validation instead of running it synchronously inside onDidChangeContent. A simple timer that resets on every change and only fires validation after 200 to 300 milliseconds of typing silence removes the vast majority of wasted work, since most keystrokes in a fast typing burst don’t need their own validation pass.
Second, validate incrementally where the language allows it. Re-parsing a full 5,000-line file on every change is wasteful if only one function body changed. Depending on your parser, this might mean caching a syntax tree and re-parsing only the changed region, or at minimum skipping unchanged lines when running line-based checks like the length rule in this tutorial.
Third, for monorepos or workspaces with many open files, avoid holding full parsed state for every document in memory indefinitely. Evict cached analysis for documents that haven’t been active in the editor for a while, and rebuild it lazily the next time that document becomes visible. Both VS Code and Cursor call documents.onDidClose when a document is no longer open; use that hook to clear any per-document cache you’re holding, since a language server that never releases memory for closed files will grow unbounded in a long editing session across a large monorepo.
Security notes when spawning a child process
Because the server runs as a separate Node.js process launched by the extension, it’s worth treating it with the same care as any other subprocess your code spawns. Never construct the server’s command line by concatenating untrusted input, such as a workspace-provided configuration value, directly into arguments passed to child_process; pass configuration through the LSP initializationOptions payload instead, which the server reads as structured data rather than shell arguments. If your server reads and executes anything from workspace files, for example a project-level config script, treat that content as untrusted, since opening a malicious repository should never result in arbitrary code execution just because a workspace happened to contain a crafted config file. This same caution applies whether the extension is running inside VS Code or Cursor, since neither editor sandboxes a language server process differently from any other Node.js child process on the host machine.
Common pitfalls when building a dual-editor language server
These are the mistakes that show up most often when developers move from a single-process extension to a client-server architecture:
- Forgetting to compile the server before packaging. The client references server/out/server.js, which only exists after a build step runs. A packaged VSIX built without that compile step installs but fails silently on activation.
- Mismatched engines.vscode ranges. Setting engines.vscode to a version newer than Cursor’s current base can make the extension marketplace-invisible in Cursor even though the code itself would run fine there.
- Leaking the debug inspect flag into production. Shipping the –inspect=6009 argument in the packaged extension’s run configuration, instead of only the debug configuration, can cause port conflicts for users who happen to have another process on 6009.
- Synchronous, blocking validation logic. Heavy parsing inside onDidChangeContent without debouncing will make every keystroke feel laggy, defeating the entire point of moving work off the main extension host.
- Publishing to only one registry. Publishing exclusively to the Microsoft Marketplace and assuming Cursor users can still find it is the single most common distribution mistake in this workflow.
- Hardcoding absolute file paths in server.ts. Always resolve the server module path relative to the extension’s install directory using context.asAbsolutePath, since the install directory differs across machines, operating systems, and between VS Code and Cursor’s separate extension storage folders.
- Ignoring workspace multi-root support. A language server that assumes a single root folder will misbehave in multi-root workspaces; scope configuration lookups per-document rather than globally.
Output examples: what correct behavior looks like
When everything is wired correctly, opening a .demo file with a line of 95 characters produces a diagnostic entry in the Problems panel reading “Line exceeds 80 characters (95). [demo-lang-server]”, with an underline starting at column 80. Triggering completion with Ctrl+Space shows a single TODO entry tagged as a Keyword. Hovering over any token shows the static tooltip text confirming the server is active for that document. The Output panel’s trace channel, when tracing is set to verbose, shows a clean sequence: textDocument/didOpen, followed by one textDocument/publishDiagnostics notification per validation pass, with no repeated or duplicate diagnostic pushes for the same document version.
Troubleshooting: 8 issues and fixes
- Cannot find module on activation. The compiled server.js path in extension.ts doesn’t match the actual output directory. Check your tsconfig’s outDir matches the path referenced in serverModule.
- Diagnostics never appear. Confirm documentSelector in the client matches the language ID of the open file exactly; a mismatch means the client never attaches to that document at all.
- Breakpoints in server.ts are greyed out. The Attach to Server launch configuration’s outFiles path doesn’t match where compiled JS actually lives, so source maps can’t resolve.
- Extension works in VS Code but not in Cursor. Check the engines.vscode version constraint in package.json against Cursor’s reported API compatibility version, visible in Cursor’s Help > About dialog.
- Completion items don’t show up until a manual reload. The server registered completionProvider capabilities after onInitialize already resolved; move capability registration into the initial response object, not a later call.
- High CPU usage while typing. Validation is running unthrottled on every keystroke. Add a short debounce, 150 to 300 milliseconds, before calling validateTextDocument.
- Extension installs but the Output channel never appears. The LanguageClient constructor’s second argument, the human-readable name, controls the Output channel label; if the client never successfully calls .start(), no channel is created at all, usually because an earlier exception was thrown and swallowed.
- Published extension missing from Open VSX search results. Allow for indexing delay, typically a few minutes to an hour, and confirm the publish command actually targeted the correct namespace with a valid, non-expired token.
Advanced tips for long-term maintenance
Once the base extension works, a few practices pay off as the project grows. Move from a single hardcoded validation rule to a small rule registry so new checks can be added without touching the core connection logic. Add incremental parsing instead of re-validating an entire document on every keystroke once files grow past a few hundred lines, since full re-parses become the dominant cost at scale. Write integration tests using @vscode/test-electron, which launches a real, disposable instance of the editor and lets you assert on actual diagnostics and completions rather than mocking the API surface. Finally, pin both the Microsoft Marketplace and Open VSX publish steps into a single CI workflow triggered on tag push, so a version bump can never ship to one registry without the other. If your extension will also expose reusable prompts or workflows to Cursor’s agent features, our Cursor Skills setup guide covers that adjacent packaging format.
Handling settings and workspace-specific configuration cleanly
Real projects rarely want one global setting for the whole editor; they want project-specific configuration, for example a .demolangrc file at the workspace root that overrides the default line-length limit for that repository only. Read workspace folders via connection.workspace.getWorkspaceFolders() on the server side, and watch for changes to the config file using the server’s own file watcher registration rather than relying on the client to detect and forward every change. This keeps the server self-contained and portable to any LSP-compatible editor, not just the two covered here.
Choosing a transport: stdio, IPC, or sockets
The ServerOptions object in Step 4 used TransportKind.ipc, but vscode-languageclient supports three transport mechanisms and the choice matters more than it looks. IPC uses Node’s built-in inter-process communication channel and is the simplest option when the server is a Node.js child process spawned directly by the extension, which is the case for everything built in this tutorial. Stdio transport pipes messages over the process’s standard input and output streams instead, and is the right choice when the server is written in a non-Node language like Python or Go, since those runtimes don’t participate in Node’s IPC protocol but can trivially read and write stdin and stdout. Socket transport opens a TCP connection between client and server, which is useful when the server needs to run somewhere other than as a direct child process of the extension host, for example a long-running server shared across multiple editor windows, or a server running inside a container that the extension connects to remotely.
For a server written in Python, the run configuration looks different from the Node example in Step 4:
const serverOptions: ServerOptions = {
command: 'python',
args: ['-m', 'demo_lang_server'],
transport: TransportKind.stdio
};
Everything else in the client, the LanguageClientOptions, the document selector, the capability handling, stays identical regardless of which transport or language the server uses. This is the practical payoff of building on a standardized protocol instead of a VS Code-specific API: swapping the server’s implementation language later doesn’t require touching the client at all, only the serverOptions block that describes how to start it.
How this fits alongside Cursor’s built-in AI completions
A reasonable question at this point is whether any of this still matters in an editor like Cursor that already offers AI-generated completions and inline chat. It does, and the two systems solve different problems rather than competing with each other. Cursor’s AI features predict plausible next text based on a trained model and surrounding context; they’re probabilistic and don’t guarantee correctness against a formal grammar or type system. A language server built on LSP, by contrast, gives deterministic, exact answers derived from actually parsing the document: a diagnostic fires because a rule was violated, not because a model estimated it might be, and a completion list is exhaustive against the language’s real symbol table rather than a plausible guess.
In practice the two layers run side by side inside Cursor without conflict. The language server extension you build in this tutorial contributes its diagnostics to the Problems panel and its completions to the same completion list Cursor’s AI suggestions also populate, and the editor merges both sources for the user. For any language with a formal syntax, config file format, or domain-specific rule set, that combination, deterministic LSP-based validation plus AI-assisted suggestions, covers more ground than either layer alone. Teams building internal tooling around a proprietary config format or DSL get the most value from this approach, since no general-purpose AI model has been trained on a syntax that only exists inside one company’s codebase. If you’re also feeding external data or tools into Cursor’s AI chat directly, that’s a separate integration path covered in our Cursor MCP server extension tutorial, and building a chat-participant style extension against the Language Model API is covered in our VS Code AI extension tutorial.
Language server extensions vs. other extension architectures
Not every extension idea calls for a full client-server split. A single-process extension is simpler to build and debug when the work is lightweight, such as a formatter that runs once on save or a snippet library. The table below compares when each architecture makes sense.
| Architecture | Best for | Runs unmodified in Cursor | Complexity |
|---|---|---|---|
| Single-process extension | Commands, sidebar views, simple formatters | Yes | Low |
| Language server (LSP) | Diagnostics, completions, hover, go-to-definition across large files | Yes, with correct engines.vscode range | Medium |
| Webview-based extension | Custom UI panels, visualizations | Yes, subject to CSP differences | Medium |
| MCP server integration | Feeding external tools and data to AI chat features | Cursor-specific behavior varies | Medium to high |
Complete working project: putting it all together
The finished project structure after following every step above looks like this:
demo-lang-server/
├── package.json (workspace root)
├── client/
│ ├── package.json (extension manifest, contributes, engines.vscode)
│ ├── src/extension.ts (LanguageClient setup)
│ └── out/extension.js (compiled output)
├── server/
│ ├── package.json
│ ├── src/server.ts (connection, diagnostics, completion, hover)
│ └── out/server.js (compiled output)
└── .vscode/
└── launch.json (Extension Development Host + Attach to Server)
From a clean checkout, the full build-test-package cycle is:
npm install
npm run compile
# Press F5 in VS Code or Cursor to test interactively
cd client && npx vsce package
npx ovsx publish demo-lang-server-0.0.1.vsix -p your-open-vsx-token
npx vsce publish
That sequence, install, compile, test via the Extension Development Host, package, publish to both registries, is the same loop you will repeat for every release, whether the extension validates a real domain-specific language, lints infrastructure config files, or adds completions for an internal API schema.
Frequently asked questions
Does a language server extension automatically work in Cursor once it works in VS Code?
In most cases yes, provided the engines.vscode version range in package.json is compatible with Cursor’s current API level and the extension is published to Open VSX rather than only the Microsoft Marketplace, since Cursor’s Extensions panel searches Open VSX by default.
Do I need vscode-languageclient, or can I write raw JSON-RPC handling myself?
You technically can write the protocol handling by hand, but vscode-languageclient and vscode-languageserver already implement capability negotiation, document synchronization, and message framing correctly, and reimplementing that logic is rarely worth the maintenance cost for a typical extension.
Why does my server run fine standalone but fail when launched from the extension?
This is almost always a path resolution issue: the server module path referenced in ServerOptions must be resolved relative to the extension’s install directory at runtime, not a path that only exists in your local development folder.
Can the server and client be written in different languages?
Yes. The client extension itself must be JavaScript or TypeScript because it runs inside the Node-based extension host, but the server process it launches can be written in any language, Python, Go, Rust, as long as it speaks the LSP JSON-RPC format over stdio, sockets, or IPC.
How do I know which Cursor API version I’m targeting for engines.vscode?
Check Cursor’s Help > About dialog, which reports the underlying VS Code API compatibility version Cursor was built against; set your engines.vscode floor at or below that number. Cursor’s own extensions documentation covers how its marketplace proxy filters and vets available packages.
Is Open VSX publishing free?
Yes, publishing to Open VSX is free and requires only an Eclipse Foundation account and a namespace matching your publisher name, separate from any Microsoft Marketplace publisher account.
Why does a diagnostic show as an error in the Problems panel when I set DiagnosticSeverity.Warning?
Check for a stale cached diagnostic from an earlier server version; reload the Extension Development Host window fully rather than relying on hot reload, since diagnostic collections can persist across a partial reload.
Should I bundle the server with esbuild before packaging?
For anything beyond a demo, yes: bundling collapses node_modules dependencies into a single file, which shrinks the packaged VSIX significantly and avoids shipping a full unbundled node_modules tree inside the extension.





