XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
UTF-8
"""MCP Tools for gpt4free

This module provides MCP tool implementations that wrap gpt4free capabilities:
- WebSearchTool: Web search using ddg search
- WebScrapeTool: Web page scraping and content extraction
- ImageGenerationTool: Image generation using various AI providers
- PythonExecuteTool: Safe Python code execution with whitelisted modules
- FileReadTool: Read files from the ~/.g4f/workspace directory (supports startLine/endLine)
- FileSearchTool: Search files and file contents in the workspace
- FileWriteTool: Write files to the ~/.g4f/workspace directory
- FileListTool: List files in the ~/.g4f/workspace directory
- FileDeleteTool: Delete files from the ~/.g4f/workspace directory
"""

from __future__ import annotations

from typing import Any, Dict
from abc import ABC, abstractmethod
import fnmatch
import re
import urllib.parse

from aiohttp import ClientSession


class MCPTool(ABC):
    """Base class for MCP tools"""

    def __init__(self, safe_mode: bool = False, github_token: Optional[str] = None):
        """Initialize tool with optional safe mode.

        Args:
            safe_mode: When ``True`` the tool operates in a restricted mode
                where callers cannot expand the module allowlist and certain
                sensitive listing operations are blocked.
        """
        self.safe_mode = safe_mode
        self.github_token = github_token

    @property
    @abstractmethod
    def description(self) -> str:
        """Tool description"""
        pass

    @property
    @abstractmethod
    def input_schema(self) -> Dict[str, Any]:
        """JSON schema for tool input parameters"""
        pass

    @abstractmethod
    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        """Execute the tool with given arguments

        Args:
            arguments: Tool input arguments matching the input_schema

        Returns:
            Dict containing either results or an error key with error message
        """
        pass


