Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[workspace]
members = [
"plugins/rust/python-package/pii_filter",
"plugins/rust/python-package/regex_filter",
"plugins/rust/python-package/rate_limiter",
]
resolver = "3"
Expand Down
31 changes: 31 additions & 0 deletions plugins/rust/python-package/regex_filter/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[package]
name = "regex_filter"
version = "0.1.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Rust-backed regex search and replace plugin for MCP Gateway"

[lib]
name = "regex_filter_rust"
crate-type = ["cdylib", "rlib"]

[[bin]]
name = "stub_gen"
path = "src/bin/stub_gen.rs"

[dependencies]
cpex_framework_bridge = { path = "../../../../crates/framework_bridge" }
log = { workspace = true }
pyo3 = { workspace = true }
pyo3-log = { workspace = true }
pyo3-stub-gen = { workspace = true }
regex = "1.12"

[dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] }

[[bench]]
name = "regex_filter"
harness = false
123 changes: 123 additions & 0 deletions plugins/rust/python-package/regex_filter/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
.PHONY: help
help:
@grep '^# help\:' $(firstword $(MAKEFILE_LIST)) | sed 's/^# help\: //'

PACKAGE_NAME := cpex-regex-filter
WHEEL_PREFIX := cpex_regex_filter
CARGO := cargo
STUB_FILES := cpex_regex_filter/__init__.pyi cpex_regex_filter/regex_filter_rust/__init__.pyi
WHEEL_DIR := ../../../../target/wheels

