FossilRepo

fossilrepo / mcp_server / tools.py
Blame History Raw 674 lines
1
"""Tool definitions and handlers for the fossilrepo MCP server.
2
3
Each tool maps to a Fossil repository operation -- reads go through
4
FossilReader (direct SQLite), writes go through FossilCLI (fossil binary).
5
"""
6
7
from mcp.types import Tool
8
9
TOOLS = [
10
Tool(
11
name="list_projects",
12
description="List all projects in the fossilrepo instance",
13
inputSchema={"type": "object", "properties": {}, "required": []},
14
),
15
Tool(
16
name="get_project",
17
description="Get details about a specific project including repo stats",
18
inputSchema={
19
"type": "object",
20
"properties": {"slug": {"type": "string", "description": "Project slug"}},
21
"required": ["slug"],
22
},
23
),
24
Tool(
25
name="browse_code",
26
description="List files in a directory of a project's repository",
27
inputSchema={
28
"type": "object",
29
"properties": {
30
"slug": {"type": "string", "description": "Project slug"},
31
"path": {"type": "string", "description": "Directory path (empty for root)", "default": ""},
32
},
33
"required": ["slug"],
34
},
35
),
36
Tool(
37
name="read_file",
38
description="Read the content of a file from a project's repository",
39
inputSchema={
40
"type": "object",
41
"properties": {
42
"slug": {"type": "string", "description": "Project slug"},
43
"filepath": {"type": "string", "description": "File path in the repo"},
44
},
45
"required": ["slug", "filepath"],
46
},
47
),
48
Tool(
49
name="get_timeline",
50
description="Get recent checkins/commits for a project",
51
inputSchema={
52
"type": "object",
53
"properties": {
54
"slug": {"type": "string", "description": "Project slug"},
55
"limit": {"type": "integer", "description": "Number of entries", "default": 25},
56
"branch": {"type": "string", "description": "Filter by branch", "default": ""},
57
},
58
"required": ["slug"],
59
},
60
),
61
Tool(
62
name="get_checkin",
63
description="Get details of a specific checkin including file changes",
64
inputSchema={
65
"type": "object",
66
"properties": {
67
"slug": {"type": "string", "description": "Project slug"},
68
"uuid": {"type": "string", "description": "Checkin UUID (or prefix)"},
69
},
70
"required": ["slug", "uuid"],
71
},
72
),
73
Tool(
74
name="search_code",
75
description="Search across checkins, tickets, and wiki pages",
76
inputSchema={
77
"type": "object",
78
"properties": {
79
"slug": {"type": "string", "description": "Project slug"},
80
"query": {"type": "string", "description": "Search query"},
81
"limit": {"type": "integer", "default": 25},
82
},
83
"required": ["slug", "query"],
84
},
85
),
86
Tool(
87
name="list_tickets",
88
description="List tickets for a project with optional status filter",
89
inputSchema={
90
"type": "object",
91
"properties": {
92
"slug": {"type": "string", "description": "Project slug"},
93
"status": {"type": "string", "description": "Filter by status (Open, Fixed, Closed)", "default": ""},
94
"limit": {"type": "integer", "default": 50},
95
},
96
"required": ["slug"],
97
},
98
),
99
Tool(
100
name="get_ticket",
101
description="Get ticket details including comments",
102
inputSchema={
103
"type": "object",
104
"properties": {
105
"slug": {"type": "string", "description": "Project slug"},
106
"uuid": {"type": "string", "description": "Ticket UUID (or prefix)"},
107
},
108
"required": ["slug", "uuid"],
109
},
110
),
111
Tool(
112
name="create_ticket",
113
description="Create a new ticket in a project",
114
inputSchema={
115
"type": "object",
116
"properties": {
117
"slug": {"type": "string", "description": "Project slug"},
118
"title": {"type": "string"},
119
"body": {"type": "string", "description": "Ticket description"},
120
"type": {"type": "string", "default": "Code_Defect"},
121
"severity": {"type": "string", "default": "Important"},
122
"priority": {"type": "string", "default": "Medium"},
123
},
124
"required": ["slug", "title", "body"],
125
},
126
),
127
Tool(
128
name="update_ticket",
129
description="Update a ticket's status, add a comment",
130
inputSchema={
131
"type": "object",
132
"properties": {
133
"slug": {"type": "string", "description": "Project slug"},
134
"uuid": {"type": "string", "description": "Ticket UUID"},
135
"status": {"type": "string", "description": "New status", "default": ""},
136
"comment": {"type": "string", "description": "Comment to add", "default": ""},
137
},
138
"required": ["slug", "uuid"],
139
},
140
),
141
Tool(
142
name="list_wiki_pages",
143
description="List all wiki pages in a project",
144
inputSchema={
145
"type": "object",
146
"properties": {"slug": {"type": "string", "description": "Project slug"}},
147
"required": ["slug"],
148
},
149
),
150
Tool(
151
name="get_wiki_page",
152
description="Read a wiki page's content",
153
inputSchema={
154
"type": "object",
155
"properties": {
156
"slug": {"type": "string", "description": "Project slug"},
157
"page_name": {"type": "string", "description": "Wiki page name"},
158
},
159
"required": ["slug", "page_name"],
160
},
161
),
162
Tool(
163
name="list_branches",
164
description="List all branches in a project's repository",
165
inputSchema={
166
"type": "object",
167
"properties": {"slug": {"type": "string", "description": "Project slug"}},
168
"required": ["slug"],
169
},
170
),
171
Tool(
172
name="get_file_blame",
173
description="Get blame annotations for a file showing who changed each line",
174
inputSchema={
175
"type": "object",
176
"properties": {
177
"slug": {"type": "string", "description": "Project slug"},
178
"filepath": {"type": "string", "description": "File path"},
179
},
180
"required": ["slug", "filepath"],
181
},
182
),
183
Tool(
184
name="get_file_history",
185
description="Get commit history for a specific file",
186
inputSchema={
187
"type": "object",
188
"properties": {
189
"slug": {"type": "string", "description": "Project slug"},
190
"filepath": {"type": "string", "description": "File path"},
191
"limit": {"type": "integer", "default": 25},
192
},
193
"required": ["slug", "filepath"],
194
},
195
),
196
Tool(
197
name="sql_query",
198
description="Run a read-only SQL query against the Fossil SQLite database. Only SELECT allowed.",
199
inputSchema={
200
"type": "object",
201
"properties": {
202
"slug": {"type": "string", "description": "Project slug"},
203
"sql": {"type": "string", "description": "SQL query (SELECT only)"},
204
},
205
"required": ["slug", "sql"],
206
},
207
),
208
]
209
210
211
def _isoformat(dt):
212
"""Safely format a datetime to ISO 8601, or None."""
213
if dt is None:
214
return None
215
return dt.isoformat()
216
217
218
def _get_repo(slug):
219
"""Look up project and its FossilRepository by slug.
220
221
Raises Project.DoesNotExist or FossilRepository.DoesNotExist on miss.
222
"""
223
from fossil.models import FossilRepository
224
from projects.models import Project
225
226
project = Project.objects.get(slug=slug, deleted_at__isnull=True)
227
repo = FossilRepository.objects.get(project=project, deleted_at__isnull=True)
228
return project, repo
229
230
231
def execute_tool(name: str, arguments: dict) -> dict:
232
"""Dispatch a tool call to the appropriate handler."""
233
handlers = {
234
"list_projects": _list_projects,
235
"get_project": _get_project,
236
"browse_code": _browse_code,
237
"read_file": _read_file,
238
"get_timeline": _get_timeline,
239
"get_checkin": _get_checkin,
240
"search_code": _search_code,
241
"list_tickets": _list_tickets,
242
"get_ticket": _get_ticket,
243
"create_ticket": _create_ticket,
244
"update_ticket": _update_ticket,
245
"list_wiki_pages": _list_wiki_pages,
246
"get_wiki_page": _get_wiki_page,
247
"list_branches": _list_branches,
248
"get_file_blame": _get_file_blame,
249
"get_file_history": _get_file_history,
250
"sql_query": _sql_query,
251
}
252
handler = handlers.get(name)
253
if not handler:
254
return {"error": f"Unknown tool: {name}"}
255
try:
256
return handler(arguments)
257
except Exception as e:
258
return {"error": str(e)}
259
260
261
# ---------------------------------------------------------------------------
262
# Read-only handlers (FossilReader)
263
# ---------------------------------------------------------------------------
264
265
266
def _list_projects(args):
267
from projects.models import Project
268
269
projects = Project.objects.filter(deleted_at__isnull=True)
270
return {
271
"projects": [
272
{
273
"name": p.name,
274
"slug": p.slug,
275
"description": p.description or "",
276
"visibility": p.visibility,
277
}
278
for p in projects
279
]
280
}
281
282
283
def _get_project(args):
284
from fossil.reader import FossilReader
285
286
project, repo = _get_repo(args["slug"])
287
result = {
288
"name": project.name,
289
"slug": project.slug,
290
"description": project.description or "",
291
"visibility": project.visibility,
292
"star_count": project.star_count,
293
"filename": repo.filename,
294
"file_size_bytes": repo.file_size_bytes,
295
"checkin_count": repo.checkin_count,
296
"last_checkin_at": _isoformat(repo.last_checkin_at),
297
}
298
if repo.exists_on_disk:
299
with FossilReader(repo.full_path) as reader:
300
meta = reader.get_metadata()
301
result["fossil_project_name"] = meta.project_name
302
result["fossil_checkin_count"] = meta.checkin_count
303
result["fossil_ticket_count"] = meta.ticket_count
304
result["fossil_wiki_page_count"] = meta.wiki_page_count
305
return result
306
307
308
def _browse_code(args):
309
from fossil.reader import FossilReader
310
311
project, repo = _get_repo(args["slug"])
312
path = args.get("path", "")
313
314
with FossilReader(repo.full_path) as reader:
315
checkin = reader.get_latest_checkin_uuid()
316
if not checkin:
317
return {"files": [], "error": "No checkins in repository"}
318
319
files = reader.get_files_at_checkin(checkin)
320
321
# Filter to requested directory
322
if path:
323
path = path.rstrip("/") + "/"
324
files = [f for f in files if f.name.startswith(path)]
325
326
return {
327
"checkin": checkin,
328
"path": path,
329
"files": [
330
{
331
"name": f.name,
332
"uuid": f.uuid,
333
"size": f.size,
334
"last_commit_message": f.last_commit_message,
335
"last_commit_user": f.last_commit_user,
336
"last_commit_time": _isoformat(f.last_commit_time),
337
}
338
for f in files
339
],
340
}
341
342
343
def _read_file(args):
344
from fossil.reader import FossilReader
345
346
project, repo = _get_repo(args["slug"])
347
348
with FossilReader(repo.full_path) as reader:
349
checkin = reader.get_latest_checkin_uuid()
350
if not checkin:
351
return {"error": "No checkins in repository"}
352
353
files = reader.get_files_at_checkin(checkin)
354
target = args["filepath"]
355
356
for f in files:
357
if f.name == target:
358
content = reader.get_file_content(f.uuid)
359
if isinstance(content, bytes):
360
try:
361
content = content.decode("utf-8")
362
except UnicodeDecodeError:
363
return {"filepath": target, "binary": True, "size": len(content)}
364
return {"filepath": target, "content": content}
365
366
return {"error": f"File not found: {target}"}
367
368
369
def _get_timeline(args):
370
from fossil.reader import FossilReader
371
372
project, repo = _get_repo(args["slug"])
373
limit = args.get("limit", 25)
374
branch_filter = args.get("branch", "")
375
376
with FossilReader(repo.full_path) as reader:
377
entries = reader.get_timeline(limit=limit, event_type="ci")
378
379
checkins = []
380
for e in entries:
381
entry = {
382
"uuid": e.uuid,
383
"timestamp": _isoformat(e.timestamp),
384
"user": e.user,
385
"comment": e.comment,
386
"branch": e.branch,
387
}
388
checkins.append(entry)
389
390
if branch_filter:
391
checkins = [c for c in checkins if c["branch"] == branch_filter]
392
393
return {"checkins": checkins, "total": len(checkins)}
394
395
396
def _get_checkin(args):
397
from fossil.reader import FossilReader
398
399
project, repo = _get_repo(args["slug"])
400
401
with FossilReader(repo.full_path) as reader:
402
detail = reader.get_checkin_detail(args["uuid"])
403
404
if detail is None:
405
return {"error": "Checkin not found"}
406
407
return {
408
"uuid": detail.uuid,
409
"timestamp": _isoformat(detail.timestamp),
410
"user": detail.user,
411
"comment": detail.comment,
412
"branch": detail.branch,
413
"parent_uuid": detail.parent_uuid,
414
"is_merge": detail.is_merge,
415
"files_changed": detail.files_changed,
416
}
417
418
419
def _search_code(args):
420
from fossil.reader import FossilReader
421
422
project, repo = _get_repo(args["slug"])
423
query = args["query"]
424
limit = args.get("limit", 25)
425
426
with FossilReader(repo.full_path) as reader:
427
results = reader.search(query, limit=limit)
428
429
# Serialize datetimes in results
430
for checkin in results.get("checkins", []):
431
checkin["timestamp"] = _isoformat(checkin.get("timestamp"))
432
for ticket in results.get("tickets", []):
433
ticket["created"] = _isoformat(ticket.get("created"))
434
435
return results
436
437
438
def _list_tickets(args):
439
from fossil.reader import FossilReader
440
441
project, repo = _get_repo(args["slug"])
442
status_filter = args.get("status", "") or None
443
limit = args.get("limit", 50)
444
445
with FossilReader(repo.full_path) as reader:
446
tickets = reader.get_tickets(status=status_filter, limit=limit)
447
448
return {
449
"tickets": [
450
{
451
"uuid": t.uuid,
452
"title": t.title,
453
"status": t.status,
454
"type": t.type,
455
"subsystem": t.subsystem,
456
"priority": t.priority,
457
"created": _isoformat(t.created),
458
}
459
for t in tickets
460
],
461
"total": len(tickets),
462
}
463
464
465
def _get_ticket(args):
466
from fossil.reader import FossilReader
467
468
project, repo = _get_repo(args["slug"])
469
470
with FossilReader(repo.full_path) as reader:
471
ticket = reader.get_ticket_detail(args["uuid"])
472
if ticket is None:
473
return {"error": "Ticket not found"}
474
comments = reader.get_ticket_comments(args["uuid"])
475
476
return {
477
"uuid": ticket.uuid,
478
"title": ticket.title,
479
"status": ticket.status,
480
"type": ticket.type,
481
"subsystem": ticket.subsystem,
482
"priority": ticket.priority,
483
"severity": ticket.severity,
484
"resolution": ticket.resolution,
485
"body": ticket.body,
486
"created": _isoformat(ticket.created),
487
"comments": [
488
{
489
"timestamp": _isoformat(c.get("timestamp")),
490
"user": c.get("user", ""),
491
"comment": c.get("comment", ""),
492
"mimetype": c.get("mimetype", "text/plain"),
493
}
494
for c in comments
495
],
496
}
497
498
499
# ---------------------------------------------------------------------------
500
# Write handlers (FossilCLI)
501
# ---------------------------------------------------------------------------
502
503
504
def _create_ticket(args):
505
from fossil.cli import FossilCLI
506
507
project, repo = _get_repo(args["slug"])
508
509
cli = FossilCLI()
510
cli.ensure_default_user(repo.full_path)
511
512
fields = {
513
"title": args["title"],
514
"comment": args["body"],
515
"type": args.get("type", "Code_Defect"),
516
"severity": args.get("severity", "Important"),
517
"priority": args.get("priority", "Medium"),
518
"status": "Open",
519
}
520
521
success = cli.ticket_add(repo.full_path, fields)
522
if not success:
523
return {"error": "Failed to create ticket"}
524
525
return {"success": True, "title": args["title"]}
526
527
528
def _update_ticket(args):
529
from fossil.cli import FossilCLI
530
531
project, repo = _get_repo(args["slug"])
532
533
cli = FossilCLI()
534
cli.ensure_default_user(repo.full_path)
535
536
fields = {}
537
if args.get("status"):
538
fields["status"] = args["status"]
539
if args.get("comment"):
540
fields["icomment"] = args["comment"]
541
542
if not fields:
543
return {"error": "No fields to update (provide status or comment)"}
544
545
success = cli.ticket_change(repo.full_path, args["uuid"], fields)
546
if not success:
547
return {"error": "Failed to update ticket"}
548
549
return {"success": True, "uuid": args["uuid"]}
550
551
552
# ---------------------------------------------------------------------------
553
# Wiki handlers (FossilReader for reads)
554
# ---------------------------------------------------------------------------
555
556
557
def _list_wiki_pages(args):
558
from fossil.reader import FossilReader
559
560
project, repo = _get_repo(args["slug"])
561
562
with FossilReader(repo.full_path) as reader:
563
pages = reader.get_wiki_pages()
564
565
return {
566
"pages": [
567
{
568
"name": p.name,
569
"last_modified": _isoformat(p.last_modified),
570
"user": p.user,
571
}
572
for p in pages
573
]
574
}
575
576
577
def _get_wiki_page(args):
578
from fossil.reader import FossilReader
579
580
project, repo = _get_repo(args["slug"])
581
582
with FossilReader(repo.full_path) as reader:
583
page = reader.get_wiki_page(args["page_name"])
584
585
if page is None:
586
return {"error": f"Wiki page not found: {args['page_name']}"}
587
588
return {
589
"name": page.name,
590
"content": page.content,
591
"last_modified": _isoformat(page.last_modified),
592
"user": page.user,
593
}
594
595
596
# ---------------------------------------------------------------------------
597
# Branch and file history handlers
598
# ---------------------------------------------------------------------------
599
600
601
def _list_branches(args):
602
from fossil.reader import FossilReader
603
604
project, repo = _get_repo(args["slug"])
605
606
with FossilReader(repo.full_path) as reader:
607
branches = reader.get_branches()
608
609
return {
610
"branches": [
611
{
612
"name": b["name"],
613
"last_checkin": _isoformat(b["last_checkin"]),
614
"last_user": b["last_user"],
615
"checkin_count": b["checkin_count"],
616
"last_uuid": b["last_uuid"],
617
}
618
for b in branches
619
]
620
}
621
622
623
def _get_file_blame(args):
624
from fossil.cli import FossilCLI
625
626
project, repo = _get_repo(args["slug"])
627
628
cli = FossilCLI()
629
lines = cli.blame(repo.full_path, args["filepath"])
630
return {"filepath": args["filepath"], "lines": lines, "total": len(lines)}
631
632
633
def _get_file_history(args):
634
from fossil.reader import FossilReader
635
636
project, repo = _get_repo(args["slug"])
637
limit = args.get("limit", 25)
638
639
with FossilReader(repo.full_path) as reader:
640
history = reader.get_file_history(args["filepath"], limit=limit)
641
642
for entry in history:
643
entry["timestamp"] = _isoformat(entry.get("timestamp"))
644
645
return {"filepath": args["filepath"], "history": history, "total": len(history)}
646
647
648
# ---------------------------------------------------------------------------
649
# SQL query handler
650
# ---------------------------------------------------------------------------
651
652
653
def _sql_query(args):
654
from fossil.reader import FossilReader
655
from fossil.ticket_reports import TicketReport
656
657
sql = args["sql"]
658
error = TicketReport.validate_sql(sql)
659
if error:
660
return {"error": error}
661
662
project, repo = _get_repo(args["slug"])
663
664
with FossilReader(repo.full_path) as reader:
665
cursor = reader.conn.cursor()
666
cursor.execute(sql)
667
columns = [desc[0] for desc in cursor.description] if cursor.description else []
668
rows = cursor.fetchmany(500)
669
return {
670
"columns": columns,
671
"rows": [list(row) for row in rows],
672
"count": len(rows),
673
}
674

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button