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.
- py_commander_mcp-0.1.0/PKG-INFO +58 -0
- py_commander_mcp-0.1.0/README.md +44 -0
- py_commander_mcp-0.1.0/pyproject.toml +31 -0
- py_commander_mcp-0.1.0/src/py_commander_mcp/__init__.py +3 -0
- py_commander_mcp-0.1.0/src/py_commander_mcp/__main__.py +4 -0
- py_commander_mcp-0.1.0/src/py_commander_mcp/server.py +802 -0
|
@@ -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,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()
|