@treedy/pyright-mcp 1.0.0 → 1.1.2

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.
@@ -1,9 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  export declare const diagnosticsSchema: {
3
- file: z.ZodString;
3
+ path: z.ZodString;
4
4
  };
5
5
  export declare function diagnostics(args: {
6
- file: string;
6
+ path: string;
7
7
  }): Promise<{
8
8
  content: {
9
9
  type: "text";
@@ -1,35 +1,93 @@
1
1
  import { z } from 'zod';
2
- import { getLspClient } from '../lsp-client.js';
3
- import { fromPosition } from '../utils/position.js';
4
- import { DiagnosticSeverity } from 'vscode-languageserver-protocol';
2
+ import { execSync } from 'child_process';
3
+ import { findProjectRoot } from '../utils/position.js';
5
4
  export const diagnosticsSchema = {
6
- file: z.string().describe('Absolute path to the Python file'),
7
- };
8
- const severityNames = {
9
- [DiagnosticSeverity.Error]: 'Error',
10
- [DiagnosticSeverity.Warning]: 'Warning',
11
- [DiagnosticSeverity.Information]: 'Information',
12
- [DiagnosticSeverity.Hint]: 'Hint',
5
+ path: z
6
+ .string()
7
+ .describe('Path to a Python file or directory to check'),
13
8
  };
14
9
  export async function diagnostics(args) {
15
- const client = getLspClient();
16
- const result = await client.getDiagnostics(args.file);
17
- if (result.length === 0) {
10
+ const { path } = args;
11
+ // Determine project root from path
12
+ const projectRoot = findProjectRoot(path);
13
+ // Build pyright command - path can be file or directory
14
+ const target = path;
15
+ const cmd = `pyright "${target}" --outputjson`;
16
+ let output;
17
+ try {
18
+ const result = execSync(cmd, {
19
+ encoding: 'utf-8',
20
+ cwd: projectRoot,
21
+ timeout: 60000,
22
+ maxBuffer: 10 * 1024 * 1024,
23
+ });
24
+ output = JSON.parse(result);
25
+ }
26
+ catch (e) {
27
+ const error = e;
28
+ // pyright returns non-zero exit code if there are errors
29
+ if (error.stdout) {
30
+ try {
31
+ output = JSON.parse(error.stdout);
32
+ }
33
+ catch {
34
+ return {
35
+ content: [
36
+ {
37
+ type: 'text',
38
+ text: `Error running pyright: ${error.message || 'Unknown error'}`,
39
+ },
40
+ ],
41
+ };
42
+ }
43
+ }
44
+ else {
45
+ return {
46
+ content: [
47
+ {
48
+ type: 'text',
49
+ text: `Error running pyright: ${error.message || 'Unknown error'}\n\nMake sure pyright is installed: npm install -g pyright`,
50
+ },
51
+ ],
52
+ };
53
+ }
54
+ }
55
+ const diags = output.generalDiagnostics || [];
56
+ const summary = output.summary;
57
+ if (diags.length === 0) {
58
+ let text = `**No issues found**\n\n`;
59
+ text += `- Files analyzed: ${summary.filesAnalyzed}\n`;
60
+ text += `- Time: ${summary.timeInSec}s`;
18
61
  return {
19
- content: [{ type: 'text', text: `No diagnostics for ${args.file}` }],
62
+ content: [{ type: 'text', text }],
20
63
  };
21
64
  }
22
- let output = `**Diagnostics** for ${args.file}\n\n`;
23
- output += `Found ${result.length} issue(s):\n\n`;
24
- for (const diag of result) {
25
- const pos = fromPosition(diag.range.start);
26
- const severity = diag.severity ? severityNames[diag.severity] || 'Unknown' : 'Unknown';
27
- const source = diag.source ? `[${diag.source}] ` : '';
28
- const code = diag.code ? ` (${diag.code})` : '';
29
- output += `- **${severity}** at line ${pos.line}:${pos.column}${code}\n`;
30
- output += ` ${source}${diag.message}\n\n`;
65
+ // Group by file
66
+ const byFile = new Map();
67
+ for (const diag of diags) {
68
+ const list = byFile.get(diag.file) || [];
69
+ list.push(diag);
70
+ byFile.set(diag.file, list);
71
+ }
72
+ let text = `**Diagnostics Summary**\n\n`;
73
+ text += `- Errors: ${summary.errorCount}\n`;
74
+ text += `- Warnings: ${summary.warningCount}\n`;
75
+ text += `- Information: ${summary.informationCount}\n`;
76
+ text += `- Files analyzed: ${summary.filesAnalyzed}\n`;
77
+ text += `- Time: ${summary.timeInSec}s\n\n`;
78
+ text += `---\n\n`;
79
+ for (const [filePath, fileDiags] of byFile) {
80
+ text += `### ${filePath}\n\n`;
81
+ for (const diag of fileDiags) {
82
+ const line = diag.range.start.line + 1;
83
+ const col = diag.range.start.character + 1;
84
+ const rule = diag.rule ? ` (${diag.rule})` : '';
85
+ const icon = diag.severity === 'error' ? '❌' : diag.severity === 'warning' ? '⚠️' : 'ℹ️';
86
+ text += `- ${icon} **${diag.severity}** at ${line}:${col}${rule}\n`;
87
+ text += ` ${diag.message}\n\n`;
88
+ }
31
89
  }
32
90
  return {
33
- content: [{ type: 'text', text: output }],
91
+ content: [{ type: 'text', text }],
34
92
  };
35
93
  }
@@ -3,13 +3,11 @@ export declare const referencesSchema: {
3
3
  file: z.ZodString;
4
4
  line: z.ZodNumber;
5
5
  column: z.ZodNumber;
6
- includeDeclaration: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
7
6
  };
8
7
  export declare function references(args: {
9
8
  file: string;
10
9
  line: number;
11
10
  column: number;
12
- includeDeclaration?: boolean;
13
11
  }): Promise<{
14
12
  content: {
15
13
  type: "text";
@@ -1,26 +1,160 @@
1
1
  import { z } from 'zod';
2
- import { getLspClient } from '../lsp-client.js';
3
- import { toPosition, fromPosition, uriToPath } from '../utils/position.js';
2
+ import { execSync } from 'child_process';
3
+ import { readFileSync } from 'fs';
4
+ import { findProjectRoot } from '../utils/position.js';
4
5
  export const referencesSchema = {
5
6
  file: z.string().describe('Absolute path to the Python file'),
6
7
  line: z.number().int().positive().describe('Line number (1-based)'),
7
8
  column: z.number().int().positive().describe('Column number (1-based)'),
8
- includeDeclaration: z.boolean().optional().default(true).describe('Include the declaration in results'),
9
9
  };
10
+ function getSymbolAtPosition(filePath, line, column) {
11
+ try {
12
+ const content = readFileSync(filePath, 'utf-8');
13
+ const lines = content.split('\n');
14
+ const targetLine = lines[line - 1];
15
+ if (!targetLine)
16
+ return null;
17
+ // Find word at position
18
+ const col = column - 1;
19
+ let start = col;
20
+ let end = col;
21
+ // Expand left
22
+ while (start > 0 && /[\w_]/.test(targetLine[start - 1])) {
23
+ start--;
24
+ }
25
+ // Expand right
26
+ while (end < targetLine.length && /[\w_]/.test(targetLine[end])) {
27
+ end++;
28
+ }
29
+ const symbol = targetLine.slice(start, end);
30
+ return symbol.length > 0 ? symbol : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
10
36
  export async function references(args) {
11
- const client = getLspClient();
12
- const position = toPosition(args.line, args.column);
13
- const result = await client.references(args.file, position, args.includeDeclaration ?? true);
14
- if (!result || result.length === 0) {
37
+ const { file, line, column } = args;
38
+ // Get symbol name at position
39
+ const symbol = getSymbolAtPosition(file, line, column);
40
+ if (!symbol) {
41
+ return {
42
+ content: [{ type: 'text', text: 'Could not identify symbol at this position.' }],
43
+ };
44
+ }
45
+ // Find project root
46
+ const projectRoot = findProjectRoot(file);
47
+ // Use ripgrep to find all references in Python files
48
+ // Match word boundaries to avoid partial matches
49
+ const pattern = `\\b${symbol}\\b`;
50
+ let rgOutput;
51
+ try {
52
+ // Try ripgrep first
53
+ rgOutput = execSync(`rg --no-heading --line-number --column --type py "${pattern}" "${projectRoot}"`, {
54
+ encoding: 'utf-8',
55
+ maxBuffer: 10 * 1024 * 1024,
56
+ timeout: 30000,
57
+ });
58
+ }
59
+ catch (e) {
60
+ const error = e;
61
+ if (error.status === 1) {
62
+ // No matches found
63
+ return {
64
+ content: [
65
+ {
66
+ type: 'text',
67
+ text: `No references found for symbol \`${symbol}\``,
68
+ },
69
+ ],
70
+ };
71
+ }
72
+ // Try grep as fallback
73
+ try {
74
+ rgOutput = execSync(`grep -rn --include="*.py" -w "${symbol}" "${projectRoot}"`, {
75
+ encoding: 'utf-8',
76
+ maxBuffer: 10 * 1024 * 1024,
77
+ timeout: 30000,
78
+ });
79
+ }
80
+ catch {
81
+ return {
82
+ content: [
83
+ {
84
+ type: 'text',
85
+ text: `No references found for symbol \`${symbol}\``,
86
+ },
87
+ ],
88
+ };
89
+ }
90
+ }
91
+ // Parse ripgrep output: file:line:column:text
92
+ const refs = [];
93
+ const lines = rgOutput.trim().split('\n').filter(Boolean);
94
+ for (const outputLine of lines) {
95
+ // Format: /path/to/file.py:10:5: some code here
96
+ const match = outputLine.match(/^(.+?):(\d+):(\d+):(.*)$/);
97
+ if (match) {
98
+ const [, filePath, lineNum, colNum, text] = match;
99
+ refs.push({
100
+ file: filePath,
101
+ line: parseInt(lineNum, 10),
102
+ column: parseInt(colNum, 10),
103
+ text: text.trim(),
104
+ isDefinition: filePath === file && parseInt(lineNum, 10) === line,
105
+ });
106
+ }
107
+ else {
108
+ // Fallback for grep format: /path/to/file.py:10: some code here
109
+ const grepMatch = outputLine.match(/^(.+?):(\d+):(.*)$/);
110
+ if (grepMatch) {
111
+ const [, filePath, lineNum, text] = grepMatch;
112
+ refs.push({
113
+ file: filePath,
114
+ line: parseInt(lineNum, 10),
115
+ column: 1,
116
+ text: text.trim(),
117
+ isDefinition: filePath === file && parseInt(lineNum, 10) === line,
118
+ });
119
+ }
120
+ }
121
+ }
122
+ if (refs.length === 0) {
15
123
  return {
16
- content: [{ type: 'text', text: 'No references found at this position.' }],
124
+ content: [
125
+ {
126
+ type: 'text',
127
+ text: `No references found for symbol \`${symbol}\``,
128
+ },
129
+ ],
17
130
  };
18
131
  }
19
- let output = `**References** for symbol at ${args.file}:${args.line}:${args.column}\n\n`;
20
- output += `Found ${result.length} reference(s):\n\n`;
21
- for (const loc of result) {
22
- const pos = fromPosition(loc.range.start);
23
- output += `- ${uriToPath(loc.uri)}:${pos.line}:${pos.column}\n`;
132
+ // Sort: definition first, then by file and line
133
+ refs.sort((a, b) => {
134
+ if (a.isDefinition && !b.isDefinition)
135
+ return -1;
136
+ if (!a.isDefinition && b.isDefinition)
137
+ return 1;
138
+ if (a.file !== b.file)
139
+ return a.file.localeCompare(b.file);
140
+ return a.line - b.line;
141
+ });
142
+ // Group by file
143
+ const byFile = new Map();
144
+ for (const ref of refs) {
145
+ const list = byFile.get(ref.file) || [];
146
+ list.push(ref);
147
+ byFile.set(ref.file, list);
148
+ }
149
+ let output = `**References** for \`${symbol}\`\n\n`;
150
+ output += `Found ${refs.length} reference(s) in ${byFile.size} file(s):\n\n`;
151
+ for (const [filePath, fileRefs] of byFile) {
152
+ output += `### ${filePath}\n\n`;
153
+ for (const ref of fileRefs) {
154
+ const marker = ref.isDefinition ? ' (definition)' : '';
155
+ output += `- **${ref.line}:${ref.column}**${marker}: \`${ref.text}\`\n`;
156
+ }
157
+ output += '\n';
24
158
  }
25
159
  return {
26
160
  content: [{ type: 'text', text: output }],
@@ -1,43 +1,152 @@
1
1
  import { z } from 'zod';
2
- import { getLspClient } from '../lsp-client.js';
3
- import { toPosition, fromPosition, uriToPath } from '../utils/position.js';
2
+ import { execSync } from 'child_process';
3
+ import { readFileSync } from 'fs';
4
+ import { findProjectRoot } from '../utils/position.js';
4
5
  export const renameSchema = {
5
6
  file: z.string().describe('Absolute path to the Python file'),
6
7
  line: z.number().int().positive().describe('Line number (1-based)'),
7
8
  column: z.number().int().positive().describe('Column number (1-based)'),
8
9
  newName: z.string().describe('New name for the symbol'),
9
10
  };
11
+ function getSymbolAtPosition(filePath, line, column) {
12
+ try {
13
+ const content = readFileSync(filePath, 'utf-8');
14
+ const lines = content.split('\n');
15
+ const targetLine = lines[line - 1];
16
+ if (!targetLine)
17
+ return null;
18
+ // Find word at position
19
+ const col = column - 1;
20
+ let start = col;
21
+ let end = col;
22
+ // Expand left
23
+ while (start > 0 && /[\w_]/.test(targetLine[start - 1])) {
24
+ start--;
25
+ }
26
+ // Expand right
27
+ while (end < targetLine.length && /[\w_]/.test(targetLine[end])) {
28
+ end++;
29
+ }
30
+ const symbol = targetLine.slice(start, end);
31
+ return symbol.length > 0 ? { symbol, start: start + 1, end: end + 1 } : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
10
37
  export async function rename(args) {
11
- const client = getLspClient();
12
- const position = toPosition(args.line, args.column);
13
- const result = await client.rename(args.file, position, args.newName);
14
- if (!result) {
38
+ const { file, line, column, newName } = args;
39
+ // Get symbol name at position
40
+ const symbolInfo = getSymbolAtPosition(file, line, column);
41
+ if (!symbolInfo) {
15
42
  return {
16
- content: [{ type: 'text', text: 'Cannot rename symbol at this position.' }],
43
+ content: [{ type: 'text', text: 'Could not identify symbol at this position.' }],
17
44
  };
18
45
  }
19
- let output = `**Rename Preview** for symbol at ${args.file}:${args.line}:${args.column}\n`;
20
- output += `New name: **${args.newName}**\n\n`;
21
- if (result.changes) {
22
- let totalEdits = 0;
23
- for (const [uri, edits] of Object.entries(result.changes)) {
24
- const filePath = uriToPath(uri);
25
- output += `**${filePath}**\n`;
26
- for (const edit of edits) {
27
- const start = fromPosition(edit.range.start);
28
- const end = fromPosition(edit.range.end);
29
- output += ` - Line ${start.line}:${start.column}-${end.line}:${end.column}: "${edit.newText}"\n`;
30
- totalEdits++;
31
- }
32
- output += '\n';
46
+ const { symbol: oldName } = symbolInfo;
47
+ if (oldName === newName) {
48
+ return {
49
+ content: [{ type: 'text', text: 'New name is the same as the old name.' }],
50
+ };
51
+ }
52
+ // Find project root
53
+ const projectRoot = findProjectRoot(file);
54
+ // Use ripgrep to find all references in Python files
55
+ const pattern = `\\b${oldName}\\b`;
56
+ let rgOutput;
57
+ try {
58
+ rgOutput = execSync(`rg --no-heading --line-number --column --type py "${pattern}" "${projectRoot}"`, {
59
+ encoding: 'utf-8',
60
+ maxBuffer: 10 * 1024 * 1024,
61
+ timeout: 30000,
62
+ });
63
+ }
64
+ catch (e) {
65
+ const error = e;
66
+ if (error.status === 1) {
67
+ return {
68
+ content: [
69
+ {
70
+ type: 'text',
71
+ text: `No references found for symbol \`${oldName}\``,
72
+ },
73
+ ],
74
+ };
75
+ }
76
+ // Try grep as fallback
77
+ try {
78
+ rgOutput = execSync(`grep -rn --include="*.py" -w "${oldName}" "${projectRoot}"`, {
79
+ encoding: 'utf-8',
80
+ maxBuffer: 10 * 1024 * 1024,
81
+ timeout: 30000,
82
+ });
83
+ }
84
+ catch {
85
+ return {
86
+ content: [
87
+ {
88
+ type: 'text',
89
+ text: `No references found for symbol \`${oldName}\``,
90
+ },
91
+ ],
92
+ };
33
93
  }
34
- output += `Total: ${totalEdits} edit(s) across ${Object.keys(result.changes).length} file(s)\n`;
35
94
  }
36
- else if (result.documentChanges) {
37
- output += 'Document changes detected (complex rename operation)\n';
38
- output += JSON.stringify(result.documentChanges, null, 2);
95
+ // Parse output and create edits
96
+ const edits = [];
97
+ const outputLines = rgOutput.trim().split('\n').filter(Boolean);
98
+ for (const outputLine of outputLines) {
99
+ // Format: /path/to/file.py:10:5: some code here
100
+ const match = outputLine.match(/^(.+?):(\d+):(\d+):(.*)$/);
101
+ if (match) {
102
+ const [, filePath, lineNum, colNum, lineContent] = match;
103
+ const col = parseInt(colNum, 10);
104
+ edits.push({
105
+ file: filePath,
106
+ line: parseInt(lineNum, 10),
107
+ column: col,
108
+ endColumn: col + oldName.length,
109
+ oldText: oldName,
110
+ newText: newName,
111
+ lineContent: lineContent.trim(),
112
+ });
113
+ }
114
+ }
115
+ if (edits.length === 0) {
116
+ return {
117
+ content: [
118
+ {
119
+ type: 'text',
120
+ text: `No references found for symbol \`${oldName}\``,
121
+ },
122
+ ],
123
+ };
124
+ }
125
+ // Group by file
126
+ const byFile = new Map();
127
+ for (const edit of edits) {
128
+ const list = byFile.get(edit.file) || [];
129
+ list.push(edit);
130
+ byFile.set(edit.file, list);
131
+ }
132
+ let output = `**Rename Preview**\n\n`;
133
+ output += `- Symbol: \`${oldName}\` → \`${newName}\`\n`;
134
+ output += `- Found ${edits.length} occurrence(s) in ${byFile.size} file(s)\n\n`;
135
+ output += `---\n\n`;
136
+ for (const [filePath, fileEdits] of byFile) {
137
+ output += `### ${filePath}\n\n`;
138
+ for (const edit of fileEdits) {
139
+ const preview = edit.lineContent.replace(new RegExp(`\\b${oldName}\\b`), `~~${oldName}~~ **${newName}**`);
140
+ output += `- Line ${edit.line}: ${preview}\n`;
141
+ }
142
+ output += '\n';
39
143
  }
40
- output += '\n**Note:** This is a preview. The actual rename has not been applied.';
144
+ output += `---\n\n`;
145
+ output += `**Note:** This is a preview only. To apply the rename, use your editor's rename feature or run:\n`;
146
+ output += `\`\`\`bash\n`;
147
+ output += `# Using sed (backup recommended)\n`;
148
+ output += `find "${projectRoot}" -name "*.py" -exec sed -i '' 's/\\b${oldName}\\b/${newName}/g' {} +\n`;
149
+ output += `\`\`\``;
41
150
  return {
42
151
  content: [{ type: 'text', text: output }],
43
152
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treedy/pyright-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.2",
4
4
  "description": "MCP server exposing Pyright LSP features for Python code intelligence",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",