syntax-map-mcp 1.1.0 → 1.2.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.2.1 - 2026-05-07
6
+
7
+ - 큰 TypeScript 파일이 workspace 기본 `paths` 조회에 포함될 때 LSP definition, references, hover, workspace symbols, completion, signature help가 `Invalid argument`로 실패하던 문제를 수정했습니다.
8
+ - `lsp_signature_help`가 TypeScript 제네릭 함수의 파라미터를 추출하지 못하던 문제를 수정했습니다.
9
+ - `get_index_status`가 기본 응답에서 `staleReasons` 전체 배열을 생략하고, `includeStaleReasons: true`일 때만 상세 사유를 반환하도록 변경했습니다.
10
+
11
+ ## 1.2.0 - 2026-05-07
12
+
13
+ - MCP 서버 실행 중 지원 소스 파일 변경을 감지해 SQLite 인덱스를 자동 갱신하는 watcher를 추가했습니다.
14
+ - `lsp_diagnostics` 도구를 추가해 tree-sitter parse error 기반의 가벼운 문법 진단을 LSP Diagnostic 형태로 반환하도록 했습니다.
15
+ - diagnostics 분석 계층에 provider 인터페이스를 추가해 이후 언어별 LSP 서버 진단 결과를 결합할 수 있게 했습니다.
16
+
5
17
  ## 1.1.0 - 2026-05-07
6
18
 
7
19
  - Rust `.rs` 파일 파싱을 지원하고, `get_ast_tree`와 LSP document symbols, definition, references, hover, workspace symbols, completion, signature help에서 Rust 심볼을 처리하도록 했습니다.
package/README.md CHANGED
@@ -37,6 +37,7 @@ npx -y syntax-map-mcp --workspace-root /path/to/workspace
37
37
  - `run_query`: 파일 하나에 tree-sitter query 실행
38
38
  - `get_ast_tree`: 파일 하나의 tree-sitter AST를 depth 제한 JSON 트리로 반환
39
39
  - `lsp_document_symbols`: 파일 하나의 심볼을 LSP DocumentSymbol 형태로 반환
40
+ - `lsp_diagnostics`: 파일 하나의 tree-sitter parse error를 LSP Diagnostic 형태로 반환
40
41
  - `lsp_definition`: 파일의 LSP 위치에 있는 식별자의 정의 위치를 반환
41
42
  - `lsp_references`: 파일의 LSP 위치에 있는 식별자의 참조 위치를 반환
42
43
  - `lsp_hover`: 파일의 LSP 위치에 있는 식별자의 hover markdown을 반환
@@ -51,11 +52,13 @@ npx -y syntax-map-mcp --workspace-root /path/to/workspace
51
52
  - `get_index_status`: 인덱스 경로, 인덱싱된 파일 수, 심볼 수, 참조 수, stale 파일 수 반환
52
53
  - `clear_index`: SQLite 인덱스 파일 삭제
53
54
 
55
+ `lsp_definition`, `lsp_references`, `lsp_hover`, `lsp_workspace_symbols`, `lsp_completion`, `lsp_signature_help`의 `paths` 입력은 선택 사항입니다. 생략하면 `workspaceRoot` 아래의 지원 소스 파일 전체를 대상으로 조회합니다.
56
+
54
57
  ## SQLite 인덱스
55
58
 
56
59
  `index_workspace`는 `workspaceRoot` 아래의 `.syntax-map-mcp/index.sqlite`에 인덱스를 저장합니다. 인덱싱 대상은 `.js`, `.jsx`, `.ts`, `.tsx`, `.py`, `.rs` 파일이며, `.git`, `.syntax-map-mcp`, `dist`, `node_modules` 디렉터리와 `workspaceRoot` 아래의 `.gitignore` 패턴에 매칭되는 파일은 제외합니다. 하위 디렉터리의 `.gitignore`는 해당 디렉터리 기준으로 적용합니다.
57
60
 
