pathgate 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pathgate
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Filesystem MCP server with allow-listed directories
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -8,7 +8,7 @@ Requires-Python: >=3.10
8
8
  Requires-Dist: mcp<3,>=2
9
9
  Description-Content-Type: text/markdown
10
10
 
11
- # Pathgate
11
+ # Pathgate
12
12
 
13
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
14
 
@@ -31,13 +31,40 @@ Access is restricted by allow-listed paths. At startup, the server resolves the
31
31
 
32
32
  ## Installation
33
33
 
34
+ Pathgate package is published on PyPI. To use it, for example with Claude Desktop, add this in the
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "pathgate": {
40
+ "command": "uvx",
41
+ "args": ["pathgate", "C:\\Users\\me\\Documents"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ Or, you can use the install command to directly write the config file
48
+
34
49
  ```bash
35
- git clone https://github.com/geekgod382/pathgate.git
36
- cd pathgate
37
- python -m pip install -e .
50
+ uvx pathgate install <paths>
38
51
  ```
39
52
 
40
- This installs the `pathgate` console command from `pyproject.toml`.
53
+ paths should have nothing between them
54
+
55
+ ```bash
56
+ uvx pathgate install C:\Users\me\Documents C:\Users\me\Projects
57
+ ```
58
+
59
+ When no paths are given, it prompts you for them.
60
+
61
+ ### Options
62
+
63
+ `--read-only:` writes the safer variant of the entry.
64
+ `--dry-run`: shows what it would write and changes nothing.
65
+ `--yes`: skips the confirmation prompt.
66
+ `--name`: sets a different entry name (default pathgate).
67
+ `--config PATH`: I build this mcp server for Claude primarily. Using this option edits a specific file, which also works for other clients that use the same mcpServers shape.
41
68
 
42
69
  ## Usage
43
70
 
@@ -77,20 +104,6 @@ The current implementation exposes these tools:
77
104
  - `search_files_tool` — search for files matching a pattern
78
105
  - `get_file_info_tool` — return metadata for a file or directory
79
106
 
80
- ## MCP client configuration
81
-
82
- Example Claude Desktop configuration:
83
-
84
- ```json
85
- {
86
- "mcpServers": {
87
- "filesystem": {
88
- "command": "pathgate",
89
- "args": ["C:\\Users\\<username>\\Documents"]
90
- }
91
- }
92
- }
93
- ```
94
107
 
95
108
  ## Example requests
96
109
 
@@ -135,7 +148,7 @@ pathgate/
135
148
  ## Requirements
136
149
 
137
150
  - Python 3.10+
138
- - `mcp>=1.25`
151
+ - `mcp>=2`
139
152
 
140
153
  ## References
141
154
 
@@ -1,4 +1,4 @@
1
- # Pathgate
1
+ # Pathgate
2
2
 
3
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
4
 
@@ -21,13 +21,40 @@ Access is restricted by allow-listed paths. At startup, the server resolves the
21
21
 
22
22
  ## Installation
23
23
 
24
+ Pathgate package is published on PyPI. To use it, for example with Claude Desktop, add this in the
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "pathgate": {
30
+ "command": "uvx",
31
+ "args": ["pathgate", "C:\\Users\\me\\Documents"]
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ Or, you can use the install command to directly write the config file
38
+
24
39
  ```bash
25
- git clone https://github.com/geekgod382/pathgate.git
26
- cd pathgate
27
- python -m pip install -e .
40
+ uvx pathgate install <paths>
28
41
  ```
29
42
 
30
- This installs the `pathgate` console command from `pyproject.toml`.
43
+ paths should have nothing between them
44
+
45
+ ```bash
46
+ uvx pathgate install C:\Users\me\Documents C:\Users\me\Projects
47
+ ```
48
+
49
+ When no paths are given, it prompts you for them.
50
+
51
+ ### Options
52
+
53
+ `--read-only:` writes the safer variant of the entry.
54
+ `--dry-run`: shows what it would write and changes nothing.
55
+ `--yes`: skips the confirmation prompt.
56
+ `--name`: sets a different entry name (default pathgate).
57
+ `--config PATH`: I build this mcp server for Claude primarily. Using this option edits a specific file, which also works for other clients that use the same mcpServers shape.
31
58
 
32
59
  ## Usage
33
60
 
@@ -67,20 +94,6 @@ The current implementation exposes these tools:
67
94
  - `search_files_tool` — search for files matching a pattern
68
95
  - `get_file_info_tool` — return metadata for a file or directory
69
96
 
70
- ## MCP client configuration
71
-
72
- Example Claude Desktop configuration:
73
-
74
- ```json
75
- {
76
- "mcpServers": {
77
- "filesystem": {
78
- "command": "pathgate",
79
- "args": ["C:\\Users\\<username>\\Documents"]
80
- }
81
- }
82
- }
83
- ```
84
97
 
85
98
  ## Example requests
86
99
 
@@ -125,7 +138,7 @@ pathgate/
125
138
  ## Requirements
126
139
 
127
140
  - Python 3.10+
128
- - `mcp>=1.25`
141
+ - `mcp>=2`
129
142
 
130
143
  ## References
131
144
 
@@ -0,0 +1,254 @@
1
+ """`pathgate install` - add Pathgate to Claude Desktop's config file.
2
+
3
+ Usage:
4
+ uvx pathgate install C:\\Users\\me\\Documents C:\\Users\\me\\Projects
5
+ uvx pathgate install (prompts for folders)
6
+ uvx pathgate install --dry-run ~/Projects
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+
20
+ DEFAULT_NAME = "pathgate"
21
+ CONFIG_FILENAME = "claude_desktop_config.json"
22
+
23
+
24
+ class InstallError(Exception):
25
+ """A problem we can explain to the user in one line."""
26
+
27
+
28
+ # --------------------------------------------------------------------------
29
+ # Finding the config file
30
+ # --------------------------------------------------------------------------
31
+
32
+ def claude_desktop_candidates() -> list[Path]:
33
+ """Every place Claude Desktop might keep its config on this OS."""
34
+ if sys.platform == "win32":
35
+ found: list[Path] = []
36
+ appdata = os.environ.get("APPDATA")
37
+ if appdata:
38
+ found.append(Path(appdata) / "Claude" / CONFIG_FILENAME)
39
+ local = os.environ.get("LOCALAPPDATA")
40
+ if local:
41
+ # Microsoft Store (MSIX) installs keep their own private copy.
42
+ pattern = "Claude_*/LocalCache/Roaming/Claude"
43
+ for folder in sorted(Path(local, "Packages").glob(pattern)):
44
+ found.append(folder / CONFIG_FILENAME)
45
+ return found
46
+ if sys.platform == "darwin":
47
+ base = Path.home() / "Library" / "Application Support" / "Claude"
48
+ return [base / CONFIG_FILENAME]
49
+ xdg = os.environ.get("XDG_CONFIG_HOME")
50
+ base = Path(xdg) if xdg else Path.home() / ".config"
51
+ return [base / "Claude" / CONFIG_FILENAME]
52
+
53
+
54
+ def resolve_config_path(explicit: str | None) -> Path:
55
+ if explicit:
56
+ return Path(explicit).expanduser().resolve()
57
+
58
+ candidates = claude_desktop_candidates()
59
+ usable = [c for c in candidates if c.exists() or c.parent.exists()]
60
+ existing = [c for c in usable if c.exists()]
61
+
62
+ if len(existing) == 1:
63
+ return existing[0]
64
+ if not existing and len(usable) == 1:
65
+ return usable[0]
66
+ if not usable:
67
+ raise InstallError(
68
+ "couldn't find Claude Desktop's config folder. Is Claude Desktop "
69
+ "installed? You can point at a config file yourself with --config PATH."
70
+ )
71
+ listing = "\n ".join(str(c) for c in usable)
72
+ raise InstallError(
73
+ "found more than one possible Claude Desktop config:\n "
74
+ f"{listing}\nRe-run with --config PATH to say which one."
75
+ )
76
+
77
+
78
+ # --------------------------------------------------------------------------
79
+ # Building the entry
80
+ # --------------------------------------------------------------------------
81
+
82
+ def build_server_entry(paths: list[str], read_only: bool) -> dict:
83
+ """The `mcpServers` entry that launches Pathgate."""
84
+ uvx = shutil.which("uvx")
85
+ uv = shutil.which("uv")
86
+ if uvx:
87
+ command, args = uvx, ["pathgate"]
88
+ elif uv:
89
+ command, args = uv, ["tool", "run", "pathgate"]
90
+ else:
91
+ command, args = sys.executable, ["-m", "pathgate"]
92
+ args = args + paths
93
+ if read_only:
94
+ args.append("--read-only")
95
+ return {"command": command, "args": args}
96
+
97
+
98
+ # --------------------------------------------------------------------------
99
+ # Reading / writing the config safely
100
+ # --------------------------------------------------------------------------
101
+
102
+ def load_config(path: Path) -> dict:
103
+ if not path.exists():
104
+ return {}
105
+ raw = path.read_text(encoding="utf-8-sig") # tolerate a BOM
106
+ if not raw.strip():
107
+ return {}
108
+ try:
109
+ data = json.loads(raw)
110
+ except json.JSONDecodeError as e:
111
+ raise InstallError(
112
+ f"{path} isn't valid JSON ({e}). I haven't changed anything - "
113
+ "fix the file by hand, then run this again."
114
+ ) from None
115
+ if not isinstance(data, dict):
116
+ raise InstallError(f"{path} doesn't contain a JSON object, so I left it alone.")
117
+ return data
118
+
119
+
120
+ def write_config(path: Path, data: dict) -> Path | None:
121
+ """Back up the old file, then replace it atomically. Returns the backup path."""
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+
124
+ backup = None
125
+ if path.exists():
126
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
127
+ backup = path.with_name(f"{path.name}.pathgate-{stamp}.bak")
128
+ shutil.copy2(path, backup)
129
+
130
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
131
+ try:
132
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
133
+ json.dump(data, f, indent=2, ensure_ascii=False)
134
+ f.write("\n")
135
+ os.replace(tmp, path)
136
+ except BaseException:
137
+ try:
138
+ os.unlink(tmp)
139
+ except OSError:
140
+ pass
141
+ raise
142
+ return backup
143
+
144
+
145
+ # --------------------------------------------------------------------------
146
+ # The command
147
+ # --------------------------------------------------------------------------
148
+
149
+ def _prompt_for_paths() -> list[str]:
150
+ print("Which folders should Pathgate be allowed to access?")
151
+ print("Enter one folder per line. Press Enter on an empty line when done.")
152
+ paths: list[str] = []
153
+ while True:
154
+ line = input("> ").strip().strip('"').strip("'")
155
+ if not line:
156
+ break
157
+ paths.append(line)
158
+ return paths
159
+
160
+
161
+ def _clean_paths(raw: list[str]) -> list[str]:
162
+ cleaned: list[str] = []
163
+ for item in raw:
164
+ p = Path(item).expanduser().resolve()
165
+ if not p.is_dir():
166
+ raise InstallError(f"not a folder: {p}")
167
+ s = str(p)
168
+ if s not in cleaned:
169
+ cleaned.append(s)
170
+ return cleaned
171
+
172
+
173
+ def _confirm(question: str) -> bool:
174
+ answer = input(f"{question} [Y/n] ").strip().lower()
175
+ return answer in ("", "y", "yes")
176
+
177
+
178
+ def _install(args: argparse.Namespace) -> int:
179
+ interactive = sys.stdin.isatty()
180
+
181
+ raw_paths = list(args.paths)
182
+ if not raw_paths:
183
+ if not interactive:
184
+ raise InstallError(
185
+ "no folders given. Example: pathgate install C:\\Users\\me\\Documents"
186
+ )
187
+ raw_paths = _prompt_for_paths()
188
+ if not raw_paths:
189
+ raise InstallError("no folders given, nothing to do.")
190
+ paths = _clean_paths(raw_paths)
191
+
192
+ config_path = resolve_config_path(args.config)
193
+ data = load_config(config_path)
194
+ entry = build_server_entry(paths, args.read_only)
195
+
196
+ servers = data.setdefault("mcpServers", {})
197
+ if not isinstance(servers, dict):
198
+ raise InstallError(f'"mcpServers" in {config_path} isn\'t an object, so I left it alone.')
199
+
200
+ existing = servers.get(args.name)
201
+ if existing == entry:
202
+ print(f'"{args.name}" is already set up in {config_path}. Nothing to change.')
203
+ return 0
204
+
205
+ print(f"Config file : {config_path}")
206
+ print(f"Server name : {args.name}")
207
+ print(f"Command : {entry['command']} {' '.join(entry['args'])}")
208
+ print("Folders : " + ", ".join(paths))
209
+ print("Mode : " + ("read-only" if args.read_only else "read/write"))
210
+ if existing is not None:
211
+ print(f'Note : this replaces your current "{args.name}" entry.')
212
+
213
+ if args.dry_run:
214
+ print("\n--dry-run: nothing was written.")
215
+ return 0
216
+
217
+ if not args.yes:
218
+ if not interactive:
219
+ raise InstallError("not running in a terminal; pass --yes to proceed.")
220
+ if not _confirm("\nWrite this to the config?"):
221
+ print("Cancelled. Nothing was changed.")
222
+ return 1
223
+
224
+ servers[args.name] = entry
225
+ backup = write_config(config_path, data)
226
+
227
+ print(f'\nDone. Added "{args.name}" to {config_path}.')
228
+ if backup:
229
+ print(f"Backup of your old config: {backup}")
230
+ print("Now fully quit Claude Desktop (system tray -> Quit) and reopen it.")
231
+ return 0
232
+
233
+
234
+ def main(argv: list[str] | None = None) -> int:
235
+ p = argparse.ArgumentParser(
236
+ prog="pathgate install",
237
+ description="Add Pathgate to Claude Desktop's config file.",
238
+ )
239
+ p.add_argument("paths", nargs="*", help="Folders Pathgate may access")
240
+ p.add_argument("--read-only", action="store_true", help="Disable write/delete/move/copy tools")
241
+ p.add_argument("--name", default=DEFAULT_NAME, help=f"Entry name in the config (default: {DEFAULT_NAME})")
242
+ p.add_argument("--config", metavar="PATH", help="Config file to edit (default: auto-detect Claude Desktop)")
243
+ p.add_argument("--dry-run", action="store_true", help="Show what would change without writing")
244
+ p.add_argument("-y", "--yes", action="store_true", help="Don't ask for confirmation")
245
+ args = p.parse_args(argv)
246
+
247
+ try:
248
+ return _install(args)
249
+ except InstallError as e:
250
+ print(f"error: {e}", file=sys.stderr)
251
+ return 1
252
+ except (KeyboardInterrupt, EOFError):
253
+ print("\nCancelled. Nothing was changed.", file=sys.stderr)
254
+ return 130
@@ -1,6 +1,7 @@
1
1
  import argparse
2
2
  import asyncio
3
3
  import os
4
+ import sys
4
5
  from importlib.metadata import PackageNotFoundError, version
5
6
  from pathlib import Path
6
7
 
@@ -31,7 +32,10 @@ server = Server(
31
32
 
32
33
 
33
34
  def parse_args(argv=None):
34
- p = argparse.ArgumentParser(prog="pathgate")
35
+ p = argparse.ArgumentParser(
36
+ prog="pathgate",
37
+ epilog="To add Pathgate to Claude Desktop automatically: pathgate install <folders>",
38
+ )
35
39
  p.add_argument("paths", nargs="*", help="Directories the server may access")
36
40
  p.add_argument("--read-only", action="store_true", help="Disable write/delete/move/copy tools")
37
41
  args = p.parse_args(argv)
@@ -55,10 +59,17 @@ async def _run():
55
59
 
56
60
 
57
61
  def main():
62
+ # `pathgate install ...` edits the Claude Desktop config instead of
63
+ # starting the server. (A folder literally named "install" can still be
64
+ # passed as ./install.)
65
+ if sys.argv[1:2] == ["install"]:
66
+ from pathgate.install import main as install_main
67
+ raise SystemExit(install_main(sys.argv[2:]))
68
+
58
69
  paths, read_only = parse_args()
59
70
  configure(paths, read_only)
60
71
  asyncio.run(_run())
61
72
 
62
73
 
63
74
  if __name__ == "__main__":
64
- main()
75
+ main()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pathgate"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Filesystem MCP server with allow-listed directories"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes