ucm-mcp 0.1.0__tar.gz

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.
Files changed (60) hide show
  1. ucm_mcp-0.1.0/PKG-INFO +13 -0
  2. ucm_mcp-0.1.0/README.md +105 -0
  3. ucm_mcp-0.1.0/eval.xml +10 -0
  4. ucm_mcp-0.1.0/pyproject.toml +25 -0
  5. ucm_mcp-0.1.0/src/ucm_mcp/__init__.py +0 -0
  6. ucm_mcp-0.1.0/src/ucm_mcp/analysis/dead_code.py +48 -0
  7. ucm_mcp-0.1.0/src/ucm_mcp/analysis/export.py +152 -0
  8. ucm_mcp-0.1.0/src/ucm_mcp/analysis/impact.py +37 -0
  9. ucm_mcp-0.1.0/src/ucm_mcp/cli.py +48 -0
  10. ucm_mcp-0.1.0/src/ucm_mcp/config.py +8 -0
  11. ucm_mcp-0.1.0/src/ucm_mcp/db/__init__.py +0 -0
  12. ucm_mcp-0.1.0/src/ucm_mcp/db/connection.py +52 -0
  13. ucm_mcp-0.1.0/src/ucm_mcp/db/registry_schema.sql +8 -0
  14. ucm_mcp-0.1.0/src/ucm_mcp/db/repository.py +58 -0
  15. ucm_mcp-0.1.0/src/ucm_mcp/db/schema.sql +86 -0
  16. ucm_mcp-0.1.0/src/ucm_mcp/extraction/__init__.py +0 -0
  17. ucm_mcp-0.1.0/src/ucm_mcp/extraction/calls.py +101 -0
  18. ucm_mcp-0.1.0/src/ucm_mcp/extraction/dependencies.py +131 -0
  19. ucm_mcp-0.1.0/src/ucm_mcp/extraction/inheritance.py +129 -0
  20. ucm_mcp-0.1.0/src/ucm_mcp/extraction/symbols.py +136 -0
  21. ucm_mcp-0.1.0/src/ucm_mcp/frameworks/angular_plugin.py +23 -0
  22. ucm_mcp-0.1.0/src/ucm_mcp/frameworks/base.py +23 -0
  23. ucm_mcp-0.1.0/src/ucm_mcp/frameworks/django_plugin.py +24 -0
  24. ucm_mcp-0.1.0/src/ucm_mcp/frameworks/dotnet_plugin.py +82 -0
  25. ucm_mcp-0.1.0/src/ucm_mcp/frameworks/express_plugin.py +24 -0
  26. ucm_mcp-0.1.0/src/ucm_mcp/frameworks/fastapi_plugin.py +23 -0
  27. ucm_mcp-0.1.0/src/ucm_mcp/frameworks/flask_plugin.py +22 -0
  28. ucm_mcp-0.1.0/src/ucm_mcp/frameworks/nestjs_plugin.py +52 -0
  29. ucm_mcp-0.1.0/src/ucm_mcp/frameworks/react_plugin.py +21 -0
  30. ucm_mcp-0.1.0/src/ucm_mcp/frameworks/spring_plugin.py +37 -0
  31. ucm_mcp-0.1.0/src/ucm_mcp/frameworks/vue_router_plugin.py +22 -0
  32. ucm_mcp-0.1.0/src/ucm_mcp/identity.py +98 -0
  33. ucm_mcp-0.1.0/src/ucm_mcp/indexing/__init__.py +0 -0
  34. ucm_mcp-0.1.0/src/ucm_mcp/indexing/indexer.py +133 -0
  35. ucm_mcp-0.1.0/src/ucm_mcp/main.py +253 -0
  36. ucm_mcp-0.1.0/src/ucm_mcp/parsing/__init__.py +0 -0
  37. ucm_mcp-0.1.0/src/ucm_mcp/parsing/engine.py +39 -0
  38. ucm_mcp-0.1.0/src/ucm_mcp/parsing/queries/javascript_symbols.scm +11 -0
  39. ucm_mcp-0.1.0/src/ucm_mcp/parsing/queries/python_symbols.scm +7 -0
  40. ucm_mcp-0.1.0/src/ucm_mcp/scanning/__init__.py +0 -0
  41. ucm_mcp-0.1.0/src/ucm_mcp/scanning/file_scanner.py +45 -0
  42. ucm_mcp-0.1.0/src/ucm_mcp/scanning/language_detect.py +23 -0
  43. ucm_mcp-0.1.0/src/ucm_mcp/server.py +34 -0
  44. ucm_mcp-0.1.0/src/ucm_mcp/tools/__init__.py +0 -0
  45. ucm_mcp-0.1.0/src/ucm_mcp/tools/analysis_tools.py +112 -0
  46. ucm_mcp-0.1.0/src/ucm_mcp/tools/framework_tools.py +122 -0
  47. ucm_mcp-0.1.0/src/ucm_mcp/tools/graph_tools.py +207 -0
  48. ucm_mcp-0.1.0/src/ucm_mcp/tools/overview_tools.py +218 -0
  49. ucm_mcp-0.1.0/src/ucm_mcp/tools/project_tools.py +49 -0
  50. ucm_mcp-0.1.0/src/ucm_mcp/tools/search_tools.py +83 -0
  51. ucm_mcp-0.1.0/src/ucm_mcp/tools/test_tools.py +84 -0
  52. ucm_mcp-0.1.0/test_api.py +7 -0
  53. ucm_mcp-0.1.0/test_call.py +6 -0
  54. ucm_mcp-0.1.0/test_tool.py +11 -0
  55. ucm_mcp-0.1.0/tests/evaluations.md +55 -0
  56. ucm_mcp-0.1.0/tests/test_extraction.py +39 -0
  57. ucm_mcp-0.1.0/tests/test_identity.py +56 -0
  58. ucm_mcp-0.1.0/tests/test_indexer.py +29 -0
  59. ucm_mcp-0.1.0/tests/test_scanner.py +8 -0
  60. ucm_mcp-0.1.0/uv.lock +1573 -0
