py-commander-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,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-commander-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP Server for filesystem operations — read, write, edit, search, manage files and directories
5
+ Author-email: Yohann <yohann@example.com>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: mcp[cli]>=1.6.0
9
+ Requires-Dist: openpyxl>=3.1.0
10
+ Requires-Dist: pillow>=10.0.0
11
+ Requires-Dist: pymupdf>=1.25.0
12
+ Requires-Dist: python-docx>=1.1.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # py-commander-mcp
16
+
17
+ MCP Server for filesystem operations — read, write, edit, search, and manage files and directories.
18
+
19
+ Built as a Python port of the key features from Desktop Commander.
20
+
21
+ ## Features
22
+
23
+ - **Read files**: text (with offset/length pagination), PDF, Excel, DOCX, images (base64)
24
+ - **Write files**: text, DOCX (from markdown), Excel (from JSON 2D arrays)
25
+ - **Edit files**: surgical find/replace with `edit_block`
26
+ - **Directory ops**: list (recursive with depth), create, move/rename
27
+ - **File metadata**: size, dates, line counts
28
+ - **Search**: by file name (glob/regex) or file content with context lines
29
+
30
+ ## Usage
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "py-commander-mcp": {
36
+ "command": "uvx",
37
+ "args": ["py-commander-mcp"]
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ ## Tools
44
+
45
+ | Tool | Description |
46
+ |------|-------------|
47
+ | `read_file` | Read files (text, PDF, Excel, DOCX, images) |
48
+ | `read_multiple_files` | Read multiple files at once |
49
+ | `write_file` | Write/create/append files |
50
+ | `edit_block` | Surgical find/replace edits |
51
+ | `create_directory` | Create directories |
52
+ | `list_directory` | List directory contents with depth |
53
+ | `move_file` | Move/rename files and directories |
54
+ | `get_file_info` | File metadata |
55
+ | `start_search` | Search files by name or content |
56
+ | `get_more_search_results` | Paginate search results |
57
+ | `stop_search` | Stop and free a search session |
58
+ | `list_searches` | List active searches |
@@ -0,0 +1,44 @@
1
+ # py-commander-mcp
2
+
3
+ MCP Server for filesystem operations — read, write, edit, search, and manage files and directories.
4
+
5
+ Built as a Python port of the key features from Desktop Commander.
6
+
7
+ ## Features
8
+
9
+ - **Read files**: text (with offset/length pagination), PDF, Excel, DOCX, images (base64)
10
+ - **Write files**: text, DOCX (from markdown), Excel (from JSON 2D arrays)
11
+ - **Edit files**: surgical find/replace with `edit_block`
12
+ - **Directory ops**: list (recursive with depth), create, move/rename
13
+ - **File metadata**: size, dates, line counts
14
+ - **Search**: by file name (glob/regex) or file content with context lines
15
+
16
+ ## Usage
17
+
18
+ ```json
19
+ {
20
+ "mcpServers": {
21
+ "py-commander-mcp": {
22
+ "command": "uvx",
23
+ "args": ["py-commander-mcp"]
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ ## Tools
30
+
31
+ | Tool | Description |
32
+ |------|-------------|
33
+ | `read_file` | Read files (text, PDF, Excel, DOCX, images) |
34
+ | `read_multiple_files` | Read multiple files at once |
35
+ | `write_file` | Write/create/append files |
36
+ | `edit_block` | Surgical find/replace edits |
37
+ | `create_directory` | Create directories |
38
+ | `list_directory` | List directory contents with depth |
39
+ | `move_file` | Move/rename files and directories |
40
+ | `get_file_info` | File metadata |
41
+ | `start_search` | Search files by name or content |
42
+ | `get_more_search_results` | Paginate search results |
43
+ | `stop_search` | Stop and free a search session |
44
+ | `list_searches` | List active searches |
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "py-commander-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP Server for filesystem operations — read, write, edit, search, manage files and directories"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Yohann", email = "yohann@example.com" },
10
+ ]
11
+
12
+ dependencies = [
13
+ "mcp[cli]>=1.6.0",
14
+ "PyMuPDF>=1.25.0",
15
+ "openpyxl>=3.1.0",
16
+ "python-docx>=1.1.0",
17
+ "Pillow>=10.0.0",
18
+ ]
19
+
20
+ [project.scripts]
21
+ py-commander-mcp = "py_commander_mcp.__main__:main"
22
+
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/py_commander_mcp"]
29
+
30
+ [tool.hatch.build.targets.sdist]
31
+ include = ["/src"]
@@ -0,0 +1,3 @@
1
+ """py-commander-mcp: filesystem MCP server for file read/write/edit/search operations."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ """Entry point for the ``py-commander-mcp`` MCP server."""
2
+ from .server import main
3
+
4
+ main()
@@ -0,0 +1,802 @@
1
+ """py-commander-mcp MCP Server — filesystem operations.
2
+
3
+ Provides tools for reading, writing, editing, searching, and managing
4
+ files and directories, with support for text, PDF, Excel, DOCX, and images.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ import shutil
15
+ import time
16
+ import uuid
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ from mcp.server.fastmcp import FastMCP
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ mcp = FastMCP("py-commander")
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Constants
28
+ # ---------------------------------------------------------------------------
29
+
30
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff"}
31
+ TEXT_EXTENSIONS = {
32
+ ".txt", ".md", ".py", ".js", ".ts", ".jsx", ".tsx", ".json", ".yaml",
33
+ ".yml", ".toml", ".cfg", ".ini", ".conf", ".csv", ".xml", ".html", ".css",
34
+ ".sh", ".bash", ".zsh", ".env", ".gitignore", ".dockerfile", ".sql",
35
+ ".rb", ".go", ".rs", ".java", ".c", ".h", ".cpp", ".hpp", ".swift",
36
+ ".kt", ".scala", ".clj", ".cljs", ".edn", ".r", ".m", ".mm",
37
+ }
38
+
39
+ SEARCH_SESSION_TTL = 300 # 5 minutes
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # In-memory state for search sessions
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _search_sessions: dict[str, dict] = {}
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Helpers
49
+ # ---------------------------------------------------------------------------
50
+
51
+ SUFFIX_TO_FORMAT: dict[str, str] = {
52
+ ".xlsx": "xlsx",
53
+ ".xls": "xls",
54
+ ".xlsm": "xlsm",
55
+ ".pdf": "pdf",
56
+ ".docx": "docx",
57
+ }
58
+
59
+
60
+ def _detect_format(path: Path) -> str | None:
61
+ """Detect special file format from extension."""
62
+ return SUFFIX_TO_FORMAT.get(path.suffix.lower())
63
+
64
+
65
+ def _is_text_file(path: Path) -> bool:
66
+ """Check if a file is likely a text file based on extension."""
67
+ fmt = _detect_format(path)
68
+ if fmt:
69
+ return False # special binary formats
70
+ if path.suffix.lower() in IMAGE_EXTENSIONS:
71
+ return False
72
+ # Default to text for unknown extensions (try reading as text)
73
+ return True
74
+
75
+
76
+ def _is_image_file(path: Path) -> bool:
77
+ """Check if a file is an image based on extension."""
78
+ return path.suffix.lower() in IMAGE_EXTENSIONS
79
+
80
+
81
+ def _read_text_file(path: Path, offset: int = 0, length: int | None = None) -> dict:
82
+ """Read a text file with optional offset/length pagination."""
83
+ total_lines = 0
84
+ lines = []
85
+
86
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
87
+ if offset < 0:
88
+ # Read last N lines (tail)
89
+ all_lines = f.readlines()
90
+ total_lines = len(all_lines)
91
+ wanted = abs(offset)
92
+ lines = all_lines[-wanted:]
93
+ start_line = total_lines - len(lines)
94
+ elif offset > 0:
95
+ all_lines = f.readlines()
96
+ total_lines = len(all_lines)
97
+ if offset >= total_lines:
98
+ lines = []
99
+ start_line = total_lines
100
+ else:
101
+ end = offset + length if length else total_lines
102
+ lines = all_lines[offset:end]
103
+ start_line = offset
104
+ else:
105
+ if length:
106
+ all_lines = f.readlines()
107
+ total_lines = len(all_lines)
108
+ lines = all_lines[:length]
109
+ start_line = 0
110
+ else:
111
+ content = f.read()
112
+ total_lines = content.count("\n") + 1
113
+ return {
114
+ "content": content,
115
+ "total_lines": total_lines,
116
+ "start_line": 0,
117
+ "end_line": total_lines - 1,
118
+ "is_truncated": False,
119
+ }
120
+
121
+ return {
122
+ "content": "".join(lines),
123
+ "total_lines": total_lines,
124
+ "start_line": start_line,
125
+ "end_line": start_line + len(lines) - 1,
126
+ "is_truncated": total_lines > (start_line + len(lines)),
127
+ }
128
+
129
+
130
+ def _read_xlsx(path: Path, sheet: str | None = None, range_str: str | None = None) -> list[list]:
131
+ """Read an Excel file and return as 2D list."""
132
+ import openpyxl
133
+
134
+ wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
135
+ try:
136
+ if sheet and sheet in wb.sheetnames:
137
+ ws = wb[sheet]
138
+ elif sheet and sheet.isdigit():
139
+ idx = int(sheet)
140
+ ws = wb.worksheets[idx]
141
+ else:
142
+ ws = wb.active
143
+
144
+ if range_str and ":" in range_str:
145
+ from openpyxl.utils import range_boundaries
146
+ min_col, min_row, max_col, max_row = range_boundaries(range_str)
147
+ data = []
148
+ for row in ws.iter_rows(min_row=min_row, min_col=min_col,
149
+ max_row=max_row, max_col=max_col,
150
+ values_only=True):
151
+ data.append([v if v is not None else "" for v in row])
152
+ return data
153
+ else:
154
+ return [[cell.value if cell.value is not None else "" for cell in row] for row in ws.iter_rows(values_only=True)]
155
+ finally:
156
+ wb.close()
157
+
158
+
159
+ def _read_pdf(path: Path) -> str:
160
+ """Extract text from a PDF file as markdown."""
161
+ import fitz # PyMuPDF
162
+ doc = fitz.open(path)
163
+ parts = []
164
+ for i, page in enumerate(doc):
165
+ text = page.get_text()
166
+ parts.append(f"## Page {i + 1}\n\n{text}")
167
+ doc.close()
168
+ return "\n\n".join(parts)
169
+
170
+
171
+ def _read_docx(path: Path) -> str:
172
+ """Extract text from a DOCX file."""
173
+ from docx import Document
174
+ doc = Document(path)
175
+ parts = []
176
+ for para in doc.paragraphs:
177
+ parts.append(para.text)
178
+ # Include tables
179
+ for table in doc.tables:
180
+ for row in table.rows:
181
+ cells = [cell.text for cell in row.cells]
182
+ parts.append(" | ".join(cells))
183
+ return "\n\n".join(parts)
184
+
185
+
186
+ def _read_image_base64(path: Path) -> dict:
187
+ """Read an image file and return base64 data with MIME type."""
188
+ from PIL import Image
189
+ import base64
190
+
191
+ img = Image.open(path)
192
+ mime_map = {
193
+ "png": "image/png",
194
+ "jpeg": "image/jpeg",
195
+ "jpg": "image/jpeg",
196
+ "gif": "image/gif",
197
+ "webp": "image/webp",
198
+ "bmp": "image/bmp",
199
+ }
200
+ fmt = img.format.lower() if img.format else "png"
201
+ mime = mime_map.get(fmt, "image/png")
202
+
203
+ buf = io.BytesIO()
204
+ img.save(buf, format=img.format or "PNG")
205
+ b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
206
+
207
+ return {
208
+ "mime_type": mime,
209
+ "data": b64,
210
+ "width": img.width,
211
+ "height": img.height,
212
+ }
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Search helpers
217
+ # ---------------------------------------------------------------------------
218
+
219
+ def _run_file_search(base_path: Path, pattern: str, ignore_case: bool = True,
220
+ include_hidden: bool = False, max_results: int = 200) -> list[dict]:
221
+ """Search for files matching a pattern (glob or regex)."""
222
+ results = []
223
+ is_regex = any(c in pattern for c in "*?[]{}()+|^$\\")
224
+ flags = re.IGNORECASE if ignore_case else 0
225
+
226
+ try:
227
+ regex = re.compile(pattern, flags) if is_regex else None
228
+ except re.error:
229
+ regex = None
230
+
231
+ # Simple glob matching for non-regex patterns without wildcards
232
+ if not is_regex and "*" not in pattern and "?" not in pattern:
233
+ # Search by exact filename or substring
234
+ pat_lower = pattern.lower() if ignore_case else pattern
235
+ for root, dirs, files in os.walk(base_path):
236
+ if not include_hidden:
237
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
238
+ files = [f for f in files if not f.startswith(".")]
239
+ for f in files:
240
+ f_lower = f.lower() if ignore_case else f
241
+ if pat_lower in f_lower:
242
+ results.append({"path": str(Path(root) / f), "type": "file"})
243
+ if len(results) >= max_results:
244
+ return results
245
+ for d in dirs:
246
+ d_lower = d.lower() if ignore_case else d
247
+ if pat_lower in d_lower:
248
+ results.append({"path": str(Path(root) / d), "type": "directory"})
249
+ if len(results) >= max_results:
250
+ return results
251
+ return results
252
+
253
+ # Glob or regex matching
254
+ for root, dirs, files in os.walk(base_path):
255
+ if not include_hidden:
256
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
257
+ files = [f for f in files if not f.startswith(".")]
258
+
259
+ for f in files:
260
+ if regex and regex.search(f):
261
+ results.append({"path": str(Path(root) / f), "type": "file"})
262
+ elif not regex:
263
+ import fnmatch
264
+ if fnmatch.fnmatch(f, pattern):
265
+ results.append({"path": str(Path(root) / f), "type": "file"})
266
+ if len(results) >= max_results:
267
+ return results
268
+
269
+ for d in dirs:
270
+ if regex and regex.search(d):
271
+ results.append({"path": str(Path(root) / d), "type": "directory"})
272
+ elif not regex:
273
+ import fnmatch
274
+ if fnmatch.fnmatch(d, pattern):
275
+ results.append({"path": str(Path(root) / d), "type": "directory"})
276
+ if len(results) >= max_results:
277
+ return results
278
+
279
+ return results
280
+
281
+
282
+ def _run_content_search(base_path: Path, pattern: str, file_pattern: str | None = None,
283
+ ignore_case: bool = True, context_lines: int = 5,
284
+ max_results: int = 200) -> list[dict]:
285
+ """Search for text content inside files."""
286
+ results = []
287
+ flags = re.IGNORECASE | re.DOTALL if ignore_case else re.DOTALL
288
+
289
+ try:
290
+ regex = re.compile(pattern, flags)
291
+ except re.error:
292
+ return results
293
+
294
+ file_regex = None
295
+ if file_pattern:
296
+ try:
297
+ file_regex = re.compile(file_pattern, re.IGNORECASE)
298
+ except re.error:
299
+ pass
300
+
301
+ for root, dirs, files in os.walk(base_path):
302
+ dirs[:] = [d for d in dirs if not d.startswith(".")]
303
+ files = [f for f in files if not f.startswith(".")]
304
+
305
+ for fname in files:
306
+ if file_regex and not file_regex.search(fname):
307
+ continue
308
+
309
+ fpath = Path(root) / fname
310
+ # Skip binary-ish files
311
+ if fpath.suffix.lower() in IMAGE_EXTENSIONS:
312
+ continue
313
+ if _detect_format(fpath):
314
+ continue
315
+
316
+ try:
317
+ with open(fpath, "r", encoding="utf-8", errors="replace") as f:
318
+ text = f.read()
319
+ except Exception:
320
+ continue
321
+
322
+ matches = list(regex.finditer(text))
323
+ for m in matches[:10]: # limit matches per file
324
+ start = max(0, m.start() - context_lines * 200)
325
+ end = min(len(text), m.end() + context_lines * 200)
326
+ snippet = text[start:end]
327
+ lines_before = snippet[:m.start() - start].count("\n")
328
+ total_context = snippet.count("\n")
329
+
330
+ results.append({
331
+ "path": str(fpath),
332
+ "line": text[:m.start()].count("\n") + 1,
333
+ "content": m.group(),
334
+ "context": snippet,
335
+ "context_lines_before": lines_before,
336
+ "context_lines_total": total_context,
337
+ })
338
+
339
+ if len(results) >= max_results:
340
+ return results
341
+
342
+ return results
343
+
344
+
345
+ # ---------------------------------------------------------------------------
346
+ # MCP Tools
347
+ # ---------------------------------------------------------------------------
348
+
349
+ @mcp.tool()
350
+ def read_file(
351
+ path: str,
352
+ offset: int = 0,
353
+ length: int | None = None,
354
+ sheet: str | None = None,
355
+ range: str | None = None,
356
+ ) -> dict:
357
+ """Read contents from files and URLs.
358
+
359
+ Supports text files (with offset/length pagination), PDF (extracted as
360
+ markdown), Excel (as 2D arrays), DOCX (as text), and images (as base64).
361
+
362
+ Args:
363
+ path: Absolute path to the file.
364
+ offset: Start line (0-based). Negative = read last N lines (tail).
365
+ length: Max lines to read (default: read all).
366
+ sheet: Sheet name or index (Excel only).
367
+ range: Cell range in FROM:TO format (Excel only, e.g. "A1:D100").
368
+ """
369
+ fpath = Path(path).resolve()
370
+ if not fpath.exists():
371
+ return {"error": f"File not found: {path}"}
372
+
373
+ fmt = _detect_format(fpath)
374
+
375
+ if fmt == "pdf":
376
+ text = _read_pdf(fpath)
377
+ return {"content": text, "format": "markdown"}
378
+
379
+ if fmt in ("xlsx", "xls", "xlsm"):
380
+ data = _read_xlsx(fpath, sheet=sheet, range_str=range)
381
+ return {"data": data, "format": "json"}
382
+
383
+ if fmt == "docx":
384
+ text = _read_docx(fpath)
385
+ return {"content": text, "format": "markdown"}
386
+
387
+ if _is_image_file(fpath):
388
+ img_data = _read_image_base64(fpath)
389
+ return img_data # mime_type + data + width + height
390
+
391
+ # Text file (default)
392
+ result = _read_text_file(fpath, offset=offset, length=length)
393
+ return result
394
+
395
+
396
+ @mcp.tool()
397
+ def read_multiple_files(paths: list[str]) -> dict:
398
+ """Read the contents of multiple files simultaneously.
399
+
400
+ Each file's content is returned with its path as a reference.
401
+ Failed reads for individual files won't stop the entire operation.
402
+
403
+ Args:
404
+ paths: List of absolute file paths.
405
+ """
406
+ results = {}
407
+ for p in paths:
408
+ try:
409
+ result = read_file(path=p)
410
+ results[p] = result
411
+ except Exception as e:
412
+ results[p] = {"error": str(e)}
413
+ return {"files": results}
414
+
415
+
416
+ @mcp.tool()
417
+ def write_file(path: str, content: str, mode: str = "rewrite") -> dict:
418
+ """Write content to a file.
419
+
420
+ For .docx extension, creates a styled DOCX from markdown content.
421
+ For .xlsx/.xls extensions, content should be a JSON 2D array or
422
+ dict of sheet names to 2D arrays.
423
+
424
+ Args:
425
+ path: Absolute path to the file.
426
+ content: File content (text, or JSON for Excel).
427
+ mode: ``"rewrite"`` (overwrite) or ``"append"`` (append to existing).
428
+ """
429
+ fpath = Path(path).resolve()
430
+ fmt = _detect_format(fpath)
431
+
432
+ if fmt == "docx":
433
+ from docx import Document
434
+ from docx.shared import Pt, Inches
435
+ import re as _re
436
+
437
+ doc = Document()
438
+ for line in content.split("\n"):
439
+ line = line.rstrip()
440
+ if line.startswith("# "):
441
+ doc.add_heading(line[2:], level=1)
442
+ elif line.startswith("## "):
443
+ doc.add_heading(line[2:], level=2)
444
+ elif line.startswith("### "):
445
+ doc.add_heading(line[3:], level=3)
446
+ elif line.strip() == "":
447
+ doc.add_paragraph("")
448
+ else:
449
+ doc.add_paragraph(line)
450
+ doc.save(fpath)
451
+ return {"message": f"DOCX written ({len(content)} chars)", "path": str(fpath)}
452
+
453
+ if fmt in ("xlsx", "xls"):
454
+ import openpyxl
455
+
456
+ try:
457
+ data = json.loads(content)
458
+ except json.JSONDecodeError:
459
+ return {"error": "Content must be valid JSON for Excel files"}
460
+
461
+ wb = openpyxl.Workbook()
462
+ if isinstance(data, dict):
463
+ for sheet_name, rows in data.items():
464
+ if sheet_name == list(data.keys())[0]:
465
+ ws = wb.active
466
+ ws.title = sheet_name
467
+ else:
468
+ ws = wb.create_sheet(title=sheet_name)
469
+ for row in rows:
470
+ ws.append(row)
471
+ elif isinstance(data, list):
472
+ ws = wb.active
473
+ for row in data:
474
+ ws.append(row)
475
+ else:
476
+ return {"error": "Content must be a 2D array or dict of sheets"}
477
+
478
+ wb.save(fpath)
479
+ return {"message": f"Excel written ({len(content)} chars)", "path": str(fpath)}
480
+
481
+ # Text file
482
+ write_mode = "a" if mode == "append" else "w"
483
+ encoding = "utf-8"
484
+
485
+ fpath.parent.mkdir(parents=True, exist_ok=True)
486
+ with open(fpath, write_mode, encoding=encoding) as f:
487
+ f.write(content)
488
+
489
+ return {"message": f"File written ({len(content)} chars)", "path": str(fpath)}
490
+
491
+
492
+ @mcp.tool()
493
+ def edit_block(
494
+ file_path: str,
495
+ old_string: str,
496
+ new_string: str,
497
+ expected_replacements: int = 1,
498
+ ) -> dict:
499
+ """Apply surgical edits to text files.
500
+
501
+ Replaces occurrences of ``old_string`` with ``new_string``.
502
+ By default replaces exactly 1 occurrence (use ``expected_replacements``
503
+ for multiple).
504
+
505
+ Args:
506
+ file_path: Absolute path to the file to edit.
507
+ old_string: Text to search for.
508
+ new_string: Replacement text.
509
+ expected_replacements: Number of replacements expected (default: 1).
510
+ """
511
+ fpath = Path(file_path).resolve()
512
+ if not fpath.exists():
513
+ return {"error": f"File not found: {file_path}"}
514
+
515
+ with open(fpath, "r", encoding="utf-8") as f:
516
+ text = f.read()
517
+
518
+ count = text.count(old_string)
519
+ if count == 0:
520
+ # Try approximate matching
521
+ return {
522
+ "error": f"old_string not found in file. Found {count} occurrences.",
523
+ "hint": "Check exact whitespace and indentation in old_string.",
524
+ }
525
+
526
+ if count < expected_replacements:
527
+ return {
528
+ "error": f"Found {count} occurrence(s), expected {expected_replacements}.",
529
+ }
530
+
531
+ text = text.replace(old_string, new_string, expected_replacements)
532
+
533
+ with open(fpath, "w", encoding="utf-8") as f:
534
+ f.write(text)
535
+
536
+ return {
537
+ "message": f"Applied {expected_replacements} replacement(s)",
538
+ "path": str(fpath),
539
+ }
540
+
541
+
542
+ @mcp.tool()
543
+ def create_directory(path: str) -> dict:
544
+ """Create a new directory or ensure it exists.
545
+
546
+ Creates parent directories if needed. Succeeds silently if the
547
+ directory already exists.
548
+
549
+ Args:
550
+ path: Absolute path of the directory to create.
551
+ """
552
+ dpath = Path(path).resolve()
553
+ dpath.mkdir(parents=True, exist_ok=True)
554
+ return {"message": f"Directory ready: {dpath}"}
555
+
556
+
557
+ @mcp.tool()
558
+ def list_directory(path: str, depth: int = 2) -> list[dict]:
559
+ """Get a detailed listing of files and directories.
560
+
561
+ Results distinguish between files and directories with [FILE] and [DIR]
562
+ prefixes.
563
+
564
+ Args:
565
+ path: Absolute path to list.
566
+ depth: How deep to recurse (default: 2, minimum: 1).
567
+ """
568
+ base = Path(path).resolve()
569
+ if not base.exists():
570
+ return [{"type": "error", "message": f"Path not found: {path}"}]
571
+ if not base.is_dir():
572
+ return [{"type": "error", "message": f"Not a directory: {path}"}]
573
+
574
+ depth = max(1, depth)
575
+ results = []
576
+ _list_recursive(base, base, depth, results)
577
+ return results
578
+
579
+
580
+ def _list_recursive(base: Path, current: Path, max_depth: int, results: list):
581
+ if not current.is_dir():
582
+ return
583
+ try:
584
+ entries = sorted(current.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
585
+ except PermissionError:
586
+ results.append({
587
+ "type": "denied",
588
+ "path": str(current.relative_to(base)) if current != base else ".",
589
+ })
590
+ return
591
+
592
+ for entry in entries:
593
+ rel = str(entry.relative_to(base)) if entry != base else "."
594
+ if entry.is_dir():
595
+ results.append({"type": "dir", "path": rel})
596
+ if max_depth > 1:
597
+ _list_recursive(base, entry, max_depth - 1, results)
598
+ else:
599
+ results.append({"type": "file", "path": rel})
600
+
601
+
602
+ @mcp.tool()
603
+ def move_file(source: str, destination: str) -> dict:
604
+ """Move or rename files and directories.
605
+
606
+ Args:
607
+ source: Current absolute path.
608
+ destination: New absolute path.
609
+ """
610
+ src = Path(source).resolve()
611
+ dst = Path(destination).resolve()
612
+
613
+ if not src.exists():
614
+ return {"error": f"Source not found: {source}"}
615
+ if dst.exists():
616
+ return {"error": f"Destination already exists: {destination}"}
617
+
618
+ dst.parent.mkdir(parents=True, exist_ok=True)
619
+ shutil.move(str(src), str(dst))
620
+ return {"message": f"Moved to {destination}"}
621
+
622
+
623
+ @mcp.tool()
624
+ def get_file_info(path: str) -> dict:
625
+ """Retrieve detailed metadata about a file or directory.
626
+
627
+ Returns size, creation time, last modified time, type, and for text
628
+ files: line count and last line number.
629
+
630
+ Args:
631
+ path: Absolute path to the file or directory.
632
+ """
633
+ fpath = Path(path).resolve()
634
+ if not fpath.exists():
635
+ return {"error": f"Path not found: {path}"}
636
+
637
+ stat = fpath.stat()
638
+ info = {
639
+ "path": str(fpath),
640
+ "size": stat.st_size,
641
+ "created": stat.st_ctime,
642
+ "modified": stat.st_mtime,
643
+ "type": "directory" if fpath.is_dir() else "file",
644
+ }
645
+
646
+ if fpath.is_file():
647
+ info["extension"] = fpath.suffix
648
+ # Try to count lines for text files
649
+ try:
650
+ if _is_text_file(fpath):
651
+ with open(fpath, "r", encoding="utf-8", errors="replace") as f:
652
+ line_count = sum(1 for _ in f)
653
+ info["line_count"] = line_count
654
+ info["last_line"] = line_count - 1 # zero-indexed
655
+ except Exception:
656
+ pass
657
+
658
+ return info
659
+
660
+
661
+ @mcp.tool()
662
+ def start_search(
663
+ path: str,
664
+ pattern: str,
665
+ search_type: str = "files",
666
+ file_pattern: str | None = None,
667
+ ignore_case: bool = True,
668
+ max_results: int = 200,
669
+ include_hidden: bool = False,
670
+ context_lines: int = 5,
671
+ literal_search: bool = False,
672
+ ) -> dict:
673
+ """Start a search for files by name or content.
674
+
675
+ Args:
676
+ path: Directory to search in.
677
+ pattern: Search pattern (text or regex).
678
+ search_type: ``"files"`` (by name) or ``"content"`` (inside files).
679
+ file_pattern: Filter by file name pattern (content search only).
680
+ ignore_case: Case-insensitive search (default: true).
681
+ max_results: Maximum results to return (default: 200).
682
+ include_hidden: Include hidden files (default: false).
683
+ context_lines: Context lines for content search (default: 5).
684
+ literal_search: Treat pattern as literal string, not regex (default: false).
685
+ """
686
+ base_path = Path(path).resolve()
687
+ if not base_path.exists():
688
+ return {"error": f"Path not found: {path}"}
689
+
690
+ if literal_search:
691
+ pattern = re.escape(pattern)
692
+
693
+ session_id = str(uuid.uuid4())
694
+
695
+ if search_type == "content":
696
+ results = _run_content_search(
697
+ base_path, pattern, file_pattern=file_pattern,
698
+ ignore_case=ignore_case, context_lines=context_lines,
699
+ max_results=max_results,
700
+ )
701
+ else:
702
+ results = _run_file_search(
703
+ base_path, pattern, ignore_case=ignore_case,
704
+ include_hidden=include_hidden, max_results=max_results,
705
+ )
706
+
707
+ session = {
708
+ "id": session_id,
709
+ "results": results,
710
+ "pattern": pattern,
711
+ "search_type": search_type,
712
+ "created_at": time.time(),
713
+ "total": len(results),
714
+ }
715
+ _search_sessions[session_id] = session
716
+
717
+ return {
718
+ "session_id": session_id,
719
+ "results": results[:50],
720
+ "total": len(results),
721
+ "truncated": len(results) > 50,
722
+ }
723
+
724
+
725
+ @mcp.tool()
726
+ def get_more_search_results(
727
+ session_id: str,
728
+ offset: int = 0,
729
+ length: int = 100,
730
+ ) -> dict:
731
+ """Get more results from an active search.
732
+
733
+ Args:
734
+ session_id: Session ID from ``start_search``.
735
+ offset: Start index (0-based). Negative = last N results.
736
+ length: Max results to return (default: 100).
737
+ """
738
+ session = _search_sessions.get(session_id)
739
+ if not session:
740
+ return {"error": f"Search session not found: {session_id}"}
741
+
742
+ results = session["results"]
743
+
744
+ if offset < 0:
745
+ wanted = abs(offset)
746
+ chunk = results[-wanted:]
747
+ start = len(results) - len(chunk)
748
+ else:
749
+ end = offset + length if length else len(results)
750
+ chunk = results[offset:end]
751
+ start = offset
752
+
753
+ return {
754
+ "session_id": session_id,
755
+ "results": chunk,
756
+ "total": len(results),
757
+ "start": start,
758
+ "end": start + len(chunk),
759
+ }
760
+
761
+
762
+ @mcp.tool()
763
+ def stop_search(session_id: str) -> dict:
764
+ """Stop an active search and free its memory.
765
+
766
+ Args:
767
+ session_id: Session ID from ``start_search``.
768
+ """
769
+ if session_id in _search_sessions:
770
+ del _search_sessions[session_id]
771
+ return {"message": f"Search session {session_id} stopped and freed."}
772
+ return {"error": f"Search session not found: {session_id}"}
773
+
774
+
775
+ @mcp.tool()
776
+ def list_searches() -> dict:
777
+ """List all active search sessions."""
778
+ return {
779
+ "sessions": [
780
+ {
781
+ "id": sid,
782
+ "pattern": s["pattern"],
783
+ "search_type": s["search_type"],
784
+ "total": s["total"],
785
+ "age_seconds": time.time() - s["created_at"],
786
+ }
787
+ for sid, s in _search_sessions.items()
788
+ ]
789
+ }
790
+
791
+
792
+ # ---------------------------------------------------------------------------
793
+ # Main entry point
794
+ # ---------------------------------------------------------------------------
795
+
796
+ def main() -> None:
797
+ """Run the py-commander-mcp MCP server."""
798
+ mcp.run(transport="stdio")
799
+
800
+
801
+ if __name__ == "__main__":
802
+ main()