class WebSearchTool(MCPTool):
    """Web search tool using gpt4free's search capabilities"""

    @property
    def description(self) -> str:
        return "Search the web for information using DuckDuckGo. Returns search results with titles, URLs, and snippets."

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The search query to execute",
                },
                "max_results": {
                    "type": "integer",
                    "description": "Maximum number of results to return (default: 5)",
                    "default": 5,
                },
                "region": {
                    "type": "string",
                    "description": "Search region (default: en-us)",
                },
            },
            "required": ["query"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        """Execute web search

        Returns:
            Dict[str, Any]: Search results or error message
        """
        from ..Provider.search.CachedSearch import CachedSearch

        query = arguments.get("query", "")
        max_results = arguments.get("max_results", 5)
        region = arguments.get("region", "en-us")

        if not query:
            return {"error": "Query parameter is required"}

        try:
            # Perform search - query parameter is used for search execution
            # and prompt parameter holds the content to be searched
            search_results = await anext(
                CachedSearch.create_async_generator(
                    "", [], prompt=query, max_results=max_results, region=region
                )
            )

            return {"query": query, **search_results.get_dict()}

        except Exception as e:
            return {"error": f"Search failed: {str(e)}"}


class ImageGenerationTool(MCPTool):
    """Image generation tool using gpt4free's image generation capabilities"""

    @property
    def description(self) -> str:
        return "Generate images from text prompts using AI image generation providers. Returns a URL to the generated image."

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "prompt": {
                    "type": "string",
                    "description": "The text prompt describing the image to generate",
                },
                "model": {
                    "type": "string",
                    "description": "The image generation model to use (default: flux)",
                    "default": "flux",
                },
                "width": {
                    "type": "integer",
                    "description": "Image width in pixels (default: 1024)",
                    "default": 1024,
                },
                "height": {
                    "type": "integer",
                    "description": "Image height in pixels (default: 1024)",
                    "default": 1024,
                },
            },
            "required": ["prompt"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        """Execute image generation

        Returns:
            Dict[str, Any]: Generated image data or error message
        """
        from ..client import AsyncClient

        prompt = arguments.get("prompt", "")
        model = arguments.get("model", "flux")
        width = arguments.get("width", 1024)
        height = arguments.get("height", 1024)

        if not prompt:
            return {"error": "Prompt parameter is required"}

        try:
            # Generate image using gpt4free client
            client = AsyncClient()

            response = await client.images.generate(
                model=model, prompt=prompt, width=width, height=height
            )

            # Get the image data with proper validation
            if not response:
                return {"error": "Image generation failed: No response from provider"}

            if not hasattr(response, "data") or not response.data:
                return {"error": "Image generation failed: No image data in response"}

            if len(response.data) == 0:
                return {"error": "Image generation failed: Empty image data array"}

            image_data = response.data[0]

            # Check if image_data has url attribute
            if not hasattr(image_data, "url"):
                return {"error": "Image generation failed: No URL in image data"}

            image_url = image_data.url

            template = 'Display the image using this template: <a href="{image}" data-width="{width}" data-height="{height}"><img src="{image}" alt="{prompt}"></a>'

            # Return result based on URL type
            if image_url.startswith("data:"):
                return {
                    "prompt": prompt,
                    "model": model,
                    "width": width,
                    "height": height,
                    "image": image_url,
                    "template": template,
                }
            else:
                if arguments.get("origin") and image_url.startswith("/media/"):
                    image_url = f"{arguments.get('origin')}{image_url}"
                return {
                    "prompt": prompt,
                    "model": model,
                    "width": width,
                    "height": height,
                    "image_url": image_url,
                    "template": template,
                }

        except Exception as e:
            return {"error": f"Image generation failed: {str(e)}"}


class MarkItDownTool(MCPTool):
    """MarkItDown tool for converting URLs to markdown format"""

    @property
    def description(self) -> str:
        return "Convert a URL to markdown format using MarkItDown. Supports HTTP/HTTPS URLs and returns formatted markdown content."

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "The URL to convert to markdown format (must be HTTP/HTTPS)",
                },
                "max_content_length": {
                    "type": "integer",
                    "description": "Maximum content length for processing (default: 10000)",
                    "default": 10000,
                },
            },
            "required": ["url"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        """Execute MarkItDown conversion

        Returns:
            Dict[str, Any]: Markdown content or error message
        """
        try:
            from ..integration.markitdown import MarkItDown
        except ImportError as e:
            return {"error": f"MarkItDown is not installed: {str(e)}"}

        url = arguments.get("url", "")
        max_content_length = arguments.get("max_content_length", 10000)

        if not url:
            return {"error": "URL parameter is required"}

        # Validate URL format
        if not url.startswith(("http://", "https://")):
            return {"error": "URL must start with http:// or https://"}

        try:
            # Initialize MarkItDown
            md = MarkItDown()

            # Convert URL to markdown
            result = md.convert_url(url)

            if not result:
                return {"error": "Failed to convert URL to markdown"}

            # Truncate if content exceeds max length
            if len(result) > max_content_length:
                result = result[:max_content_length] + "\n\n[Content truncated...]"

            return {
                "url": url,
                "markdown_content": result,
                "content_length": len(result),
                "truncated": len(result) > max_content_length,
            }

        except Exception as e:
            return {"error": f"MarkItDown conversion failed: {str(e)}"}


class TextToAudioTool(MCPTool):
    """TextToAudio tool for generating audio from text prompts using Pollinations AI"""

    @property
    def description(self) -> str:
        return "Generate an audio URL from a text prompt using Pollinations AI text-to-speech service. Returns a direct URL to the generated audio file."

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "prompt": {
                    "type": "string",
                    "description": "The text prompt to the audio model (example: 'Read this: Hello, world!')",
                },
                "voice": {
                    "type": "string",
                    "description": "Voice option for text-to-speech (default: 'alloy')",
                    "default": "alloy",
                },
                "url_encode": {
                    "type": "boolean",
                    "description": "Whether to URL-encode the prompt text (default: True)",
                    "default": True,
                },
            },
            "required": ["prompt"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        """Execute text-to-speech conversion

        Returns:
            Dict[str, Any]: Audio URL or error message
        """
        prompt = arguments.get("prompt", "")
        voice = arguments.get("voice", "alloy")
        url_encode = arguments.get("url_encode", True)

        if not prompt:
            return {"error": "Prompt parameter is required"}

        # Validate prompt length (reasonable limit for text-to-speech)
        if len(prompt) > 10000:
            return {"error": "Prompt is too long (max 10000 characters)"}

        try:
            # Prepare the prompt for URL
            if url_encode:
                encoded_prompt = urllib.parse.quote(prompt)
            else:
                encoded_prompt = prompt.replace(" ", "%20")  # Basic space encoding

            # Construct the Pollinations AI text-to-speech URL
            audio_url = f"/backend-api/v2/synthesize/Gemini?text={encoded_prompt}"

            if arguments.get("origin"):
                audio_url = f"{arguments.get('origin')}{audio_url}"
                async with ClientSession() as session:
                    async with session.get(audio_url, max_redirects=0) as resp:
                        audio_url = str(resp.url)

            template = (
                'Play the audio using this template: <audio controls src="{audio_url}">'
            )

            return {
                "prompt": prompt,
                "voice": voice,
                "audio_url": audio_url,
                "template": template,
            }

        except Exception as e:
            return {"error": f"Text-to-speech URL generation failed: {str(e)}"}


class PythonExecuteTool(MCPTool):
    """Safe Python code execution tool with whitelisted module imports.

    Executes the supplied code snippet inside a restricted sandbox where only
    a curated list of modules may be imported and file-system access is limited
    to the ``~/.g4f/workspace`` directory.  The value assigned to the ``result``
    variable (if any) is returned along with captured stdout/stderr.
    """

    @property
    def description(self) -> str:
        return (
            "Execute a Python code snippet safely. Only whitelisted modules may "
            "be imported (math, json, re, datetime, asyncio, aiohttp, g4f, …). "
            "File access is restricted to the ~/.g4f/workspace directory. "
            "Assign the value you want back to a variable named 'result'. "
            "Returns stdout, stderr, and the value of 'result'."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "code": {
                    "type": "string",
                    "description": "Python code to execute in the safe sandbox",
                },
                "allowed_extra_modules": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": (
                        "Optional list of additional module names to allow "
                        "beyond the default whitelist (ignored in safe mode)"
                    ),
                },
                "timeout": {
                    "type": "number",
                    "description": (
                        "Wall-clock seconds to allow before aborting execution "
                        f"(max {30.0}s; ignored in safe mode)"
                    ),
                },
                "max_depth": {
                    "type": "integer",
                    "description": (
                        "Maximum Python call-stack depth inside the sandbox "
                        f"(max {500}; ignored in safe mode)"
                    ),
                },
            },
            "required": ["code"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from .pa_provider import (
            execute_safe_code,
            SAFE_MODULES,
            MAX_EXEC_TIMEOUT,
            MAX_RECURSION_DEPTH,
        )

        code = arguments.get("code", "")
        if not code:
            return {"error": "code parameter is required"}

        if self.safe_mode:
            # In safe mode the caller cannot override any security parameters
            allowed = SAFE_MODULES
            timeout = MAX_EXEC_TIMEOUT
            max_depth = MAX_RECURSION_DEPTH
        else:
            extra_names = arguments.get("allowed_extra_modules") or []
            allowed = SAFE_MODULES | frozenset(extra_names)
            # Allow callers to reduce (but not exceed) the defaults
            requested_timeout = arguments.get("timeout")
            if requested_timeout is not None:
                try:
                    timeout = min(float(requested_timeout), MAX_EXEC_TIMEOUT)
                except (TypeError, ValueError):
                    return {"error": "timeout must be a number"}
            else:
                timeout = MAX_EXEC_TIMEOUT
            requested_depth = arguments.get("max_depth")
            if requested_depth is not None:
                try:
                    max_depth = min(int(requested_depth), MAX_RECURSION_DEPTH)
                except (TypeError, ValueError):
                    return {"error": "max_depth must be an integer"}
            else:
                max_depth = MAX_RECURSION_DEPTH

        try:
            exec_result = execute_safe_code(
                code,
                allowed_modules=allowed,
                timeout=timeout,
                max_depth=max_depth,
            )
            return exec_result.to_dict()
        except Exception as exc:
            return {"error": f"Execution error: {exc}"}


class FileReadTool(MCPTool):
    """Read a file from the ``~/.g4f/workspace`` directory."""

    @property
    def description(self) -> str:
        return (
            "Read the text content of a file inside the ~/.g4f/workspace directory. "
            "Provide a relative path from the workspace root. "
            "Optionally specify startLine and endLine (1-based) to read only a range of lines."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Relative path to the file inside the workspace",
                },
                "startLine": {
                    "type": "number",
                    "description": "The line number to start reading from, 1-based. If omitted, reads from the beginning.",
                },
                "endLine": {
                    "type": "number",
                    "description": "The inclusive line number to end reading at, 1-based. If omitted, reads to the end of file.",
                },
            },
            "required": ["path"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from .pa_provider import get_workspace_dir

        rel_path = arguments.get("path", "")
        if not rel_path:
            return {"error": "path parameter is required"}

        start_line = arguments.get("startLine")
        end_line = arguments.get("endLine")

        if start_line is not None:
            try:
                start_line = int(start_line)
            except (TypeError, ValueError):
                return {"error": "startLine must be a number"}
            if start_line < 1:
                return {"error": "startLine must be >= 1"}

        if end_line is not None:
            try:
                end_line = int(end_line)
            except (TypeError, ValueError):
                return {"error": "endLine must be a number"}
            if start_line is not None and end_line < start_line:
                return {"error": "endLine must be >= startLine"}

        workspace = get_workspace_dir().resolve()
        try:
            target = (workspace / rel_path).resolve()
            if not str(target).startswith(str(workspace)):
                return {"error": "Access outside the workspace is not allowed"}
            if not target.exists():
                return {"error": f"File not found: {rel_path}"}
            if not target.is_file():
                return {"error": f"Path is not a file: {rel_path}"}

            if start_line is not None or end_line is not None:
                lines = target.read_text(encoding="utf-8").splitlines(True)
                total_lines = len(lines)
                s = (start_line - 1) if start_line is not None else 0
                e = end_line if end_line is not None else total_lines
                s = max(0, min(s, total_lines))
                e = max(s, min(e, total_lines))
                content = "".join(lines[s:e])
                return {
                    "path": rel_path,
                    "content": content,
                    "size": len(content),
                    "startLine": s + 1,
                    "endLine": e,
                    "totalLines": total_lines,
                }

            content = target.read_text(encoding="utf-8")
            return {
                "path": rel_path,
                "content": content,
                "size": len(content),
            }
        except Exception as exc:
            return {"error": f"Read failed: {exc}"}


class FileListTool(MCPTool):
    """List files and directories inside the ``~/.g4f/workspace`` directory."""

    @property
    def description(self) -> str:
        return (
            "List files and directories inside the ~/.g4f/workspace directory. "
            "Optionally provide a subdirectory path relative to the workspace root. "
            "Returns names, types, and sizes."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": (
                        "Relative path to a subdirectory inside the workspace "
                        "(default: workspace root)"
                    ),
                    "default": "",
                },
                "recursive": {
                    "type": "boolean",
                    "description": "If true, list files recursively (default: false)",
                    "default": False,
                },
            },
            "required": [],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from .pa_provider import get_workspace_dir, is_hidden_file

        rel_path = arguments.get("path", "") or ""
        recursive = bool(arguments.get("recursive", False))

        workspace = get_workspace_dir().resolve()
        try:
            target = (workspace / rel_path).resolve() if rel_path else workspace
            if not str(target).startswith(str(workspace)):
                return {"error": "Access outside the workspace is not allowed"}
            if self.safe_mode and target == workspace:
                return {
                    "error": "Listing the workspace root directory is not allowed in safe mode"
                }
            if not target.exists():
                return {"error": f"Directory not found: {rel_path or '/'}"}
            if not target.is_dir():
                return {"error": f"Path is not a directory: {rel_path}"}

            entries = []
            skipped = 0
            iterator = target.rglob("*") if recursive else target.iterdir()
            for entry in sorted(iterator):
                try:
                    rel = str(entry.relative_to(workspace))
                    if is_hidden_file(rel):
                        continue
                    info: Dict[str, Any] = {
                        "path": rel,
                        "type": "file" if entry.is_file() else "directory",
                    }
                    if entry.is_file():
                        info["size"] = entry.stat().st_size
                    entries.append(info)
                except Exception:
                    skipped += 1
                    continue

            result: Dict[str, Any] = {
                "workspace": "" if self.safe_mode else str(workspace),
                "path": rel_path or "/",
                "entries": entries,
                "count": len(entries),
            }
            if skipped:
                result["skipped"] = skipped
            return result
        except Exception as exc:
            return {"error": f"List failed: {exc}"}


