pathgate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pathgate/__init__.py ADDED
File without changes
pathgate/__main__.py ADDED
File without changes
pathgate/config.py ADDED
@@ -0,0 +1,40 @@
1
+ from pathlib import Path
2
+ import os
3
+ import logging
4
+ from mcp.server import Server
5
+
6
+ logging.basicConfig(
7
+ level=logging.INFO,
8
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
9
+ handlers=[logging.StreamHandler()]
10
+ )
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Create the MCP server
14
+ server = Server("pathgate")
15
+
16
+
17
+ # pathgate/config.py
18
+ ALLOWED_PATHS: list[str] = [] # filled in by configure() at startup
19
+ _state = {"read_only": False}
20
+
21
+ def configure(paths: list[str], read_only: bool) -> None:
22
+ ALLOWED_PATHS.clear()
23
+ ALLOWED_PATHS.extend(paths) # mutate in place so existing imports see it
24
+ _state["read_only"] = read_only
25
+
26
+ def is_read_only() -> bool:
27
+ return _state["read_only"]
28
+
29
+
30
+ def is_path_allowed(path):
31
+ abs_path = os.path.realpath(path) # resolves symlinks too
32
+ for allowed in ALLOWED_PATHS:
33
+ allowed_normalized = os.path.realpath(allowed)
34
+ # Ensure that the allowed path ends with separator before prefix check
35
+ if not allowed_normalized.endswith(os.sep):
36
+ allowed_normalized += os.sep
37
+ # check exact match
38
+ if abs_path == os.path.realpath(allowed) or abs_path.startswith(allowed_normalized):
39
+ return True
40
+ return False
pathgate/prompts.py ADDED
@@ -0,0 +1,84 @@
1
+ from pathgate.config import server
2
+ from mcp.types import (TextContent, GetPromptResult, ListPromptsResult, PromptMessage, Prompt)
3
+
4
+ @server.list_prompts()
5
+ async def list_prompts() -> ListPromptsResult:
6
+ """List available prompts"""
7
+ prompts = [
8
+ Prompt(
9
+ name="analyze_directory",
10
+ description="Generate a prompt to analyze a directory structure",
11
+ arguments=[
12
+ {
13
+ "name": "directory",
14
+ "description": "Directory path to analyze",
15
+ "required": True
16
+ }
17
+ ]
18
+ ),
19
+ Prompt(
20
+ name="find_and_read",
21
+ description="Generate a prompt to find and read files matching a pattern",
22
+ arguments=[
23
+ {
24
+ "name": "directory",
25
+ "description": "Directory to search in",
26
+ "required": True
27
+ },
28
+ {
29
+ "name": "pattern",
30
+ "description": "File pattern to search for",
31
+ "required": True
32
+ }
33
+ ]
34
+ )
35
+ ]
36
+
37
+ return ListPromptsResult(prompts=prompts)
38
+
39
+ @server.get_prompt()
40
+ async def get_prompt(name: str, arguments: dict) -> GetPromptResult:
41
+ """Get a prompt"""
42
+ if name == "analyze_directory":
43
+ directory = arguments["directory"]
44
+ content = f"""Please analyze the directory structure at: {directory}
45
+
46
+ Use the list_directory_tool to explore the directory and provide insights about:
47
+ 1. Total number of files and directories
48
+ 2. File types and their distribution
49
+ 3. Largest files
50
+ 4. Directory organization
51
+ 5. Any potential issues or recommendations"""
52
+
53
+ return GetPromptResult(
54
+ description=f"Analysis prompt for directory: {directory}",
55
+ messages=[
56
+ PromptMessage(
57
+ role="user",
58
+ content=TextContent(type="text", text=content)
59
+ )
60
+ ]
61
+ )
62
+
63
+ elif name == "find_and_read":
64
+ directory = arguments["directory"]
65
+ pattern = arguments["pattern"]
66
+ content = f"""Please find and read files in: {directory}
67
+ Pattern: {pattern}
68
+
69
+ 1. Use search_files_tool to find matching files
70
+ 2. Use read_file_tool to read the contents
71
+ 3. Provide a summary of what you found"""
72
+
73
+ return GetPromptResult(
74
+ description=f"Find and read prompt for {pattern} in {directory}",
75
+ messages=[
76
+ PromptMessage(
77
+ role="user",
78
+ content=TextContent(type="text", text=content)
79
+ )
80
+ ]
81
+ )
82
+
83
+ else:
84
+ raise ValueError(f"Unknown prompt: {name}")
pathgate/resources.py ADDED
@@ -0,0 +1,90 @@
1
+ from pathgate.config import server, is_path_allowed
2
+ from mcp.types import (Resource, ListResourcesResult, ReadResourceResult, TextContent)
3
+ import os
4
+ import json
5
+
6
+ # RESOURCES HANDLERS
7
+ @server.list_resources()
8
+ async def list_resources() -> ListResourcesResult:
9
+ """List available resources"""
10
+ resources = [
11
+ Resource(
12
+ uri="file://*",
13
+ name="File Contents",
14
+ description="Read the contents of any file by providing its path",
15
+ mimeType="text/plain"
16
+ ),
17
+ Resource(
18
+ uri="directory://*",
19
+ name="Directory Listing",
20
+ description="List the contents of any directory by providing its path",
21
+ mimeType="application/json"
22
+ )
23
+ ]
24
+
25
+ return ListResourcesResult(resources=resources)
26
+
27
+ @server.read_resource()
28
+ async def read_resource(uri: str) -> ReadResourceResult:
29
+ """Read a resource"""
30
+ if uri.startswith("file://"):
31
+ path = uri[7:] # Remove "file://" prefix
32
+
33
+ if not is_path_allowed(path):
34
+ return ReadResourceResult(
35
+ contents=[TextContent(type="text", text=f"Error: Access denied to {path}")]
36
+ )
37
+
38
+ if not os.path.exists(path) or not os.path.isfile(path):
39
+ return ReadResourceResult(
40
+ contents=[TextContent(type="text", text=f"Error: File not found or not a file: {path}")]
41
+ )
42
+
43
+ try:
44
+ with open(path, 'r', encoding='utf-8') as f:
45
+ content = f.read()
46
+ return ReadResourceResult(
47
+ contents=[TextContent(type="text", text=content)]
48
+ )
49
+ except Exception as e:
50
+ return ReadResourceResult(
51
+ contents=[TextContent(type="text", text=f"Error reading file: {str(e)}")]
52
+ )
53
+
54
+ elif uri.startswith("directory://"):
55
+ path = uri[12:] # Remove "directory://" prefix
56
+
57
+ if not is_path_allowed(path):
58
+ return ReadResourceResult(
59
+ contents=[TextContent(type="text", text=f"Error: Access denied to {path}")]
60
+ )
61
+
62
+ if not os.path.exists(path) or not os.path.isdir(path):
63
+ return ReadResourceResult(
64
+ contents=[TextContent(type="text", text=f"Error: Directory not found or not a directory: {path}")]
65
+ )
66
+
67
+ try:
68
+ entries = []
69
+ for item in os.listdir(path):
70
+ item_path = os.path.join(path, item)
71
+ stat = os.stat(item_path)
72
+ entries.append({
73
+ "name": item,
74
+ "type": "directory" if os.path.isdir(item_path) else "file",
75
+ "size": stat.st_size,
76
+ "modified": stat.st_mtime
77
+ })
78
+
79
+ return ReadResourceResult(
80
+ contents=[TextContent(type="text", text=json.dumps(entries, indent=2))]
81
+ )
82
+ except Exception as e:
83
+ return ReadResourceResult(
84
+ contents=[TextContent(type="text", text=f"Error listing directory: {str(e)}")]
85
+ )
86
+
87
+ else:
88
+ return ReadResourceResult(
89
+ contents=[TextContent(type="text", text="Error: Unsupported resource URI")]
90
+ )
pathgate/server.py ADDED
@@ -0,0 +1,42 @@
1
+ from pathgate.config import configure, server
2
+ from mcp.server.stdio import stdio_server
3
+ from pathgate import tools, resources, prompts, tool_schemas
4
+ import asyncio
5
+ import os
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ server.list_tools()(tool_schemas.list_tools)
10
+ server.call_tool()(tools.call_tool)
11
+ server.list_resources()(resources.list_resources)
12
+ server.read_resource()(resources.read_resource)
13
+ server.list_prompts()(prompts.list_prompts)
14
+ server.get_prompt()(prompts.get_prompt)
15
+
16
+ def parse_args(argv=None):
17
+ p = argparse.ArgumentParser(prog="filesystem-mcp-server")
18
+ p.add_argument("paths", nargs="*", help="Directories the server may access")
19
+ p.add_argument("--read-only", action="store_true", help="Disable write/delete/move/copy tools")
20
+ args = p.parse_args(argv)
21
+
22
+ env_paths = [x for x in os.environ.get("FS_MCP_ALLOWED_PATHS", "").split(os.pathsep) if x]
23
+ raw = args.paths or env_paths
24
+ if not raw:
25
+ p.error("no allowed directories given. Pass at least one path "
26
+ "or set FS_MCP_ALLOWED_PATHS.")
27
+
28
+ paths = [str(Path(x).expanduser().resolve()) for x in raw]
29
+ for x in paths:
30
+ if not Path(x).is_dir():
31
+ p.error(f"not a directory: {x}")
32
+ return paths, args.read_only
33
+
34
+ async def main():
35
+ async with stdio_server() as (read_stream, write_stream):
36
+ await server.run(read_stream, write_stream, server.create_initialization_options())
37
+
38
+ if __name__ == "__main__":
39
+ paths, read_only = parse_args()
40
+ configure(paths, read_only)
41
+ asyncio.run(main())
42
+
@@ -0,0 +1,191 @@
1
+ from pathgate.config import server
2
+ from mcp.types import Tool, ListToolsResult
3
+
4
+ @server.list_tools()
5
+ async def list_tools() -> ListToolsResult:
6
+ """List available tools"""
7
+ tools = [
8
+ Tool(
9
+ name="read_file_tool",
10
+ description="Read the contents of a file",
11
+ inputSchema={
12
+ "type": "object",
13
+ "properties": {
14
+ "path": {
15
+ "type": "string",
16
+ "description": "Absolute or relative path to the file"
17
+ },
18
+ "encoding": {
19
+ "type": "string",
20
+ "description": "File encoding (default: utf-8)",
21
+ "default": "utf-8"
22
+ }
23
+ },
24
+ "required": ["path"]
25
+ }
26
+ ),
27
+ Tool(
28
+ name="write_file_tool",
29
+ description="Write content to a file (creates or overwrites)",
30
+ inputSchema={
31
+ "type": "object",
32
+ "properties": {
33
+ "path": {
34
+ "type": "string",
35
+ "description": "Absolute or relative path to the file"
36
+ },
37
+ "content": {
38
+ "type": "string",
39
+ "description": "Content to write"
40
+ },
41
+ "encoding": {
42
+ "type": "string",
43
+ "description": "File encoding (default: utf-8)",
44
+ "default": "utf-8"
45
+ }
46
+ },
47
+ "required": ["path", "content"]
48
+ }
49
+ ),
50
+ Tool(
51
+ name="list_directory_tool",
52
+ description="List contents of a directory",
53
+ inputSchema={
54
+ "type": "object",
55
+ "properties": {
56
+ "path": {
57
+ "type": "string",
58
+ "description": "Absolute or relative path to the directory"
59
+ },
60
+ "recursive": {
61
+ "type": "boolean",
62
+ "description": "If True, list subdirectories recursively",
63
+ "default": False
64
+ }
65
+ },
66
+ "required": ["path"]
67
+ }
68
+ ),
69
+ Tool(
70
+ name="create_directory_tool",
71
+ description="Create a new directory (creates parent directories if needed)",
72
+ inputSchema={
73
+ "type": "object",
74
+ "properties": {
75
+ "path": {
76
+ "type": "string",
77
+ "description": "Absolute or relative path to the directory"
78
+ }
79
+ },
80
+ "required": ["path"]
81
+ }
82
+ ),
83
+ Tool(
84
+ name="delete_file_tool",
85
+ description="Delete a file",
86
+ inputSchema={
87
+ "type": "object",
88
+ "properties": {
89
+ "path": {
90
+ "type": "string",
91
+ "description": "Absolute or relative path to the file"
92
+ }
93
+ },
94
+ "required": ["path"]
95
+ }
96
+ ),
97
+ Tool(
98
+ name="delete_directory_tool",
99
+ description="Delete a directory",
100
+ inputSchema={
101
+ "type": "object",
102
+ "properties": {
103
+ "path": {
104
+ "type": "string",
105
+ "description": "Absolute or relative path to the directory"
106
+ },
107
+ "recursive": {
108
+ "type": "boolean",
109
+ "description": "If True, delete directory and all contents",
110
+ "default": False
111
+ }
112
+ },
113
+ "required": ["path"]
114
+ }
115
+ ),
116
+ Tool(
117
+ name="move_file_tool",
118
+ description="Move or rename a file or directory",
119
+ inputSchema={
120
+ "type": "object",
121
+ "properties": {
122
+ "source": {
123
+ "type": "string",
124
+ "description": "Source path"
125
+ },
126
+ "destination": {
127
+ "type": "string",
128
+ "description": "Destination path"
129
+ }
130
+ },
131
+ "required": ["source", "destination"]
132
+ }
133
+ ),
134
+ Tool(
135
+ name="copy_file_tool",
136
+ description="Copy a file or directory",
137
+ inputSchema={
138
+ "type": "object",
139
+ "properties": {
140
+ "source": {
141
+ "type": "string",
142
+ "description": "Source path"
143
+ },
144
+ "destination": {
145
+ "type": "string",
146
+ "description": "Destination path"
147
+ }
148
+ },
149
+ "required": ["source", "destination"]
150
+ }
151
+ ),
152
+ Tool(
153
+ name="search_files_tool",
154
+ description="Search for files matching a pattern",
155
+ inputSchema={
156
+ "type": "object",
157
+ "properties": {
158
+ "directory": {
159
+ "type": "string",
160
+ "description": "Directory to search in"
161
+ },
162
+ "pattern": {
163
+ "type": "string",
164
+ "description": "Filename pattern (supports wildcards like *.txt)"
165
+ },
166
+ "recursive": {
167
+ "type": "boolean",
168
+ "description": "If True, search subdirectories",
169
+ "default": True
170
+ }
171
+ },
172
+ "required": ["directory", "pattern"]
173
+ }
174
+ ),
175
+ Tool(
176
+ name="get_file_info_tool",
177
+ description="Get detailed information about a file or directory",
178
+ inputSchema={
179
+ "type": "object",
180
+ "properties": {
181
+ "path": {
182
+ "type": "string",
183
+ "description": "Absolute or relative path"
184
+ }
185
+ },
186
+ "required": ["path"]
187
+ }
188
+ )
189
+ ]
190
+
191
+ return ListToolsResult(tools=tools)
pathgate/tools.py ADDED
@@ -0,0 +1,457 @@
1
+ from mcp.types import (TextContent, CallToolResult)
2
+ import os
3
+ from pathgate.config import is_path_allowed, server, is_read_only
4
+ import logging
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ @server.call_tool()
12
+ async def call_tool(name: str, arguments: dict) -> CallToolResult:
13
+ """Handle tool calls"""
14
+ try:
15
+ if name == "read_file_tool":
16
+ return await read_file_tool(arguments)
17
+ elif name == "write_file_tool":
18
+ return await write_file_tool(arguments)
19
+ elif name == "list_directory_tool":
20
+ return await list_directory_tool(arguments)
21
+ elif name == "create_directory_tool":
22
+ return await create_directory_tool(arguments)
23
+ elif name == "delete_file_tool":
24
+ return await delete_file_tool(arguments)
25
+ elif name == "delete_directory_tool":
26
+ return await delete_directory_tool(arguments)
27
+ elif name == "move_file_tool":
28
+ return await move_file_tool(arguments)
29
+ elif name == "copy_file_tool":
30
+ return await copy_file_tool(arguments)
31
+ elif name == "search_files_tool":
32
+ return await search_files_tool(arguments)
33
+ elif name == "get_file_info_tool":
34
+ return await get_file_info_tool(arguments)
35
+ else:
36
+ raise ValueError(f"Unknown tool: {name}")
37
+ except Exception as e:
38
+ logger.error(f"Error in {name}: {str(e)}")
39
+ return CallToolResult(
40
+ content=[TextContent(type="text", text=f"Error: {str(e)}")]
41
+ )
42
+
43
+ # Tool implementations
44
+ async def read_file_tool(arguments: dict) -> CallToolResult:
45
+ """Read the contents of a file"""
46
+ path = arguments["path"]
47
+ encoding = arguments.get("encoding", "utf-8")
48
+
49
+ if not is_path_allowed(path):
50
+ return CallToolResult(
51
+ content=[TextContent(type="text", text=f"Error: Access denied to {path}")]
52
+ )
53
+
54
+ if not os.path.exists(path):
55
+ return CallToolResult(
56
+ content=[TextContent(type="text", text=f"Error: File not found: {path}")]
57
+ )
58
+
59
+ if not os.path.isfile(path):
60
+ return CallToolResult(
61
+ content=[TextContent(type="text", text=f"Error: Not a file: {path}")]
62
+ )
63
+
64
+ try:
65
+ with open(path, 'r', encoding=encoding) as f:
66
+ content = f.read()
67
+
68
+ result = {
69
+ "path": path,
70
+ "content": content,
71
+ "size": len(content),
72
+ "encoding": encoding
73
+ }
74
+
75
+ return CallToolResult(
76
+ content=[TextContent(type="text", text=json.dumps(result, indent=2))]
77
+ )
78
+ except UnicodeDecodeError:
79
+ return CallToolResult(
80
+ content=[TextContent(type="text", text=f"Error: Cannot decode file with {encoding} encoding")]
81
+ )
82
+ except Exception as e:
83
+ return CallToolResult(
84
+ content=[TextContent(type="text", text=f"Error reading file: {str(e)}")]
85
+ )
86
+
87
+ async def write_file_tool(arguments: dict) -> CallToolResult:
88
+ """Write content to a file"""
89
+ path = arguments["path"]
90
+ content = arguments["content"]
91
+ encoding = arguments.get("encoding", "utf-8")
92
+
93
+ iro = is_read_only()
94
+ if not iro:
95
+ return CallToolResult(
96
+ content=[TextContent(type="text", text=f"Error: Write operations are disabled in read-only mode.")]
97
+ )
98
+
99
+ if not is_path_allowed(path):
100
+ return CallToolResult(
101
+ content=[TextContent(type="text", text=f"Error: Access denied to {path}")]
102
+ )
103
+
104
+ try:
105
+ # Create parent directories if they don't exist
106
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
107
+
108
+ with open(path, 'w', encoding=encoding) as f:
109
+ f.write(content)
110
+
111
+ result = {
112
+ "success": True,
113
+ "path": path,
114
+ "bytes_written": len(content.encode(encoding))
115
+ }
116
+
117
+ return CallToolResult(
118
+ content=[TextContent(type="text", text=json.dumps(result, indent=2))]
119
+ )
120
+ except Exception as e:
121
+ return CallToolResult(
122
+ content=[TextContent(type="text", text=f"Error writing file: {str(e)}")]
123
+ )
124
+
125
+ async def list_directory_tool(arguments: dict) -> CallToolResult:
126
+ """List contents of a directory"""
127
+ path = arguments["path"]
128
+ recursive = arguments.get("recursive", False)
129
+
130
+ if not is_path_allowed(path):
131
+ return CallToolResult(
132
+ content=[TextContent(type="text", text=f"Error: Access denied to {path}")]
133
+ )
134
+
135
+ if not os.path.exists(path):
136
+ return CallToolResult(
137
+ content=[TextContent(type="text", text=f"Error: Directory not found: {path}")]
138
+ )
139
+
140
+ if not os.path.isdir(path):
141
+ return CallToolResult(
142
+ content=[TextContent(type="text", text=f"Error: Not a directory: {path}")]
143
+ )
144
+
145
+ try:
146
+ entries = []
147
+
148
+ if recursive:
149
+ for root, dirs, files in os.walk(path):
150
+ for name in dirs + files:
151
+ item_path = os.path.join(root, name)
152
+ stat = os.stat(item_path)
153
+ entries.append({
154
+ "path": item_path,
155
+ "name": name,
156
+ "type": "directory" if os.path.isdir(item_path) else "file",
157
+ "size": stat.st_size,
158
+ "modified": stat.st_mtime
159
+ })
160
+ else:
161
+ for item in os.listdir(path):
162
+ item_path = os.path.join(path, item)
163
+ stat = os.stat(item_path)
164
+ entries.append({
165
+ "path": item_path,
166
+ "name": item,
167
+ "type": "directory" if os.path.isdir(item_path) else "file",
168
+ "size": stat.st_size,
169
+ "modified": stat.st_mtime
170
+ })
171
+
172
+ result = {
173
+ "path": path,
174
+ "count": len(entries),
175
+ "entries": entries
176
+ }
177
+
178
+ return CallToolResult(
179
+ content=[TextContent(type="text", text=json.dumps(result, indent=2))]
180
+ )
181
+ except Exception as e:
182
+ return CallToolResult(
183
+ content=[TextContent(type="text", text=f"Error listing directory: {str(e)}")]
184
+ )
185
+
186
+ async def create_directory_tool(arguments: dict) -> CallToolResult:
187
+ """Create a new directory"""
188
+ path = arguments["path"]
189
+
190
+ iro = is_read_only()
191
+ if not iro:
192
+ return CallToolResult(
193
+ content=[TextContent(type="text", text=f"Error: Create operations are disabled in read-only mode.")]
194
+ )
195
+
196
+ if not is_path_allowed(path):
197
+ return CallToolResult(
198
+ content=[TextContent(type="text", text=f"Error: Access denied to {path}")]
199
+ )
200
+
201
+ try:
202
+ os.makedirs(path, exist_ok=True)
203
+ result = {
204
+ "success": True,
205
+ "path": path,
206
+ "message": "Directory created successfully"
207
+ }
208
+ return CallToolResult(
209
+ content=[TextContent(type="text", text=json.dumps(result, indent=2))]
210
+ )
211
+ except Exception as e:
212
+ return CallToolResult(
213
+ content=[TextContent(type="text", text=f"Error creating directory: {str(e)}")]
214
+ )
215
+
216
+ async def delete_file_tool(arguments: dict) -> CallToolResult:
217
+ """Delete a file"""
218
+ path = arguments["path"]
219
+
220
+ iro = is_read_only()
221
+ if not iro:
222
+ return CallToolResult(
223
+ content=[TextContent(type="text", text=f"Error: Delete operations are disabled in read-only mode.")]
224
+ )
225
+
226
+ if not is_path_allowed(path):
227
+ return CallToolResult(
228
+ content=[TextContent(type="text", text=f"Error: Access denied to {path}")]
229
+ )
230
+
231
+ if not os.path.exists(path):
232
+ return CallToolResult(
233
+ content=[TextContent(type="text", text=f"Error: File not found: {path}")]
234
+ )
235
+
236
+ if not os.path.isfile(path):
237
+ return CallToolResult(
238
+ content=[TextContent(type="text", text=f"Error: Not a file: {path}")]
239
+ )
240
+
241
+ try:
242
+ os.remove(path)
243
+ result = {
244
+ "success": True,
245
+ "path": path,
246
+ "message": "File deleted successfully"
247
+ }
248
+ return CallToolResult(
249
+ content=[TextContent(type="text", text=json.dumps(result, indent=2))]
250
+ )
251
+ except Exception as e:
252
+ return CallToolResult(
253
+ content=[TextContent(type="text", text=f"Error deleting file: {str(e)}")]
254
+ )
255
+
256
+ async def delete_directory_tool(arguments: dict) -> CallToolResult:
257
+ """Delete a directory"""
258
+ path = arguments["path"]
259
+ recursive = arguments.get("recursive", False)
260
+
261
+ iro = is_read_only()
262
+ if not iro:
263
+ return CallToolResult(
264
+ content=[TextContent(type="text", text=f"Error: Delete operations are disabled in read-only mode.")]
265
+ )
266
+
267
+ if not is_path_allowed(path):
268
+ return CallToolResult(
269
+ content=[TextContent(type="text", text=f"Error: Access denied to {path}")]
270
+ )
271
+
272
+ if not os.path.exists(path):
273
+ return CallToolResult(
274
+ content=[TextContent(type="text", text=f"Error: Directory not found: {path}")]
275
+ )
276
+
277
+ if not os.path.isdir(path):
278
+ return CallToolResult(
279
+ content=[TextContent(type="text", text=f"Error: Not a directory: {path}")]
280
+ )
281
+
282
+ try:
283
+ if recursive:
284
+ shutil.rmtree(path)
285
+ else:
286
+ os.rmdir(path)
287
+
288
+ result = {
289
+ "success": True,
290
+ "path": path,
291
+ "message": "Directory deleted successfully"
292
+ }
293
+ return CallToolResult(
294
+ content=[TextContent(type="text", text=json.dumps(result, indent=2))]
295
+ )
296
+ except Exception as e:
297
+ return CallToolResult(
298
+ content=[TextContent(type="text", text=f"Error deleting directory: {str(e)}")]
299
+ )
300
+
301
+ async def move_file_tool(arguments: dict) -> CallToolResult:
302
+ """Move or rename a file or directory"""
303
+ source = arguments["source"]
304
+ destination = arguments["destination"]
305
+
306
+ iro = is_read_only()
307
+ if not iro:
308
+ return CallToolResult(
309
+ content=[TextContent(type="text", text=f"Error: Move operations are disabled in read-only mode.")]
310
+ )
311
+
312
+ if not is_path_allowed(source) or not is_path_allowed(destination):
313
+ return CallToolResult(
314
+ content=[TextContent(type="text", text="Error: Access denied")]
315
+ )
316
+
317
+ if not os.path.exists(source):
318
+ return CallToolResult(
319
+ content=[TextContent(type="text", text=f"Error: Source not found: {source}")]
320
+ )
321
+
322
+ try:
323
+ shutil.move(source, destination)
324
+ result = {
325
+ "success": True,
326
+ "source": source,
327
+ "destination": destination,
328
+ "message": "Moved successfully"
329
+ }
330
+ return CallToolResult(
331
+ content=[TextContent(type="text", text=json.dumps(result, indent=2))]
332
+ )
333
+ except Exception as e:
334
+ return CallToolResult(
335
+ content=[TextContent(type="text", text=f"Error moving: {str(e)}")]
336
+ )
337
+
338
+ async def copy_file_tool(arguments: dict) -> CallToolResult:
339
+ """Copy a file or directory"""
340
+ source = arguments["source"]
341
+ destination = arguments["destination"]
342
+
343
+ iro = is_read_only()
344
+ if not iro:
345
+ return CallToolResult(
346
+ content=[TextContent(type="text", text=f"Error: Copy operations are disabled in read-only mode.")]
347
+ )
348
+
349
+ if not is_path_allowed(source) or not is_path_allowed(destination):
350
+ return CallToolResult(
351
+ content=[TextContent(type="text", text="Error: Access denied")]
352
+ )
353
+
354
+ if not os.path.exists(source):
355
+ return CallToolResult(
356
+ content=[TextContent(type="text", text=f"Error: Source not found: {source}")]
357
+ )
358
+
359
+ try:
360
+ if os.path.isfile(source):
361
+ shutil.copy2(source, destination)
362
+ else:
363
+ shutil.copytree(source, destination)
364
+
365
+ result = {
366
+ "success": True,
367
+ "source": source,
368
+ "destination": destination,
369
+ "message": "Copied successfully"
370
+ }
371
+ return CallToolResult(
372
+ content=[TextContent(type="text", text=json.dumps(result, indent=2))]
373
+ )
374
+ except Exception as e:
375
+ return CallToolResult(
376
+ content=[TextContent(type="text", text=f"Error copying: {str(e)}")]
377
+ )
378
+
379
+ async def search_files_tool(arguments: dict) -> CallToolResult:
380
+ """Search for files matching a pattern"""
381
+ directory = arguments["directory"]
382
+ pattern = arguments["pattern"]
383
+ recursive = arguments.get("recursive", True)
384
+
385
+ if not is_path_allowed(directory):
386
+ return CallToolResult(
387
+ content=[TextContent(type="text", text=f"Error: Access denied to {directory}")]
388
+ )
389
+
390
+ if not os.path.exists(directory):
391
+ return CallToolResult(
392
+ content=[TextContent(type="text", text=f"Error: Directory not found: {directory}")]
393
+ )
394
+
395
+ try:
396
+ path_obj = Path(directory)
397
+ if recursive:
398
+ matches = list(path_obj.rglob(pattern))
399
+ else:
400
+ matches = list(path_obj.glob(pattern))
401
+
402
+ results = [str(match) for match in matches]
403
+
404
+ result = {
405
+ "directory": directory,
406
+ "pattern": pattern,
407
+ "count": len(results),
408
+ "matches": results
409
+ }
410
+
411
+ return CallToolResult(
412
+ content=[TextContent(type="text", text=json.dumps(result, indent=2))]
413
+ )
414
+ except Exception as e:
415
+ return CallToolResult(
416
+ content=[TextContent(type="text", text=f"Error searching: {str(e)}")]
417
+ )
418
+
419
+ async def get_file_info_tool(arguments: dict) -> CallToolResult:
420
+ """Get detailed information about a file or directory"""
421
+ path = arguments["path"]
422
+
423
+ if not is_path_allowed(path):
424
+ return CallToolResult(
425
+ content=[TextContent(type="text", text=f"Error: Access denied to {path}")]
426
+ )
427
+
428
+ if not os.path.exists(path):
429
+ return CallToolResult(
430
+ content=[TextContent(type="text", text=f"Error: Path not found: {path}")]
431
+ )
432
+
433
+ try:
434
+ stat = os.stat(path)
435
+ abs_path = os.path.abspath(path)
436
+
437
+ info = {
438
+ "path": abs_path,
439
+ "name": os.path.basename(path),
440
+ "type": "directory" if os.path.isdir(path) else "file",
441
+ "size": stat.st_size,
442
+ "created": stat.st_ctime,
443
+ "modified": stat.st_mtime,
444
+ "accessed": stat.st_atime,
445
+ "permissions": oct(stat.st_mode)[-3:],
446
+ }
447
+
448
+ if os.path.isfile(path):
449
+ info["extension"] = os.path.splitext(path)[1]
450
+
451
+ return CallToolResult(
452
+ content=[TextContent(type="text", text=json.dumps(info, indent=2))]
453
+ )
454
+ except Exception as e:
455
+ return CallToolResult(
456
+ content=[TextContent(type="text", text=f"Error getting file info: {str(e)}")]
457
+ )
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.5
2
+ Name: pathgate
3
+ Version: 0.1.0
4
+ Summary: Filesystem MCP server with allow-listed directories
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: mcp>=1.25
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Filesystem MCP Server
12
+
13
+ `pathgate` is a Python Model Context Protocol (MCP) server that exposes a controlled filesystem interface to MCP clients such as Claude Desktop, Cursor, VS Code, and inspector tools. The server only permits access to directories that are explicitly allow-listed at startup.
14
+
15
+ ## What it does
16
+
17
+ The server exposes:
18
+
19
+ - File resources via `file://...`
20
+ - Directory resources via `directory://...`
21
+ - Tools for reading, writing, listing, creating, deleting, moving, copying, searching, and inspecting files/directories
22
+ - Built-in prompts for directory analysis and file discovery
23
+
24
+ ## Security model
25
+
26
+ Access is restricted by allow-listed paths. At startup, the server resolves the supplied paths and only permits operations within those directories.
27
+
28
+ - Paths are checked with `os.path.realpath()` and prefix matching
29
+ - Symlinks are resolved before comparison
30
+ - The server can be run in read-only mode to disable mutating operations
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ git clone https://github.com/geekgod382/filesystem-mcp-server.git
36
+ cd filesystem-mcp-server
37
+ python -m pip install -e .
38
+ ```
39
+
40
+ This installs the `filesystem-mcp-server` console command from `pyproject.toml`.
41
+
42
+ ## Usage
43
+
44
+ ### Start with allowed directories
45
+
46
+ ```bash
47
+ filesystem-mcp-server "C:\Users\<username>\Documents" "C:\Users\<username>\Projects"
48
+ ```
49
+
50
+ ### Read-only mode
51
+
52
+ ```bash
53
+ filesystem-mcp-server --read-only "C:\Users\<username>\Documents"
54
+ ```
55
+
56
+ ### Environment variable alternative
57
+
58
+ ```bash
59
+ $env:FS_MCP_ALLOWED_PATHS = "C:\Users\<username>\Documents;C:\Users\<username>\Projects"
60
+ filesystem-mcp-server
61
+ ```
62
+
63
+ On macOS/Linux, use `:` instead of `;` for the path separator.
64
+
65
+ ## Tools
66
+
67
+ The current implementation exposes these tools:
68
+
69
+ - `read_file_tool` — read a file and return its content, size, and encoding
70
+ - `write_file_tool` — write content to a file
71
+ - `list_directory_tool` — list directory contents, optionally recursively
72
+ - `create_directory_tool` — create a directory tree if needed
73
+ - `delete_file_tool` — delete a single file
74
+ - `delete_directory_tool` — delete a directory, optionally recursively
75
+ - `move_file_tool` — move or rename a file/directory
76
+ - `copy_file_tool` — copy a file/directory
77
+ - `search_files_tool` — search for files matching a pattern
78
+ - `get_file_info_tool` — return metadata for a file or directory
79
+
80
+ ## MCP client configuration
81
+
82
+ Example Claude Desktop configuration:
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "filesystem": {
88
+ "command": "filesystem-mcp-server",
89
+ "args": ["C:\\Users\\<username>\\Documents"]
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ ## Example requests
96
+
97
+ Once connected, clients can ask:
98
+
99
+ ```text
100
+ List the files in C:\Users\<username>\Documents
101
+ ```
102
+
103
+ ```text
104
+ Read C:\Users\<username>\Documents\notes.txt
105
+ ```
106
+
107
+ ```text
108
+ Search for *.py files under C:\Users\<username>\Projects
109
+ ```
110
+
111
+ ```text
112
+ Create a file at C:\Users\<username>\Documents\todo.txt with the content "Ship release"
113
+ ```
114
+
115
+ ## Project layout
116
+
117
+ ```text
118
+ filesystem-mcp-server/
119
+ ├── pathgate/
120
+ │ ├── __init__.py
121
+ │ ├── __main__.py
122
+ │ ├── config.py
123
+ │ ├── prompts.py
124
+ │ ├── resources.py
125
+ │ ├── server.py
126
+ │ ├── tool_schemas.py
127
+ │ └── tools.py
128
+ ├── tests/
129
+ │ └── test_server.py
130
+ ├── pyproject.toml
131
+ ├── README.md
132
+ └── LICENSE
133
+ ```
134
+
135
+ ## Requirements
136
+
137
+ - Python 3.10+
138
+ - `mcp>=1.25`
139
+
140
+ ## References
141
+
142
+ - [Model Context Protocol](https://modelcontextprotocol.io/)
143
+ - [Python SDK](https://github.com/modelcontextprotocol/python-sdk)
@@ -0,0 +1,13 @@
1
+ pathgate/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pathgate/__main__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ pathgate/config.py,sha256=hfVRr_uCCrDapMlZ21Ckqs-zLAl-VYXZD4PQBL6yslA,1265
4
+ pathgate/prompts.py,sha256=yK4T9VTDoZBmPltjR7hNK8mum8xdg-hOMMJIlkYw0T0,2837
5
+ pathgate/resources.py,sha256=tM3YH3eZpKybXa-AFuXZdj_So8GF7yaRdROyzraLPdY,3370
6
+ pathgate/server.py,sha256=PkwAVBMG4mNm-WMfWS1v1sixef4oZTI6O_dQUTJnMHA,1577
7
+ pathgate/tool_schemas.py,sha256=JJhWI8oih-AzAExBiylK7a7e1UQrp2cjuv-tbeu-mm8,6793
8
+ pathgate/tools.py,sha256=h5xN_hMbrP_xxdzBgBxgOcTGzrl3_g3CsjH8g5BG2eg,15691
9
+ pathgate-0.1.0.dist-info/METADATA,sha256=1d0D_EReHOj3DA5Vy_FCp0dl-VLLMDFimcBDoyOQX1w,3727
10
+ pathgate-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ pathgate-0.1.0.dist-info/entry_points.txt,sha256=rZ5WAyKHrINDn8lmQ-RiwHIUk5xCRVLHs6ei7GturMo,63
12
+ pathgate-0.1.0.dist-info/licenses/LICENSE,sha256=JA1aQy0b0Ni5hczvQz1vwPvpRSDRRMy8RW7RX1rZXuA,1090
13
+ pathgate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ filesystem-mcp-server = pathgate.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Manan Sharma
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.