XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/gpt4free

feat: add FileReadLinesTool and FileSearchTool for enhanced file operations

7a99598d
hlohaus <hlohaus@users.noreply.github.com>
提交于

代码差异

4 个文件 +281 -4
Modified etc/unittest/mcp.py +33 -1
@@ -8,7 +8,8 @@ from pathlib import Path
8 8 from g4f.mcp.server import MCPServer, MCPRequest
9 9 from g4f.mcp.tools import (
10 10 WebSearchTool, WebScrapeTool, ImageGenerationTool,
11 PythonExecuteTool, FileReadTool, FileWriteTool, FileListTool, FileDeleteTool,
11 PythonExecuteTool, FileReadTool, FileReadLinesTool, FileSearchTool,
12 FileWriteTool, FileListTool, FileDeleteTool,
12 13 )
13 14 from g4f.mcp.pa_provider import execute_safe_code, get_workspace_dir, SAFE_MODULES
14 15
@@ -37,6 +38,8 @@ class TestMCPServer(unittest.IsolatedAsyncioTestCase):
37 38 self.assertIn('image_generation', server.tools)
38 39 self.assertIn('python_execute', server.tools)
39 40 self.assertIn('file_read', server.tools)
41 self.assertIn('file_read_lines', server.tools)
42 self.assertIn('file_search', server.tools)
40 43 self.assertIn('file_write', server.tools)
41 44 self.assertIn('file_list', server.tools)
42 45 self.assertIn('file_delete', server.tools)
@@ -56,6 +59,11 @@ class TestMCPServer(unittest.IsolatedAsyncioTestCase):
56 59 self.assertIsNotNone(response.result)
57 60 self.assertEqual(response.result["protocolVersion"], "2024-11-05")
58 61 self.assertIn("serverInfo", response.result)
62 self.assertIn("capabilities", response.result)
63 self.assertIn("tools", response.result["capabilities"])
64 self.assertIsInstance(response.result["capabilities"]["tools"], dict)
65 self.assertIn("file_read", response.result["capabilities"]["tools"])
66 self.assertIn("file_read_lines", response.result["capabilities"]["tools"])
59 67
60 68 async def test_tools_list(self):
61 69 """Test tools/list method"""
@@ -79,6 +87,8 @@ class TestMCPServer(unittest.IsolatedAsyncioTestCase):
79 87 self.assertIn("image_generation", tool_names)
80 88 self.assertIn("python_execute", tool_names)
81 89 self.assertIn("file_read", tool_names)
90 self.assertIn("file_read_lines", tool_names)
91 self.assertIn("file_search", tool_names)
82 92 self.assertIn("file_write", tool_names)
83 93 self.assertIn("file_list", tool_names)
84 94 self.assertIn("file_delete", tool_names)
@@ -340,6 +350,28 @@ class TestFilesTools(unittest.IsolatedAsyncioTestCase):
340 350 result = await read_tool.execute({"path": "../../etc/passwd"})
341 351 self.assertIn("error", result)
342 352
353 async def test_file_read_lines(self):
354 write_tool = FileWriteTool()
355 read_lines_tool = FileReadLinesTool()
356
357 await write_tool.execute({"path": self.test_file, "content": "line1\nline2\nline3\n"})
358 result = await read_lines_tool.execute({"path": self.test_file, "start_line": 2, "end_line": 3})
359
360 self.assertNotIn("error", result)
361 self.assertEqual(result["returned_lines"], 2)
362 self.assertEqual(result["lines"], ["line2\n", "line3\n"])
363
364 async def test_file_search_by_pattern_and_content(self):
365 write_tool = FileWriteTool()
366 search_tool = FileSearchTool()
367
368 await write_tool.execute({"path": self.test_file, "content": "hello workspace"})
369 result = await search_tool.execute({"pattern": "*test.txt", "query": "workspace"})
370
371 self.assertNotIn("error", result)
372 self.assertTrue(result["count"] >= 1)
373 self.assertTrue(any(self.test_file in match["path"] for match in result["matches"]))
374
343 375 async def test_file_append(self):
344 376 write_tool = FileWriteTool()
345 377 read_tool = FileReadTool()
Modified g4f/mcp/__init__.py +4 -0
@@ -19,6 +19,8 @@ from .tools import (
19 19 ImageGenerationTool,
20 20 PythonExecuteTool,
21 21 FileReadTool,
22 FileReadLinesTool,
23 FileSearchTool,
22 24 FileWriteTool,
23 25 FileListTool,
24 26 FileDeleteTool,
@@ -45,6 +47,8 @@ __all__ = [
45 47 # New tools
46 48 'PythonExecuteTool',
47 49 'FileReadTool',
50 'FileReadLinesTool',
51 'FileSearchTool',
48 52 'FileWriteTool',
49 53 'FileListTool',
50 54 'FileDeleteTool',
Modified g4f/mcp/server.py +6 -3
@@ -28,8 +28,8 @@ from ..image.copy_images import get_media_dir, copy_media, get_source_url
28 28
29 29 from .tools import (
30 30 MarkItDownTool, TextToAudioTool, WebSearchTool, WebScrapeTool, ImageGenerationTool,
31 PythonExecuteTool, FileReadTool, FileWriteTool, FileListTool, FileDeleteTool,
32 ApplyPatchTool
31 PythonExecuteTool, FileReadTool, FileReadLinesTool, FileSearchTool,
32 FileWriteTool, FileListTool, FileDeleteTool, ApplyPatchTool
33 33 )
34 34
35 35
@@ -77,6 +77,8 @@ class MCPServer:
77 77 'python_execute': PythonExecuteTool(safe_mode=safe_mode),
78 78 'apply_patch': ApplyPatchTool(),
79 79 'file_read': FileReadTool(),
80 'file_read_lines': FileReadLinesTool(),
81 'file_search': FileSearchTool(),
80 82 'file_write': FileWriteTool(),
81 83 'file_list': FileListTool(safe_mode=safe_mode),
82 84 'file_delete': FileDeleteTool(),
@@ -109,11 +111,12 @@ class MCPServer:
109 111
110 112 # Handle MCP protocol methods
111 113 if method == "initialize":
114 tool_list = self.get_tool_list()
112 115 result = {
113 116 "protocolVersion": "2024-11-05",
114 117 "serverInfo": self.server_info,
115 118 "capabilities": {
116 "tools": {}
119 "tools": {tool["name"]: tool for tool in tool_list}
117 120 }
118 121 }
119 122 return MCPResponse(jsonrpc="2.0", id=request.id, result=result)
Modified g4f/mcp/tools.py +238 -0
@@ -6,6 +6,8 @@ This module provides MCP tool implementations that wrap gpt4free capabilities:
6 6 - ImageGenerationTool: Image generation using various AI providers
7 7 - PythonExecuteTool: Safe Python code execution with whitelisted modules
8 8 - FileReadTool: Read files from the ~/.g4f/workspace directory
9 - FileReadLinesTool: Read a range of lines from a workspace file
10 - FileSearchTool: Search files and file contents in the workspace
9 11 - FileWriteTool: Write files to the ~/.g4f/workspace directory
10 12 - FileListTool: List files in the ~/.g4f/workspace directory
11 13 - FileDeleteTool: Delete files from the ~/.g4f/workspace directory
@@ -15,6 +17,8 @@ from __future__ import annotations
15 17
16 18 from typing import Any, Dict
17 19 from abc import ABC, abstractmethod
20 import fnmatch
21 import re
18 22 import urllib.parse
19 23
20 24 from aiohttp import ClientSession
@@ -624,6 +628,105 @@ class FileReadTool(MCPTool):
624 628 return {"error": f"Read failed: {exc}"}
625 629
626 630
631 class FileReadLinesTool(MCPTool):
632 """Read a range of lines from a file inside the workspace."""
633
634 @property
635 def description(self) -> str:
636 return (
637 "Read a range of lines from a text file inside the ~/.g4f/workspace directory. "
638 "Provide a relative path and optional start/end line indexes."
639 )
640
641 @property
642 def input_schema(self) -> Dict[str, Any]:
643 return {
644 "type": "object",
645 "properties": {
646 "path": {
647 "type": "string",
648 "description": "Relative path to the file inside the workspace",
649 },
650 "start_line": {
651 "type": "integer",
652 "description": "1-based first line to read (default: 1)",
653 "default": 1,
654 },
655 "end_line": {
656 "type": "integer",
657 "description": "1-based last line to read (inclusive)",
658 },
659 "max_lines": {
660 "type": "integer",
661 "description": "Maximum number of lines to return when end_line is not provided",
662 "default": 1000,
663 },
664 },
665 "required": ["path"],
666 }
667
668 async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
669 from .pa_provider import get_workspace_dir
670
671 rel_path = arguments.get("path", "")
672 if not rel_path:
673 return {"error": "path parameter is required"}
674
675 start_line = int(arguments.get("start_line", 1))
676 end_line = arguments.get("end_line")
677 max_lines = int(arguments.get("max_lines", 1000))
678
679 if start_line < 1:
680 return {"error": "start_line must be >= 1"}
681
682 if end_line is not None:
683 try:
684 end_line = int(end_line)
685 except (TypeError, ValueError):
686 return {"error": "end_line must be an integer"}
687 if end_line < start_line:
688 return {"error": "end_line must be greater than or equal to start_line"}
689
690 workspace = get_workspace_dir()
691 try:
692 target = (workspace / rel_path).resolve()
693 if not str(target).startswith(str(workspace.resolve())):
694 return {"error": "Access outside the workspace is not allowed"}
695 if not target.exists():
696 return {"error": f"File not found: {rel_path}"}
697 if not target.is_file():
698 return {"error": f"Path is not a file: {rel_path}"}
699
700 lines = target.read_text(encoding="utf-8").splitlines(True)
701 total_lines = len(lines)
702 if start_line > total_lines:
703 return {
704 "path": rel_path,
705 "start_line": start_line,
706 "end_line": end_line,
707 "lines": [],
708 "total_lines": total_lines,
709 }
710
711 start_index = start_line - 1
712 if end_line is None:
713 end_index = min(total_lines, start_index + max_lines)
714 else:
715 end_index = min(total_lines, end_line)
716
717 selected = lines[start_index:end_index]
718 return {
719 "path": rel_path,
720 "start_line": start_line,
721 "end_line": end_index,
722 "lines": selected,
723 "total_lines": total_lines,
724 "returned_lines": len(selected),
725 }
726 except Exception as exc:
727 return {"error": f"Read lines failed: {exc}"}
728
729
627 730 class FileWriteTool(MCPTool):
628 731 """Write (or create) a file inside the ``~/.g4f/workspace`` directory."""
629 732
@@ -693,6 +796,141 @@ class FileWriteTool(MCPTool):
693 796 return {"error": f"Write failed: {exc}"}
694 797
695 798
799 class FileSearchTool(MCPTool):
800 """Search files and file contents inside the ``~/.g4f/workspace`` directory."""
801
802 @property
803 def description(self) -> str:
804 return (
805 "Search files and contents inside the ~/.g4f/workspace directory. "
806 "Provide a relative path, filename pattern, or text query to search."
807 )
808
809 @property
810 def input_schema(self) -> Dict[str, Any]:
811 return {
812 "type": "object",
813 "properties": {
814 "path": {
815 "type": "string",
816 "description": (
817 "Relative path to a workspace directory to search in "
818 "(default: workspace root)"
819 ),
820 "default": "",
821 },
822 "pattern": {
823 "type": "string",
824 "description": (
825 "File name glob or substring pattern to filter files "
826 "(example: '*.py' or 'README')."
827 ),
828 },
829 "query": {
830 "type": "string",
831 "description": "Text query to search inside file contents.",
832 },
833 "regex": {
834 "type": "boolean",
835 "description": "Interpret the query as a regular expression.",
836 "default": False,
837 },
838 "case_sensitive": {
839 "type": "boolean",
840 "description": "Perform case-sensitive matching for names and content.",
841 "default": False,
842 },
843 "recursive": {
844 "type": "boolean",
845 "description": "Search directories recursively (default: true).",
846 "default": True,
847 },
848 "max_results": {
849 "type": "integer",
850 "description": "Maximum number of matched files to return.",
851 "default": 100,
852 },
853 },
854 "required": [],
855 }
856
857 async def execute(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
858 from .pa_provider import get_workspace_dir
859
860 rel_path = arguments.get("path", "") or ""
861 pattern = arguments.get("pattern")
862 query = arguments.get("query")
863 regex = bool(arguments.get("regex", False))
864 case_sensitive = bool(arguments.get("case_sensitive", False))
865 recursive = bool(arguments.get("recursive", True))
866 max_results = int(arguments.get("max_results", 100))
867
868 if not pattern and not query:
869 return {"error": "At least one of pattern or query is required"}
870
871 workspace = get_workspace_dir()
872 try:
873 target = (workspace / rel_path).resolve() if rel_path else workspace.resolve()
874 if not str(target).startswith(str(workspace.resolve())):
875 return {"error": "Access outside the workspace is not allowed"}
876 if not target.exists():
877 return {"error": f"Directory not found: {rel_path or '/'}"}
878 if not target.is_dir():
879 return {"error": f"Path is not a directory: {rel_path}"}
880
881 if pattern and not case_sensitive:
882 pattern_lower = pattern.lower()
883
884 if query and regex:
885 flags = 0 if case_sensitive else re.IGNORECASE
886 try:
887 query_re = re.compile(query, flags)
888 except re.error as exc:
889 return {"error": f"Invalid regular expression: {exc}"}
890
891 matches = []
892 iterator = target.rglob("*") if recursive else target.iterdir()
893 for entry in sorted(iterator):
894 if not entry.is_file():
895 continue
896
897 name = entry.name
898 if pattern:
899 candidate = name if case_sensitive else name.lower()
900 pattern_to_match = pattern if case_sensitive else pattern_lower
901 if not fnmatch.fnmatch(candidate, pattern_to_match):
902 continue
903
904 if query:
905 try:
906 content = entry.read_text(encoding="utf-8", errors="ignore")
907 except Exception:
908 continue
909 if regex:
910 if not query_re.search(content):
911 continue
912 else:
913 haystack = content if case_sensitive else content.lower()
914 needle = query if case_sensitive else query.lower()
915 if needle not in haystack:
916 continue
917
918 matches.append({
919 "path": str(entry.relative_to(workspace)),
920 "name": name,
921 })
922 if len(matches) >= max_results:
923 break
924
925 return {
926 "path": str(target.relative_to(workspace)),
927 "matches": matches,
928 "count": len(matches),
929 }
930 except Exception as exc:
931 return {"error": f"Search failed: {exc}"}
932
933
696 934 class FileListTool(MCPTool):
697 935 """List files and directories inside the ``~/.g4f/workspace`` directory."""
698 936