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.
- ucm_mcp-0.1.0/PKG-INFO +13 -0
- ucm_mcp-0.1.0/README.md +105 -0
- ucm_mcp-0.1.0/eval.xml +10 -0
- ucm_mcp-0.1.0/pyproject.toml +25 -0
- ucm_mcp-0.1.0/src/ucm_mcp/__init__.py +0 -0
- ucm_mcp-0.1.0/src/ucm_mcp/analysis/dead_code.py +48 -0
- ucm_mcp-0.1.0/src/ucm_mcp/analysis/export.py +152 -0
- ucm_mcp-0.1.0/src/ucm_mcp/analysis/impact.py +37 -0
- ucm_mcp-0.1.0/src/ucm_mcp/cli.py +48 -0
- ucm_mcp-0.1.0/src/ucm_mcp/config.py +8 -0
- ucm_mcp-0.1.0/src/ucm_mcp/db/__init__.py +0 -0
- ucm_mcp-0.1.0/src/ucm_mcp/db/connection.py +52 -0
- ucm_mcp-0.1.0/src/ucm_mcp/db/registry_schema.sql +8 -0
- ucm_mcp-0.1.0/src/ucm_mcp/db/repository.py +58 -0
- ucm_mcp-0.1.0/src/ucm_mcp/db/schema.sql +86 -0
- ucm_mcp-0.1.0/src/ucm_mcp/extraction/__init__.py +0 -0
- ucm_mcp-0.1.0/src/ucm_mcp/extraction/calls.py +101 -0
- ucm_mcp-0.1.0/src/ucm_mcp/extraction/dependencies.py +131 -0
- ucm_mcp-0.1.0/src/ucm_mcp/extraction/inheritance.py +129 -0
- ucm_mcp-0.1.0/src/ucm_mcp/extraction/symbols.py +136 -0
- ucm_mcp-0.1.0/src/ucm_mcp/frameworks/angular_plugin.py +23 -0
- ucm_mcp-0.1.0/src/ucm_mcp/frameworks/base.py +23 -0
- ucm_mcp-0.1.0/src/ucm_mcp/frameworks/django_plugin.py +24 -0
- ucm_mcp-0.1.0/src/ucm_mcp/frameworks/dotnet_plugin.py +82 -0
- ucm_mcp-0.1.0/src/ucm_mcp/frameworks/express_plugin.py +24 -0
- ucm_mcp-0.1.0/src/ucm_mcp/frameworks/fastapi_plugin.py +23 -0
- ucm_mcp-0.1.0/src/ucm_mcp/frameworks/flask_plugin.py +22 -0
- ucm_mcp-0.1.0/src/ucm_mcp/frameworks/nestjs_plugin.py +52 -0
- ucm_mcp-0.1.0/src/ucm_mcp/frameworks/react_plugin.py +21 -0
- ucm_mcp-0.1.0/src/ucm_mcp/frameworks/spring_plugin.py +37 -0
- ucm_mcp-0.1.0/src/ucm_mcp/frameworks/vue_router_plugin.py +22 -0
- ucm_mcp-0.1.0/src/ucm_mcp/identity.py +98 -0
- ucm_mcp-0.1.0/src/ucm_mcp/indexing/__init__.py +0 -0
- ucm_mcp-0.1.0/src/ucm_mcp/indexing/indexer.py +133 -0
- ucm_mcp-0.1.0/src/ucm_mcp/main.py +253 -0
- ucm_mcp-0.1.0/src/ucm_mcp/parsing/__init__.py +0 -0
- ucm_mcp-0.1.0/src/ucm_mcp/parsing/engine.py +39 -0
- ucm_mcp-0.1.0/src/ucm_mcp/parsing/queries/javascript_symbols.scm +11 -0
- ucm_mcp-0.1.0/src/ucm_mcp/parsing/queries/python_symbols.scm +7 -0
- ucm_mcp-0.1.0/src/ucm_mcp/scanning/__init__.py +0 -0
- ucm_mcp-0.1.0/src/ucm_mcp/scanning/file_scanner.py +45 -0
- ucm_mcp-0.1.0/src/ucm_mcp/scanning/language_detect.py +23 -0
- ucm_mcp-0.1.0/src/ucm_mcp/server.py +34 -0
- ucm_mcp-0.1.0/src/ucm_mcp/tools/__init__.py +0 -0
- ucm_mcp-0.1.0/src/ucm_mcp/tools/analysis_tools.py +112 -0
- ucm_mcp-0.1.0/src/ucm_mcp/tools/framework_tools.py +122 -0
- ucm_mcp-0.1.0/src/ucm_mcp/tools/graph_tools.py +207 -0
- ucm_mcp-0.1.0/src/ucm_mcp/tools/overview_tools.py +218 -0
- ucm_mcp-0.1.0/src/ucm_mcp/tools/project_tools.py +49 -0
- ucm_mcp-0.1.0/src/ucm_mcp/tools/search_tools.py +83 -0
- ucm_mcp-0.1.0/src/ucm_mcp/tools/test_tools.py +84 -0
- ucm_mcp-0.1.0/test_api.py +7 -0
- ucm_mcp-0.1.0/test_call.py +6 -0
- ucm_mcp-0.1.0/test_tool.py +11 -0
- ucm_mcp-0.1.0/tests/evaluations.md +55 -0
- ucm_mcp-0.1.0/tests/test_extraction.py +39 -0
- ucm_mcp-0.1.0/tests/test_identity.py +56 -0
- ucm_mcp-0.1.0/tests/test_indexer.py +29 -0
- ucm_mcp-0.1.0/tests/test_scanner.py +8 -0
- 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
|
ucm_mcp-0.1.0/README.md
ADDED
|
@@ -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()
|
|
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,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
|