ucm_mcp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: ucm-mcp
3
+ Version: 0.1.0
4
+ Summary: Universal Code Mapper - MCP server for AI-agent code navigation
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: fastapi>=0.139.0
7
+ Requires-Dist: fastmcp>=3.4.3
8
+ Requires-Dist: mcp[cli]>=1.0
9
+ Requires-Dist: pathspec
10
+ Requires-Dist: pydantic>=2
11
+ Requires-Dist: tree-sitter-language-pack
12
+ Requires-Dist: tree-sitter>=0.21
13
+ Requires-Dist: watchfiles
@@ -0,0 +1,105 @@
1
+ # Universal Code Mapper (UCM) MCP Server
2
+
3
+ UCM is a fast, offline, Universal Code Mapper exposed as a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server. It enables AI coding assistants (like Claude) to navigate, search, and understand your codebases structurally without relying on embeddings or cloud AI dependencies.
4
+
5
+ ## ✨ Features
6
+
7
+ - **Blazing Fast**: Uses `tree-sitter` for rapid parsing and SQLite FTS5 for full-text search. No embeddings!
8
+ - **Interactive Web UI**: Comes with a built-in network graph visualization to explore your codebase map in the browser.
9
+ - **Call Graphs & Relationships**: Traverse callers, callees, inheritance, and dependencies.
10
+ - **Framework Aware**: Extensively extracts routes and architectural heuristics for Django and React.
11
+ - **Incremental Indexing**: Instant re-indexing of unchanged files using `mtime` and content hashing.
12
+ - **Agent-Optimized Tools**: Designed specifically for LLMs to easily understand project architecture, route maps, and dead code.
13
+
14
+ ---
15
+
16
+ ## 📦 Installation
17
+
18
+ UCM can be installed directly from PyPI. We recommend using [`uv`](https://github.com/astral-sh/uv) for the fastest installation and execution:
19
+
20
+ ```bash
21
+ uv tool install ucm-mcp
22
+ ```
23
+
24
+ Or using standard `pip`:
25
+
26
+ ```bash
27
+ pip install ucm-mcp
28
+ ```
29
+
30
+ ---
31
+
32
+ ## 🚀 Quickstart
33
+
34
+ ### 1. The Interactive Web UI
35
+
36
+ UCM comes with a rich, interactive web UI to visualize your codebase architecture, view full network graphs, and run architectural analysis.
37
+
38
+ When you run UCM, the visualizer UI is automatically served in the background!
39
+
40
+ ```bash
41
+ uvx ucm-mcp
42
+ # Or if installed globally:
43
+ # ucm-mcp
44
+ ```
45
+
46
+ Upon startup, you will see a banner in your terminal:
47
+ ```text
48
+ ============================================================
49
+ 🚀 UCM Backend is running!
50
+ 🌐 Open the UI at: https://ucm-ui.netlify.app/?port=8000
51
+ ============================================================
52
+ ```
53
+ Click the link to open the Visualizer UI, which will automatically connect to your local backend.
54
+
55
+ ### 2. Setting up in Claude Desktop (MCP Client)
56
+
57
+ To give Claude (or any other MCP-compatible AI assistant) access to your codebase architecture, add UCM to your MCP client configuration.
58
+
59
+ **For Claude Desktop (`claude_desktop_config.json`):**
60
+
61
+ ```json
62
+ {
63
+ "mcpServers": {
64
+ "ucm": {
65
+ "command": "uvx",
66
+ "args": [
67
+ "ucm-mcp"
68
+ ]
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ *Note: Even when running inside Claude, the background Web UI will still be active and available to you via the browser!*
75
+
76
+ ---
77
+
78
+ ## 🛠️ Usage & Tools
79
+
80
+ Once connected, your AI assistant will have access to a powerful suite of tools to understand your code.
81
+
82
+ **First Step:**
83
+ - `ucm_index_project`: Indexes the specified directory. This **must** be called before queries work. (The UI can also trigger this index!)
84
+
85
+ **Exploration Tools:**
86
+ - `ucm_search_symbol` / `ucm_search_keywords`: Powerful search over AST symbols and full-text code.
87
+ - `ucm_file_map` / `ucm_directory_map`: View the hierarchical layout of the project.
88
+ - `ucm_get_symbol_info`: Retrieve detailed AST information about a specific class, function, or method.
89
+
90
+ **Analysis Tools:**
91
+ - `ucm_find_callers` / `ucm_find_callees`: Trace execution paths and function calls.
92
+ - `ucm_impact_analysis`: Check what breaks when a specific function or class is modified.
93
+ - `ucm_architecture_summary`: Automatically generates a high-level overview of the project structure.
94
+ - `ucm_route_lookup`: Automatically extracts web framework routes (e.g., Django, React).
95
+ - `ucm_dead_code_detection`: Identifies unused functions and classes.
96
+
97
+ ---
98
+
99
+ ## ⚙️ Advanced Configuration
100
+
101
+ You can customize the UCM server using the following CLI arguments:
102
+
103
+ - `--port <PORT>`: Change the default port (8000) for the UI server.
104
+ - `--http`: Run exclusively in HTTP (SSE) mode instead of standard MCP stdio.
105
+ - `--data-dir <PATH>`: Override the default SQLite storage location (defaults to `~/.ucm`).
ucm_mcp-0.1.0/eval.xml ADDED
@@ -0,0 +1,10 @@
1
+ <evaluation>
2
+ <qa_pair>
3
+ <question>Search for a symbol named 'FastMCP' in the project 'd:\DEV\mcp\Code-Mapper\ucm-mcp'. Set this path as the active project first using ucm_set_active_project, then use ucm_search_symbol. Wait, you must index the project first using ucm_index_project. What is the line number where 'FastMCP' is imported or defined in src\ucm_mcp\server.py according to the search results?</question>
4
+ <answer>2</answer>
5
+ </qa_pair>
6
+ <qa_pair>
7
+ <question>In the active project 'd:\DEV\mcp\Code-Mapper\ucm-mcp', use ucm_search_symbol to find the symbol 'ucm_search_keywords'. What file path (relative to the project root, like src\...) is it located in?</question>
8
+ <answer>src\ucm_mcp\tools\search_tools.py</answer>
9
+ </qa_pair>
10
+ </evaluation>
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "ucm-mcp"
3
+ version = "0.1.0"
4
+ description = "Universal Code Mapper - MCP server for AI-agent code navigation"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "mcp[cli]>=1.0",
8
+ "pydantic>=2",
9
+ "tree-sitter>=0.21",
10
+ "tree-sitter-language-pack",
11
+ "watchfiles",
12
+ "pathspec",
13
+ "fastapi>=0.139.0",
14
+ "fastmcp>=3.4.3",
15
+ ]
16
+
17
+ [project.scripts]
18
+ ucm-mcp = "ucm_mcp.cli:main"
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/ucm_mcp"]
File without changes
@@ -0,0 +1,48 @@
1
+ from typing import List, Dict, Any
2
+ from ucm_mcp.db.connection import get_connection
3
+
4
+ def find_dead_code(db_id: str, symbol_types: List[str] | None, data_dir: str | None = None) -> List[Dict[str, Any]]:
5
+ conn = get_connection(db_id, data_dir)
6
+ cur = conn.cursor()
7
+
8
+ # We find symbols that never appear as a callee_name in `calls`
9
+ # and maybe filter by symbol_types (like ['function', 'method'])
10
+
11
+ sql = '''
12
+ SELECT s.name, s.type, f.path, s.line
13
+ FROM symbols s
14
+ JOIN files f ON s.file_id = f.id
15
+ WHERE s.name NOT IN (SELECT callee_name FROM calls)
16
+ AND s.id NOT IN (SELECT handler_symbol_id FROM routes WHERE handler_symbol_id IS NOT NULL)
17
+ '''
18
+ params = []
19
+
20
+ if symbol_types:
21
+ placeholders = ",".join("?" for _ in symbol_types)
22
+ sql += f" AND s.type IN ({placeholders})"
23
+ params.extend(symbol_types)
24
+
25
+ # Also ignore standard dunder methods in Python, etc.
26
+ sql += " AND s.name NOT LIKE '\\_\\_%' ESCAPE '\\'"
27
+
28
+ # Ignore test functions
29
+ sql += " AND s.name NOT LIKE 'test_%'"
30
+
31
+ # Ignore controllers (often implicitly instantiated by frameworks)
32
+ sql += " AND s.name NOT LIKE '%Controller%'"
33
+ sql += " AND s.name NOT LIKE '%controller%'"
34
+
35
+ sql += " ORDER BY f.path, s.line"
36
+
37
+ cur.execute(sql, tuple(params))
38
+
39
+ results = []
40
+ for row in cur.fetchall():
41
+ results.append({
42
+ "name": row["name"],
43
+ "type": row["type"],
44
+ "path": row["path"],
45
+ "line": row["line"]
46
+ })
47
+
48
+ return results
@@ -0,0 +1,152 @@
1
+ from typing import Dict, Any, Optional, List
2
+ from pydantic import BaseModel
3
+ from ucm_mcp.db.connection import get_connection
4
+
5
+ class Node(BaseModel):
6
+ id: str
7
+ type: str
8
+ label: str
9
+ parent: Optional[str] = None
10
+ language: Optional[str] = None
11
+ symbol_type: Optional[str] = None
12
+ line: Optional[int] = None
13
+ framework: Optional[str] = None
14
+
15
+ class Edge(BaseModel):
16
+ id: str
17
+ source: str
18
+ target: str
19
+ type: str
20
+ label: Optional[str] = None
21
+
22
+ class GraphData(BaseModel):
23
+ nodes: List[Node]
24
+ edges: List[Edge]
25
+
26
+ def get_full_graph(db_id: str, data_dir: Optional[str] = None) -> GraphData:
27
+ conn = get_connection(db_id, data_dir)
28
+ cur = conn.cursor()
29
+
30
+ cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='files'")
31
+ if not cur.fetchone():
32
+ raise ValueError("Project not indexed")
33
+
34
+
35
+ nodes: List[Node] = []
36
+ edges: List[Edge] = []
37
+
38
+ # 1. Add a central root node for the project
39
+ nodes.append(Node(id="dir_root", type="dir", label="Project Root", parent=None))
40
+
41
+ # 2. Get all files and build directory nodes
42
+ cur.execute("SELECT id, path, language FROM files")
43
+ files = cur.fetchall()
44
+ seen_dirs = {"dir_root"}
45
+
46
+ for f in files:
47
+ path = f['path']
48
+ parts = path.split('/')
49
+ file_name = parts[-1]
50
+
51
+ parent_id = "dir_root"
52
+ current_dir_path = ""
53
+ for i in range(len(parts) - 1):
54
+ part = parts[i]
55
+ if current_dir_path:
56
+ current_dir_path += f"/{part}"
57
+ else:
58
+ current_dir_path = part
59
+
60
+ dir_id = f"dir_{current_dir_path}"
61
+ if dir_id not in seen_dirs:
62
+ seen_dirs.add(dir_id)
63
+ nodes.append(Node(
64
+ id=dir_id,
65
+ type="dir",
66
+ label=part,
67
+ parent=parent_id
68
+ ))
69
+ parent_id = dir_id
70
+
71
+ nodes.append(Node(
72
+ id=f"file_{f['id']}",
73
+ type="file",
74
+ label=file_name,
75
+ language=f['language'],
76
+ parent=parent_id
77
+ ))
78
+
79
+ # 2. Get all symbols
80
+ cur.execute("SELECT id, file_id, type, name, line FROM symbols")
81
+ symbols = cur.fetchall()
82
+ for s in symbols:
83
+ nodes.append(Node(
84
+ id=f"sym_{s['id']}",
85
+ type="symbol",
86
+ label=s['name'],
87
+ symbol_type=s['type'],
88
+ line=s['line'],
89
+ parent=f"file_{s['file_id']}"
90
+ ))
91
+
92
+ # 3. Get all routes
93
+ cur.execute("""
94
+ SELECT r.id, r.method, r.path, r.handler_symbol_id, r.framework, s.file_id
95
+ FROM routes r
96
+ LEFT JOIN symbols s ON r.handler_symbol_id = s.id
97
+ """)
98
+ routes = cur.fetchall()
99
+ for r in routes:
100
+ parent_id = f"file_{r['file_id']}" if r['file_id'] else None
101
+ nodes.append(Node(
102
+ id=f"route_{r['id']}",
103
+ type="route",
104
+ label=f"{r['method']} {r['path']}",
105
+ framework=r['framework'],
106
+ parent=parent_id
107
+ ))
108
+ # Edge: Route -> Handler
109
+ if r['handler_symbol_id']:
110
+ edges.append(Edge(
111
+ id=f"edge_r2h_{r['id']}",
112
+ source=f"route_{r['id']}",
113
+ target=f"sym_{r['handler_symbol_id']}",
114
+ type="handled_by"
115
+ ))
116
+
117
+ # 4. Get all imports (File -> File)
118
+ cur.execute("SELECT id, from_file_id, resolved_file_id, to_module FROM imports WHERE resolved_file_id IS NOT NULL")
119
+ imports = cur.fetchall()
120
+ for i in imports:
121
+ edges.append(Edge(
122
+ id=f"edge_imp_{i['id']}",
123
+ source=f"file_{i['from_file_id']}",
124
+ target=f"file_{i['resolved_file_id']}",
125
+ type="imports",
126
+ label=i['to_module']
127
+ ))
128
+
129
+ # 5. Get all calls (Symbol -> Symbol)
130
+ cur.execute("SELECT id, caller_symbol_id, callee_symbol_id, callee_name FROM calls WHERE callee_symbol_id IS NOT NULL")
131
+ calls = cur.fetchall()
132
+ for c in calls:
133
+ edges.append(Edge(
134
+ id=f"edge_call_{c['id']}",
135
+ source=f"sym_{c['caller_symbol_id']}",
136
+ target=f"sym_{c['callee_symbol_id']}",
137
+ type="calls",
138
+ label=c['callee_name']
139
+ ))
140
+
141
+ # 6. Get all inheritance
142
+ cur.execute("SELECT id, child_symbol_id, parent_symbol_id, kind FROM inheritance WHERE parent_symbol_id IS NOT NULL")
143
+ inheritances = cur.fetchall()
144
+ for ih in inheritances:
145
+ edges.append(Edge(
146
+ id=f"edge_inh_{ih['id']}",
147
+ source=f"sym_{ih['child_symbol_id']}",
148
+ target=f"sym_{ih['parent_symbol_id']}",
149
+ type=ih['kind']
150
+ ))
151
+
152
+ return GraphData(nodes=nodes, edges=edges)
@@ -0,0 +1,37 @@
1
+ from typing import List, Dict, Any, Set
2
+ from ucm_mcp.db.connection import get_connection
3
+
4
+ def calculate_impact(db_id: str, symbol_name: str, depth: int, data_dir: str | None = None) -> List[Dict[str, Any]]:
5
+ conn = get_connection(db_id, data_dir)
6
+ cur = conn.cursor()
7
+
8
+ # We find all symbols that call this symbol (directly or indirectly) up to 'depth' levels.
9
+ # Because we don't have a CTE (Common Table Expression) for recursion easily accessible if we just want simple Python logic,
10
+ # we can do it iteratively.
11
+
12
+ affected_symbols = set()
13
+ current_level_names = {symbol_name}
14
+
15
+ for _ in range(depth):
16
+ if not current_level_names:
17
+ break
18
+
19
+ placeholders = ",".join("?" for _ in current_level_names)
20
+ cur.execute(f'''
21
+ SELECT s.name, s.type, f.path
22
+ FROM calls c
23
+ JOIN symbols s ON c.caller_symbol_id = s.id
24
+ JOIN files f ON s.file_id = f.id
25
+ WHERE c.callee_name IN ({placeholders})
26
+ ''', list(current_level_names))
27
+
28
+ next_level = set()
29
+ for row in cur.fetchall():
30
+ sym_desc = f"{row['type']} {row['name']} ({row['path']})"
31
+ if sym_desc not in affected_symbols:
32
+ affected_symbols.add(sym_desc)
33
+ next_level.add(row['name'])
34
+
35
+ current_level_names = next_level
36
+
37
+ return list(affected_symbols)
@@ -0,0 +1,48 @@
1
+ import os
2
+ import sys
3
+ import argparse
4
+ import threading
5
+ import uvicorn
6
+ import asyncio
7
+
8
+ def run_uvicorn(port: int):
9
+ # Run uvicorn with logging disabled to avoid interfering with MCP stdio
10
+ config = uvicorn.Config(
11
+ "ucm_mcp.main:app",
12
+ host="127.0.0.1",
13
+ port=port,
14
+ log_level="error",
15
+ access_log=False,
16
+ )
17
+ server = uvicorn.Server(config)
18
+ try:
19
+ asyncio.run(server.serve())
20
+ except Exception:
21
+ pass
22
+
23
+ def main() -> None:
24
+ parser = argparse.ArgumentParser(prog="ucm-mcp")
25
+ parser.add_argument("--http", action="store_true", help="Use streamable HTTP transport instead of stdio")
26
+ parser.add_argument("--port", type=int, default=8000)
27
+ parser.add_argument("--data-dir", default=None, help="Override ~/.ucm storage location")
28
+ args = parser.parse_args()
29
+
30
+ # Set env vars before importing main
31
+ if args.data_dir:
32
+ os.environ["UCM_DATA_DIR"] = args.data_dir
33
+ os.environ["UCM_PORT"] = str(args.port)
34
+
35
+ # Import mcp from main after setting env vars
36
+ from ucm_mcp.main import mcp
37
+
38
+ if args.http:
39
+ # Run natively in the main thread (allows logs)
40
+ uvicorn.run("ucm_mcp.main:app", host="127.0.0.1", port=args.port)
41
+ else:
42
+ # Run stdio in main thread, run UI http server in background
43
+ t = threading.Thread(target=run_uvicorn, args=(args.port,), daemon=True)
44
+ t.start()
45
+ mcp.run()
46
+
47
+ if __name__ == "__main__":
48
+ main()
@@ -0,0 +1,8 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ def get_base_dir(data_dir: str | None = None) -> Path:
5
+ """Return the base directory for UCM storage (~/.ucm)."""
6
+ if data_dir:
7
+ return Path(data_dir).resolve()
8
+ return Path.home() / ".ucm"
File without changes
@@ -0,0 +1,52 @@
1
+ import sqlite3
2
+ from collections import OrderedDict
3
+ from pathlib import Path
4
+
5
+ from ucm_mcp.config import get_base_dir
6
+
7
+ class ConnectionCache:
8
+ def __init__(self, maxsize: int = 20):
9
+ self.maxsize = maxsize
10
+ self._cache: OrderedDict[str, sqlite3.Connection] = OrderedDict()
11
+
12
+ def get_connection(self, db_id: str, data_dir: str | None = None) -> sqlite3.Connection:
13
+ if db_id in self._cache:
14
+ self._cache.move_to_end(db_id)
15
+ return self._cache[db_id]
16
+
17
+ base_dir = get_base_dir(data_dir)
18
+ project_dir = base_dir / "projects" / db_id
19
+ project_dir.mkdir(parents=True, exist_ok=True)
20
+
21
+ db_path = project_dir / "index.sqlite3"
22
+ print(f"Db path from connection.py: {db_path}")
23
+ conn = sqlite3.connect(db_path, check_same_thread=False)
24
+ conn.row_factory = sqlite3.Row
25
+
26
+ # Initialize schema if new
27
+ self._init_schema(conn)
28
+
29
+ self._cache[db_id] = conn
30
+ if len(self._cache) > self.maxsize:
31
+ _, old_conn = self._cache.popitem(last=False)
32
+ old_conn.close()
33
+
34
+ return conn
35
+
36
+ def _init_schema(self, conn: sqlite3.Connection) -> None:
37
+ schema_path = Path(__file__).parent / "schema.sql"
38
+ if schema_path.exists():
39
+ with open(schema_path, "r", encoding="utf-8") as f:
40
+ schema = f.read()
41
+ conn.executescript(schema)
42
+ conn.commit()
43
+
44
+ def close_all(self):
45
+ for conn in self._cache.values():
46
+ conn.close()
47
+ self._cache.clear()
48
+
49
+ _connection_cache = ConnectionCache()
50
+
51
+ def get_connection(db_id: str, data_dir: str | None = None) -> sqlite3.Connection:
52
+ return _connection_cache.get_connection(db_id, data_dir)
@@ -0,0 +1,8 @@
1
+ CREATE TABLE IF NOT EXISTS projects (
2
+ db_id TEXT PRIMARY KEY,
3
+ canonical_path TEXT UNIQUE NOT NULL,
4
+ first_indexed_at REAL NOT NULL,
5
+ last_indexed_at REAL,
6
+ file_count INTEGER DEFAULT 0,
7
+ symbol_count INTEGER DEFAULT 0
8
+ );
@@ -0,0 +1,58 @@
1
+ import sqlite3
2
+ from typing import TypedDict, List
3
+ from ucm_mcp.db.connection import get_connection
4
+
5
+ class FileRecord(TypedDict):
6
+ id: int
7
+ path: str
8
+ language: str | None
9
+ hash: str
10
+ size: int
11
+ mtime: float
12
+
13
+ def insert_or_update_file(db_id: str, path: str, language: str | None, file_hash: str, size: int, mtime: float, data_dir: str | None = None) -> tuple[int, bool]:
14
+ conn = get_connection(db_id, data_dir)
15
+ cur = conn.cursor()
16
+
17
+ cur.execute("SELECT id, hash FROM files WHERE path = ?", (path,))
18
+ row = cur.fetchone()
19
+
20
+ is_changed = True
21
+ if row:
22
+ file_id = row["id"]
23
+ old_hash = row["hash"]
24
+ if old_hash == file_hash:
25
+ is_changed = False
26
+
27
+ cur.execute(
28
+ """UPDATE files SET language = ?, hash = ?, size = ?, mtime = ? WHERE id = ?""",
29
+ (language, file_hash, size, mtime, file_id)
30
+ )
31
+ else:
32
+ cur.execute(
33
+ """INSERT INTO files (path, language, hash, size, mtime) VALUES (?, ?, ?, ?, ?)""",
34
+ (path, language, file_hash, size, mtime)
35
+ )
36
+ file_id = cur.lastrowid
37
+
38
+ conn.commit()
39
+ assert file_id is not None
40
+ return file_id, is_changed
41
+
42
+ def get_file_counts(db_id: str, data_dir: str | None = None) -> dict[str, int]:
43
+ conn = get_connection(db_id, data_dir)
44
+ cur = conn.cursor()
45
+ cur.execute("SELECT language, COUNT(*) as count FROM files GROUP BY language")
46
+ rows = cur.fetchall()
47
+
48
+ result = {}
49
+ for row in rows:
50
+ lang = row["language"] or "unknown"
51
+ result[lang] = row["count"]
52
+ return result
53
+
54
+ def get_total_file_count(db_id: str, data_dir: str | None = None) -> int:
55
+ conn = get_connection(db_id, data_dir)
56
+ cur = conn.cursor()
57
+ cur.execute("SELECT COUNT(*) as c FROM files")
58
+ return cur.fetchone()["c"]
@@ -0,0 +1,86 @@
1
+ CREATE TABLE IF NOT EXISTS files (
2
+ id INTEGER PRIMARY KEY,
3
+ path TEXT UNIQUE NOT NULL,
4
+ language TEXT,
5
+ hash TEXT NOT NULL,
6
+ size INTEGER NOT NULL,
7
+ mtime REAL NOT NULL
8
+ );
9
+
10
+ CREATE TABLE IF NOT EXISTS symbols (
11
+ id INTEGER PRIMARY KEY,
12
+ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
13
+ type TEXT NOT NULL,
14
+ name TEXT NOT NULL,
15
+ parent_id INTEGER REFERENCES symbols(id),
16
+ visibility TEXT,
17
+ line INTEGER NOT NULL,
18
+ column INTEGER NOT NULL,
19
+ signature TEXT,
20
+ docstring TEXT,
21
+ summary TEXT
22
+ );
23
+
24
+ CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
25
+ name, docstring, summary,
26
+ content='symbols', content_rowid='id',
27
+ tokenize='unicode61 remove_diacritics 1'
28
+ );
29
+
30
+ CREATE TRIGGER IF NOT EXISTS symbols_ai AFTER INSERT ON symbols BEGIN
31
+ INSERT INTO symbols_fts(rowid, name, docstring, summary) VALUES (new.id, new.name, new.docstring, new.summary);
32
+ END;
33
+ CREATE TRIGGER IF NOT EXISTS symbols_ad AFTER DELETE ON symbols BEGIN
34
+ INSERT INTO symbols_fts(symbols_fts, rowid, name, docstring, summary) VALUES ('delete', old.id, old.name, old.docstring, old.summary);
35
+ END;
36
+ CREATE TRIGGER IF NOT EXISTS symbols_au AFTER UPDATE ON symbols BEGIN
37
+ INSERT INTO symbols_fts(symbols_fts, rowid, name, docstring, summary) VALUES ('delete', old.id, old.name, old.docstring, old.summary);
38
+ INSERT INTO symbols_fts(rowid, name, docstring, summary) VALUES (new.id, new.name, new.docstring, new.summary);
39
+ END;
40
+
41
+ CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
42
+ CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id);
43
+
44
+ CREATE TABLE IF NOT EXISTS imports (
45
+ id INTEGER PRIMARY KEY,
46
+ from_file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
47
+ to_module TEXT NOT NULL,
48
+ resolved_file_id INTEGER REFERENCES files(id),
49
+ is_dynamic BOOLEAN DEFAULT 0,
50
+ alias TEXT
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS calls (
54
+ id INTEGER PRIMARY KEY,
55
+ caller_symbol_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
56
+ callee_symbol_id INTEGER REFERENCES symbols(id),
57
+ callee_name TEXT NOT NULL,
58
+ line INTEGER
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS refs (
62
+ id INTEGER PRIMARY KEY,
63
+ symbol_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
64
+ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
65
+ line INTEGER NOT NULL
66
+ );
67
+
68
+ CREATE TABLE IF NOT EXISTS inheritance (
69
+ id INTEGER PRIMARY KEY,
70
+ child_symbol_id INTEGER NOT NULL REFERENCES symbols(id) ON DELETE CASCADE,
71
+ parent_symbol_id INTEGER REFERENCES symbols(id),
72
+ parent_name TEXT NOT NULL,
73
+ kind TEXT NOT NULL
74
+ );
75
+
76
+ CREATE INDEX IF NOT EXISTS idx_calls_caller ON calls(caller_symbol_id);
77
+ CREATE INDEX IF NOT EXISTS idx_calls_callee ON calls(callee_symbol_id);
78
+
79
+ CREATE TABLE IF NOT EXISTS routes (
80
+ id INTEGER PRIMARY KEY,
81
+ file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
82
+ method TEXT NOT NULL,
83
+ path TEXT NOT NULL,
84
+ handler_symbol_id INTEGER REFERENCES symbols(id) ON DELETE CASCADE,
85
+ framework TEXT
86
+ );
File without changes