filekeeper-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.
- filekeeper_mcp-0.1.0/LICENSE +21 -0
- filekeeper_mcp-0.1.0/PKG-INFO +176 -0
- filekeeper_mcp-0.1.0/README.md +153 -0
- filekeeper_mcp-0.1.0/pyproject.toml +49 -0
- filekeeper_mcp-0.1.0/pyproject.toml.orig +43 -0
- filekeeper_mcp-0.1.0/src/filekeeper_mcp/__init__.py +13 -0
- filekeeper_mcp-0.1.0/src/filekeeper_mcp/errors.py +115 -0
- filekeeper_mcp-0.1.0/src/filekeeper_mcp/payloads.py +55 -0
- filekeeper_mcp-0.1.0/src/filekeeper_mcp/server.py +143 -0
- filekeeper_mcp-0.1.0/src/filekeeper_mcp/tools.py +865 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sangingeum
|
|
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,176 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: filekeeper-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server that exposes Filekeeper's filesystem operations to agent hosts.
|
|
5
|
+
Keywords: filesystem,mcp,agents,atomic-write
|
|
6
|
+
Author: sangingeum
|
|
7
|
+
Author-email: sangingeum <sangingeum@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Topic :: System :: Filesystems
|
|
17
|
+
Requires-Dist: filekeeper-cli>=0.1,<0.2
|
|
18
|
+
Requires-Dist: mcp>=2.3,<3
|
|
19
|
+
Requires-Python: >=3.12, <3.13
|
|
20
|
+
Project-URL: Homepage, https://github.com/sangingeum/filekeeper
|
|
21
|
+
Project-URL: Changelog, https://github.com/sangingeum/filekeeper/blob/main/CHANGELOG.md
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# filekeeper-mcp
|
|
25
|
+
|
|
26
|
+
The Model Context Protocol server for [Filekeeper](../..). It is a thin adapter:
|
|
27
|
+
every tool delegates to the `filekeeper` Core library, so an operation behaves
|
|
28
|
+
identically whether it arrives through the CLI or through MCP.
|
|
29
|
+
|
|
30
|
+
This is a **separate distribution**. `pip install filekeeper-cli` installs only
|
|
31
|
+
Core and the `keeper` CLI and never pulls the MCP SDK. `pip install
|
|
32
|
+
filekeeper-mcp` installs both.
|
|
33
|
+
|
|
34
|
+
## Install and run
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv tool install filekeeper-mcp # or: pip install filekeeper-mcp
|
|
38
|
+
filekeeper-mcp --root ~/project
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The transport is STDIO. There is no network listener in this release, and no
|
|
42
|
+
option that binds a socket.
|
|
43
|
+
|
|
44
|
+
Start-up options:
|
|
45
|
+
|
|
46
|
+
| Option | Meaning |
|
|
47
|
+
| ------ | ------- |
|
|
48
|
+
| `--workspace NAME` | serve a registered workspace by name or id |
|
|
49
|
+
| `--root PATH` | serve a directory as a transient workspace (nothing registered) |
|
|
50
|
+
| `--home PATH` | Filekeeper home directory; defaults to `FILEKEEPER_HOME` or the user data dir |
|
|
51
|
+
| `--actor NAME` | identity recorded on every mutation (optional audit metadata) |
|
|
52
|
+
| `--task-id ID` | optional external task identifier recorded on every mutation |
|
|
53
|
+
| `--log-level LEVEL` | `debug`, `info`, `warning` (default), `error`, `critical`; stderr only |
|
|
54
|
+
|
|
55
|
+
`--workspace` and `--root` are mutually exclusive. Selection precedence is the
|
|
56
|
+
same as the CLI: explicit name, explicit root, then the configured default, then
|
|
57
|
+
the single registered workspace. The server never guesses between several.
|
|
58
|
+
|
|
59
|
+
Allowed roots come from start-up configuration only. No tool argument can widen
|
|
60
|
+
the workspace, register a workspace, or point the server at another tree.
|
|
61
|
+
|
|
62
|
+
Example host configuration:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"filekeeper": {
|
|
68
|
+
"command": "filekeeper-mcp",
|
|
69
|
+
"args": ["--root", "/home/me/project", "--actor", "editor"]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Tools
|
|
76
|
+
|
|
77
|
+
Read-only:
|
|
78
|
+
|
|
79
|
+
`workspace_list`, `workspace_info`, `file_read`, `file_read_many`,
|
|
80
|
+
`directory_list`, `file_info`, `file_search`, `content_search`, `history_list`,
|
|
81
|
+
`history_get`, `history_diff`.
|
|
82
|
+
|
|
83
|
+
Mutating:
|
|
84
|
+
|
|
85
|
+
`file_create`, `file_write`, `file_copy`, `file_move`, `file_delete`,
|
|
86
|
+
`directory_create`, `directory_delete`, `file_replace_text`,
|
|
87
|
+
`file_replace_lines`, `file_insert_lines`, `file_delete_lines`,
|
|
88
|
+
`file_apply_patch`, `undo_operation`, `undo_last`.
|
|
89
|
+
|
|
90
|
+
Conventions that hold for every tool:
|
|
91
|
+
|
|
92
|
+
- Paths are workspace-relative. Absolute paths are accepted only when they
|
|
93
|
+
resolve inside the selected workspace.
|
|
94
|
+
- Line numbers are **1-based and inclusive**. Out-of-range references are
|
|
95
|
+
refused, never clamped. End-of-file insertion is `position: "end"`.
|
|
96
|
+
- Every mutating tool returns the Core `operation_id` and `undo_available`.
|
|
97
|
+
`operation_id` is `null` for a dry run.
|
|
98
|
+
- `expected_hash` (whole file, from `file_info` or `file_read`) and
|
|
99
|
+
`expected_content` (the exact text of the edited region) are the guards. Pass
|
|
100
|
+
at least one on content you did not just read.
|
|
101
|
+
- `dry_run` validates every guard and writes nothing. It is available on every
|
|
102
|
+
mutating tool.
|
|
103
|
+
- `file_replace_text` requires an explicit `match` policy, `"first"` or `"all"`,
|
|
104
|
+
and reports the matching stage (`exact` or `normalised`) in its result.
|
|
105
|
+
- `i_accept_secret_write_risk` is the only way past the credential-shaped write
|
|
106
|
+
refusal. The override records a warning in the result.
|
|
107
|
+
- Destructive tools (`file_delete`, `directory_delete`, `file_apply_patch` and
|
|
108
|
+
the no-undo-free content replacements) state their scope in their description
|
|
109
|
+
and never operate outside the selected root. Recursive directory deletion
|
|
110
|
+
requires both `recursive` and `confirm_recursive`.
|
|
111
|
+
|
|
112
|
+
Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`,
|
|
113
|
+
`idempotentHint`, `openWorldHint`) that describe what it actually does. A whole
|
|
114
|
+
file write is not read-only and not idempotent; a directory create is additive;
|
|
115
|
+
readers are read-only and idempotent.
|
|
116
|
+
|
|
117
|
+
## Limits
|
|
118
|
+
|
|
119
|
+
The adapter refuses oversized requests instead of truncating them silently:
|
|
120
|
+
|
|
121
|
+
| Bound | Value |
|
|
122
|
+
| ----- | ----- |
|
|
123
|
+
| paths in `file_read_many` | 32 |
|
|
124
|
+
| patch size | 1 MiB |
|
|
125
|
+
| requested search results | 200 |
|
|
126
|
+
| requested directory entries | 1000 |
|
|
127
|
+
| requested history entries | 200 |
|
|
128
|
+
|
|
129
|
+
Core's own configurable limits (`max_read_bytes`, `max_write_bytes`,
|
|
130
|
+
`max_search_results`, `max_directory_entries`, `max_recursion_depth`) still
|
|
131
|
+
apply and are described in the main README. A bounded result reports
|
|
132
|
+
`truncated` rather than pretending to be complete.
|
|
133
|
+
|
|
134
|
+
## Errors
|
|
135
|
+
|
|
136
|
+
Failures are structured data, never a stack trace:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"ok": false,
|
|
141
|
+
"error": {
|
|
142
|
+
"code": "CONTENT_CONFLICT",
|
|
143
|
+
"message": "file content changed since it was read; refusing to overwrite",
|
|
144
|
+
"retryable": false,
|
|
145
|
+
"details": {
|
|
146
|
+
"path": "notes.txt",
|
|
147
|
+
"expected_hash": "sha256:...",
|
|
148
|
+
"actual_hash": "sha256:...",
|
|
149
|
+
"recovery": "re-read the target, then retry against its current hash"
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The envelope is the same one the CLI emits: a failure document has exactly `ok`
|
|
156
|
+
and `error` at the top level, and `error` has exactly `code`, `message`,
|
|
157
|
+
`retryable` and `details`. The caller's own path is in `error.details.path`;
|
|
158
|
+
server-side host paths are not preserved. `retryable` describes the identical
|
|
159
|
+
request, so a stale hash is `false`: the caller must re-read the target and write
|
|
160
|
+
against its current hash.
|
|
161
|
+
|
|
162
|
+
## Not in this release
|
|
163
|
+
|
|
164
|
+
Advisory locks, user-facing snapshots and restore, batch execution, file
|
|
165
|
+
watching, HTTP transport, redo, and binary/media reads are deferred. They are not
|
|
166
|
+
half-implemented and not advertised as tools.
|
|
167
|
+
|
|
168
|
+
## Dependency pin
|
|
169
|
+
|
|
170
|
+
The server is built and tested against `mcp>=2.3,<3`. The SDK's server API
|
|
171
|
+
changed between 1.x and 2.x, so the major version is pinned; see
|
|
172
|
+
`docs/decisions.md` in the repository for the reasoning.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# filekeeper-mcp
|
|
2
|
+
|
|
3
|
+
The Model Context Protocol server for [Filekeeper](../..). It is a thin adapter:
|
|
4
|
+
every tool delegates to the `filekeeper` Core library, so an operation behaves
|
|
5
|
+
identically whether it arrives through the CLI or through MCP.
|
|
6
|
+
|
|
7
|
+
This is a **separate distribution**. `pip install filekeeper-cli` installs only
|
|
8
|
+
Core and the `keeper` CLI and never pulls the MCP SDK. `pip install
|
|
9
|
+
filekeeper-mcp` installs both.
|
|
10
|
+
|
|
11
|
+
## Install and run
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv tool install filekeeper-mcp # or: pip install filekeeper-mcp
|
|
15
|
+
filekeeper-mcp --root ~/project
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The transport is STDIO. There is no network listener in this release, and no
|
|
19
|
+
option that binds a socket.
|
|
20
|
+
|
|
21
|
+
Start-up options:
|
|
22
|
+
|
|
23
|
+
| Option | Meaning |
|
|
24
|
+
| ------ | ------- |
|
|
25
|
+
| `--workspace NAME` | serve a registered workspace by name or id |
|
|
26
|
+
| `--root PATH` | serve a directory as a transient workspace (nothing registered) |
|
|
27
|
+
| `--home PATH` | Filekeeper home directory; defaults to `FILEKEEPER_HOME` or the user data dir |
|
|
28
|
+
| `--actor NAME` | identity recorded on every mutation (optional audit metadata) |
|
|
29
|
+
| `--task-id ID` | optional external task identifier recorded on every mutation |
|
|
30
|
+
| `--log-level LEVEL` | `debug`, `info`, `warning` (default), `error`, `critical`; stderr only |
|
|
31
|
+
|
|
32
|
+
`--workspace` and `--root` are mutually exclusive. Selection precedence is the
|
|
33
|
+
same as the CLI: explicit name, explicit root, then the configured default, then
|
|
34
|
+
the single registered workspace. The server never guesses between several.
|
|
35
|
+
|
|
36
|
+
Allowed roots come from start-up configuration only. No tool argument can widen
|
|
37
|
+
the workspace, register a workspace, or point the server at another tree.
|
|
38
|
+
|
|
39
|
+
Example host configuration:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mcpServers": {
|
|
44
|
+
"filekeeper": {
|
|
45
|
+
"command": "filekeeper-mcp",
|
|
46
|
+
"args": ["--root", "/home/me/project", "--actor", "editor"]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Tools
|
|
53
|
+
|
|
54
|
+
Read-only:
|
|
55
|
+
|
|
56
|
+
`workspace_list`, `workspace_info`, `file_read`, `file_read_many`,
|
|
57
|
+
`directory_list`, `file_info`, `file_search`, `content_search`, `history_list`,
|
|
58
|
+
`history_get`, `history_diff`.
|
|
59
|
+
|
|
60
|
+
Mutating:
|
|
61
|
+
|
|
62
|
+
`file_create`, `file_write`, `file_copy`, `file_move`, `file_delete`,
|
|
63
|
+
`directory_create`, `directory_delete`, `file_replace_text`,
|
|
64
|
+
`file_replace_lines`, `file_insert_lines`, `file_delete_lines`,
|
|
65
|
+
`file_apply_patch`, `undo_operation`, `undo_last`.
|
|
66
|
+
|
|
67
|
+
Conventions that hold for every tool:
|
|
68
|
+
|
|
69
|
+
- Paths are workspace-relative. Absolute paths are accepted only when they
|
|
70
|
+
resolve inside the selected workspace.
|
|
71
|
+
- Line numbers are **1-based and inclusive**. Out-of-range references are
|
|
72
|
+
refused, never clamped. End-of-file insertion is `position: "end"`.
|
|
73
|
+
- Every mutating tool returns the Core `operation_id` and `undo_available`.
|
|
74
|
+
`operation_id` is `null` for a dry run.
|
|
75
|
+
- `expected_hash` (whole file, from `file_info` or `file_read`) and
|
|
76
|
+
`expected_content` (the exact text of the edited region) are the guards. Pass
|
|
77
|
+
at least one on content you did not just read.
|
|
78
|
+
- `dry_run` validates every guard and writes nothing. It is available on every
|
|
79
|
+
mutating tool.
|
|
80
|
+
- `file_replace_text` requires an explicit `match` policy, `"first"` or `"all"`,
|
|
81
|
+
and reports the matching stage (`exact` or `normalised`) in its result.
|
|
82
|
+
- `i_accept_secret_write_risk` is the only way past the credential-shaped write
|
|
83
|
+
refusal. The override records a warning in the result.
|
|
84
|
+
- Destructive tools (`file_delete`, `directory_delete`, `file_apply_patch` and
|
|
85
|
+
the no-undo-free content replacements) state their scope in their description
|
|
86
|
+
and never operate outside the selected root. Recursive directory deletion
|
|
87
|
+
requires both `recursive` and `confirm_recursive`.
|
|
88
|
+
|
|
89
|
+
Every tool carries MCP annotations (`readOnlyHint`, `destructiveHint`,
|
|
90
|
+
`idempotentHint`, `openWorldHint`) that describe what it actually does. A whole
|
|
91
|
+
file write is not read-only and not idempotent; a directory create is additive;
|
|
92
|
+
readers are read-only and idempotent.
|
|
93
|
+
|
|
94
|
+
## Limits
|
|
95
|
+
|
|
96
|
+
The adapter refuses oversized requests instead of truncating them silently:
|
|
97
|
+
|
|
98
|
+
| Bound | Value |
|
|
99
|
+
| ----- | ----- |
|
|
100
|
+
| paths in `file_read_many` | 32 |
|
|
101
|
+
| patch size | 1 MiB |
|
|
102
|
+
| requested search results | 200 |
|
|
103
|
+
| requested directory entries | 1000 |
|
|
104
|
+
| requested history entries | 200 |
|
|
105
|
+
|
|
106
|
+
Core's own configurable limits (`max_read_bytes`, `max_write_bytes`,
|
|
107
|
+
`max_search_results`, `max_directory_entries`, `max_recursion_depth`) still
|
|
108
|
+
apply and are described in the main README. A bounded result reports
|
|
109
|
+
`truncated` rather than pretending to be complete.
|
|
110
|
+
|
|
111
|
+
## Errors
|
|
112
|
+
|
|
113
|
+
Failures are structured data, never a stack trace:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"ok": false,
|
|
118
|
+
"error": {
|
|
119
|
+
"code": "CONTENT_CONFLICT",
|
|
120
|
+
"message": "file content changed since it was read; refusing to overwrite",
|
|
121
|
+
"retryable": false,
|
|
122
|
+
"details": {
|
|
123
|
+
"path": "notes.txt",
|
|
124
|
+
"expected_hash": "sha256:...",
|
|
125
|
+
"actual_hash": "sha256:...",
|
|
126
|
+
"recovery": "re-read the target, then retry against its current hash"
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The envelope is the same one the CLI emits: a failure document has exactly `ok`
|
|
133
|
+
and `error` at the top level, and `error` has exactly `code`, `message`,
|
|
134
|
+
`retryable` and `details`. The caller's own path is in `error.details.path`;
|
|
135
|
+
server-side host paths are not preserved. `retryable` describes the identical
|
|
136
|
+
request, so a stale hash is `false`: the caller must re-read the target and write
|
|
137
|
+
against its current hash.
|
|
138
|
+
|
|
139
|
+
## Not in this release
|
|
140
|
+
|
|
141
|
+
Advisory locks, user-facing snapshots and restore, batch execution, file
|
|
142
|
+
watching, HTTP transport, redo, and binary/media reads are deferred. They are not
|
|
143
|
+
half-implemented and not advertised as tools.
|
|
144
|
+
|
|
145
|
+
## Dependency pin
|
|
146
|
+
|
|
147
|
+
The server is built and tested against `mcp>=2.3,<3`. The SDK's server API
|
|
148
|
+
changed between 1.x and 2.x, so the major version is pinned; see
|
|
149
|
+
`docs/decisions.md` in the repository for the reasoning.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "filekeeper-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server that exposes Filekeeper's filesystem operations to agent hosts."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12,<3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"filesystem",
|
|
11
|
+
"mcp",
|
|
12
|
+
"agents",
|
|
13
|
+
"atomic-write",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
"Topic :: System :: Filesystems",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"filekeeper-cli>=0.1,<0.2",
|
|
26
|
+
"mcp>=2.3,<3",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[[project.authors]]
|
|
30
|
+
name = "sangingeum"
|
|
31
|
+
email = "sangingeum@gmail.com"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
filekeeper-mcp = "filekeeper_mcp.server:main"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/sangingeum/filekeeper"
|
|
38
|
+
Changelog = "https://github.com/sangingeum/filekeeper/blob/main/CHANGELOG.md"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["uv_build>=0.9.0,<0.13"]
|
|
42
|
+
build-backend = "uv_build"
|
|
43
|
+
|
|
44
|
+
[tool.uv.build-backend]
|
|
45
|
+
module-name = "filekeeper_mcp"
|
|
46
|
+
module-root = "src"
|
|
47
|
+
|
|
48
|
+
[tool.uv.sources.filekeeper-cli]
|
|
49
|
+
workspace = true
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "filekeeper-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server that exposes Filekeeper's filesystem operations to agent hosts."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12,<3.13"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "sangingeum", email = "sangingeum@gmail.com" }]
|
|
10
|
+
keywords = ["filesystem", "mcp", "agents", "atomic-write"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
18
|
+
"Topic :: System :: Filesystems",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"filekeeper-cli>=0.1,<0.2",
|
|
22
|
+
# Pinned to the major version this server was built and tested against. The
|
|
23
|
+
# SDK's server API changed between 1.x and 2.x; see docs/decisions.md.
|
|
24
|
+
"mcp>=2.3,<3",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
filekeeper-mcp = "filekeeper_mcp.server:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/sangingeum/filekeeper"
|
|
32
|
+
Changelog = "https://github.com/sangingeum/filekeeper/blob/main/CHANGELOG.md"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["uv_build>=0.9.0,<0.13"]
|
|
36
|
+
build-backend = "uv_build"
|
|
37
|
+
|
|
38
|
+
[tool.uv.build-backend]
|
|
39
|
+
module-name = "filekeeper_mcp"
|
|
40
|
+
module-root = "src"
|
|
41
|
+
|
|
42
|
+
[tool.uv.sources]
|
|
43
|
+
filekeeper-cli = { workspace = true }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""MCP protocol adapter over Filekeeper Core.
|
|
2
|
+
|
|
3
|
+
This package contains no filesystem logic. Every operation is delegated to
|
|
4
|
+
``filekeeper.core``; the adapter only maps protocol arguments to Core calls and
|
|
5
|
+
Core results to structured protocol results. Core remains importable without the
|
|
6
|
+
MCP SDK installed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Structured protocol errors.
|
|
2
|
+
|
|
3
|
+
Every failure reaches the client as data, never as a Python traceback. The Core
|
|
4
|
+
error code and the caller's own path survive; host filesystem paths do not. A
|
|
5
|
+
resolved host path is useful to nobody outside this machine and can leak the shape
|
|
6
|
+
of the tree the server is confined to.
|
|
7
|
+
|
|
8
|
+
The envelope is the same one the CLI emits: ``{"ok": false, "error": {...}}``
|
|
9
|
+
with no fields beside ``ok`` and ``error`` at the top level. The caller's path
|
|
10
|
+
travels inside ``error.details.path``, where Core already records it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import mcp.types as types
|
|
20
|
+
|
|
21
|
+
from filekeeper import ErrorCode, FilekeeperError
|
|
22
|
+
|
|
23
|
+
from .payloads import compact_json
|
|
24
|
+
|
|
25
|
+
# Path-like strings that begin a token. Relative paths such as ``src/main.py`` are
|
|
26
|
+
# left alone because the character before the slash is part of a word.
|
|
27
|
+
_ABSOLUTE_POSIX = re.compile(r"(?<![\w:/])/(?:[^\s\"',;:()\[\]]+)")
|
|
28
|
+
_ABSOLUTE_WINDOWS = re.compile(r"\b[A-Za-z]:\\[^\s\"',;:()\[\]]+")
|
|
29
|
+
|
|
30
|
+
# Detail keys that only ever carry a server-side location.
|
|
31
|
+
_LOCATION_KEYS = frozenset({"root", "state_dir", "home", "database", "path_absolute"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def scrub_text(text: str, host_paths: Iterable[str]) -> str:
|
|
35
|
+
"""Remove host locations from a message while keeping its meaning."""
|
|
36
|
+
|
|
37
|
+
cleaned = text
|
|
38
|
+
for location in sorted(host_paths, key=len, reverse=True):
|
|
39
|
+
if location:
|
|
40
|
+
cleaned = cleaned.replace(location, "<host-path>")
|
|
41
|
+
cleaned = _ABSOLUTE_WINDOWS.sub("<host-path>", cleaned)
|
|
42
|
+
return _ABSOLUTE_POSIX.sub("<host-path>", cleaned)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _scrub_value(value: Any, host_paths: Iterable[str]) -> Any:
|
|
46
|
+
if isinstance(value, str):
|
|
47
|
+
return scrub_text(value, host_paths)
|
|
48
|
+
if isinstance(value, dict):
|
|
49
|
+
return {str(key): _scrub_value(item, host_paths) for key, item in value.items()}
|
|
50
|
+
if isinstance(value, (list, tuple)):
|
|
51
|
+
return [_scrub_value(item, host_paths) for item in value]
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def error_payload(
|
|
56
|
+
error: FilekeeperError,
|
|
57
|
+
*,
|
|
58
|
+
host_paths: Iterable[str],
|
|
59
|
+
caller_path: str | None = None,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""The stable error envelope: ``ok`` plus ``error.code/message/retryable/details``.
|
|
62
|
+
|
|
63
|
+
Nothing is duplicated at the top level: a client reads every error the same
|
|
64
|
+
way it reads a CLI JSON error.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
locations = tuple(host_paths)
|
|
68
|
+
details = {
|
|
69
|
+
key: _scrub_value(value, locations)
|
|
70
|
+
for key, value in error.details.items()
|
|
71
|
+
if key not in _LOCATION_KEYS
|
|
72
|
+
}
|
|
73
|
+
error_body: dict[str, Any] = {
|
|
74
|
+
"code": str(error.code),
|
|
75
|
+
"message": scrub_text(error.message, locations),
|
|
76
|
+
"retryable": error.retryable,
|
|
77
|
+
}
|
|
78
|
+
if caller_path is not None:
|
|
79
|
+
details.setdefault("path", caller_path)
|
|
80
|
+
if details:
|
|
81
|
+
error_body["details"] = details
|
|
82
|
+
return {"ok": False, "error": error_body}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def error_result(
|
|
86
|
+
error: FilekeeperError,
|
|
87
|
+
*,
|
|
88
|
+
host_paths: Iterable[str],
|
|
89
|
+
caller_path: str | None = None,
|
|
90
|
+
) -> types.CallToolResult:
|
|
91
|
+
payload = error_payload(error, host_paths=host_paths, caller_path=caller_path)
|
|
92
|
+
return types.CallToolResult(
|
|
93
|
+
content=[types.TextContent(type="text", text=compact_json(payload))],
|
|
94
|
+
structured_content=payload,
|
|
95
|
+
is_error=True,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def internal_error_result() -> types.CallToolResult:
|
|
100
|
+
"""A crash rendered as data. The exception text stays in the server log."""
|
|
101
|
+
|
|
102
|
+
error = FilekeeperError(
|
|
103
|
+
"the operation failed unexpectedly; nothing about the request was changed "
|
|
104
|
+
"beyond what an earlier partial result reported"
|
|
105
|
+
)
|
|
106
|
+
return error_result(error, host_paths=())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
"ErrorCode",
|
|
111
|
+
"error_payload",
|
|
112
|
+
"error_result",
|
|
113
|
+
"internal_error_result",
|
|
114
|
+
"scrub_text",
|
|
115
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Conversion of Core values into JSON-ready protocol payloads.
|
|
2
|
+
|
|
3
|
+
Rendering only: nothing here reads, resolves or writes a filesystem path. The
|
|
4
|
+
shapes deliberately mirror the Core dataclasses field for field, so a client sees
|
|
5
|
+
exactly what the library returned.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import json
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def jsonable(value: Any) -> Any:
|
|
18
|
+
"""Convert dataclasses, enums, paths, tuples and mappings into JSON values."""
|
|
19
|
+
|
|
20
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
21
|
+
return {
|
|
22
|
+
field.name: jsonable(getattr(value, field.name))
|
|
23
|
+
for field in dataclasses.fields(value)
|
|
24
|
+
}
|
|
25
|
+
if isinstance(value, Enum):
|
|
26
|
+
return value.value
|
|
27
|
+
if isinstance(value, Path):
|
|
28
|
+
return str(value)
|
|
29
|
+
if isinstance(value, dict):
|
|
30
|
+
return {str(key): jsonable(item) for key, item in value.items()}
|
|
31
|
+
if isinstance(value, (list, tuple)):
|
|
32
|
+
return [jsonable(item) for item in value]
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def success_payload(result: Any) -> dict[str, Any]:
|
|
37
|
+
"""Wrap one Core result in the stable success envelope."""
|
|
38
|
+
|
|
39
|
+
payload: dict[str, Any] = {"ok": True}
|
|
40
|
+
converted = jsonable(result)
|
|
41
|
+
if isinstance(converted, dict):
|
|
42
|
+
payload.update(converted)
|
|
43
|
+
else: # pragma: no cover - every tool result is an object
|
|
44
|
+
payload["result"] = converted
|
|
45
|
+
return payload
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def compact_json(payload: dict[str, Any]) -> str:
|
|
49
|
+
"""The text content that accompanies structured content.
|
|
50
|
+
|
|
51
|
+
Kept compact on purpose: the structured payload is the contract, and this is
|
|
52
|
+
the fallback for hosts that only render text.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
return json.dumps(jsonable(payload), ensure_ascii=False, separators=(",", ":"))
|