class FileDeleteTool(MCPTool):
    """Delete a file from the ``~/.g4f/workspace`` directory."""

    @property
    def description(self) -> str:
        return (
            "Delete a file from the ~/.g4f/workspace directory. "
            "Provide a relative path from the workspace root. "
            "Only files can be deleted; directories are not removed."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Relative path to the file inside the workspace",
                }
            },
            "required": ["path"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from .pa_provider import get_workspace_dir

        rel_path = arguments.get("path", "")
        if not rel_path:
            return {"error": "path parameter is required"}

        workspace = get_workspace_dir().resolve()
        try:
            target = (workspace / rel_path).resolve()
            if not str(target).startswith(str(workspace)):
                return {"error": "Access outside the workspace is not allowed"}
            if not target.exists():
                return {"error": f"File not found: {rel_path}"}
            if not target.is_file():
                return {
                    "error": f"Path is not a file (directories cannot be deleted): {rel_path}"
                }
            target.unlink()
            return {"path": rel_path, "deleted": True}
        except Exception as exc:
            return {"error": f"Delete failed: {exc}"}


class CreateDirectoryTool(MCPTool):
    """Create a directory (and all parents) inside the ``~/.g4f/workspace`` directory."""

    @property
    def description(self) -> str:
        return (
            "Create a new directory structure in the ~/.g4f/workspace. "
            "Will recursively create all directories in the path, like mkdir -p. "
            "You do not need to use this tool before using create_file, that tool will automatically create the needed directories."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "dirPath": {
                    "type": "string",
                    "description": "Relative path to the directory to create inside the workspace",
                }
            },
            "required": ["dirPath"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from .pa_provider import get_workspace_dir

        rel_path = arguments.get("dirPath", "")
        if not rel_path:
            return {"error": "dirPath parameter is required"}

        workspace = get_workspace_dir().resolve()
        try:
            target = (workspace / rel_path).resolve()
            if not str(target).startswith(str(workspace)):
                return {"error": "Access outside the workspace is not allowed"}
            target.mkdir(parents=True, exist_ok=True)
            return {"dirPath": rel_path, "created": True}
        except Exception as exc:
            return {"error": f"Create directory failed: {exc}"}


class CreateFileTool(MCPTool):
    """Create a new file inside the ``~/.g4f/workspace`` directory."""

    @property
    def description(self) -> str:
        return (
            "Create a new file in the ~/.g4f/workspace with the specified content. "
            "The directory will be created if it does not already exist. "
            "Never use this tool to edit a file that already exists."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "filePath": {
                    "type": "string",
                    "description": "Relative path to the file to create inside the workspace",
                },
                "content": {
                    "type": "string",
                    "description": "The content to write to the file",
                },
            },
            "required": ["filePath", "content"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from .pa_provider import get_workspace_dir

        rel_path = arguments.get("filePath", "")
        content = arguments.get("content")

        if not rel_path:
            return {"error": "filePath parameter is required"}
        if content is None:
            return {"error": "content parameter is required"}

        workspace = get_workspace_dir().resolve()
        try:
            target = (workspace / rel_path).resolve()
            if not str(target).startswith(str(workspace)):
                return {"error": "Access outside the workspace is not allowed"}
            if target.exists():
                return {
                    "error": f"File already exists: {rel_path}. Use FileWriteTool to overwrite."
                }
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_text(content, encoding="utf-8")
            result: Dict[str, Any] = {
                "filePath": rel_path,
                "created": True,
                "size": len(content),
            }
            origin = arguments.get("origin")
            if origin:
                result["url"] = f"{origin}/pa/files/{rel_path}"
            return result
        except Exception as exc:
            return {"error": f"Create file failed: {exc}"}


class FileWriteTool(MCPTool):
    """Write (or create) a file inside the ``~/.g4f/workspace`` directory."""

    @property
    def description(self) -> str:
        return (
            "Write text content to a file inside the ~/.g4f/workspace directory. "
            "Creates parent directories as needed. Overwrites the file if it already exists. "
            "Provide a relative path from the workspace root and the content to write."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Relative path to the file inside the workspace",
                },
                "content": {
                    "type": "string",
                    "description": "Text content to write to the file",
                },
                "append": {
                    "type": "boolean",
                    "description": "If true, append to existing file instead of overwriting (default: false)",
                    "default": False,
                },
            },
            "required": ["path", "content"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from .pa_provider import get_workspace_dir

        rel_path = arguments.get("path", "")
        content = arguments.get("content")
        append = bool(arguments.get("append", False))

        if not rel_path:
            return {"error": "path parameter is required"}
        if content is None:
            return {"error": "content parameter is required"}

        workspace = get_workspace_dir().resolve()
        try:
            target = (workspace / rel_path).resolve()
            if not str(target).startswith(str(workspace)):
                return {"error": "Access outside the workspace is not allowed"}
            target.parent.mkdir(parents=True, exist_ok=True)
            if append:
                with open(target, "a", encoding="utf-8") as f:
                    f.write(content)
            else:
                target.write_text(content, encoding="utf-8")
            result: Dict[str, Any] = {
                "path": rel_path,
                "size": len(content),
                "appended": append,
            }
            origin = arguments.get("origin")
            if origin:
                result["url"] = f"{origin}/pa/files/{rel_path}"
            return result
        except Exception as exc:
            return {"error": f"Write failed: {exc}"}


class ReplaceStringInFileTool(MCPTool):
    """Replace an exact string in a file inside the ``~/.g4f/workspace`` directory."""

    @property
    def description(self) -> str:
        return (
            "Replace an exact string in a file inside the ~/.g4f/workspace directory. "
            "Provide the relative file path, the exact old string, and the new string. "
            "Only the first occurrence is replaced. Returns whether a replacement was made."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "filePath": {
                    "type": "string",
                    "description": "Relative path to the file inside the workspace",
                },
                "oldString": {
                    "type": "string",
                    "description": "The exact text to replace. Must uniquely identify one location.",
                },
                "newString": {
                    "type": "string",
                    "description": "The text to replace oldString with.",
                },
            },
            "required": ["filePath", "oldString", "newString"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from .pa_provider import get_workspace_dir

        rel_path = arguments.get("filePath", "")
        old_string = arguments.get("oldString")
        new_string = arguments.get("newString")

        if not rel_path:
            return {"error": "filePath parameter is required"}
        if old_string is None:
            return {"error": "oldString parameter is required"}
        if new_string is None:
            return {"error": "newString parameter is required"}

        workspace = get_workspace_dir().resolve()
        try:
            target = (workspace / rel_path).resolve()
            if not str(target).startswith(str(workspace)):
                return {"error": "Access outside the workspace is not allowed"}
            if not target.exists():
                return {"error": f"File not found: {rel_path}"}
            if not target.is_file():
                return {"error": f"Path is not a file: {rel_path}"}

            content = target.read_text(encoding="utf-8")
            if old_string not in content:
                return {
                    "error": "oldString not found in file. Include more surrounding context to uniquely identify the location."
                }

            new_content = content.replace(old_string, new_string, 1)
            target.write_text(new_content, encoding="utf-8")
            return {
                "filePath": rel_path,
                "replaced": True,
                "size": len(new_content),
            }
        except Exception as exc:
            return {"error": f"Replace failed: {exc}"}


class FetchWebpageTool(MCPTool):
    """Fetch and return the main content from one or more web pages."""

    @property
    def description(self) -> str:
        return (
            "Fetches the main content from web pages. Useful for summarizing or analyzing webpage content. "
            "Provide one or more URLs and a query describing what content to find."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "urls": {
                    "type": "array",
                    "items": {"type": "string"},
                    "description": "An array of URLs to fetch content from.",
                },
                "query": {
                    "type": "string",
                    "description": "The query to search for in the web page's content.",
                },
                "max_words": {
                    "type": "integer",
                    "description": "Maximum number of words to extract per page (default: 1000)",
                    "default": 1000,
                },
            },
            "required": ["urls", "query"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from ..tools.fetch_and_scrape import fetch_and_scrape

        urls = arguments.get("urls", [])
        query = arguments.get("query", "")
        max_words = int(arguments.get("max_words", 1000))

        if not urls:
            return {"error": "urls parameter is required"}
        if not query:
            return {"error": "query parameter is required"}

        results = []
        async with ClientSession() as session:
            for url in urls:
                try:
                    content = await fetch_and_scrape(
                        session=session,
                        url=url,
                        max_words=max_words,
                        add_metadata=True,
                    )
                    results.append(
                        {
                            "url": url,
                            "content": content or "",
                            "word_count": len((content or "").split()),
                        }
                    )
                except Exception as exc:
                    results.append({"url": url, "error": str(exc)})

        return {"query": query, "results": results}


class FileSearchGlobTool(MCPTool):
    """Search for files in the workspace by glob pattern."""

    @property
    def description(self) -> str:
        return (
            "Search for files in the ~/.g4f/workspace by glob pattern. "
            "Returns the paths of matching files. "
            "Examples: **/*.py to match all Python files; src/** to match all files under src/."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "Glob pattern to match file names or paths (e.g. '**/*.py', 'src/**')",
                },
                "maxResults": {
                    "type": "number",
                    "description": "The maximum number of results to return (default: 50)",
                    "default": 50,
                },
            },
            "required": ["query"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from .pa_provider import get_workspace_dir, is_hidden_file

        pattern = arguments.get("query", "")
        max_results = int(arguments.get("maxResults", 50))

        if not pattern:
            return {"error": "query parameter is required"}

        workspace = get_workspace_dir().resolve()
        try:
            matches = []
            for entry in workspace.rglob("*"):
                if not entry.is_file():
                    continue
                rel = entry.relative_to(workspace)
                rel_str = rel.as_posix()
                if is_hidden_file(rel_str):
                    continue
                if fnmatch.fnmatch(rel_str, pattern) or fnmatch.fnmatch(
                    entry.name, pattern
                ):
                    matches.append(rel_str)
                if len(matches) >= max_results:
                    break
            return {"pattern": pattern, "matches": matches, "count": len(matches)}
        except Exception as exc:
            return {"error": f"File search failed: {exc}"}


class GrepSearchTool(MCPTool):
    """Fast text search in workspace files."""

    @property
    def description(self) -> str:
        return (
            "Do a fast text search in the ~/.g4f/workspace files. "
            "Supports exact string or regex patterns. "
            "Use includePattern to restrict to specific file types. "
            "Returns matching file paths and line snippets."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "The pattern to search for. Use regex alternation (e.g. 'word1|word2') for multiple words.",
                },
                "isRegexp": {
                    "type": "boolean",
                    "description": "Whether the pattern is a regular expression.",
                    "default": False,
                },
                "includePattern": {
                    "type": "string",
                    "description": "Glob pattern to filter which files to search (e.g. '**/*.py').",
                },
                "maxResults": {
                    "type": "number",
                    "description": "Maximum number of matching lines to return (default: 100)",
                    "default": 100,
                },
            },
            "required": ["query", "isRegexp"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from .pa_provider import get_workspace_dir, is_hidden_file

        pattern = arguments.get("query", "")
        is_regexp = bool(arguments.get("isRegexp", False))
        include_pattern = arguments.get("includePattern")
        max_results = int(arguments.get("maxResults", 100))

        if not pattern:
            return {"error": "query parameter is required"}

        workspace = get_workspace_dir().resolve()
        try:
            if is_regexp:
                try:
                    compiled = re.compile(pattern, re.IGNORECASE)
                except re.error as exc:
                    return {"error": f"Invalid regular expression: {exc}"}
            else:
                compiled = None

            matches = []
            for entry in sorted(workspace.rglob("*")):
                if not entry.is_file():
                    continue
                rel_str = entry.relative_to(workspace).as_posix()
                if is_hidden_file(rel_str):
                    continue
                if include_pattern and not fnmatch.fnmatch(rel_str, include_pattern):
                    continue
                try:
                    lines = entry.read_text(
                        encoding="utf-8", errors="ignore"
                    ).splitlines()
                except Exception:
                    continue
                for lineno, line in enumerate(lines, 1):
                    if is_regexp:
                        hit = bool(compiled.search(line))
                    else:
                        hit = pattern.lower() in line.lower()
                    if hit:
                        matches.append(
                            {
                                "path": rel_str,
                                "line": lineno,
                                "text": line,
                            }
                        )
                    if len(matches) >= max_results:
                        break
                if len(matches) >= max_results:
                    break

            return {"pattern": pattern, "matches": matches, "count": len(matches)}
        except Exception as exc:
            return {"error": f"Grep search failed: {exc}"}


class GithubRepoTool(MCPTool):
    """Search a GitHub repository for relevant source code snippets."""

    @property
    def description(self) -> str:
        return (
            "Searches a GitHub repository for relevant source code snippets. "
            "Only use this tool if the user is clearly asking for code from a specific GitHub repository. "
            "Do not use for repos the user has open locally."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "repo": {
                    "type": "string",
                    "description": "The GitHub repository to search, formatted as '<owner>/<repo>'.",
                },
                "query": {
                    "type": "string",
                    "description": "The query to search for in the repository.",
                },
            },
            "required": ["repo", "query"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        repo = arguments.get("repo", "")
        query = arguments.get("query", "")

        if not repo:
            return {"error": "repo parameter is required"}
        if not query:
            return {"error": "query parameter is required"}
        if "/" not in repo:
            return {"error": "repo must be formatted as '<owner>/<repo>'"}

        try:
            search_url = f"https://api.github.com/search/code?q={urllib.parse.quote(query)}+repo:{urllib.parse.quote(repo)}"
            headers = {
                "Accept": "application/vnd.github+json",
                "X-GitHub-Api-Version": "2022-11-28",
                "Authorization": f"Bearer {self.github_token}" if self.github_token else "",
            }
            async with ClientSession() as session:
                async with session.get(search_url, headers=headers) as resp:
                    if resp.status == 403:
                        return {
                            "error": "GitHub API rate limit exceeded. Try again later."
                        }
                    if resp.status != 200:
                        return {"error": f"GitHub API error: HTTP {resp.status}"}
                    data = await resp.json()

            items = data.get("items", [])[:10]
            results = []
            for item in items:
                results.append(
                    {
                        "path": item.get("path"),
                        "url": item.get("html_url"),
                        "repository": item.get("repository", {}).get("full_name"),
                    }
                )
            return {
                "repo": repo,
                "query": query,
                "results": results,
                "count": len(results),
            }
        except Exception as exc:
            return {"error": f"GitHub repo search failed: {exc}"}


class GithubTextSearchTool(MCPTool):
    """Lexically search a GitHub repository or organisation for files with specific keywords."""

    @property
    def description(self) -> str:
        return (
            "Lexically searches a GitHub repository or organization for files containing "
            "specific keywords or code patterns. Use 'owner/repo' to search a single repository "
            "or an org name (no slash) to search across an entire organization. "
            "Unlike semantic search, this uses keyword matching."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "scope": {
                    "type": "string",
                    "description": "GitHub scope: 'owner/repo' for a single repo, or an org name for org-wide search.",
                },
                "query": {
                    "type": "string",
                    "description": "Keyword search query. Supports GitHub code search syntax (e.g. 'language:python', 'path:src/').",
                },
                "maxResults": {
                    "type": "number",
                    "description": "Maximum number of search results to return (default: 100)",
                    "default": 100,
                },
            },
            "required": ["scope", "query"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        scope = arguments.get("scope", "")
        query = arguments.get("query", "")
        max_results = int(arguments.get("maxResults", 100))

        if not scope:
            return {"error": "scope parameter is required"}
        if not query:
            return {"error": "query parameter is required"}

        try:
            # Determine if scope is a repo or an org
            if "/" in scope:
                qualifier = f"repo:{scope}"
            else:
                qualifier = f"org:{scope}"

            search_url = (
                f"https://api.github.com/search/code"
                f"?q={urllib.parse.quote(query)}+{urllib.parse.quote(qualifier)}"
                f"&per_page={min(max_results, 100)}"
            )
            headers = {
                "Accept": "application/vnd.github+json",
                "X-GitHub-Api-Version": "2022-11-28",
                "Authorization": f"Bearer {self.github_token}" if self.github_token else "",
            }
            async with ClientSession() as session:
                async with session.get(search_url, headers=headers) as resp:
                    if resp.status == 403:
                        return {
                            "error": "GitHub API rate limit exceeded. Try again later."
                        }
                    if resp.status != 200:
                        return {"error": f"GitHub API error: HTTP {resp.status}"}
                    data = await resp.json()

            items = data.get("items", [])[:max_results]
            results = []
            for item in items:
                results.append(
                    {
                        "path": item.get("path"),
                        "url": item.get("html_url"),
                        "repository": item.get("repository", {}).get("full_name"),
                        "name": item.get("name"),
                    }
                )
            return {
                "scope": scope,
                "query": query,
                "results": results,
                "count": len(results),
            }
        except Exception as exc:
            return {"error": f"GitHub text search failed: {exc}"}


class ApplyPatchTool(MCPTool):
    """Apply a unified diff patch to a file or directory using the system 'patch' command."""

    @property
    def description(self) -> str:
        return (
            "Apply a unified diff patch to a target file or directory using the system 'patch' command. "
            "Provide the target path, patch content, and optional parameters for strip level, backup, and dry run. "
            "Returns success status, output from the patch command, and any error messages."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "target_path": {
                    "type": "string",
                    "description": "Path to the target file or directory to patch",
                },
                "patch_content": {
                    "type": "string",
                    "description": "The unified diff patch content to apply",
                },
                "strip": {
                    "type": "integer",
                    "description": "Number of leading path components to strip from file paths in the patch (default: 1)",
                    "default": 1,
                },
                "backup": {
                    "type": "boolean",
                    "description": "Whether to create backup files before patching (default: false)",
                    "default": False,
                },
                "dry_run": {
                    "type": "boolean",
                    "description": (
                        "If true, perform a dry run without making changes (default: false). "
                        "The output will indicate whether the patch would apply cleanly."
                    ),
                    "default": False,
                },
            },
            "required": ["target_path", "patch_content"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from .pa_provider import get_workspace_dir
        from .apply_patch import apply_patch_with_fallback

        target_path = arguments.get("target_path", "")
        patch_content = arguments.get("patch_content", "")
        backup = bool(arguments.get("backup", False))
        dry_run = bool(arguments.get("dry_run", False))
        workspace = get_workspace_dir().resolve()
        try:
            target = (workspace / target_path).resolve()
            if not str(target).startswith(str(workspace)):
                return {"error": "Access outside the workspace is not allowed"}
            if not target.exists():
                return {"error": f"File not found: {target_path}"}
        except Exception as exc:
            return {"error": f"Invalid target path: {exc}"}

        return apply_patch_with_fallback(patch_content, str(target), backup, dry_run)


class TokenOptimizerTool(MCPTool):
    """Optimize a prompt (messages list) to reduce token waste before sending to providers.

    Wraps the optional `token_optimizer` plugin
    (https://github.com/alexgreensh/token-optimizer). When the package is not
    installed the tool reports that it is unavailable so callers can fall back
    to the built-in request optimizer.
    """

    @property
    def description(self) -> str:
        return (
            "Optimize a prompt (OpenAI-style messages list) to cut wasted input tokens "
            "before it reaches AI providers. Compresses verbose system prompts, repeated "
            "tool output, and stale context. Requires the optional `token_optimizer` package. "
            "Returns the optimized messages and the number of tokens saved."
        )

    @property
    def input_schema(self) -> Dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "messages": {
                    "type": "array",
                    "description": "OpenAI-style messages list to optimize.",
                    "items": {"type": "object"},
                },
                "tools": {
                    "type": "array",
                    "description": "Optional list of tool definitions to compress alongside the messages.",
                    "items": {"type": "object"},
                },
            },
            "required": ["messages"],
        }

    async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
        from ..tools.token_optimizer import (
            is_available,
            get_install_path,
            optimize_messages,
        )

        if not is_available():
            return {
                "available": False,
                "error": (
                    "token_optimizer package is not installed. "
                    "Install it from https://github.com/alexgreensh/token-optimizer "
                    "to enable prompt optimization."
                ),
            }

        messages = arguments.get("messages")
        if not isinstance(messages, list) or not messages:
            return {"available": True, "error": "messages must be a non-empty list"}

        tools = arguments.get("tools")
        # Copy so we don't mutate the caller's input across the MCP boundary.
        messages_copy = [dict(m) if isinstance(m, dict) else m for m in messages]
        tools_copy = (
            [dict(t) if isinstance(t, dict) else t for t in tools]
            if isinstance(tools, list)
            else None
        )

        try:
            saved_tokens, logs = optimize_messages(messages_copy, tools_copy)
        except Exception as exc:
            return {"available": True, "error": f"Optimization failed: {exc}"}

        return {
            "available": True,
            "install_path": get_install_path(),
            "saved_tokens": saved_tokens,
            "logs": logs,
            "optimized_messages": messages_copy,
        }