Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2e93a36
feat: migrate output_length_guard plugin to Rust (closes #145)
prakhar-singh1928 Aug 20, 2026
f3ad819
fix(output_length_guard): enforce max_structure_size on MCP content l…
prakhar-singh1928 Aug 24, 2026
5f91b7b
chore(output_length_guard): add uv.lock for reproducible dev installs
prakhar-singh1928 Aug 24, 2026
9ca8d4e
fix(output_length_guard): emit observability metrics for MCP CallTool…
prakhar-singh1928 Aug 24, 2026
2682cc1
test(output_length_guard): kill 86 surviving mutants; fix detect-secr…
prakhar-singh1928 Aug 24, 2026
57f5380
test(output_length_guard): kill surviving cargo-mutants via targeted …
prakhar-singh1928 Aug 25, 2026
b0d8acf
chore(output_length_guard): add mutants workspace dep for #[mutants::…
prakhar-singh1928 Aug 25, 2026
0d66b8e
add mutants to cargo.lock
prakhar-singh1928 Aug 25, 2026
f7374ab
fix(output_length_guard): correct metrics mode/strategy and enforce m…
prakhar-singh1928 Aug 26, 2026
62b891e
fix clippy errors
prakhar-singh1928 Aug 26, 2026
39b1493
fix(output_length_guard): fix metadata key collision and remove dead …
prakhar-singh1928 Aug 27, 2026
f6e14b5
fix(output_length_guard): use char count for character-mode detection…
prakhar-singh1928 Aug 27, 2026
30e41b0
fix(test_plugin_catalog): update counts and splits for 8 Rust + 1 Pyt…
prakhar-singh1928 Aug 27, 2026
0e2de8a
fix(output_length_guard): resolve 4 blocking review issues from PR #169
prakhar-singh1928 Aug 27, 2026
66a1fcc
fix(plugin_hooks): add mcp_error_code to PluginViolation stub
prakhar-singh1928 Aug 27, 2026
5b6ef24
fix(output_length_guard): suppress unkillable mutants on log-only tru…
prakhar-singh1928 Aug 27, 2026
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
16 changes: 16 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
Expand Up @@ -2,6 +2,7 @@
members = [
"crates/framework_bridge",
"plugins/rust/python-package/encoded_exfil_detection",
"plugins/rust/python-package/output_length_guard",
"plugins/rust/python-package/pii_filter",
"plugins/rust/python-package/rate_limiter",
"plugins/rust/python-package/retry_with_backoff",
Expand Down
35 changes: 35 additions & 0 deletions plugins/rust/python-package/output_length_guard/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[package]
name = "output_length_guard"
version = "0.1.0"
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Rust-backed output length guard plugin for MCP Gateway"

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

[[bin]]
name = "stub_gen"
path = "src/bin/stub_gen.rs"
required-features = ["stub-gen"]

[features]
default = []
stub-gen = ["dep:pyo3-stub-gen"]

[dependencies]
cpex_framework_bridge = { workspace = true }
log = { workspace = true }
mutants = { workspace = true }
pyo3 = { workspace = true }
pyo3-log = { workspace = true }
pyo3-stub-gen = { workspace = true, optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

[dev-dependencies]
criterion = { workspace = true }
132 changes: 132 additions & 0 deletions plugins/rust/python-package/output_length_guard/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
.PHONY: help
help:
@grep '^# help\:' $(firstword $(MAKEFILE_LIST)) | sed 's/^# help\: //'

PACKAGE_NAME := cpex-output-length-guard
WHEEL_PREFIX := cpex_output_length_guard
CARGO := cargo
CARGO_PACKAGE := output_length_guard
NEXTEST_PROFILE ?= default
STUB_FILES := cpex_output_length_guard/__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 and Python integration tests
# help: test-unit - Run Rust unit tests
# help: test-verbose - Run Rust tests with verbose output
# help: test-integration - Run repo-level integration tests for output_length_guard
# help: test-all - Alias for test
.PHONY: sync test test-unit test-verbose test-python test-integration test-all verify-stubs

sync:
uv sync --dev

test-unit:
@echo "$(GREEN)Running output_length_guard Rust tests...$(NC)"
$(CARGO) nextest run --profile $(NEXTEST_PROFILE) -p $(CARGO_PACKAGE)

test: test-unit test-integration

test-verbose:
@echo "$(GREEN)Running output_length_guard Rust tests (verbose)...$(NC)"
$(CARGO) nextest run --profile $(NEXTEST_PROFILE) -p $(CARGO_PACKAGE) --no-capture

test-python:
$(MAKE) test-integration

test-integration:
@echo "$(GREEN)Running Python tests...$(NC)"
CPEX_TEST_PLUGIN_HOOKS=1 uv run pytest ../../../tests/output_length_guard/test_integration.py -v -rs

test-all: test

verify-stubs:
@test -f cpex_output_length_guard/__init__.pyi

# 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 --features stub-gen --bin stub_gen
@echo "$(GREEN)Stubs generated$(NC)"

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

install:
@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

.PHONY: clean clean-all

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

clean-all: clean

# help: doc - Generate Rust documentation
# help: doc-open - Generate and open documentation
.PHONY: doc doc-open

doc:
$(CARGO) doc --no-deps --document-private-items

doc-open: doc
$(CARGO) doc --no-deps --document-private-items --open

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

verify:
@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"

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

ci-build: check-all verify-stubs build install-wheel

ci: ci-build test-integration
@echo "$(GREEN)CI verification passed$(NC)"

pre-commit: check-all

.DEFAULT_GOAL := help
79 changes: 79 additions & 0 deletions plugins/rust/python-package/output_length_guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# cpex-output-length-guard

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.

## Features

- **Character mode** (`limit_mode: "character"`): enforce min/max character counts
- **Token mode** (`limit_mode: "token"`): enforce min/max estimated token counts (using configurable `chars_per_token` ratio)
- **Truncate strategy**: shorten over-limit output, optionally at word boundaries, with configurable ellipsis
- **Block strategy**: return a `PluginViolation` to halt processing when limits are exceeded
- **Supported input shapes**:
- Plain `str`
- `dict` with a `text` field
- `list[str]`
- MCP content array: `[{"type": "text", "text": "..."}]`
- MCP `CallToolResult` dict with `content` list (and optional `structuredContent`)
- **Numeric string preservation**: numeric values (integers, floats, scientific notation) pass through without modification
- **Security limits**: `max_text_length`, `max_structure_size`, `max_recursion_depth` prevent DoS from oversized inputs

## Configuration

```yaml
kind: "cpex_output_length_guard.output_length_guard.OutputLengthGuardPlugin"
available_hooks:
- "tool_post_invoke"
config:
min_chars: 0 # Minimum characters (0 = disabled)
max_chars: 15000 # Maximum characters (null = disabled)
min_tokens: 0 # Minimum estimated tokens (0 = disabled)
max_tokens: null # Maximum estimated tokens (null = disabled)
chars_per_token: 4 # Characters per token estimate (1–10)
limit_mode: "character" # "character" or "token"
strategy: "truncate" # "truncate" or "block"
ellipsis: "…" # Appended on truncation (empty = none)
word_boundary: false # Truncate at word boundary
max_text_length: 1000000 # Security: max bytes to process (1KB–10MB)
max_structure_size: 10000 # Security: max items in list/dict (10–100K)
max_recursion_depth: 100 # Security: max nesting depth (10–1000)
```

## Observability

When an OpenTelemetry trace is active (via `extensions.request.trace_id`), the plugin emits metrics to `result.metadata["output_length_guard"]`:

```python
result.metadata["output_length_guard"] = {
"chars_seen": 42000, # characters in the oversized content
"truncated_count": 1, # number of items truncated
"blocked": False, # True if blocked, False if truncated
"limit_mode": "character", # enforcement mode used
"strategy": "truncate", # strategy applied
"stage": "tool_post_invoke",
}
```

Metrics never contain raw output content — only counts, labels, and status indicators.

## Violation Codes

| Code | Description |
|------|-------------|
| `OUTPUT_LENGTH_VIOLATION` | String length outside configured bounds |
| `OUTPUT_TOKEN_VIOLATION` | Estimated token count outside configured bounds |
| `STRUCTURE_SIZE_VIOLATION` | List/dict too large (security limit) |
| `STRUCTURE_DEPTH_VIOLATION` | Nesting too deep (security limit) |

## Development

```bash
uv sync --dev
make install # Build Rust extension and install
make test-all # Run Rust + Python tests
make test-integration # Run plugin-framework integration tests
make check-all # fmt-check + clippy + Rust tests
```

## License

Apache-2.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
"""Output length guard plugin package."""

from __future__ import annotations


def __getattr__(name: str):
if name == "OutputLengthGuardPlugin":
from cpex_output_length_guard.output_length_guard import OutputLengthGuardPlugin

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


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

from .output_length_guard import OutputLengthGuardPlugin

__all__ = [
"OutputLengthGuardPlugin",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Copyright 2025
# SPDX-License-Identifier: Apache-2.0
"""Thin compatibility shim for the Rust-owned output length guard plugin."""

from __future__ import annotations

from cpex.framework import Plugin
from cpex_output_length_guard.output_length_guard_rust import OutputLengthGuardPluginCore


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

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

async def tool_post_invoke(self, payload, context, extensions=None):
return self._core.tool_post_invoke(payload, context, extensions)


__all__ = ["OutputLengthGuardPlugin"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
description: "Rust-backed output length guard for tool outputs — truncates or blocks responses that exceed configurable character or token limits"
author: "ContextForge Contributors"
version: "0.1.0"
kind: "cpex_output_length_guard.output_length_guard.OutputLengthGuardPlugin"
available_hooks:
- "tool_post_invoke"
default_configs:
min_chars: 0
max_chars: 15000
min_tokens: 0
chars_per_token: 4
limit_mode: "character"
strategy: "truncate"
ellipsis: "…"
word_boundary: false
max_text_length: 1000000
max_structure_size: 10000
max_recursion_depth: 100
38 changes: 38 additions & 0 deletions plugins/rust/python-package/output_length_guard/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[build-system]
requires = ["maturin>=1.13.3,<2.0"]
build-backend = "maturin"

[project]
name = "cpex-output-length-guard"
dynamic = ["version"]
description = "Rust-backed output length guard plugin for MCP Gateway"
authors = [{ name = "ContextForge Contributors" }]
license = { text = "Apache-2.0" }
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"cpex>=0.1.3,<0.2",
"mcp<2",
]
classifiers = [
"Programming Language :: Rust",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]

[project.entry-points."cpex.plugins"]
output_length_guard = "cpex_output_length_guard.output_length_guard:OutputLengthGuardPlugin"

[tool.maturin]
module-name = "cpex_output_length_guard.output_length_guard_rust"
python-source = "."
features = ["pyo3/extension-module"]

[dependency-groups]
dev = [
"maturin>=1.13.3",
"pytest>=9.1.1",
"pytest-asyncio>=1.3.0",
]
Loading