58
- 파일 변경 여부는 `mtimeMs`와 `size`로 판단합니다. 다시 `index_workspace`를 호출하면 변경된 파일만 재파싱하고, 삭제된 파일은 인덱스에서 제거합니다. `search_symbols`, `find_indexed_definition`, `find_indexed_references`는 `isStale`, `staleFiles`, `refreshed`를 반환해 검색 결과가 최신 인덱스 기반인지 알려줍니다. 세 도구에 `refreshIfStale: true`를 전달하면 stale 파일이 있을 때 먼저 인덱스를 갱신한 뒤 검색합니다. 인덱스 검색 도구는 인덱스에 저장된 위치를 조회한 뒤 현재 파일에서 해당 줄 snippet을 읽어 반환합니다. `contextBefore`, `contextAfter`를 0-10 사이 정수로 전달하면 snippet 주변 라인도 함께 반환합니다. `includePreview: true`를 전달하면 `path:line` 헤더와 코드블록으로 구성된 `previewMarkdown`도 함께 반환합니다. 자동 watch 모드는 아직 포함하지 않았습니다.
61
+ 파일 변경 여부는 `mtimeMs`와 `size`로 판단합니다. 다시 `index_workspace`를 호출하면 변경된 파일만 재파싱하고, 삭제된 파일은 인덱스에서 제거합니다. MCP 서버를 실행하면 지원 소스 파일 변경을 감지하는 watcher가 함께 시작되어 SQLite 인덱스를 자동 갱신합니다. `.syntax-map-mcp`, `.git`, `dist`, `node_modules`와 지원하지 않는 확장자 변경은 watcher refresh 대상에서 제외합니다. `get_index_status`는 기본적으로 stale 파일 개수만 반환하고, 상세 목록이 필요하면 `includeStaleReasons: true`를 전달합니다. `search_symbols`, `find_indexed_definition`, `find_indexed_references`는 `isStale`, `staleFiles`, `refreshed`를 반환해 검색 결과가 최신 인덱스 기반인지 알려줍니다. 세 도구에 `refreshIfStale: true`를 전달하면 stale 파일이 있을 때 먼저 인덱스를 갱신한 뒤 검색합니다. 인덱스 검색 도구는 인덱스에 저장된 위치를 조회한 뒤 현재 파일에서 해당 줄 snippet을 읽어 반환합니다. `contextBefore`, `contextAfter`를 0-10 사이 정수로 전달하면 snippet 주변 라인도 함께 반환합니다. `includePreview: true`를 전달하면 `path:line` 헤더와 코드블록으로 구성된 `previewMarkdown`도 함께 반환합니다.
59
62
 
60
63
  `summarize_file`은 `sources` 필드로 `symbols`, `imports`, `exports`가 어떤 방식으로 추출되었는지 반환합니다.
61
64
 
