data-filter-mcp 0.2.1__tar.gz → 0.2.4__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.
Files changed (36) hide show
  1. data_filter_mcp-0.2.4/PKG-INFO +151 -0
  2. data_filter_mcp-0.2.4/README.md +127 -0
  3. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/_version.py +3 -3
  4. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/models.py +24 -0
  5. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/server.py +174 -1
  6. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/validator.py +242 -4
  7. data_filter_mcp-0.2.4/data_filter_mcp.egg-info/PKG-INFO +151 -0
  8. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/tests/test_mcp_tools.py +34 -0
  9. data_filter_mcp-0.2.4/tests/test_server_flow.py +537 -0
  10. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/tests/test_validator.py +238 -0
  11. data_filter_mcp-0.2.1/PKG-INFO +0 -107
  12. data_filter_mcp-0.2.1/README.md +0 -83
  13. data_filter_mcp-0.2.1/data_filter_mcp.egg-info/PKG-INFO +0 -107
  14. data_filter_mcp-0.2.1/tests/test_server_flow.py +0 -200
  15. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/.github/workflows/ci.yml +0 -0
  16. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/.github/workflows/release.yml +0 -0
  17. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/.gitignore +0 -0
  18. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/LICENSE +0 -0
  19. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/__init__.py +0 -0
  20. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/__init__.py +0 -0
  21. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/base.py +0 -0
  22. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/factory.py +0 -0
  23. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/json_loader.py +0 -0
  24. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/txt_loader.py +0 -0
  25. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/yaml_loader.py +0 -0
  26. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/registry.py +0 -0
  27. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp.egg-info/SOURCES.txt +0 -0
  28. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp.egg-info/dependency_links.txt +0 -0
  29. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp.egg-info/entry_points.txt +0 -0
  30. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp.egg-info/requires.txt +0 -0
  31. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp.egg-info/top_level.txt +0 -0
  32. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/pyproject.toml +0 -0
  33. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/server.py +0 -0
  34. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/setup.cfg +0 -0
  35. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/tests/test_loaders.py +0 -0
  36. {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/tests/test_registry.py +0 -0
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: data-filter-mcp
3
+ Version: 0.2.4
4
+ Summary: Local MCP server for running restricted Python text filters over files
5
+ Author: Aleksei Arakelian
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/alxark/data-filter-mcp
8
+ Project-URL: Repository, https://github.com/alxark/data-filter-mcp
9
+ Project-URL: Issues, https://github.com/alxark/data-filter-mcp/issues
10
+ Keywords: mcp,model-context-protocol,filter,python,server
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: mcp<2.0,>=1.12
19
+ Requires-Dist: pydantic<3,>=2
20
+ Requires-Dist: PyYAML<7,>=6
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest<9,>=8; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # data-filter-mcp
26
+
27
+ Local MCP server that registers restricted Python filters and runs them against local `json`, `yaml`, and `txt` files.
28
+
29
+ ## What it does
30
+
31
+ - `register_filter` accepts Python source code with exactly one top-level function: `def filter_item(data):`
32
+ - `run_filter` loads a local file, passes the loaded document into `filter_item(data)`, and returns the text from `result_text`
33
+ - `convert_file` loads a local file, passes it into `filter_item(data)`, and writes the returned text to another local file
34
+ - Registered filters live only in memory and expire automatically based on server TTL settings
35
+
36
+ ### What filter code may use
37
+
38
+ Filter bodies are AST-validated against a whitelist. In addition to a curated set of builtins (`len`, `sorted`, `max`, `min`, `range`, `enumerate`, `zip`, `sum`, `any`, `all`, conversions, etc.) and safe string/dict/list methods, filters may also use a curated set of standard-library modules. Modules are exposed by their canonical names (`math`, `datetime`, `hashlib`, etc.). Filesystem, process, network, and unsafe serialization modules (`os`, `pathlib`, `shutil`, `subprocess`, `socket`, `urllib`, `pickle`, etc.) are intentionally not available.
39
+
40
+ - **`lambda` expressions** — typically as `key=` arguments, e.g. `sorted(data, key=lambda item: item.get("score"))`. Lambda bodies are validated by the same rules as the rest of the filter.
41
+ - **`json`** — `json.loads`, `json.dumps`.
42
+ - **`yaml`** — `yaml.safe_load`, `yaml.safe_dump`. The unsafe `yaml.load` / `yaml.dump` are intentionally not exposed.
43
+ - **`re`** — `re.match`, `re.search`, `re.fullmatch`, `re.findall`, `re.sub`, `re.subn`, `re.compile`, `re.escape`, plus `Match` / `Pattern` methods (`group`, `groups`, `groupdict`, `start`, `end`, `span`).
44
+ - **`math`** — numeric helpers such as `math.ceil`, `math.floor`, `math.sqrt`, `math.log`, `math.exp`, `math.pow`, `math.factorial`, `math.gcd`, `math.lcm`, `math.isfinite`, `math.isclose`.
45
+ - **`statistics`** — aggregates such as `statistics.mean`, `statistics.median`, `statistics.stdev`, `statistics.variance`, `statistics.quantiles`.
46
+ - **`datetime`** — `datetime.datetime.fromisoformat`, `datetime.datetime.now`, `datetime.timedelta`, `datetime.timezone.utc`, and instance methods such as `isoformat`, `strftime`, `timestamp`, `weekday`, `total_seconds`. General instance attribute reads such as `dt.year` and `dt.month` are not supported by the current policy.
47
+ - **`decimal`** — `decimal.Decimal(...)`, `quantize`, `normalize`, `to_eng_string`, `to_integral_value`.
48
+ - **`collections`** — `collections.Counter`, `collections.defaultdict`, `collections.OrderedDict`, `collections.deque`, plus methods such as `most_common`, `elements`, `popleft`, `appendleft`, `rotate`.
49
+ - **`itertools`** — `chain`, `chain.from_iterable`, `islice`, `takewhile`, `dropwhile`, `groupby`, `starmap`, `accumulate`, `combinations`, `permutations`, `product`, `filterfalse`.
50
+ - **`functools`** — `reduce`, `partial`, `cmp_to_key`, `wraps`. Caching decorators such as `lru_cache` and `cache` are intentionally not exposed because they can retain process-local state across filter calls.
51
+ - **`operator`** — `itemgetter`, `methodcaller`, and arithmetic/comparison helpers such as `add`, `mul`, `lt`, `eq`, `gt`. `attrgetter` is intentionally not exposed.
52
+ - **`textwrap`** — `fill`, `wrap`, `shorten`, `indent`, `dedent`.
53
+ - **`html`** — `html.escape`, `html.unescape`.
54
+ - **`base64`** — `b64encode`, `b64decode`, `urlsafe_b64encode`, `urlsafe_b64decode`, `b32encode`, `b32decode`, `b16encode`, `b16decode`.
55
+ - **`hashlib`** — `hashlib.sha256`, `hashlib.sha1`, `hashlib.md5`, `hashlib.blake2b`, `hashlib.new`, plus hash object methods such as `hexdigest`, `digest`, `update`.
56
+ - **`ipaddress`** — `ip_address`, `ip_network`, `ip_interface`, `IPv4Network`, `IPv6Network`, plus methods such as `supernet`, `subnets`, `hosts`, `overlaps`, `subnet_of`, `supernet_of`. General instance attribute reads such as `addr.is_private` and `addr.compressed` are not supported by the current policy.
57
+ - **`unicodedata`** — `category`, `name`, `lookup`, `numeric`, `digit`, `decimal`, `bidirectional`, `combining`, `mirrored`.
58
+ - **`difflib`** — `get_close_matches`, `ndiff`, `unified_diff`, `context_diff`, `SequenceMatcher`.
59
+
60
+ Note: `re.compile` runs against patterns supplied by filter code, so a pathological pattern can stall the server (ReDoS). Some helpers such as `difflib.SequenceMatcher` can also be CPU-heavy on large inputs. Treat filter source as trusted-but-restricted.
61
+
62
+ ## Run with uvx
63
+
64
+ After publishing to PyPI, start the server with:
65
+
66
+ ```bash
67
+ uvx data-filter-mcp --filter-ttl-seconds 3600 --cleanup-interval-seconds 60
68
+ ```
69
+
70
+ Show the available CLI flags with:
71
+
72
+ ```bash
73
+ uvx data-filter-mcp --help
74
+ ```
75
+
76
+ ### Restricting file access with `--workdir`
77
+
78
+ By default the server can read any file on the local filesystem. Use one or
79
+ more `--workdir` flags to restrict file reads to specific directories:
80
+
81
+ ```bash
82
+ uvx data-filter-mcp \
83
+ --filter-ttl-seconds 3600 \
84
+ --cleanup-interval-seconds 60 \
85
+ --workdir /Users/me/project \
86
+ --workdir /tmp/data
87
+ ```
88
+
89
+ Rules:
90
+ - Each `--workdir` value must be an **absolute path** to an existing directory.
91
+ - `run_filter` will only accept files located inside the allowed directories.
92
+ - If no `--workdir` flags are provided, no restrictions are applied (backward compatible).
93
+ - `convert_file` always requires at least one `--workdir` because it writes to disk.
94
+ - `convert_file` requires the destination path to be inside an allowed workdir.
95
+ - `convert_file` creates missing destination parent directories automatically.
96
+ - `convert_file` refuses to replace an existing destination file unless `overwrite` is `true`.
97
+
98
+ ### Writing transformed files with `convert_file`
99
+
100
+ Use `convert_file` when the filtered output should be persisted instead of returned
101
+ inline to the model. The tool accepts:
102
+
103
+ - `filter_id` — an identifier returned by `register_filter`
104
+ - `source_file_path` — absolute path to the json/yaml/txt file to load
105
+ - `destination_file_path` — absolute path where the returned text should be saved
106
+ - `file_type` — optional source file type override (`json`, `yaml`, or `txt`)
107
+ - `overwrite` — optional boolean, default `false`
108
+
109
+ Example flow:
110
+
111
+ ```python
112
+ def filter_item(data):
113
+ return "\n".join(data["items"])
114
+ ```
115
+
116
+ Then call `convert_file` with a source such as `/tmp/data/items.json` and a
117
+ destination such as `/tmp/data/out/items.txt`. The result is written as UTF-8
118
+ text. The returned metadata includes the resolved source and destination paths,
119
+ the effective source file type, `bytes_written`, and whether an existing file was
120
+ overwritten.
121
+
122
+ Example MCP client configuration:
123
+
124
+ ```json
125
+ {
126
+ "mcpServers": {
127
+ "data-filter": {
128
+ "command": "uvx",
129
+ "args": [
130
+ "data-filter-mcp",
131
+ "--filter-ttl-seconds",
132
+ "3600",
133
+ "--cleanup-interval-seconds",
134
+ "60",
135
+ "--workdir",
136
+ "/Users/me/project",
137
+ "--workdir",
138
+ "/tmp/data"
139
+ ]
140
+ }
141
+ }
142
+ }
143
+ ```
144
+
145
+ ## Run locally
146
+
147
+ ```bash
148
+ python server.py --filter-ttl-seconds 3600 --cleanup-interval-seconds 60
149
+ python -m data_filter_mcp.server --filter-ttl-seconds 3600 --cleanup-interval-seconds 60
150
+ .venv/bin/data-filter-mcp --filter-ttl-seconds 3600 --cleanup-interval-seconds 60
151
+ ```
@@ -0,0 +1,127 @@
1
+ # data-filter-mcp
2
+
3
+ Local MCP server that registers restricted Python filters and runs them against local `json`, `yaml`, and `txt` files.
4
+
5
+ ## What it does
6
+
7
+ - `register_filter` accepts Python source code with exactly one top-level function: `def filter_item(data):`
8
+ - `run_filter` loads a local file, passes the loaded document into `filter_item(data)`, and returns the text from `result_text`
9
+ - `convert_file` loads a local file, passes it into `filter_item(data)`, and writes the returned text to another local file
10
+ - Registered filters live only in memory and expire automatically based on server TTL settings
11
+
12
+ ### What filter code may use
13
+
14
+ Filter bodies are AST-validated against a whitelist. In addition to a curated set of builtins (`len`, `sorted`, `max`, `min`, `range`, `enumerate`, `zip`, `sum`, `any`, `all`, conversions, etc.) and safe string/dict/list methods, filters may also use a curated set of standard-library modules. Modules are exposed by their canonical names (`math`, `datetime`, `hashlib`, etc.). Filesystem, process, network, and unsafe serialization modules (`os`, `pathlib`, `shutil`, `subprocess`, `socket`, `urllib`, `pickle`, etc.) are intentionally not available.
15
+
16
+ - **`lambda` expressions** — typically as `key=` arguments, e.g. `sorted(data, key=lambda item: item.get("score"))`. Lambda bodies are validated by the same rules as the rest of the filter.
17
+ - **`json`** — `json.loads`, `json.dumps`.
18
+ - **`yaml`** — `yaml.safe_load`, `yaml.safe_dump`. The unsafe `yaml.load` / `yaml.dump` are intentionally not exposed.
19
+ - **`re`** — `re.match`, `re.search`, `re.fullmatch`, `re.findall`, `re.sub`, `re.subn`, `re.compile`, `re.escape`, plus `Match` / `Pattern` methods (`group`, `groups`, `groupdict`, `start`, `end`, `span`).
20
+ - **`math`** — numeric helpers such as `math.ceil`, `math.floor`, `math.sqrt`, `math.log`, `math.exp`, `math.pow`, `math.factorial`, `math.gcd`, `math.lcm`, `math.isfinite`, `math.isclose`.
21
+ - **`statistics`** — aggregates such as `statistics.mean`, `statistics.median`, `statistics.stdev`, `statistics.variance`, `statistics.quantiles`.
22
+ - **`datetime`** — `datetime.datetime.fromisoformat`, `datetime.datetime.now`, `datetime.timedelta`, `datetime.timezone.utc`, and instance methods such as `isoformat`, `strftime`, `timestamp`, `weekday`, `total_seconds`. General instance attribute reads such as `dt.year` and `dt.month` are not supported by the current policy.
23
+ - **`decimal`** — `decimal.Decimal(...)`, `quantize`, `normalize`, `to_eng_string`, `to_integral_value`.
24
+ - **`collections`** — `collections.Counter`, `collections.defaultdict`, `collections.OrderedDict`, `collections.deque`, plus methods such as `most_common`, `elements`, `popleft`, `appendleft`, `rotate`.
25
+ - **`itertools`** — `chain`, `chain.from_iterable`, `islice`, `takewhile`, `dropwhile`, `groupby`, `starmap`, `accumulate`, `combinations`, `permutations`, `product`, `filterfalse`.
26
+ - **`functools`** — `reduce`, `partial`, `cmp_to_key`, `wraps`. Caching decorators such as `lru_cache` and `cache` are intentionally not exposed because they can retain process-local state across filter calls.
27
+ - **`operator`** — `itemgetter`, `methodcaller`, and arithmetic/comparison helpers such as `add`, `mul`, `lt`, `eq`, `gt`. `attrgetter` is intentionally not exposed.
28
+ - **`textwrap`** — `fill`, `wrap`, `shorten`, `indent`, `dedent`.
29
+ - **`html`** — `html.escape`, `html.unescape`.
30
+ - **`base64`** — `b64encode`, `b64decode`, `urlsafe_b64encode`, `urlsafe_b64decode`, `b32encode`, `b32decode`, `b16encode`, `b16decode`.
31
+ - **`hashlib`** — `hashlib.sha256`, `hashlib.sha1`, `hashlib.md5`, `hashlib.blake2b`, `hashlib.new`, plus hash object methods such as `hexdigest`, `digest`, `update`.
32
+ - **`ipaddress`** — `ip_address`, `ip_network`, `ip_interface`, `IPv4Network`, `IPv6Network`, plus methods such as `supernet`, `subnets`, `hosts`, `overlaps`, `subnet_of`, `supernet_of`. General instance attribute reads such as `addr.is_private` and `addr.compressed` are not supported by the current policy.
33
+ - **`unicodedata`** — `category`, `name`, `lookup`, `numeric`, `digit`, `decimal`, `bidirectional`, `combining`, `mirrored`.
34
+ - **`difflib`** — `get_close_matches`, `ndiff`, `unified_diff`, `context_diff`, `SequenceMatcher`.
35
+
36
+ Note: `re.compile` runs against patterns supplied by filter code, so a pathological pattern can stall the server (ReDoS). Some helpers such as `difflib.SequenceMatcher` can also be CPU-heavy on large inputs. Treat filter source as trusted-but-restricted.
37
+
38
+ ## Run with uvx
39
+
40
+ After publishing to PyPI, start the server with:
41
+
42
+ ```bash
43
+ uvx data-filter-mcp --filter-ttl-seconds 3600 --cleanup-interval-seconds 60
44
+ ```
45
+
46
+ Show the available CLI flags with:
47
+
48
+ ```bash
49
+ uvx data-filter-mcp --help
50
+ ```
51
+
52
+ ### Restricting file access with `--workdir`
53
+
54
+ By default the server can read any file on the local filesystem. Use one or
55
+ more `--workdir` flags to restrict file reads to specific directories:
56
+
57
+ ```bash
58
+ uvx data-filter-mcp \
59
+ --filter-ttl-seconds 3600 \
60
+ --cleanup-interval-seconds 60 \
61
+ --workdir /Users/me/project \
62
+ --workdir /tmp/data
63
+ ```
64
+
65
+ Rules:
66
+ - Each `--workdir` value must be an **absolute path** to an existing directory.
67
+ - `run_filter` will only accept files located inside the allowed directories.
68
+ - If no `--workdir` flags are provided, no restrictions are applied (backward compatible).
69
+ - `convert_file` always requires at least one `--workdir` because it writes to disk.
70
+ - `convert_file` requires the destination path to be inside an allowed workdir.
71
+ - `convert_file` creates missing destination parent directories automatically.
72
+ - `convert_file` refuses to replace an existing destination file unless `overwrite` is `true`.
73
+
74
+ ### Writing transformed files with `convert_file`
75
+
76
+ Use `convert_file` when the filtered output should be persisted instead of returned
77
+ inline to the model. The tool accepts:
78
+
79
+ - `filter_id` — an identifier returned by `register_filter`
80
+ - `source_file_path` — absolute path to the json/yaml/txt file to load
81
+ - `destination_file_path` — absolute path where the returned text should be saved
82
+ - `file_type` — optional source file type override (`json`, `yaml`, or `txt`)
83
+ - `overwrite` — optional boolean, default `false`
84
+
85
+ Example flow:
86
+
87
+ ```python
88
+ def filter_item(data):
89
+ return "\n".join(data["items"])
90
+ ```
91
+
92
+ Then call `convert_file` with a source such as `/tmp/data/items.json` and a
93
+ destination such as `/tmp/data/out/items.txt`. The result is written as UTF-8
94
+ text. The returned metadata includes the resolved source and destination paths,
95
+ the effective source file type, `bytes_written`, and whether an existing file was
96
+ overwritten.
97
+
98
+ Example MCP client configuration:
99
+
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "data-filter": {
104
+ "command": "uvx",
105
+ "args": [
106
+ "data-filter-mcp",
107
+ "--filter-ttl-seconds",
108
+ "3600",
109
+ "--cleanup-interval-seconds",
110
+ "60",
111
+ "--workdir",
112
+ "/Users/me/project",
113
+ "--workdir",
114
+ "/tmp/data"
115
+ ]
116
+ }
117
+ }
118
+ }
119
+ ```
120
+
121
+ ## Run locally
122
+
123
+ ```bash
124
+ python server.py --filter-ttl-seconds 3600 --cleanup-interval-seconds 60
125
+ python -m data_filter_mcp.server --filter-ttl-seconds 3600 --cleanup-interval-seconds 60
126
+ .venv/bin/data-filter-mcp --filter-ttl-seconds 3600 --cleanup-interval-seconds 60
127
+ ```
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.1'
22
- __version_tuple__ = version_tuple = (0, 2, 1)
21
+ __version__ = version = '0.2.4'
22
+ __version_tuple__ = version_tuple = (0, 2, 4)
23
23
 
24
- __commit_id__ = commit_id = 'ge560d71c2'
24
+ __commit_id__ = commit_id = 'gd2ae7c43f'
@@ -41,6 +41,30 @@ class RunFilterResult(BaseModel):
41
41
  result_text: str = Field(description="Exact text returned by filter_item(data).")
42
42
 
43
43
 
44
+ class ConvertFileResult(BaseModel):
45
+ filter_id: str = Field(
46
+ description="Identifier of the registered filter that produced this file."
47
+ )
48
+ source_file_path: str = Field(
49
+ description="Resolved absolute path of the processed source file."
50
+ )
51
+ destination_file_path: str = Field(
52
+ description="Resolved absolute path where the result text was written."
53
+ )
54
+ file_type: str = Field(
55
+ description="Effective loader type used for the source file. One of: json, yaml, txt."
56
+ )
57
+ expires_at: str = Field(
58
+ description="UTC timestamp in ISO 8601 format when this filter expires."
59
+ )
60
+ bytes_written: int = Field(
61
+ description="Number of UTF-8 bytes written to the destination file."
62
+ )
63
+ overwritten: bool = Field(
64
+ description="Whether an existing destination file was replaced."
65
+ )
66
+
67
+
44
68
  @dataclass(slots=True)
45
69
  class RegisteredFilter:
46
70
  filter_id: str
@@ -9,7 +9,7 @@ from mcp.server.fastmcp import FastMCP
9
9
  from pydantic import Field
10
10
 
11
11
  from .loaders.factory import load_document
12
- from .models import RegisterFilterResult, RunFilterResult
12
+ from .models import ConvertFileResult, RegisterFilterResult, RunFilterResult
13
13
  from .registry import FilterExpiredError, FilterNotFoundError, FilterRegistry
14
14
  from .validator import POLICY_VERSION, FilterValidationError, compile_filter
15
15
 
@@ -88,6 +88,47 @@ class FilterService:
88
88
 
89
89
  return resolved
90
90
 
91
+ def _resolve_destination_file_path(self, file_path: str) -> Path:
92
+ candidate = Path(file_path)
93
+ if not candidate.is_absolute():
94
+ raise ValueError(
95
+ f"destination_file_path must be an absolute path: {file_path}"
96
+ )
97
+
98
+ if not self._workdirs:
99
+ raise ValueError(
100
+ "convert_file requires at least one --workdir to be configured"
101
+ )
102
+
103
+ try:
104
+ resolved = candidate.expanduser().resolve(strict=False)
105
+ except OSError as exc:
106
+ raise ValueError(f"Cannot resolve destination path: {file_path}") from exc
107
+
108
+ if not any(resolved == wd or wd in resolved.parents for wd in self._workdirs):
109
+ raise ValueError(f"Destination path is outside allowed workdirs: {resolved}")
110
+
111
+ if resolved.exists() and resolved.is_dir():
112
+ raise ValueError(f"Destination path is a directory: {resolved}")
113
+
114
+ return resolved
115
+
116
+ def _ensure_destination_parent_allowed(self, destination: Path) -> None:
117
+ try:
118
+ resolved_parent = destination.parent.resolve(strict=True)
119
+ except OSError as exc:
120
+ raise ValueError(
121
+ f"Cannot resolve destination parent directory: {destination.parent}"
122
+ ) from exc
123
+
124
+ if not any(
125
+ resolved_parent == wd or wd in resolved_parent.parents
126
+ for wd in self._workdirs
127
+ ):
128
+ raise ValueError(
129
+ f"Destination parent is outside allowed workdirs: {resolved_parent}"
130
+ )
131
+
91
132
  def run_filter(
92
133
  self,
93
134
  filter_id: str,
@@ -114,6 +155,52 @@ class FilterService:
114
155
  result_text=result,
115
156
  )
116
157
 
158
+ def convert_file(
159
+ self,
160
+ filter_id: str,
161
+ source_file_path: str,
162
+ destination_file_path: str,
163
+ file_type: Literal["json", "yaml", "txt"] | None = None,
164
+ overwrite: bool = False,
165
+ ) -> ConvertFileResult:
166
+ try:
167
+ entry = self._registry.get(filter_id)
168
+ except (FilterNotFoundError, FilterExpiredError) as exc:
169
+ raise ValueError(str(exc)) from exc
170
+
171
+ resolved_source = self._resolve_allowed_file_path(source_file_path)
172
+ resolved_destination = self._resolve_destination_file_path(destination_file_path)
173
+
174
+ if resolved_source == resolved_destination:
175
+ raise ValueError("source and destination paths must differ")
176
+
177
+ will_overwrite = resolved_destination.exists()
178
+ if will_overwrite and not overwrite:
179
+ raise ValueError(
180
+ "Destination file already exists (set overwrite=true to replace): "
181
+ f"{resolved_destination}"
182
+ )
183
+
184
+ document, resolved_file_type = load_document(resolved_source, file_type)
185
+ result = entry.function(document)
186
+ if not isinstance(result, str):
187
+ raise ValueError("filter_item(data) must return a string")
188
+
189
+ encoded = result.encode("utf-8")
190
+ resolved_destination.parent.mkdir(parents=True, exist_ok=True)
191
+ self._ensure_destination_parent_allowed(resolved_destination)
192
+ resolved_destination.write_bytes(encoded)
193
+
194
+ return ConvertFileResult(
195
+ expires_at=_to_isoformat(entry.expires_at),
196
+ filter_id=entry.filter_id,
197
+ source_file_path=str(resolved_source),
198
+ destination_file_path=str(resolved_destination),
199
+ file_type=resolved_file_type,
200
+ bytes_written=len(encoded),
201
+ overwritten=will_overwrite,
202
+ )
203
+
117
204
 
118
205
  def create_mcp_server(service: FilterService | None = None) -> FastMCP:
119
206
  active_service = service or FilterService()
@@ -152,6 +239,12 @@ def create_mcp_server(service: FilterService | None = None) -> FastMCP:
152
239
  The function must return a text result (str). The returned text may contain any
153
240
  format you want, such as plain text, YAML, CSV-like text, or a custom report.
154
241
 
242
+ Preloaded standard-library modules:
243
+ - json, yaml, re
244
+ - math, statistics, datetime, decimal
245
+ - collections, itertools, functools, operator
246
+ - textwrap, html, base64, hashlib, ipaddress, unicodedata, difflib
247
+
155
248
  Safety rules:
156
249
  - The code is validated against a restricted Python subset
157
250
  - Imports, network access, dynamic execution, and unsafe attribute access are rejected
@@ -238,6 +331,86 @@ def create_mcp_server(service: FilterService | None = None) -> FastMCP:
238
331
 
239
332
  return active_service.run_filter(filter_id, file_path, file_type)
240
333
 
334
+ @mcp.tool()
335
+ def convert_file(
336
+ filter_id: Annotated[
337
+ str,
338
+ Field(description="Identifier previously returned by register_filter."),
339
+ ],
340
+ source_file_path: Annotated[
341
+ str,
342
+ Field(
343
+ description=(
344
+ "Absolute path to the source file. Must be inside an allowed "
345
+ "--workdir if any are configured."
346
+ )
347
+ ),
348
+ ],
349
+ destination_file_path: Annotated[
350
+ str,
351
+ Field(
352
+ description=(
353
+ "Absolute path to the destination file. Must be inside an "
354
+ "allowed --workdir. Missing parent directories are created "
355
+ "automatically."
356
+ )
357
+ ),
358
+ ],
359
+ file_type: Annotated[
360
+ Literal["json", "yaml", "txt"] | None,
361
+ Field(
362
+ description=(
363
+ "Optional explicit file type override for the source file. "
364
+ "If omitted, detected from the source extension."
365
+ )
366
+ ),
367
+ ] = None,
368
+ overwrite: Annotated[
369
+ bool,
370
+ Field(
371
+ description=(
372
+ "If false (default), fail when destination exists. If true, "
373
+ "overwrite the existing destination file."
374
+ )
375
+ ),
376
+ ] = False,
377
+ ) -> ConvertFileResult:
378
+ """
379
+ Apply a registered filter to a source file and save the text output.
380
+
381
+ Use this tool after register_filter when you want to transform a local
382
+ json, yaml, or txt file and persist the returned string as UTF-8 text.
383
+ The destination path must be inside a configured --workdir; unlike
384
+ run_filter, convert_file refuses to write when no --workdir is configured.
385
+
386
+ Missing destination parent directories are created automatically. Existing
387
+ destination files are rejected unless overwrite is true.
388
+
389
+ Args:
390
+ filter_id: Identifier returned earlier by register_filter.
391
+ source_file_path: Absolute path to the source file to load.
392
+ destination_file_path: Absolute path where result text is saved.
393
+ file_type: Optional explicit source file type override.
394
+ overwrite: Whether to replace an existing destination file.
395
+
396
+ Returns:
397
+ A structured object describing the written file and filter metadata.
398
+
399
+ Raises:
400
+ ValueError: If paths are invalid, workdir is missing, the filter is
401
+ unknown or expired, destination exists without overwrite, or the
402
+ filter returns a non-string result.
403
+ FileNotFoundError: If the source file does not exist.
404
+ """
405
+
406
+ return active_service.convert_file(
407
+ filter_id,
408
+ source_file_path,
409
+ destination_file_path,
410
+ file_type,
411
+ overwrite,
412
+ )
413
+
241
414
  return mcp
242
415
 
243
416