syntax-map-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.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/analysis/context.js +47 -0
- package/dist/analysis/definitions.js +26 -0
- package/dist/analysis/index.js +383 -0
- package/dist/analysis/query.js +32 -0
- package/dist/analysis/references.js +41 -0
- package/dist/analysis/summary.js +36 -0
- package/dist/analysis/symbols.js +119 -0
- package/dist/cli.js +13 -0
- package/dist/languages.js +14 -0
- package/dist/parser.js +51 -0
- package/dist/result.js +15 -0
- package/dist/server.js +17 -0
- package/dist/tools.js +164 -0
- package/dist/types.js +1 -0
- package/dist/workspace.js +97 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kht6163
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# syntax-map-mcp
|
|
2
|
+
|
|
3
|
+
Tree-sitter 기반 코드 분석 MCP 서버입니다. 지정한 `workspaceRoot` 아래의 JavaScript, TypeScript, TSX, Python 소스 파일을 읽고 심볼, 정의, 참조, 요약, tree-sitter query, 컨텍스트 markdown을 제공합니다.
|
|
4
|
+
|
|
5
|
+
## 설치와 빌드
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
개발 중 검증:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm test
|
|
16
|
+
npm run typecheck
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 실행
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
node dist/cli.js --workspace-root /path/to/workspace
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`workspaceRoot` 결정 순서:
|
|
26
|
+
|
|
27
|
+
1. `--workspace-root` 인자
|
|
28
|
+
2. `WORKSPACE_ROOT` 환경 변수
|
|
29
|
+
3. 현재 작업 디렉터리
|
|
30
|
+
|
|
31
|
+
## 제공 도구
|
|
32
|
+
|
|
33
|
+
- `list_symbols`: 파일 하나의 top-level 심볼 목록 반환
|
|
34
|
+
- `find_definition`: 여러 파일에서 이름과 선택적 kind로 정의 검색
|
|
35
|
+
- `find_references`: 여러 파일에서 식별자 참조 검색
|
|
36
|
+
- `summarize_file`: 파일 언어, 라인 수, imports, exports, symbols 요약
|
|
37
|
+
- `run_query`: 파일 하나에 tree-sitter query 실행
|
|
38
|
+
- `build_context`: 여러 파일 요약을 markdown 컨텍스트로 구성
|
|
39
|
+
- `index_workspace`: 지원 소스 파일을 파싱해 SQLite 심볼 인덱스 생성 또는 갱신
|
|
40
|
+
- `search_symbols`: SQLite 인덱스에서 심볼 이름 검색
|
|
41
|
+
- `get_index_status`: 인덱스 경로, 인덱싱된 파일 수, 심볼 수, stale 파일 수 반환
|
|
42
|
+
- `clear_index`: SQLite 인덱스 파일 삭제
|
|
43
|
+
|
|
44
|
+
## SQLite 인덱스
|
|
45
|
+
|
|
46
|
+
`index_workspace`는 `workspaceRoot` 아래의 `.syntax-map-mcp/index.sqlite`에 인덱스를 저장합니다. 인덱싱 대상은 `.js`, `.jsx`, `.ts`, `.tsx`, `.py` 파일이며, `.git`, `.syntax-map-mcp`, `dist`, `node_modules` 디렉터리는 제외합니다.
|
|
47
|
+
|
|
48
|
+
파일 변경 여부는 `mtimeMs`와 `size`로 판단합니다. 다시 `index_workspace`를 호출하면 변경된 파일만 재파싱하고, 삭제된 파일은 인덱스에서 제거합니다. 자동 watch 모드는 아직 포함하지 않았습니다.
|
|
49
|
+
|
|
50
|
+
## MCP 설정 예시
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"syntax-map-mcp": {
|
|
56
|
+
"command": "node",
|
|
57
|
+
"args": [
|
|
58
|
+
"/Users/hantaekim/my-project/tree-sitter/dist/cli.js",
|
|
59
|
+
"--workspace-root",
|
|
60
|
+
"/path/to/workspace"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 보안 경계
|
|
68
|
+
|
|
69
|
+
서버는 `workspaceRoot` 내부 파일만 읽습니다. 지원 확장자는 `.js`, `.jsx`, `.ts`, `.tsx`, `.py`뿐이며, workspace 밖으로 나가는 경로나 지원하지 않는 확장자는 오류로 처리합니다.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { summarizeFile } from './summary.js';
|
|
2
|
+
export async function buildContext(workspace, input) {
|
|
3
|
+
const summaries = [];
|
|
4
|
+
for (const filePath of input.paths) {
|
|
5
|
+
const summary = await summarizeFile(workspace, filePath);
|
|
6
|
+
if (!summary.ok)
|
|
7
|
+
return summary;
|
|
8
|
+
summaries.push(summary);
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
ok: true,
|
|
12
|
+
markdown: renderMarkdown(summaries, input.detail)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function renderMarkdown(summaries, detail) {
|
|
16
|
+
return ['# Code Context', ...summaries.map(summary => renderFile(summary, detail))].join('\n\n');
|
|
17
|
+
}
|
|
18
|
+
function renderFile(summary, detail) {
|
|
19
|
+
const lines = [
|
|
20
|
+
`## ${summary.path}`,
|
|
21
|
+
'',
|
|
22
|
+
`- Language: ${summary.language}`,
|
|
23
|
+
`- Lines: ${summary.lineCount}`,
|
|
24
|
+
'',
|
|
25
|
+
'### Symbols',
|
|
26
|
+
...renderSymbols(summary)
|
|
27
|
+
];
|
|
28
|
+
if (detail === 'full') {
|
|
29
|
+
if (summary.imports.length > 0) {
|
|
30
|
+
lines.push('', '### Imports', ...summary.imports.map(line => `- ${line}`));
|
|
31
|
+
}
|
|
32
|
+
if (summary.exports.length > 0) {
|
|
33
|
+
lines.push('', '### Exports', ...summary.exports.map(line => `- ${line}`));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return lines.join('\n');
|
|
37
|
+
}
|
|
38
|
+
function renderSymbols(summary) {
|
|
39
|
+
if (summary.symbols.length === 0)
|
|
40
|
+
return ['- None'];
|
|
41
|
+
return summary.symbols.map(symbol => {
|
|
42
|
+
const row = symbol.range.start.row + 1;
|
|
43
|
+
const column = symbol.range.start.column + 1;
|
|
44
|
+
const name = symbol.kind === 'method' && symbol.parentName ? `${symbol.parentName}.${symbol.name}` : symbol.name;
|
|
45
|
+
return `- ${symbol.kind} ${name} (${row}:${column})`;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parseSourceFile } from '../parser.js';
|
|
2
|
+
import { listSymbols } from './symbols.js';
|
|
3
|
+
export async function findDefinitions(workspace, input) {
|
|
4
|
+
const definitions = [];
|
|
5
|
+
const kinds = input.kinds ? new Set(input.kinds) : undefined;
|
|
6
|
+
for (const inputPath of input.paths) {
|
|
7
|
+
const file = await workspace.readSourceFile(inputPath);
|
|
8
|
+
if (!file.ok)
|
|
9
|
+
return file;
|
|
10
|
+
const parsed = parseSourceFile(file);
|
|
11
|
+
if (!parsed.ok)
|
|
12
|
+
return parsed;
|
|
13
|
+
definitions.push(...listSymbols(parsed)
|
|
14
|
+
.filter(symbol => symbol.name === input.name)
|
|
15
|
+
.filter(symbol => !kinds || kinds.has(symbol.kind))
|
|
16
|
+
.map(symbol => ({
|
|
17
|
+
...symbol,
|
|
18
|
+
path: file.relativePath,
|
|
19
|
+
snippet: lineAt(file.text, symbol.range.start.row)
|
|
20
|
+
})));
|
|
21
|
+
}
|
|
22
|
+
return { ok: true, definitions };
|
|
23
|
+
}
|
|
24
|
+
function lineAt(text, row) {
|
|
25
|
+
return text.split(/\r?\n/)[row] ?? '';
|
|
26
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import initSqlJs from 'sql.js';
|
|
4
|
+
import { listSymbols } from './symbols.js';
|
|
5
|
+
import { parseSourceFile } from '../parser.js';
|
|
6
|
+
const INDEX_DIRECTORY = '.syntax-map-mcp';
|
|
7
|
+
const INDEX_FILE = 'index.sqlite';
|
|
8
|
+
function indexPathForWorkspace(workspace) {
|
|
9
|
+
return path.join(workspace.root, INDEX_DIRECTORY, INDEX_FILE);
|
|
10
|
+
}
|
|
11
|
+
function failure(message) {
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
error: {
|
|
15
|
+
code: 'INDEX_ERROR',
|
|
16
|
+
message
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
async function openDatabase(indexPath) {
|
|
21
|
+
const SQL = await initSqlJs();
|
|
22
|
+
try {
|
|
23
|
+
const data = await readFile(indexPath);
|
|
24
|
+
return new SQL.Database(data);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return new SQL.Database();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function saveDatabase(database, indexPath) {
|
|
31
|
+
await mkdir(path.dirname(indexPath), { recursive: true });
|
|
32
|
+
await writeFile(indexPath, Buffer.from(database.export()));
|
|
33
|
+
}
|
|
34
|
+
function initSchema(database) {
|
|
35
|
+
database.exec(`
|
|
36
|
+
PRAGMA foreign_keys = ON;
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
39
|
+
path TEXT PRIMARY KEY,
|
|
40
|
+
language TEXT,
|
|
41
|
+
size INTEGER NOT NULL,
|
|
42
|
+
mtime_ms REAL NOT NULL,
|
|
43
|
+
parse_status TEXT NOT NULL,
|
|
44
|
+
error_message TEXT,
|
|
45
|
+
indexed_at TEXT NOT NULL
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
file_path TEXT NOT NULL,
|
|
51
|
+
language TEXT NOT NULL,
|
|
52
|
+
name TEXT NOT NULL,
|
|
53
|
+
kind TEXT NOT NULL,
|
|
54
|
+
parent_name TEXT,
|
|
55
|
+
start_row INTEGER NOT NULL,
|
|
56
|
+
start_column INTEGER NOT NULL,
|
|
57
|
+
end_row INTEGER NOT NULL,
|
|
58
|
+
end_column INTEGER NOT NULL,
|
|
59
|
+
selection_start_row INTEGER,
|
|
60
|
+
selection_start_column INTEGER,
|
|
61
|
+
selection_end_row INTEGER,
|
|
62
|
+
selection_end_column INTEGER,
|
|
63
|
+
FOREIGN KEY(file_path) REFERENCES files(path) ON DELETE CASCADE
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE INDEX IF NOT EXISTS symbols_name_idx ON symbols(name);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS symbols_kind_idx ON symbols(kind);
|
|
68
|
+
`);
|
|
69
|
+
}
|
|
70
|
+
function selectStoredFiles(database) {
|
|
71
|
+
const rows = database.exec('SELECT path, size, mtime_ms, parse_status FROM files');
|
|
72
|
+
const files = new Map();
|
|
73
|
+
const result = rows[0];
|
|
74
|
+
if (!result)
|
|
75
|
+
return files;
|
|
76
|
+
for (const row of result.values) {
|
|
77
|
+
const [filePath, size, mtimeMs, parseStatus] = row;
|
|
78
|
+
files.set(String(filePath), {
|
|
79
|
+
path: String(filePath),
|
|
80
|
+
size: Number(size),
|
|
81
|
+
mtimeMs: Number(mtimeMs),
|
|
82
|
+
parseStatus: String(parseStatus)
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return files;
|
|
86
|
+
}
|
|
87
|
+
function isCurrent(stored, current) {
|
|
88
|
+
return (stored !== undefined &&
|
|
89
|
+
stored.parseStatus === 'ok' &&
|
|
90
|
+
stored.size === current.size &&
|
|
91
|
+
stored.mtimeMs === current.mtimeMs);
|
|
92
|
+
}
|
|
93
|
+
function upsertFile(database, input) {
|
|
94
|
+
database.run(`
|
|
95
|
+
INSERT INTO files (path, language, size, mtime_ms, parse_status, error_message, indexed_at)
|
|
96
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
97
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
98
|
+
language = excluded.language,
|
|
99
|
+
size = excluded.size,
|
|
100
|
+
mtime_ms = excluded.mtime_ms,
|
|
101
|
+
parse_status = excluded.parse_status,
|
|
102
|
+
error_message = excluded.error_message,
|
|
103
|
+
indexed_at = excluded.indexed_at
|
|
104
|
+
`, [
|
|
105
|
+
input.file.relativePath,
|
|
106
|
+
input.language,
|
|
107
|
+
input.file.size,
|
|
108
|
+
input.file.mtimeMs,
|
|
109
|
+
input.parseStatus,
|
|
110
|
+
input.errorMessage,
|
|
111
|
+
new Date().toISOString()
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
function insertSymbol(database, input) {
|
|
115
|
+
database.run(`
|
|
116
|
+
INSERT INTO symbols (
|
|
117
|
+
file_path,
|
|
118
|
+
language,
|
|
119
|
+
name,
|
|
120
|
+
kind,
|
|
121
|
+
parent_name,
|
|
122
|
+
start_row,
|
|
123
|
+
start_column,
|
|
124
|
+
end_row,
|
|
125
|
+
end_column,
|
|
126
|
+
selection_start_row,
|
|
127
|
+
selection_start_column,
|
|
128
|
+
selection_end_row,
|
|
129
|
+
selection_end_column
|
|
130
|
+
)
|
|
131
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
132
|
+
`, [
|
|
133
|
+
input.filePath,
|
|
134
|
+
input.language,
|
|
135
|
+
input.symbol.name,
|
|
136
|
+
input.symbol.kind,
|
|
137
|
+
input.symbol.parentName ?? null,
|
|
138
|
+
input.symbol.range.start.row,
|
|
139
|
+
input.symbol.range.start.column,
|
|
140
|
+
input.symbol.range.end.row,
|
|
141
|
+
input.symbol.range.end.column,
|
|
142
|
+
input.symbol.selectionRange?.start.row ?? null,
|
|
143
|
+
input.symbol.selectionRange?.start.column ?? null,
|
|
144
|
+
input.symbol.selectionRange?.end.row ?? null,
|
|
145
|
+
input.symbol.selectionRange?.end.column ?? null
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
function deleteFile(database, filePath) {
|
|
149
|
+
database.run('DELETE FROM files WHERE path = ?', [filePath]);
|
|
150
|
+
}
|
|
151
|
+
function deleteSymbolsForFile(database, filePath) {
|
|
152
|
+
database.run('DELETE FROM symbols WHERE file_path = ?', [filePath]);
|
|
153
|
+
}
|
|
154
|
+
function scalarCount(database, sql) {
|
|
155
|
+
const result = database.exec(sql)[0];
|
|
156
|
+
if (!result)
|
|
157
|
+
return 0;
|
|
158
|
+
return Number(result.values[0]?.[0] ?? 0);
|
|
159
|
+
}
|
|
160
|
+
function sqlLikePattern(query) {
|
|
161
|
+
return `%${query.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
|
162
|
+
}
|
|
163
|
+
function rowValue(row, key) {
|
|
164
|
+
return row[key];
|
|
165
|
+
}
|
|
166
|
+
export async function indexWorkspace(workspace) {
|
|
167
|
+
const indexPath = indexPathForWorkspace(workspace);
|
|
168
|
+
const database = await openDatabase(indexPath);
|
|
169
|
+
try {
|
|
170
|
+
initSchema(database);
|
|
171
|
+
const currentFiles = await workspace.listSourceFiles();
|
|
172
|
+
const currentPaths = new Set(currentFiles.map(file => file.relativePath));
|
|
173
|
+
const storedFiles = selectStoredFiles(database);
|
|
174
|
+
let removedFiles = 0;
|
|
175
|
+
let indexedFiles = 0;
|
|
176
|
+
let skippedFiles = 0;
|
|
177
|
+
for (const storedPath of storedFiles.keys()) {
|
|
178
|
+
if (!currentPaths.has(storedPath)) {
|
|
179
|
+
deleteFile(database, storedPath);
|
|
180
|
+
removedFiles += 1;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
for (const fileInfo of currentFiles) {
|
|
184
|
+
if (isCurrent(storedFiles.get(fileInfo.relativePath), fileInfo)) {
|
|
185
|
+
skippedFiles += 1;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const file = await workspace.readSourceFile(fileInfo.relativePath);
|
|
189
|
+
if (!file.ok) {
|
|
190
|
+
upsertFile(database, {
|
|
191
|
+
file: fileInfo,
|
|
192
|
+
language: null,
|
|
193
|
+
parseStatus: 'error',
|
|
194
|
+
errorMessage: file.error.message
|
|
195
|
+
});
|
|
196
|
+
deleteSymbolsForFile(database, fileInfo.relativePath);
|
|
197
|
+
indexedFiles += 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const parsed = parseSourceFile(file);
|
|
201
|
+
if (!parsed.ok) {
|
|
202
|
+
upsertFile(database, {
|
|
203
|
+
file: fileInfo,
|
|
204
|
+
language: null,
|
|
205
|
+
parseStatus: 'error',
|
|
206
|
+
errorMessage: parsed.error.message
|
|
207
|
+
});
|
|
208
|
+
deleteSymbolsForFile(database, fileInfo.relativePath);
|
|
209
|
+
indexedFiles += 1;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
upsertFile(database, {
|
|
213
|
+
file: fileInfo,
|
|
214
|
+
language: parsed.language,
|
|
215
|
+
parseStatus: 'ok',
|
|
216
|
+
errorMessage: null
|
|
217
|
+
});
|
|
218
|
+
deleteSymbolsForFile(database, fileInfo.relativePath);
|
|
219
|
+
for (const symbol of listSymbols(parsed)) {
|
|
220
|
+
insertSymbol(database, {
|
|
221
|
+
filePath: fileInfo.relativePath,
|
|
222
|
+
language: parsed.language,
|
|
223
|
+
symbol
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
indexedFiles += 1;
|
|
227
|
+
}
|
|
228
|
+
await saveDatabase(database, indexPath);
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
indexPath,
|
|
232
|
+
indexedFiles,
|
|
233
|
+
skippedFiles,
|
|
234
|
+
removedFiles,
|
|
235
|
+
symbols: scalarCount(database, 'SELECT COUNT(*) FROM symbols')
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
database.close();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
export async function searchSymbols(workspace, input) {
|
|
246
|
+
const indexPath = indexPathForWorkspace(workspace);
|
|
247
|
+
const database = await openDatabase(indexPath);
|
|
248
|
+
try {
|
|
249
|
+
initSchema(database);
|
|
250
|
+
const where = ['name LIKE ? ESCAPE "\\"'];
|
|
251
|
+
const params = [sqlLikePattern(input.query)];
|
|
252
|
+
if (input.kinds && input.kinds.length > 0) {
|
|
253
|
+
where.push(`kind IN (${input.kinds.map(() => '?').join(', ')})`);
|
|
254
|
+
params.push(...input.kinds);
|
|
255
|
+
}
|
|
256
|
+
const limit = input.limit ?? 50;
|
|
257
|
+
params.push(limit);
|
|
258
|
+
const statement = database.prepare(`
|
|
259
|
+
SELECT
|
|
260
|
+
file_path,
|
|
261
|
+
language,
|
|
262
|
+
name,
|
|
263
|
+
kind,
|
|
264
|
+
parent_name,
|
|
265
|
+
start_row,
|
|
266
|
+
start_column,
|
|
267
|
+
end_row,
|
|
268
|
+
end_column,
|
|
269
|
+
selection_start_row,
|
|
270
|
+
selection_start_column,
|
|
271
|
+
selection_end_row,
|
|
272
|
+
selection_end_column
|
|
273
|
+
FROM symbols
|
|
274
|
+
WHERE ${where.join(' AND ')}
|
|
275
|
+
ORDER BY name ASC, file_path ASC, start_row ASC
|
|
276
|
+
LIMIT ?
|
|
277
|
+
`, params);
|
|
278
|
+
const symbols = [];
|
|
279
|
+
try {
|
|
280
|
+
while (statement.step()) {
|
|
281
|
+
const row = statement.getAsObject();
|
|
282
|
+
const selectionStartRow = rowValue(row, 'selection_start_row');
|
|
283
|
+
const selectionStartColumn = rowValue(row, 'selection_start_column');
|
|
284
|
+
const selectionEndRow = rowValue(row, 'selection_end_row');
|
|
285
|
+
const selectionEndColumn = rowValue(row, 'selection_end_column');
|
|
286
|
+
symbols.push({
|
|
287
|
+
path: String(rowValue(row, 'file_path')),
|
|
288
|
+
language: rowValue(row, 'language'),
|
|
289
|
+
name: String(rowValue(row, 'name')),
|
|
290
|
+
kind: rowValue(row, 'kind'),
|
|
291
|
+
parentName: rowValue(row, 'parent_name') === null ? undefined : String(rowValue(row, 'parent_name')),
|
|
292
|
+
range: {
|
|
293
|
+
start: {
|
|
294
|
+
row: Number(rowValue(row, 'start_row')),
|
|
295
|
+
column: Number(rowValue(row, 'start_column'))
|
|
296
|
+
},
|
|
297
|
+
end: {
|
|
298
|
+
row: Number(rowValue(row, 'end_row')),
|
|
299
|
+
column: Number(rowValue(row, 'end_column'))
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
selectionRange: selectionStartRow === null ||
|
|
303
|
+
selectionStartColumn === null ||
|
|
304
|
+
selectionEndRow === null ||
|
|
305
|
+
selectionEndColumn === null
|
|
306
|
+
? undefined
|
|
307
|
+
: {
|
|
308
|
+
start: {
|
|
309
|
+
row: Number(selectionStartRow),
|
|
310
|
+
column: Number(selectionStartColumn)
|
|
311
|
+
},
|
|
312
|
+
end: {
|
|
313
|
+
row: Number(selectionEndRow),
|
|
314
|
+
column: Number(selectionEndColumn)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
statement.free();
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
ok: true,
|
|
325
|
+
indexPath,
|
|
326
|
+
total: symbols.length,
|
|
327
|
+
symbols
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
database.close();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
export async function getIndexStatus(workspace) {
|
|
338
|
+
const indexPath = indexPathForWorkspace(workspace);
|
|
339
|
+
const database = await openDatabase(indexPath);
|
|
340
|
+
try {
|
|
341
|
+
initSchema(database);
|
|
342
|
+
const storedFiles = selectStoredFiles(database);
|
|
343
|
+
const currentFiles = await workspace.listSourceFiles();
|
|
344
|
+
let staleFiles = 0;
|
|
345
|
+
for (const file of currentFiles) {
|
|
346
|
+
if (!isCurrent(storedFiles.get(file.relativePath), file)) {
|
|
347
|
+
staleFiles += 1;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
for (const storedPath of storedFiles.keys()) {
|
|
351
|
+
if (!currentFiles.some(file => file.relativePath === storedPath)) {
|
|
352
|
+
staleFiles += 1;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
indexPath,
|
|
358
|
+
indexedFiles: scalarCount(database, 'SELECT COUNT(*) FROM files WHERE parse_status = "ok"'),
|
|
359
|
+
symbols: scalarCount(database, 'SELECT COUNT(*) FROM symbols'),
|
|
360
|
+
staleFiles
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
database.close();
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
export async function clearIndex(workspace) {
|
|
371
|
+
const indexPath = indexPathForWorkspace(workspace);
|
|
372
|
+
try {
|
|
373
|
+
await rm(indexPath, { force: true });
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
indexPath,
|
|
377
|
+
cleared: true
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import Parser from 'tree-sitter';
|
|
2
|
+
import { languageForName } from '../languages.js';
|
|
3
|
+
export function runTreeSitterQuery(parsed, queryText) {
|
|
4
|
+
try {
|
|
5
|
+
const query = new Parser.Query(languageForName(parsed.language), queryText);
|
|
6
|
+
const captures = query.captures(parsed.tree.rootNode).map(capture => ({
|
|
7
|
+
name: capture.name,
|
|
8
|
+
nodeType: capture.node.type,
|
|
9
|
+
range: {
|
|
10
|
+
start: capture.node.startPosition,
|
|
11
|
+
end: capture.node.endPosition
|
|
12
|
+
},
|
|
13
|
+
text: capture.node.text
|
|
14
|
+
}));
|
|
15
|
+
return {
|
|
16
|
+
ok: true,
|
|
17
|
+
language: parsed.language,
|
|
18
|
+
path: parsed.file.relativePath,
|
|
19
|
+
captures
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
error: {
|
|
27
|
+
code: 'QUERY_ERROR',
|
|
28
|
+
message
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { parseSourceFile } from '../parser.js';
|
|
2
|
+
import { runTreeSitterQuery } from './query.js';
|
|
3
|
+
export async function findReferences(workspace, input) {
|
|
4
|
+
const references = [];
|
|
5
|
+
for (const inputPath of input.paths) {
|
|
6
|
+
const file = await workspace.readSourceFile(inputPath);
|
|
7
|
+
if (!file.ok)
|
|
8
|
+
return file;
|
|
9
|
+
const parsed = parseSourceFile(file);
|
|
10
|
+
if (!parsed.ok)
|
|
11
|
+
return parsed;
|
|
12
|
+
const query = runTreeSitterQuery(parsed, referenceQueryForLanguage(parsed.language));
|
|
13
|
+
if (!query.ok)
|
|
14
|
+
return query;
|
|
15
|
+
references.push(...query.captures
|
|
16
|
+
.filter(capture => capture.text === input.name)
|
|
17
|
+
.map(capture => ({
|
|
18
|
+
path: file.relativePath,
|
|
19
|
+
name: capture.text,
|
|
20
|
+
nodeType: capture.nodeType,
|
|
21
|
+
range: capture.range,
|
|
22
|
+
snippet: lineAt(file.text, capture.range.start.row)
|
|
23
|
+
})));
|
|
24
|
+
}
|
|
25
|
+
return { ok: true, references };
|
|
26
|
+
}
|
|
27
|
+
function referenceQueryForLanguage(language) {
|
|
28
|
+
switch (language) {
|
|
29
|
+
case 'typescript':
|
|
30
|
+
case 'tsx':
|
|
31
|
+
return '[(identifier) (type_identifier) (property_identifier)] @reference';
|
|
32
|
+
case 'javascript':
|
|
33
|
+
return '[(identifier) (property_identifier)] @reference';
|
|
34
|
+
case 'python':
|
|
35
|
+
default:
|
|
36
|
+
return '(identifier) @reference';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function lineAt(text, row) {
|
|
40
|
+
return text.split(/\r?\n/)[row] ?? '';
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { parseSourceFile } from '../parser.js';
|
|
2
|
+
import { listSymbols } from './symbols.js';
|
|
3
|
+
export async function summarizeFile(workspace, filePath) {
|
|
4
|
+
const file = await workspace.readSourceFile(filePath);
|
|
5
|
+
if (!file.ok)
|
|
6
|
+
return file;
|
|
7
|
+
const parsed = parseSourceFile(file);
|
|
8
|
+
if (!parsed.ok)
|
|
9
|
+
return parsed;
|
|
10
|
+
return {
|
|
11
|
+
ok: true,
|
|
12
|
+
path: file.relativePath,
|
|
13
|
+
language: parsed.language,
|
|
14
|
+
lineCount: countLines(file.text),
|
|
15
|
+
symbols: listSymbols(parsed),
|
|
16
|
+
imports: findImports(file.text),
|
|
17
|
+
exports: findExports(file.text)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function countLines(text) {
|
|
21
|
+
if (text.length === 0)
|
|
22
|
+
return 0;
|
|
23
|
+
return text.replace(/\r\n|\r|\n$/, '').split(/\r\n|\r|\n/).length;
|
|
24
|
+
}
|
|
25
|
+
function findImports(text) {
|
|
26
|
+
return trimmedLines(text).filter(line => line.startsWith('import ') || line.startsWith('from '));
|
|
27
|
+
}
|
|
28
|
+
function findExports(text) {
|
|
29
|
+
return trimmedLines(text).filter(line => line.startsWith('export '));
|
|
30
|
+
}
|
|
31
|
+
function trimmedLines(text) {
|
|
32
|
+
return text
|
|
33
|
+
.split(/\r\n|\r|\n/)
|
|
34
|
+
.map(line => line.trim())
|
|
35
|
+
.filter(Boolean);
|
|
36
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import Parser from 'tree-sitter';
|
|
2
|
+
import { languageForName } from '../languages.js';
|
|
3
|
+
const javaScriptSymbolPatterns = [
|
|
4
|
+
{ kind: 'class', query: '(class_declaration name: (identifier) @name) @definition' },
|
|
5
|
+
{ kind: 'method', query: '(method_definition name: (property_identifier) @name) @definition' },
|
|
6
|
+
{ kind: 'function', query: '(function_declaration name: (identifier) @name) @definition' },
|
|
7
|
+
{ kind: 'variable', query: '(variable_declarator name: (identifier) @name) @definition' }
|
|
8
|
+
];
|
|
9
|
+
const typeScriptSymbolPatterns = [
|
|
10
|
+
{ kind: 'interface', query: '(interface_declaration name: (type_identifier) @name) @definition' },
|
|
11
|
+
{ kind: 'type', query: '(type_alias_declaration name: (type_identifier) @name) @definition' },
|
|
12
|
+
{ kind: 'class', query: '(class_declaration name: (type_identifier) @name) @definition' },
|
|
13
|
+
{ kind: 'class', query: '(abstract_class_declaration name: (type_identifier) @name) @definition' },
|
|
14
|
+
{ kind: 'method', query: '(method_definition name: (property_identifier) @name) @definition' },
|
|
15
|
+
{ kind: 'method', query: '(abstract_method_signature name: (property_identifier) @name) @definition' },
|
|
16
|
+
{ kind: 'function', query: '(function_declaration name: (identifier) @name) @definition' },
|
|
17
|
+
{ kind: 'variable', query: '(variable_declarator name: (identifier) @name) @definition' }
|
|
18
|
+
];
|
|
19
|
+
const pythonSymbolPatterns = [
|
|
20
|
+
{ kind: 'class', query: '(class_definition name: (identifier) @name) @definition' },
|
|
21
|
+
{ kind: 'function', query: '(function_definition name: (identifier) @name) @definition' },
|
|
22
|
+
{ kind: 'variable', query: '(module (expression_statement (assignment left: (identifier) @name) @definition))' }
|
|
23
|
+
];
|
|
24
|
+
export function listSymbols(parsed) {
|
|
25
|
+
return patternsForLanguage(parsed).flatMap(pattern => querySymbols(parsed, pattern));
|
|
26
|
+
}
|
|
27
|
+
function patternsForLanguage(parsed) {
|
|
28
|
+
switch (parsed.language) {
|
|
29
|
+
case 'javascript':
|
|
30
|
+
return javaScriptSymbolPatterns;
|
|
31
|
+
case 'typescript':
|
|
32
|
+
case 'tsx':
|
|
33
|
+
return typeScriptSymbolPatterns;
|
|
34
|
+
case 'python':
|
|
35
|
+
return pythonSymbolPatterns;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function querySymbols(parsed, pattern) {
|
|
39
|
+
const query = new Parser.Query(languageForName(parsed.language), pattern.query);
|
|
40
|
+
return query.matches(parsed.tree.rootNode).flatMap(match => {
|
|
41
|
+
const name = match.captures.find(capture => capture.name === 'name')?.node;
|
|
42
|
+
const definition = match.captures.find(capture => capture.name === 'definition')?.node ?? name;
|
|
43
|
+
if (!name || !definition)
|
|
44
|
+
return [];
|
|
45
|
+
if (pattern.kind === 'variable' && !isTopLevelVariableDefinition(definition))
|
|
46
|
+
return [];
|
|
47
|
+
if (pattern.kind === 'method' &&
|
|
48
|
+
isJavaScriptLikeLanguage(parsed) &&
|
|
49
|
+
!directJavaScriptLikeMethodClass(definition)) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
if (pattern.kind === 'method' && name.text === 'constructor')
|
|
53
|
+
return [];
|
|
54
|
+
const kind = symbolKind(parsed, pattern.kind, definition);
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
name: name.text,
|
|
58
|
+
kind,
|
|
59
|
+
range: rangeForNode(definition),
|
|
60
|
+
selectionRange: rangeForNode(name),
|
|
61
|
+
parentName: parentNameForSymbol(parsed, kind, definition)
|
|
62
|
+
}
|
|
63
|
+
];
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function symbolKind(parsed, kind, definition) {
|
|
67
|
+
if (parsed.language === 'python' && kind === 'function' && isDirectPythonMethod(definition)) {
|
|
68
|
+
return 'method';
|
|
69
|
+
}
|
|
70
|
+
return kind;
|
|
71
|
+
}
|
|
72
|
+
function parentNameForSymbol(parsed, kind, definition) {
|
|
73
|
+
if (kind !== 'method')
|
|
74
|
+
return undefined;
|
|
75
|
+
const classNode = parsed.language === 'python'
|
|
76
|
+
? directPythonMethodClass(definition)
|
|
77
|
+
: directJavaScriptLikeMethodClass(definition);
|
|
78
|
+
return classNode?.childForFieldName('name')?.text;
|
|
79
|
+
}
|
|
80
|
+
function isJavaScriptLikeLanguage(parsed) {
|
|
81
|
+
return (parsed.language === 'javascript' ||
|
|
82
|
+
parsed.language === 'typescript' ||
|
|
83
|
+
parsed.language === 'tsx');
|
|
84
|
+
}
|
|
85
|
+
function isTopLevelVariableDefinition(definition) {
|
|
86
|
+
const statement = definition.parent;
|
|
87
|
+
if (!statement)
|
|
88
|
+
return false;
|
|
89
|
+
if (statement.parent?.type === 'program' || statement.parent?.type === 'module')
|
|
90
|
+
return true;
|
|
91
|
+
return (statement.parent?.type === 'export_statement' &&
|
|
92
|
+
(statement.parent.parent?.type === 'program' || statement.parent.parent?.type === 'module'));
|
|
93
|
+
}
|
|
94
|
+
function isDirectPythonMethod(definition) {
|
|
95
|
+
return directPythonMethodClass(definition) !== undefined;
|
|
96
|
+
}
|
|
97
|
+
function directPythonMethodClass(definition) {
|
|
98
|
+
const methodNode = definition.parent?.type === 'decorated_definition' ? definition.parent : definition;
|
|
99
|
+
const block = methodNode.parent;
|
|
100
|
+
const classNode = block?.parent;
|
|
101
|
+
if (block?.type !== 'block' || classNode?.type !== 'class_definition')
|
|
102
|
+
return undefined;
|
|
103
|
+
return classNode;
|
|
104
|
+
}
|
|
105
|
+
function directJavaScriptLikeMethodClass(definition) {
|
|
106
|
+
const classBody = definition.parent;
|
|
107
|
+
const classNode = classBody?.parent;
|
|
108
|
+
if (classBody?.type !== 'class_body' ||
|
|
109
|
+
(classNode?.type !== 'class_declaration' && classNode?.type !== 'abstract_class_declaration')) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return classNode;
|
|
113
|
+
}
|
|
114
|
+
function rangeForNode(node) {
|
|
115
|
+
return {
|
|
116
|
+
start: node.startPosition,
|
|
117
|
+
end: node.endPosition
|
|
118
|
+
};
|
|
119
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { runServer } from './server.js';
|
|
4
|
+
function readWorkspaceRoot(argv, env) {
|
|
5
|
+
const flagIndex = argv.indexOf('--workspace-root');
|
|
6
|
+
if (flagIndex >= 0 && argv[flagIndex + 1]) {
|
|
7
|
+
return argv[flagIndex + 1];
|
|
8
|
+
}
|
|
9
|
+
return env.WORKSPACE_ROOT ?? process.cwd();
|
|
10
|
+
}
|
|
11
|
+
await runServer({
|
|
12
|
+
workspaceRoot: readWorkspaceRoot(process.argv.slice(2), process.env)
|
|
13
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
const javascript = require('tree-sitter-javascript');
|
|
4
|
+
const python = require('tree-sitter-python');
|
|
5
|
+
const typescript = require('tree-sitter-typescript');
|
|
6
|
+
const languages = {
|
|
7
|
+
javascript,
|
|
8
|
+
typescript: typescript.typescript,
|
|
9
|
+
tsx: typescript.tsx,
|
|
10
|
+
python
|
|
11
|
+
};
|
|
12
|
+
export function languageForName(language) {
|
|
13
|
+
return languages[language];
|
|
14
|
+
}
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import Parser from 'tree-sitter';
|
|
3
|
+
import { languageForName } from './languages.js';
|
|
4
|
+
export function detectLanguage(filePath) {
|
|
5
|
+
const extension = path.extname(filePath);
|
|
6
|
+
switch (extension) {
|
|
7
|
+
case '.js':
|
|
8
|
+
case '.jsx':
|
|
9
|
+
return { ok: true, language: 'javascript' };
|
|
10
|
+
case '.ts':
|
|
11
|
+
return { ok: true, language: 'typescript' };
|
|
12
|
+
case '.tsx':
|
|
13
|
+
return { ok: true, language: 'tsx' };
|
|
14
|
+
case '.py':
|
|
15
|
+
return { ok: true, language: 'python' };
|
|
16
|
+
default:
|
|
17
|
+
return {
|
|
18
|
+
ok: false,
|
|
19
|
+
error: {
|
|
20
|
+
code: 'UNSUPPORTED_EXTENSION',
|
|
21
|
+
message: `Unsupported extension: ${extension}`
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function parseSourceFile(file) {
|
|
27
|
+
const detected = detectLanguage(file.absolutePath);
|
|
28
|
+
if (!detected.ok)
|
|
29
|
+
return detected;
|
|
30
|
+
try {
|
|
31
|
+
const parser = new Parser();
|
|
32
|
+
parser.setLanguage(languageForName(detected.language));
|
|
33
|
+
const tree = parser.parse(file.text);
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
file,
|
|
37
|
+
language: detected.language,
|
|
38
|
+
tree
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: {
|
|
46
|
+
code: 'PARSE_ERROR',
|
|
47
|
+
message
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/result.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function jsonResult(value) {
|
|
2
|
+
return {
|
|
3
|
+
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
4
|
+
structuredContent: value
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
export function toolFailure(code, message) {
|
|
8
|
+
return {
|
|
9
|
+
...jsonResult({
|
|
10
|
+
ok: false,
|
|
11
|
+
error: { code, message }
|
|
12
|
+
}),
|
|
13
|
+
isError: true
|
|
14
|
+
};
|
|
15
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { registerTools } from './tools.js';
|
|
4
|
+
import { createWorkspace } from './workspace.js';
|
|
5
|
+
export async function createServer(options) {
|
|
6
|
+
const workspace = await createWorkspace(options.workspaceRoot);
|
|
7
|
+
const server = new McpServer({ name: 'syntax-map-mcp', version: '0.1.0' }, {
|
|
8
|
+
instructions: 'Analyze JavaScript, TypeScript, and Python source files under the configured workspaceRoot only.'
|
|
9
|
+
});
|
|
10
|
+
registerTools(server, workspace);
|
|
11
|
+
return server;
|
|
12
|
+
}
|
|
13
|
+
export async function runServer(options) {
|
|
14
|
+
const server = await createServer(options);
|
|
15
|
+
const transport = new StdioServerTransport();
|
|
16
|
+
await server.connect(transport);
|
|
17
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildContext as buildContextAnalysis } from './analysis/context.js';
|
|
3
|
+
import { findDefinitions } from './analysis/definitions.js';
|
|
4
|
+
import { clearIndex as clearWorkspaceIndex, getIndexStatus as getWorkspaceIndexStatus, indexWorkspace as indexWorkspaceAnalysis, searchSymbols as searchIndexedSymbols } from './analysis/index.js';
|
|
5
|
+
import { runTreeSitterQuery } from './analysis/query.js';
|
|
6
|
+
import { findReferences as findReferencesAnalysis } from './analysis/references.js';
|
|
7
|
+
import { summarizeFile as summarizeFileAnalysis } from './analysis/summary.js';
|
|
8
|
+
import { listSymbols as listParsedSymbols } from './analysis/symbols.js';
|
|
9
|
+
import { parseSourceFile } from './parser.js';
|
|
10
|
+
import { jsonResult, toolFailure } from './result.js';
|
|
11
|
+
const symbolKindSchema = z.enum(['function', 'method', 'class', 'variable', 'interface', 'type']);
|
|
12
|
+
const detailSchema = z.enum(['compact', 'full']);
|
|
13
|
+
export function createToolHandlers(workspace) {
|
|
14
|
+
return {
|
|
15
|
+
async listSymbols(input) {
|
|
16
|
+
const file = await workspace.readSourceFile(input.path);
|
|
17
|
+
if (!file.ok)
|
|
18
|
+
return toolFailure(file.error.code, file.error.message);
|
|
19
|
+
const parsed = parseSourceFile(file);
|
|
20
|
+
if (!parsed.ok)
|
|
21
|
+
return toolFailure(parsed.error.code, parsed.error.message);
|
|
22
|
+
return jsonResult({
|
|
23
|
+
ok: true,
|
|
24
|
+
path: file.relativePath,
|
|
25
|
+
language: parsed.language,
|
|
26
|
+
symbols: listParsedSymbols(parsed)
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
async findDefinition(input) {
|
|
30
|
+
const result = await findDefinitions(workspace, input);
|
|
31
|
+
if (!result.ok)
|
|
32
|
+
return toolFailure(result.error.code, result.error.message);
|
|
33
|
+
return jsonResult(result);
|
|
34
|
+
},
|
|
35
|
+
async findReferences(input) {
|
|
36
|
+
const result = await findReferencesAnalysis(workspace, input);
|
|
37
|
+
if (!result.ok)
|
|
38
|
+
return toolFailure(result.error.code, result.error.message);
|
|
39
|
+
return jsonResult(result);
|
|
40
|
+
},
|
|
41
|
+
async summarizeFile(input) {
|
|
42
|
+
const result = await summarizeFileAnalysis(workspace, input.path);
|
|
43
|
+
if (!result.ok)
|
|
44
|
+
return toolFailure(result.error.code, result.error.message);
|
|
45
|
+
return jsonResult(result);
|
|
46
|
+
},
|
|
47
|
+
async runQuery(input) {
|
|
48
|
+
const file = await workspace.readSourceFile(input.path);
|
|
49
|
+
if (!file.ok)
|
|
50
|
+
return toolFailure(file.error.code, file.error.message);
|
|
51
|
+
const parsed = parseSourceFile(file);
|
|
52
|
+
if (!parsed.ok)
|
|
53
|
+
return toolFailure(parsed.error.code, parsed.error.message);
|
|
54
|
+
const result = runTreeSitterQuery(parsed, input.query);
|
|
55
|
+
if (!result.ok)
|
|
56
|
+
return toolFailure(result.error.code, result.error.message);
|
|
57
|
+
return jsonResult(result);
|
|
58
|
+
},
|
|
59
|
+
async buildContext(input) {
|
|
60
|
+
const result = await buildContextAnalysis(workspace, input);
|
|
61
|
+
if (!result.ok)
|
|
62
|
+
return toolFailure(result.error.code, result.error.message);
|
|
63
|
+
return jsonResult(result);
|
|
64
|
+
},
|
|
65
|
+
async indexWorkspace(_input) {
|
|
66
|
+
const result = await indexWorkspaceAnalysis(workspace);
|
|
67
|
+
if (!result.ok)
|
|
68
|
+
return toolFailure(result.error.code, result.error.message);
|
|
69
|
+
return jsonResult(result);
|
|
70
|
+
},
|
|
71
|
+
async searchSymbols(input) {
|
|
72
|
+
const result = await searchIndexedSymbols(workspace, input);
|
|
73
|
+
if (!result.ok)
|
|
74
|
+
return toolFailure(result.error.code, result.error.message);
|
|
75
|
+
return jsonResult(result);
|
|
76
|
+
},
|
|
77
|
+
async getIndexStatus(_input) {
|
|
78
|
+
const result = await getWorkspaceIndexStatus(workspace);
|
|
79
|
+
if (!result.ok)
|
|
80
|
+
return toolFailure(result.error.code, result.error.message);
|
|
81
|
+
return jsonResult(result);
|
|
82
|
+
},
|
|
83
|
+
async clearIndex(_input) {
|
|
84
|
+
const result = await clearWorkspaceIndex(workspace);
|
|
85
|
+
if (!result.ok)
|
|
86
|
+
return toolFailure(result.error.code, result.error.message);
|
|
87
|
+
return jsonResult(result);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export function registerTools(server, workspace) {
|
|
92
|
+
const handlers = createToolHandlers(workspace);
|
|
93
|
+
server.registerTool('list_symbols', {
|
|
94
|
+
title: 'List symbols',
|
|
95
|
+
description: 'List top-level symbols in one supported source file.',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
path: z.string()
|
|
98
|
+
}
|
|
99
|
+
}, handlers.listSymbols);
|
|
100
|
+
server.registerTool('find_definition', {
|
|
101
|
+
title: 'Find definition',
|
|
102
|
+
description: 'Find symbol definitions by name across supported source files.',
|
|
103
|
+
inputSchema: {
|
|
104
|
+
name: z.string(),
|
|
105
|
+
paths: z.array(z.string()),
|
|
106
|
+
kinds: z.array(symbolKindSchema).optional()
|
|
107
|
+
}
|
|
108
|
+
}, handlers.findDefinition);
|
|
109
|
+
server.registerTool('find_references', {
|
|
110
|
+
title: 'Find references',
|
|
111
|
+
description: 'Find identifier references by name across supported source files.',
|
|
112
|
+
inputSchema: {
|
|
113
|
+
name: z.string(),
|
|
114
|
+
paths: z.array(z.string())
|
|
115
|
+
}
|
|
116
|
+
}, handlers.findReferences);
|
|
117
|
+
server.registerTool('summarize_file', {
|
|
118
|
+
title: 'Summarize file',
|
|
119
|
+
description: 'Summarize language, line count, imports, exports, and symbols for one file.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
path: z.string()
|
|
122
|
+
}
|
|
123
|
+
}, handlers.summarizeFile);
|
|
124
|
+
server.registerTool('run_query', {
|
|
125
|
+
title: 'Run tree-sitter query',
|
|
126
|
+
description: 'Run a tree-sitter query against one supported source file.',
|
|
127
|
+
inputSchema: {
|
|
128
|
+
path: z.string(),
|
|
129
|
+
query: z.string()
|
|
130
|
+
}
|
|
131
|
+
}, handlers.runQuery);
|
|
132
|
+
server.registerTool('build_context', {
|
|
133
|
+
title: 'Build context',
|
|
134
|
+
description: 'Build markdown context for supported source files.',
|
|
135
|
+
inputSchema: {
|
|
136
|
+
paths: z.array(z.string()),
|
|
137
|
+
detail: detailSchema
|
|
138
|
+
}
|
|
139
|
+
}, handlers.buildContext);
|
|
140
|
+
server.registerTool('index_workspace', {
|
|
141
|
+
title: 'Index workspace',
|
|
142
|
+
description: 'Build or refresh the SQLite symbol index for all supported source files.',
|
|
143
|
+
inputSchema: {}
|
|
144
|
+
}, handlers.indexWorkspace);
|
|
145
|
+
server.registerTool('search_symbols', {
|
|
146
|
+
title: 'Search indexed symbols',
|
|
147
|
+
description: 'Search symbols from the SQLite workspace index.',
|
|
148
|
+
inputSchema: {
|
|
149
|
+
query: z.string(),
|
|
150
|
+
kinds: z.array(symbolKindSchema).optional(),
|
|
151
|
+
limit: z.number().int().positive().max(500).optional()
|
|
152
|
+
}
|
|
153
|
+
}, handlers.searchSymbols);
|
|
154
|
+
server.registerTool('get_index_status', {
|
|
155
|
+
title: 'Get index status',
|
|
156
|
+
description: 'Return SQLite index path, indexed file count, symbol count, and stale file count.',
|
|
157
|
+
inputSchema: {}
|
|
158
|
+
}, handlers.getIndexStatus);
|
|
159
|
+
server.registerTool('clear_index', {
|
|
160
|
+
title: 'Clear index',
|
|
161
|
+
description: 'Delete the SQLite workspace index file.',
|
|
162
|
+
inputSchema: {}
|
|
163
|
+
}, handlers.clearIndex);
|
|
164
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const SUPPORTED_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.py']);
|
|
4
|
+
const EXCLUDED_DIRECTORIES = new Set(['.git', '.syntax-map-mcp', 'dist', 'node_modules']);
|
|
5
|
+
function isInsideRoot(root, candidate) {
|
|
6
|
+
const relative = path.relative(root, candidate);
|
|
7
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
8
|
+
}
|
|
9
|
+
function failure(code, message) {
|
|
10
|
+
return { ok: false, error: { code, message } };
|
|
11
|
+
}
|
|
12
|
+
export async function createWorkspace(workspaceRoot) {
|
|
13
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
14
|
+
async function readSourceFile(inputPath) {
|
|
15
|
+
const resolved = path.resolve(root, inputPath);
|
|
16
|
+
if (!isInsideRoot(root, resolved)) {
|
|
17
|
+
return failure('WORKSPACE_OUTSIDE_ROOT', `Path is outside workspaceRoot: ${inputPath}`);
|
|
18
|
+
}
|
|
19
|
+
const extension = path.extname(resolved);
|
|
20
|
+
if (!SUPPORTED_EXTENSIONS.has(extension)) {
|
|
21
|
+
return failure('UNSUPPORTED_EXTENSION', `Unsupported extension: ${extension}`);
|
|
22
|
+
}
|
|
23
|
+
let actualPath;
|
|
24
|
+
try {
|
|
25
|
+
actualPath = await realpath(resolved);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return failure('FILE_NOT_FOUND', `File not found: ${inputPath}`);
|
|
29
|
+
}
|
|
30
|
+
if (!isInsideRoot(root, actualPath)) {
|
|
31
|
+
return failure('WORKSPACE_OUTSIDE_ROOT', `Path is outside workspaceRoot: ${inputPath}`);
|
|
32
|
+
}
|
|
33
|
+
const actualExtension = path.extname(actualPath);
|
|
34
|
+
if (!SUPPORTED_EXTENSIONS.has(actualExtension)) {
|
|
35
|
+
return failure('UNSUPPORTED_EXTENSION', `Unsupported extension: ${actualExtension}`);
|
|
36
|
+
}
|
|
37
|
+
const fileStat = await stat(actualPath);
|
|
38
|
+
if (!fileStat.isFile()) {
|
|
39
|
+
return failure('FILE_NOT_FOUND', `Not a file: ${inputPath}`);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
absolutePath: actualPath,
|
|
44
|
+
relativePath: path.relative(root, actualPath),
|
|
45
|
+
text: await readFile(actualPath, 'utf8'),
|
|
46
|
+
size: fileStat.size,
|
|
47
|
+
mtimeMs: fileStat.mtimeMs
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async function listSourceFilesInDirectory(directory) {
|
|
51
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
52
|
+
const files = [];
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
const absolutePath = path.join(directory, entry.name);
|
|
55
|
+
if (entry.isDirectory()) {
|
|
56
|
+
if (!EXCLUDED_DIRECTORIES.has(entry.name)) {
|
|
57
|
+
files.push(...(await listSourceFilesInDirectory(absolutePath)));
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!entry.isFile() || !SUPPORTED_EXTENSIONS.has(path.extname(entry.name))) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
let actualPath;
|
|
65
|
+
try {
|
|
66
|
+
actualPath = await realpath(absolutePath);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!isInsideRoot(root, actualPath)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const fileStat = await stat(actualPath);
|
|
75
|
+
if (!fileStat.isFile()) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
files.push({
|
|
79
|
+
absolutePath: actualPath,
|
|
80
|
+
relativePath: path.relative(root, actualPath),
|
|
81
|
+
size: fileStat.size,
|
|
82
|
+
mtimeMs: fileStat.mtimeMs
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
root,
|
|
89
|
+
readSourceFile,
|
|
90
|
+
readSourceFiles(inputPaths) {
|
|
91
|
+
return Promise.all(inputPaths.map(readSourceFile));
|
|
92
|
+
},
|
|
93
|
+
listSourceFiles() {
|
|
94
|
+
return listSourceFilesInDirectory(root);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "syntax-map-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tree-sitter based code analysis MCP server",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"syntax-map-mcp": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev": "tsx src/cli.ts",
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
26
|
+
"@types/sql.js": "^1.4.11",
|
|
27
|
+
"sql.js": "^1.14.1",
|
|
28
|
+
"tree-sitter": "^0.21.1",
|
|
29
|
+
"tree-sitter-javascript": "^0.21.4",
|
|
30
|
+
"tree-sitter-python": "^0.21.0",
|
|
31
|
+
"tree-sitter-typescript": "^0.21.2",
|
|
32
|
+
"zod": "^4.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/node": "^22.0.0",
|
|
36
|
+
"tsx": "^4.0.0",
|
|
37
|
+
"typescript": "^5.8.0",
|
|
38
|
+
"vitest": "^3.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|