@@ -0,0 +1,73 @@
1
+ import { parseSourceFile } from '../parser.js';
2
+ export const treeSitterDiagnosticProvider = {
3
+ name: 'tree-sitter',
4
+ getDiagnostics(context) {
5
+ return collectTreeSitterDiagnostics(context.tree.rootNode);
6
+ }
7
+ };
8
+ const defaultDiagnosticProviders = [treeSitterDiagnosticProvider];
9
+ export async function getDiagnostics(workspace, input, providers = defaultDiagnosticProviders) {
10
+ const file = await workspace.readSourceFile(input.path);
11
+ if (!file.ok)
12
+ return file;
13
+ const parsed = parseSourceFile(file);
14
+ /* v8 ignore next -- parser failures are covered by parser tests. */
15
+ if (!parsed.ok)
16
+ return parsed;
17
+ const context = diagnosticContext(parsed);
18
+ const diagnostics = (await Promise.all(providers.map(provider => Promise.resolve(provider.getDiagnostics(context))))).flat();
19
+ return {
20
+ ok: true,
21
+ path: file.relativePath,
22
+ language: parsed.language,
23
+ diagnostics
24
+ };
25
+ }
26
+ function diagnosticContext(parsed) {
27
+ return {
28
+ path: parsed.file.relativePath,
29
+ language: parsed.language,
30
+ text: parsed.file.text,
31
+ tree: parsed.tree
32
+ };
33
+ }
34
+ function collectTreeSitterDiagnostics(root) {
35
+ if (!root.hasError)
36
+ return [];
37
+ const diagnostics = [];
38
+ collectErrorNodes(root, diagnostics);
39
+ return diagnostics;
40
+ }
41
+ function collectErrorNodes(node, diagnostics) {
42
+ if (node.isError || node.isMissing) {
43
+ diagnostics.push({
44
+ range: lspRange(rangeForNode(node)),
45
+ severity: 1,
46
+ source: 'tree-sitter',
47
+ message: node.isMissing ? `Syntax error: missing ${node.type}` : 'Syntax error'
48
+ });
49
+ }
50
+ if (!node.hasError)
51
+ return;
52
+ for (const child of node.children) {
53
+ collectErrorNodes(child, diagnostics);
54
+ }
55
+ }
56
+ function lspRange(range) {
57
+ return {
58
+ start: {
59
+ line: range.start.row,
60
+ character: range.start.column
61
+ },
62
+ end: {
63
+ line: range.end.row,
64
+ character: range.end.column
65
+ }
66
+ };
67
+ }
68
+ function rangeForNode(node) {
69
+ return {
70
+ start: node.startPosition,
71
+ end: node.endPosition
72
+ };
73
+ }
@@ -0,0 +1,80 @@
1
+ import { watch } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { indexWorkspace } from './index.js';
4
+ const DEFAULT_DEBOUNCE_MS = 250;
5
+ const SUPPORTED_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.py', '.rs']);
6
+ const EXCLUDED_DIRECTORIES = new Set(['.git', '.syntax-map-mcp', 'dist', 'node_modules']);
7
+ /* v8 ignore next -- default fs.watch wiring is exercised by CLI/manual MCP runs. */
8
+ const defaultWatchFactory = (root, options, listener) => watch(root, options, listener);
9
+ export function startIndexWatcher(workspace, options = {}) {
10
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
11
+ const refreshIndex = options.indexWorkspace ?? indexWorkspace;
12
+ const setTimer = options.setTimer ?? setTimeout;
13
+ const clearTimer = options.clearTimer ?? clearTimeout;
14
+ let stopped = false;
15
+ let timer;
16
+ let refreshChain = Promise.resolve();
17
+ function runRefresh() {
18
+ refreshChain = refreshChain
19
+ .then(async () => {
20
+ if (stopped)
21
+ return;
22
+ const result = await refreshIndex(workspace);
23
+ if (!result.ok) {
24
+ throw new Error(result.error.message);
25
+ }
26
+ })
27
+ .catch(error => {
28
+ options.onError?.(error instanceof Error ? error : new Error(String(error)));
29
+ });
30
+ return refreshChain;
31
+ }
32
+ function scheduleRefresh(filename) {
33
+ if (stopped || !isRelevantWatchPath(filename))
34
+ return;
35
+ if (timer) {
36
+ clearTimer(timer);
37
+ }
38
+ timer = setTimer(() => {
39
+ timer = undefined;
40
+ void runRefresh();
41
+ }, debounceMs);
42
+ timer.unref?.();
43
+ }
44
+ /* v8 ignore next -- tests inject watchFactory to avoid platform-specific fs.watch behavior. */
45
+ const watchFactory = options.watchFactory ??
46
+ /* v8 ignore next -- default fs.watch wiring is exercised by CLI/manual MCP runs. */
47
+ defaultWatchFactory;
48
+ const watcher = watchFactory(workspace.root, { recursive: true }, (_eventType, filename) => {
49
+ scheduleRefresh(filename);
50
+ });
51
+ watcher.unref?.();
52
+ return {
53
+ ready: runRefresh(),
54
+ async flush() {
55
+ if (timer) {
56
+ clearTimer(timer);
57
+ timer = undefined;
58
+ await runRefresh();
59
+ }
60
+ await refreshChain;
61
+ },
62
+ stop() {
63
+ stopped = true;
64
+ if (timer) {
65
+ clearTimer(timer);
66
+ timer = undefined;
67
+ }
68
+ watcher.close();
69
+ }
70
+ };
71
+ }
72
+ function isRelevantWatchPath(filename) {
73
+ if (filename === null)
74
+ return true;
75
+ const normalized = filename.toString().split(path.sep).join('/');
76
+ const parts = normalized.split('/');
77
+ if (parts.some(part => EXCLUDED_DIRECTORIES.has(part)))
78
+ return false;
79
+ return SUPPORTED_EXTENSIONS.has(path.extname(normalized));
80
+ }
@@ -747,22 +747,25 @@ export async function searchSymbols(workspace, input) {
747
747
  readState?.database.close();
748
748
  }
