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.
@@ -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=(",", ":"))