pathgate 0.1.0__tar.gz
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-0.1.0/.gitignore +3 -0
- pathgate-0.1.0/LICENSE +21 -0
- pathgate-0.1.0/PKG-INFO +143 -0
- pathgate-0.1.0/README.md +133 -0
- pathgate-0.1.0/pathgate/__init__.py +0 -0
- pathgate-0.1.0/pathgate/__main__.py +0 -0
- pathgate-0.1.0/pathgate/config.py +40 -0
- pathgate-0.1.0/pathgate/prompts.py +84 -0
- pathgate-0.1.0/pathgate/resources.py +90 -0
- pathgate-0.1.0/pathgate/server.py +42 -0
- pathgate-0.1.0/pathgate/tool_schemas.py +191 -0
- pathgate-0.1.0/pathgate/tools.py +457 -0
- pathgate-0.1.0/pyproject.toml +15 -0
- pathgate-0.1.0/tests/test_server.py +79 -0
pathgate-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pathgate-0.1.0/PKG-INFO
ADDED
|
@@ -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)
|
pathgate-0.1.0/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Filesystem MCP Server
|
|
2
|
+
|
|
3
|
+
`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.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
The server exposes:
|
|
8
|
+
|
|
9
|
+
- File resources via `file://...`
|
|
10
|
+
- Directory resources via `directory://...`
|
|
11
|
+
- Tools for reading, writing, listing, creating, deleting, moving, copying, searching, and inspecting files/directories
|
|
12
|
+
- Built-in prompts for directory analysis and file discovery
|
|
13
|
+
|
|
14
|
+
## Security model
|
|
15
|
+
|
|
16
|
+
Access is restricted by allow-listed paths. At startup, the server resolves the supplied paths and only permits operations within those directories.
|
|
17
|
+
|
|
18
|
+
- Paths are checked with `os.path.realpath()` and prefix matching
|
|
19
|
+
- Symlinks are resolved before comparison
|
|
20
|
+
- The server can be run in read-only mode to disable mutating operations
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/geekgod382/filesystem-mcp-server.git
|
|
26
|
+
cd filesystem-mcp-server
|
|
27
|
+
python -m pip install -e .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This installs the `filesystem-mcp-server` console command from `pyproject.toml`.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### Start with allowed directories
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
filesystem-mcp-server "C:\Users\<username>\Documents" "C:\Users\<username>\Projects"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Read-only mode
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
filesystem-mcp-server --read-only "C:\Users\<username>\Documents"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Environment variable alternative
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
$env:FS_MCP_ALLOWED_PATHS = "C:\Users\<username>\Documents;C:\Users\<username>\Projects"
|
|
50
|
+
filesystem-mcp-server
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
On macOS/Linux, use `:` instead of `;` for the path separator.
|
|
54
|
+
|
|
55
|
+
## Tools
|
|
56
|
+
|
|
57
|
+
The current implementation exposes these tools:
|
|
58
|
+
|
|
59
|
+
- `read_file_tool` — read a file and return its content, size, and encoding
|
|
60
|
+
- `write_file_tool` — write content to a file
|
|
61
|
+
- `list_directory_tool` — list directory contents, optionally recursively
|
|
62
|
+
- `create_directory_tool` — create a directory tree if needed
|
|
63
|
+
- `delete_file_tool` — delete a single file
|
|
64
|
+
- `delete_directory_tool` — delete a directory, optionally recursively
|
|
65
|
+
- `move_file_tool` — move or rename a file/directory
|
|
66
|
+
- `copy_file_tool` — copy a file/directory
|
|
67
|
+
- `search_files_tool` — search for files matching a pattern
|
|
68
|
+
- `get_file_info_tool` — return metadata for a file or directory
|
|
69
|
+
|
|
70
|
+
## MCP client configuration
|
|
71
|
+
|
|
72
|
+
Example Claude Desktop configuration:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"mcpServers": {
|
|
77
|
+
"filesystem": {
|
|
78
|
+
"command": "filesystem-mcp-server",
|
|
79
|
+
"args": ["C:\\Users\\<username>\\Documents"]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Example requests
|
|
86
|
+
|
|
87
|
+
Once connected, clients can ask:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
List the files in C:\Users\<username>\Documents
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
Read C:\Users\<username>\Documents\notes.txt
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
Search for *.py files under C:\Users\<username>\Projects
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
Create a file at C:\Users\<username>\Documents\todo.txt with the content "Ship release"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Project layout
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
filesystem-mcp-server/
|
|
109
|
+
├── pathgate/
|
|
110
|
+
│ ├── __init__.py
|
|
111
|
+
│ ├── __main__.py
|
|
112
|
+
│ ├── config.py
|
|
113
|
+
│ ├── prompts.py
|
|
114
|
+
│ ├── resources.py
|
|
115
|
+
│ ├── server.py
|
|
116
|
+
│ ├── tool_schemas.py
|
|
117
|
+
│ └── tools.py
|
|
118
|
+
├── tests/
|
|
119
|
+
│ └── test_server.py
|
|
120
|
+
├── pyproject.toml
|
|
121
|
+
├── README.md
|
|
122
|
+
└── LICENSE
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Requirements
|
|
126
|
+
|
|
127
|
+
- Python 3.10+
|
|
128
|
+
- `mcp>=1.25`
|
|
129
|
+
|
|
130
|
+
## References
|
|
131
|
+
|
|
132
|
+
- [Model Context Protocol](https://modelcontextprotocol.io/)
|
|
133
|
+
- [Python SDK](https://github.com/modelcontextprotocol/python-sdk)
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|
|
@@ -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}")
|
|
@@ -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
|
+
)
|
|
@@ -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)
|
|
@@ -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,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pathgate"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Filesystem MCP server with allow-listed directories"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = ["mcp>=1.25"]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
filesystem-mcp-server = "pathgate.server:main"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test script for the pure MCP server implementation
|
|
3
|
+
"""
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
from mcp import ClientSession, StdioServerParameters
|
|
7
|
+
from mcp.client.stdio import stdio_client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def test_server():
|
|
11
|
+
"""Test the filesystem MCP server"""
|
|
12
|
+
|
|
13
|
+
# Server parameters
|
|
14
|
+
server_params = StdioServerParameters(
|
|
15
|
+
command="python",
|
|
16
|
+
args=["C:\\Users\\manan\\filesystem-mcp-server\\server.py"],
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
print("🚀 Starting Pure MCP Filesystem Server test...\n")
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
async with stdio_client(server_params) as (read, write):
|
|
23
|
+
async with ClientSession(read, write) as session:
|
|
24
|
+
# Initialize the session
|
|
25
|
+
await session.initialize()
|
|
26
|
+
|
|
27
|
+
print("✅ Server initialized successfully!\n")
|
|
28
|
+
|
|
29
|
+
# Test 1: List available tools
|
|
30
|
+
print("📋 Available Tools:")
|
|
31
|
+
tools = await session.list_tools()
|
|
32
|
+
for tool in tools.tools:
|
|
33
|
+
print(f" - {tool.name}")
|
|
34
|
+
print(f"\nTotal tools: {len(tools.tools)}\n")
|
|
35
|
+
|
|
36
|
+
# Test 2: List available resources
|
|
37
|
+
print("📚 Available Resources:")
|
|
38
|
+
resources = await session.list_resources()
|
|
39
|
+
for resource in resources.resources:
|
|
40
|
+
print(f" - {resource.uri}: {resource.name}")
|
|
41
|
+
print()
|
|
42
|
+
|
|
43
|
+
# Test 3: List available prompts
|
|
44
|
+
print("💭 Available Prompts:")
|
|
45
|
+
prompts = await session.list_prompts()
|
|
46
|
+
for prompt in prompts.prompts:
|
|
47
|
+
print(f" - {prompt.name}: {prompt.description}")
|
|
48
|
+
print()
|
|
49
|
+
|
|
50
|
+
# Test 4: Test list_directory_tool on current directory
|
|
51
|
+
print("📂 Testing list_directory_tool on current directory:")
|
|
52
|
+
result = await session.call_tool(
|
|
53
|
+
"list_directory_tool",
|
|
54
|
+
arguments={"path": ".", "recursive": False}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if result.content:
|
|
58
|
+
content = result.content[0]
|
|
59
|
+
if hasattr(content, 'text'):
|
|
60
|
+
data = json.loads(content.text)
|
|
61
|
+
print(f" Found {data['count']} items in current directory")
|
|
62
|
+
print(f" First few items:")
|
|
63
|
+
for entry in data['entries'][:3]:
|
|
64
|
+
print(f" - {entry['name']} ({entry['type']})")
|
|
65
|
+
print()
|
|
66
|
+
|
|
67
|
+
print("✅ All tests passed! Pure MCP Server is working correctly.")
|
|
68
|
+
|
|
69
|
+
except Exception as e:
|
|
70
|
+
print(f"❌ Error during testing: {e}")
|
|
71
|
+
import traceback
|
|
72
|
+
traceback.print_exc()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
try:
|
|
77
|
+
asyncio.run(test_server())
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
print("\n⚠️ Test interrupted by user")
|