pathgate 0.1.1__tar.gz → 0.2.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.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pathgate
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Filesystem MCP server with allow-listed directories
5
5
  License: MIT
6
6
  License-File: LICENSE
7
7
  Requires-Python: >=3.10
8
- Requires-Dist: mcp>=1.25
8
+ Requires-Dist: mcp<3,>=2
9
9
  Description-Content-Type: text/markdown
10
10
 
11
11
  # Pathgate
@@ -0,0 +1,3 @@
1
+ from pathgate.server import main
2
+
3
+ main()
@@ -1,7 +1,6 @@
1
1
  from pathlib import Path
2
2
  import os
3
3
  import logging
4
- from mcp.server import Server
5
4
 
6
5
  logging.basicConfig(
7
6
  level=logging.INFO,
@@ -10,10 +9,6 @@ logging.basicConfig(
10
9
  )
11
10
  logger = logging.getLogger(__name__)
12
11
 
13
- # Create the MCP server
14
- server = Server("pathgate")
15
-
16
-
17
12
  # pathgate/config.py
18
13
  ALLOWED_PATHS: list[str] = [] # filled in by configure() at startup
19
14
  _state = {"read_only": False}
@@ -1,54 +1,53 @@
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:
1
+ from mcp import MCPError
2
+ from mcp.server import ServerRequestContext
3
+ from mcp.types import (INVALID_PARAMS, GetPromptRequestParams, GetPromptResult, ListPromptsResult,
4
+ PaginatedRequestParams, Prompt, PromptArgument, PromptMessage, TextContent)
5
+ async def list_prompts(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListPromptsResult:
6
6
  """List available prompts"""
7
7
  prompts = [
8
8
  Prompt(
9
9
  name="analyze_directory",
10
10
  description="Generate a prompt to analyze a directory structure",
11
11
  arguments=[
12
- {
13
- "name": "directory",
14
- "description": "Directory path to analyze",
15
- "required": True
16
- }
12
+ PromptArgument(
13
+ name="directory",
14
+ description="Directory path to analyze",
15
+ required=True
16
+ )
17
17
  ]
18
18
  ),
19
19
  Prompt(
20
20
  name="find_and_read",
21
21
  description="Generate a prompt to find and read files matching a pattern",
22
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
- }
23
+ PromptArgument(
24
+ name="directory",
25
+ description="Directory to search in",
26
+ required=True
27
+ ),
28
+ PromptArgument(
29
+ name="pattern",
30
+ description="File pattern to search for",
31
+ required=True
32
+ )
33
33
  ]
34
34
  )
35
35
  ]
36
36
 
37
37
  return ListPromptsResult(prompts=prompts)
38
38
 
39
- @server.get_prompt()
40
- async def get_prompt(name: str, arguments: dict) -> GetPromptResult:
39
+ async def get_prompt(ctx: ServerRequestContext, params: GetPromptRequestParams) -> GetPromptResult:
40
+ name, arguments = params.name, params.arguments or {}
41
41
  """Get a prompt"""
42
42
  if name == "analyze_directory":
43
43
  directory = arguments["directory"]
44
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"""
45
+ Use the list_directory_tool to explore the directory and provide insights about:
46
+ 1. Total number of files and directories
47
+ 2. File types and their distribution
48
+ 3. Largest files
49
+ 4. Directory organization
50
+ 5. Any potential issues or recommendations"""
52
51
 