749
749
  }
750
- export async function getIndexStatus(workspace) {
750
+ export async function getIndexStatus(workspace, input = {}) {
751
751
  const indexPath = indexPathForWorkspace(workspace);
752
752
  const { database } = await openCompatibleDatabase(indexPath);
753
753
  try {
754
754
  initSchema(database);
755
755
  const staleReasons = await collectStaleReasons(workspace, database);
756
- return {
756
+ const result = {
757
757
  ok: true,
758
758
  indexPath,
759
759
  schemaVersion: INDEX_SCHEMA_VERSION,
760
760
  indexedFiles: scalarCount(database, 'SELECT COUNT(*) FROM files WHERE parse_status = "ok"'),
761
761
  symbols: scalarCount(database, 'SELECT COUNT(*) FROM symbols'),
762
762
  references: scalarCount(database, 'SELECT COUNT(*) FROM reference_captures'),
763
- staleFiles: staleReasons.length,
764
- staleReasons
763
+ staleFiles: staleReasons.length
765
764
  };
765
+ if (input.includeStaleReasons === true) {
766
+ result.staleReasons = staleReasons;
767
+ }
768
+ return result;
766
769
  /* v8 ignore next -- status read failures require a corrupted sqlite runtime path. */
767
770
  }
768
771
  catch (error) {
@@ -120,8 +120,8 @@ function splitParameters(parameters) {
120
120
  }
121
121
  function signatureFromSnippet(name, snippet) {
122
122
  const firstLine = snippet.split(/\r?\n/)[0].trim();
123
- const functionMatch = firstLine.match(/^(?:export\s+)?(?:async\s+)?function\s+[A-Za-z_$][A-Za-z0-9_$]*\s*\(([^)]*)\)\s*([^{:]*(?::\s*[^{]+)?)[{:]?/);
124
- const methodMatch = firstLine.match(/^(?:public\s+|private\s+|protected\s+|static\s+|async\s+)*[A-Za-z_$][A-Za-z0-9_$]*\s*\(([^)]*)\)\s*([^{:]*(?::\s*[^{]+)?)[{:]?/);
123
+ const functionMatch = firstLine.match(/^(?:export\s+)?(?:async\s+)?function\s+[A-Za-z_$][A-Za-z0-9_$]*\s*(?:<[^>]+>)?\s*\(([^)]*)\)\s*([^{:]*(?::\s*[^{]+)?)[{:]?/);
124
+ const methodMatch = firstLine.match(/^(?:public\s+|private\s+|protected\s+|static\s+|async\s+)*[A-Za-z_$][A-Za-z0-9_$]*\s*(?:<[^>]+>)?\s*\(([^)]*)\)\s*([^{:]*(?::\s*[^{]+)?)[{:]?/);
125
125
  const pythonMatch = firstLine.match(/^def\s+[A-Za-z_$][A-Za-z0-9_$]*\s*\(([^)]*)\)\s*([^:]*)/);
126
126
  const rustMatch = firstLine.match(/^(?:pub\s+)?(?:async\s+)?fn\s+[A-Za-z_$][A-Za-z0-9_$]*\s*\(([^)]*)\)\s*([^{]*)/);
127
127
  const match = functionMatch ?? pythonMatch ?? rustMatch ?? methodMatch;
package/dist/parser.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import Parser from 'tree-sitter';
3
3
  import { languageForName } from './languages.js';
4
+ const PARSE_CHUNK_SIZE = 4096;
4
5
  export function detectLanguage(filePath) {
5
6
  const extension = path.extname(filePath);
6
7
  switch (extension) {
@@ -32,7 +33,7 @@ export function parseSourceFile(file, resolveLanguage = languageForName) {
32
33
  try {
33
34
  const parser = new Parser();
34
35
  parser.setLanguage(resolveLanguage(detected.language));
35
- const tree = parser.parse(file.text);
36
+ const tree = parser.parse(offset => file.text.slice(offset, offset + PARSE_CHUNK_SIZE));
36
37
  return {
37
38
  ok: true,
38
39
  file,
package/dist/server.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import { startIndexWatcher } from './analysis/index-watcher.js';
6
7
  import { registerTools } from './tools.js';
7
8
  import { createWorkspace } from './workspace.js';
8
9
  function defaultPackageJsonPath() {
@@ -18,17 +19,35 @@ export async function createServerInfo(packageJsonPath = defaultPackageJsonPath(
18
19
  version: packageJson.version
19
20
  };
20
21
  }
21
- export async function createServer(options) {
22
+ export async function createServer(options, dependencies = {}) {
22
23
  const workspace = await createWorkspace(options.workspaceRoot);
23
24
  const server = new McpServer(await createServerInfo(), {
24
25
  instructions: 'Analyze JavaScript, TypeScript, Python, and Rust source files under the configured workspaceRoot only.'
25
26
  });
26
27
  registerTools(server, workspace);
28
+ if (options.autoIndex === true) {
29
+ attachIndexWatcher(server, (dependencies.startIndexWatcher ?? startIndexWatcher)(workspace, {
30
+ debounceMs: options.indexDebounceMs
31
+ }));
32
+ }
27
33
  return server;
28
34
  }
29
35
  export async function runServer(options, dependencies = {}) {
30
36
  /* v8 ignore next 2 -- default CLI wiring is exercised by the package smoke test in a child process. */
31
- const server = await (dependencies.createServer ?? createServer)(options);
32
- const transport = (dependencies.createTransport ?? (() => new StdioServerTransport()))();
37
+ const server = await (dependencies.createServer ?? createServer)({
38
+ ...options,
39
+ autoIndex: options.autoIndex ?? true
40
+ });
41
+ const createTransport = dependencies.createTransport ??
42
+ /* v8 ignore next -- default stdio transport wiring is exercised by the package smoke test. */
43
+ (() => new StdioServerTransport());
44
+ const transport = createTransport();
33
45
  await server.connect(transport);
34
46
  }
47
+ function attachIndexWatcher(server, watcher) {
48
+ const closeServer = server.close.bind(server);
49
+ server.close = async () => {
50
+ watcher.stop();
51
+ await closeServer();
52
+ };
53
+ }
package/dist/tools.js CHANGED
@@ -2,6 +2,7 @@ import { z } from 'zod';
2
2
  import { getAstTree as getAstTreeAnalysis } from './analysis/ast-tree.js';
3
3
  import { buildContext as buildContextAnalysis } from './analysis/context.js';
4
4
  import { findDefinitions } from './analysis/definitions.js';
5
+ import { getDiagnostics as getLspDiagnostics } from './analysis/diagnostics.js';
5
6
  import { clearIndex as clearWorkspaceIndex, findIndexedDefinitions, findIndexedReferences, getIndexStatus as getWorkspaceIndexStatus, indexWorkspace as indexWorkspaceAnalysis, searchSymbols as searchIndexedSymbols } from './analysis/index.js';
6
7
  import { runTreeSitterQuery } from './analysis/query.js';
7
8
  import { findReferences as findReferencesAnalysis } from './analysis/references.js';
@@ -75,6 +76,12 @@ export function createToolHandlers(workspace) {
75
76
  return toolFailure(result.error.code, result.error.message);
76
77
  return jsonResult(result);
77
78
  },
79
+ async lspDiagnostics(input) {
80
+ const result = await getLspDiagnostics(workspace, input);
81
+ if (!result.ok)
82
+ return toolFailure(result.error.code, result.error.message);
83
+ return jsonResult(result);
84
+ },
78
85
  async lspDefinition(input) {
79
86
  const result = await getLspDefinition(workspace, input);
80
87
  if (!result.ok)
@@ -141,8 +148,8 @@ export function createToolHandlers(workspace) {
141
148
  return toolFailure(result.error.code, result.error.message);
142
149
  return jsonResult(result);
143
150
  },
144
- async getIndexStatus(_input) {
145
- const result = await getWorkspaceIndexStatus(workspace);
151
+ async getIndexStatus(input) {
152
+ const result = await getWorkspaceIndexStatus(workspace, input);
146
153
  /* v8 ignore next -- getIndexStatus read failures are defensive and covered at index layer. */
147
154
  if (!result.ok)
148
155
  return toolFailure(result.error.code, result.error.message);
@@ -214,6 +221,13 @@ export function registerTools(server, workspace) {
214
221
  path: z.string()
215
222
  }
216
223
  }, handlers.lspDocumentSymbols);
224
+ server.registerTool('lsp_diagnostics', {
225
+ title: 'LSP diagnostics',
226
+ description: 'Return lightweight syntax diagnostics from tree-sitter parse errors.',
227
+ inputSchema: {
228
+ path: z.string()
229
+ }
230
+ }, handlers.lspDiagnostics);
217
231
  server.registerTool('lsp_definition', {
218
232
  title: 'LSP definition',
219
233
  description: 'Return definition locations for the identifier at a zero-based LSP position.',
@@ -351,7 +365,9 @@ export function registerTools(server, workspace) {
351
365
  server.registerTool('get_index_status', {
352
366
  title: 'Get index status',
353
367
  description: 'Return SQLite index path, indexed file count, symbol count, and stale file count.',
354
- inputSchema: {}
368
+ inputSchema: {
369
+ includeStaleReasons: z.boolean().optional()
370
+ }
355
371
  }, handlers.getIndexStatus);
356
372
  server.registerTool('clear_index', {
357
373
  title: 'Clear index',
package/docs/tools.md CHANGED
@@ -215,6 +215,37 @@ syntax-map-mcp의 주요 MCP 도구 입력과 응답 예시입니다. 응답 예
215
215
  }
216
216
  ```
217
217
 
218
+ ## lsp_diagnostics
219
+
220
+ 입력:
221
+
222
+ ```json
223
+ {
224
+ "path": "src/users.ts"
225
+ }
226
+ ```
227
+
228
+ 응답 일부:
229
+
230
+ ```json
231
+ {
232
+ "ok": true,
233
+ "path": "src/users.ts",
234
+ "language": "typescript",
235
+ "diagnostics": [
236
+ {
237
+ "range": {
238
+ "start": { "line": 10, "character": 4 },
239
+ "end": { "line": 10, "character": 12 }
240
+ },
241
+ "severity": 1,
242
+ "source": "tree-sitter",
243
+ "message": "Syntax error"
244
+ }
245
+ ]
246
+ }
247
+ ```
248
+
218
249
  ## lsp_definition
219
250
 
220
251
  입력:
@@ -473,7 +504,9 @@ syntax-map-mcp의 주요 MCP 도구 입력과 응답 예시입니다. 응답 예
473
504
  입력:
474
505
 
475
506
  ```json
476
- {}
507
+ {
508
+ "includeStaleReasons": true
509
+ }
477
510
  ```
478
511
 
479
512
  응답 일부:
@@ -616,6 +649,8 @@ syntax-map-mcp의 주요 MCP 도구 입력과 응답 예시입니다. 응답 예
616
649
  }
617
650
  ```
618
651
 
652
+ `includeStaleReasons`를 생략하거나 `false`로 전달하면 응답에는 `staleFiles` 개수만 포함됩니다.
653
+
619
654
  ## clear_index
620
655
 
621
656
  입력:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "syntax-map-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Tree-sitter based code analysis MCP server",
5
5
  "license": "MIT",
6
6
  "type": "module",