@theupsider/lsp-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerWriteTools = registerWriteTools;
7
+ const promises_1 = require("node:fs/promises");
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const zod_1 = require("zod");
10
+ const uri_1 = require("../../utils/uri");
11
+ const shared_1 = require("./shared");
12
+ function registerWriteTools(registrar, lifecycleManager) {
13
+ registrar.registerTool('lsp_rename', { description: 'Rename symbol', inputSchema: zod_1.z.object({ file: zod_1.z.string(), line: zod_1.z.number().int(), character: zod_1.z.number().int(), newName: zod_1.z.string() }) }, async (args) => {
14
+ const filePath = getFilePath(args);
15
+ await lifecycleManager.ensureLanguageForFile(filePath);
16
+ const client = lifecycleManager.getClientForFile(filePath);
17
+ if (!client) {
18
+ return (0, shared_1.noServerResult)(filePath);
19
+ }
20
+ if (hasRenameProviderDisabled(client.getCapabilities())) {
21
+ return (0, shared_1.failure)('Rename is not supported by the active language server.');
22
+ }
23
+ try {
24
+ await (0, shared_1.ensureDidOpen)(client, filePath);
25
+ const edit = await client.request('textDocument/rename', {
26
+ textDocument: { uri: (0, uri_1.pathToUri)(filePath) },
27
+ position: getPosition(args),
28
+ newName: String(args.newName ?? '')
29
+ }, 15000);
30
+ return await applyWorkspaceEdit(edit, lifecycleManager, client, 'Applied workspace edit to');
31
+ }
32
+ catch (error) {
33
+ return (0, shared_1.mapToolError)(error, 15);
34
+ }
35
+ });
36
+ registrar.registerTool('lsp_code_action', { description: 'List or apply code actions', inputSchema: zod_1.z.object({ file: zod_1.z.string(), line: zod_1.z.number().int(), character: zod_1.z.number().int(), apply: zod_1.z.union([zod_1.z.boolean(), zod_1.z.object({ index: zod_1.z.number().int() })]).optional(), range: rangeSchema.optional() }) }, async (args) => {
37
+ const filePath = getFilePath(args);
38
+ await lifecycleManager.ensureLanguageForFile(filePath);
39
+ const client = lifecycleManager.getClientForFile(filePath);
40
+ if (!client) {
41
+ return (0, shared_1.noServerResult)(filePath);
42
+ }
43
+ try {
44
+ await (0, shared_1.ensureDidOpen)(client, filePath);
45
+ const range = isRange(args.range)
46
+ ? args.range
47
+ : { start: getPosition(args), end: getPosition(args) };
48
+ const actions = await client.request('textDocument/codeAction', {
49
+ textDocument: { uri: (0, uri_1.pathToUri)(filePath) },
50
+ range,
51
+ context: { diagnostics: [] }
52
+ }, 15000);
53
+ const selected = selectCodeAction(actions, args.apply);
54
+ if (!selected) {
55
+ return (0, shared_1.success)(formatCodeActions(actions ?? []), actions ?? []);
56
+ }
57
+ const applied = selected.edit
58
+ ? await applyWorkspaceEdit(selected.edit, lifecycleManager, client, 'Applied workspace edit to')
59
+ : (0, shared_1.success)('Applied workspace edit to 0 file(s)', { changedFiles: [] });
60
+ if (selected.command) {
61
+ await client.request('workspace/executeCommand', selected.command, 15000);
62
+ }
63
+ return (0, shared_1.success)(`Applied code action: ${selected.title}`, {
64
+ title: selected.title,
65
+ changedFiles: extractChangedFiles(applied.raw)
66
+ });
67
+ }
68
+ catch (error) {
69
+ return (0, shared_1.mapToolError)(error, 15);
70
+ }
71
+ });
72
+ registrar.registerTool('lsp_formatting', { description: 'Format document', inputSchema: zod_1.z.object({ file: zod_1.z.string(), options: formattingOptionsSchema.optional() }) }, async (args) => {
73
+ return await runFormattingRequest('textDocument/formatting', args, lifecycleManager, async (client, filePath, options) => {
74
+ return await client.request('textDocument/formatting', { textDocument: { uri: (0, uri_1.pathToUri)(filePath) }, options }, 15000);
75
+ });
76
+ });
77
+ registrar.registerTool('lsp_range_formatting', { description: 'Format selected range', inputSchema: zod_1.z.object({ file: zod_1.z.string(), range: rangeSchema, options: formattingOptionsSchema.optional() }) }, async (args) => {
78
+ return await runFormattingRequest('textDocument/rangeFormatting', args, lifecycleManager, async (client, filePath, options) => {
79
+ return await client.request('textDocument/rangeFormatting', {
80
+ textDocument: { uri: (0, uri_1.pathToUri)(filePath) },
81
+ range: args.range,
82
+ options
83
+ }, 15000);
84
+ });
85
+ });
86
+ registrar.registerTool('lsp_apply_workspace_edit', { description: 'Apply raw workspace edit', inputSchema: zod_1.z.object({ edit: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()) }) }, async (args) => {
87
+ const clients = lifecycleManager.getReadyClients();
88
+ const client = clients[0] ?? null;
89
+ if (!client) {
90
+ return (0, shared_1.failure)('No language servers are ready. Run lsp_health for details.');
91
+ }
92
+ try {
93
+ return await applyWorkspaceEdit((args.edit ?? null), lifecycleManager, client, 'Applied workspace edit to');
94
+ }
95
+ catch (error) {
96
+ return (0, shared_1.mapToolError)(error, 15);
97
+ }
98
+ });
99
+ }
100
+ const positionSchema = zod_1.z.object({ line: zod_1.z.number().int(), character: zod_1.z.number().int() });
101
+ const rangeSchema = zod_1.z.object({ start: positionSchema, end: positionSchema });
102
+ const formattingOptionsSchema = zod_1.z.object({ tabSize: zod_1.z.number().int(), insertSpaces: zod_1.z.boolean() });
103
+ async function runFormattingRequest(_method, args, lifecycleManager, requestEdits) {
104
+ const filePath = getFilePath(args);
105
+ await lifecycleManager.ensureLanguageForFile(filePath);
106
+ const client = lifecycleManager.getClientForFile(filePath);
107
+ if (!client) {
108
+ return (0, shared_1.noServerResult)(filePath);
109
+ }
110
+ try {
111
+ await (0, shared_1.ensureDidOpen)(client, filePath);
112
+ const options = await resolveFormattingOptions(filePath, args.options);
113
+ const edits = await requestEdits(client, filePath, options);
114
+ return await applyTextEdits(edits, [filePath], lifecycleManager, client);
115
+ }
116
+ catch (error) {
117
+ return (0, shared_1.mapToolError)(error, 15);
118
+ }
119
+ }
120
+ async function applyWorkspaceEdit(edit, lifecycleManager, fallbackClient, verb) {
121
+ if (!edit) {
122
+ return (0, shared_1.success)('No result', null);
123
+ }
124
+ const changeEntries = Object.entries(edit.changes ?? {});
125
+ const changedFiles = [];
126
+ for (const [uri, edits] of changeEntries) {
127
+ const filePath = (0, uri_1.uriToPath)(uri);
128
+ const client = lifecycleManager.getClientForFile(filePath) ?? fallbackClient;
129
+ await (0, shared_1.ensureDidOpen)(client, filePath);
130
+ await applyEditsToFile(filePath, edits ?? []);
131
+ client.notify('textDocument/didSave', { textDocument: { uri } });
132
+ changedFiles.push(filePath);
133
+ }
134
+ for (const change of edit.documentChanges ?? []) {
135
+ if ('textDocument' in change && 'edits' in change) {
136
+ const filePath = (0, uri_1.uriToPath)(change.textDocument.uri);
137
+ const client = lifecycleManager.getClientForFile(filePath) ?? fallbackClient;
138
+ await (0, shared_1.ensureDidOpen)(client, filePath);
139
+ await applyEditsToFile(filePath, change.edits);
140
+ client.notify('textDocument/didSave', { textDocument: { uri: change.textDocument.uri } });
141
+ changedFiles.push(filePath);
142
+ }
143
+ }
144
+ return (0, shared_1.success)(`${verb} ${changedFiles.length} file(s)`, { changedFiles: [...new Set(changedFiles)] });
145
+ }
146
+ async function applyTextEdits(edits, files, lifecycleManager, fallbackClient) {
147
+ const changedFiles = [];
148
+ for (const filePath of files) {
149
+ if (!edits || edits.length === 0) {
150
+ continue;
151
+ }
152
+ const client = lifecycleManager.getClientForFile(filePath) ?? fallbackClient;
153
+ await applyEditsToFile(filePath, edits);
154
+ client.notify('textDocument/didSave', { textDocument: { uri: (0, uri_1.pathToUri)(filePath) } });
155
+ changedFiles.push(filePath);
156
+ }
157
+ return (0, shared_1.success)(`Applied workspace edit to ${changedFiles.length} file(s)`, { changedFiles });
158
+ }
159
+ async function applyEditsToFile(filePath, edits) {
160
+ const original = await (0, promises_1.readFile)(filePath, 'utf8');
161
+ const updated = applyEdits(original, edits);
162
+ await (0, promises_1.writeFile)(filePath, updated, 'utf8');
163
+ }
164
+ function applyEdits(text, edits) {
165
+ const ordered = [...edits].sort((left, right) => comparePosition(right.range.start, left.range.start));
166
+ let current = text;
167
+ for (const edit of ordered) {
168
+ const start = positionToOffset(current, edit.range.start);
169
+ const end = positionToOffset(current, edit.range.end);
170
+ current = `${current.slice(0, start)}${edit.newText}${current.slice(end)}`;
171
+ }
172
+ return current;
173
+ }
174
+ async function resolveFormattingOptions(filePath, provided) {
175
+ if (isFormattingOptions(provided)) {
176
+ return provided;
177
+ }
178
+ const editorConfig = await readNearestConfig(filePath, '.editorconfig');
179
+ if (editorConfig) {
180
+ const tabSizeMatch = editorConfig.match(/indent_size\s*=\s*(\d+)/);
181
+ const indentStyleMatch = editorConfig.match(/indent_style\s*=\s*(space|tab)/);
182
+ return {
183
+ tabSize: Number(tabSizeMatch?.[1] ?? 2),
184
+ insertSpaces: indentStyleMatch?.[1] !== 'tab'
185
+ };
186
+ }
187
+ return { tabSize: 2, insertSpaces: true };
188
+ }
189
+ async function readNearestConfig(filePath, fileName) {
190
+ let currentDir = node_path_1.default.dirname(filePath);
191
+ while (true) {
192
+ const candidate = node_path_1.default.join(currentDir, fileName);
193
+ try {
194
+ await (0, promises_1.access)(candidate);
195
+ return await (0, promises_1.readFile)(candidate, 'utf8');
196
+ }
197
+ catch {
198
+ if (currentDir === node_path_1.default.dirname(currentDir)) {
199
+ return null;
200
+ }
201
+ currentDir = node_path_1.default.dirname(currentDir);
202
+ }
203
+ }
204
+ }
205
+ function selectCodeAction(actions, apply) {
206
+ if (apply === true) {
207
+ return actions?.[0] ?? null;
208
+ }
209
+ if (typeof apply === 'object' && apply && 'index' in apply && typeof apply.index === 'number') {
210
+ return actions?.[apply.index] ?? null;
211
+ }
212
+ return null;
213
+ }
214
+ function formatCodeActions(actions) {
215
+ if (actions.length === 0) {
216
+ return 'No result';
217
+ }
218
+ return ['Available code actions:', ...actions.map((action, index) => `- [${index}] ${action.title}`)].join('\n');
219
+ }
220
+ function extractChangedFiles(raw) {
221
+ if (typeof raw === 'object' && raw && 'changedFiles' in raw && Array.isArray(raw.changedFiles)) {
222
+ return raw.changedFiles.filter((value) => typeof value === 'string');
223
+ }
224
+ return [];
225
+ }
226
+ function getFilePath(args) {
227
+ return typeof args.file === 'string' ? args.file : '';
228
+ }
229
+ function getPosition(args) {
230
+ return { line: Number(args.line ?? 0), character: Number(args.character ?? 0) };
231
+ }
232
+ function comparePosition(left, right) {
233
+ return left.line === right.line ? left.character - right.character : left.line - right.line;
234
+ }
235
+ function positionToOffset(text, position) {
236
+ const lines = text.split('\n');
237
+ let offset = 0;
238
+ for (let index = 0; index < position.line; index += 1) {
239
+ offset += (lines[index]?.length ?? 0) + 1;
240
+ }
241
+ return offset + position.character;
242
+ }
243
+ function isRange(value) {
244
+ return typeof value === 'object' && value !== null && 'start' in value && 'end' in value;
245
+ }
246
+ function isFormattingOptions(value) {
247
+ return typeof value === 'object' && value !== null && 'tabSize' in value && 'insertSpaces' in value
248
+ && typeof value.tabSize === 'number' && typeof value.insertSpaces === 'boolean';
249
+ }
250
+ function hasRenameProviderDisabled(value) {
251
+ return typeof value === 'object' && value !== null && 'renameProvider' in value && value.renameProvider === false;
252
+ }
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const uri_1 = require("../uri");
4
+ describe('uri utilities', () => {
5
+ it('converts an absolute posix path to a file URI', () => {
6
+ expect((0, uri_1.pathToUri)('/home/user/foo.ts')).toBe('file:///home/user/foo.ts');
7
+ });
8
+ it('round-trips posix paths with spaces', () => {
9
+ const uri = (0, uri_1.pathToUri)('/home/user/My Project/foo bar.ts');
10
+ expect(uri).toBe('file:///home/user/My%20Project/foo%20bar.ts');
11
+ expect((0, uri_1.uriToPath)(uri)).toBe('/home/user/My Project/foo bar.ts');
12
+ });
13
+ it('returns file URIs unchanged and rejects relative paths', () => {
14
+ expect((0, uri_1.pathToUri)('file:///home/user/foo.ts')).toBe('file:///home/user/foo.ts');
15
+ expect(() => (0, uri_1.pathToUri)('src/foo.ts')).toThrow('Expected an absolute file path');
16
+ });
17
+ it('handles windows drive paths in both directions', () => {
18
+ expect((0, uri_1.pathToUri)('C:\\work\\foo bar.ts')).toBe('file:///C:/work/foo%20bar.ts');
19
+ expect((0, uri_1.uriToPath)('file:///C:/work/foo%20bar.ts')).toBe('C:\\work\\foo bar.ts');
20
+ });
21
+ });
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pathToUri = pathToUri;
4
+ exports.uriToPath = uriToPath;
5
+ const FILE_URI_PREFIX = 'file://';
6
+ function pathToUri(filePath) {
7
+ if (isFileUri(filePath)) {
8
+ return filePath;
9
+ }
10
+ if (isWindowsPath(filePath)) {
11
+ const normalized = filePath.replace(/\\/g, '/');
12
+ return `${FILE_URI_PREFIX}/${encodePath(normalized)}`;
13
+ }
14
+ if (!filePath.startsWith('/')) {
15
+ throw new Error('Expected an absolute file path');
16
+ }
17
+ return `${FILE_URI_PREFIX}${encodePath(filePath)}`;
18
+ }
19
+ function uriToPath(uri) {
20
+ if (!isFileUri(uri)) {
21
+ throw new Error('Expected a file URI');
22
+ }
23
+ const decoded = decodeURIComponent(uri.slice(FILE_URI_PREFIX.length));
24
+ if (/^\/[A-Za-z]:\//.test(decoded)) {
25
+ return decoded.slice(1).replace(/\//g, '\\');
26
+ }
27
+ return decoded;
28
+ }
29
+ function isFileUri(value) {
30
+ return value.startsWith(`${FILE_URI_PREFIX}/`);
31
+ }
32
+ function isWindowsPath(value) {
33
+ return /^[A-Za-z]:[\\/]/.test(value);
34
+ }
35
+ function encodePath(value) {
36
+ return value
37
+ .split('/')
38
+ .map((segment, index) => {
39
+ const encoded = encodeURIComponent(segment);
40
+ return index === 0 && /^[A-Za-z]:$/.test(segment) ? encoded.replace('%3A', ':') : encoded;
41
+ })
42
+ .join('/');
43
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@theupsider/lsp-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Universal LSP MCP server",
5
+ "license": "Apache-2.0",
6
+ "type": "commonjs",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "lsp-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "test": "jest --config jest.config.js --coverage",
17
+ "test:integration": "jest --config jest.config.integration.js --forceExit --runInBand",
18
+ "start": "node dist/index.js"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "latest",
22
+ "vscode-languageserver-protocol": "latest",
23
+ "zod": "^4.3.6"
24
+ },
25
+ "devDependencies": {
26
+ "@types/jest": "latest",
27
+ "@types/node": "latest",
28
+ "jest": "latest",
29
+ "ts-jest": "latest",
30
+ "typescript": "latest"
31
+ }
32
+ }