53
52
  return GetPromptResult(
54
53
  description=f"Analysis prompt for directory: {directory}",
@@ -0,0 +1,55 @@
1
+ import json
2
+ import os
3
+
4
+ from mcp import MCPError
5
+ from mcp.server import ServerRequestContext
6
+ from mcp.types import (INVALID_PARAMS, ListResourcesResult, PaginatedRequestParams,
7
+ ReadResourceRequestParams, ReadResourceResult, Resource,
8
+ TextResourceContents)
9
+
10
+ from pathgate.config import is_path_allowed
11
+
12
+
13
+ async def list_resources(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListResourcesResult:
14
+ return ListResourcesResult(resources=[
15
+ Resource(uri="file://*", name="File Contents",
16
+ description="Read the contents of any file by providing its path",
17
+ mime_type="text/plain"),
18
+ Resource(uri="directory://*", name="Directory Listing",
19
+ description="List the contents of any directory by providing its path",
20
+ mime_type="application/json"),
21
+ ])
22
+
23
+
24
+ async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
25
+ uri = params.uri
26
+
27
+ if uri.startswith("file://"):
28
+ path = uri[len("file://"):]
29
+ if not is_path_allowed(path):
30
+ raise MCPError(INVALID_PARAMS, f"Access denied to {path}")
31
+ if not os.path.isfile(path):
32
+ raise MCPError(INVALID_PARAMS, f"File not found or not a file: {path}")
33
+ with open(path, "r", encoding="utf-8") as f:
34
+ text = f.read()
35
+ return ReadResourceResult(contents=[
36
+ TextResourceContents(uri=uri, text=text, mime_type="text/plain")])
37
+
38
+ if uri.startswith("directory://"):
39
+ path = uri[len("directory://"):]
40
+ if not is_path_allowed(path):
41
+ raise MCPError(INVALID_PARAMS, f"Access denied to {path}")
42
+ if not os.path.isdir(path):
43
+ raise MCPError(INVALID_PARAMS, f"Directory not found: {path}")
44
+ entries = []
45
+ for item in os.listdir(path):
46
+ item_path = os.path.join(path, item)
47
+ stat = os.stat(item_path)
48
+ entries.append({"name": item,
49
+ "type": "directory" if os.path.isdir(item_path) else "file",
50
+ "size": stat.st_size, "modified": stat.st_mtime})
51
+ return ReadResourceResult(contents=[
52
+ TextResourceContents(uri=uri, text=json.dumps(entries, indent=2),
53
+ mime_type="application/json")])
54
+
55
+ raise MCPError(INVALID_PARAMS, f"Unsupported resource URI: {uri}")
@@ -1,17 +1,34 @@
1
- from pathgate.config import configure, server
2
- from mcp.server.stdio import stdio_server
3
- from pathgate import tools, resources, prompts, tool_schemas
1
+ import argparse
4
2
  import asyncio
5
3
  import os
6
- import argparse
4
+ from importlib.metadata import PackageNotFoundError, version
7
5
  from pathlib import Path
8
6
 
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)
7
+ from mcp.server import Server
8
+ from mcp.server.stdio import stdio_server
9
+
10
+ from pathgate import prompts, resources, tool_schemas, tools
11
+ from pathgate.config import configure
12
+
13
+
14
+ def _version() -> str:
15
+ try:
16
+ return version("pathgate")
17
+ except PackageNotFoundError:
18
+ return "0.0.0"
19
+
20
+
21
+ server = Server(
22
+ "pathgate",
23
+ version=_version(),
24
+ on_list_tools=tool_schemas.list_tools,
25
+ on_call_tool=tools.call_tool,
26
+ on_list_resources=resources.list_resources,
27
+ on_read_resource=resources.read_resource,
28
+ on_list_prompts=prompts.list_prompts,
29
+ on_get_prompt=prompts.get_prompt,
30
+ )
31
+
15
32
 
16
33
  def parse_args(argv=None):
17
34
  p = argparse.ArgumentParser(prog="pathgate")
@@ -31,12 +48,17 @@ def parse_args(argv=None):
31
48
  p.error(f"not a directory: {x}")
32
49
  return paths, args.read_only
33
50
 
34
- async def main():
51
+
52
+ async def _run():
35
53
  async with stdio_server() as (read_stream, write_stream):
36
54
  await server.run(read_stream, write_stream, server.create_initialization_options())
37
55
 
38
- if __name__ == "__main__":
56
+
57
+ def main():
39
58
  paths, read_only = parse_args()
40
59
  configure(paths, read_only)
41
- asyncio.run(main())
60
+ asyncio.run(_run())
61
+
42
62
 
63
+ if __name__ == "__main__":
64
+ main()
@@ -1,8 +1,7 @@
1
- from pathgate.config import server
2
- from mcp.types import Tool, ListToolsResult
1
+ from mcp.server import ServerRequestContext
2
+ from mcp.types import ListToolsResult, PaginatedRequestParams, Tool
3
3
 
4
- @server.list_tools()
5
- async def list_tools() -> ListToolsResult:
4
+ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
6
5
  """List available tools"""
7
6
  tools = [
8
7
  Tool(
@@ -1,44 +1,35 @@
1
- from mcp.types import (TextContent, CallToolResult)
2
- import os
3
- from pathgate.config import is_path_allowed, server, is_read_only
1
+ from mcp.server import ServerRequestContext
2
+ from mcp.types import CallToolRequestParams, CallToolResult, TextContent
4
3
  import logging
4
+ import os
5
5
  import json
6
+ from pathgate.config import is_path_allowed, is_read_only
6
7
  import shutil
7
8
  from pathlib import Path
8
9
 
9
10
  logger = logging.getLogger(__name__)
10
11
 
11
- @server.call_tool()
12
- async def call_tool(name: str, arguments: dict) -> CallToolResult:
13
- """Handle tool calls"""
12
+ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
13
+ handler = HANDLERS.get(params.name)
14
+ if handler is None:
15
+ return _error(f"Unknown tool: {params.name}")
14
16
  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}")
17
+ result = await handler(params.arguments or {})
18
+ except KeyError as e:
19
+ return _error(f"Missing required argument: {e.args[0]}")
37
20
  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
- )
21
+ logger.exception("Error in %s", params.name)
22
+ return _error(str(e))
23
+
24
+ # existing handlers return "Error..." text without the error flag
25
+ text = result.content[0].text if result.content else ""
26
+ if text.startswith("Error") and not result.is_error:
27
+ result = result.model_copy(update={"is_error": True})
28
+ return result
29
+
30
+ def _error(msg: str) -> CallToolResult:
31
+ return CallToolResult(content=[TextContent(type="text", text=f"Error: {msg}")], is_error=True)
32
+
42
33
 
43
34
  # Tool implementations
44
35
  async def read_file_tool(arguments: dict) -> CallToolResult:
@@ -90,8 +81,7 @@ async def write_file_tool(arguments: dict) -> CallToolResult:
90
81
  content = arguments["content"]
91
82
  encoding = arguments.get("encoding", "utf-8")
92
83
 
93
- iro = is_read_only()
94
- if not iro:
84
+ if is_read_only():
95
85
  return CallToolResult(
96
86
  content=[TextContent(type="text", text=f"Error: Write operations are disabled in read-only mode.")]
97
87
  )
@@ -187,10 +177,9 @@ async def create_directory_tool(arguments: dict) -> CallToolResult:
187
177
  """Create a new directory"""
188
178
  path = arguments["path"]
189
179
 
190
- iro = is_read_only()
191
- if not iro:
180
+ if is_read_only():
192
181
  return CallToolResult(
193
- content=[TextContent(type="text", text=f"Error: Create operations are disabled in read-only mode.")]
182
+ content=[TextContent(type="text", text=f"Error: Write operations are disabled in read-only mode.")]
194
183
  )
195
184
 
196
185
  if not is_path_allowed(path):
@@ -217,8 +206,7 @@ async def delete_file_tool(arguments: dict) -> CallToolResult:
217
206
  """Delete a file"""
218
207
  path = arguments["path"]
219
208
 
220
- iro = is_read_only()
221
- if not iro:
209
+ if is_read_only():
222
210
  return CallToolResult(
223
211
  content=[TextContent(type="text", text=f"Error: Delete operations are disabled in read-only mode.")]
224
212
  )
@@ -258,8 +246,7 @@ async def delete_directory_tool(arguments: dict) -> CallToolResult:
258
246
  path = arguments["path"]
259
247
  recursive = arguments.get("recursive", False)
260
248
 
261
- iro = is_read_only()
262
- if not iro:
249
+ if is_read_only():
263
250
  return CallToolResult(
264
251
  content=[TextContent(type="text", text=f"Error: Delete operations are disabled in read-only mode.")]
265
252
  )
@@ -303,8 +290,7 @@ async def move_file_tool(arguments: dict) -> CallToolResult:
303
290
  source = arguments["source"]
304
291
  destination = arguments["destination"]
305
292
 
306
- iro = is_read_only()
307
- if not iro:
293
+ if is_read_only():
308
294
  return CallToolResult(
309
295
  content=[TextContent(type="text", text=f"Error: Move operations are disabled in read-only mode.")]
310
296
  )
@@ -340,8 +326,7 @@ async def copy_file_tool(arguments: dict) -> CallToolResult:
340
326
  source = arguments["source"]
341
327
  destination = arguments["destination"]
342
328
 
343
- iro = is_read_only()
344
- if not iro:
329
+ if is_read_only():
345
330
  return CallToolResult(
346
331
  content=[TextContent(type="text", text=f"Error: Copy operations are disabled in read-only mode.")]
347
332
  )
@@ -399,7 +384,7 @@ async def search_files_tool(arguments: dict) -> CallToolResult:
399
384
  else:
400
385
  matches = list(path_obj.glob(pattern))
401
386
 
402
- results = [str(match) for match in matches]
387
+ results = [str(m) for m in matches if is_path_allowed(str(m))]
403
388
 
404
389
  result = {
405
390
  "directory": directory,
@@ -455,3 +440,16 @@ async def get_file_info_tool(arguments: dict) -> CallToolResult:
455
440
  return CallToolResult(
456
441
  content=[TextContent(type="text", text=f"Error getting file info: {str(e)}")]
457
442
  )
443
+
444
+ HANDLERS = {
445
+ "read_file_tool": read_file_tool,
446
+ "write_file_tool": write_file_tool,
447
+ "list_directory_tool": list_directory_tool,
448
+ "create_directory_tool": create_directory_tool,
449
+ "delete_file_tool": delete_file_tool,
450
+ "delete_directory_tool": delete_directory_tool,
451
+ "move_file_tool": move_file_tool,
452
+ "copy_file_tool": copy_file_tool,
453
+ "search_files_tool": search_files_tool,
454
+ "get_file_info_tool": get_file_info_tool,
455
+ }
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pathgate"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Filesystem MCP server with allow-listed directories"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  license = { text = "MIT" }
12
- dependencies = ["mcp>=1.25"]
12
+ dependencies = ["mcp>=2,<3"]
13
13
 
14
14
  [project.scripts]
15
15
  pathgate = "pathgate.server:main"
File without changes
@@ -1,90 +0,0 @@
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
- )
File without changes
File without changes
File without changes
File without changes
File without changes