# mcp_client.py """ Note(Gemini): MCP-style file context tools for manual_slop. Exposes read-only filesystem tools the AI can call to selectively fetch file content on demand, instead of having everything inlined into the context block. All access is restricted to paths that are either: - Explicitly listed in the project's allowed_paths set, OR - Contained within an allowed base_dir (must resolve to a subpath of it) This is heavily inspired by Claude's own tooling limits. We enforce safety here so the AI doesn't wander outside the project workspace. """ # mcp_client.py #MCP-style file context tools for manual_slop. # Exposes read-only filesystem tools the AI can call to selectively fetch file # content on demand, instead of having everything inlined into the context block. # All access is restricted to paths that are either: # - Explicitly listed in the project's allowed_paths set, OR # - Contained within an allowed base_dir (must resolve to a subpath of it) # Tools exposed: # read_file(path) - return full UTF-8 content of a file # list_directory(path) - list entries in a directory (names + type) # search_files(path, pattern) - glob pattern search within an allowed dir # get_file_summary(path) - return the summarize.py heuristic summary # from __future__ import annotations from pathlib import Path from typing import Optional, Callable, Any import os import summarize import outline_tool import urllib.request import urllib.parse from html.parser import HTMLParser import re as _re # ------------------------------------------------------------------ state # Set by configure() before the AI send loop starts. # allowed_paths : set of resolved absolute Path objects (files or dirs) # base_dirs : set of resolved absolute Path dirs that act as roots _allowed_paths: set[Path] = set() _base_dirs: set[Path] = set() _primary_base_dir: Path | None = None # Injected by gui_legacy.py - returns a dict of performance metrics perf_monitor_callback: Optional[Callable[[], dict[str, Any]]] = None def configure(file_items: list[dict[str, Any]], extra_base_dirs: list[str] | None = None) -> None: """ Build the allowlist from aggregate file_items. Called by ai_client before each send so the list reflects the current project. file_items : list of dicts from aggregate.build_file_items() extra_base_dirs : additional directory roots to allow traversal of """ global _allowed_paths, _base_dirs, _primary_base_dir _allowed_paths = set() _base_dirs = set() _primary_base_dir = Path(extra_base_dirs[0]).resolve() if extra_base_dirs else Path.cwd() for item in file_items: p = item.get("path") if p is not None: try: rp = Path(p).resolve(strict=True) except (OSError, ValueError): rp = Path(p).resolve() _allowed_paths.add(rp) _base_dirs.add(rp.parent) if extra_base_dirs: for d in extra_base_dirs: dp = Path(d).resolve() if dp.is_dir(): _base_dirs.add(dp) def _is_allowed(path: Path) -> bool: """ Return True if `path` is within the allowlist. A path is allowed if: - it is explicitly in _allowed_paths, OR - it is contained within (or equal to) one of the _base_dirs All paths are resolved (follows symlinks) before comparison to prevent symlink-based path traversal. CRITICAL: Blacklisted files (history) are NEVER allowed. """ # Blacklist check name = path.name.lower() if name == "history.toml" or name.endswith("_history.toml"): return False try: rp = path.resolve(strict=True) except (OSError, ValueError): rp = path.resolve() if rp in _allowed_paths: return True # Allow current working directory and subpaths by default if no base_dirs cwd = Path.cwd().resolve() if not _base_dirs: try: rp.relative_to(cwd) return True except ValueError: pass for bd in _base_dirs: try: rp.relative_to(bd) return True except ValueError: continue return False def _resolve_and_check(raw_path: str) -> tuple[Path | None, str]: """ Resolve raw_path and verify it passes the allowlist check. Returns (resolved_path, error_string). error_string is empty on success. """ try: p = Path(raw_path) if not p.is_absolute() and _primary_base_dir: p = _primary_base_dir / p p = p.resolve() except Exception as e: return None, f"ERROR: invalid path '{raw_path}': {e}" if not _is_allowed(p): allowed_bases = "\\n".join([f" - {d}" for d in _base_dirs]) return None, ( f"ACCESS DENIED: '{raw_path}' resolves to '{p}', which is not within the allowed paths.\\n" f"Allowed base directories are:\\n{allowed_bases}" ) return p, "" # ------------------------------------------------------------------ tool implementations def read_file(path: str) -> str: """Return the UTF-8 content of a file, or an error string.""" p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: file not found: {path}" if not p.is_file(): return f"ERROR: not a file: {path}" try: return p.read_text(encoding="utf-8") except Exception as e: return f"ERROR reading '{path}': {e}" def list_directory(path: str) -> str: """List entries in a directory. Returns a compact text table.""" p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: path not found: {path}" if not p.is_dir(): return f"ERROR: not a directory: {path}" try: entries = sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name.lower())) lines = [f"Directory: {p}", ""] count = 0 for entry in entries: # Blacklist check name = entry.name.lower() if name == "history.toml" or name.endswith("_history.toml"): continue kind = "file" if entry.is_file() else "dir " size = f"{entry.stat().st_size:>10,} bytes" if entry.is_file() else "" lines.append(f" [{kind}] {entry.name:<40} {size}") count += 1 lines.append(f" ({count} entries)") return "\n".join(lines) except Exception as e: return f"ERROR listing '{path}': {e}" def search_files(path: str, pattern: str) -> str: """ Search for files matching a glob pattern within path. pattern examples: '*.py', '**/*.toml', 'src/**/*.rs' """ p, err = _resolve_and_check(path) if err: return err if not p.is_dir(): return f"ERROR: not a directory: {path}" try: matches = sorted(p.glob(pattern)) if not matches: return f"No files matched '{pattern}' in {path}" lines = [f"Search '{pattern}' in {p}:", ""] count = 0 for m in matches: # Blacklist check name = m.name.lower() if name == "history.toml" or name.endswith("_history.toml"): continue rel = m.relative_to(p) kind = "file" if m.is_file() else "dir " lines.append(f" [{kind}] {rel}") count += 1 lines.append(f" ({count} match(es))") return "\n".join(lines) except Exception as e: return f"ERROR searching '{path}': {e}" def get_file_summary(path: str) -> str: """ Return the heuristic summary for a file (same as the initial context block). For .py files: imports, classes, methods, functions, constants. For .toml: table keys. For .md: headings. Others: line count + preview. """ p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: file not found: {path}" if not p.is_file(): return f"ERROR: not a file: {path}" try: content = p.read_text(encoding="utf-8") return summarize.summarise_file(p, content) except Exception as e: return f"ERROR summarising '{path}': {e}" def py_get_skeleton(path: str) -> str: """ Returns a skeleton of a Python file (preserving docstrings, stripping function bodies). """ p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: file not found: {path}" if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" try: from file_cache import ASTParser code = p.read_text(encoding="utf-8") parser = ASTParser("python") return parser.get_skeleton(code) except Exception as e: return f"ERROR generating skeleton for '{path}': {e}" def py_get_code_outline(path: str) -> str: """ Returns a hierarchical outline of a code file (classes, functions, methods with line ranges). """ p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: file not found: {path}" if not p.is_file(): return f"ERROR: not a file: {path}" try: code = p.read_text(encoding="utf-8") return outline_tool.get_outline(p, code) except Exception as e: return f"ERROR generating outline for '{path}': {e}" def get_file_slice(path: str, start_line: int, end_line: int) -> str: """Return a specific line range from a file.""" p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: file not found: {path}" try: lines = p.read_text(encoding="utf-8").splitlines(keepends=True) start_idx = int(start_line) - 1 end_idx = int(end_line) return "".join(lines[start_idx:end_idx]) except Exception as e: return f"ERROR reading slice from '{path}': {e}" def set_file_slice(path: str, start_line: int, end_line: int, new_content: str) -> str: """Replace a specific line range in a file with new content.""" p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: file not found: {path}" try: lines = p.read_text(encoding="utf-8").splitlines(keepends=True) start_idx = int(start_line) - 1 end_idx = int(end_line) if new_content and not new_content.endswith("\n"): new_content += "\n" new_lines = new_content.splitlines(keepends=True) if new_content else [] lines[start_idx:end_idx] = new_lines p.write_text("".join(lines), encoding="utf-8", newline="") return f"Successfully updated lines {start_line}-{end_line} in {path}" except Exception as e: return f"ERROR updating slice in '{path}': {e}" def _get_symbol_node(tree: Any, name: str) -> Any: """Helper to find an AST node by name (Class, Function, or Variable). Supports dot notation.""" import ast parts = name.split(".") def find_in_scope(scope_node, target_name): # scope_node could be Module, ClassDef, or FunctionDef body = getattr(scope_node, "body", []) for node in body: if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == target_name: return node if isinstance(node, ast.Assign): for t in node.targets: if isinstance(t, ast.Name) and t.id == target_name: return node if isinstance(node, ast.AnnAssign): if isinstance(node.target, ast.Name) and node.target.id == target_name: return node return None current = tree for part in parts: found = find_in_scope(current, part) if not found: return None current = found return current def py_get_definition(path: str, name: str) -> str: """ Returns the source code for a specific class, function, or method definition. path: Path to the code file. name: Name of the definition to retrieve (e.g., 'MyClass', 'my_function', 'MyClass.my_method'). """ p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: file not found: {path}" if not p.is_file(): return f"ERROR: not a file: {path}" if p.suffix != ".py": return f"ERROR: py_get_definition currently only supports .py files (unsupported: {p.suffix})" try: import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) lines = code.splitlines(keepends=True) tree = ast.parse(code) node = _get_symbol_node(tree, name) if node: start = getattr(node, "lineno") - 1 end = getattr(node, "end_lineno") return "".join(lines[start:end]) return f"ERROR: could not find definition '{name}' in {path}" except Exception as e: return f"ERROR retrieving definition '{name}' from '{path}': {e}" def py_update_definition(path: str, name: str, new_content: str) -> str: """Surgically replace the definition of a class or function.""" p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: file not found: {path}" try: import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) tree = ast.parse(code) node = _get_symbol_node(tree, name) if not node: return f"ERROR: could not find definition '{name}' in {path}" start = getattr(node, "lineno") end = getattr(node, "end_lineno") return set_file_slice(path, start, end, new_content) except Exception as e: return f"ERROR updating definition '{name}' in '{path}': {e}" def py_get_signature(path: str, name: str) -> str: """Returns only the signature part of a function or method (def line until colon).""" p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: file not found: {path}" try: import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) lines = code.splitlines(keepends=True) tree = ast.parse(code) node = _get_symbol_node(tree, name) if not node or not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): return f"ERROR: could not find function/method '{name}' in {path}" start = getattr(node, "lineno") - 1 body_start = node.body[0].lineno - 1 sig_lines = lines[start:body_start] sig = "".join(sig_lines).strip() if sig.endswith(":"): return sig # If body is on the same line (e.g. def foo(): pass), we need to split by colon full_line = lines[start] colon_idx = full_line.find(":") if colon_idx != -1: return full_line[:colon_idx+1].strip() return sig except Exception as e: return f"ERROR retrieving signature '{name}' from '{path}': {e}" def py_set_signature(path: str, name: str, new_signature: str) -> str: """Surgically replace only the signature of a function/method.""" p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: file not found: {path}" try: import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) lines = code.splitlines(keepends=True) tree = ast.parse(code) node = _get_symbol_node(tree, name) if not node or not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): return f"ERROR: could not find function/method '{name}' in {path}" start = getattr(node, "lineno") body_start_line = node.body[0].lineno # We replace from start until body_start_line - 1 # But we must be careful about comments/docstrings between sig and body # For now, we replace only the lines that contain the signature end = body_start_line - 1 return set_file_slice(path, start, end, new_signature) except Exception as e: return f"ERROR updating signature '{name}' in '{path}': {e}" def py_get_class_summary(path: str, name: str) -> str: """Returns a summary of a class: its methods and their signatures.""" p, err = _resolve_and_check(path) if err: return err if not p.exists(): return f"ERROR: file not found: {path}" try: import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) tree = ast.parse(code) node = _get_symbol_node(tree, name) if not node or not isinstance(node, ast.ClassDef): return f"ERROR: could not find class '{name}' in {path}" lines = code.splitlines(keepends=True) summary = [f"Class: {name}"] doc = ast.get_docstring(node) if doc: summary.append(f" Docstring: {doc}") for body_node in node.body: if isinstance(body_node, (ast.FunctionDef, ast.AsyncFunctionDef)): start = getattr(body_node, "lineno") - 1 body_start = body_node.body[0].lineno - 1 sig = "".join(lines[start:body_start]).strip() summary.append(f" - {sig}") return "\n".join(summary) except Exception as e: return f"ERROR summarizing class '{name}' in '{path}': {e}" def py_get_var_declaration(path: str, name: str) -> str: """Get the assignment/declaration line(s) for a module-level or class-level variable.""" p, err = _resolve_and_check(path) if err: return err if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" try: import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) lines = code.splitlines(keepends=True) tree = ast.parse(code) node = _get_symbol_node(tree, name) if not node or not isinstance(node, (ast.Assign, ast.AnnAssign)): return f"ERROR: could not find variable '{name}' in {path}" start = getattr(node, "lineno") - 1 end = getattr(node, "end_lineno") return "".join(lines[start:end]) except Exception as e: return f"ERROR retrieving variable '{name}' from '{path}': {e}" def py_set_var_declaration(path: str, name: str, new_declaration: str) -> str: """Surgically replace a variable assignment/declaration.""" p, err = _resolve_and_check(path) if err: return err if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" try: import ast code = p.read_text(encoding="utf-8").lstrip(chr(0xFEFF)) tree = ast.parse(code) node = _get_symbol_node(tree, name) if not node or not isinstance(node, (ast.Assign, ast.AnnAssign)): return f"ERROR: could not find variable '{name}' in {path}" start = getattr(node, "lineno") end = getattr(node, "end_lineno") return set_file_slice(path, start, end, new_declaration) except Exception as e: return f"ERROR updating variable '{name}' in '{path}': {e}" def get_git_diff(path: str, base_rev: str = "HEAD", head_rev: str = "") -> str: """ Returns the git diff for a file or directory. base_rev: The base revision (default: HEAD) head_rev: The head revision (optional) """ import subprocess p, err = _resolve_and_check(path) if err: return err cmd = ["git", "diff", base_rev] if head_rev: cmd.append(head_rev) cmd.extend(["--", str(p)]) try: result = subprocess.run(cmd, capture_output=True, text=True, check=True, encoding="utf-8") return result.stdout if result.stdout else "(no changes)" except subprocess.CalledProcessError as e: return f"ERROR running git diff: {e.stderr}" except Exception as e: return f"ERROR: {e}" def py_find_usages(path: str, name: str) -> str: """Finds exact string matches of a symbol in a given file or directory.""" p, err = _resolve_and_check(path) if err: return err try: import re pattern = re.compile(r"\b" + re.escape(name) + r"\b") results = [] def _search_file(fp): if fp.name == "history.toml" or fp.name.endswith("_history.toml"): return if not _is_allowed(fp): return try: text = fp.read_text(encoding="utf-8") lines = text.splitlines() for i, line in enumerate(lines, 1): if pattern.search(line): rel = fp.relative_to(_primary_base_dir if _primary_base_dir else Path.cwd()) results.append(f"{rel}:{i}: {line.strip()[:100]}") except Exception: pass if p.is_file(): _search_file(p) else: for root, dirs, files in os.walk(p): dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ('__pycache__', 'venv', 'env')] for file in files: if file.endswith(('.py', '.md', '.toml', '.txt', '.json')): _search_file(Path(root) / file) if not results: return f"No usages found for '{name}' in {p}" if len(results) > 100: return "\n".join(results[:100]) + f"\n... (and {len(results)-100} more)" return "\n".join(results) except Exception as e: return f"ERROR finding usages for '{name}': {e}" def py_get_imports(path: str) -> str: """Parses a file's AST and returns a strict list of its dependencies.""" p, err = _resolve_and_check(path) if err: return err if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" try: import ast code = p.read_text(encoding="utf-8") tree = ast.parse(code) imports = [] for node in tree.body: if isinstance(node, ast.Import): for alias in node.names: imports.append(alias.name) elif isinstance(node, ast.ImportFrom): module = node.module or "" for alias in node.names: imports.append(f"{module}.{alias.name}" if module else alias.name) if not imports: return "No imports found." return "Imports:\n" + "\n".join(f" - {i}" for i in imports) except Exception as e: return f"ERROR getting imports for '{path}': {e}" def py_check_syntax(path: str) -> str: """Runs a quick syntax check on a Python file.""" p, err = _resolve_and_check(path) if err: return err if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" try: import ast code = p.read_text(encoding="utf-8") ast.parse(code) return f"Syntax OK: {path}" except SyntaxError as e: return f"SyntaxError in {path} at line {e.lineno}, offset {e.offset}: {e.msg}\n{e.text}" except Exception as e: return f"ERROR checking syntax for '{path}': {e}" def py_get_hierarchy(path: str, class_name: str) -> str: """Scans the project to find subclasses of a given class.""" p, err = _resolve_and_check(path) if err: return err import ast subclasses = [] def _search_file(fp): if not _is_allowed(fp): return try: code = fp.read_text(encoding="utf-8") tree = ast.parse(code) for node in ast.walk(tree): if isinstance(node, ast.ClassDef): for base in node.bases: if isinstance(base, ast.Name) and base.id == class_name: subclasses.append(f"{fp.name}: class {node.name}({class_name})") elif isinstance(base, ast.Attribute) and base.attr == class_name: subclasses.append(f"{fp.name}: class {node.name}({base.value.id}.{class_name})") except Exception: pass try: if p.is_file(): _search_file(p) else: for root, dirs, files in os.walk(p): dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ('__pycache__', 'venv', 'env')] for file in files: if file.endswith('.py'): _search_file(Path(root) / file) if not subclasses: return f"No subclasses of '{class_name}' found in {p}" return f"Subclasses of '{class_name}':\n" + "\n".join(f" - {s}" for s in subclasses) except Exception as e: return f"ERROR finding subclasses of '{class_name}': {e}" def py_get_docstring(path: str, name: str) -> str: """Extracts the docstring for a specific module, class, or function.""" p, err = _resolve_and_check(path) if err: return err if not p.is_file() or p.suffix != ".py": return f"ERROR: not a python file: {path}" try: import ast code = p.read_text(encoding="utf-8") tree = ast.parse(code) if not name or name == "module": doc = ast.get_docstring(tree) return doc if doc else "No module docstring found." node = _get_symbol_node(tree, name) if not node: return f"ERROR: could not find symbol '{name}' in {path}" doc = ast.get_docstring(node) return doc if doc else f"No docstring found for '{name}'." except Exception as e: return f"ERROR getting docstring for '{name}': {e}" def get_tree(path: str, max_depth: int = 2) -> str: """Returns a directory structure up to a max depth.""" p, err = _resolve_and_check(path) if err: return err if not p.is_dir(): return f"ERROR: not a directory: {path}" try: max_depth = int(max_depth) def _build_tree(dir_path, current_depth, prefix=""): if current_depth > max_depth: return [] lines = [] try: entries = sorted(dir_path.iterdir(), key=lambda e: (e.is_file(), e.name.lower())) except PermissionError: return [] # Filter entries = [e for e in entries if not e.name.startswith('.') and e.name not in ('__pycache__', 'venv', 'env') and e.name != "history.toml" and not e.name.endswith("_history.toml")] for i, entry in enumerate(entries): is_last = (i == len(entries) - 1) connector = "└── " if is_last else "├── " lines.append(f"{prefix}{connector}{entry.name}") if entry.is_dir(): extension = " " if is_last else "│ " lines.extend(_build_tree(entry, current_depth + 1, prefix + extension)) return lines tree_lines = [f"{p.name}/"] + _build_tree(p, 1) return "\n".join(tree_lines) except Exception as e: return f"ERROR generating tree for '{path}': {e}" # ------------------------------------------------------------------ web tools class _DDGParser(HTMLParser): def __init__(self) -> None: super().__init__() self.results: list[dict[str, str]] = [] self.in_result: bool = False self.in_title: bool = False self.in_snippet: bool = False self.current_link: str = "" self.current_title: str = "" self.current_snippet: str = "" def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: attrs_dict = dict(attrs) if tag == "a" and "result__url" in attrs_dict.get("class", ""): self.current_link = attrs_dict.get("href", "") if tag == "a" and "result__snippet" in attrs_dict.get("class", ""): self.in_snippet = True if tag == "h2" and "result__title" in attrs_dict.get("class", ""): self.in_title = True def handle_endtag(self, tag: str) -> None: if tag == "a" and self.in_snippet: self.in_snippet = False if tag == "h2" and self.in_title: self.in_title = False if self.current_link: self.results.append({ "title": self.current_title.strip(), "link": self.current_link, "snippet": self.current_snippet.strip() }) self.current_title = "" self.current_snippet = "" self.current_link = "" def handle_data(self, data: str) -> None: if self.in_title: self.current_title += data if self.in_snippet: self.current_snippet += data class _TextExtractor(HTMLParser): def __init__(self) -> None: super().__init__() self.text: list[str] = [] self.hide: int = 0 self.ignore_tags: set[str] = {'script', 'style', 'head', 'meta', 'nav', 'header', 'footer', 'noscript', 'svg'} def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: if tag in self.ignore_tags: self.hide += 1 def handle_endtag(self, tag: str) -> None: if tag in self.ignore_tags: self.hide -= 1 def handle_data(self, data: str) -> None: if self.hide == 0: cleaned = data.strip() if cleaned: self.text.append(cleaned) def web_search(query: str) -> str: """Search the web using DuckDuckGo HTML and return top results.""" url = "https://html.duckduckgo.com/html/?q=" + urllib.parse.quote(query) req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}) try: with urllib.request.urlopen(req, timeout=10) as resp: html = resp.read().decode('utf-8', errors='ignore') parser = _DDGParser() parser.feed(html) if not parser.results: return f"No results found for '{query}'" lines = [f"Search Results for '{query}':"] for i, r in enumerate(parser.results[:5], 1): lines.append(f"{i}. {r['title']}\nURL: {r['link']}\nSnippet: {r['snippet']}\n") return "\n".join(lines) except Exception as e: return f"ERROR searching web for '{query}': {e}" def fetch_url(url: str) -> str: """Fetch a URL and return its text content (stripped of HTML tags).""" # Correct duckduckgo redirect links if passed if url.startswith("//duckduckgo.com/l/?uddg="): url = urllib.parse.unquote(url.split("uddg=")[1].split("&")[0]) if not url.startswith("http"): url = "https://" + url req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'}) try: with urllib.request.urlopen(req, timeout=10) as resp: html = resp.read().decode('utf-8', errors='ignore') parser = _TextExtractor() parser.feed(html) full_text = " ".join(parser.text) full_text = _re.sub(r'\s+', ' ', full_text) if not full_text.strip(): return f"FETCH OK: No readable text extracted from {url}. The page might be empty, JavaScript-heavy, or blocked." # Limit to 40k chars to prevent context blowup if len(full_text) > 40000: return full_text[:40000] + "\n... (content truncated)" return full_text except Exception as e: return f"ERROR fetching URL '{url}': {e}" def get_ui_performance() -> str: """Returns current UI performance metrics (FPS, Frame Time, CPU, Input Lag).""" if perf_monitor_callback is None: return "INFO: UI Performance monitor is not available (headless/CLI mode). This tool is only functional when the Manual Slop GUI is running." try: metrics = perf_monitor_callback() # Clean up the dict string for the AI metric_str = str(metrics) for char in "{}'": metric_str = metric_str.replace(char, "") return f"UI Performance Snapshot:\n{metric_str}" except Exception as e: return f"ERROR: Failed to retrieve UI performance: {str(e)}" # ------------------------------------------------------------------ tool dispatch TOOL_NAMES = {"read_file", "list_directory", "search_files", "get_file_summary", "py_get_skeleton", "py_get_code_outline", "py_get_definition", "get_git_diff", "web_search", "fetch_url", "get_ui_performance", "get_file_slice", "set_file_slice", "py_update_definition", "py_get_signature", "py_set_signature", "py_get_class_summary", "py_get_var_declaration", "py_set_var_declaration", "py_find_usages", "py_get_imports", "py_check_syntax", "py_get_hierarchy", "py_get_docstring", "get_tree"} def dispatch(tool_name: str, tool_input: dict[str, Any]) -> str: """ Dispatch an MCP tool call by name. Returns the result as a string. """ if tool_name == "read_file": return read_file(tool_input.get("path", "")) if tool_name == "list_directory": return list_directory(tool_input.get("path", "")) if tool_name == "search_files": return search_files(tool_input.get("path", ""), tool_input.get("pattern", "*")) if tool_name == "get_file_summary": return get_file_summary(tool_input.get("path", "")) if tool_name == "py_get_skeleton": return py_get_skeleton(tool_input.get("path", "")) if tool_name == "py_get_code_outline": return py_get_code_outline(tool_input.get("path", "")) if tool_name == "py_get_definition": return py_get_definition(tool_input.get("path", ""), tool_input.get("name", "")) if tool_name == "py_update_definition": return py_update_definition(tool_input.get("path", ""), tool_input.get("name", ""), tool_input.get("new_content", "")) if tool_name == "py_get_signature": return py_get_signature(tool_input.get("path", ""), tool_input.get("name", "")) if tool_name == "py_set_signature": return py_set_signature(tool_input.get("path", ""), tool_input.get("name", ""), tool_input.get("new_signature", "")) if tool_name == "py_get_class_summary": return py_get_class_summary(tool_input.get("path", ""), tool_input.get("name", "")) if tool_name == "py_get_var_declaration": return py_get_var_declaration(tool_input.get("path", ""), tool_input.get("name", "")) if tool_name == "py_set_var_declaration": return py_set_var_declaration(tool_input.get("path", ""), tool_input.get("name", ""), tool_input.get("new_declaration", "")) if tool_name == "get_file_slice": return get_file_slice(tool_input.get("path", ""), tool_input.get("start_line", 1), tool_input.get("end_line", 1)) if tool_name == "set_file_slice": return set_file_slice(tool_input.get("path", ""), tool_input.get("start_line", 1), tool_input.get("end_line", 1), tool_input.get("new_content", "")) if tool_name == "get_git_diff": return get_git_diff( tool_input.get("path", ""), tool_input.get("base_rev", "HEAD"), tool_input.get("head_rev", "") ) if tool_name == "web_search": return web_search(tool_input.get("query", "")) if tool_name == "fetch_url": return fetch_url(tool_input.get("url", "")) if tool_name == "get_ui_performance": return get_ui_performance() if tool_name == "py_find_usages": return py_find_usages(tool_input.get("path", ""), tool_input.get("name", "")) if tool_name == "py_get_imports": return py_get_imports(tool_input.get("path", "")) if tool_name == "py_check_syntax": return py_check_syntax(tool_input.get("path", "")) if tool_name == "py_get_hierarchy": return py_get_hierarchy(tool_input.get("path", ""), tool_input.get("class_name", "")) if tool_name == "py_get_docstring": return py_get_docstring(tool_input.get("path", ""), tool_input.get("name", "")) if tool_name == "get_tree": return get_tree(tool_input.get("path", ""), tool_input.get("max_depth", 2)) return f"ERROR: unknown MCP tool '{tool_name}'" # ------------------------------------------------------------------ tool schema helpers # These are imported by ai_client.py to build provider-specific declarations. MCP_TOOL_SPECS: list[dict[str, Any]] = [ { "name": "read_file", "description": ( "Read the full UTF-8 content of a file within the allowed project paths. " "Use get_file_summary first to decide whether you need the full content." ), "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Absolute or relative path to the file to read.", } }, "required": ["path"], }, }, { "name": "list_directory", "description": ( "List files and subdirectories within an allowed directory. " "Shows name, type (file/dir), and size. Use this to explore the project structure." ), "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Absolute path to the directory to list.", } }, "required": ["path"], }, }, { "name": "search_files", "description": ( "Search for files matching a glob pattern within an allowed directory. " "Supports recursive patterns like '**/*.py'. " "Use this to find files by extension or name pattern." ), "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Absolute path to the directory to search within.", }, "pattern": { "type": "string", "description": "Glob pattern, e.g. '*.py', '**/*.toml', 'src/**/*.rs'.", }, }, "required": ["path", "pattern"], }, }, { "name": "get_file_summary", "description": ( "Get a compact heuristic summary of a file without reading its full content. " "For Python: imports, classes, methods, functions, constants. " "For TOML: table keys. For Markdown: headings. Others: line count + preview. " "Use this before read_file to decide if you need the full content." ), "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Absolute or relative path to the file to summarise.", } }, "required": ["path"], }, }, { "name": "py_get_skeleton", "description": ( "Get a skeleton view of a Python file. " "This returns all classes and function signatures with their docstrings, " "but replaces function bodies with '...'. " "Use this to understand module interfaces without reading the full implementation." ), "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the .py file.", } }, "required": ["path"], }, }, { "name": "py_get_code_outline", "description": ( "Get a hierarchical outline of a code file. " "This returns classes, functions, and methods with their line ranges and brief docstrings. " "Use this to quickly map out a file's structure before reading specific sections." ), "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the code file (currently supports .py).", } }, "required": ["path"], }, }, { "name": "get_file_slice", "description": "Read a specific line range from a file. Useful for reading parts of very large files.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the file." }, "start_line": { "type": "integer", "description": "1-based start line number." }, "end_line": { "type": "integer", "description": "1-based end line number (inclusive)." } }, "required": ["path", "start_line", "end_line"] } }, { "name": "set_file_slice", "description": "Replace a specific line range in a file with new content. Surgical edit tool.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the file." }, "start_line": { "type": "integer", "description": "1-based start line number." }, "end_line": { "type": "integer", "description": "1-based end line number (inclusive)." }, "new_content": { "type": "string", "description": "New content to insert." } }, "required": ["path", "start_line", "end_line", "new_content"] } }, { "name": "py_get_definition", "description": ( "Get the full source code of a specific class, function, or method definition. " "This is more efficient than reading the whole file if you know what you're looking for." ), "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the .py file.", }, "name": { "type": "string", "description": "The name of the class or function to retrieve. Use 'ClassName.method_name' for methods.", } }, "required": ["path", "name"], }, }, { "name": "py_update_definition", "description": "Surgically replace the definition of a class or function in a Python file using AST to find line ranges.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the .py file." }, "name": { "type": "string", "description": "Name of class/function/method." }, "new_content": { "type": "string", "description": "Complete new source for the definition." } }, "required": ["path", "name", "new_content"] } }, { "name": "py_get_signature", "description": "Get only the signature part of a Python function or method (from def until colon).", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the .py file." }, "name": { "type": "string", "description": "Name of the function/method (e.g. 'ClassName.method_name')." } }, "required": ["path", "name"] } }, { "name": "py_set_signature", "description": "Surgically replace only the signature of a Python function or method.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the .py file." }, "name": { "type": "string", "description": "Name of the function/method." }, "new_signature": { "type": "string", "description": "Complete new signature string (including def and trailing colon)." } }, "required": ["path", "name", "new_signature"] } }, { "name": "py_get_class_summary", "description": "Get a summary of a Python class, listing its docstring and all method signatures.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the .py file." }, "name": { "type": "string", "description": "Name of the class." } }, "required": ["path", "name"] } }, { "name": "py_get_var_declaration", "description": "Get the assignment/declaration line for a variable.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the .py file." }, "name": { "type": "string", "description": "Name of the variable." } }, "required": ["path", "name"] } }, { "name": "py_set_var_declaration", "description": "Surgically replace a variable assignment/declaration.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the .py file." }, "name": { "type": "string", "description": "Name of the variable." }, "new_declaration": { "type": "string", "description": "Complete new assignment/declaration string." } }, "required": ["path", "name", "new_declaration"] } }, { "name": "get_git_diff", "description": ( "Returns the git diff for a file or directory. " "Use this to review changes efficiently without reading entire files." ), "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the file or directory.", }, "base_rev": { "type": "string", "description": "Base revision (e.g. 'HEAD', 'HEAD~1', or a commit hash). Defaults to 'HEAD'.", }, "head_rev": { "type": "string", "description": "Head revision (optional).", } }, "required": ["path"], }, }, { "name": "web_search", "description": "Search the web using DuckDuckGo. Returns the top 5 search results with titles, URLs, and snippets. Chain this with fetch_url to read specific pages.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "The search query." } }, "required": ["query"] } }, { "name": "fetch_url", "description": "Fetch the full text content of a URL (stripped of HTML tags). Use this after web_search to read relevant information from the web.", "parameters": { "type": "object", "properties": { "url": { "type": "string", "description": "The full URL to fetch." } }, "required": ["url"] } }, { "name": "get_ui_performance", "description": "Get a snapshot of the current UI performance metrics, including FPS, Frame Time (ms), CPU usage (%), and Input Lag (ms). Use this to diagnose UI slowness or verify that your changes haven't degraded the user experience.", "parameters": { "type": "object", "properties": {} } }, { "name": "py_find_usages", "description": "Finds exact string matches of a symbol in a given file or directory.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to file or directory to search." }, "name": { "type": "string", "description": "The symbol/string to search for." } }, "required": ["path", "name"] } }, { "name": "py_get_imports", "description": "Parses a file's AST and returns a strict list of its dependencies.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the .py file." } }, "required": ["path"] } }, { "name": "py_check_syntax", "description": "Runs a quick syntax check on a Python file.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the .py file." } }, "required": ["path"] } }, { "name": "py_get_hierarchy", "description": "Scans the project to find subclasses of a given class.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory path to search in." }, "class_name": { "type": "string", "description": "Name of the base class." } }, "required": ["path", "class_name"] } }, { "name": "py_get_docstring", "description": "Extracts the docstring for a specific module, class, or function.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the .py file." }, "name": { "type": "string", "description": "Name of symbol or 'module' for the file docstring." } }, "required": ["path", "name"] } }, { "name": "get_tree", "description": "Returns a directory structure up to a max depth.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory path." }, "max_depth": { "type": "integer", "description": "Maximum depth to recurse (default 2)." } }, "required": ["path"] } } ]