GREEN := \033[0;32m
YELLOW := \033[0;33m
NC := \033[0m

# help: fmt - Format Rust code with rustfmt
# help: fmt-check - Check Rust code formatting (CI)
# help: clippy - Run clippy lints
.PHONY: fmt fmt-check clippy

fmt:
$(CARGO) fmt

fmt-check:
$(CARGO) fmt -- --check

clippy:
$(CARGO) clippy -- -D warnings

# help: sync - Install plugin development dependencies
# help: test - Run Rust unit tests
# help: test-verbose - Run Rust tests with verbose output
# help: test-python - Run Python plugin tests
# help: test-all - Run both Rust and Python tests
.PHONY: sync test test-verbose test-python test-all verify-stubs

sync:
uv sync --dev

test:
@echo "$(GREEN)Running regex_filter Rust tests...$(NC)"
$(CARGO) test

test-verbose:
@echo "$(GREEN)Running regex_filter Rust tests (verbose)...$(NC)"
$(CARGO) test -- --nocapture

test-python:
@echo "$(GREEN)Running Python tests...$(NC)"
uv run pytest tests/ -v

test-all: test test-python

verify-stubs: stub-gen
@git diff --exit-code -- $(STUB_FILES)

# help: stub-gen - Generate Python type stubs (.pyi files)
# help: build - Build release wheel (no install)
# help: install - Build and install editable extension into project venv
# help: install-wheel - Install the previously built wheel into project venv
.PHONY: stub-gen build install install-wheel uninstall

stub-gen:
@echo "$(GREEN)Generating Python type stubs...$(NC)"
$(CARGO) run --bin stub_gen
@echo "$(GREEN)Stubs generated$(NC)"

build: stub-gen
@echo "$(GREEN)Building $(PACKAGE_NAME)...$(NC)"
uv run maturin build --release
@echo "$(GREEN)Build complete$(NC)"

install: stub-gen
@echo "$(GREEN)Installing $(PACKAGE_NAME)...$(NC)"
uv run maturin develop --release
@echo "$(GREEN)Installation complete$(NC)"

install-wheel: build
@echo "$(GREEN)Installing built wheel for $(PACKAGE_NAME)...$(NC)"
python3 ../../../../tools/install_built_wheel.py --wheel-dir "$(WHEEL_DIR)" --wheel-prefix "$(WHEEL_PREFIX)" --package-name "$(PACKAGE_NAME)" --venv-dir .venv
@echo "$(GREEN)Wheel installation complete$(NC)"

uninstall:
@echo "$(YELLOW)Uninstalling $(PACKAGE_NAME)...$(NC)"
@uv pip uninstall -y $(PACKAGE_NAME) 2>/dev/null || true

# help: bench - Run Criterion benchmarks
# help: bench-no-run - Compile Criterion benchmarks without running them
.PHONY: bench bench-no-run

bench:
@echo "$(GREEN)Running benchmarks...$(NC)"
$(CARGO) bench

bench-no-run:
@echo "$(GREEN)Compiling benchmarks without running them...$(NC)"
$(CARGO) bench --no-run

.PHONY: clean clean-all

clean:
$(CARGO) clean
rm -rf target/ coverage/
find . -name "*.whl" -delete

clean-all: clean

# help: verify - Verify plugin installation
# help: check-all - Run fmt-check + clippy + Rust tests
# help: ci - Run the full CI-equivalent plugin verification flow
.PHONY: verify check-all ci pre-commit

verify:
@uv run python -c "from cpex_regex_filter import regex_filter_rust; print('regex_filter_rust available')" || echo "regex_filter_rust not installed — run: make install"

check-all: fmt-check clippy test
@echo "$(GREEN)All checks passed$(NC)"

ci: check-all verify-stubs build bench-no-run install-wheel test-python
@echo "$(GREEN)CI verification passed$(NC)"

pre-commit: check-all

.DEFAULT_GOAL := help
31 changes: 31 additions & 0 deletions plugins/rust/python-package/regex_filter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Regex Filter Plugin for CPEX

Rust-backed regex search and replace for prompt arguments, prompt messages, tool arguments, and tool results.

This package follows the same layout as the other Rust+Python CPEX plugins in this repository:

- Rust owns the matching and recursive data traversal
- Python keeps a minimal gateway-facing `Plugin` shim
- Tests cover both the Rust engine and the gateway hook surface

## Configuration

```yaml
config:
words:
- search: "\\bsecret\\b"
replace: "[REDACTED]"
- search: "\\d{3}-\\d{2}-\\d{4}"
replace: "XXX-XX-XXXX"
```

## Development

From this plugin directory:

```bash
uv sync --dev
make install
make test-all
make check-all
```
33 changes: 33 additions & 0 deletions plugins/rust/python-package/regex_filter/benches/regex_filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright 2026
// SPDX-License-Identifier: Apache-2.0

use criterion::{Criterion, criterion_group, criterion_main};
use regex::RegexSet;
use regex_filter_rust::{SearchReplace, SearchReplaceConfig, SearchReplacePluginRust};

fn bench_apply_patterns(c: &mut Criterion) {
let config = SearchReplaceConfig {
words: vec![
SearchReplace {
search: r"\bsecret\b".to_string(),
replace: "[REDACTED]".to_string(),
compiled: regex::Regex::new(r"\bsecret\b").unwrap(),
},
SearchReplace {
search: r"\d{3}-\d{2}-\d{4}".to_string(),
replace: "XXX-XX-XXXX".to_string(),
compiled: regex::Regex::new(r"\d{3}-\d{2}-\d{4}").unwrap(),
},
],
pattern_set: RegexSet::new([r"\bsecret\b", r"\d{3}-\d{2}-\d{4}"]).ok(),
};
let plugin = SearchReplacePluginRust { config };
let text = "The secret number is 123-45-6789";

c.bench_function("regex_filter_apply_patterns", |b| {
b.iter(|| plugin.apply_patterns(text))
});
}

criterion_group!(benches, bench_apply_patterns);
criterion_main!(benches);
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""Regex filter plugin package."""

from __future__ import annotations


def __getattr__(name: str):
if name == "SearchReplacePlugin":
from cpex_regex_filter.regex_filter import SearchReplacePlugin

return SearchReplacePlugin
if name == "SearchReplacePluginRust":
from cpex_regex_filter.regex_filter_rust import SearchReplacePluginRust

return SearchReplacePluginRust
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


__all__ = ["SearchReplacePlugin", "SearchReplacePluginRust"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# This file is automatically generated by pyo3_stub_gen
# ruff: noqa: E501, F401, F403, F405

from .regex_filter import SearchReplacePlugin
from .regex_filter_rust import SearchReplacePluginRust

__all__ = [
"SearchReplacePlugin",
"SearchReplacePluginRust",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
description: "Rust-backed regex search and replace for prompt arguments, prompt messages, tool inputs, and tool outputs"
author: "ContextForge Contributors"
version: "0.1.0"
available_hooks:
- "prompt_pre_fetch"
- "prompt_post_fetch"
- "tool_pre_invoke"
- "tool_post_invoke"
default_configs:
words: []
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""Thin compatibility shim for the Rust-owned regex filter plugin."""

from __future__ import annotations

try:
from mcpgateway.plugins.framework import Plugin
except ModuleNotFoundError:
class Plugin: # type: ignore[no-redef]
def __init__(self, config) -> None:
self._config = config

from cpex_regex_filter.regex_filter_rust import RegexFilterPluginCore, SearchReplacePluginRust

_RUST_AVAILABLE = True


class SearchReplacePlugin(Plugin):
"""Gateway-facing Plugin subclass that delegates behavior to Rust."""

def __init__(self, config) -> None:
super().__init__(config)
self._core = RegexFilterPluginCore(config.config or {})

async def prompt_pre_fetch(self, payload, context):
result = self._core.prompt_pre_fetch(payload, context)
if hasattr(result, "__await__"):
return await result
return result

async def prompt_post_fetch(self, payload, context):
result = self._core.prompt_post_fetch(payload, context)
if hasattr(result, "__await__"):
return await result
return result

async def tool_pre_invoke(self, payload, context):
result = self._core.tool_pre_invoke(payload, context)
if hasattr(result, "__await__"):
return await result
return result

async def tool_post_invoke(self, payload, context):
result = self._core.tool_post_invoke(payload, context)
if hasattr(result, "__await__"):
return await result
return result


__all__ = ["SearchReplacePlugin", "SearchReplacePluginRust", "_RUST_AVAILABLE"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# This file is automatically generated by pyo3_stub_gen
# ruff: noqa: E501, F401, F403, F405

import builtins
import typing
__all__ = [
"RegexFilterPluginCore",
"SearchReplacePluginRust",
]

@typing.final
class RegexFilterPluginCore:
def __new__(cls, config: dict) -> RegexFilterPluginCore: ...
def prompt_pre_fetch(self, payload: typing.Any, _context: typing.Any) -> typing.Any: ...
def prompt_post_fetch(self, payload: typing.Any, _context: typing.Any) -> typing.Any: ...
def tool_pre_invoke(self, payload: typing.Any, _context: typing.Any) -> typing.Any: ...
def tool_post_invoke(self, payload: typing.Any, _context: typing.Any) -> typing.Any: ...

@typing.final
class SearchReplacePluginRust:
def __new__(cls, config_dict: dict) -> SearchReplacePluginRust: ...
def apply_patterns(self, text: builtins.str) -> builtins.str: ...
def process_nested(self, data: typing.Any) -> tuple[builtins.bool, typing.Any]: ...
Loading