#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import re import subprocess import sys import time from pathlib import Path from typing import Any DG_ROOT = Path(__file__).resolve().parents[1] DG_AGENT = DG_ROOT / "scripts" / "dg_agent.sh" NOTE_ROOT = DG_ROOT / "runlogs" / "dg-agent-notes" AGENT_RUN_ROOT = DG_ROOT / "runlogs" / "dg-agent-runs" AGENT_RUN_ARTIFACT_FILENAMES = { "agent_json": "agent.json", "transcript": "tool-loop.json", "stdout": "stdout.log", "stderr": "stderr.log", } AGENT_RUN_ARTIFACT_ALIASES = { "agent": "agent_json", "json": "agent_json", "report": "agent_json", "tool_loop": "transcript", "tool-loop": "transcript", "tool-loop.json": "transcript", "stdout.log": "stdout", "stderr.log": "stderr", } WINDOWS_DRIVE_PATH = re.compile(r"^([A-Za-z]):[\\\\/](.*)$") def normalize_host_path(value: str) -> str: """Map Windows client paths to WSL mounts when this server runs in Linux.""" raw = str(value or "").strip() if os.name == "nt": return raw match = WINDOWS_DRIVE_PATH.match(raw) if not match: return raw drive, tail = match.groups() return f"/mnt/{drive.lower()}/{tail.replace(chr(92), '/')}" def log(message: str) -> None: print(f"[dg-mcp] {message}", file=sys.stderr, flush=True) def rpc_response(request_id: Any, result: Any) -> dict[str, Any]: return {"jsonrpc": "2.0", "id": request_id, "result": result} def rpc_error(request_id: Any, code: int, message: str, data: Any | None = None) -> dict[str, Any]: error: dict[str, Any] = {"code": code, "message": message} if data is not None: error["data"] = data return {"jsonrpc": "2.0", "id": request_id, "error": error} def emit(message: dict[str, Any]) -> None: sys.stdout.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n") sys.stdout.flush() def text_result(text: str, *, is_error: bool = False, structured: dict[str, Any] | None = None) -> dict[str, Any]: result: dict[str, Any] = {"content": [{"type": "text", "text": text}], "isError": is_error} if structured is not None: result["structuredContent"] = structured return result def arg_str(args: dict[str, Any], name: str, default: str = "") -> str: value = args.get(name, default) if value is None: return default return str(value) def arg_int(args: dict[str, Any], name: str, default: int, *, minimum: int = 1, maximum: int = 3600) -> int: try: value = int(args.get(name, default)) except Exception: value = default return max(minimum, min(maximum, value)) def arg_bool(args: dict[str, Any], name: str, default: bool = False) -> bool: value = args.get(name, default) if isinstance(value, bool): return value if isinstance(value, str): return value.lower() in {"1", "true", "yes", "on"} return bool(value) def arg_files(args: dict[str, Any]) -> list[str]: value = args.get("files", args.get("file", [])) if isinstance(value, str): return [value] if value else [] if isinstance(value, list): return [str(item) for item in value if str(item)] return [] def arg_list(args: dict[str, Any], name: str) -> list[str]: value = args.get(name, []) if isinstance(value, str): return [value] if value else [] if isinstance(value, list): return [str(item) for item in value if str(item)] return [] def run_cmd(command: list[str], *, cwd: Path | None = None, timeout: int = 120) -> dict[str, Any]: started = time.time() try: proc = subprocess.run( command, cwd=str(cwd or DG_ROOT), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, check=False, ) return { "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr, "elapsed_sec": round(time.time() - started, 3), "timed_out": False, } except subprocess.TimeoutExpired as exc: stdout = exc.stdout.decode("utf-8", errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "") stderr = exc.stderr.decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "") return { "returncode": 124, "stdout": stdout, "stderr": stderr + f"\nTimed out after {timeout}s", "elapsed_sec": round(time.time() - started, 3), "timed_out": True, } def tail(text: str, limit: int = 8000) -> str: return text[-limit:] if len(text) > limit else text def head(text: str, limit: int = 8000) -> str: return text[:limit] if len(text) > limit else text def command_report(command: list[str], result: dict[str, Any]) -> str: output = (result.get("stdout", "") + ("\n" + result.get("stderr", "") if result.get("stderr") else "")).strip() return ( f"command: {' '.join(command)}\n" f"returncode: {result['returncode']}\n" f"elapsed_sec: {result['elapsed_sec']}\n\n" f"{tail(output)}" ).rstrip() def repo_path(repo: str) -> Path: path = Path(normalize_host_path(repo)).expanduser().resolve() if not path.exists() or not path.is_dir(): raise ValueError(f"repo does not exist: {path}") return path def safe_repo_file(repo: Path, file_name: str) -> Path: if not file_name: raise ValueError("missing file path") raw = Path(file_name).expanduser() target = raw.resolve() if raw.is_absolute() else (repo / raw).resolve() if target != repo and repo not in target.parents: raise ValueError(f"path escapes repo: {file_name}") if not target.exists() or not target.is_file(): raise ValueError(f"file does not exist: {target}") return target def slugify(text: str, default: str = "note") -> str: slug = re.sub(r"[^A-Za-z0-9._-]+", "-", text.strip().lower()).strip("-._") return (slug or default)[:80] def note_paths() -> list[Path]: if not NOTE_ROOT.exists(): return [] return sorted((path for path in NOTE_ROOT.glob("*.md") if path.is_file()), key=lambda path: path.stat().st_mtime, reverse=True) def safe_note_path(note: str = "", latest: bool = False) -> Path | None: paths = note_paths() if latest: return paths[0] if paths else None if not note: return None raw = Path(note) candidate = raw.resolve() if raw.is_absolute() else (NOTE_ROOT / raw.name).resolve() root = NOTE_ROOT.resolve() if candidate != root and root not in candidate.parents: raise ValueError(f"note path escapes note root: {note}") if candidate.exists() and candidate.is_file(): return candidate stem = raw.stem or raw.name for path in paths: if path.name == note or path.stem == stem or path.name.startswith(stem): return path return None def note_summary(path: Path) -> dict[str, Any]: text = path.read_text(encoding="utf-8", errors="replace") title = path.stem for line in text.splitlines(): if line.startswith("# "): title = line[2:].strip() break return { "id": path.stem, "path": str(path), "title": title, "mtime": path.stat().st_mtime, "size": path.stat().st_size, } def tool_task_note(args: dict[str, Any]) -> dict[str, Any]: task = arg_str(args, "task", "") body = arg_str(args, "body", "") if not task: return text_result("Missing required argument: task", is_error=True) if not body: return text_result("Missing required argument: body", is_error=True) repo = arg_str(args, "repo", "") title = arg_str(args, "title", task) tags = arg_list(args, "tags") append_to = arg_str(args, "append_to", "") NOTE_ROOT.mkdir(parents=True, exist_ok=True) existing = safe_note_path(append_to) if append_to else None timestamp = time.strftime("%Y-%m-%d %H:%M:%S %z") if existing: addition = f"\n\n## Update {timestamp}\n\n{body.rstrip()}\n" existing.write_text(existing.read_text(encoding="utf-8", errors="replace").rstrip() + addition, encoding="utf-8") path = existing status = "updated" else: filename = f"{time.strftime('%Y%m%d-%H%M%S')}-{slugify(title)}.md" path = NOTE_ROOT / filename metadata = [ f"# {title}", "", f"- created: {timestamp}", f"- task: {task}", ] if repo: metadata.append(f"- repo: {repo}") if tags: metadata.append(f"- tags: {', '.join(tags)}") path.write_text("\n".join(metadata) + "\n\n" + body.rstrip() + "\n", encoding="utf-8") status = "written" structured = {"returncode": 0, "stdout": str(path), "stderr": "", "elapsed_sec": 0, "timed_out": False, "status": status, "note": note_summary(path)} return text_result(f"{status}: {path}", structured=structured) def tool_task_notes(args: dict[str, Any]) -> dict[str, Any]: limit = arg_int(args, "limit", 20, maximum=200) max_chars = arg_int(args, "max_chars", 20000, maximum=200000) note = arg_str(args, "note", "") latest = arg_bool(args, "latest", False) selected = safe_note_path(note, latest=latest) if selected: text = head(selected.read_text(encoding="utf-8", errors="replace"), max_chars) structured = { "returncode": 0, "stdout": text, "stderr": "", "elapsed_sec": 0, "timed_out": False, "note": note_summary(selected), "truncated": selected.stat().st_size > max_chars, } return text_result(text, structured=structured) if note or latest: return text_result("No matching task note found.", is_error=True, structured={"returncode": 2, "stdout": "", "stderr": "not found", "elapsed_sec": 0, "timed_out": False}) notes = [note_summary(path) for path in note_paths()[:limit]] text = json.dumps({"notes": notes, "root": str(NOTE_ROOT)}, ensure_ascii=False, indent=2) structured = {"returncode": 0, "stdout": text, "stderr": "", "elapsed_sec": 0, "timed_out": False, "notes": notes, "root": str(NOTE_ROOT)} return text_result(text, structured=structured) def tool_repo_status(args: dict[str, Any]) -> dict[str, Any]: repo = repo_path(arg_str(args, "repo", ".")) timeout = arg_int(args, "timeout", 30, maximum=180) command = ["git", "status", "--short"] result = run_cmd(command, cwd=repo, timeout=timeout) diff_stat = run_cmd(["git", "diff", "--stat"], cwd=repo, timeout=timeout) untracked = run_cmd(["git", "ls-files", "--others", "--exclude-standard"], cwd=repo, timeout=timeout) structured = { **result, "repo": str(repo), "diff_stat": diff_stat.get("stdout", ""), "untracked": [line for line in untracked.get("stdout", "").splitlines() if line], } text = command_report(command, result) if diff_stat.get("stdout"): text += "\n\nDiff stat:\n" + diff_stat["stdout"].strip() if untracked.get("stdout"): text += "\n\nUntracked files:\n" + head(untracked["stdout"], arg_int(args, "max_chars", 8000, maximum=100000)).strip() return text_result(text, is_error=result["returncode"] != 0, structured=structured) def tool_list_files(args: dict[str, Any]) -> dict[str, Any]: repo = repo_path(arg_str(args, "repo", ".")) timeout = arg_int(args, "timeout", 30, maximum=180) limit = arg_int(args, "limit", 200, maximum=5000) pattern = arg_str(args, "pattern", "") command = ["rg", "--files"] for glob in arg_list(args, "globs"): command.extend(["-g", glob]) result = run_cmd(command, cwd=repo, timeout=timeout) if result["returncode"] != 0: command = ["git", "ls-files"] result = run_cmd(command, cwd=repo, timeout=timeout) files = [line for line in result.get("stdout", "").splitlines() if line] if pattern: files = [file for file in files if pattern.lower() in file.lower()] visible = files[:limit] structured = {**result, "repo": str(repo), "files": visible, "total_matches": len(files), "truncated": len(files) > limit} text = "\n".join(visible) if len(files) > limit: text += f"\n... truncated {len(files) - limit} more files" return text_result(text, is_error=result["returncode"] != 0, structured=structured) def tool_search(args: dict[str, Any]) -> dict[str, Any]: repo = repo_path(arg_str(args, "repo", ".")) query = arg_str(args, "query", "") if not query: return text_result("Missing required argument: query", is_error=True) timeout = arg_int(args, "timeout", 30, maximum=180) max_matches = arg_int(args, "max_matches", 80, maximum=1000) max_chars = arg_int(args, "max_chars", 12000, maximum=200000) command = ["rg", "--line-number", "--column", "--color", "never"] context = arg_int(args, "context", 0, minimum=0, maximum=5) if context: command.extend(["--context", str(context)]) for glob in arg_list(args, "globs"): command.extend(["-g", glob]) command.extend(["--", query, "."]) result = run_cmd(command, cwd=repo, timeout=timeout) lines = result.get("stdout", "").splitlines() visible = lines[:max_matches] text = head("\n".join(visible), max_chars) if len(lines) > max_matches: text += f"\n... truncated {len(lines) - max_matches} more matching lines" structured = { **result, "repo": str(repo), "query": query, "matches": visible, "total_lines": len(lines), "truncated": len(lines) > max_matches or len("\n".join(visible)) > max_chars, } return text_result(text, is_error=result["returncode"] not in {0, 1}, structured=structured) def tool_read_file(args: dict[str, Any]) -> dict[str, Any]: repo = repo_path(arg_str(args, "repo", ".")) target = safe_repo_file(repo, arg_str(args, "path", "")) start_line = arg_int(args, "start_line", 1, minimum=1, maximum=10_000_000) max_lines = arg_int(args, "max_lines", 160, maximum=5000) max_chars = arg_int(args, "max_chars", 20000, maximum=200000) text = target.read_text(encoding="utf-8", errors="replace") lines = text.splitlines() selected = lines[start_line - 1 : start_line - 1 + max_lines] numbered = "\n".join(f"{idx}: {line}" for idx, line in enumerate(selected, start=start_line)) numbered = head(numbered, max_chars) rel = target.relative_to(repo) structured = { "returncode": 0, "stdout": numbered, "stderr": "", "elapsed_sec": 0, "timed_out": False, "repo": str(repo), "path": str(rel), "line_count": len(lines), "start_line": start_line, "returned_lines": len(selected), "truncated": len(selected) < len(lines[start_line - 1 :]) or len(numbered) >= max_chars, } return text_result(numbered, structured=structured) def tool_git_diff(args: dict[str, Any]) -> dict[str, Any]: repo = repo_path(arg_str(args, "repo", ".")) timeout = arg_int(args, "timeout", 30, maximum=180) max_chars = arg_int(args, "max_chars", 20000, maximum=200000) command = ["git", "diff"] if arg_bool(args, "cached", False): command.append("--cached") if arg_bool(args, "stat", False): command.append("--stat") for file_name in arg_files(args): command.extend(["--", file_name]) break result = run_cmd(command, cwd=repo, timeout=timeout) output = head(result.get("stdout", ""), max_chars) structured = { **result, "repo": str(repo), "stdout": output, "truncated": len(result.get("stdout", "")) > max_chars, } return text_result(command_report(command, structured), is_error=result["returncode"] != 0, structured=structured) def tool_status(args: dict[str, Any]) -> dict[str, Any]: timeout = arg_int(args, "timeout", 30, maximum=180) command = [str(DG_AGENT), "status"] result = run_cmd(command, timeout=timeout) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) def tool_context(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") task = arg_str(args, "task", "") if not task: return text_result("Missing required argument: task", is_error=True) timeout = arg_int(args, "timeout", 120, maximum=600) command = [ str(DG_AGENT), "context", "--repo", repo, "--task", task, "--max-files", str(arg_int(args, "max_files", 3, maximum=20)), "--max-snippet-chars", str(arg_int(args, "max_snippet_chars", 1200, maximum=20000)), ] for file_name in arg_files(args): command.extend(["--file", file_name]) result = run_cmd(command, timeout=timeout) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) def rag_command(args: dict[str, Any], *, print_context: bool) -> tuple[list[str], int]: repo = arg_str(args, "repo", ".") task = arg_str(args, "task", "") if not task: return [], 0 timeout = arg_int(args, "timeout", 120 if print_context else 300, maximum=1800) command = [ str(DG_AGENT), "rag", "--repo", repo, "--task", task, "--max-context-chars", str(arg_int(args, "max_context_chars", 900 if print_context else 650, maximum=20000)), "--max-files", str(arg_int(args, "max_files", 3, maximum=20)), "--max-tokens", str(arg_int(args, "max_tokens", 128, maximum=1024)), "--timeout", str(timeout), ] base_url = arg_str(args, "base_url", "") if base_url: command.extend(["--base-url", base_url]) model = arg_str(args, "model", "") if model: command.extend(["--model", model]) if print_context: command.append("--print-context") if arg_bool(args, "debug", False): command.append("--debug") return command, timeout def tool_rag_context(args: dict[str, Any]) -> dict[str, Any]: if not arg_str(args, "task", ""): return text_result("Missing required argument: task", is_error=True) command, timeout = rag_command(args, print_context=True) result = run_cmd(command, timeout=timeout) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) def tool_rag_answer(args: dict[str, Any]) -> dict[str, Any]: if not arg_str(args, "task", ""): return text_result("Missing required argument: task", is_error=True) command, timeout = rag_command(args, print_context=False) result = run_cmd(command, timeout=timeout + 30) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) def tool_repo_pack(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") timeout = arg_int(args, "timeout", 180, maximum=1800) max_chars = arg_int(args, "max_chars", 20000, minimum=1000, maximum=200000) command = [ str(DG_AGENT), "repo-pack", "--repo", repo, "--style", arg_str(args, "style", "markdown"), "--stdout", "--top-files-len", str(arg_int(args, "top_files_len", 5, maximum=50)), ] for pattern in arg_list(args, "include"): command.extend(["--include", pattern]) for pattern in arg_list(args, "ignore"): command.extend(["--ignore", pattern]) if arg_bool(args, "compress", False): command.append("--compress") if arg_bool(args, "include_diffs", False): command.append("--include-diffs") if arg_bool(args, "output_show_line_numbers", False): command.append("--output-show-line-numbers") if arg_bool(args, "remove_comments", False): command.append("--remove-comments") if arg_bool(args, "remove_empty_lines", False): command.append("--remove-empty-lines") if arg_bool(args, "no_files", False): command.append("--no-files") if arg_bool(args, "no_security_check", False): command.append("--no-security-check") token_budget = arg_int(args, "token_budget", 0, minimum=0, maximum=2_000_000) if token_budget: command.extend(["--token-budget", str(token_budget)]) result = run_cmd(command, timeout=timeout) stdout = result.get("stdout", "") stderr = result.get("stderr", "") truncated = len(stdout) > max_chars body = stdout[:max_chars] + ("\n\n[truncated]\n" if truncated else "") report = ( f"command: {' '.join(command)}\n" f"returncode: {result['returncode']}\n" f"elapsed_sec: {result['elapsed_sec']}\n" f"stdout_chars: {len(stdout)}\n" f"truncated: {truncated}\n\n" f"{body}" ).rstrip() if stderr.strip(): report += "\n\nstderr:\n" + tail(stderr, 4000) structured = {**result, "stdout_chars": len(stdout), "truncated": truncated, "max_chars": max_chars} return text_result(report, is_error=result["returncode"] != 0, structured=structured) def tool_repo_map(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") timeout = arg_int(args, "timeout", 180, maximum=1800) max_chars = arg_int(args, "max_chars", 20000, minimum=1000, maximum=200000) command = [ str(DG_AGENT), "repo-map", "--repo", repo, "--map-tokens", str(arg_int(args, "map_tokens", 512, minimum=128, maximum=64000)), "--max-chars", str(max_chars), "--timeout", str(timeout), ] if arg_bool(args, "map_only", True): command.append("--map-only") base_url = arg_str(args, "base_url", "") if base_url: command.extend(["--base-url", base_url]) model = arg_str(args, "model", "") if model: command.extend(["--model", model]) for path in arg_list(args, "paths"): command.append(path) result = run_cmd(command, timeout=timeout + 30) stdout = result.get("stdout", "") stderr = result.get("stderr", "") truncated = len(stdout) > max_chars report = ( f"command: {' '.join(command)}\n" f"returncode: {result['returncode']}\n" f"elapsed_sec: {result['elapsed_sec']}\n" f"stdout_chars: {len(stdout)}\n" f"truncated: {truncated}\n\n" f"{stdout[:max_chars]}" ).rstrip() if truncated: report += "\n\n[truncated]" if stderr.strip(): report += "\n\nstderr:\n" + tail(stderr, 4000) structured = {**result, "stdout_chars": len(stdout), "truncated": truncated, "max_chars": max_chars} return text_result(report, is_error=result["returncode"] != 0, structured=structured) def tool_ast_grep(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") pattern = arg_str(args, "pattern", "") kind = arg_str(args, "kind", "") selector = arg_str(args, "selector", "") if not any([pattern, kind, selector]): return text_result("Missing required argument: pattern, kind, or selector", is_error=True) timeout = arg_int(args, "timeout", 120, maximum=1800) max_chars = arg_int(args, "max_chars", 20000, minimum=1000, maximum=200000) command = [ str(DG_AGENT), "ast-grep", "--repo", repo, "--max-matches", str(arg_int(args, "max_matches", 80, maximum=1000)), "--max-chars", str(max_chars), "--timeout", str(timeout), ] lang = arg_str(args, "lang", "") if lang: command.extend(["--lang", lang]) if pattern: command.extend(["--pattern", pattern]) if kind: command.extend(["--kind", kind]) if selector: command.extend(["--selector", selector]) strictness = arg_str(args, "strictness", "") if strictness: command.extend(["--strictness", strictness]) context = arg_int(args, "context", 0, minimum=0, maximum=20) if context: command.extend(["--context", str(context)]) for glob in arg_list(args, "globs"): command.extend(["--glob", glob]) if arg_bool(args, "files_with_matches", False): command.append("--files-with-matches") elif arg_bool(args, "json", True): command.append("--json") paths = arg_list(args, "paths") command.extend(paths) result = run_cmd(command, timeout=timeout + 30) stdout = result.get("stdout", "") stderr = result.get("stderr", "") truncated = len(stdout) > max_chars report = ( f"command: {' '.join(command)}\n" f"returncode: {result['returncode']}\n" f"elapsed_sec: {result['elapsed_sec']}\n" f"stdout_chars: {len(stdout)}\n" f"truncated: {truncated}\n\n" f"{stdout[:max_chars]}" ).rstrip() if truncated: report += "\n\n[truncated]" if stderr.strip(): report += "\n\nstderr:\n" + tail(stderr, 4000) structured = {**result, "stdout_chars": len(stdout), "truncated": truncated, "max_chars": max_chars} return text_result(report, is_error=result["returncode"] != 0, structured=structured) def tool_code_outline(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") timeout = arg_int(args, "timeout", 120, maximum=1800) max_chars = arg_int(args, "max_chars", 20000, minimum=1000, maximum=200000) command = [ str(DG_AGENT), "code-outline", "--repo", repo, "--items", arg_str(args, "items", "auto"), "--view", arg_str(args, "view", "auto"), "--max-items", str(arg_int(args, "max_items", 200, maximum=5000)), "--max-chars", str(max_chars), "--timeout", str(timeout), ] lang = arg_str(args, "lang", "") if lang: command.extend(["--lang", lang]) symbol_type = arg_str(args, "type", "") if symbol_type: command.extend(["--type", symbol_type]) match = arg_str(args, "match", "") if match: command.extend(["--match", match]) if arg_bool(args, "pub_members", False): command.append("--pub-members") for glob in arg_list(args, "globs"): command.extend(["--glob", glob]) if arg_bool(args, "json", True): command.append("--json") paths = arg_list(args, "paths") command.extend(paths) result = run_cmd(command, timeout=timeout + 30) stdout = result.get("stdout", "") stderr = result.get("stderr", "") truncated = len(stdout) > max_chars report = ( f"command: {' '.join(command)}\n" f"returncode: {result['returncode']}\n" f"elapsed_sec: {result['elapsed_sec']}\n" f"stdout_chars: {len(stdout)}\n" f"truncated: {truncated}\n\n" f"{stdout[:max_chars]}" ).rstrip() if truncated: report += "\n\n[truncated]" if stderr.strip(): report += "\n\nstderr:\n" + tail(stderr, 4000) structured = {**result, "stdout_chars": len(stdout), "truncated": truncated, "max_chars": max_chars} return text_result(report, is_error=result["returncode"] != 0, structured=structured) def repo_output_path(repo: str, value: str) -> str: if not value: return "" raw = Path(value).expanduser() if raw.is_absolute(): return str(raw.resolve()) return str((repo_path(repo) / raw).resolve()) def tool_preflight(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") timeout = arg_int(args, "timeout", 120, maximum=600) command = [str(DG_AGENT), "preflight", "--repo", repo, "--json"] task = arg_str(args, "task", "") if task: command.extend(["--task", task]) for file_name in arg_files(args): command.extend(["--file", file_name]) if arg_bool(args, "allow_dirty", False): command.append("--allow-dirty") result = run_cmd(command, timeout=timeout) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) def tool_plan(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") task = arg_str(args, "task", "") if not task: return text_result("Missing required argument: task", is_error=True) timeout = arg_int(args, "timeout", 120, maximum=1800) command = [ str(DG_AGENT), "plan", "--repo", repo, "--task", task, "--name", arg_str(args, "name", "edit"), "--max-files", str(arg_int(args, "max_files", 1, maximum=20)), "--max-snippet-chars", str(arg_int(args, "max_snippet_chars", 1200, maximum=20000)), "--test-timeout", str(arg_int(args, "test_timeout", 120, maximum=3600)), "--aider-timeout", str(arg_int(args, "aider_timeout", 420, maximum=7200)), "--repair-attempts", str(arg_int(args, "repair_attempts", 1, maximum=5)), ] for file_name in arg_files(args): command.extend(["--file", file_name]) out = arg_str(args, "out", "") if out: command.extend(["--out", repo_output_path(repo, out)]) test_cmd = arg_str(args, "test_cmd", "") if test_cmd: command.extend(["--test-cmd", test_cmd]) if arg_bool(args, "auto_test", False): command.append("--auto-test") if arg_bool(args, "no_deterministic_first", False): command.append("--no-deterministic-first") result = run_cmd(command, timeout=timeout) structured = dict(result) if result["returncode"] == 0 and out: structured["plan_path"] = result.get("stdout", "").strip().splitlines()[-1] if result.get("stdout", "").strip() else repo_output_path(repo, out) elif result["returncode"] == 0: try: structured["plan"] = json.loads(result.get("stdout", "")) except Exception: pass return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=structured) def tool_task(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") plan = arg_str(args, "plan", "") if not plan: return text_result("Missing required argument: plan", is_error=True) timeout = arg_int(args, "timeout", 900, maximum=7200) command = [str(DG_AGENT), "task", "--repo", repo, "--plan", repo_output_path(repo, plan)] report = arg_str(args, "report", "") if report: command.extend(["--report", repo_output_path(repo, report)]) supervisor = arg_str(args, "supervisor", "") if supervisor: command.extend(["--supervisor", repo_output_path(repo, supervisor)]) step_report_dir = arg_str(args, "step_report_dir", "") if step_report_dir: command.extend(["--step-report-dir", repo_output_path(repo, step_report_dir)]) if arg_bool(args, "allow_dirty", False): command.append("--allow-dirty") if arg_bool(args, "dry_run", False): command.append("--dry-run") if arg_bool(args, "rollback_on_failure", True): command.append("--rollback-on-failure") if arg_bool(args, "continue_on_failure", False): command.append("--continue-on-failure") result = run_cmd(command, timeout=timeout + 60) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) def tool_verify(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") timeout = arg_int(args, "timeout", 120, maximum=1800) command = [str(DG_AGENT), "verify", "--repo", repo, "--timeout", str(timeout), "--json"] test_cmd = arg_str(args, "test_cmd", "") if test_cmd: command.extend(["--test-cmd", test_cmd]) for file_name in arg_files(args): command.extend(["--file", file_name]) result = run_cmd(command, timeout=timeout + 30) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) def session_artifacts(stdout: str) -> dict[str, str]: artifacts: dict[str, str] = {} patterns = { "session_dir": r"(?m)^Session dir:\s*(.+)$", "context": r"(?m)^Context:\s*(.+)$", "plan": r"(?m)^Plan:\s*(.+)$", "session_report": r"(?m)^Session report:\s*(.+)$", "aggregate_report": r"(?m)^Aggregate report:\s*(.+)$", } for key, pattern in patterns.items(): match = re.search(pattern, stdout) if match: artifacts[key] = match.group(1).strip() return artifacts def tool_session(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") task = arg_str(args, "task", "") if not task: return text_result("Missing required argument: task", is_error=True) wall_timeout = arg_int(args, "wall_timeout", 900, maximum=7200) command = [ str(DG_AGENT), "session", "--repo", repo, "--task", task, "--max-files", str(arg_int(args, "max_files", 1, maximum=20)), "--max-snippet-chars", str(arg_int(args, "max_snippet_chars", 1200, maximum=20000)), "--test-timeout", str(arg_int(args, "test_timeout", 120, maximum=3600)), "--aider-timeout", str(arg_int(args, "aider_timeout", 420, maximum=7200)), "--repair-attempts", str(arg_int(args, "repair_attempts", 1, maximum=5)), "--wall-timeout", str(wall_timeout), "--rollback-on-failure", ] for file_name in arg_files(args): command.extend(["--file", file_name]) test_cmd = arg_str(args, "test_cmd", "") if test_cmd: command.extend(["--test-cmd", test_cmd]) if arg_bool(args, "auto_test", False): command.append("--auto-test") if arg_bool(args, "allow_dirty", False): command.append("--allow-dirty") if arg_bool(args, "dry_run", False): command.append("--dry-run") if arg_bool(args, "no_verify_after", False): command.append("--no-verify-after") result = run_cmd(command, timeout=wall_timeout + 60) artifacts = session_artifacts(result.get("stdout", "")) structured = {**result, "artifacts": artifacts} report = command_report(command, result) if artifacts: report += "\n\nartifacts:\n" + "\n".join(f"- {key}: {value}" for key, value in artifacts.items()) return text_result(report, is_error=result["returncode"] != 0, structured=structured) def tool_capabilities(args: dict[str, Any]) -> dict[str, Any]: timeout = arg_int(args, "timeout", 180, maximum=1800) command = [str(DG_AGENT), "capabilities", "--latest", "--json"] if arg_bool(args, "run_live", False): command = [str(DG_AGENT), "capabilities", "--live", "--json", "--timeout", str(timeout)] result = run_cmd(command, timeout=timeout + 60) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) def tool_client_smoke(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") client = arg_str(args, "client", "cursor") timeout = arg_int(args, "timeout", 180, maximum=1800) command = [str(DG_AGENT), "client-smoke", "--repo", repo, "--client", client, "--json"] target = arg_str(args, "target", "") if target: command.extend(["--target", target]) if arg_bool(args, "force_init", False): command.append("--force-init") if arg_bool(args, "no_init", False): command.append("--no-init") if arg_bool(args, "no_rules", False): command.append("--no-rules") if arg_bool(args, "no_oss_stack", False): command.append("--no-oss-stack") if arg_bool(args, "live", False): command.append("--live") result = run_cmd(command, timeout=timeout + 60) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) def tool_client_report(args: dict[str, Any]) -> dict[str, Any]: repo = arg_str(args, "repo", ".") client = arg_str(args, "client", "cursor") timeout = arg_int(args, "timeout", 240, maximum=1800) command = [str(DG_AGENT), "client-report", "--repo", repo, "--client", client, "--json"] target = arg_str(args, "target", "") if target: command.extend(["--target", target]) if arg_bool(args, "force_init", False): command.append("--force-init") if arg_bool(args, "no_init", False): command.append("--no-init") if arg_bool(args, "no_rules", False): command.append("--no-rules") if arg_bool(args, "no_oss_stack", False): command.append("--no-oss-stack") if arg_bool(args, "live", False): command.append("--live") if arg_bool(args, "no_write", False): command.append("--no-write") result = run_cmd(command, timeout=timeout + 60) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) def agent_run_reports(root: Path = AGENT_RUN_ROOT) -> list[dict[str, Any]]: reports: list[dict[str, Any]] = [] if not root.exists(): return reports for path in root.rglob("agent.json"): try: data = json.loads(path.read_text(encoding="utf-8")) data["_agent_json"] = str(path) data["_mtime"] = path.stat().st_mtime reports.append(data) except Exception: continue reports.sort(key=lambda item: float(item.get("_mtime", 0)), reverse=True) return reports def agent_run_summary(report: dict[str, Any]) -> dict[str, Any]: artifacts = report.get("artifacts") if isinstance(report.get("artifacts"), dict) else {} transcript = Path(str(artifacts.get("transcript", ""))) run_dir = str(report.get("run_dir") or "") return { "status": report.get("status"), "mode": report.get("mode"), "route": report.get("route"), "task": report.get("task"), "repo": report.get("repo"), "run_dir": run_dir, "run_id": Path(run_dir).name if run_dir else "", "agent_json": report.get("_agent_json") or artifacts.get("agent_json"), "returncode": report.get("returncode"), "elapsed_sec": report.get("elapsed_sec"), "steps": report.get("steps"), "tool_names": report.get("tool_names"), "transcript_bytes": transcript.stat().st_size if transcript.exists() else 0, } def load_agent_run_report(run: str = "", latest: bool = False, root: Path = AGENT_RUN_ROOT) -> dict[str, Any] | None: reports = agent_run_reports(root) if latest or not run: return reports[0] if reports else None if run.isdigit(): index = int(run) - 1 if 0 <= index < len(reports): return reports[index] for report in reports: summary = agent_run_summary(report) if run in {str(summary.get("run_id") or ""), str(summary.get("run_dir") or ""), str(summary.get("agent_json") or "")}: return report path = Path(normalize_host_path(run)) if path.is_dir(): path = path / "agent.json" if not path.is_absolute(): path = root / path if path.is_dir(): path = path / "agent.json" if path.exists() and path.is_file(): try: data = json.loads(path.read_text(encoding="utf-8")) data["_agent_json"] = str(path) return data except Exception: return None return None def agent_run_artifact_path(report: dict[str, Any], artifact: str) -> tuple[str, Path | None]: key = AGENT_RUN_ARTIFACT_ALIASES.get(artifact, artifact) if key not in AGENT_RUN_ARTIFACT_FILENAMES: return key, None artifacts = report.get("artifacts") if isinstance(report.get("artifacts"), dict) else {} raw_path = normalize_host_path(str(artifacts.get(key) or "")) if not raw_path and key == "agent_json": raw_path = normalize_host_path(str(report.get("_agent_json") or "")) if not raw_path: run_dir = Path(normalize_host_path(str(report.get("run_dir") or ""))) if run_dir: raw_path = str(run_dir / AGENT_RUN_ARTIFACT_FILENAMES[key]) if not raw_path: return key, None path = Path(normalize_host_path(raw_path)) run_dir_text = normalize_host_path(str(report.get("run_dir") or "")) if run_dir_text: run_dir = Path(run_dir_text).resolve() try: resolved = path.resolve() except Exception: return key, None if resolved != run_dir and run_dir not in resolved.parents: return key, None return key, path def tool_agent_runs(args: dict[str, Any]) -> dict[str, Any]: limit = arg_int(args, "limit", 10, maximum=100) root_arg = arg_str(args, "root", "") root = Path(normalize_host_path(root_arg)) if root_arg else AGENT_RUN_ROOT reports = agent_run_reports(root) payload = {"ok": True, "root": str(root), "runs": [agent_run_summary(report) for report in reports[:limit]]} text = json.dumps(payload, ensure_ascii=False, indent=2) return text_result(text, structured={"returncode": 0, "stdout": text, "stderr": "", **payload}) def tool_agent_run_artifact(args: dict[str, Any]) -> dict[str, Any]: artifact = arg_str(args, "artifact", "agent_json") run = arg_str(args, "run", "") latest = arg_bool(args, "latest", True) path_only = arg_bool(args, "path_only", False) limit = arg_int(args, "limit", 200_000, minimum=1_000, maximum=1_000_000) root_arg = arg_str(args, "root", "") root = Path(normalize_host_path(root_arg)) if root_arg else AGENT_RUN_ROOT report = load_agent_run_report(run=run, latest=latest or not run, root=root) if report is None: payload = {"ok": False, "error": f"agent run not found: {run or 'latest'}", "root": str(root)} return text_result(json.dumps(payload, ensure_ascii=False, indent=2), is_error=True, structured=payload) key, path = agent_run_artifact_path(report, artifact) if path is None: payload = { "ok": False, "error": f"unknown artifact: {artifact}", "available_artifacts": sorted(set(AGENT_RUN_ARTIFACT_FILENAMES) | set(AGENT_RUN_ARTIFACT_ALIASES)), } return text_result(json.dumps(payload, ensure_ascii=False, indent=2), is_error=True, structured=payload) if not path.exists(): payload = {"ok": False, "error": f"artifact missing: {key}", "path": str(path)} return text_result(json.dumps(payload, ensure_ascii=False, indent=2), is_error=True, structured=payload) if path_only: payload = {"ok": True, "artifact": key, "path": str(path), "run": agent_run_summary(report)} return text_result(str(path), structured=payload) text = path.read_text(encoding="utf-8", errors="replace") truncated = len(text) > limit content = text[:limit] payload = { "ok": True, "artifact": key, "path": str(path), "bytes": path.stat().st_size, "truncated": truncated, "content": content, "run": agent_run_summary(report), } return text_result(content, structured=payload) def tool_sessions(args: dict[str, Any]) -> dict[str, Any]: limit = arg_int(args, "limit", 10, maximum=100) timeout = arg_int(args, "timeout", 60, maximum=300) command = [str(DG_AGENT), "sessions", "list", "--limit", str(limit), "--json"] root = arg_str(args, "root", "") if root: command.extend(["--root", root]) result = run_cmd(command, timeout=timeout) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) def tool_session_artifact(args: dict[str, Any]) -> dict[str, Any]: artifact = arg_str(args, "artifact", "session_json") session = arg_str(args, "session", "") timeout = arg_int(args, "timeout", 60, maximum=300) command = [str(DG_AGENT), "sessions", "artifact", artifact] if session: command.append(session) if arg_bool(args, "latest", True): command.append("--latest") if arg_bool(args, "path_only", False): command.append("--path-only") root = arg_str(args, "root", "") if root: command.extend(["--root", root]) result = run_cmd(command, timeout=timeout) return text_result(command_report(command, result), is_error=result["returncode"] != 0, structured=result) TOOLS = { "dg_task_note": { "description": "Write or append a durable Markdown task note under runlogs for MCP clients.", "inputSchema": { "type": "object", "properties": { "task": {"type": "string"}, "body": {"type": "string"}, "title": {"type": "string"}, "repo": {"type": "string"}, "tags": {"type": "array", "items": {"type": "string"}}, "append_to": {"type": "string"}, }, "required": ["task", "body"], }, "handler": tool_task_note, }, "dg_task_notes": { "description": "List or read durable Markdown task notes saved by MCP clients.", "inputSchema": { "type": "object", "properties": { "note": {"type": "string"}, "latest": {"type": "boolean", "default": False}, "limit": {"type": "integer", "default": 20}, "max_chars": {"type": "integer", "default": 20000}, }, }, "handler": tool_task_notes, }, "dg_repo_status": { "description": "Inspect git status, diff stat, and untracked files for a repository.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "max_chars": {"type": "integer", "default": 8000}, "timeout": {"type": "integer", "default": 30}, }, }, "handler": tool_repo_status, }, "dg_list_files": { "description": "List repository files using ripgrep or git fallback, with optional glob filters.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "pattern": {"type": "string"}, "globs": {"type": "array", "items": {"type": "string"}}, "limit": {"type": "integer", "default": 200}, "timeout": {"type": "integer", "default": 30}, }, }, "handler": tool_list_files, }, "dg_search": { "description": "Search a repository with ripgrep and return bounded line/column matches.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "query": {"type": "string"}, "globs": {"type": "array", "items": {"type": "string"}}, "context": {"type": "integer", "default": 0}, "max_matches": {"type": "integer", "default": 80}, "max_chars": {"type": "integer", "default": 12000}, "timeout": {"type": "integer", "default": 30}, }, "required": ["query"], }, "handler": tool_search, }, "dg_read_file": { "description": "Read a bounded, line-numbered slice of a file inside a repository.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "path": {"type": "string"}, "start_line": {"type": "integer", "default": 1}, "max_lines": {"type": "integer", "default": 160}, "max_chars": {"type": "integer", "default": 20000}, }, "required": ["path"], }, "handler": tool_read_file, }, "dg_git_diff": { "description": "Read a bounded git diff or diff stat for a repository.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "files": {"type": "array", "items": {"type": "string"}}, "cached": {"type": "boolean", "default": False}, "stat": {"type": "boolean", "default": False}, "max_chars": {"type": "integer", "default": 20000}, "timeout": {"type": "integer", "default": 30}, }, }, "handler": tool_git_diff, }, "dg_status": { "description": "Check local DiffusionGemma backend/proxy/LiteLLM health.", "inputSchema": { "type": "object", "properties": {"timeout": {"type": "integer", "minimum": 1, "maximum": 180}}, }, "handler": tool_status, }, "dg_context": { "description": "Build a compact rg-based context pack for a repository task.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "task": {"type": "string"}, "files": {"type": "array", "items": {"type": "string"}}, "max_files": {"type": "integer", "default": 3}, "max_snippet_chars": {"type": "integer", "default": 1200}, "timeout": {"type": "integer", "default": 120}, }, "required": ["task"], }, "handler": tool_context, }, "dg_rag_context": { "description": "Retrieve a compact read-only RAG context for a repository task without calling the model.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "task": {"type": "string"}, "max_context_chars": {"type": "integer", "default": 900}, "max_files": {"type": "integer", "default": 3}, "debug": {"type": "boolean", "default": False}, "timeout": {"type": "integer", "default": 120}, }, "required": ["task"], }, "handler": tool_rag_context, }, "dg_rag_answer": { "description": "Ask the local model using a compact rg-retrieved repository context.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "task": {"type": "string"}, "base_url": {"type": "string"}, "model": {"type": "string"}, "max_context_chars": {"type": "integer", "default": 650}, "max_files": {"type": "integer", "default": 3}, "max_tokens": {"type": "integer", "default": 128}, "debug": {"type": "boolean", "default": False}, "timeout": {"type": "integer", "default": 300}, }, "required": ["task"], }, "handler": tool_rag_answer, }, "dg_repo_pack": { "description": "Pack repository content with the upstream Repomix OSS tool and return bounded output.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "style": {"type": "string", "default": "markdown"}, "include": {"type": "array", "items": {"type": "string"}}, "ignore": {"type": "array", "items": {"type": "string"}}, "compress": {"type": "boolean", "default": False}, "include_diffs": {"type": "boolean", "default": False}, "output_show_line_numbers": {"type": "boolean", "default": False}, "remove_comments": {"type": "boolean", "default": False}, "remove_empty_lines": {"type": "boolean", "default": False}, "no_files": {"type": "boolean", "default": False}, "no_security_check": {"type": "boolean", "default": False}, "token_budget": {"type": "integer", "default": 0}, "top_files_len": {"type": "integer", "default": 5}, "max_chars": {"type": "integer", "default": 20000}, "timeout": {"type": "integer", "default": 180}, }, }, "handler": tool_repo_pack, }, "dg_repo_map": { "description": "Build a bounded upstream Aider repo-map for repository-scale code context.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "map_tokens": {"type": "integer", "default": 512}, "paths": {"type": "array", "items": {"type": "string"}}, "map_only": {"type": "boolean", "default": True}, "base_url": {"type": "string"}, "model": {"type": "string"}, "max_chars": {"type": "integer", "default": 20000}, "timeout": {"type": "integer", "default": 180}, }, }, "handler": tool_repo_map, }, "dg_ast_grep": { "description": "Search repository code structurally with upstream ast-grep and bounded output.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "pattern": {"type": "string", "description": "AST pattern, for example: return $X"}, "kind": {"type": "string", "description": "Tree-sitter node kind or ESQuery-style kind selector"}, "selector": {"type": "string"}, "strictness": {"type": "string"}, "lang": {"type": "string", "description": "Pattern language, for example python, ts, rust, go"}, "context": {"type": "integer", "default": 0}, "globs": {"type": "array", "items": {"type": "string"}}, "paths": {"type": "array", "items": {"type": "string"}}, "json": {"type": "boolean", "default": True}, "files_with_matches": {"type": "boolean", "default": False}, "max_matches": {"type": "integer", "default": 80}, "max_chars": {"type": "integer", "default": 20000}, "timeout": {"type": "integer", "default": 120}, }, }, "handler": tool_ast_grep, }, "dg_code_outline": { "description": "Build a bounded symbol outline with upstream ast-grep outline.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "lang": {"type": "string", "description": "Input language, for example python, ts, rust, go"}, "items": {"type": "string", "default": "auto"}, "view": {"type": "string", "default": "auto"}, "type": {"type": "string", "description": "Comma-separated symbol types to keep"}, "match": {"type": "string", "description": "Regex matched against top-level item names/signatures"}, "pub_members": {"type": "boolean", "default": False}, "globs": {"type": "array", "items": {"type": "string"}}, "paths": {"type": "array", "items": {"type": "string"}}, "json": {"type": "boolean", "default": True}, "max_items": {"type": "integer", "default": 200}, "max_chars": {"type": "integer", "default": 20000}, "timeout": {"type": "integer", "default": 120}, }, }, "handler": tool_code_outline, }, "dg_preflight": { "description": "Check whether a target repository and local DG wrapper stack are ready for agent work.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "task": {"type": "string"}, "files": {"type": "array", "items": {"type": "string"}}, "allow_dirty": {"type": "boolean", "default": False}, "timeout": {"type": "integer", "default": 120}, }, }, "handler": tool_preflight, }, "dg_plan": { "description": "Generate a JSON task-runner plan from a natural-language repository task.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "task": {"type": "string"}, "files": {"type": "array", "items": {"type": "string"}}, "out": {"type": "string"}, "name": {"type": "string", "default": "edit"}, "test_cmd": {"type": "string"}, "auto_test": {"type": "boolean", "default": False}, "max_files": {"type": "integer", "default": 1}, "max_snippet_chars": {"type": "integer", "default": 1200}, "test_timeout": {"type": "integer", "default": 120}, "aider_timeout": {"type": "integer", "default": 420}, "repair_attempts": {"type": "integer", "default": 1}, "no_deterministic_first": {"type": "boolean", "default": False}, "timeout": {"type": "integer", "default": 120}, }, "required": ["task"], }, "handler": tool_plan, }, "dg_task": { "description": "Execute an existing DG task-runner plan; use dry_run to inspect the command without editing.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "plan": {"type": "string"}, "report": {"type": "string"}, "supervisor": {"type": "string"}, "step_report_dir": {"type": "string"}, "allow_dirty": {"type": "boolean", "default": False}, "dry_run": {"type": "boolean", "default": False}, "rollback_on_failure": {"type": "boolean", "default": True}, "continue_on_failure": {"type": "boolean", "default": False}, "timeout": {"type": "integer", "default": 900}, }, "required": ["plan"], }, "handler": tool_task, }, "dg_session": { "description": "Run the artifacted local DG coding-agent session with rollback on failure.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "task": {"type": "string"}, "files": {"type": "array", "items": {"type": "string"}}, "test_cmd": {"type": "string"}, "auto_test": {"type": "boolean", "default": False}, "allow_dirty": {"type": "boolean", "default": False}, "dry_run": {"type": "boolean", "default": False}, "max_files": {"type": "integer", "default": 1}, "max_snippet_chars": {"type": "integer", "default": 1200}, "test_timeout": {"type": "integer", "default": 120}, "aider_timeout": {"type": "integer", "default": 420}, "repair_attempts": {"type": "integer", "default": 1}, "wall_timeout": {"type": "integer", "default": 900}, }, "required": ["task"], }, "handler": tool_session, }, "dg_verify": { "description": "Run or infer a repository verification command.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "files": {"type": "array", "items": {"type": "string"}}, "test_cmd": {"type": "string"}, "timeout": {"type": "integer", "default": 120}, }, }, "handler": tool_verify, }, "dg_capabilities": { "description": "Read the latest DG wrapper capability report, or run a live capability probe.", "inputSchema": { "type": "object", "properties": { "run_live": {"type": "boolean", "default": False}, "timeout": {"type": "integer", "default": 180}, }, }, "handler": tool_capabilities, }, "dg_client_smoke": { "description": "Prepare or validate a target repo for external IDE/agent clients.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "client": {"type": "string", "default": "cursor", "enum": ["claude-code", "claude-desktop", "cursor", "vscode"]}, "target": {"type": "string"}, "force_init": {"type": "boolean", "default": False}, "no_init": {"type": "boolean", "default": False}, "no_rules": {"type": "boolean", "default": False}, "no_oss_stack": {"type": "boolean", "default": False}, "live": {"type": "boolean", "default": False}, "timeout": {"type": "integer", "default": 180}, }, }, "handler": tool_client_smoke, }, "dg_client_report": { "description": "Generate repo-local Markdown/JSON handoff files for external clients.", "inputSchema": { "type": "object", "properties": { "repo": {"type": "string", "default": "."}, "client": {"type": "string", "default": "cursor", "enum": ["claude-code", "claude-desktop", "cursor", "vscode"]}, "target": {"type": "string"}, "force_init": {"type": "boolean", "default": False}, "no_init": {"type": "boolean", "default": False}, "no_rules": {"type": "boolean", "default": False}, "no_oss_stack": {"type": "boolean", "default": False}, "live": {"type": "boolean", "default": False}, "no_write": {"type": "boolean", "default": False}, "timeout": {"type": "integer", "default": 240}, }, }, "handler": tool_client_report, }, "dg_sessions": { "description": "List recent artifacted DG agent sessions.", "inputSchema": { "type": "object", "properties": { "limit": {"type": "integer", "default": 10, "minimum": 1, "maximum": 100}, "root": {"type": "string"}, "timeout": {"type": "integer", "default": 60}, }, }, "handler": tool_sessions, }, "dg_session_artifact": { "description": "Read a preserved artifact from a DG agent session, defaulting to the latest session.", "inputSchema": { "type": "object", "properties": { "artifact": {"type": "string", "default": "session_json"}, "session": {"type": "string"}, "latest": {"type": "boolean", "default": True}, "path_only": {"type": "boolean", "default": False}, "root": {"type": "string"}, "timeout": {"type": "integer", "default": 60}, }, }, "handler": tool_session_artifact, }, "dg_agent_runs": { "description": "List recent high-level dg_agent runs and their preserved artifacts.", "inputSchema": { "type": "object", "properties": { "limit": {"type": "integer", "default": 10, "minimum": 1, "maximum": 100}, "root": {"type": "string"}, }, }, "handler": tool_agent_runs, }, "dg_agent_run_artifact": { "description": "Read a preserved artifact from a high-level dg_agent run, defaulting to the latest run.", "inputSchema": { "type": "object", "properties": { "artifact": {"type": "string", "default": "agent_json"}, "run": {"type": "string"}, "latest": {"type": "boolean", "default": True}, "path_only": {"type": "boolean", "default": False}, "limit": {"type": "integer", "default": 200000, "minimum": 1000, "maximum": 1000000}, "root": {"type": "string"}, }, }, "handler": tool_agent_run_artifact, }, } def list_tools() -> list[dict[str, Any]]: return [ { "name": name, "description": meta["description"], "inputSchema": meta["inputSchema"], } for name, meta in TOOLS.items() ] def handle_request(message: dict[str, Any]) -> dict[str, Any] | None: if "id" not in message: return None request_id = message.get("id") method = message.get("method") params = message.get("params") if isinstance(message.get("params"), dict) else {} try: if method == "initialize": protocol = params.get("protocolVersion") or "2025-03-26" return rpc_response( request_id, { "protocolVersion": protocol, "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "diffusiongemma-local-agent", "version": "0.1.0"}, }, ) if method == "tools/list": return rpc_response(request_id, {"tools": list_tools()}) if method == "tools/call": name = params.get("name") arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} if name not in TOOLS: return rpc_error(request_id, -32602, f"Unknown tool: {name}") handler = TOOLS[name]["handler"] return rpc_response(request_id, handler(arguments)) if method == "ping": return rpc_response(request_id, {}) return rpc_error(request_id, -32601, f"Method not found: {method}") except Exception as exc: log(f"request failed: {type(exc).__name__}: {exc}") return rpc_error(request_id, -32000, f"{type(exc).__name__}: {exc}") def serve() -> int: log("started") for line in sys.stdin: line = line.strip() if not line: continue try: message = json.loads(line) except json.JSONDecodeError as exc: log(f"invalid JSON line: {exc}") continue messages = message if isinstance(message, list) else [message] responses: list[dict[str, Any]] = [] for item in messages: if not isinstance(item, dict): continue response = handle_request(item) if response is not None: responses.append(response) if isinstance(message, list): if responses: emit(responses) elif responses: emit(responses[0]) log("stopped") return 0 def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="MCP stdio server exposing local DiffusionGemma agent tools.") parser.add_argument("--stdio", action="store_true", help="Run stdio MCP server (default)") parser.add_argument("--list-tools", action="store_true", help="Print tool list JSON and exit") return parser.parse_args() def main() -> int: args = parse_args() if args.list_tools: print(json.dumps({"tools": list_tools()}, ensure_ascii=False, indent=2)) return 0 return serve() if __name__ == "__main__": raise SystemExit(main())