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.
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
fs_mcp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: fs-mcp
3
+ Version: 0.1.0
4
+ Summary: A secure MCP filesystem server with Stdio and Web UI modes.
5
+ Author-email: Your Name <you@example.com>
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: fastmcp>=0.1.0
8
+ Requires-Dist: streamlit>=1.30.0
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