mcp-files-context 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_files_context-0.2.0/LICENSE +21 -0
- mcp_files_context-0.2.0/PKG-INFO +105 -0
- mcp_files_context-0.2.0/README.md +91 -0
- mcp_files_context-0.2.0/pyproject.toml +28 -0
- mcp_files_context-0.2.0/pyproject.toml.orig +28 -0
- mcp_files_context-0.2.0/src/mcp_server/__init__.py +72 -0
- mcp_files_context-0.2.0/src/mcp_server/cli.py +52 -0
- mcp_files_context-0.2.0/src/mcp_server/config.py +22 -0
- mcp_files_context-0.2.0/src/mcp_server/helpers.py +57 -0
- mcp_files_context-0.2.0/src/mcp_server/mcp.py +2 -0
- mcp_files_context-0.2.0/src/mcp_server/py.typed +0 -0
- mcp_files_context-0.2.0/src/mcp_server/tools.py +240 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 convert.guru
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-files-context
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: MCP tools for text files inside a configured local workspace
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: fastmcp>=4.1.4,<5
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Project-URL: Homepage, https://github.com/jayanth-mkv/agent-mcp-registry
|
|
10
|
+
Project-URL: Documentation, https://github.com/jayanth-mkv/agent-mcp-registry/tree/main/mcp-files
|
|
11
|
+
Project-URL: Repository, https://github.com/jayanth-mkv/agent-mcp-registry
|
|
12
|
+
Project-URL: Issues, https://github.com/jayanth-mkv/agent-mcp-registry/issues
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# File Server MCP
|
|
16
|
+
|
|
17
|
+
An MCP server with 14 tools for managing UTF-8 text files within a configured workspace. No API key or external service is required.
|
|
18
|
+
|
|
19
|
+
## Install a published release
|
|
20
|
+
|
|
21
|
+
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/). The distribution is `mcp-files-context`; the command is `mcp-files`:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uvx --python 3.13 --from mcp-files-context==0.2.0 mcp-files --workspace ./workspace
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Published versions are listed on [PyPI](https://pypi.org/project/mcp-files-context/). If the version is not available yet, use the GitHub command below. Publishing is managed through [GitHub Actions](https://github.com/jayanth-mkv/agent-mcp-registry/blob/main/docs/publishing.md).
|
|
28
|
+
|
|
29
|
+
## Run from GitHub without cloning
|
|
30
|
+
|
|
31
|
+
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/) and Git. uv downloads the selected package and manages Python 3.13 and its dependencies:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uvx --python 3.13 --from "git+https://github.com/jayanth-mkv/agent-mcp-registry.git@main#subdirectory=mcp-files" mcp-files --workspace ./workspace
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Only this MCP and its runtime dependencies are installed. uv fetches the repository internally; you do not need a local checkout. To pin the source, replace `main` with a release tag or full commit SHA.
|
|
38
|
+
|
|
39
|
+
## Install from source
|
|
40
|
+
|
|
41
|
+
From this directory, with Python 3.13+ and [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv sync --locked --no-dev
|
|
45
|
+
uv run --locked --no-dev mcp-files --workspace ./workspace
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The default transport is stdio. Configure your MCP client using the [repository example](https://github.com/jayanth-mkv/agent-mcp-registry#connect-an-mcp-client).
|
|
49
|
+
|
|
50
|
+
For Streamable HTTP:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv run --locked --no-dev mcp-files --transport http --host 127.0.0.1 --port 8000 --workspace ./workspace
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Endpoint: `http://127.0.0.1:8000/mcp`. Legacy SSE is available with `--transport sse` at `/sse`.
|
|
57
|
+
|
|
58
|
+
## Tools
|
|
59
|
+
|
|
60
|
+
| Tool | Arguments | Behavior |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| `create_file` | `path`, `content=""` | Create a new file; fail if it exists. |
|
|
63
|
+
| `read_file` | `path` | Read UTF-8 text. |
|
|
64
|
+
| `write_file` | `path`, `content` | Create or overwrite a text file. |
|
|
65
|
+
| `append_file` | `path`, `content` | Append text; create the file if missing. |
|
|
66
|
+
| `delete_path` | `path` | Delete a file or recursively delete a directory. |
|
|
67
|
+
| `rename_path` | `src`, `dst` | Rename a file or directory. |
|
|
68
|
+
| `copy_path` | `src`, `dst` | Copy a file or directory; merge existing directories. |
|
|
69
|
+
| `move_path` | `src`, `dst` | Move a file or directory. |
|
|
70
|
+
| `list_files` | None | List workspace files recursively. |
|
|
71
|
+
| `list_dirs` | None | List workspace directories recursively. |
|
|
72
|
+
| `search_file` | `path`, `term` | Return matching lines with line numbers. |
|
|
73
|
+
| `file_info` | `path` | Return JSON metadata. |
|
|
74
|
+
| `workspace_stats` | None | Return JSON counts, sizes, and extensions. |
|
|
75
|
+
| `path_exists` | `path` | Return `file`, `dir`, or `none`. |
|
|
76
|
+
|
|
77
|
+
Paths resolve inside the configured workspace. Parent directories are created when writing files. Relative workspace paths resolve from the process working directory; prefer an absolute workspace path in client configuration.
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
| Setting | Environment variable | Default |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| Workspace | `MCP_WORKSPACE_DIR` | `./workspace` |
|
|
84
|
+
| HTTP/SSE host | `MCP_SERVER_HOST` | `127.0.0.1` |
|
|
85
|
+
| HTTP/SSE port | `MCP_SERVER_PORT` | `8000` |
|
|
86
|
+
|
|
87
|
+
CLI flags override environment settings. `--workspace` is applied before the server starts. Startup diagnostics use stderr so stdout remains available for MCP messages.
|
|
88
|
+
|
|
89
|
+
## Boundaries
|
|
90
|
+
|
|
91
|
+
Use a dedicated workspace: callers have read and write access, including recursive deletion. The workspace root itself cannot be deleted, renamed, or moved. Symbolic links and Windows junctions are unsupported and rejected, including links inside copied directory trees. These checks do not provide isolation against concurrent filesystem changes by other processes.
|
|
92
|
+
|
|
93
|
+
The server has no authentication or size quotas. Keep HTTP/SSE on loopback. Text tools read complete files into memory and are intended for small local text files.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
uv sync --locked
|
|
99
|
+
uv run --locked pytest -q
|
|
100
|
+
uv build
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The CLI is now `mcp-files`; the former nested `mcp-server` workspace has been consolidated into this package. AI clients are in [experimental/](https://github.com/jayanth-mkv/agent-mcp-registry/tree/main/experimental).
|
|
104
|
+
|
|
105
|
+
Version 0.2.0. Licensed under the [MIT License](https://github.com/jayanth-mkv/agent-mcp-registry/blob/main/mcp-files/LICENSE).
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# File Server MCP
|
|
2
|
+
|
|
3
|
+
An MCP server with 14 tools for managing UTF-8 text files within a configured workspace. No API key or external service is required.
|
|
4
|
+
|
|
5
|
+
## Install a published release
|
|
6
|
+
|
|
7
|
+
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/). The distribution is `mcp-files-context`; the command is `mcp-files`:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uvx --python 3.13 --from mcp-files-context==0.2.0 mcp-files --workspace ./workspace
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Published versions are listed on [PyPI](https://pypi.org/project/mcp-files-context/). If the version is not available yet, use the GitHub command below. Publishing is managed through [GitHub Actions](https://github.com/jayanth-mkv/agent-mcp-registry/blob/main/docs/publishing.md).
|
|
14
|
+
|
|
15
|
+
## Run from GitHub without cloning
|
|
16
|
+
|
|
17
|
+
Requires [uv](https://docs.astral.sh/uv/getting-started/installation/) and Git. uv downloads the selected package and manages Python 3.13 and its dependencies:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uvx --python 3.13 --from "git+https://github.com/jayanth-mkv/agent-mcp-registry.git@main#subdirectory=mcp-files" mcp-files --workspace ./workspace
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Only this MCP and its runtime dependencies are installed. uv fetches the repository internally; you do not need a local checkout. To pin the source, replace `main` with a release tag or full commit SHA.
|
|
24
|
+
|
|
25
|
+
## Install from source
|
|
26
|
+
|
|
27
|
+
From this directory, with Python 3.13+ and [uv](https://docs.astral.sh/uv/getting-started/installation/):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv sync --locked --no-dev
|
|
31
|
+
uv run --locked --no-dev mcp-files --workspace ./workspace
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The default transport is stdio. Configure your MCP client using the [repository example](https://github.com/jayanth-mkv/agent-mcp-registry#connect-an-mcp-client).
|
|
35
|
+
|
|
36
|
+
For Streamable HTTP:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv run --locked --no-dev mcp-files --transport http --host 127.0.0.1 --port 8000 --workspace ./workspace
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Endpoint: `http://127.0.0.1:8000/mcp`. Legacy SSE is available with `--transport sse` at `/sse`.
|
|
43
|
+
|
|
44
|
+
## Tools
|
|
45
|
+
|
|
46
|
+
| Tool | Arguments | Behavior |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `create_file` | `path`, `content=""` | Create a new file; fail if it exists. |
|
|
49
|
+
| `read_file` | `path` | Read UTF-8 text. |
|
|
50
|
+
| `write_file` | `path`, `content` | Create or overwrite a text file. |
|
|
51
|
+
| `append_file` | `path`, `content` | Append text; create the file if missing. |
|
|
52
|
+
| `delete_path` | `path` | Delete a file or recursively delete a directory. |
|
|
53
|
+
| `rename_path` | `src`, `dst` | Rename a file or directory. |
|
|
54
|
+
| `copy_path` | `src`, `dst` | Copy a file or directory; merge existing directories. |
|
|
55
|
+
| `move_path` | `src`, `dst` | Move a file or directory. |
|
|
56
|
+
| `list_files` | None | List workspace files recursively. |
|
|
57
|
+
| `list_dirs` | None | List workspace directories recursively. |
|
|
58
|
+
| `search_file` | `path`, `term` | Return matching lines with line numbers. |
|
|
59
|
+
| `file_info` | `path` | Return JSON metadata. |
|
|
60
|
+
| `workspace_stats` | None | Return JSON counts, sizes, and extensions. |
|
|
61
|
+
| `path_exists` | `path` | Return `file`, `dir`, or `none`. |
|
|
62
|
+
|
|
63
|
+
Paths resolve inside the configured workspace. Parent directories are created when writing files. Relative workspace paths resolve from the process working directory; prefer an absolute workspace path in client configuration.
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
| Setting | Environment variable | Default |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| Workspace | `MCP_WORKSPACE_DIR` | `./workspace` |
|
|
70
|
+
| HTTP/SSE host | `MCP_SERVER_HOST` | `127.0.0.1` |
|
|
71
|
+
| HTTP/SSE port | `MCP_SERVER_PORT` | `8000` |
|
|
72
|
+
|
|
73
|
+
CLI flags override environment settings. `--workspace` is applied before the server starts. Startup diagnostics use stderr so stdout remains available for MCP messages.
|
|
74
|
+
|
|
75
|
+
## Boundaries
|
|
76
|
+
|
|
77
|
+
Use a dedicated workspace: callers have read and write access, including recursive deletion. The workspace root itself cannot be deleted, renamed, or moved. Symbolic links and Windows junctions are unsupported and rejected, including links inside copied directory trees. These checks do not provide isolation against concurrent filesystem changes by other processes.
|
|
78
|
+
|
|
79
|
+
The server has no authentication or size quotas. Keep HTTP/SSE on loopback. Text tools read complete files into memory and are intended for small local text files.
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
uv sync --locked
|
|
85
|
+
uv run --locked pytest -q
|
|
86
|
+
uv build
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The CLI is now `mcp-files`; the former nested `mcp-server` workspace has been consolidated into this package. AI clients are in [experimental/](https://github.com/jayanth-mkv/agent-mcp-registry/tree/main/experimental).
|
|
90
|
+
|
|
91
|
+
Version 0.2.0. Licensed under the [MIT License](https://github.com/jayanth-mkv/agent-mcp-registry/blob/main/mcp-files/LICENSE).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-files-context"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "MCP tools for text files inside a configured local workspace"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = ["fastmcp>=4.1.4,<5"]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://github.com/jayanth-mkv/agent-mcp-registry"
|
|
13
|
+
Documentation = "https://github.com/jayanth-mkv/agent-mcp-registry/tree/main/mcp-files"
|
|
14
|
+
Repository = "https://github.com/jayanth-mkv/agent-mcp-registry"
|
|
15
|
+
Issues = "https://github.com/jayanth-mkv/agent-mcp-registry/issues"
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
mcp-files = "mcp_server.cli:main"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = ["pytest>=9.2.0,<10"]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.13.6,<0.14"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[tool.uv.build-backend]
|
|
28
|
+
module-name = "mcp_server"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcp-files-context"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "MCP tools for text files inside a configured local workspace"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
dependencies = ["fastmcp>=4.1.4,<5"]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://github.com/jayanth-mkv/agent-mcp-registry"
|
|
13
|
+
Documentation = "https://github.com/jayanth-mkv/agent-mcp-registry/tree/main/mcp-files"
|
|
14
|
+
Repository = "https://github.com/jayanth-mkv/agent-mcp-registry"
|
|
15
|
+
Issues = "https://github.com/jayanth-mkv/agent-mcp-registry/issues"
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
mcp-files = "mcp_server.cli:main"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = ["pytest>=9.2.0,<10"]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.13.6,<0.14"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[tool.uv.build-backend]
|
|
28
|
+
module-name = "mcp_server"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Lean MCP File Server tools.
|
|
2
|
+
|
|
3
|
+
Simplified + organized. Redundant / niche tools removed.
|
|
4
|
+
Core focus: create, read, write, list, search, move, copy, delete, stats.
|
|
5
|
+
Paths are validated & sandboxed inside the configured workspace.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from .mcp import mcp
|
|
14
|
+
from .config import configure_workspace, DEFAULT_HOST, DEFAULT_PORT
|
|
15
|
+
from .tools import (
|
|
16
|
+
create_file,
|
|
17
|
+
read_file,
|
|
18
|
+
write_file,
|
|
19
|
+
append_file,
|
|
20
|
+
delete_path,
|
|
21
|
+
rename_path,
|
|
22
|
+
copy_path,
|
|
23
|
+
move_path,
|
|
24
|
+
list_files,
|
|
25
|
+
list_dirs,
|
|
26
|
+
search_file,
|
|
27
|
+
file_info,
|
|
28
|
+
workspace_stats,
|
|
29
|
+
path_exists,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"mcp",
|
|
36
|
+
"run",
|
|
37
|
+
# file ops
|
|
38
|
+
"create_file",
|
|
39
|
+
"read_file",
|
|
40
|
+
"write_file",
|
|
41
|
+
"append_file",
|
|
42
|
+
"delete_path",
|
|
43
|
+
"rename_path",
|
|
44
|
+
"copy_path",
|
|
45
|
+
"move_path",
|
|
46
|
+
"list_files",
|
|
47
|
+
"list_dirs",
|
|
48
|
+
"search_file",
|
|
49
|
+
"file_info",
|
|
50
|
+
"workspace_stats",
|
|
51
|
+
"path_exists",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ----------------------------- server run ------------------------------- #
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(
|
|
59
|
+
host: str | None = None,
|
|
60
|
+
port: int | None = None,
|
|
61
|
+
transport: str = "stdio",
|
|
62
|
+
workspace: str | None = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Run the MCP server."""
|
|
65
|
+
_host = host or os.getenv("MCP_SERVER_HOST", DEFAULT_HOST)
|
|
66
|
+
_port = int(port or os.getenv("MCP_SERVER_PORT", DEFAULT_PORT))
|
|
67
|
+
root = configure_workspace(workspace)
|
|
68
|
+
print(f"Workspace: {root}", file=sys.stderr)
|
|
69
|
+
if transport in {"sse", "http"}:
|
|
70
|
+
mcp.run(transport=transport, host=_host, port=_port)
|
|
71
|
+
else:
|
|
72
|
+
mcp.run(transport=transport)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Command line interface for the mcp-server package.
|
|
2
|
+
|
|
3
|
+
Usage examples:
|
|
4
|
+
uv run mcp-files --transport http --port 9000
|
|
5
|
+
python -m mcp_server.cli --transport sse
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from . import run as run_programmatic
|
|
14
|
+
from .config import DEFAULT_HOST, DEFAULT_PORT
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(description="Run the MCP file server.")
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--host",
|
|
21
|
+
default=None,
|
|
22
|
+
help=f"Host interface to bind (default: env MCP_SERVER_HOST or {DEFAULT_HOST})",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--port",
|
|
26
|
+
type=int,
|
|
27
|
+
default=None,
|
|
28
|
+
help=f"Port to listen on (default: env MCP_SERVER_PORT or {DEFAULT_PORT})",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--transport",
|
|
32
|
+
default="stdio",
|
|
33
|
+
choices=["sse", "http", "stdio"],
|
|
34
|
+
help="MCP transport (default: stdio; sse is a legacy option).",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--workspace",
|
|
38
|
+
default=None,
|
|
39
|
+
help="Workspace directory (default: MCP_WORKSPACE_DIR or ./workspace).",
|
|
40
|
+
)
|
|
41
|
+
return parser
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv: Optional[list[str]] = None) -> None:
|
|
45
|
+
parser = build_parser()
|
|
46
|
+
args = parser.parse_args(argv)
|
|
47
|
+
|
|
48
|
+
run_programmatic(host=args.host, port=args.port, transport=args.transport, workspace=args.workspace)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__": # pragma: no cover
|
|
52
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Runtime configuration without filesystem changes during import."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
7
|
+
DEFAULT_PORT = 8000
|
|
8
|
+
_workspace: Path | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def configure_workspace(path: str | None = None) -> Path:
|
|
12
|
+
global _workspace
|
|
13
|
+
root = Path(path or os.getenv("MCP_WORKSPACE_DIR", "workspace")).resolve()
|
|
14
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
if not root.is_dir():
|
|
16
|
+
raise ValueError("Workspace must be a directory")
|
|
17
|
+
_workspace = root
|
|
18
|
+
return root
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_workspace() -> Path:
|
|
22
|
+
return _workspace if _workspace is not None else configure_workspace()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from fastmcp.exceptions import ToolError
|
|
4
|
+
from .config import get_workspace
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# ----------------------------- helpers ---------------------------------- #
|
|
9
|
+
|
|
10
|
+
def _resolve(rel: str, *, protect_root: bool = False) -> Path:
|
|
11
|
+
"""Resolve a relative path safely within the workspace sandbox."""
|
|
12
|
+
base = get_workspace()
|
|
13
|
+
candidate = base / rel
|
|
14
|
+
p = candidate.resolve()
|
|
15
|
+
if base not in p.parents and p != base:
|
|
16
|
+
raise ValueError("Path escapes workspace")
|
|
17
|
+
for component in (candidate, *candidate.parents):
|
|
18
|
+
if component == base:
|
|
19
|
+
break
|
|
20
|
+
if component.is_symlink() or component.is_junction():
|
|
21
|
+
raise ValueError("Symbolic links and junctions are not supported")
|
|
22
|
+
if protect_root and p == base:
|
|
23
|
+
raise ValueError("Cannot replace, move, or delete the workspace root")
|
|
24
|
+
return p
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _iter_files() -> Iterable[Path]:
|
|
28
|
+
for p in _iter_paths():
|
|
29
|
+
if p.is_file():
|
|
30
|
+
yield p
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _iter_paths(root: Path | None = None) -> Iterable[Path]:
|
|
34
|
+
"""Walk without following links; reject linked entries before using them."""
|
|
35
|
+
start = root if root is not None else get_workspace()
|
|
36
|
+
for current, directories, files in os.walk(start, followlinks=False):
|
|
37
|
+
for name in directories + files:
|
|
38
|
+
p = Path(current) / name
|
|
39
|
+
if p.is_symlink() or p.is_junction():
|
|
40
|
+
raise ValueError("Symbolic links and junctions are not supported")
|
|
41
|
+
yield p
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check_tree(root: Path) -> None:
|
|
45
|
+
if root.is_dir():
|
|
46
|
+
for _ in _iter_paths(root):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ok(msg: str) -> str: # tiny convention wrapper
|
|
51
|
+
return msg
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _err(e: Exception) -> str: # pragma: no cover (simple path)
|
|
55
|
+
raise ToolError(str(e)) from e
|
|
56
|
+
|
|
57
|
+
__all__ = ["_resolve", "_iter_files", "_iter_paths", "_check_tree", "_ok", "_err"]
|
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import time
|
|
4
|
+
from .helpers import _resolve, _iter_files, _iter_paths, _check_tree, _ok, _err
|
|
5
|
+
from .mcp import mcp
|
|
6
|
+
from .config import get_workspace
|
|
7
|
+
|
|
8
|
+
# ----------------------------- file + dir ops --------------------------- #
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@mcp.tool
|
|
12
|
+
def create_file(path: str, content: str = "") -> str:
|
|
13
|
+
"""Create a new text file without overwriting an existing file."""
|
|
14
|
+
try:
|
|
15
|
+
p = _resolve(path)
|
|
16
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
with p.open("x", encoding="utf-8") as f:
|
|
18
|
+
f.write(content)
|
|
19
|
+
return _ok(f"created: {path}")
|
|
20
|
+
except Exception as e: # pragma: no cover
|
|
21
|
+
return _err(e)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool
|
|
25
|
+
def read_file(path: str) -> str:
|
|
26
|
+
"""Read a text file."""
|
|
27
|
+
try:
|
|
28
|
+
return _resolve(path).read_text(encoding="utf-8")
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
return f"Not found: {path}"
|
|
31
|
+
except Exception as e: # pragma: no cover
|
|
32
|
+
return _err(e)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@mcp.tool
|
|
36
|
+
def write_file(path: str, content: str) -> str:
|
|
37
|
+
"""Overwrite (or create) a file."""
|
|
38
|
+
try:
|
|
39
|
+
p = _resolve(path)
|
|
40
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
p.write_text(content, encoding="utf-8")
|
|
42
|
+
return _ok(f"written: {path}")
|
|
43
|
+
except Exception as e: # pragma: no cover
|
|
44
|
+
return _err(e)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@mcp.tool
|
|
48
|
+
def append_file(path: str, content: str) -> str:
|
|
49
|
+
"""Append to a file (creates if missing)."""
|
|
50
|
+
try:
|
|
51
|
+
p = _resolve(path)
|
|
52
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
with p.open("a", encoding="utf-8") as f:
|
|
54
|
+
f.write(content)
|
|
55
|
+
return _ok(f"appended: {path}")
|
|
56
|
+
except Exception as e: # pragma: no cover
|
|
57
|
+
return _err(e)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@mcp.tool
|
|
61
|
+
def delete_path(path: str) -> str:
|
|
62
|
+
"""Delete file or directory (recursive)."""
|
|
63
|
+
try:
|
|
64
|
+
p = _resolve(path, protect_root=True)
|
|
65
|
+
_check_tree(p)
|
|
66
|
+
if p.is_dir():
|
|
67
|
+
shutil.rmtree(p)
|
|
68
|
+
elif p.exists():
|
|
69
|
+
p.unlink()
|
|
70
|
+
else:
|
|
71
|
+
return f"Not found: {path}"
|
|
72
|
+
return _ok(f"deleted: {path}")
|
|
73
|
+
except Exception as e: # pragma: no cover
|
|
74
|
+
return _err(e)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@mcp.tool
|
|
78
|
+
def rename_path(src: str, dst: str) -> str:
|
|
79
|
+
"""Rename a file or directory inside the workspace."""
|
|
80
|
+
try:
|
|
81
|
+
s = _resolve(src, protect_root=True)
|
|
82
|
+
d = _resolve(dst, protect_root=True)
|
|
83
|
+
_check_tree(s)
|
|
84
|
+
_check_tree(d)
|
|
85
|
+
d.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
s.rename(d)
|
|
87
|
+
return _ok(f"renamed: {src} -> {dst}")
|
|
88
|
+
except FileNotFoundError:
|
|
89
|
+
return f"Not found: {src}"
|
|
90
|
+
except Exception as e: # pragma: no cover
|
|
91
|
+
return _err(e)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@mcp.tool
|
|
95
|
+
def copy_path(src: str, dst: str) -> str:
|
|
96
|
+
"""Copy file or directory."""
|
|
97
|
+
try:
|
|
98
|
+
s = _resolve(src)
|
|
99
|
+
d = _resolve(dst, protect_root=True)
|
|
100
|
+
if not s.exists():
|
|
101
|
+
return f"Not found: {src}"
|
|
102
|
+
if s == d or s in d.parents:
|
|
103
|
+
raise ValueError("Cannot copy a path into itself")
|
|
104
|
+
_check_tree(s)
|
|
105
|
+
_check_tree(d)
|
|
106
|
+
if s.is_dir():
|
|
107
|
+
shutil.copytree(s, d, dirs_exist_ok=True)
|
|
108
|
+
else:
|
|
109
|
+
d.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
shutil.copy2(s, d)
|
|
111
|
+
return _ok(f"copied: {src} -> {dst}")
|
|
112
|
+
except Exception as e: # pragma: no cover
|
|
113
|
+
return _err(e)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@mcp.tool
|
|
117
|
+
def move_path(src: str, dst: str) -> str:
|
|
118
|
+
"""Move file or directory."""
|
|
119
|
+
try:
|
|
120
|
+
s = _resolve(src, protect_root=True)
|
|
121
|
+
d = _resolve(dst, protect_root=True)
|
|
122
|
+
if not s.exists():
|
|
123
|
+
return f"Not found: {src}"
|
|
124
|
+
if s == d or s in d.parents:
|
|
125
|
+
raise ValueError("Cannot move a path into itself")
|
|
126
|
+
_check_tree(s)
|
|
127
|
+
_check_tree(d)
|
|
128
|
+
d.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
shutil.move(str(s), str(d))
|
|
130
|
+
return _ok(f"moved: {src} -> {dst}")
|
|
131
|
+
except Exception as e: # pragma: no cover
|
|
132
|
+
return _err(e)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@mcp.tool
|
|
136
|
+
def list_files() -> str:
|
|
137
|
+
"""List all files (relative)."""
|
|
138
|
+
try:
|
|
139
|
+
files = sorted(str(p.relative_to(get_workspace())) for p in _iter_files())
|
|
140
|
+
return "\n".join(files) if files else "(none)"
|
|
141
|
+
except Exception as e: # pragma: no cover
|
|
142
|
+
return _err(e)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@mcp.tool
|
|
146
|
+
def list_dirs() -> str:
|
|
147
|
+
"""List all directories (relative)."""
|
|
148
|
+
try:
|
|
149
|
+
dirs = sorted(str(p.relative_to(get_workspace())) for p in _iter_paths() if p.is_dir())
|
|
150
|
+
return "\n".join(dirs) if dirs else "(none)"
|
|
151
|
+
except Exception as e: # pragma: no cover
|
|
152
|
+
return _err(e)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@mcp.tool
|
|
156
|
+
def search_file(path: str, term: str) -> str:
|
|
157
|
+
"""Return matching lines containing term."""
|
|
158
|
+
try:
|
|
159
|
+
p = _resolve(path)
|
|
160
|
+
lines = []
|
|
161
|
+
for i, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
|
|
162
|
+
if term in line:
|
|
163
|
+
lines.append(f"{i}: {line.strip()}")
|
|
164
|
+
return "\n".join(lines) if lines else "(no matches)"
|
|
165
|
+
except FileNotFoundError:
|
|
166
|
+
return f"Not found: {path}"
|
|
167
|
+
except Exception as e: # pragma: no cover
|
|
168
|
+
return _err(e)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@mcp.tool
|
|
172
|
+
def file_info(path: str) -> str:
|
|
173
|
+
"""Basic metadata (JSON)."""
|
|
174
|
+
try:
|
|
175
|
+
p = _resolve(path)
|
|
176
|
+
if not p.exists():
|
|
177
|
+
return f"Not found: {path}"
|
|
178
|
+
st = p.stat()
|
|
179
|
+
info = {
|
|
180
|
+
"path": path,
|
|
181
|
+
"type": "dir" if p.is_dir() else "file",
|
|
182
|
+
"size": st.st_size,
|
|
183
|
+
"modified": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(st.st_mtime)),
|
|
184
|
+
"created": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(st.st_ctime)),
|
|
185
|
+
"absolute": str(p),
|
|
186
|
+
}
|
|
187
|
+
if p.is_file():
|
|
188
|
+
try:
|
|
189
|
+
info["lines"] = sum(1 for _ in p.open("r", encoding="utf-8"))
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
return json.dumps(info, indent=2)
|
|
193
|
+
except Exception as e: # pragma: no cover
|
|
194
|
+
return _err(e)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@mcp.tool
|
|
198
|
+
def workspace_stats() -> str:
|
|
199
|
+
"""Aggregate workspace stats (JSON)."""
|
|
200
|
+
try:
|
|
201
|
+
files = 0
|
|
202
|
+
dirs = 0
|
|
203
|
+
size = 0
|
|
204
|
+
exts: dict[str, int] = {}
|
|
205
|
+
for p in _iter_paths():
|
|
206
|
+
if p.is_dir():
|
|
207
|
+
dirs += 1
|
|
208
|
+
elif p.is_file():
|
|
209
|
+
files += 1
|
|
210
|
+
try:
|
|
211
|
+
size += p.stat().st_size
|
|
212
|
+
except OSError:
|
|
213
|
+
pass
|
|
214
|
+
ext = p.suffix.lower() or "<none>"
|
|
215
|
+
exts[ext] = exts.get(ext, 0) + 1
|
|
216
|
+
data = {
|
|
217
|
+
"workspace": str(get_workspace()),
|
|
218
|
+
"files": files,
|
|
219
|
+
"directories": dirs,
|
|
220
|
+
"bytes": size,
|
|
221
|
+
"extensions": dict(sorted(exts.items(), key=lambda x: (-x[1], x[0]))),
|
|
222
|
+
}
|
|
223
|
+
return json.dumps(data, indent=2)
|
|
224
|
+
except Exception as e: # pragma: no cover
|
|
225
|
+
return _err(e)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@mcp.tool
|
|
229
|
+
def path_exists(path: str) -> str:
|
|
230
|
+
"""Return one of: file | dir | none."""
|
|
231
|
+
try:
|
|
232
|
+
p = _resolve(path)
|
|
233
|
+
if p.is_file():
|
|
234
|
+
return "file"
|
|
235
|
+
if p.is_dir():
|
|
236
|
+
return "dir"
|
|
237
|
+
return "none"
|
|
238
|
+
except Exception: # pragma: no cover
|
|
239
|
+
return "none"
|
|
240
|
+
|