返回提交历史
Modified
etc/unittest/mcp.py
+33
-1
Modified
g4f/mcp/__init__.py
+4
-0
Modified
g4f/mcp/server.py
+6
-3
Modified
g4f/mcp/tools.py
+238
-0
XFEstudio/gpt4free
feat: add FileReadLinesTool and FileSearchTool for enhanced file operations
7a99598d
代码差异
4 个文件
+281
-4
@@ -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()
@@ -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',
@@ -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)
@@ -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