fs-mcp 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.
- fs_mcp-0.1.0/.gitignore +10 -0
- fs_mcp-0.1.0/PKG-INFO +8 -0
- fs_mcp-0.1.0/README.md +0 -0
- fs_mcp-0.1.0/pyproject.toml +26 -0
- fs_mcp-0.1.0/src/fs_mcp/__init__.py +0 -0
- fs_mcp-0.1.0/src/fs_mcp/__main__.py +53 -0
- fs_mcp-0.1.0/src/fs_mcp/server.py +215 -0
- fs_mcp-0.1.0/src/fs_mcp/web_ui.py +262 -0
- fs_mcp-0.1.0/tests/test_server.py +37 -0
- fs_mcp-0.1.0/uv.lock +2526 -0
fs_mcp-0.1.0/.gitignore
ADDED
fs_mcp-0.1.0/PKG-INFO
ADDED
fs_mcp-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fs-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A secure MCP filesystem server with Stdio and Web UI modes."
|
|
5
|
+
authors = [{name = "Your Name", email = "you@example.com"}]
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"fastmcp>=0.1.0",
|
|
10
|
+
"streamlit>=1.30.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
fs-mcp = "fs_mcp.__main__:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/fs_mcp"]
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=8.0.0",
|
|
26
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from fs_mcp import server
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
parser = argparse.ArgumentParser(description="fs-mcp server")
|
|
9
|
+
parser.add_argument("--ui", action="store_true", help="Launch Web UI")
|
|
10
|
+
parser.add_argument("--host", default="0.0.0.0", help="UI Host")
|
|
11
|
+
parser.add_argument("--port", default="8501", help="UI Port")
|
|
12
|
+
parser.add_argument("dirs", nargs="*", help="Allowed directories")
|
|
13
|
+
|
|
14
|
+
# Parse known args to allow Streamlit to handle its own flags if needed
|
|
15
|
+
args, unknown = parser.parse_known_args()
|
|
16
|
+
|
|
17
|
+
# Initialize Core Logic for Stdio mode
|
|
18
|
+
dirs = args.dirs or [str(Path.cwd())]
|
|
19
|
+
try:
|
|
20
|
+
server.initialize(dirs)
|
|
21
|
+
except ValueError as e:
|
|
22
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
if args.ui:
|
|
26
|
+
# Launch Streamlit as a subprocess
|
|
27
|
+
# FIX: Find the file without importing it
|
|
28
|
+
current_dir = Path(__file__).parent
|
|
29
|
+
ui_path = (current_dir / "web_ui.py").resolve()
|
|
30
|
+
|
|
31
|
+
if not ui_path.exists():
|
|
32
|
+
print(f"Error: Could not find web_ui.py at {ui_path}", file=sys.stderr)
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
cmd = [
|
|
36
|
+
sys.executable, "-m", "streamlit", "run", str(ui_path),
|
|
37
|
+
"--server.address", args.host,
|
|
38
|
+
"--server.port", args.port,
|
|
39
|
+
"--", # Separator: args after this are passed to the script
|
|
40
|
+
*dirs
|
|
41
|
+
]
|
|
42
|
+
print(f"🚀 Launching UI on http://{args.host}:{args.port}", file=sys.stderr)
|
|
43
|
+
# Use simple run, Streamlit handles the rest
|
|
44
|
+
try:
|
|
45
|
+
subprocess.run(cmd)
|
|
46
|
+
except KeyboardInterrupt:
|
|
47
|
+
pass
|
|
48
|
+
else:
|
|
49
|
+
# Run Standard MCP Server
|
|
50
|
+
server.mcp.run()
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import base64
|
|
3
|
+
import mimetypes
|
|
4
|
+
import fnmatch
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional, Literal, Dict
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
# --- Global Configuration ---
|
|
11
|
+
ALLOWED_DIRS: List[Path] = []
|
|
12
|
+
mcp = FastMCP("filesystem")
|
|
13
|
+
|
|
14
|
+
def initialize(directories: List[str]):
|
|
15
|
+
"""Initialize the allowed directories configuration."""
|
|
16
|
+
global ALLOWED_DIRS
|
|
17
|
+
ALLOWED_DIRS.clear()
|
|
18
|
+
|
|
19
|
+
# Resolve all paths to absolute
|
|
20
|
+
# If no paths provided, default to CWD
|
|
21
|
+
raw_dirs = directories or [str(Path.cwd())]
|
|
22
|
+
|
|
23
|
+
for d in raw_dirs:
|
|
24
|
+
try:
|
|
25
|
+
p = Path(d).expanduser().resolve()
|
|
26
|
+
if not p.exists() or not p.is_dir():
|
|
27
|
+
# Just warn in logs, don't crash entire server if one dir is bad
|
|
28
|
+
print(f"Warning: Skipping invalid directory: {p}")
|
|
29
|
+
continue
|
|
30
|
+
ALLOWED_DIRS.append(p)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
print(f"Warning: Could not resolve {d}: {e}")
|
|
33
|
+
|
|
34
|
+
if not ALLOWED_DIRS:
|
|
35
|
+
print("Warning: No valid directories allowed. Defaulting to CWD.")
|
|
36
|
+
ALLOWED_DIRS.append(Path.cwd())
|
|
37
|
+
|
|
38
|
+
return ALLOWED_DIRS
|
|
39
|
+
|
|
40
|
+
def validate_path(requested_path: str) -> Path:
|
|
41
|
+
"""Security barrier: Ensures path is within ALLOWED_DIRS."""
|
|
42
|
+
try:
|
|
43
|
+
path_obj = Path(requested_path).expanduser().resolve()
|
|
44
|
+
except Exception:
|
|
45
|
+
# Handle new files (write ops)
|
|
46
|
+
path_obj = Path(requested_path).expanduser().absolute()
|
|
47
|
+
|
|
48
|
+
# Check strict containment
|
|
49
|
+
is_allowed = any(
|
|
50
|
+
str(path_obj).startswith(str(allowed))
|
|
51
|
+
for allowed in ALLOWED_DIRS
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if not is_allowed:
|
|
55
|
+
raise ValueError(f"Access denied: {requested_path} is outside allowed directories.")
|
|
56
|
+
|
|
57
|
+
return path_obj
|
|
58
|
+
|
|
59
|
+
def format_size(size_bytes: int) -> str:
|
|
60
|
+
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
|
61
|
+
if size_bytes < 1024.0:
|
|
62
|
+
return f"{size_bytes:.2f} {unit}"
|
|
63
|
+
size_bytes /= 1024.0
|
|
64
|
+
return f"{size_bytes:.2f} PB"
|
|
65
|
+
|
|
66
|
+
# --- Tools ---
|
|
67
|
+
|
|
68
|
+
@mcp.tool()
|
|
69
|
+
def list_allowed_directories() -> str:
|
|
70
|
+
"""List the directories this server is allowed to access."""
|
|
71
|
+
return "\n".join(str(d) for d in ALLOWED_DIRS)
|
|
72
|
+
|
|
73
|
+
@mcp.tool()
|
|
74
|
+
def read_text_file(path: str, head: Optional[int] = None, tail: Optional[int] = None) -> str:
|
|
75
|
+
"""Read text file contents."""
|
|
76
|
+
if head is not None and tail is not None:
|
|
77
|
+
raise ValueError("Cannot specify both head and tail")
|
|
78
|
+
|
|
79
|
+
path_obj = validate_path(path)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
with open(path_obj, 'r', encoding='utf-8') as f:
|
|
83
|
+
if head is not None:
|
|
84
|
+
return "".join([next(f) for _ in range(head)])
|
|
85
|
+
elif tail is not None:
|
|
86
|
+
return "".join(f.readlines()[-tail:])
|
|
87
|
+
else:
|
|
88
|
+
return f.read()
|
|
89
|
+
except UnicodeDecodeError:
|
|
90
|
+
return f"Error: File {path} appears to be binary. Use read_media_file instead."
|
|
91
|
+
except Exception as e:
|
|
92
|
+
return f"Error reading file: {str(e)}"
|
|
93
|
+
|
|
94
|
+
@mcp.tool()
|
|
95
|
+
def read_media_file(path: str) -> dict:
|
|
96
|
+
"""Read an image or audio file as base64."""
|
|
97
|
+
path_obj = validate_path(path)
|
|
98
|
+
mime_type, _ = mimetypes.guess_type(path_obj)
|
|
99
|
+
if not mime_type: mime_type = "application/octet-stream"
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
with open(path_obj, "rb") as f:
|
|
103
|
+
data = base64.b64encode(f.read()).decode("utf-8")
|
|
104
|
+
|
|
105
|
+
type_category = "image" if mime_type.startswith("image/") else "audio" if mime_type.startswith("audio/") else "blob"
|
|
106
|
+
return {"type": type_category, "data": data, "mimeType": mime_type}
|
|
107
|
+
except Exception as e:
|
|
108
|
+
return {"error": str(e)}
|
|
109
|
+
|
|
110
|
+
@mcp.tool()
|
|
111
|
+
def write_file(path: str, content: str) -> str:
|
|
112
|
+
"""Create a new file or completely overwrite an existing file."""
|
|
113
|
+
path_obj = validate_path(path)
|
|
114
|
+
with open(path_obj, 'w', encoding='utf-8') as f:
|
|
115
|
+
f.write(content)
|
|
116
|
+
return f"Successfully wrote to {path}"
|
|
117
|
+
|
|
118
|
+
@mcp.tool()
|
|
119
|
+
def create_directory(path: str) -> str:
|
|
120
|
+
"""Create a new directory or ensure it exists."""
|
|
121
|
+
path_obj = validate_path(path)
|
|
122
|
+
os.makedirs(path_obj, exist_ok=True)
|
|
123
|
+
return f"Successfully created directory {path}"
|
|
124
|
+
|
|
125
|
+
@mcp.tool()
|
|
126
|
+
def list_directory(path: str) -> str:
|
|
127
|
+
"""Get a detailed listing of all files and directories."""
|
|
128
|
+
path_obj = validate_path(path)
|
|
129
|
+
if not path_obj.is_dir(): return f"Error: {path} is not a directory"
|
|
130
|
+
|
|
131
|
+
entries = []
|
|
132
|
+
for entry in path_obj.iterdir():
|
|
133
|
+
prefix = "[DIR]" if entry.is_dir() else "[FILE]"
|
|
134
|
+
entries.append(f"{prefix} {entry.name}")
|
|
135
|
+
return "\n".join(sorted(entries))
|
|
136
|
+
|
|
137
|
+
@mcp.tool()
|
|
138
|
+
def list_directory_with_sizes(path: str) -> str:
|
|
139
|
+
"""Get listing with file sizes."""
|
|
140
|
+
path_obj = validate_path(path)
|
|
141
|
+
if not path_obj.is_dir(): return f"Error: Not a directory"
|
|
142
|
+
|
|
143
|
+
output = []
|
|
144
|
+
for entry in path_obj.iterdir():
|
|
145
|
+
try:
|
|
146
|
+
s = entry.stat().st_size if not entry.is_dir() else 0
|
|
147
|
+
prefix = "[DIR]" if entry.is_dir() else "[FILE]"
|
|
148
|
+
size_str = "" if entry.is_dir() else format_size(s)
|
|
149
|
+
output.append(f"{prefix} {entry.name.ljust(30)} {size_str}")
|
|
150
|
+
except: continue
|
|
151
|
+
return "\n".join(sorted(output))
|
|
152
|
+
|
|
153
|
+
@mcp.tool()
|
|
154
|
+
def move_file(source: str, destination: str) -> str:
|
|
155
|
+
"""Move or rename files."""
|
|
156
|
+
src = validate_path(source)
|
|
157
|
+
dst = validate_path(destination)
|
|
158
|
+
if dst.exists(): raise ValueError(f"Destination {destination} already exists")
|
|
159
|
+
src.rename(dst)
|
|
160
|
+
return f"Moved {source} to {destination}"
|
|
161
|
+
|
|
162
|
+
@mcp.tool()
|
|
163
|
+
def search_files(path: str, pattern: str, exclude_patterns: List[str] = []) -> str:
|
|
164
|
+
"""Recursively search for files matching glob pattern."""
|
|
165
|
+
root = validate_path(path)
|
|
166
|
+
results = []
|
|
167
|
+
for r, d, f in os.walk(root):
|
|
168
|
+
d[:] = [x for x in d if not any(fnmatch.fnmatch(x, p) for p in exclude_patterns)]
|
|
169
|
+
for name in f:
|
|
170
|
+
if any(fnmatch.fnmatch(name, p) for p in exclude_patterns): continue
|
|
171
|
+
if fnmatch.fnmatch(name, pattern):
|
|
172
|
+
results.append(str(Path(r) / name))
|
|
173
|
+
return "\n".join(results) or "No matches found"
|
|
174
|
+
|
|
175
|
+
@mcp.tool()
|
|
176
|
+
def get_file_info(path: str) -> str:
|
|
177
|
+
"""Retrieve detailed metadata."""
|
|
178
|
+
p = validate_path(path)
|
|
179
|
+
s = p.stat()
|
|
180
|
+
return f"Path: {p}\nType: {'Dir' if p.is_dir() else 'File'}\nSize: {format_size(s.st_size)}\nModified: {datetime.fromtimestamp(s.st_mtime)}"
|
|
181
|
+
|
|
182
|
+
@mcp.tool()
|
|
183
|
+
def directory_tree(path: str, exclude_patterns: List[str] = []) -> str:
|
|
184
|
+
"""Get recursive JSON tree."""
|
|
185
|
+
import json
|
|
186
|
+
root = validate_path(path)
|
|
187
|
+
def build(current: Path) -> Dict:
|
|
188
|
+
name = current.name or str(current)
|
|
189
|
+
if any(fnmatch.fnmatch(name, p) for p in exclude_patterns): return None
|
|
190
|
+
node = {"name": name, "type": "directory" if current.is_dir() else "file"}
|
|
191
|
+
if current.is_dir():
|
|
192
|
+
node["children"] = [c for c in [build(e) for e in sorted(current.iterdir(), key=lambda x: x.name)] if c]
|
|
193
|
+
return node
|
|
194
|
+
return json.dumps(build(root), indent=2)
|
|
195
|
+
|
|
196
|
+
@mcp.tool()
|
|
197
|
+
def edit_file(path: str, edits: List[Dict[str, str]], dry_run: bool = False) -> str:
|
|
198
|
+
"""Line-based file editing with diff preview."""
|
|
199
|
+
import difflib
|
|
200
|
+
p = validate_path(path)
|
|
201
|
+
with open(p, 'r', encoding='utf-8') as f: original = f.read()
|
|
202
|
+
modified = original
|
|
203
|
+
for edit in edits:
|
|
204
|
+
old = edit['oldText'].replace('\r\n', '\n')
|
|
205
|
+
new = edit['newText'].replace('\r\n', '\n')
|
|
206
|
+
if old not in modified: raise ValueError(f"Text not found: {old}")
|
|
207
|
+
modified = modified.replace(old, new, 1)
|
|
208
|
+
|
|
209
|
+
diff = "\n".join(difflib.unified_diff(
|
|
210
|
+
original.splitlines(), modified.splitlines(),
|
|
211
|
+
fromfile="original", tofile="modified", lineterm=""
|
|
212
|
+
))
|
|
213
|
+
if not dry_run:
|
|
214
|
+
with open(p, 'w', encoding='utf-8') as f: f.write(modified)
|
|
215
|
+
return diff
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import streamlit as st
|
|
2
|
+
import sys
|
|
3
|
+
import inspect
|
|
4
|
+
import json
|
|
5
|
+
import base64
|
|
6
|
+
from typing import Optional, Union, List, Dict, Any
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# --- 1. SETUP & CONFIG ---
|
|
10
|
+
st.set_page_config(page_title="FS-MCP", layout="wide", page_icon="📂")
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from fs_mcp import server
|
|
14
|
+
except ImportError:
|
|
15
|
+
st.error("❌ Could not import 'fs_mcp.server'. Is the package installed?")
|
|
16
|
+
st.stop()
|
|
17
|
+
|
|
18
|
+
# Initialize Config from CLI Args
|
|
19
|
+
try:
|
|
20
|
+
if "--" in sys.argv:
|
|
21
|
+
raw_args = sys.argv[sys.argv.index("--") + 1:]
|
|
22
|
+
else:
|
|
23
|
+
raw_args = [a for a in sys.argv[1:] if not a.startswith("-")]
|
|
24
|
+
server.initialize(raw_args)
|
|
25
|
+
except Exception as e:
|
|
26
|
+
st.error(f"❌ Configuration Error: {e}")
|
|
27
|
+
st.stop()
|
|
28
|
+
|
|
29
|
+
# --- 2. HEADER ---
|
|
30
|
+
st.title("📂 FS-MCP Explorer")
|
|
31
|
+
if not server.ALLOWED_DIRS:
|
|
32
|
+
st.warning("⚠️ No directories configured! Defaulting to CWD.")
|
|
33
|
+
|
|
34
|
+
st.sidebar.header("Active Configuration")
|
|
35
|
+
st.sidebar.code("\n".join(str(d) for d in server.ALLOWED_DIRS))
|
|
36
|
+
|
|
37
|
+
# --- 3. TOOL DISCOVERY & SCHEMA EXPORT ---
|
|
38
|
+
KNOWN_TOOLS = [
|
|
39
|
+
"list_directory", "list_directory_with_sizes", "read_text_file",
|
|
40
|
+
"read_media_file", "write_file", "create_directory",
|
|
41
|
+
"move_file", "search_files", "get_file_info",
|
|
42
|
+
"directory_tree", "edit_file", "list_allowed_directories"
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
tools = {}
|
|
46
|
+
tool_schemas = []
|
|
47
|
+
|
|
48
|
+
for name in KNOWN_TOOLS:
|
|
49
|
+
if hasattr(server, name):
|
|
50
|
+
wrapper = getattr(server, name)
|
|
51
|
+
fn = wrapper.fn if hasattr(wrapper, 'fn') else wrapper
|
|
52
|
+
tools[name] = fn
|
|
53
|
+
|
|
54
|
+
# Build Schema for export
|
|
55
|
+
schema = {
|
|
56
|
+
"name": name,
|
|
57
|
+
"description": inspect.getdoc(fn) or "",
|
|
58
|
+
"inputSchema": {"type": "object", "properties": {}}
|
|
59
|
+
}
|
|
60
|
+
sig = inspect.signature(fn)
|
|
61
|
+
for param_name, param in sig.parameters.items():
|
|
62
|
+
if param_name in ['ctx', 'context']: continue
|
|
63
|
+
param_type = "string"
|
|
64
|
+
if param.annotation == int: param_type = "integer"
|
|
65
|
+
if param.annotation == bool: param_type = "boolean"
|
|
66
|
+
if param.annotation == list: param_type = "array"
|
|
67
|
+
schema["inputSchema"]["properties"][param_name] = {"type": param_type}
|
|
68
|
+
tool_schemas.append(schema)
|
|
69
|
+
|
|
70
|
+
with st.sidebar.expander("🔌 Tool Schemas (JSON)", expanded=False):
|
|
71
|
+
st.caption("Copy this to agent configuration:")
|
|
72
|
+
st.code(json.dumps(tool_schemas, indent=2), language="json")
|
|
73
|
+
|
|
74
|
+
# --- 4. EXECUTION HANDLER ---
|
|
75
|
+
def execute_tool(func, args):
|
|
76
|
+
"""Executes tool and returns both raw result and protocol view"""
|
|
77
|
+
try:
|
|
78
|
+
# Run the actual function
|
|
79
|
+
result = func(**args)
|
|
80
|
+
|
|
81
|
+
# Simulate Protocol Response (Agent View)
|
|
82
|
+
protocol_response = {
|
|
83
|
+
"content": [],
|
|
84
|
+
"isError": False
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Format Content Block
|
|
88
|
+
if isinstance(result, dict) and result.get("type") == "image":
|
|
89
|
+
# Image protocol format
|
|
90
|
+
protocol_response["content"].append({
|
|
91
|
+
"type": "image",
|
|
92
|
+
"data": result["data"],
|
|
93
|
+
"mimeType": result.get("mimeType", "image/png")
|
|
94
|
+
})
|
|
95
|
+
display_type = "image"
|
|
96
|
+
elif isinstance(result, (dict, list)):
|
|
97
|
+
# Structured data usually sent as embedded text JSON
|
|
98
|
+
text_content = json.dumps(result, indent=2)
|
|
99
|
+
protocol_response["content"].append({
|
|
100
|
+
"type": "text",
|
|
101
|
+
"text": text_content
|
|
102
|
+
})
|
|
103
|
+
display_type = "json"
|
|
104
|
+
else:
|
|
105
|
+
# Plain text
|
|
106
|
+
text_result = str(result)
|
|
107
|
+
protocol_response["content"].append({
|
|
108
|
+
"type": "text",
|
|
109
|
+
"text": text_result
|
|
110
|
+
})
|
|
111
|
+
display_type = "text"
|
|
112
|
+
|
|
113
|
+
return result, protocol_response, display_type, None
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
error_resp = {
|
|
117
|
+
"content": [{"type": "text", "text": str(e)}],
|
|
118
|
+
"isError": True
|
|
119
|
+
}
|
|
120
|
+
return None, error_resp, "error", str(e)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- 5. UI LOGIC ---
|
|
124
|
+
if not tools:
|
|
125
|
+
st.error("No tools found.")
|
|
126
|
+
st.stop()
|
|
127
|
+
|
|
128
|
+
selected = st.sidebar.radio("Available Tools", list(tools.keys()))
|
|
129
|
+
fn = tools[selected]
|
|
130
|
+
sig = inspect.signature(fn)
|
|
131
|
+
|
|
132
|
+
st.header(f"🔧 {selected}")
|
|
133
|
+
if inspect.getdoc(fn):
|
|
134
|
+
st.info(inspect.getdoc(fn))
|
|
135
|
+
|
|
136
|
+
# INPUT TABS
|
|
137
|
+
tab_form, tab_raw, tab_compact = st.tabs(["📝 Interactive Form", "📄 Raw JSON", "⚡ Compact JSON"])
|
|
138
|
+
|
|
139
|
+
execution_args = None
|
|
140
|
+
trigger_run = False
|
|
141
|
+
|
|
142
|
+
# --- TAB 1: INTERACTIVE FORM ---
|
|
143
|
+
with tab_form:
|
|
144
|
+
with st.form("interactive_form"):
|
|
145
|
+
form_inputs = {}
|
|
146
|
+
for name, param in sig.parameters.items():
|
|
147
|
+
if name in ['ctx', 'context']: continue
|
|
148
|
+
|
|
149
|
+
# Type Checking
|
|
150
|
+
annotation = param.annotation
|
|
151
|
+
is_number = (annotation in [int, float]) or (getattr(annotation, "__origin__", None) is Union and int in getattr(annotation, "__args__", []))
|
|
152
|
+
is_bool = (annotation == bool) or (getattr(annotation, "__origin__", None) is Union and bool in getattr(annotation, "__args__", []))
|
|
153
|
+
|
|
154
|
+
if name in ['path', 'source', 'destination']:
|
|
155
|
+
def_val = str(server.ALLOWED_DIRS[0])
|
|
156
|
+
form_inputs[name] = st.text_input(name, value=def_val)
|
|
157
|
+
elif name == 'content':
|
|
158
|
+
st.caption("Literal Content (WYSIWYG - Enter creates newlines)")
|
|
159
|
+
form_inputs[name] = st.text_area(name, height=200)
|
|
160
|
+
elif name == 'edits':
|
|
161
|
+
st.write("Edits (JSON List)")
|
|
162
|
+
val = st.text_area("JSON", value='[{"oldText": "foo", "newText": "bar"}]')
|
|
163
|
+
form_inputs[name] = val # Parse later
|
|
164
|
+
elif name == 'exclude_patterns':
|
|
165
|
+
val = st.text_area(f"{name} (one per line)")
|
|
166
|
+
form_inputs[name] = val
|
|
167
|
+
elif is_bool:
|
|
168
|
+
form_inputs[name] = st.checkbox(name)
|
|
169
|
+
elif is_number:
|
|
170
|
+
form_inputs[name] = st.number_input(name, value=0)
|
|
171
|
+
else:
|
|
172
|
+
form_inputs[name] = st.text_input(name)
|
|
173
|
+
|
|
174
|
+
if st.form_submit_button("Run Form"):
|
|
175
|
+
# Process Form Inputs
|
|
176
|
+
try:
|
|
177
|
+
processed = {}
|
|
178
|
+
for k, v in form_inputs.items():
|
|
179
|
+
# Handle lists
|
|
180
|
+
if k == 'exclude_patterns':
|
|
181
|
+
processed[k] = [x.strip() for x in v.split('\n') if x.strip()]
|
|
182
|
+
# Handle JSON fields
|
|
183
|
+
elif k == 'edits':
|
|
184
|
+
processed[k] = json.loads(v)
|
|
185
|
+
# Handle Optionals
|
|
186
|
+
elif v == 0 and k in ['head', 'tail']:
|
|
187
|
+
processed[k] = None
|
|
188
|
+
else:
|
|
189
|
+
processed[k] = v
|
|
190
|
+
execution_args = processed
|
|
191
|
+
trigger_run = True
|
|
192
|
+
except Exception as e:
|
|
193
|
+
st.error(f"Form Error: {e}")
|
|
194
|
+
|
|
195
|
+
# --- TAB 2 & 3: JSON INPUTS ---
|
|
196
|
+
# Helper to generate template
|
|
197
|
+
default_args = {}
|
|
198
|
+
for name, param in sig.parameters.items():
|
|
199
|
+
if name in ['ctx', 'context']: continue
|
|
200
|
+
if name in ['path', 'source', 'destination']: default_args[name] = str(server.ALLOWED_DIRS[0])
|
|
201
|
+
elif name == 'content': default_args[name] = "Line 1\nLine 2"
|
|
202
|
+
else: default_args[name] = ""
|
|
203
|
+
|
|
204
|
+
json_template = json.dumps(default_args, indent=2)
|
|
205
|
+
|
|
206
|
+
with tab_raw:
|
|
207
|
+
with st.form("json_raw_form"):
|
|
208
|
+
raw_text = st.text_area("JSON Input", value=json_template, height=300)
|
|
209
|
+
if st.form_submit_button("Run Raw JSON"):
|
|
210
|
+
try:
|
|
211
|
+
execution_args = json.loads(raw_text)
|
|
212
|
+
trigger_run = True
|
|
213
|
+
except Exception as e:
|
|
214
|
+
st.error(f"Invalid JSON: {e}")
|
|
215
|
+
|
|
216
|
+
with tab_compact:
|
|
217
|
+
with st.form("json_compact_form"):
|
|
218
|
+
compact_text = st.text_input("One-line JSON", value=json.dumps(default_args))
|
|
219
|
+
if st.form_submit_button("Run Compact JSON"):
|
|
220
|
+
try:
|
|
221
|
+
execution_args = json.loads(compact_text)
|
|
222
|
+
trigger_run = True
|
|
223
|
+
except Exception as e:
|
|
224
|
+
st.error(f"Invalid JSON: {e}")
|
|
225
|
+
|
|
226
|
+
# --- OUTPUT DISPLAY ---
|
|
227
|
+
if trigger_run and execution_args is not None:
|
|
228
|
+
st.divider()
|
|
229
|
+
|
|
230
|
+
# 1. Run
|
|
231
|
+
with st.spinner("Running tool..."):
|
|
232
|
+
res_raw, res_proto, dtype, err = execute_tool(fn, execution_args)
|
|
233
|
+
|
|
234
|
+
# 2. Display Status
|
|
235
|
+
if err:
|
|
236
|
+
st.error("Tool Execution Failed")
|
|
237
|
+
else:
|
|
238
|
+
st.success("Tool Execution Successful")
|
|
239
|
+
|
|
240
|
+
# 3. Split View: Human vs Agent
|
|
241
|
+
col_human, col_agent = st.columns(2)
|
|
242
|
+
|
|
243
|
+
with col_human:
|
|
244
|
+
st.subheader("👀 Human View")
|
|
245
|
+
if err:
|
|
246
|
+
st.error(err)
|
|
247
|
+
elif dtype == "image":
|
|
248
|
+
st.image(base64.b64decode(res_proto["content"][0]["data"]))
|
|
249
|
+
elif dtype == "json":
|
|
250
|
+
st.json(res_raw)
|
|
251
|
+
else:
|
|
252
|
+
# Check for diffs
|
|
253
|
+
text = str(res_raw)
|
|
254
|
+
if text.startswith("---") or text.startswith("+++"):
|
|
255
|
+
st.code(text, language="diff")
|
|
256
|
+
else:
|
|
257
|
+
st.code(text)
|
|
258
|
+
|
|
259
|
+
with col_agent:
|
|
260
|
+
st.subheader("🤖 Agent Protocol View")
|
|
261
|
+
st.caption("This is exactly what the LLM receives.")
|
|
262
|
+
st.code(json.dumps(res_proto, indent=None), language="json")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from fs_mcp import server
|
|
4
|
+
|
|
5
|
+
@pytest.fixture
|
|
6
|
+
def temp_env(tmp_path):
|
|
7
|
+
"""Sets up a safe temporary directory environment"""
|
|
8
|
+
server.initialize([str(tmp_path)])
|
|
9
|
+
return tmp_path
|
|
10
|
+
|
|
11
|
+
def test_security_barrier(temp_env):
|
|
12
|
+
"""Attempting to access outside the temp dir should fail"""
|
|
13
|
+
outside = Path("/etc/passwd")
|
|
14
|
+
|
|
15
|
+
with pytest.raises(ValueError, match="Access denied"):
|
|
16
|
+
server.validate_path(str(outside))
|
|
17
|
+
|
|
18
|
+
def test_write_and_read(temp_env):
|
|
19
|
+
"""Test basic read/write tools"""
|
|
20
|
+
target = temp_env / "test.txt"
|
|
21
|
+
|
|
22
|
+
# Write (Access .fn to call underlying function)
|
|
23
|
+
server.write_file.fn(str(target), "Hello MCP")
|
|
24
|
+
assert target.exists()
|
|
25
|
+
|
|
26
|
+
# Read
|
|
27
|
+
content = server.read_text_file.fn(str(target))
|
|
28
|
+
assert content == "Hello MCP"
|
|
29
|
+
|
|
30
|
+
def test_list_directory(temp_env):
|
|
31
|
+
"""Test directory listing"""
|
|
32
|
+
(temp_env / "A").mkdir()
|
|
33
|
+
(temp_env / "B.txt").touch()
|
|
34
|
+
|
|
35
|
+
res = server.list_directory.fn(str(temp_env))
|
|
36
|
+
assert "[DIR] A" in res
|
|
37
|
+
assert "[FILE] B.txt" in res
|