tree-sitter-analyzer 0.9.9__py3-none-any.whl → 1.0.0__py3-none-any.whl

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.

Potentially problematic release.


This version of tree-sitter-analyzer might be problematic. Click here for more details.

@@ -1,650 +1,655 @@
1
- #!/usr/bin/env python3
2
- """
3
- MCP Server implementation for Tree-sitter Analyzer (Fixed Version)
4
-
5
- This module provides the main MCP server that exposes tree-sitter analyzer
6
- functionality through the Model Context Protocol.
7
- """
8
-
9
- import argparse
10
- import asyncio
11
- import json
12
- import os
13
- import sys
14
- from typing import Any
15
-
16
- try:
17
- from mcp.server import Server
18
- from mcp.server.models import InitializationOptions
19
- from mcp.server.stdio import stdio_server
20
- from mcp.types import Resource, TextContent, Tool
21
-
22
- MCP_AVAILABLE = True
23
- except ImportError:
24
- MCP_AVAILABLE = False
25
-
26
- # Fallback types for development without MCP
27
- class Server:
28
- pass
29
-
30
- class InitializationOptions:
31
- def __init__(self, **kwargs):
32
- pass
33
-
34
- class Tool:
35
- pass
36
-
37
- class Resource:
38
- pass
39
-
40
- class TextContent:
41
- pass
42
-
43
- def stdio_server():
44
- pass
45
-
46
-
47
- from ..core.analysis_engine import get_analysis_engine
48
- from ..project_detector import detect_project_root
49
- from ..security import SecurityValidator
50
- from ..utils import setup_logger
51
- from . import MCP_INFO
52
- from .resources import CodeFileResource, ProjectStatsResource
53
- from .tools.base_tool import MCPTool
54
- from .tools.query_tool import QueryTool
55
- from .tools.read_partial_tool import ReadPartialTool
56
- from .tools.table_format_tool import TableFormatTool
57
-
58
- # Set up logging
59
- logger = setup_logger(__name__)
60
-
61
-
62
- class TreeSitterAnalyzerMCPServer:
63
- """
64
- MCP Server for Tree-sitter Analyzer
65
-
66
- Provides code analysis capabilities through the Model Context Protocol,
67
- integrating with existing analyzer components.
68
- """
69
-
70
- def __init__(self, project_root: str = None) -> None:
71
- """Initialize the MCP server with analyzer components."""
72
- self.server: Server | None = None
73
- self._initialization_complete = False
74
-
75
- logger.info("Starting MCP server initialization...")
76
-
77
- self.analysis_engine = get_analysis_engine(project_root)
78
- self.security_validator = SecurityValidator(project_root)
79
- # Use unified analysis engine instead of deprecated AdvancedAnalyzer
80
-
81
- # Initialize MCP tools with security validation (four core tools)
82
- self.query_tool = QueryTool(project_root) # query_code
83
- self.read_partial_tool: MCPTool = ReadPartialTool(
84
- project_root
85
- ) # extract_code_section
86
- self.table_format_tool: MCPTool = TableFormatTool(
87
- project_root
88
- ) # analyze_code_structure
89
- # Optional universal tool to satisfy initialization tests
90
- try:
91
- from .tools.universal_analyze_tool import UniversalAnalyzeTool
92
-
93
- self.universal_analyze_tool = UniversalAnalyzeTool(project_root)
94
- except Exception:
95
- self.universal_analyze_tool = None
96
-
97
- # Initialize MCP resources
98
- self.code_file_resource = CodeFileResource()
99
- self.project_stats_resource = ProjectStatsResource()
100
-
101
- # Server metadata
102
- self.name = MCP_INFO["name"]
103
- self.version = MCP_INFO["version"]
104
-
105
- self._initialization_complete = True
106
- logger.info(f"MCP server initialization complete: {self.name} v{self.version}")
107
-
108
- def is_initialized(self) -> bool:
109
- """Check if the server is fully initialized."""
110
- return self._initialization_complete
111
-
112
- def _ensure_initialized(self) -> None:
113
- """Ensure the server is initialized before processing requests."""
114
- if not self._initialization_complete:
115
- raise RuntimeError(
116
- "Server not fully initialized. Please wait for initialization to complete."
117
- )
118
-
119
- async def _analyze_code_scale(self, arguments: dict[str, Any]) -> dict[str, Any]:
120
- """
121
- Analyze code scale and complexity metrics using the analysis engine directly.
122
- """
123
- # For initialization-specific tests, we should raise MCPError instead of RuntimeError
124
- if not self._initialization_complete:
125
- from .utils.error_handler import MCPError
126
-
127
- raise MCPError("Server is still initializing")
128
-
129
- # For specific initialization tests we allow delegating to universal tool
130
- if (
131
- "file_path" not in arguments
132
- and getattr(self, "universal_analyze_tool", None) is not None
133
- ):
134
- return await self.universal_analyze_tool.execute(arguments)
135
- if "file_path" not in arguments:
136
- raise ValueError("file_path is required")
137
-
138
- file_path = arguments["file_path"]
139
- language = arguments.get("language")
140
- include_complexity = arguments.get("include_complexity", True)
141
- include_details = arguments.get("include_details", False)
142
-
143
- # Resolve relative path against project root for consistent behavior
144
- import os
145
-
146
- base_root = getattr(
147
- getattr(self.security_validator, "boundary_manager", None),
148
- "project_root",
149
- None,
150
- )
151
- if not os.path.isabs(file_path) and base_root:
152
- resolved_path = os.path.realpath(os.path.join(base_root, file_path))
153
- else:
154
- resolved_path = file_path
155
-
156
- # Security validation
157
- is_valid, error_msg = self.security_validator.validate_file_path(resolved_path)
158
- if not is_valid:
159
- raise ValueError(f"Invalid file path: {error_msg}")
160
-
161
- # Use analysis engine directly
162
- from pathlib import Path
163
-
164
- from ..core.analysis_engine import AnalysisRequest
165
- from ..language_detector import detect_language_from_file
166
-
167
- # Validate file exists
168
- if not Path(resolved_path).exists():
169
- raise FileNotFoundError(f"File not found: {file_path}")
170
-
171
- # Detect language if not specified
172
- if not language:
173
- language = detect_language_from_file(resolved_path)
174
-
175
- # Create analysis request
176
- request = AnalysisRequest(
177
- file_path=resolved_path,
178
- language=language,
179
- include_complexity=include_complexity,
180
- include_details=include_details,
181
- )
182
-
183
- # Perform analysis
184
- analysis_result = await self.analysis_engine.analyze(request)
185
-
186
- if analysis_result is None or not analysis_result.success:
187
- error_msg = (
188
- analysis_result.error_message if analysis_result else "Unknown error"
189
- )
190
- raise RuntimeError(f"Failed to analyze file: {file_path} - {error_msg}")
191
-
192
- # Convert to dictionary format
193
- result_dict = analysis_result.to_dict()
194
-
195
- # Format result to match test expectations
196
- elements = result_dict.get("elements", [])
197
-
198
- # Count elements by type
199
- classes_count = len([e for e in elements if e.get("__class__") == "Class"])
200
- methods_count = len([e for e in elements if e.get("__class__") == "Function"])
201
- fields_count = len([e for e in elements if e.get("__class__") == "Variable"])
202
- imports_count = len([e for e in elements if e.get("__class__") == "Import"])
203
-
204
- result = {
205
- "file_path": file_path,
206
- "language": language,
207
- "metrics": {
208
- "lines_total": result_dict.get("line_count", 0),
209
- "lines_code": result_dict.get("line_count", 0), # Approximation
210
- "lines_comment": 0, # Not available in basic analysis
211
- "lines_blank": 0, # Not available in basic analysis
212
- "elements": {
213
- "classes": classes_count,
214
- "methods": methods_count,
215
- "fields": fields_count,
216
- "imports": imports_count,
217
- "total": len(elements),
218
- },
219
- },
220
- }
221
-
222
- if include_complexity:
223
- # Add complexity metrics if available
224
- methods = [e for e in elements if e.get("__class__") == "Function"]
225
- if methods:
226
- complexities = [e.get("complexity_score", 0) for e in methods]
227
- result["metrics"]["complexity"] = {
228
- "total": sum(complexities),
229
- "average": (
230
- sum(complexities) / len(complexities) if complexities else 0
231
- ),
232
- "max": max(complexities) if complexities else 0,
233
- }
234
-
235
- if include_details:
236
- result["detailed_elements"] = elements
237
-
238
- return result
239
-
240
- def create_server(self) -> Server:
241
- """
242
- Create and configure the MCP server.
243
-
244
- Returns:
245
- Configured MCP Server instance
246
- """
247
- if not MCP_AVAILABLE:
248
- raise RuntimeError("MCP library not available. Please install mcp package.")
249
-
250
- server: Server = Server(self.name)
251
-
252
- # Register tools using @server decorators (standard MCP pattern)
253
- @server.list_tools()
254
- async def handle_list_tools() -> list[Tool]:
255
- """List all available tools."""
256
- logger.info("Client requesting tools list")
257
-
258
- tools = [
259
- Tool(
260
- name="check_code_scale",
261
- description="Analyze code file size and complexity metrics",
262
- inputSchema={
263
- "type": "object",
264
- "properties": {
265
- "file_path": {
266
- "type": "string",
267
- "description": "Path to the code file (relative to project root)",
268
- }
269
- },
270
- "required": ["file_path"],
271
- "additionalProperties": False,
272
- },
273
- ),
274
- Tool(
275
- name="analyze_code_structure",
276
- description="Analyze code structure and generate tables with line positions",
277
- inputSchema={
278
- "type": "object",
279
- "properties": {
280
- "file_path": {
281
- "type": "string",
282
- "description": "Path to the code file (relative to project root)",
283
- }
284
- },
285
- "required": ["file_path"],
286
- "additionalProperties": False,
287
- },
288
- ),
289
- Tool(
290
- name="extract_code_section",
291
- description="Extract a code section by line range",
292
- inputSchema={
293
- "type": "object",
294
- "properties": {
295
- "file_path": {
296
- "type": "string",
297
- "description": "Path to the code file (relative to project root)",
298
- },
299
- "start_line": {
300
- "type": "integer",
301
- "description": "Start line (1-based)",
302
- "minimum": 1,
303
- },
304
- "end_line": {
305
- "type": "integer",
306
- "description": "End line (optional, 1-based)",
307
- "minimum": 1,
308
- },
309
- },
310
- "required": ["file_path", "start_line"],
311
- "additionalProperties": False,
312
- },
313
- ),
314
- Tool(
315
- name="set_project_path",
316
- description="Set or override the project root path used for security boundaries",
317
- inputSchema={
318
- "type": "object",
319
- "properties": {
320
- "project_path": {
321
- "type": "string",
322
- "description": "Absolute path to the project root",
323
- }
324
- },
325
- "required": ["project_path"],
326
- "additionalProperties": False,
327
- },
328
- ),
329
- Tool(**self.query_tool.get_tool_definition()),
330
- ]
331
-
332
- logger.info(f"Returning {len(tools)} tools: {[t.name for t in tools]}")
333
- return tools
334
-
335
- @server.call_tool()
336
- async def handle_call_tool(
337
- name: str, arguments: dict[str, Any]
338
- ) -> list[TextContent]:
339
- try:
340
- # Ensure server is fully initialized
341
- self._ensure_initialized()
342
-
343
- # Log tool call
344
- logger.info(
345
- f"MCP tool call: {name} with args: {list(arguments.keys())}"
346
- )
347
-
348
- # Validate file path security
349
- if "file_path" in arguments:
350
- file_path = arguments["file_path"]
351
- is_valid, error_msg = self.security_validator.validate_file_path(
352
- file_path
353
- )
354
- if not is_valid:
355
- raise ValueError(
356
- f"Invalid or unsafe file path: {error_msg or file_path}"
357
- )
358
-
359
- # Handle tool calls with simplified parameter handling
360
- if name == "check_code_scale":
361
- # Ensure file_path is provided
362
- if "file_path" not in arguments:
363
- raise ValueError("file_path parameter is required")
364
-
365
- # Add default values for optional parameters
366
- full_args = {
367
- "file_path": arguments["file_path"],
368
- "language": arguments.get("language"),
369
- "include_complexity": arguments.get("include_complexity", True),
370
- "include_details": arguments.get("include_details", False),
371
- }
372
- result = await self._analyze_code_scale(full_args)
373
-
374
- elif name == "analyze_code_structure":
375
- if "file_path" not in arguments:
376
- raise ValueError("file_path parameter is required")
377
-
378
- full_args = {
379
- "file_path": arguments["file_path"],
380
- "format_type": arguments.get("format_type", "full"),
381
- "language": arguments.get("language"),
382
- }
383
- result = await self.table_format_tool.execute(full_args)
384
-
385
- elif name == "extract_code_section":
386
- if "file_path" not in arguments or "start_line" not in arguments:
387
- raise ValueError(
388
- "file_path and start_line parameters are required"
389
- )
390
-
391
- full_args = {
392
- "file_path": arguments["file_path"],
393
- "start_line": arguments["start_line"],
394
- "end_line": arguments.get("end_line"),
395
- "start_column": arguments.get("start_column"),
396
- "end_column": arguments.get("end_column"),
397
- "format": arguments.get("format", "text"),
398
- }
399
- result = await self.read_partial_tool.execute(full_args)
400
-
401
- elif name == "set_project_path":
402
- project_path = arguments.get("project_path")
403
- if not project_path or not isinstance(project_path, str):
404
- raise ValueError(
405
- "project_path parameter is required and must be a string"
406
- )
407
- if not os.path.isdir(project_path):
408
- raise ValueError(f"Project path does not exist: {project_path}")
409
- self.set_project_path(project_path)
410
- result = {"status": "success", "project_root": project_path}
411
-
412
- elif name == "query_code":
413
- result = await self.query_tool.execute(arguments)
414
-
415
- else:
416
- raise ValueError(f"Unknown tool: {name}")
417
-
418
- # Return result
419
- return [
420
- TextContent(
421
- type="text",
422
- text=json.dumps(result, indent=2, ensure_ascii=False),
423
- )
424
- ]
425
-
426
- except Exception as e:
427
- try:
428
- logger.error(f"Tool call error for {name}: {e}")
429
- except (ValueError, OSError):
430
- pass # Silently ignore logging errors during shutdown
431
- return [
432
- TextContent(
433
- type="text",
434
- text=json.dumps(
435
- {"error": str(e), "tool": name, "arguments": arguments},
436
- indent=2,
437
- ),
438
- )
439
- ]
440
-
441
- # Register resources
442
- @server.list_resources() # type: ignore
443
- async def handle_list_resources() -> list[Resource]:
444
- """List available resources."""
445
- return [
446
- Resource(
447
- uri=self.code_file_resource.get_resource_info()["uri_template"],
448
- name=self.code_file_resource.get_resource_info()["name"],
449
- description=self.code_file_resource.get_resource_info()[
450
- "description"
451
- ],
452
- mimeType=self.code_file_resource.get_resource_info()["mime_type"],
453
- ),
454
- Resource(
455
- uri=self.project_stats_resource.get_resource_info()["uri_template"],
456
- name=self.project_stats_resource.get_resource_info()["name"],
457
- description=self.project_stats_resource.get_resource_info()[
458
- "description"
459
- ],
460
- mimeType=self.project_stats_resource.get_resource_info()[
461
- "mime_type"
462
- ],
463
- ),
464
- ]
465
-
466
- @server.read_resource() # type: ignore
467
- async def handle_read_resource(uri: str) -> str:
468
- """Read resource content."""
469
- try:
470
- # Check which resource matches the URI
471
- if self.code_file_resource.matches_uri(uri):
472
- return await self.code_file_resource.read_resource(uri)
473
- elif self.project_stats_resource.matches_uri(uri):
474
- return await self.project_stats_resource.read_resource(uri)
475
- else:
476
- raise ValueError(f"Resource not found: {uri}")
477
-
478
- except Exception as e:
479
- try:
480
- logger.error(f"Resource read error for {uri}: {e}")
481
- except (ValueError, OSError):
482
- pass # Silently ignore logging errors during shutdown
483
- raise
484
-
485
- # Some clients may request prompts; explicitly return empty list
486
- try:
487
- from mcp.types import Prompt # type: ignore
488
-
489
- @server.list_prompts() # type: ignore
490
- async def handle_list_prompts() -> list[Prompt]:
491
- logger.info("Client requested prompts list (returning empty)")
492
- return []
493
-
494
- except Exception as e:
495
- # If Prompt type is unavailable, log at debug level and continue safely
496
- try:
497
- logger.debug(f"Prompts API unavailable or incompatible: {e}")
498
- except (ValueError, OSError):
499
- pass
500
-
501
- self.server = server
502
- try:
503
- logger.info("MCP server created successfully")
504
- except (ValueError, OSError):
505
- pass # Silently ignore logging errors during shutdown
506
- return server
507
-
508
- def set_project_path(self, project_path: str) -> None:
509
- """
510
- Set the project path for statistics resource
511
-
512
- Args:
513
- project_path: Path to the project directory
514
- """
515
- self.project_stats_resource.set_project_path(project_path)
516
- try:
517
- logger.info(f"Set project path to: {project_path}")
518
- except (ValueError, OSError):
519
- pass # Silently ignore logging errors during shutdown
520
-
521
- async def run(self) -> None:
522
- """
523
- Run the MCP server.
524
-
525
- This method starts the server and handles stdio communication.
526
- """
527
- if not MCP_AVAILABLE:
528
- raise RuntimeError("MCP library not available. Please install mcp package.")
529
-
530
- server = self.create_server()
531
-
532
- # Initialize server options with required capabilities field
533
- options = InitializationOptions(
534
- server_name=self.name,
535
- server_version=self.version,
536
- capabilities={"tools": {}, "resources": {}, "prompts": {}, "logging": {}},
537
- )
538
-
539
- try:
540
- logger.info(f"Starting MCP server: {self.name} v{self.version}")
541
- except (ValueError, OSError):
542
- pass # Silently ignore logging errors during shutdown
543
-
544
- try:
545
- async with stdio_server() as (read_stream, write_stream):
546
- logger.info("Server running, waiting for requests...")
547
- await server.run(read_stream, write_stream, options)
548
- except Exception as e:
549
- # Use safe logging to avoid I/O errors during shutdown
550
- try:
551
- logger.error(f"Server error: {e}")
552
- except (ValueError, OSError):
553
- pass # Silently ignore logging errors during shutdown
554
- raise
555
- finally:
556
- # Safe cleanup
557
- try:
558
- logger.info("MCP server shutting down")
559
- except (ValueError, OSError):
560
- pass # Silently ignore logging errors during shutdown
561
-
562
-
563
- def parse_mcp_args(args=None) -> argparse.Namespace:
564
- """Parse command line arguments for MCP server."""
565
- parser = argparse.ArgumentParser(
566
- description="Tree-sitter Analyzer MCP Server",
567
- formatter_class=argparse.RawDescriptionHelpFormatter,
568
- epilog="""
569
- Environment Variables:
570
- TREE_SITTER_PROJECT_ROOT Project root directory (alternative to --project-root)
571
-
572
- Examples:
573
- python -m tree_sitter_analyzer.mcp.server
574
- python -m tree_sitter_analyzer.mcp.server --project-root /path/to/project
575
- """,
576
- )
577
-
578
- parser.add_argument(
579
- "--project-root",
580
- help="Project root directory for security validation (auto-detected if not specified)",
581
- )
582
-
583
- return parser.parse_args(args)
584
-
585
-
586
- async def main() -> None:
587
- """Main entry point for the MCP server."""
588
- try:
589
- # Parse command line arguments (ignore unknown so pytest flags won't crash)
590
- args = parse_mcp_args([] if "pytest" in sys.argv[0] else None)
591
-
592
- # Determine project root with robust priority handling and fallbacks
593
- project_root = None
594
-
595
- # Priority 1: Command line argument
596
- if args.project_root:
597
- project_root = args.project_root
598
- # Priority 2: Environment variable
599
- elif os.getenv("TREE_SITTER_PROJECT_ROOT"):
600
- project_root = os.getenv("TREE_SITTER_PROJECT_ROOT")
601
- # Priority 3: Auto-detection from current directory
602
- else:
603
- project_root = detect_project_root()
604
-
605
- # Handle unresolved placeholders from clients (e.g., "${workspaceFolder}")
606
- invalid_placeholder = isinstance(project_root, str) and (
607
- "${" in project_root or "}" in project_root or "$" in project_root
608
- )
609
-
610
- # Validate existence; if invalid, fall back to auto-detected root
611
- if not project_root or invalid_placeholder or not os.path.isdir(project_root):
612
- detected = detect_project_root()
613
- try:
614
- logger.warning(
615
- f"Invalid project root '{project_root}', falling back to auto-detected root: {detected}"
616
- )
617
- except (ValueError, OSError):
618
- pass
619
- project_root = detected
620
-
621
- logger.info(f"MCP server starting with project root: {project_root}")
622
-
623
- server = TreeSitterAnalyzerMCPServer(project_root)
624
- await server.run()
625
- except KeyboardInterrupt:
626
- try:
627
- logger.info("Server stopped by user")
628
- except (ValueError, OSError):
629
- pass # Silently ignore logging errors during shutdown
630
- except Exception as e:
631
- try:
632
- logger.error(f"Server failed: {e}")
633
- except (ValueError, OSError):
634
- pass # Silently ignore logging errors during shutdown
635
- sys.exit(1)
636
- finally:
637
- # Ensure clean shutdown
638
- try:
639
- logger.info("MCP server shutdown complete")
640
- except (ValueError, OSError):
641
- pass # Silently ignore logging errors during shutdown
642
-
643
-
644
- def main_sync() -> None:
645
- """Synchronous entry point for setuptools scripts."""
646
- asyncio.run(main())
647
-
648
-
649
- if __name__ == "__main__":
650
- main_sync()
1
+ #!/usr/bin/env python3
2
+ """
3
+ MCP Server implementation for Tree-sitter Analyzer (Fixed Version)
4
+
5
+ This module provides the main MCP server that exposes tree-sitter analyzer
6
+ functionality through the Model Context Protocol.
7
+ """
8
+
9
+ import argparse
10
+ import asyncio
11
+ import json
12
+ import os
13
+ import sys
14
+ from pathlib import Path as PathClass
15
+ from typing import Any
16
+
17
+ try:
18
+ from mcp.server import Server
19
+ from mcp.server.models import InitializationOptions
20
+ from mcp.server.stdio import stdio_server
21
+ from mcp.types import Resource, TextContent, Tool
22
+
23
+ MCP_AVAILABLE = True
24
+ except ImportError:
25
+ MCP_AVAILABLE = False
26
+
27
+ # Fallback types for development without MCP
28
+ class Server:
29
+ pass
30
+
31
+ class InitializationOptions:
32
+ def __init__(self, **kwargs):
33
+ pass
34
+
35
+ class Tool:
36
+ pass
37
+
38
+ class Resource:
39
+ pass
40
+
41
+ class TextContent:
42
+ pass
43
+
44
+ def stdio_server():
45
+ pass
46
+
47
+
48
+ from ..core.analysis_engine import get_analysis_engine
49
+ from ..project_detector import detect_project_root
50
+ from ..security import SecurityValidator
51
+ from ..utils import setup_logger
52
+ from . import MCP_INFO
53
+ from .resources import CodeFileResource, ProjectStatsResource
54
+ from .tools.base_tool import MCPTool
55
+ from .tools.query_tool import QueryTool
56
+ from .tools.read_partial_tool import ReadPartialTool
57
+ from .tools.table_format_tool import TableFormatTool
58
+
59
+ # Set up logging
60
+ logger = setup_logger(__name__)
61
+
62
+
63
+ class TreeSitterAnalyzerMCPServer:
64
+ """
65
+ MCP Server for Tree-sitter Analyzer
66
+
67
+ Provides code analysis capabilities through the Model Context Protocol,
68
+ integrating with existing analyzer components.
69
+ """
70
+
71
+ def __init__(self, project_root: str = None) -> None:
72
+ """Initialize the MCP server with analyzer components."""
73
+ self.server: Server | None = None
74
+ self._initialization_complete = False
75
+
76
+ logger.info("Starting MCP server initialization...")
77
+
78
+ self.analysis_engine = get_analysis_engine(project_root)
79
+ self.security_validator = SecurityValidator(project_root)
80
+ # Use unified analysis engine instead of deprecated AdvancedAnalyzer
81
+
82
+ # Initialize MCP tools with security validation (four core tools)
83
+ self.query_tool = QueryTool(project_root) # query_code
84
+ self.read_partial_tool: MCPTool = ReadPartialTool(
85
+ project_root
86
+ ) # extract_code_section
87
+ self.table_format_tool: MCPTool = TableFormatTool(
88
+ project_root
89
+ ) # analyze_code_structure
90
+ # Optional universal tool to satisfy initialization tests
91
+ try:
92
+ from .tools.universal_analyze_tool import UniversalAnalyzeTool
93
+
94
+ self.universal_analyze_tool = UniversalAnalyzeTool(project_root)
95
+ except Exception:
96
+ self.universal_analyze_tool = None
97
+
98
+ # Initialize MCP resources
99
+ self.code_file_resource = CodeFileResource()
100
+ self.project_stats_resource = ProjectStatsResource()
101
+
102
+ # Server metadata
103
+ self.name = MCP_INFO["name"]
104
+ self.version = MCP_INFO["version"]
105
+
106
+ self._initialization_complete = True
107
+ logger.info(f"MCP server initialization complete: {self.name} v{self.version}")
108
+
109
+ def is_initialized(self) -> bool:
110
+ """Check if the server is fully initialized."""
111
+ return self._initialization_complete
112
+
113
+ def _ensure_initialized(self) -> None:
114
+ """Ensure the server is initialized before processing requests."""
115
+ if not self._initialization_complete:
116
+ raise RuntimeError(
117
+ "Server not fully initialized. Please wait for initialization to complete."
118
+ )
119
+
120
+ async def _analyze_code_scale(self, arguments: dict[str, Any]) -> dict[str, Any]:
121
+ """
122
+ Analyze code scale and complexity metrics using the analysis engine directly.
123
+ """
124
+ # For initialization-specific tests, we should raise MCPError instead of RuntimeError
125
+ if not self._initialization_complete:
126
+ from .utils.error_handler import MCPError
127
+
128
+ raise MCPError("Server is still initializing")
129
+
130
+ # For specific initialization tests we allow delegating to universal tool
131
+ if (
132
+ "file_path" not in arguments
133
+ and getattr(self, "universal_analyze_tool", None) is not None
134
+ ):
135
+ return await self.universal_analyze_tool.execute(arguments)
136
+ if "file_path" not in arguments:
137
+ raise ValueError("file_path is required")
138
+
139
+ file_path = arguments["file_path"]
140
+ language = arguments.get("language")
141
+ include_complexity = arguments.get("include_complexity", True)
142
+ include_details = arguments.get("include_details", False)
143
+
144
+ # Resolve relative path against project root for consistent behavior
145
+ base_root = getattr(
146
+ getattr(self.security_validator, "boundary_manager", None),
147
+ "project_root",
148
+ None,
149
+ )
150
+ if not PathClass(file_path).is_absolute() and base_root:
151
+ resolved_path = str((PathClass(base_root) / file_path).resolve())
152
+ else:
153
+ resolved_path = file_path
154
+
155
+ # Security validation
156
+ is_valid, error_msg = self.security_validator.validate_file_path(resolved_path)
157
+ if not is_valid:
158
+ raise ValueError(f"Invalid file path: {error_msg}")
159
+
160
+ # Use analysis engine directly
161
+ from ..core.analysis_engine import AnalysisRequest
162
+ from ..language_detector import detect_language_from_file
163
+
164
+ # Validate file exists
165
+ if not PathClass(resolved_path).exists():
166
+ raise FileNotFoundError(f"File not found: {file_path}")
167
+
168
+ # Detect language if not specified
169
+ if not language:
170
+ language = detect_language_from_file(resolved_path)
171
+
172
+ # Create analysis request
173
+ request = AnalysisRequest(
174
+ file_path=resolved_path,
175
+ language=language,
176
+ include_complexity=include_complexity,
177
+ include_details=include_details,
178
+ )
179
+
180
+ # Perform analysis
181
+ analysis_result = await self.analysis_engine.analyze(request)
182
+
183
+ if analysis_result is None or not analysis_result.success:
184
+ error_msg = (
185
+ analysis_result.error_message if analysis_result else "Unknown error"
186
+ )
187
+ raise RuntimeError(f"Failed to analyze file: {file_path} - {error_msg}")
188
+
189
+ # Convert to dictionary format
190
+ result_dict = analysis_result.to_dict()
191
+
192
+ # Format result to match test expectations
193
+ elements = result_dict.get("elements", [])
194
+
195
+ # Count elements by type
196
+ classes_count = len([e for e in elements if e.get("__class__") == "Class"])
197
+ methods_count = len([e for e in elements if e.get("__class__") == "Function"])
198
+ fields_count = len([e for e in elements if e.get("__class__") == "Variable"])
199
+ imports_count = len([e for e in elements if e.get("__class__") == "Import"])
200
+
201
+ result = {
202
+ "file_path": file_path,
203
+ "language": language,
204
+ "metrics": {
205
+ "lines_total": result_dict.get("line_count", 0),
206
+ "lines_code": result_dict.get("line_count", 0), # Approximation
207
+ "lines_comment": 0, # Not available in basic analysis
208
+ "lines_blank": 0, # Not available in basic analysis
209
+ "elements": {
210
+ "classes": classes_count,
211
+ "methods": methods_count,
212
+ "fields": fields_count,
213
+ "imports": imports_count,
214
+ "total": len(elements),
215
+ },
216
+ },
217
+ }
218
+
219
+ if include_complexity:
220
+ # Add complexity metrics if available
221
+ methods = [e for e in elements if e.get("__class__") == "Function"]
222
+ if methods:
223
+ complexities = [e.get("complexity_score", 0) for e in methods]
224
+ result["metrics"]["complexity"] = {
225
+ "total": sum(complexities),
226
+ "average": (
227
+ sum(complexities) / len(complexities) if complexities else 0
228
+ ),
229
+ "max": max(complexities) if complexities else 0,
230
+ }
231
+
232
+ if include_details:
233
+ result["detailed_elements"] = elements
234
+
235
+ return result
236
+
237
+ def create_server(self) -> Server:
238
+ """
239
+ Create and configure the MCP server.
240
+
241
+ Returns:
242
+ Configured MCP Server instance
243
+ """
244
+ if not MCP_AVAILABLE:
245
+ raise RuntimeError("MCP library not available. Please install mcp package.")
246
+
247
+ server: Server = Server(self.name)
248
+
249
+ # Register tools using @server decorators (standard MCP pattern)
250
+ @server.list_tools()
251
+ async def handle_list_tools() -> list[Tool]:
252
+ """List all available tools."""
253
+ logger.info("Client requesting tools list")
254
+
255
+ tools = [
256
+ Tool(
257
+ name="check_code_scale",
258
+ description="Analyze code file size and complexity metrics",
259
+ inputSchema={
260
+ "type": "object",
261
+ "properties": {
262
+ "file_path": {
263
+ "type": "string",
264
+ "description": "Path to the code file (relative to project root)",
265
+ }
266
+ },
267
+ "required": ["file_path"],
268
+ "additionalProperties": False,
269
+ },
270
+ ),
271
+ Tool(
272
+ name="analyze_code_structure",
273
+ description="Analyze code structure and generate tables with line positions",
274
+ inputSchema={
275
+ "type": "object",
276
+ "properties": {
277
+ "file_path": {
278
+ "type": "string",
279
+ "description": "Path to the code file (relative to project root)",
280
+ }
281
+ },
282
+ "required": ["file_path"],
283
+ "additionalProperties": False,
284
+ },
285
+ ),
286
+ Tool(
287
+ name="extract_code_section",
288
+ description="Extract a code section by line range",
289
+ inputSchema={
290
+ "type": "object",
291
+ "properties": {
292
+ "file_path": {
293
+ "type": "string",
294
+ "description": "Path to the code file (relative to project root)",
295
+ },
296
+ "start_line": {
297
+ "type": "integer",
298
+ "description": "Start line (1-based)",
299
+ "minimum": 1,
300
+ },
301
+ "end_line": {
302
+ "type": "integer",
303
+ "description": "End line (optional, 1-based)",
304
+ "minimum": 1,
305
+ },
306
+ },
307
+ "required": ["file_path", "start_line"],
308
+ "additionalProperties": False,
309
+ },
310
+ ),
311
+ Tool(
312
+ name="set_project_path",
313
+ description="Set or override the project root path used for security boundaries",
314
+ inputSchema={
315
+ "type": "object",
316
+ "properties": {
317
+ "project_path": {
318
+ "type": "string",
319
+ "description": "Absolute path to the project root",
320
+ }
321
+ },
322
+ "required": ["project_path"],
323
+ "additionalProperties": False,
324
+ },
325
+ ),
326
+ Tool(**self.query_tool.get_tool_definition()),
327
+ ]
328
+
329
+ logger.info(f"Returning {len(tools)} tools: {[t.name for t in tools]}")
330
+ return tools
331
+
332
+ @server.call_tool()
333
+ async def handle_call_tool(
334
+ name: str, arguments: dict[str, Any]
335
+ ) -> list[TextContent]:
336
+ try:
337
+ # Ensure server is fully initialized
338
+ self._ensure_initialized()
339
+
340
+ # Log tool call
341
+ logger.info(
342
+ f"MCP tool call: {name} with args: {list(arguments.keys())}"
343
+ )
344
+
345
+ # Validate file path security
346
+ if "file_path" in arguments:
347
+ file_path = arguments["file_path"]
348
+ is_valid, error_msg = self.security_validator.validate_file_path(
349
+ file_path
350
+ )
351
+ if not is_valid:
352
+ raise ValueError(
353
+ f"Invalid or unsafe file path: {error_msg or file_path}"
354
+ )
355
+
356
+ # Handle tool calls with simplified parameter handling
357
+ if name == "check_code_scale":
358
+ # Ensure file_path is provided
359
+ if "file_path" not in arguments:
360
+ raise ValueError("file_path parameter is required")
361
+
362
+ # Add default values for optional parameters
363
+ full_args = {
364
+ "file_path": arguments["file_path"],
365
+ "language": arguments.get("language"),
366
+ "include_complexity": arguments.get("include_complexity", True),
367
+ "include_details": arguments.get("include_details", False),
368
+ }
369
+ result = await self._analyze_code_scale(full_args)
370
+
371
+ elif name == "analyze_code_structure":
372
+ if "file_path" not in arguments:
373
+ raise ValueError("file_path parameter is required")
374
+
375
+ full_args = {
376
+ "file_path": arguments["file_path"],
377
+ "format_type": arguments.get("format_type", "full"),
378
+ "language": arguments.get("language"),
379
+ }
380
+ result = await self.table_format_tool.execute(full_args)
381
+
382
+ elif name == "extract_code_section":
383
+ if "file_path" not in arguments or "start_line" not in arguments:
384
+ raise ValueError(
385
+ "file_path and start_line parameters are required"
386
+ )
387
+
388
+ full_args = {
389
+ "file_path": arguments["file_path"],
390
+ "start_line": arguments["start_line"],
391
+ "end_line": arguments.get("end_line"),
392
+ "start_column": arguments.get("start_column"),
393
+ "end_column": arguments.get("end_column"),
394
+ "format": arguments.get("format", "text"),
395
+ }
396
+ result = await self.read_partial_tool.execute(full_args)
397
+
398
+ elif name == "set_project_path":
399
+ project_path = arguments.get("project_path")
400
+ if not project_path or not isinstance(project_path, str):
401
+ raise ValueError(
402
+ "project_path parameter is required and must be a string"
403
+ )
404
+ if not PathClass(project_path).is_dir():
405
+ raise ValueError(f"Project path does not exist: {project_path}")
406
+ self.set_project_path(project_path)
407
+ result = {"status": "success", "project_root": project_path}
408
+
409
+ elif name == "query_code":
410
+ result = await self.query_tool.execute(arguments)
411
+
412
+ else:
413
+ raise ValueError(f"Unknown tool: {name}")
414
+
415
+ # Return result
416
+ return [
417
+ TextContent(
418
+ type="text",
419
+ text=json.dumps(result, indent=2, ensure_ascii=False),
420
+ )
421
+ ]
422
+
423
+ except Exception as e:
424
+ try:
425
+ logger.error(f"Tool call error for {name}: {e}")
426
+ except (ValueError, OSError):
427
+ pass # Silently ignore logging errors during shutdown
428
+ return [
429
+ TextContent(
430
+ type="text",
431
+ text=json.dumps(
432
+ {"error": str(e), "tool": name, "arguments": arguments},
433
+ indent=2,
434
+ ),
435
+ )
436
+ ]
437
+
438
+ # Register resources
439
+ @server.list_resources() # type: ignore
440
+ async def handle_list_resources() -> list[Resource]:
441
+ """List available resources."""
442
+ return [
443
+ Resource(
444
+ uri=self.code_file_resource.get_resource_info()["uri_template"],
445
+ name=self.code_file_resource.get_resource_info()["name"],
446
+ description=self.code_file_resource.get_resource_info()[
447
+ "description"
448
+ ],
449
+ mimeType=self.code_file_resource.get_resource_info()["mime_type"],
450
+ ),
451
+ Resource(
452
+ uri=self.project_stats_resource.get_resource_info()["uri_template"],
453
+ name=self.project_stats_resource.get_resource_info()["name"],
454
+ description=self.project_stats_resource.get_resource_info()[
455
+ "description"
456
+ ],
457
+ mimeType=self.project_stats_resource.get_resource_info()[
458
+ "mime_type"
459
+ ],
460
+ ),
461
+ ]
462
+
463
+ @server.read_resource() # type: ignore
464
+ async def handle_read_resource(uri: str) -> str:
465
+ """Read resource content."""
466
+ try:
467
+ # Check which resource matches the URI
468
+ if self.code_file_resource.matches_uri(uri):
469
+ return await self.code_file_resource.read_resource(uri)
470
+ elif self.project_stats_resource.matches_uri(uri):
471
+ return await self.project_stats_resource.read_resource(uri)
472
+ else:
473
+ raise ValueError(f"Resource not found: {uri}")
474
+
475
+ except Exception as e:
476
+ try:
477
+ logger.error(f"Resource read error for {uri}: {e}")
478
+ except (ValueError, OSError):
479
+ pass # Silently ignore logging errors during shutdown
480
+ raise
481
+
482
+ # Some clients may request prompts; explicitly return empty list
483
+ try:
484
+ from mcp.types import Prompt # type: ignore
485
+
486
+ @server.list_prompts() # type: ignore
487
+ async def handle_list_prompts() -> list[Prompt]:
488
+ logger.info("Client requested prompts list (returning empty)")
489
+ return []
490
+
491
+ except Exception as e:
492
+ # If Prompt type is unavailable, log at debug level and continue safely
493
+ try:
494
+ logger.debug(f"Prompts API unavailable or incompatible: {e}")
495
+ except (ValueError, OSError):
496
+ pass
497
+
498
+ self.server = server
499
+ try:
500
+ logger.info("MCP server created successfully")
501
+ except (ValueError, OSError):
502
+ pass # Silently ignore logging errors during shutdown
503
+ return server
504
+
505
+ def set_project_path(self, project_path: str) -> None:
506
+ """
507
+ Set the project path for statistics resource
508
+
509
+ Args:
510
+ project_path: Path to the project directory
511
+ """
512
+ self.project_stats_resource.set_project_path(project_path)
513
+ try:
514
+ logger.info(f"Set project path to: {project_path}")
515
+ except (ValueError, OSError):
516
+ pass # Silently ignore logging errors during shutdown
517
+
518
+ async def run(self) -> None:
519
+ """
520
+ Run the MCP server.
521
+
522
+ This method starts the server and handles stdio communication.
523
+ """
524
+ if not MCP_AVAILABLE:
525
+ raise RuntimeError("MCP library not available. Please install mcp package.")
526
+
527
+ server = self.create_server()
528
+
529
+ # Initialize server options with required capabilities field
530
+ options = InitializationOptions(
531
+ server_name=self.name,
532
+ server_version=self.version,
533
+ capabilities={"tools": {}, "resources": {}, "prompts": {}, "logging": {}},
534
+ )
535
+
536
+ try:
537
+ logger.info(f"Starting MCP server: {self.name} v{self.version}")
538
+ except (ValueError, OSError):
539
+ pass # Silently ignore logging errors during shutdown
540
+
541
+ try:
542
+ async with stdio_server() as (read_stream, write_stream):
543
+ logger.info("Server running, waiting for requests...")
544
+ await server.run(read_stream, write_stream, options)
545
+ except Exception as e:
546
+ # Use safe logging to avoid I/O errors during shutdown
547
+ try:
548
+ logger.error(f"Server error: {e}")
549
+ except (ValueError, OSError):
550
+ pass # Silently ignore logging errors during shutdown
551
+ raise
552
+ finally:
553
+ # Safe cleanup
554
+ try:
555
+ logger.info("MCP server shutting down")
556
+ except (ValueError, OSError):
557
+ pass # Silently ignore logging errors during shutdown
558
+
559
+
560
+ def parse_mcp_args(args=None) -> argparse.Namespace:
561
+ """Parse command line arguments for MCP server."""
562
+ parser = argparse.ArgumentParser(
563
+ description="Tree-sitter Analyzer MCP Server",
564
+ formatter_class=argparse.RawDescriptionHelpFormatter,
565
+ epilog="""
566
+ Environment Variables:
567
+ TREE_SITTER_PROJECT_ROOT Project root directory (alternative to --project-root)
568
+
569
+ Examples:
570
+ python -m tree_sitter_analyzer.mcp.server
571
+ python -m tree_sitter_analyzer.mcp.server --project-root /path/to/project
572
+ """,
573
+ )
574
+
575
+ parser.add_argument(
576
+ "--project-root",
577
+ help="Project root directory for security validation (auto-detected if not specified)",
578
+ )
579
+
580
+ return parser.parse_args(args)
581
+
582
+
583
+ async def main() -> None:
584
+ """Main entry point for the MCP server."""
585
+ try:
586
+ # Parse command line arguments (ignore unknown so pytest flags won't crash)
587
+ args = parse_mcp_args([] if "pytest" in sys.argv[0] else None)
588
+
589
+ # Determine project root with robust priority handling and fallbacks
590
+ project_root = None
591
+
592
+ # Priority 1: Command line argument
593
+ if args.project_root:
594
+ project_root = args.project_root
595
+ # Priority 2: Environment variable
596
+ elif (
597
+ PathClass.cwd()
598
+ .joinpath(os.environ.get("TREE_SITTER_PROJECT_ROOT", ""))
599
+ .exists()
600
+ ):
601
+ project_root = os.environ.get("TREE_SITTER_PROJECT_ROOT")
602
+ # Priority 3: Auto-detection from current directory
603
+ else:
604
+ project_root = detect_project_root()
605
+
606
+ # Handle unresolved placeholders from clients (e.g., "${workspaceFolder}")
607
+ invalid_placeholder = isinstance(project_root, str) and (
608
+ "${" in project_root or "}" in project_root or "$" in project_root
609
+ )
610
+
611
+ # Validate existence; if invalid, fall back to auto-detected root
612
+ if (
613
+ not project_root
614
+ or invalid_placeholder
615
+ or not PathClass(project_root).is_dir()
616
+ ):
617
+ detected = detect_project_root()
618
+ try:
619
+ logger.warning(
620
+ f"Invalid project root '{project_root}', falling back to auto-detected root: {detected}"
621
+ )
622
+ except (ValueError, OSError):
623
+ pass
624
+ project_root = detected
625
+
626
+ logger.info(f"MCP server starting with project root: {project_root}")
627
+
628
+ server = TreeSitterAnalyzerMCPServer(project_root)
629
+ await server.run()
630
+ except KeyboardInterrupt:
631
+ try:
632
+ logger.info("Server stopped by user")
633
+ except (ValueError, OSError):
634
+ pass # Silently ignore logging errors during shutdown
635
+ except Exception as e:
636
+ try:
637
+ logger.error(f"Server failed: {e}")
638
+ except (ValueError, OSError):
639
+ pass # Silently ignore logging errors during shutdown
640
+ sys.exit(1)
641
+ finally:
642
+ # Ensure clean shutdown
643
+ try:
644
+ logger.info("MCP server shutdown complete")
645
+ except (ValueError, OSError):
646
+ pass # Silently ignore logging errors during shutdown
647
+
648
+
649
+ def main_sync() -> None:
650
+ """Synchronous entry point for setuptools scripts."""
651
+ asyncio.run(main())
652
+
653
+
654
+ if __name__ == "__main__":
655
+ main_sync()