-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmcp_server.py
More file actions
224 lines (188 loc) · 7.83 KB
/
Copy pathmcp_server.py
File metadata and controls
224 lines (188 loc) · 7.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
"""
ArchMap v2 MCP server — 3 tools for agent interaction.
Tools:
orient — See the purpose tree + active task info
get_task — Get the active task with scoped context
report — Report work done: propose tree changes
"""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from archmap.core.store import is_initialized, init_project, normalize_path
from archmap.tree import (
get_tree, render_tree, get_context, detect_drift, get_active_task, list_tasks,
create_task, propose_addition, propose_removal, _file_index,
)
mcp = FastMCP("archmap")
@mcp.tool()
def orient(project_path: str, depth: int = 2) -> str:
"""See the purpose tree and active task.
Call this at the start of a session to understand the project structure
and what you're supposed to be working on.
Args:
project_path: Path to the project root
depth: How many levels deep to show (default 2)
"""
if not is_initialized(project_path):
init_project(project_path)
return (
"ArchMap initialized for this project.\n"
"No tree yet. Ask the user what this project is about, "
"then use report() to propose the root structure."
)
parts: list[str] = []
# Tree
tree_text = render_tree(project_path, depth)
parts.append("## Purpose Tree\n")
parts.append(tree_text)
# Active task
active = get_active_task(project_path)
if active:
parts.append(f"\n\n## Active Task\n")
parts.append(f"**{active.description}**")
if active.target_node_id:
tree = get_tree(project_path)
if tree:
target = tree.find(active.target_node_id)
if target:
parts.append(f"Target: {target.name}")
else:
queued = [t for t in list_tasks(project_path) if t.status == "queued"]
if queued:
parts.append(f"\n\nNo active task. {len(queued)} task(s) queued.")
else:
parts.append("\n\nNo tasks.")
# Drift detection
drift = detect_drift(project_path)
if drift:
parts.append("\n\n## Drift Detected\n")
for d in drift:
changed = ", ".join(d["changed_files"])
missing = ", ".join(d["missing_files"])
detail = []
if changed:
detail.append(f"changed: {changed}")
if missing:
detail.append(f"missing: {missing}")
parts.append(f"- **{d['node_name']}**: {'; '.join(detail)}")
return "\n".join(parts)
@mcp.tool()
def get_task(project_path: str) -> str:
"""Get the active task with scoped context.
Returns the task description plus the tree context around the target node
(breadcrumb path, siblings, children). This tells you exactly what to work
on and what scope you're in.
Args:
project_path: Path to the project root
"""
active = get_active_task(project_path)
if active is None:
return "No active task. Ask the user what they want to do, then use `report` to create a task."
parts: list[str] = []
parts.append(f"## Task: {active.description}")
parts.append(f"Status: {active.status}")
if active.target_node_id:
try:
ctx = get_context(project_path, active.target_node_id)
parts.append(f"\n{ctx}")
except Exception:
parts.append(f"\nTarget node: {active.target_node_id}")
if active.proposed_additions:
parts.append(f"\nAlready proposed: {len(active.proposed_additions)} node(s)")
if active.proposed_removals:
parts.append(f"Proposed removals: {len(active.proposed_removals)} node(s)")
# Drift on target node
if active.target_node_id:
drift = detect_drift(project_path)
target_drift = [d for d in drift if d["node_id"] == active.target_node_id]
if target_drift:
d = target_drift[0]
detail = []
if d["changed_files"]:
detail.append(f"changed: {', '.join(d['changed_files'])}")
if d["missing_files"]:
detail.append(f"missing: {', '.join(d['missing_files'])}")
parts.append(f"\n**Drift warning**: {'; '.join(detail)}")
return "\n".join(parts)
@mcp.tool()
def report(
project_path: str,
files_touched: list[str] | None = None,
proposed_nodes: list[dict] | None = None,
proposed_removals: list[str] | None = None,
new_task: str | None = None,
new_task_target: str | None = None,
) -> str:
"""Report work done and propose tree changes.
Call this after completing work to update the tree. You can:
- Propose new nodes (they appear as grey/ghost in the UI for user review)
- Propose removing existing nodes
- Create a new task (if the user asked for something)
Files are attached via proposed_nodes[].files, NOT via files_touched.
files_touched only reports which files you edited (for tracking).
Args:
project_path: Path to the project root
files_touched: List of file paths you created or modified (for tracking)
proposed_nodes: List of {parent_id, name, description, files?} to propose as new tree nodes
proposed_removals: List of node IDs to propose for removal
new_task: Description for a new task to create (optional)
new_task_target: Target node ID for the new task (optional)
"""
parts: list[str] = []
# Get or create task context
active = get_active_task(project_path)
# Create new task if requested
if new_task:
task = create_task(project_path, new_task, new_task_target or "")
parts.append(f"Created task: {task.description} ({task.status})")
if active is None:
active = task
if active is None:
return "No active task and no new task created. Nothing to report against."
# Propose additions
if proposed_nodes:
for pn in proposed_nodes:
parent_id = pn.get("parent_id", "")
name = pn.get("name", "")
desc = pn.get("description", "")
files = pn.get("files")
if not parent_id or not name:
parts.append(f"Skipped invalid proposal: {pn}")
continue
node = propose_addition(project_path, active.id, parent_id, name, desc, files)
parts.append(f"Proposed: {name} (id: {node.id})")
if files:
parts.append(f" Files migrated to this node: {', '.join(files)}")
# Propose removals
if proposed_removals:
for node_id in proposed_removals:
try:
node = propose_removal(project_path, active.id, node_id)
parts.append(f"Proposed removal: {node.name}")
except Exception as e:
parts.append(f"Failed to propose removal of {node_id}: {e}")
# Report files_touched status (no auto-attach)
if files_touched:
tree = get_tree(project_path)
if tree:
index = _file_index(tree)
tracked = []
new_files = []
for fp in files_touched:
norm = normalize_path(project_path, fp)
if norm in index:
owner = tree.find(index[norm])
owner_name = owner.name if owner else "?"
tracked.append(f"{fp} (on: {owner_name})")
else:
new_files.append(fp)
if tracked:
parts.append(f"\nFiles already tracked: {', '.join(tracked)}")
if new_files:
parts.append(f"\nNew files not in tree: {', '.join(new_files)}")
parts.append("Propose a node with these files, or attach them in the UI.")
if not parts:
parts.append("Report received. No tree changes proposed.")
parts.append("\nUser will review proposals in the UI.")
return "\n".join(parts)
if __name__ == "__main__":
mcp.run()