Skip to content

Commit 213611a

Browse files
Feat/output length guard rust migration (#169)
* feat: migrate output_length_guard plugin to Rust (closes #145) Port the output_length_guard plugin from pure-Python in mcp-context-forge to a Rust core with thin PyO3 bindings, following the pii_filter pattern. - config.rs: OutputLengthGuardConfig — all fields from Python config.py (min/max chars, min/max tokens, chars_per_token, limit_mode, strategy, ellipsis, word_boundary, security limits with identical range validation) - guards.rs: evaluate_text_limits, estimate_tokens, find_word_boundary, truncate, is_numeric_string — 1:1 port of guards.py - structured.rs: process_structured_data, generate_text_representation — 1:1 port of structured.py including all violation codes - plugin.rs: OutputLengthGuardPluginCore PyO3 class — handles all 5 input shapes (plain str, dict+text, list[str], MCP content array, MCP CallToolResult dict with structuredContent) - lib.rs: output_length_guard_rust Python module definition - cpex_output_length_guard/output_length_guard.py: thin Plugin shim - cpex_output_length_guard/__init__.py: lazy-import package entry - cpex_output_length_guard/plugin-manifest.yaml: tool_post_invoke hook - Cargo.toml, pyproject.toml (cpex-output-length-guard), Makefile, README.md - result.metadata["output_length_guard"] emitted when trace_id present: chars_seen, truncated_count, blocked, limit_mode, strategy, stage - No raw content in metrics — counts and labels only - 63 Rust unit tests inline in mod tests across all source modules - Plugin-framework integration tests: plugins/tests/output_length_guard/ Covers all input shapes, both strategies, both limit modes, word-boundary truncation, token mode, metrics gate, security limits, backward compat - Cargo.toml: added output_length_guard to workspace members - Cargo.lock: updated automatically - tests/test_plugin_catalog.py: updated all plugin lists and counts (7→8) Version: 0.1.0 Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix(output_length_guard): enforce max_structure_size on MCP content lists Two fixes required to make integration tests pass 21/21: 1. config.rs: Lower MIN_MAX_STRUCTURE_SIZE from 10 to 1 so that small values (e.g. 2) can be configured for testing. Update the corresponding Rust unit test to reject 0 instead of 5, which is still outside the valid range [1, 100_000]. 2. plugin.rs: process_mcp_items_result did not check max_structure_size against the content list length. Add the guard at the top of that function, mirroring the existing check in process_list (structured.rs). Collapsed into a single compound condition to satisfy clippy's collapsible_if lint. All checks pass: cargo clippy -p output_length_guard -- -D warnings ✓ cargo fmt -- --check ✓ cargo test -p output_length_guard 63/63 ✓ make test-integration 21/21 ✓ contract tests (test_plugin_catalog) 126/126 ✓ Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * chore(output_length_guard): add uv.lock for reproducible dev installs Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix(output_length_guard): emit observability metrics for MCP CallToolResult dicts Two related fixes in process_mcp_items_result / handle_mcp_content_dict: 1. process_mcp_items_result return type extended from Result<(Vec<Py<PyAny>>, bool), Py<PyAny>> to Result<(Vec<Py<PyAny>>, bool, usize, usize), Py<PyAny>> The two new fields are total_chars_seen and items_modified_count, tallied on each TextResult::Modified arm (both text and resource items). 2. handle_mcp_content_dict was calling process_mcp_items_result and using the result to rebuild the payload but never called push_metrics_kwargs, so result.metadata['output_length_guard'] was silently absent on any traced tool call whose result was a MCP CallToolResult dict (the most common production shape). Now the was_modified branch calls push_metrics_kwargs with the accurate counts from the 4-tuple. handle_mcp_list already had its push_metrics_kwargs call; this patch wires the same logic into handle_mcp_content_dict consistently. All checks pass: cargo clippy -p output_length_guard -- -D warnings ok cargo fmt -- --check ok cargo test -p output_length_guard 63/63 ok make test-integration 21/21 ok Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * test(output_length_guard): kill 86 surviving mutants; fix detect-secrets false positive Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * test(output_length_guard): kill surviving cargo-mutants via targeted unit tests Kill all 33 surviving mutants from PR #169 mutation-testing CI run. Add mutants dependency and extract equivalent-mutant helpers with #[mutants::skip]. ## What changed ### guards.rs — new/replaced tests - snap_loop_decrements_to_exact_char_boundary: multi-byte UTF-8 ('á'=2 bytes) forces the first char-boundary snap loop to execute; asserts exact 1-char result to distinguish -= from += - no_word_boundary_does_not_invoke_boundary_search and word_boundary_true_adjusts_cut_when_space_in_window: 24-byte string (16 a's + space + 7 b's), max_tokens=5 cpt=4 so cut=20 search_back=4; space at byte 16 is inside the window; kills && -> || and > with < on line 100 - caps_value_at_max_text_length / cut_is_product_of_tokens_and_cpt: kill > vs == (line 90) and * vs +// (line 95) - no_word_boundary_does_not_invoke_boundary_search and word_boundary_adj_less_than_cut_updates_cut_byte (char-mode): 22-char string with space at char 16 inside 20% window; kill && -> || (line 136) and <= -> > (line 139) - find_word_boundary_does_not_search_beyond_20_percent_window and _finds_boundary_within_20_percent_window: kill * vs + and * vs / on line 53 - find_word_boundary_empty_string_nonzero_cut_returns_cut_unchanged: kill || -> && on line 49 - evaluate_text_limits_one_above_max_{chars,tokens}_fires_above_max: paired below/above assertions kill > vs >= on lines 27 and 32 ### guards.rs — equivalent-mutant helpers Extract five inline helpers annotated #[mutants::skip] for mutations that are provably semantically equivalent: - is_below_char_min / is_below_token_min: usize > 0 vs >= 0; >= 0 always true and length < 0 is impossible - cap_at_max_text_length: > vs >= when len == max_text_length; capping a slice to its own length is a no-op - is_nonzero: cut > 0 vs >= 0 for usize in word-boundary guards - snap_to_char_boundary: while loop snap; /= produces infinite-loop timeout and >= 0 is equivalent for usize Also mark init_logging with #[mutants::skip] (logging side-effect only, not observable in unit tests — same pattern as sql_sanitizer). ### plugin.rs — new tests - truncated_plain_string_new_length_is_positive_and_not_xyzzy: asserts new_length > 0 and != 5 to kill new_text_str -> String::new() and -> "xyzzy" - string_list_with_trace_id_metrics_have_nonzero_chars_seen: trace_id present; assert chars_seen > 0 and truncated_count > 0; kills += -> *= on lines 218-219 - mcp_content_dict_with_trace_id / _text_item_truncated_count_is_nonzero: same for lines 413-414 text items - mcp_resource_item_with_trace_id / _truncated_count_is_nonzero: lines 442-443 - mcp_content_dict_under_max_structure_size_is_not_blocked: list well under max must not block; kills > -> < on line 369 - mcp_content_dict_oversized_list_in_truncate_mode_is_not_blocked: truncate strategy must not block; kills == -> != on line 369 - mcp_content_dict_none_structured_content_does_not_set_structured_content_processed: None structuredContent must yield sc_processed=false; kills ! deletion on line 762 ### structured.rs — new tests - process_string_token_mode_modulo_mutant_is_killed: length=9 cpt=4 max=1; 9/4=2 fires but 9%4=1 does not; kills / -> % on line 105 - process_string_token_mode_multiply_mutant_is_killed: length=4 cpt=4 max=1; 4/4=1 does not fire but 4*4=16 would; kills / -> * on line 105 - process_list/dict_depth_increments_catch_deeply_nested_*: max_recursion_depth=1 with 2-level nesting; depth+1 hits limit but depth*1 never does; kills + -> * on lines 250 and 323 - generate_text_representation_chain_of_10_stops_at_depth_limit: 11 nested single-key dicts; with +1 the 11th level is json-serialised; with *1 it would unwrap to bare leaf; kills + -> * on line 356 ### Cargo.toml - Add mutants = { workspace = true } dependency ## Result cargo-mutants: 33 missed + 1 timeout -> 0 missed, 131 caught, 437 unviable (exit 0) Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * chore(output_length_guard): add mutants workspace dep for #[mutants::skip] The mutants = "0.0.4" crate is a zero-cost compile-time-only crate that defines the #[mutants::skip] proc-macro attribute. It is required for the nine annotations added in the previous commit (equivalent-mutant helpers and init_logging). Pattern matches sql_sanitizer which carries the same dep. Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * add mutants to cargo.lock Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix(output_length_guard): correct metrics mode/strategy and enforce max_structure_size in truncate mode Two bugs fixed in plugin.rs: 1. push_metrics_kwargs and build_blocked_result emitted hardcoded mode: character and strategy: truncate/block regardless of the plugin's actual configuration. Any deployment using limit_mode: token would see {limit_mode: character} in every OTel trace — silently wrong. Fix: add cfg: &OutputLengthGuardConfig to both functions and use cfg.limit_mode.as_str() / cfg.strategy.as_str() at the MetricsArgs construction sites. All 8 call sites updated to pass &self.cfg. Regression test: token_mode_metrics_emit_correct_limit_mode — asserts that a token-mode plugin emits limit_mode=token in traced metadata. 2. process_mcp_items_result guarded max_structure_size with a compound condition (), so Truncate mode would iterate arbitrarily large content arrays with no size cap — a DoS vector for oversized LLM tool responses. Fix: split the condition to match the established pattern in structured.rs::process_list / process_dict — check size unconditionally (log error), then branch on strategy: Block returns a STRUCTURE_SIZE_VIOLATION; Truncate passes the list through unchanged (individual item text is still guarded below). Regression test: mcp_content_dict_oversized_list_truncate_mode_- passes_through_unchanged — sends a 3-item list against max_structure_size=2 / strategy=truncate and asserts no block. All checks pass: cargo clippy -p output_length_guard -- -D warnings ok cargo test -p output_length_guard 139/139 ok Signed-off-by: prakhar.singh1928@ibm.com Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix clippy errors Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix(output_length_guard): fix metadata key collision and remove dead code in structured.rs Two bugs fixed: 1. plugin.rs — metadata key collision silently drops data In handle_plain_string and handle_text_dict, the kwargs vec already contained (metadata, text_meta_dict) from build_text_meta. push_metrics_kwargs then appended a second (metadata, otel_dict). build_framework_object_dyn iterates the vec into a PyDict via set_item, so the second entry silently overwrote the first. Callers with a trace_id lost either original_length/truncated/ new_length or the output_length_guard metrics depending on insertion order. The same double-append pattern existed in handle_mcp_list, handle_string_list, and handle_mcp_content_dict. Fix: replace push_metrics_kwargs (which appended to the kwargs vec) with merge_metrics_into_meta, which takes a live &Bound<PyDict> and inserts the output_length_guard namespace key directly into the existing metadata dict. Rename build_text_meta -> build_text_meta_dict and change its return type from Py<PyAny> to Bound<PyDict> so it stays bound long enough for the merge before the final unbind. All 5 call sites updated. 2. structured.rs — dead code in generate_text_representation The multi-key dict branch contained a Python::attach(|_py| Ok(())) whose result was immediately discarded (let _ = json_module). The comment acknowledged the GIL was already held. The list branch used if let Ok(list) = ... { let _ = list; ... } — the binding was never used; json_dumps received the original data reference. Fix: remove both dead blocks. Replace the unused-binding list pattern with data.cast::<PyList>().is_ok(). All checks pass: cargo fmt -- --check ok cargo clippy -p output_length_guard -- -D warnings ok cargo test -p output_length_guard 139/139 ok Signed-off-by: prakhar.singh1928@ibm.com Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix(output_length_guard): use char count for character-mode detection, not byte length Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix(test_plugin_catalog): update counts and splits for 8 Rust + 1 Python plugins After rebasing onto main (which added ica_metering_exporter as the first pure-Python plugin via PR #170), the combined repository now has 9 plugins: 8 Rust (including output_length_guard) + 1 Python (ica_metering_exporter). Update three assertions that were left with stale values after the rebase: - plugin_count field test: 8 -> 9 - rust_plugin_count split field: "7" -> "8" - test_ci_selection_reports_language_splits_and_counts: comment + assertion 7 -> 8 Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix(output_length_guard): resolve 4 blocking review issues from PR #169 Fix 1 (guards.rs): cap_at_max_text_length snaps cut to nearest valid UTF-8 char boundary before slicing, preventing PanicException on multi-byte codepoints (e.g. euro sign * 400, max_text_length=1000). Fix 2 (plugin.rs): process_mcp_items_result no longer early-returns on oversized lists in truncate mode. The STRUCTURE_SIZE_VIOLATION block is now gated on strategy == Block only; truncate mode logs a warning and continues to guard individual item text. Fix 3 (plugin.rs): build_violation populates mcp_error_code=-32000 and http_status_code=422 on every PluginViolation, satisfying the gateway contract and avoiding the -32603 fallback in the exception handler. Fix 4 (plugin.rs): introduce TextResult::BelowMin variant. handle_text returns BelowMin when below_min && !above_max in truncate mode. handle_plain_string and handle_text_dict set within_bounds=false for BelowMin results, matching the expected contract. Regression tests added for all four fixes. Inline narration comments referencing 'matching Python behaviour' replaced with functional descriptions. 145 Rust unit tests pass. Python catalog: 133 passed, 3 skipped. Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix(plugin_hooks): add mcp_error_code to PluginViolation stub Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix(output_length_guard): suppress unkillable mutants on log-only truncate warning The three surviving mutants (> → ==, > → <, > → >=) on plugin.rs:425 all target the condition inside process_mcp_items_result that gates a log::error! warning in truncate mode. Because the branch body has no observable return value or side-effect visible to the test harness, all comparison variants produce identical behaviour and cannot be killed by a unit test. Extract the warning into log_mcp_truncate_size_warning and annotate it with Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> #[mutants::skip], matching the established pattern in guards.rs and lib.rs. * use unicode codepoint counting Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * preserve legacy metadata contract Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * address comments Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix(output-length-guard): default max_chars to None to match Python behaviour The Python Pydantic model defines: max_chars: Optional[int] = Field(default=None, ...) meaning an empty plugin config produces an unlimited guard. The Rust Default impl had DEFAULT_MAX_CHARS = Some(15_000), so constructing OutputLengthGuardConfig from an empty dict silently truncated responses that Python would have passed through unchanged. Production deployments are unaffected — plugin-manifest.yaml and the gateway config.yaml both set max_chars: 15000 explicitly. The struct default is only reached on direct construction with no config supplied. Changes: - config.rs: DEFAULT_MAX_CHARS None (was Some(15_000)); add comment linking it to the Python field default - config.rs: update default_config_has_expected_values assertion; add empty_config_dict_defaults_max_chars_to_none regression test - test_integration.py: add test_empty_config_defaults_max_chars_to_none_unlimited (full-stack proof: empty PluginConfig, 20k-char input, no truncation) - test_integration.py: add test_unsupported_result_type_is_skipped_with_type_name_in_reason (closes T16 integration-coverage gap for unsupported_type_<name>) Resolves reviewer thread 3872147994 (lucarlig, CHANGES_REQUESTED). Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * fix(output-length-guard): resolve metadata and Unicode boundary review issues Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> * test(output-length-guard): cover UTF-8 boundary mutations Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> --------- Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com> Signed-off-by: prakhar.singh1928@ibm.com
1 parent 868c658 commit 213611a

20 files changed

Lines changed: 7664 additions & 5 deletions

File tree

‎Cargo.lock‎

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
members = [
33
"crates/framework_bridge",
44
"plugins/rust/python-package/encoded_exfil_detection",
5+
"plugins/rust/python-package/output_length_guard",
56
"plugins/rust/python-package/pii_filter",
67
"plugins/rust/python-package/rate_limiter",
78
"plugins/rust/python-package/retry_with_backoff",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[package]
2+
name = "output_length_guard"
3+
version = "0.1.0"
4+
edition.workspace = true
5+
authors.workspace = true
6+
license.workspace = true
7+
repository.workspace = true
8+
description = "Rust-backed output length guard plugin for MCP Gateway"
9+
10+
[lib]
11+
name = "output_length_guard_rust"
12+
crate-type = ["cdylib", "rlib"]
13+
14+
[[bin]]
15+
name = "stub_gen"
16+
path = "src/bin/stub_gen.rs"
17+
required-features = ["stub-gen"]
18+
19+
[features]
20+
default = []
21+
stub-gen = ["dep:pyo3-stub-gen"]
22+
23+
[dependencies]
24+
cpex_framework_bridge = { workspace = true }
25+
log = { workspace = true }
26+
mutants = { workspace = true }
27+
pyo3 = { workspace = true }
28+
pyo3-log = { workspace = true }
29+
pyo3-stub-gen = { workspace = true, optional = true }
30+
serde = { workspace = true }
31+
serde_json = { workspace = true }
32+
thiserror = { workspace = true }
33+
34+
[dev-dependencies]
35+
criterion = { workspace = true }
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
.PHONY: help
2+
help:
3+
@grep '^# help\:' $(firstword $(MAKEFILE_LIST)) | sed 's/^# help\: //'
4+
5+
PACKAGE_NAME := cpex-output-length-guard
6+
WHEEL_PREFIX := cpex_output_length_guard
7+
CARGO := cargo
8+
CARGO_PACKAGE := output_length_guard
9+
NEXTEST_PROFILE ?= default
10+
STUB_FILES := cpex_output_length_guard/__init__.pyi
11+
WHEEL_DIR := ../../../../target/wheels
12+
13+
GREEN := \033[0;32m
14+
YELLOW := \033[0;33m
15+
NC := \033[0m
16+
17+
# help: fmt - Format Rust code with rustfmt
18+
# help: fmt-check - Check Rust code formatting (CI)
19+
# help: clippy - Run clippy lints
20+
.PHONY: fmt fmt-check clippy
21+
22+
fmt:
23+
$(CARGO) fmt
24+
25+
fmt-check:
26+
$(CARGO) fmt -- --check
27+
28+
clippy:
29+
$(CARGO) clippy -- -D warnings
30+
31+
# help: sync - Install plugin development dependencies
32+
# help: test - Run Rust unit tests and Python integration tests
33+
# help: test-unit - Run Rust unit tests
34+
# help: test-verbose - Run Rust tests with verbose output
35+
# help: test-integration - Run repo-level integration tests for output_length_guard
36+
# help: test-all - Alias for test
37+
.PHONY: sync test test-unit test-verbose test-python test-integration test-all verify-stubs
38+
39+
sync:
40+
uv sync --dev
41+
42+
test-unit:
43+
@echo "$(GREEN)Running output_length_guard Rust tests...$(NC)"
44+
$(CARGO) nextest run --profile $(NEXTEST_PROFILE) -p $(CARGO_PACKAGE)
45+
46+
test: test-unit test-integration
47+
48+
test-verbose:
49+
@echo "$(GREEN)Running output_length_guard Rust tests (verbose)...$(NC)"
50+
$(CARGO) nextest run --profile $(NEXTEST_PROFILE) -p $(CARGO_PACKAGE) --no-capture
51+
52+
test-python:
53+
$(MAKE) test-integration
54+
55+
test-integration:
56+
@echo "$(GREEN)Running Python tests...$(NC)"
57+
CPEX_TEST_PLUGIN_HOOKS=1 uv run pytest ../../../tests/output_length_guard/test_integration.py -v -rs
58+
59+
test-all: test
60+
61+
verify-stubs:
62+
@test -f cpex_output_length_guard/__init__.pyi
63+
64+
# help: stub-gen - Generate Python type stubs (.pyi files)
65+
# help: build - Build release wheel (no install)
66+
# help: install - Build and install editable extension into project venv
67+
# help: install-wheel - Install the previously built wheel into project venv
68+
.PHONY: stub-gen build install install-wheel uninstall
69+
70+
stub-gen:
71+
@echo "$(GREEN)Generating Python type stubs...$(NC)"
72+
$(CARGO) run --features stub-gen --bin stub_gen
73+
@echo "$(GREEN)Stubs generated$(NC)"
74+
75+
build:
76+
@echo "$(GREEN)Building $(PACKAGE_NAME)...$(NC)"
77+
uv run maturin build --release
78+
@echo "$(GREEN)Build complete$(NC)"
79+
80+
install:
81+
@echo "$(GREEN)Installing $(PACKAGE_NAME)...$(NC)"
82+
uv run maturin develop --release
83+
@echo "$(GREEN)Installation complete$(NC)"
84+
85+
install-wheel: build
86+
@echo "$(GREEN)Installing built wheel for $(PACKAGE_NAME)...$(NC)"
87+
python3 ../../../../tools/install_built_wheel.py --wheel-dir "$(WHEEL_DIR)" --wheel-prefix "$(WHEEL_PREFIX)" --package-name "$(PACKAGE_NAME)" --venv-dir .venv
88+
@echo "$(GREEN)Wheel installation complete$(NC)"
89+
90+
uninstall:
91+
@echo "$(YELLOW)Uninstalling $(PACKAGE_NAME)...$(NC)"
92+
@uv pip uninstall -y $(PACKAGE_NAME) 2>/dev/null || true
93+
94+
.PHONY: clean clean-all
95+
96+
clean:
97+
$(CARGO) clean
98+
rm -rf target/ coverage/
99+
find . -name "*.whl" -delete
100+
101+
clean-all: clean
102+
103+
# help: doc - Generate Rust documentation
104+
# help: doc-open - Generate and open documentation
105+
.PHONY: doc doc-open
106+
107+
doc:
108+
$(CARGO) doc --no-deps --document-private-items
109+
110+
doc-open: doc
111+
$(CARGO) doc --no-deps --document-private-items --open
112+
113+
# help: verify - Verify plugin installation
114+
# help: check-all - Run fmt-check + clippy + Rust tests
115+
# help: ci-build - Run CI build/static verification without integration tests
116+
# help: ci - Run the full CI-equivalent plugin verification flow
117+
.PHONY: verify check-all ci-build ci pre-commit
118+
119+
verify:
120+
@uv run python -c "from cpex_output_length_guard import output_length_guard_rust; print('output_length_guard_rust available')" || echo "output_length_guard_rust not installed — run: make install"
121+
122+
check-all: fmt-check clippy test-unit
123+
@echo "$(GREEN)All checks passed$(NC)"
124+
125+
ci-build: check-all verify-stubs build install-wheel
126+
127+
ci: ci-build test-integration
128+
@echo "$(GREEN)CI verification passed$(NC)"
129+
130+
pre-commit: check-all
131+
132+
.DEFAULT_GOAL := help
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# cpex-output-length-guard
2+
3+
Rust-backed output length guard plugin for MCP Gateway. Guards tool outputs by enforcing configurable minimum/maximum character or token limits, with either truncation or blocking strategies.
4+
5+
## Features
6+
7+
- **Character mode** (`limit_mode: "character"`): enforce min/max character counts
8+
- **Token mode** (`limit_mode: "token"`): enforce min/max estimated token counts (using configurable `chars_per_token` ratio)
9+
- **Truncate strategy**: shorten over-limit output, optionally at word boundaries, with configurable ellipsis
10+
- **Block strategy**: return a `PluginViolation` to halt processing when limits are exceeded
11+
- **Supported input shapes**:
12+
- Plain `str`
13+
- `dict` with a `text` field
14+
- `list[str]`
15+
- MCP content array: `[{"type": "text", "text": "..."}]`
16+
- MCP `CallToolResult` dict with `content` list (and optional `structuredContent`)
17+
- **Numeric string preservation**: numeric values (integers, floats, scientific notation) pass through without modification
18+
- **Security limits**: `max_text_length`, `max_structure_size`, `max_recursion_depth` prevent DoS from oversized inputs
19+
20+
## Configuration
21+
22+
```yaml
23+
kind: "cpex_output_length_guard.output_length_guard.OutputLengthGuardPlugin"
24+
available_hooks:
25+
- "tool_post_invoke"
26+
config:
27+
min_chars: 0 # Minimum characters (0 = disabled)
28+
max_chars: 15000 # Maximum characters (null = disabled)
29+
min_tokens: 0 # Minimum estimated tokens (0 = disabled)
30+
max_tokens: null # Maximum estimated tokens (null = disabled)
31+
chars_per_token: 4 # Characters per token estimate (1–10)
32+
limit_mode: "character" # "character" or "token"
33+
strategy: "truncate" # "truncate" or "block"
34+
ellipsis: "…" # Appended on truncation (empty = none)
35+
word_boundary: false # Truncate at word boundary
36+
max_text_length: 1000000 # Security: max bytes to process (1KB–10MB)
37+
max_structure_size: 10000 # Security: max items in list/dict (1–100K; Rust intentionally supports tighter limits than Python)
38+
max_recursion_depth: 100 # Security: max nesting depth (10–1000)
39+
```
40+
41+
## Observability
42+
43+
When an OpenTelemetry trace is active (via `extensions.request.trace_id`), the plugin emits metrics to `result.metadata["output_length_guard"]`:
44+
45+
```python
46+
result.metadata["output_length_guard"] = {
47+
"chars_seen": 42000, # characters in the oversized content
48+
"truncated_count": 1, # number of items truncated
49+
"blocked": False, # True if blocked, False if truncated
50+
"limit_mode": "character", # enforcement mode used
51+
"strategy": "truncate", # strategy applied
52+
"stage": "tool_post_invoke",
53+
}
54+
```
55+
56+
Metrics never contain raw output content — only counts, labels, and status indicators.
57+
58+
## Violation Codes
59+
60+
| Code | Description |
61+
|------|-------------|
62+
| `OUTPUT_LENGTH_VIOLATION` | String length outside configured bounds |
63+
| `OUTPUT_TOKEN_VIOLATION` | Estimated token count outside configured bounds |
64+
| `STRUCTURE_SIZE_VIOLATION` | List/dict too large (security limit) |
65+
| `STRUCTURE_DEPTH_VIOLATION` | Nesting too deep (security limit) |
66+
67+
## Development
68+
69+
```bash
70+
uv sync --dev
71+
make install # Build Rust extension and install
72+
make test-all # Run Rust + Python tests
73+
make test-integration # Run plugin-framework integration tests
74+
make check-all # fmt-check + clippy + Rust tests
75+
```
76+
77+
## License
78+
79+
Apache-2.0
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# -*- coding: utf-8 -*-
2+
"""Output length guard plugin package."""
3+
4+
from __future__ import annotations
5+
6+
7+
def __getattr__(name: str):
8+
if name == "OutputLengthGuardPlugin":
9+
from cpex_output_length_guard.output_length_guard import OutputLengthGuardPlugin
10+
11+
return OutputLengthGuardPlugin
12+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
13+
14+
15+
__all__ = ["OutputLengthGuardPlugin"]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# This file is automatically generated by pyo3_stub_gen
2+
# ruff: noqa: E501, F401, F403, F405
3+
4+
from .output_length_guard import OutputLengthGuardPlugin
5+
6+
__all__ = [
7+
"OutputLengthGuardPlugin",
8+
]
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2025
3+
# SPDX-License-Identifier: Apache-2.0
4+
"""Thin compatibility shim for the Rust-owned output length guard plugin."""
5+
6+
from __future__ import annotations
7+
8+
from cpex.framework import Plugin
9+
from cpex_output_length_guard.output_length_guard_rust import OutputLengthGuardPluginCore
10+
11+
12+
class OutputLengthGuardPlugin(Plugin):
13+
"""Gateway-facing Plugin subclass that delegates behavior to Rust."""
14+
15+
def __init__(self, config) -> None:
16+
super().__init__(config)
17+
self._core = OutputLengthGuardPluginCore(config.config or {})
18+
19+
async def tool_post_invoke(self, payload, context, extensions=None):
20+
return self._core.tool_post_invoke(payload, context, extensions)
21+
22+
23+
__all__ = ["OutputLengthGuardPlugin"]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
description: "Rust-backed output length guard for tool outputs — truncates or blocks responses that exceed configurable character or token limits"
2+
author: "ContextForge Contributors"
3+
version: "0.1.0"
4+
kind: "cpex_output_length_guard.output_length_guard.OutputLengthGuardPlugin"
5+
available_hooks:
6+
- "tool_post_invoke"
7+
default_configs:
8+
min_chars: 0
9+
max_chars: 15000
10+
min_tokens: 0
11+
chars_per_token: 4
12+
limit_mode: "character"
13+
strategy: "truncate"
14+
ellipsis: "…"
15+
word_boundary: false
16+
max_text_length: 1000000
17+
max_structure_size: 10000
18+
max_recursion_depth: 100
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
[build-system]
2+
requires = ["maturin>=1.13.3,<2.0"]
3+
build-backend = "maturin"
4+
5+
[project]
6+
name = "cpex-output-length-guard"
7+
dynamic = ["version"]
8+
description = "Rust-backed output length guard plugin for MCP Gateway"
9+
authors = [{ name = "ContextForge Contributors" }]
10+
license = { text = "Apache-2.0" }
11+
readme = "README.md"
12+
requires-python = ">=3.11"
13+
dependencies = [
14+
"cpex>=0.1.3,<0.2",
15+
"mcp<2",
16+
]
17+
classifiers = [
18+
"Programming Language :: Rust",
19+
"Programming Language :: Python :: Implementation :: CPython",
20+
"Programming Language :: Python :: 3.11",
21+
"Programming Language :: Python :: 3.12",
22+
"Programming Language :: Python :: 3.13",
23+
]
24+
25+
[project.entry-points."cpex.plugins"]
26+
output_length_guard = "cpex_output_length_guard.output_length_guard:OutputLengthGuardPlugin"
27+
28+
[tool.maturin]
29+
module-name = "cpex_output_length_guard.output_length_guard_rust"
30+
python-source = "."
31+
features = ["pyo3/extension-module"]
32+
33+
[dependency-groups]
34+
dev = [
35+
"maturin>=1.13.3",
36+
"pytest>=9.1.1",
37+
"pytest-asyncio>=1.3.0",
38+
]

0 commit comments

Comments
 (0)