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.
- data_filter_mcp-0.2.4/PKG-INFO +151 -0
- data_filter_mcp-0.2.4/README.md +127 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/_version.py +3 -3
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/models.py +24 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/server.py +174 -1
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/validator.py +242 -4
- data_filter_mcp-0.2.4/data_filter_mcp.egg-info/PKG-INFO +151 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/tests/test_mcp_tools.py +34 -0
- data_filter_mcp-0.2.4/tests/test_server_flow.py +537 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/tests/test_validator.py +238 -0
- data_filter_mcp-0.2.1/PKG-INFO +0 -107
- data_filter_mcp-0.2.1/README.md +0 -83
- data_filter_mcp-0.2.1/data_filter_mcp.egg-info/PKG-INFO +0 -107
- data_filter_mcp-0.2.1/tests/test_server_flow.py +0 -200
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/.github/workflows/ci.yml +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/.github/workflows/release.yml +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/.gitignore +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/LICENSE +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/__init__.py +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/__init__.py +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/base.py +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/factory.py +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/json_loader.py +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/txt_loader.py +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/loaders/yaml_loader.py +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp/registry.py +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp.egg-info/SOURCES.txt +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp.egg-info/dependency_links.txt +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp.egg-info/entry_points.txt +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp.egg-info/requires.txt +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/data_filter_mcp.egg-info/top_level.txt +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/pyproject.toml +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/server.py +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/setup.cfg +0 -0
- {data_filter_mcp-0.2.1 → data_filter_mcp-0.2.4}/tests/test_loaders.py +0 -0
- {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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 4)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
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
|
|