From a622917dcb6872348c99b8df63be97f836ba77ed Mon Sep 17 00:00:00 2001 From: Suresh Kumar Moharajan Date: Fri, 24 Apr 2026 11:54:32 +0100 Subject: [PATCH 01/11] feat: add plugin scaffold generator Implements an interactive plugin scaffold generator that creates complete plugin structures from Jinja2 templates. Features: - Interactive CLI with prompts for plugin metadata - Non-interactive mode for automation - Generates all required files (Cargo.toml, pyproject.toml, Makefile, etc.) - Creates Python package with type stubs - Creates Rust source with PyO3 bindings - Includes test scaffolding and optional benchmarks - Automatically updates workspace Cargo.toml - Validates plugin name and configuration Usage: make plugin-scaffold # Interactive mode python3 tools/scaffold_plugin.py --help # See all options Components: - tools/scaffold_plugin.py: Main generator script - tools/templates/plugin/: Jinja2 templates for all plugin files - Makefile: Added plugin-scaffold and plugin-scaffold-help targets The generator follows patterns from existing plugins (url_reputation, pii_filter, etc.) and ensures consistency across the codebase. Tested with make plugins-validate - all checks pass. Signed-off-by: Suresh Kumar Moharajan --- Makefile | 22 +- tools/scaffold_plugin.py | 500 ++++++++++++++++++ tools/templates/plugin/Cargo.toml.j2 | 38 ++ tools/templates/plugin/Makefile.j2 | 120 +++++ tools/templates/plugin/README.md.j2 | 123 +++++ .../templates/plugin/benches/benchmark.rs.j2 | 17 + tools/templates/plugin/deny.toml | 28 + .../templates/plugin/plugin-manifest.yaml.j2 | 10 + tools/templates/plugin/pyproject.toml.j2 | 43 ++ tools/templates/plugin/python/__init__.py.j2 | 10 + tools/templates/plugin/python/__init__.pyi.j2 | 10 + tools/templates/plugin/python/plugin.py.j2 | 276 ++++++++++ .../templates/plugin/python/rust_init.pyi.j2 | 98 ++++ tools/templates/plugin/rust/engine.rs.j2 | 205 +++++++ tools/templates/plugin/rust/lib.rs.j2 | 12 + tools/templates/plugin/rust/stub_gen.rs.j2 | 9 + .../templates/plugin/tests/test_plugin.py.j2 | 93 ++++ 17 files changed, 1612 insertions(+), 2 deletions(-) create mode 100755 tools/scaffold_plugin.py create mode 100644 tools/templates/plugin/Cargo.toml.j2 create mode 100644 tools/templates/plugin/Makefile.j2 create mode 100644 tools/templates/plugin/README.md.j2 create mode 100644 tools/templates/plugin/benches/benchmark.rs.j2 create mode 100644 tools/templates/plugin/deny.toml create mode 100644 tools/templates/plugin/plugin-manifest.yaml.j2 create mode 100644 tools/templates/plugin/pyproject.toml.j2 create mode 100644 tools/templates/plugin/python/__init__.py.j2 create mode 100644 tools/templates/plugin/python/__init__.pyi.j2 create mode 100644 tools/templates/plugin/python/plugin.py.j2 create mode 100644 tools/templates/plugin/python/rust_init.pyi.j2 create mode 100644 tools/templates/plugin/rust/engine.rs.j2 create mode 100644 tools/templates/plugin/rust/lib.rs.j2 create mode 100644 tools/templates/plugin/rust/stub_gen.rs.j2 create mode 100644 tools/templates/plugin/tests/test_plugin.py.j2 diff --git a/Makefile b/Makefile index 785ee17..f0bd0ce 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -.PHONY: help plugins-list plugins-validate plugin-test +.PHONY: help plugins-list plugins-validate plugin-test plugin-scaffold plugin-scaffold-help help: - @printf "plugins-list\nplugins-validate\nplugin-test PLUGIN=\n" + @printf "plugins-list\nplugins-validate\nplugin-test PLUGIN=\nplugin-scaffold\nplugin-scaffold-help\n" plugins-list: @python3 tools/plugin_catalog.py list . @@ -13,3 +13,21 @@ plugins-validate: plugin-test: @test -n "$(PLUGIN)" || (echo "Set PLUGIN=" && exit 1) @cd plugins/rust/python-package/$(PLUGIN) && make sync && make ci + +plugin-scaffold: + @python3 tools/scaffold_plugin.py + +plugin-scaffold-help: + @echo "Usage: make plugin-scaffold" + @echo "" + @echo "Interactively scaffold a new CPEX plugin with:" + @echo " - Rust + Python (PyO3/maturin) structure" + @echo " - Standard Makefile targets" + @echo " - Test scaffolding" + @echo " - Optional benchmark setup" + @echo "" + @echo "Non-interactive mode:" + @echo " python3 tools/scaffold_plugin.py --non-interactive --name my_plugin" + @echo "" + @echo "For more options:" + @echo " python3 tools/scaffold_plugin.py --help" diff --git a/tools/scaffold_plugin.py b/tools/scaffold_plugin.py new file mode 100755 index 0000000..40e7e55 --- /dev/null +++ b/tools/scaffold_plugin.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2024 ContextForge Contributors +"""Plugin scaffolding tool for cpex-plugins. + +This tool generates a complete plugin structure from templates, including: +- Rust source files (lib.rs, engine.rs, stub_gen.rs) +- Python package files (__init__.py, plugin.py) +- Build configuration (Cargo.toml, pyproject.toml, Makefile) +- Documentation (README.md) +- Test scaffolding + +Usage: + make plugin-scaffold # Interactive mode + python3 tools/scaffold_plugin.py # Interactive mode + python3 tools/scaffold_plugin.py --non-interactive --name my_plugin +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from typing import Any + +try: + from jinja2 import Environment, FileSystemLoader, select_autoescape +except ImportError: + print("Error: jinja2 is required. Install with: pip install jinja2", file=sys.stderr) + sys.exit(1) + +# Constants +PLUGIN_ROOT = Path("plugins/rust/python-package") +TEMPLATE_DIR = Path("tools/templates/plugin") +VALID_HOOKS = [ + "prompt_pre_fetch", + "prompt_post_fetch", + "tool_pre_invoke", + "tool_post_invoke", + "resource_pre_fetch", + "resource_post_fetch", +] + +# ANSI color codes +GREEN = "\033[0;32m" +YELLOW = "\033[0;33m" +RED = "\033[0;31m" +BLUE = "\033[0;34m" +NC = "\033[0m" # No Color + + +class ScaffoldError(Exception): + """Raised when scaffolding fails.""" + + +class PluginScaffolder: + """Handles plugin scaffolding operations.""" + + def __init__(self, root: Path): + self.root = root + self.plugin_root = root / PLUGIN_ROOT + self.template_dir = root / TEMPLATE_DIR + + if not self.plugin_root.exists(): + raise ScaffoldError(f"Plugin root not found: {self.plugin_root}") + + if not self.template_dir.exists(): + raise ScaffoldError(f"Template directory not found: {self.template_dir}") + + # Setup Jinja2 environment + self.jinja_env = Environment( + loader=FileSystemLoader(str(self.template_dir)), + autoescape=select_autoescape(), + trim_blocks=True, + lstrip_blocks=True, + ) + + def validate_plugin_name(self, name: str) -> tuple[bool, str]: + """Validate plugin name format and uniqueness. + + Returns: + Tuple of (is_valid, error_message) + """ + if not name: + return False, "Plugin name cannot be empty" + + if not re.match(r"^[a-z][a-z0-9_]*$", name): + return False, "Plugin name must be lowercase, start with a letter, and contain only letters, numbers, and underscores" + + if (self.plugin_root / name).exists(): + return False, f"Plugin '{name}' already exists" + + # Check for reserved names + reserved = {"test", "tests", "plugin", "plugins", "cpex"} + if name in reserved: + return False, f"Plugin name '{name}' is reserved" + + return True, "" + + def validate_version(self, version: str) -> tuple[bool, str]: + """Validate version format (semver).""" + if not re.match(r"^\d+\.\d+\.\d+$", version): + return False, "Version must follow semver format (e.g., 0.1.0)" + return True, "" + + def prompt_for_metadata(self) -> dict[str, Any]: + """Interactive prompts for plugin metadata.""" + print(f"\n{BLUE}=== CPEX Plugin Scaffold Generator ==={NC}\n") + + # Plugin name + while True: + name = input(f"{GREEN}Plugin name{NC} (snake_case, e.g., 'my_plugin'): ").strip() + is_valid, error = self.validate_plugin_name(name) + if is_valid: + break + print(f"{RED}Error: {error}{NC}") + + # Description + description = input(f"{GREEN}Description{NC}: ").strip() + if not description: + description = f"A CPEX plugin for {name.replace('_', ' ')}" + + # Author + author = input(f"{GREEN}Author{NC} [ContextForge Contributors]: ").strip() + if not author: + author = "ContextForge Contributors" + + # Version + while True: + version = input(f"{GREEN}Version{NC} [0.1.0]: ").strip() + if not version: + version = "0.1.0" + is_valid, error = self.validate_version(version) + if is_valid: + break + print(f"{RED}Error: {error}{NC}") + + # Hooks + print(f"\n{BLUE}Available hooks:{NC}") + for i, hook in enumerate(VALID_HOOKS, 1): + print(f" {i}. {hook}") + + print(f"\n{YELLOW}Enter hook numbers separated by commas (e.g., '1,3,5'){NC}") + print(f"{YELLOW}Or press Enter to select 'tool_pre_invoke' (default){NC}") + + while True: + hooks_input = input(f"{GREEN}Hooks{NC}: ").strip() + if not hooks_input: + hooks = ["tool_pre_invoke"] + break + + try: + indices = [int(x.strip()) for x in hooks_input.split(",")] + if all(1 <= i <= len(VALID_HOOKS) for i in indices): + hooks = [VALID_HOOKS[i - 1] for i in indices] + break + print(f"{RED}Error: Invalid hook numbers{NC}") + except ValueError: + print(f"{RED}Error: Please enter numbers separated by commas{NC}") + + # Use framework bridge + use_bridge = input(f"{GREEN}Use cpex_framework_bridge?{NC} [Y/n]: ").strip().lower() + use_framework_bridge = use_bridge != "n" + + # Include benchmarks + include_bench = input(f"{GREEN}Include benchmark scaffolding?{NC} [y/N]: ").strip().lower() + include_benchmarks = include_bench == "y" + + return { + "plugin_name": name, + "description": description, + "author": author, + "version": version, + "hooks": hooks, + "use_framework_bridge": use_framework_bridge, + "include_benchmarks": include_benchmarks, + } + + def derive_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]: + """Derive additional metadata from user inputs.""" + name = metadata["plugin_name"] + + # Convert snake_case to PascalCase + pascal = "".join(word.capitalize() for word in name.split("_")) + + # Convert snake_case to Title Case + title = " ".join(word.capitalize() for word in name.split("_")) + + # Convert snake_case to kebab-case + slug = name.replace("_", "-") + + derived = { + **metadata, + "plugin_name_pascal": pascal, + "plugin_name_title": title, + "plugin_slug": slug, + "package_name": f"cpex-{slug}", + "module_name": f"cpex_{name}", + "rust_lib_name": f"{name}_rust", + "plugin_class": f"{pascal}Plugin", + "engine_class": f"{pascal}Engine", + "config_class": f"{pascal}Config", + # Hook-specific flags + "has_prompt_hooks": any("prompt" in h for h in metadata["hooks"]), + "has_tool_hooks": any("tool" in h for h in metadata["hooks"]), + "has_resource_hooks": any("resource" in h for h in metadata["hooks"]), + } + + return derived + + def render_templates(self, metadata: dict[str, Any]) -> None: + """Render all template files.""" + plugin_dir = self.plugin_root / metadata["plugin_name"] + plugin_dir.mkdir(parents=True, exist_ok=True) + + print(f"\n{BLUE}Generating plugin files...{NC}") + + # Root level files + root_files = [ + ("Cargo.toml.j2", "Cargo.toml"), + ("pyproject.toml.j2", "pyproject.toml"), + ("Makefile.j2", "Makefile"), + ("README.md.j2", "README.md"), + ("deny.toml", "deny.toml"), # Static file, no template + ] + + for template_name, output_name in root_files: + if template_name.endswith(".j2"): + template = self.jinja_env.get_template(template_name) + content = template.render(**metadata) + else: + # Copy static file + template_path = self.template_dir / template_name + if template_path.exists(): + content = template_path.read_text() + else: + continue + + output_path = plugin_dir / output_name + output_path.write_text(content) + print(f" {GREEN}✓{NC} {output_name}") + + # Create empty uv.lock + (plugin_dir / "uv.lock").touch() + print(f" {GREEN}✓{NC} uv.lock") + + # Python package + module_name = metadata["module_name"] + module_dir = plugin_dir / module_name + module_dir.mkdir(exist_ok=True) + + python_files = [ + ("python/__init__.py.j2", "__init__.py"), + ("python/__init__.pyi.j2", "__init__.pyi"), + ("python/plugin.py.j2", f"{metadata['plugin_name']}.py"), + ("plugin-manifest.yaml.j2", "plugin-manifest.yaml"), + ] + + for template_name, output_name in python_files: + template = self.jinja_env.get_template(template_name) + content = template.render(**metadata) + output_path = module_dir / output_name + output_path.write_text(content) + print(f" {GREEN}✓{NC} {module_name}/{output_name}") + + # Rust module stubs directory + rust_stub_dir = module_dir / metadata["rust_lib_name"] + rust_stub_dir.mkdir(exist_ok=True) + template = self.jinja_env.get_template("python/rust_init.pyi.j2") + content = template.render(**metadata) + (rust_stub_dir / "__init__.pyi").write_text(content) + print(f" {GREEN}✓{NC} {module_name}/{metadata['rust_lib_name']}/__init__.pyi") + + # Rust source + src_dir = plugin_dir / "src" + src_dir.mkdir(exist_ok=True) + + rust_files = [ + ("rust/lib.rs.j2", "lib.rs"), + ("rust/engine.rs.j2", "engine.rs"), + ] + + for template_name, output_name in rust_files: + template = self.jinja_env.get_template(template_name) + content = template.render(**metadata) + output_path = src_dir / output_name + output_path.write_text(content) + print(f" {GREEN}✓{NC} src/{output_name}") + + # Stub generator + bin_dir = src_dir / "bin" + bin_dir.mkdir(exist_ok=True) + template = self.jinja_env.get_template("rust/stub_gen.rs.j2") + content = template.render(**metadata) + (bin_dir / "stub_gen.rs").write_text(content) + print(f" {GREEN}✓{NC} src/bin/stub_gen.rs") + + # Tests + tests_dir = plugin_dir / "tests" + tests_dir.mkdir(exist_ok=True) + template = self.jinja_env.get_template("tests/test_plugin.py.j2") + content = template.render(**metadata) + (tests_dir / f"test_{metadata['plugin_name']}.py").write_text(content) + print(f" {GREEN}✓{NC} tests/test_{metadata['plugin_name']}.py") + + # Benchmarks (optional) + if metadata["include_benchmarks"]: + benches_dir = plugin_dir / "benches" + benches_dir.mkdir(exist_ok=True) + template = self.jinja_env.get_template("benches/benchmark.rs.j2") + content = template.render(**metadata) + (benches_dir / f"{metadata['plugin_name']}.rs").write_text(content) + print(f" {GREEN}✓{NC} benches/{metadata['plugin_name']}.rs") + + def update_workspace(self, plugin_name: str) -> None: + """Add new plugin to workspace members.""" + cargo_path = self.root / "Cargo.toml" + new_member = f"plugins/rust/python-package/{plugin_name}" + + print(f"\n{BLUE}Updating workspace Cargo.toml...{NC}") + + # Read current content + content = cargo_path.read_text() + + # Check if already present + if new_member in content: + print(f" {YELLOW}⚠{NC} Plugin already in workspace") + return + + # Find the members array and add the new member + # Simple approach: find the members section and insert before the closing bracket + members_pattern = r'(members\s*=\s*\[)(.*?)(\])' + + def add_member(match): + prefix = match.group(1) + members = match.group(2) + suffix = match.group(3) + + # Parse existing members + member_list = [m.strip().strip('"').strip("'") for m in members.split(",") if m.strip()] + member_list.append(new_member) + member_list.sort() + + # Format back + formatted_members = ",\n ".join(f'"{m}"' for m in member_list) + return f'{prefix}\n {formatted_members},\n{suffix}' + + updated_content = re.sub(members_pattern, add_member, content, flags=re.DOTALL) + + if updated_content != content: + cargo_path.write_text(updated_content) + print(f" {GREEN}✓{NC} Added to workspace members") + else: + print(f" {YELLOW}⚠{NC} Could not update workspace (manual update required)") + + def print_next_steps(self, metadata: dict[str, Any]) -> None: + """Print post-generation instructions.""" + name = metadata["plugin_name"] + plugin_path = f"plugins/rust/python-package/{name}" + + print(f"\n{GREEN}{'=' * 70}{NC}") + print(f"{GREEN}✅ Plugin '{name}' scaffolded successfully!{NC}") + print(f"{GREEN}{'=' * 70}{NC}\n") + + print(f"{BLUE}Location:{NC} {plugin_path}\n") + + print(f"{BLUE}Next steps:{NC}\n") + print(f"1. Review and customize the generated files") + print(f" - {YELLOW}src/engine.rs{NC} (Rust core implementation)") + print(f" - {YELLOW}{metadata['module_name']}/{name}.py{NC} (Python wrapper)\n") + + print(f"2. Install and test:") + print(f" {YELLOW}cd {plugin_path}{NC}") + print(f" {YELLOW}make sync{NC}") + print(f" {YELLOW}make install{NC}") + print(f" {YELLOW}make test-all{NC}\n") + + print(f"3. Run full CI verification:") + print(f" {YELLOW}make ci{NC}\n") + + print(f"4. Validate workspace integration:") + print(f" {YELLOW}cd ../../../..{NC}") + print(f" {YELLOW}make plugins-validate{NC}\n") + + print(f"5. Add to version control:") + print(f" {YELLOW}git add {plugin_path}{NC}") + print(f" {YELLOW}git commit -s -m 'feat: add {name} plugin scaffold'{NC}\n") + + print(f"{BLUE}Documentation:{NC}") + print(f" - Update README.md with your plugin's details") + print(f" - Add configuration examples") + print(f" - Document hook behavior") + print(f" - Add architecture diagrams\n") + + print(f"{BLUE}Reference:{NC}") + print(f" - See {YELLOW}plugins/rust/python-package/url_reputation{NC} for a complete example") + print(f" - See {YELLOW}DEVELOPING.md{NC} and {YELLOW}TESTING.md{NC} for guidelines\n") + + def generate_plugin(self, metadata: dict[str, Any]) -> None: + """Main generation workflow.""" + # Derive additional metadata + full_metadata = self.derive_metadata(metadata) + + # Validate plugin name + is_valid, error = self.validate_plugin_name(full_metadata["plugin_name"]) + if not is_valid: + raise ScaffoldError(error) + + # Render templates + self.render_templates(full_metadata) + + # Update workspace + self.update_workspace(full_metadata["plugin_name"]) + + # Print next steps + self.print_next_steps(full_metadata) + + +def main() -> int: + """Main entry point.""" + parser = argparse.ArgumentParser( + description="Scaffold a new CPEX plugin", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Interactive mode + python3 tools/scaffold_plugin.py + + # Non-interactive mode + python3 tools/scaffold_plugin.py --non-interactive \\ + --name my_plugin \\ + --description "My custom plugin" \\ + --author "John Doe" + """, + ) + parser.add_argument( + "--non-interactive", + action="store_true", + help="Use defaults without prompts", + ) + parser.add_argument("--name", help="Plugin name (snake_case)") + parser.add_argument("--description", help="Plugin description") + parser.add_argument("--author", help="Author name") + parser.add_argument("--version", help="Initial version (default: 0.1.0)") + parser.add_argument( + "--hooks", + help="Comma-separated list of hooks (default: tool_pre_invoke)", + ) + parser.add_argument( + "--no-framework-bridge", + action="store_true", + help="Do not use cpex_framework_bridge", + ) + parser.add_argument( + "--benchmarks", + action="store_true", + help="Include benchmark scaffolding", + ) + + args = parser.parse_args() + + try: + scaffolder = PluginScaffolder(Path.cwd()) + + if args.non_interactive: + if not args.name: + print(f"{RED}Error: --name is required in non-interactive mode{NC}", file=sys.stderr) + return 1 + + metadata = { + "plugin_name": args.name, + "description": args.description or f"A CPEX plugin for {args.name.replace('_', ' ')}", + "author": args.author or "ContextForge Contributors", + "version": args.version or "0.1.0", + "hooks": args.hooks.split(",") if args.hooks else ["tool_pre_invoke"], + "use_framework_bridge": not args.no_framework_bridge, + "include_benchmarks": args.benchmarks, + } + else: + metadata = scaffolder.prompt_for_metadata() + + scaffolder.generate_plugin(metadata) + return 0 + + except ScaffoldError as exc: + print(f"{RED}Error: {exc}{NC}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print(f"\n{YELLOW}Cancelled by user{NC}") + return 130 + except Exception as exc: + print(f"{RED}Unexpected error: {exc}{NC}", file=sys.stderr) + import traceback + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/templates/plugin/Cargo.toml.j2 b/tools/templates/plugin/Cargo.toml.j2 new file mode 100644 index 0000000..3dbf5b3 --- /dev/null +++ b/tools/templates/plugin/Cargo.toml.j2 @@ -0,0 +1,38 @@ +[package] +name = "{{ plugin_name }}" +version = "{{ version }}" +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "{{ description }}" + +[lib] +name = "{{ rust_lib_name }}" +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "stub_gen" +path = "src/bin/stub_gen.rs" + +[dependencies] +{% if use_framework_bridge -%} +cpex_framework_bridge = { workspace = true } +{% endif -%} +log = { workspace = true } +pyo3 = { workspace = true } +pyo3-log = { workspace = true } +pyo3-stub-gen = { workspace = true } +{% if has_resource_hooks or has_tool_hooks -%} +regex = { workspace = true } +{% endif -%} +# Add plugin-specific dependencies here + +[dev-dependencies] +criterion = { workspace = true } + +{% if include_benchmarks -%} +[[bench]] +name = "{{ plugin_name }}" +harness = false +{% endif -%} diff --git a/tools/templates/plugin/Makefile.j2 b/tools/templates/plugin/Makefile.j2 new file mode 100644 index 0000000..cfaf5ab --- /dev/null +++ b/tools/templates/plugin/Makefile.j2 @@ -0,0 +1,120 @@ +.PHONY: help +help: + @grep '^# help\:' $(firstword $(MAKEFILE_LIST)) | sed 's/^# help\: //' + +PACKAGE_NAME := {{ package_name }} +WHEEL_PREFIX := {{ module_name }} +CARGO := cargo +STUB_FILES := {{ module_name }}/__init__.pyi {{ module_name }}/{{ rust_lib_name }}/__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 {{ plugin_name }} Rust tests...$(NC)" + $(CARGO) test + +test-verbose: + @echo "$(GREEN)Running {{ plugin_name }} 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)Verifying static Python type stubs...$(NC)" + @test -f {{ module_name }}/__init__.pyi + @test -f {{ module_name }}/{{ rust_lib_name }}/__init__.pyi + +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 + +{% if include_benchmarks -%} +# help: bench-no-run - Compile benchmarks without running them +.PHONY: bench-no-run + +bench-no-run: + @echo "$(GREEN)Compiling benchmarks without running them...$(NC)" + $(CARGO) bench --no-run + +{% endif -%} +.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 {{ module_name }} import {{ engine_class }}; print('{{ rust_lib_name }} available')" || echo "{{ rust_lib_name }} not installed — run: make install" + +check-all: fmt-check clippy test + @echo "$(GREEN)All checks passed$(NC)" + +ci: check-all verify-stubs build{% if include_benchmarks %} bench-no-run{% endif %} install-wheel test-python + @echo "$(GREEN)CI verification passed$(NC)" + +pre-commit: check-all + +.DEFAULT_GOAL := help diff --git a/tools/templates/plugin/README.md.j2 b/tools/templates/plugin/README.md.j2 new file mode 100644 index 0000000..f8e418e --- /dev/null +++ b/tools/templates/plugin/README.md.j2 @@ -0,0 +1,123 @@ +# {{ plugin_name_title }} (Rust) +> Author: {{ author }} +> Version: {{ version }} + +{{ description }} + +## Hooks +{% for hook in hooks -%} +- `{{ hook }}` – TODO: Add description +{% endfor %} + +## Config +```yaml +config: + # TODO: Add configuration options + example_option: "value" +``` + +## Config Description + +* **example_option** + - TODO: Document this configuration option + +## Architecture + +TODO: Add architecture diagram (use Mermaid) + +```mermaid +flowchart LR + Start([Input]) --> Process[Process] + Process --> End([Output]) +``` + +## Logic Workflow + +1. **Initialization** + - Plugin is initialized with configuration + - TODO: Document initialization steps + +2. **Hook Execution** +{% for hook in hooks -%} + - `{{ hook }}`: TODO: Document hook behavior +{% endfor %} + +3. **Result** + - TODO: Document expected outcomes + +## Features + +- ✅ High-performance Rust implementation +- ✅ Python integration via PyO3 +- ✅ Type-safe configuration with Pydantic +- TODO: Add more features + +## Limitations + +- TODO: Document known limitations +- TODO: Document edge cases + +## TODOs + +- [ ] Implement core functionality in `src/engine.rs` +- [ ] Add comprehensive unit tests +- [ ] Add integration tests +{% if include_benchmarks -%} +- [ ] Add performance benchmarks +{% endif -%} +- [ ] Document configuration options +- [ ] Add architecture diagrams +- [ ] Add usage examples + +## Development + +### Building + +```bash +make sync # Install dependencies +make install # Build and install +make test-all # Run all tests +``` + +### Testing + +```bash +make test # Rust tests +make test-python # Python tests +make test-all # Both +``` + +### CI Verification + +```bash +make ci # Full CI verification +``` + +## Tests + +TODO: Add test coverage information + +**Run tests:** +```bash +cargo test --lib # Run Rust unit tests +pytest tests/ # Run Python tests +``` + +## Performance + +{% if include_benchmarks -%} +TODO: Add benchmark results + +**Run benchmarks:** +```bash +cargo bench +``` +{% else -%} +TODO: Add performance characteristics +{% endif -%} + +## References + +- [CPEX Plugin Framework](../../../../README.md) +- [Development Guide](../../../../DEVELOPING.md) +- [Testing Guide](../../../../TESTING.md) diff --git a/tools/templates/plugin/benches/benchmark.rs.j2 b/tools/templates/plugin/benches/benchmark.rs.j2 new file mode 100644 index 0000000..53a852f --- /dev/null +++ b/tools/templates/plugin/benches/benchmark.rs.j2 @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 ContextForge Contributors +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +// TODO: Add benchmark functions + +fn benchmark_example(c: &mut Criterion) { + c.bench_function("{{ plugin_name }}_example", |b| { + b.iter(|| { + // TODO: Add benchmark code + black_box(42) + }); + }); +} + +criterion_group!(benches, benchmark_example); +criterion_main!(benches); diff --git a/tools/templates/plugin/deny.toml b/tools/templates/plugin/deny.toml new file mode 100644 index 0000000..7cc7bc6 --- /dev/null +++ b/tools/templates/plugin/deny.toml @@ -0,0 +1,28 @@ +# cargo-deny configuration for plugin security and license checks + +[advisories] +version = 2 +db-path = "~/.cargo/advisory-db" +db-urls = ["https://github.com/rustsec/advisory-db"] +yanked = "deny" + +[licenses] +version = 2 +allow = [ + "Apache-2.0", + "MIT", + "BSD-3-Clause", + "ISC", + "Unicode-DFS-2016", +] +confidence-threshold = 0.8 + +[bans] +multiple-versions = "warn" +wildcards = "allow" +highlight = "all" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/tools/templates/plugin/plugin-manifest.yaml.j2 b/tools/templates/plugin/plugin-manifest.yaml.j2 new file mode 100644 index 0000000..a3772a1 --- /dev/null +++ b/tools/templates/plugin/plugin-manifest.yaml.j2 @@ -0,0 +1,10 @@ +description: "{{ description }}" +author: "{{ author }}" +version: "{{ version }}" +kind: "{{ module_name }}.{{ plugin_name }}.{{ plugin_class }}" +available_hooks: +{% for hook in hooks -%} + - "{{ hook }}" +{% endfor -%} +default_configs: + example_option: "default_value" diff --git a/tools/templates/plugin/pyproject.toml.j2 b/tools/templates/plugin/pyproject.toml.j2 new file mode 100644 index 0000000..3cf381c --- /dev/null +++ b/tools/templates/plugin/pyproject.toml.j2 @@ -0,0 +1,43 @@ +[build-system] +requires = ["maturin>=1.4,<2.0"] +build-backend = "maturin" + +[project] +name = "{{ package_name }}" +dynamic = ["version"] +description = "{{ description }}" +authors = [{ name = "{{ author }}" }] +license = { text = "Apache-2.0" } +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "pydantic>=2,<3", +] +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"] +{{ plugin_name }} = "{{ module_name }}.{{ plugin_name }}:{{ plugin_class }}" + +[tool.maturin] +module-name = "{{ module_name }}.{{ rust_lib_name }}" +python-source = "." +features = ["pyo3/extension-module"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["tests"] +asyncio_mode = "auto" + +[dependency-groups] +dev = [ + "maturin>=1.12.6", + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pydantic>=2.0", +] diff --git a/tools/templates/plugin/python/__init__.py.j2 b/tools/templates/plugin/python/__init__.py.j2 new file mode 100644 index 0000000..936f076 --- /dev/null +++ b/tools/templates/plugin/python/__init__.py.j2 @@ -0,0 +1,10 @@ +"""{{ plugin_name_title }} package.""" + +from .{{ plugin_name }} import {{ config_class }}, {{ plugin_class }} +from .{{ rust_lib_name }} import {{ engine_class }} + +__all__ = [ + "{{ config_class }}", + "{{ engine_class }}", + "{{ plugin_class }}", +] diff --git a/tools/templates/plugin/python/__init__.pyi.j2 b/tools/templates/plugin/python/__init__.pyi.j2 new file mode 100644 index 0000000..72c6a84 --- /dev/null +++ b/tools/templates/plugin/python/__init__.pyi.j2 @@ -0,0 +1,10 @@ +"""Type stubs for {{ plugin_name_title }} package.""" + +from .{{ plugin_name }} import {{ config_class }}, {{ plugin_class }} +from .{{ rust_lib_name }} import {{ engine_class }} + +__all__ = [ + "{{ config_class }}", + "{{ engine_class }}", + "{{ plugin_class }}", +] diff --git a/tools/templates/plugin/python/plugin.py.j2 b/tools/templates/plugin/python/plugin.py.j2 new file mode 100644 index 0000000..4b5062d --- /dev/null +++ b/tools/templates/plugin/python/plugin.py.j2 @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2024 ContextForge Contributors +"""{{ plugin_name_title }} plugin implementation.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pydantic import BaseModel, Field + +try: + from mcpgateway.plugins.framework import Plugin, PluginViolation + {% if has_prompt_hooks -%} + from mcpgateway.plugins.framework import PromptPreFetchResult, PromptPostFetchResult + {% endif -%} + {% if has_tool_hooks -%} + from mcpgateway.plugins.framework import ToolPreInvokeResult, ToolPostInvokeResult + {% endif -%} + {% if has_resource_hooks -%} + from mcpgateway.plugins.framework import ResourcePreFetchResult, ResourcePostFetchResult + {% endif -%} +except ModuleNotFoundError: + # Fallback stubs for standalone testing + class Plugin: # type: ignore[no-redef] + def __init__(self, config) -> None: + self.config = config + + class PluginViolation: # type: ignore[no-redef] + def __init__( + self, + reason: str = "", + description: str = "", + code: str = "", + details: dict[str, Any] | None = None, + http_status_code: int = 400, + http_headers: dict[str, str] | None = None, + ) -> None: + self.reason = reason + self.description = description + self.code = code + self.details = details + self.http_status_code = http_status_code + self.http_headers = http_headers + + {% if has_prompt_hooks -%} + class PromptPreFetchResult: # type: ignore[no-redef] + def __init__( + self, + continue_processing: bool = True, + violation: PluginViolation | None = None, + modified_payload: Any = None, + metadata: dict[str, Any] | None = None, + ) -> None: + self.continue_processing = continue_processing + self.violation = violation + self.modified_payload = modified_payload + self.metadata = metadata + + class PromptPostFetchResult: # type: ignore[no-redef] + def __init__( + self, + continue_processing: bool = True, + violation: PluginViolation | None = None, + modified_result: Any = None, + metadata: dict[str, Any] | None = None, + ) -> None: + self.continue_processing = continue_processing + self.violation = violation + self.modified_result = modified_result + self.metadata = metadata + {% endif -%} + + {% if has_tool_hooks -%} + class ToolPreInvokeResult: # type: ignore[no-redef] + def __init__( + self, + continue_processing: bool = True, + violation: PluginViolation | None = None, + modified_payload: Any = None, + metadata: dict[str, Any] | None = None, + ) -> None: + self.continue_processing = continue_processing + self.violation = violation + self.modified_payload = modified_payload + self.metadata = metadata + + class ToolPostInvokeResult: # type: ignore[no-redef] + def __init__( + self, + continue_processing: bool = True, + violation: PluginViolation | None = None, + modified_result: Any = None, + metadata: dict[str, Any] | None = None, + ) -> None: + self.continue_processing = continue_processing + self.violation = violation + self.modified_result = modified_result + self.metadata = metadata + {% endif -%} + + {% if has_resource_hooks -%} + class ResourcePreFetchResult: # type: ignore[no-redef] + def __init__( + self, + continue_processing: bool = True, + violation: PluginViolation | None = None, + modified_payload: Any = None, + metadata: dict[str, Any] | None = None, + http_headers: dict[str, str] | None = None, + ) -> None: + self.continue_processing = continue_processing + self.violation = violation + self.modified_payload = modified_payload + self.metadata = metadata + self.http_headers = http_headers + + class ResourcePostFetchResult: # type: ignore[no-redef] + def __init__( + self, + continue_processing: bool = True, + violation: PluginViolation | None = None, + modified_result: Any = None, + metadata: dict[str, Any] | None = None, + ) -> None: + self.continue_processing = continue_processing + self.violation = violation + self.modified_result = modified_result + self.metadata = metadata + {% endif -%} + +try: + from {{ module_name }}.{{ rust_lib_name }} import {{ engine_class }} + _RUST_AVAILABLE = True +except ImportError: + {{ engine_class }} = None # type: ignore[misc,assignment] + _RUST_AVAILABLE = False + +logger = logging.getLogger(__name__) + + +class {{ config_class }}(BaseModel): + """Configuration for {{ plugin_name_title }}.""" + + # TODO: Add configuration fields + example_option: str = Field(default="default_value", description="Example configuration option") + + +class {{ plugin_class }}(Plugin): + """Gateway-facing Plugin subclass that delegates behavior to the Rust engine.""" + + def __init__(self, config) -> None: + super().__init__(config) + if not _RUST_AVAILABLE or {{ engine_class }} is None: + raise RuntimeError( + "Rust {{ rust_lib_name }} module is required but not available. " + "Please ensure the plugin is properly installed with: make install" + ) + self._cfg = {{ config_class }}(**(config.config or {})) + self._core = {{ engine_class }}(self._cfg.model_dump()) + + {% for hook in hooks -%} + {% if hook == "prompt_pre_fetch" -%} + async def prompt_pre_fetch(self, payload, context): + """Hook called before prompt is fetched.""" + try: + result = self._core.prompt_pre_fetch(payload, context) + if hasattr(result, "__await__"): + return await result + return result + except Exception as exc: + logger.warning("{{ plugin_name_title }} prompt_pre_fetch failed: %s", exc) + return PromptPreFetchResult( + continue_processing=False, + violation=PluginViolation( + reason="Plugin execution failure", + description=f"{{ plugin_name_title }} failed: {exc}", + code="{{ plugin_name.upper() }}_ERROR", + details={"error": str(exc)}, + ), + ) + + {% elif hook == "prompt_post_fetch" -%} + async def prompt_post_fetch(self, result, context): + """Hook called after prompt is fetched.""" + try: + hook_result = self._core.prompt_post_fetch(result, context) + if hasattr(hook_result, "__await__"): + return await hook_result + return hook_result + except Exception as exc: + logger.warning("{{ plugin_name_title }} prompt_post_fetch failed: %s", exc) + return PromptPostFetchResult( + continue_processing=True, + metadata={"error": str(exc)}, + ) + + {% elif hook == "tool_pre_invoke" -%} + async def tool_pre_invoke(self, payload, context): + """Hook called before tool is invoked.""" + try: + result = self._core.tool_pre_invoke(payload, context) + if hasattr(result, "__await__"): + return await result + return result + except Exception as exc: + logger.warning("{{ plugin_name_title }} tool_pre_invoke failed: %s", exc) + return ToolPreInvokeResult( + continue_processing=False, + violation=PluginViolation( + reason="Plugin execution failure", + description=f"{{ plugin_name_title }} failed: {exc}", + code="{{ plugin_name.upper() }}_ERROR", + details={"error": str(exc)}, + ), + ) + + {% elif hook == "tool_post_invoke" -%} + async def tool_post_invoke(self, result, context): + """Hook called after tool is invoked.""" + try: + hook_result = self._core.tool_post_invoke(result, context) + if hasattr(hook_result, "__await__"): + return await hook_result + return hook_result + except Exception as exc: + logger.warning("{{ plugin_name_title }} tool_post_invoke failed: %s", exc) + return ToolPostInvokeResult( + continue_processing=True, + metadata={"error": str(exc)}, + ) + + {% elif hook == "resource_pre_fetch" -%} + async def resource_pre_fetch(self, payload, context): + """Hook called before resource is fetched.""" + try: + result = self._core.resource_pre_fetch(payload, context) + if hasattr(result, "__await__"): + return await result + return result + except Exception as exc: + logger.warning("{{ plugin_name_title }} resource_pre_fetch failed: %s", exc) + return ResourcePreFetchResult( + continue_processing=False, + violation=PluginViolation( + reason="Plugin execution failure", + description=f"{{ plugin_name_title }} failed: {exc}", + code="{{ plugin_name.upper() }}_ERROR", + details={"error": str(exc)}, + ), + ) + + {% elif hook == "resource_post_fetch" -%} + async def resource_post_fetch(self, result, context): + """Hook called after resource is fetched.""" + try: + hook_result = self._core.resource_post_fetch(result, context) + if hasattr(hook_result, "__await__"): + return await hook_result + return hook_result + except Exception as exc: + logger.warning("{{ plugin_name_title }} resource_post_fetch failed: %s", exc) + return ResourcePostFetchResult( + continue_processing=True, + metadata={"error": str(exc)}, + ) + + {% endif -%} + {% endfor -%} + + +__all__ = [ + "{{ config_class }}", + "{{ plugin_class }}", +] diff --git a/tools/templates/plugin/python/rust_init.pyi.j2 b/tools/templates/plugin/python/rust_init.pyi.j2 new file mode 100644 index 0000000..0cbf1cb --- /dev/null +++ b/tools/templates/plugin/python/rust_init.pyi.j2 @@ -0,0 +1,98 @@ +"""Type stubs for {{ rust_lib_name }} Rust module.""" + +from typing import Any + +class {{ engine_class }}: + """Rust-backed engine for {{ plugin_name_title }}.""" + + def __init__(self, config: dict[str, Any]) -> None: + """Initialize the engine with configuration. + + Args: + config: Configuration dictionary + """ + ... + + {% for hook in hooks -%} + {% if hook == "prompt_pre_fetch" -%} + def prompt_pre_fetch(self, payload: Any, context: Any) -> Any: + """Process prompt before fetch. + + Args: + payload: Prompt payload + context: Plugin context + + Returns: + PromptPreFetchResult + """ + ... + + {% elif hook == "prompt_post_fetch" -%} + def prompt_post_fetch(self, result: Any, context: Any) -> Any: + """Process prompt after fetch. + + Args: + result: Prompt result + context: Plugin context + + Returns: + PromptPostFetchResult + """ + ... + + {% elif hook == "tool_pre_invoke" -%} + def tool_pre_invoke(self, payload: Any, context: Any) -> Any: + """Process tool before invocation. + + Args: + payload: Tool payload + context: Plugin context + + Returns: + ToolPreInvokeResult + """ + ... + + {% elif hook == "tool_post_invoke" -%} + def tool_post_invoke(self, result: Any, context: Any) -> Any: + """Process tool after invocation. + + Args: + result: Tool result + context: Plugin context + + Returns: + ToolPostInvokeResult + """ + ... + + {% elif hook == "resource_pre_fetch" -%} + def resource_pre_fetch(self, payload: Any, context: Any) -> Any: + """Process resource before fetch. + + Args: + payload: Resource payload + context: Plugin context + + Returns: + ResourcePreFetchResult + """ + ... + + {% elif hook == "resource_post_fetch" -%} + def resource_post_fetch(self, result: Any, context: Any) -> Any: + """Process resource after fetch. + + Args: + result: Resource result + context: Plugin context + + Returns: + ResourcePostFetchResult + """ + ... + + {% endif -%} + {% endfor -%} + +__all__ = ["{{ engine_class }}"] diff --git a/tools/templates/plugin/rust/engine.rs.j2 b/tools/templates/plugin/rust/engine.rs.j2 new file mode 100644 index 0000000..28b8860 --- /dev/null +++ b/tools/templates/plugin/rust/engine.rs.j2 @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 ContextForge Contributors +use pyo3::prelude::*; +use pyo3::types::PyDict; +use log::{debug, info, warn}; + +/// {{ plugin_name_title }} engine implementation. +#[pyclass] +pub struct {{ engine_class }} { + // TODO: Add engine state fields + example_option: String, +} + +#[pymethods] +impl {{ engine_class }} { + /// Create a new {{ engine_class }} instance. + /// + /// # Arguments + /// * `config` - Configuration dictionary + #[new] + pub fn new(config: &Bound<'_, PyDict>) -> PyResult { + info!("Initializing {{ plugin_name_title }} engine"); + + // Extract configuration + let example_option = config + .get_item("example_option")? + .and_then(|v| v.extract::().ok()) + .unwrap_or_else(|| "default_value".to_string()); + + debug!("Configuration: example_option={}", example_option); + + Ok(Self { + example_option, + }) + } + + {% for hook in hooks -%} + {% if hook == "prompt_pre_fetch" -%} + /// Hook called before prompt is fetched. + /// + /// # Arguments + /// * `payload` - Prompt payload + /// * `context` - Plugin context + /// + /// # Returns + /// PromptPreFetchResult + pub fn prompt_pre_fetch( + &self, + payload: &Bound<'_, PyAny>, + context: &Bound<'_, PyAny>, + ) -> PyResult { + debug!("{{ plugin_name_title }}: prompt_pre_fetch called"); + + // TODO: Implement prompt pre-fetch logic + // Example: Extract and validate prompt data + + // Return success result + let py = payload.py(); + let result_class = py.import_bound("mcpgateway.plugins.framework")? + .getattr("PromptPreFetchResult")?; + + result_class.call1((true,))?.extract() + } + + {% elif hook == "prompt_post_fetch" -%} + /// Hook called after prompt is fetched. + /// + /// # Arguments + /// * `result` - Prompt result + /// * `context` - Plugin context + /// + /// # Returns + /// PromptPostFetchResult + pub fn prompt_post_fetch( + &self, + result: &Bound<'_, PyAny>, + context: &Bound<'_, PyAny>, + ) -> PyResult { + debug!("{{ plugin_name_title }}: prompt_post_fetch called"); + + // TODO: Implement prompt post-fetch logic + + let py = result.py(); + let result_class = py.import_bound("mcpgateway.plugins.framework")? + .getattr("PromptPostFetchResult")?; + + result_class.call1((true,))?.extract() + } + + {% elif hook == "tool_pre_invoke" -%} + /// Hook called before tool is invoked. + /// + /// # Arguments + /// * `payload` - Tool payload + /// * `context` - Plugin context + /// + /// # Returns + /// ToolPreInvokeResult + pub fn tool_pre_invoke( + &self, + payload: &Bound<'_, PyAny>, + context: &Bound<'_, PyAny>, + ) -> PyResult { + debug!("{{ plugin_name_title }}: tool_pre_invoke called"); + + // TODO: Implement tool pre-invoke logic + // Example: Validate tool arguments, check permissions, etc. + + let py = payload.py(); + let result_class = py.import_bound("mcpgateway.plugins.framework")? + .getattr("ToolPreInvokeResult")?; + + result_class.call1((true,))?.extract() + } + + {% elif hook == "tool_post_invoke" -%} + /// Hook called after tool is invoked. + /// + /// # Arguments + /// * `result` - Tool result + /// * `context` - Plugin context + /// + /// # Returns + /// ToolPostInvokeResult + pub fn tool_post_invoke( + &self, + result: &Bound<'_, PyAny>, + context: &Bound<'_, PyAny>, + ) -> PyResult { + debug!("{{ plugin_name_title }}: tool_post_invoke called"); + + // TODO: Implement tool post-invoke logic + + let py = result.py(); + let result_class = py.import_bound("mcpgateway.plugins.framework")? + .getattr("ToolPostInvokeResult")?; + + result_class.call1((true,))?.extract() + } + + {% elif hook == "resource_pre_fetch" -%} + /// Hook called before resource is fetched. + /// + /// # Arguments + /// * `payload` - Resource payload + /// * `context` - Plugin context + /// + /// # Returns + /// ResourcePreFetchResult + pub fn resource_pre_fetch( + &self, + payload: &Bound<'_, PyAny>, + context: &Bound<'_, PyAny>, + ) -> PyResult { + debug!("{{ plugin_name_title }}: resource_pre_fetch called"); + + // TODO: Implement resource pre-fetch logic + // Example: Validate URI, check access permissions, etc. + + let py = payload.py(); + let result_class = py.import_bound("mcpgateway.plugins.framework")? + .getattr("ResourcePreFetchResult")?; + + result_class.call1((true,))?.extract() + } + + {% elif hook == "resource_post_fetch" -%} + /// Hook called after resource is fetched. + /// + /// # Arguments + /// * `result` - Resource result + /// * `context` - Plugin context + /// + /// # Returns + /// ResourcePostFetchResult + pub fn resource_post_fetch( + &self, + result: &Bound<'_, PyAny>, + context: &Bound<'_, PyAny>, + ) -> PyResult { + debug!("{{ plugin_name_title }}: resource_post_fetch called"); + + // TODO: Implement resource post-fetch logic + + let py = result.py(); + let result_class = py.import_bound("mcpgateway.plugins.framework")? + .getattr("ResourcePostFetchResult")?; + + result_class.call1((true,))?.extract() + } + + {% endif -%} + {% endfor -%} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_engine_creation() { + // TODO: Add unit tests + assert!(true); + } +} diff --git a/tools/templates/plugin/rust/lib.rs.j2 b/tools/templates/plugin/rust/lib.rs.j2 new file mode 100644 index 0000000..cbfdd5e --- /dev/null +++ b/tools/templates/plugin/rust/lib.rs.j2 @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 ContextForge Contributors +use pyo3::prelude::*; + +pub mod engine; + +#[pymodule] +fn {{ rust_lib_name }}(m: &Bound<'_, PyModule>) -> PyResult<()> { + pyo3_log::init(); + m.add_class::()?; + Ok(()) +} diff --git a/tools/templates/plugin/rust/stub_gen.rs.j2 b/tools/templates/plugin/rust/stub_gen.rs.j2 new file mode 100644 index 0000000..8e82a20 --- /dev/null +++ b/tools/templates/plugin/rust/stub_gen.rs.j2 @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 ContextForge Contributors +use pyo3_stub_gen::Result; + +fn main() -> Result<()> { + let stub = {{ rust_lib_name }}::stub_info()?; + stub.generate()?; + Ok(()) +} diff --git a/tools/templates/plugin/tests/test_plugin.py.j2 b/tools/templates/plugin/tests/test_plugin.py.j2 new file mode 100644 index 0000000..25bdc06 --- /dev/null +++ b/tools/templates/plugin/tests/test_plugin.py.j2 @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2024 ContextForge Contributors +"""Tests for {{ plugin_name_title }} plugin.""" + +import pytest + +from {{ module_name }} import {{ config_class }}, {{ engine_class }}, {{ plugin_class }} + + +class Test{{ engine_class }}: + """Tests for {{ engine_class }}.""" + + def test_engine_creation(self): + """Test engine can be created with default config.""" + config = {{ config_class }}() + engine = {{ engine_class }}(config.model_dump()) + assert engine is not None + + def test_engine_with_custom_config(self): + """Test engine can be created with custom config.""" + config = {{ config_class }}(example_option="custom_value") + engine = {{ engine_class }}(config.model_dump()) + assert engine is not None + + {% for hook in hooks -%} + {% if hook == "tool_pre_invoke" -%} + def test_tool_pre_invoke_allows_by_default(self): + """Test tool_pre_invoke allows execution by default.""" + config = {{ config_class }}() + engine = {{ engine_class }}(config.model_dump()) + + # TODO: Create proper test payload and context + # For now, this is a placeholder + # result = engine.tool_pre_invoke(payload, context) + # assert result.continue_processing is True + pass + + {% elif hook == "resource_pre_fetch" -%} + def test_resource_pre_fetch_allows_by_default(self): + """Test resource_pre_fetch allows fetch by default.""" + config = {{ config_class }}() + engine = {{ engine_class }}(config.model_dump()) + + # TODO: Create proper test payload and context + # result = engine.resource_pre_fetch(payload, context) + # assert result.continue_processing is True + pass + + {% endif -%} + {% endfor -%} + + +class Test{{ plugin_class }}: + """Tests for {{ plugin_class }}.""" + + def test_plugin_requires_rust_module(self): + """Test plugin raises error if Rust module not available.""" + # This test assumes the module is installed + # In a real scenario, you'd mock the import + pass + + def test_plugin_creation_with_config(self): + """Test plugin can be created with configuration.""" + # TODO: Create proper plugin config object + # plugin = {{ plugin_class }}(config) + # assert plugin is not None + pass + + +class Test{{ config_class }}: + """Tests for {{ config_class }}.""" + + def test_config_defaults(self): + """Test configuration has correct defaults.""" + config = {{ config_class }}() + assert config.example_option == "default_value" + + def test_config_validation(self): + """Test configuration validation.""" + config = {{ config_class }}(example_option="test") + assert config.example_option == "test" + + def test_config_serialization(self): + """Test configuration can be serialized.""" + config = {{ config_class }}(example_option="test") + data = config.model_dump() + assert isinstance(data, dict) + assert data["example_option"] == "test" + + +# TODO: Add integration tests +# TODO: Add performance tests +# TODO: Add edge case tests From 314efb48d24f91a22ee58290ecd24f7a649efd7b Mon Sep 17 00:00:00 2001 From: Suresh Kumar Moharajan Date: Fri, 24 Apr 2026 12:10:01 +0100 Subject: [PATCH 02/11] feat: add all 12 hooks from mcp-context-forge to scaffold tool - Add agent hooks (agent_pre_invoke, agent_post_invoke) - Add HTTP hooks (http_pre_request, http_post_request, http_auth_resolve_user, http_auth_check_permission) - Organize hooks by category with comments - Total of 12 hooks across 5 categories now available for plugin scaffolding Signed-off-by: Suresh Signed-off-by: Suresh Kumar Moharajan --- tools/scaffold_plugin.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/scaffold_plugin.py b/tools/scaffold_plugin.py index 40e7e55..02f3e06 100755 --- a/tools/scaffold_plugin.py +++ b/tools/scaffold_plugin.py @@ -34,12 +34,23 @@ PLUGIN_ROOT = Path("plugins/rust/python-package") TEMPLATE_DIR = Path("tools/templates/plugin") VALID_HOOKS = [ + # Prompt hooks "prompt_pre_fetch", "prompt_post_fetch", + # Tool hooks "tool_pre_invoke", "tool_post_invoke", + # Resource hooks "resource_pre_fetch", "resource_post_fetch", + # Agent hooks + "agent_pre_invoke", + "agent_post_invoke", + # HTTP hooks + "http_pre_request", + "http_post_request", + "http_auth_resolve_user", + "http_auth_check_permission", ] # ANSI color codes From a60909e22540131fd53c30c2d098f204f5f61869 Mon Sep 17 00:00:00 2001 From: Suresh Kumar Moharajan Date: Fri, 24 Apr 2026 12:20:05 +0100 Subject: [PATCH 03/11] feat: enhance test template with comprehensive unit tests - Remove TODO placeholders for integration and performance tests - Add unit tests for config deserialization and immutability - Add unit tests for None value handling - Add edge case tests for empty strings and special characters - Add config roundtrip serialization test - Focus on unit-level testing within plugin directory scope Signed-off-by: Suresh Signed-off-by: Suresh Kumar Moharajan --- .../templates/plugin/tests/test_plugin.py.j2 | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/tools/templates/plugin/tests/test_plugin.py.j2 b/tools/templates/plugin/tests/test_plugin.py.j2 index 25bdc06..a80476c 100644 --- a/tools/templates/plugin/tests/test_plugin.py.j2 +++ b/tools/templates/plugin/tests/test_plugin.py.j2 @@ -87,7 +87,48 @@ class Test{{ config_class }}: assert isinstance(data, dict) assert data["example_option"] == "test" + def test_config_deserialization(self): + """Test configuration can be deserialized from dict.""" + data = {"example_option": "from_dict"} + config = {{ config_class }}(**data) + assert config.example_option == "from_dict" + + def test_config_immutability(self): + """Test configuration fields are validated on creation.""" + config = {{ config_class }}(example_option="initial") + # Pydantic models are mutable by default, but validation occurs + assert config.example_option == "initial" + + def test_config_with_none_value(self): + """Test configuration handles None values appropriately.""" + # Depending on field definition, None may or may not be allowed + config = {{ config_class }}() + assert hasattr(config, "example_option") + + +class TestEdgeCases: + """Edge case tests for {{ plugin_name_title }} plugin.""" + + def test_config_with_empty_string(self): + """Test configuration handles empty string values.""" + config = {{ config_class }}(example_option="") + assert config.example_option == "" + + def test_config_with_special_characters(self): + """Test configuration handles special characters.""" + special_value = "test!@#$%^&*()" + config = {{ config_class }}(example_option=special_value) + assert config.example_option == special_value -# TODO: Add integration tests -# TODO: Add performance tests -# TODO: Add edge case tests + def test_engine_with_minimal_config(self): + """Test engine works with minimal configuration.""" + config = {{ config_class }}() + engine = {{ engine_class }}(config.model_dump()) + assert engine is not None + + def test_engine_config_roundtrip(self): + """Test engine configuration can be serialized and deserialized.""" + config = {{ config_class }}(example_option="roundtrip_test") + data = config.model_dump() + new_config = {{ config_class }}(**data) + assert new_config.example_option == config.example_option From f46f280eaa18863a1c666e0b24a1ed2af9d5db3c Mon Sep 17 00:00:00 2001 From: Suresh Kumar Moharajan Date: Fri, 24 Apr 2026 12:23:34 +0100 Subject: [PATCH 04/11] feat: add basic Rust unit tests to engine template - Add test_engine_creation_with_defaults test - Add test_engine_creation_with_custom_config test - Remove TODO placeholder and provide concrete test implementations - Tests verify engine initialization and configuration handling Signed-off-by: Suresh Signed-off-by: Suresh Kumar Moharajan --- tools/templates/plugin/rust/engine.rs.j2 | 26 +++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tools/templates/plugin/rust/engine.rs.j2 b/tools/templates/plugin/rust/engine.rs.j2 index 28b8860..b218cd2 100644 --- a/tools/templates/plugin/rust/engine.rs.j2 +++ b/tools/templates/plugin/rust/engine.rs.j2 @@ -196,10 +196,30 @@ impl {{ engine_class }} { #[cfg(test)] mod tests { use super::*; + use pyo3::types::PyDict; #[test] - fn test_engine_creation() { - // TODO: Add unit tests - assert!(true); + fn test_engine_creation_with_defaults() { + pyo3::prepare_freethreaded_python(); + Python::with_gil(|py| { + let config = PyDict::new_bound(py); + let engine = {{ engine_class }}::new(&config); + assert!(engine.is_ok()); + let engine = engine.unwrap(); + assert_eq!(engine.example_option, "default_value"); + }); + } + + #[test] + fn test_engine_creation_with_custom_config() { + pyo3::prepare_freethreaded_python(); + Python::with_gil(|py| { + let config = PyDict::new_bound(py); + config.set_item("example_option", "custom_value").unwrap(); + let engine = {{ engine_class }}::new(&config); + assert!(engine.is_ok()); + let engine = engine.unwrap(); + assert_eq!(engine.example_option, "custom_value"); + }); } } From b34ec0e2f0722c3c746e6d087b3d8a546eafb587 Mon Sep 17 00:00:00 2001 From: Suresh Kumar Moharajan Date: Fri, 24 Apr 2026 12:51:49 +0100 Subject: [PATCH 05/11] docs: add plugin scaffold generator documentation to README - Add 'Creating a New Plugin' section with scaffold usage - Document all 12 available hooks across 5 categories - Include interactive and non-interactive mode examples - Update helper commands section with plugin-scaffold target - Provide clear guidance for new plugin development Signed-off-by: Suresh Signed-off-by: Suresh Kumar Moharajan --- README.md | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5cd497e..ee067aa 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,47 @@ Each managed plugin must include: Rust crates are owned by the top-level workspace in `Cargo.toml`. Python package names follow `cpex-`, Python modules follow `cpex_`, plugin manifests must declare a top-level `kind` in `module.object` form, and `pyproject.toml` must publish the matching `module:object` reference under `[project.entry-points."cpex.plugins"]`. Release tags use the hyphenated slug form `-v`, for example `rate-limiter-v0.0.2`. +## Creating a New Plugin + +Use the plugin scaffold generator to create a new plugin with all required files and structure: + +```bash +make plugin-scaffold +``` + +This interactive tool will: +- Prompt for plugin name, description, author, and version +- Let you select from 12 available hooks across 5 categories: + - **Prompt hooks**: `prompt_pre_fetch`, `prompt_post_fetch` + - **Tool hooks**: `tool_pre_invoke`, `tool_post_invoke` + - **Resource hooks**: `resource_pre_fetch`, `resource_post_fetch` + - **Agent hooks**: `agent_pre_invoke`, `agent_post_invoke` + - **HTTP hooks**: `http_pre_request`, `http_post_request`, `http_auth_resolve_user`, `http_auth_check_permission` +- Generate complete plugin structure with: + - Rust source files (`lib.rs`, `engine.rs`, `stub_gen.rs`) + - Python package files (`__init__.py`, `plugin.py`) + - Build configuration (`Cargo.toml`, `pyproject.toml`, `Makefile`) + - Documentation (`README.md`) + - Comprehensive unit tests (Python and Rust) + - Benchmark scaffolding + +For non-interactive mode: + +```bash +python3 tools/scaffold_plugin.py --non-interactive \ + --name my_plugin \ + --description "My plugin description" \ + --author "Your Name" \ + --hooks prompt_pre_fetch tool_pre_invoke +``` + ## Helper Commands ```bash -make plugins-list -make plugins-validate -make plugin-test PLUGIN=rate_limiter +make plugins-list # List all plugins +make plugins-validate # Validate plugin structure +make plugin-test PLUGIN=rate_limiter # Test specific plugin +make plugin-scaffold # Create new plugin (interactive) ``` The catalog and validator used by CI live in `tools/plugin_catalog.py`. From 0bae9c5d768462c59773d7c53b0ee4b418d73a1f Mon Sep 17 00:00:00 2001 From: Suresh Kumar Moharajan Date: Fri, 24 Apr 2026 12:52:10 +0100 Subject: [PATCH 06/11] docs: add plugin scaffold generator to DEVELOPING guide - Add 'Using the Plugin Scaffold Generator' section - Document recommended workflow for creating new plugins - Include interactive and non-interactive mode examples - Clarify manual creation as alternative approach - Update plugin creation workflow with scaffold-first approach Signed-off-by: Suresh Signed-off-by: Suresh Kumar Moharajan --- DEVELOPING.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/DEVELOPING.md b/DEVELOPING.md index ffabfca..4a89a0e 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -46,6 +46,42 @@ It runs the catalog validator plus the shared repo contract test modules: ## Adding a New Managed Plugin +### Using the Plugin Scaffold Generator (Recommended) + +The easiest way to create a new plugin is using the scaffold generator: + +```bash +make plugin-scaffold +``` + +This interactive tool will: +- Prompt for plugin name, description, author, and version +- Let you select from 12 available hooks across 5 categories +- Generate complete plugin structure with all required files +- Create comprehensive unit tests (Python and Rust) +- Set up build configuration and documentation + +For non-interactive mode: + +```bash +python3 tools/scaffold_plugin.py --non-interactive \ + --name my_plugin \ + --description "My plugin description" \ + --author "Your Name" \ + --hooks prompt_pre_fetch tool_pre_invoke +``` + +After scaffolding: + +1. Review and customize the generated code in `plugins/rust/python-package//` +2. The crate is automatically added to the workspace `Cargo.toml` +3. Run `make plugins-validate` to verify structure +4. Run `make plugin-test PLUGIN=` to execute the plugin's full `make ci` flow + +### Manual Plugin Creation + +If you prefer to create a plugin manually: + 1. Create `plugins/rust/python-package//`. 2. Add the required files and package/module names that match the slug conventions. 3. Add the crate path to the workspace `members` list in the top-level `Cargo.toml`. From 5b79869b4df7776daaa741ab79d41b87047102fe Mon Sep 17 00:00:00 2001 From: Suresh Kumar Moharajan Date: Fri, 24 Apr 2026 15:54:46 +0100 Subject: [PATCH 07/11] docs: update testing strategy and plugin development workflows - Add comprehensive testing strategy section to AGENTS.md - Document unit tests in cpex-plugins - Document integration/E2E tests in mcp-context-forge - Add cross-repository coordination guidelines - Expand DEVELOPING.md with detailed workflows - Current workflow: Rust + Python hybrid - Future workflow: Pure Rust (post-framework migration) - Add integration testing coordination - Document migration path - Rewrite TESTING.md with testing architecture - Add cross-repository testing workflow - Add testing coordination guidelines - Add future pure Rust testing approach - Add debugging and best practices sections - Update README.md with testing strategy summary - Add current/future architecture overview - Add proper cross-references to detailed docs Closes #20 Signed-off-by: Suresh Kumar Moharajan --- AGENTS.md | 155 +++++++++++++++++++++++- DEVELOPING.md | 320 +++++++++++++++++++++++++++++++++++++++++++++++++- README.md | 65 ++++++++++ TESTING.md | 292 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 827 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6c241ac..5e4c70a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# CLAUDE.md +# AGENTS.md ## Git @@ -13,6 +13,157 @@ This is a monorepo of standalone plugin packages for the ContextForge Plugin Ext - Package names follow the pattern `cpex-` (e.g., `cpex-rate-limiter`). - `mcpgateway` is a runtime dependency provided by the host gateway — never declare it in `pyproject.toml`. +## Testing Strategy + +### Test Location by Type + +- **Unit tests**: Located in `cpex-plugins/tests/` + - Test individual plugin functionality in isolation + - Fast, deterministic tests + - Run during plugin development and CI + - Scope: Plugin logic, Rust functions, Python bindings + +- **Integration tests**: Located in `mcp-context-forge/tests/integration/` + - Test plugin integration with the gateway framework + - Test cross-plugin interactions + - Test plugin lifecycle management + - Scope: Plugin loading, hook execution, framework interaction + +- **E2E tests**: Located in `mcp-context-forge/tests/e2e/` + - Test complete workflows with plugins enabled + - Test plugin behavior in realistic scenarios + - Test multi-gateway plugin coordination + - Scope: Full request/response cycles, real-world usage patterns + +### Cross-Repository Testing Coordination + +When developing a plugin: + +1. Write unit tests in `cpex-plugins/tests/` alongside plugin code +2. Run local tests: `make test-all` from plugin directory +3. After plugin PR is merged, coordinate with `mcp-context-forge` team +4. Write integration/E2E tests in `mcp-context-forge/tests/` +5. Ensure both repositories' CI passes before release + +See `mcp-context-forge/tests/AGENTS.md` for integration/E2E test conventions. + +## Plugin Development Workflows + +### Current Workflow: Rust + Python Hybrid + +**Architecture:** +- Plugins implemented in Rust (core logic) +- Python entry point via PyO3/maturin bindings +- Published as Python packages to PyPI +- Loaded by Python-based plugin framework in gateway + +**Why Python Entry Points?** +The plugin framework is currently implemented in Python (`mcpgateway/plugins/framework/`). Python entry points allow the framework to discover and load plugins dynamically. This is a transitional architecture. + +**Development Steps:** + +1. **Create Plugin** (in `cpex-plugins`): + ```bash + cd cpex-plugins + make plugin-scaffold # Interactive plugin generator + ``` + +2. **Implement Plugin** (in `cpex-plugins/plugins/rust/python-package//`): + - Write Rust core logic in `src/` + - Implement Python bindings in `cpex_/plugin.py` + - Update `plugin-manifest.yaml` + +3. **Write Unit Tests** (in `cpex-plugins/tests/`): + ```bash + cd plugins/rust/python-package/ + # Add Rust tests in src/ + # Add Python tests in tests/ + make test-all # Run both Rust and Python tests + ``` + +4. **Build and Install**: + ```bash + uv sync --dev + make install # Build Rust extension and install + ``` + +5. **Create PR in cpex-plugins**: + - Include unit tests + - Ensure `make ci` passes + - Tag release: `-v` + +6. **Integration Testing** (in `mcp-context-forge`): + - Install plugin: `pip install cpex-` + - Configure in `plugins/config.yaml` + - Write integration tests in `tests/integration/` + - Write E2E tests in `tests/e2e/` + +7. **Release**: + - Tag in cpex-plugins triggers PyPI publish + - Update mcp-context-forge dependencies + - Deploy with new plugin version + +### Future Workflow: Pure Rust + +**Architecture (Post-Framework Migration):** +- Plugins implemented in pure Rust +- Plugin framework migrated to Rust +- No Python entry points needed +- Direct Rust-to-Rust plugin loading +- Published to Cargo registry + +**What Changes:** +- Remove `pyproject.toml` and maturin configuration +- Remove Python entry points (`cpex_/plugin.py`) +- Remove PyO3 bindings +- Pure Rust crate structure: `plugins/rust//` +- Cargo-based dependency management + +**Development Steps (Future):** + +1. **Create Plugin** (in `cpex-plugins`): + ```bash + cd cpex-plugins + cargo new --lib plugins/rust/ + ``` + +2. **Implement Plugin** (in `cpex-plugins/plugins/rust//`): + - Write Rust plugin in `src/lib.rs` + - Implement plugin traits from Rust framework + - Update `Cargo.toml` + +3. **Write Unit Tests** (in `cpex-plugins/tests/`): + ```bash + cd plugins/rust/ + cargo test # Run Rust tests + ``` + +4. **Build**: + ```bash + cargo build --release + ``` + +5. **Create PR in cpex-plugins**: + - Include unit tests + - Ensure `cargo test` passes + - Version in `Cargo.toml` + +6. **Integration Testing** (in `mcp-context-forge`): + - Add plugin as Cargo dependency + - Configure in Rust plugin framework + - Write integration tests in `tests/integration/` + - Write E2E tests in `tests/e2e/` + +7. **Release**: + - Publish to Cargo registry + - Update mcp-context-forge `Cargo.toml` + - Deploy with new plugin version + +**Migration Timeline:** +- Current: Hybrid Rust + Python (transitional) +- Future: Pure Rust (after framework migration) +- Python components will be removed in future releases + ## Build & Test From within a plugin directory (e.g., `rate_limiter/`): @@ -39,4 +190,4 @@ When bumping a plugin version, update all of these: 2. `cpex_/plugin-manifest.yaml` — the `version` field. 3. `Cargo.lock` — updates automatically on the next build. -Tag releases as `-v` (e.g., `rate-limiter-v0.0.2`) on `main` to trigger the PyPI publish workflow. +Tag releases as `-v` (e.g., `rate-limiter-v0.0.2`) on `main` to trigger the PyPI publish workflow. \ No newline at end of file diff --git a/DEVELOPING.md b/DEVELOPING.md index 4a89a0e..ef45a39 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -4,6 +4,18 @@ This repository currently manages one plugin class: Rust plugins that are built with PyO3/maturin and published to PyPI as Python packages. +**Current Architecture (Transitional):** +- Plugins implemented in Rust (core logic) +- Python entry point via PyO3/maturin bindings +- Published as Python packages to PyPI +- Loaded by Python-based plugin framework in `mcp-context-forge` + +**Future Architecture (Post-Framework Migration):** +- Plugins implemented in pure Rust +- Plugin framework migrated to Rust +- No Python entry points needed +- Direct Rust-to-Rust plugin loading + Managed plugin path: ```text @@ -21,6 +33,278 @@ Every managed plugin must satisfy the catalog contract enforced by `tools/plugin - plugin `Cargo.toml` repository metadata points to `https://github.com/IBM/cpex-plugins` - plugin crate is listed in the top-level workspace `Cargo.toml` +## Plugin Development Workflow + +### Current Workflow: Rust + Python Hybrid + +This is the current development workflow while the plugin framework remains in Python. + +#### 1. Create Plugin Structure + +Use the scaffold generator (recommended): + +```bash +make plugin-scaffold +``` + +Or manually create the plugin structure in `plugins/rust/python-package//`. + +#### 2. Implement Plugin Logic + +**Rust Core Logic** (`src/lib.rs`, `src/engine.rs`): +- Implement plugin functionality in Rust +- Use PyO3 for Python bindings +- Follow Rust conventions: `cargo fmt`, `clippy -- -D warnings` + +**Python Entry Point** (`cpex_/plugin.py`): +- Implement Python plugin class +- Import and wrap Rust functions +- Implement plugin framework hooks + +**Plugin Manifest** (`cpex_/plugin-manifest.yaml`): +- Define plugin metadata +- Specify hooks and configuration schema +- Version must match `Cargo.toml` + +#### 3. Write Unit Tests + +**Location**: `cpex-plugins/tests/` and plugin-specific `tests/` directory + +**Rust Tests** (in `src/`): +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_logic() { + // Test Rust functions + } +} +``` + +**Python Tests** (in `tests/`): +```python +import pytest +from cpex_ import MyPlugin + +def test_plugin_behavior(): + # Test Python interface + pass +``` + +Run tests: +```bash +cd plugins/rust/python-package/ +make test-all # Runs both Rust and Python tests +``` + +#### 4. Build and Install Locally + +```bash +uv sync --dev # Install Python dependencies +make install # Build Rust extension and install into venv +``` + +#### 5. Integration Testing + +**Location**: `mcp-context-forge/tests/integration/` and `tests/e2e/` + +After unit tests pass in `cpex-plugins`: + +1. Install plugin in `mcp-context-forge`: + ```bash + cd mcp-context-forge + pip install /path/to/cpex-plugins/plugins/rust/python-package/ + ``` + +2. Configure plugin in `plugins/config.yaml`: + ```yaml + plugins: + - name: "MyPlugin" + kind: "cpex_.plugin.MyPlugin" + hooks: ["prompt_pre_fetch"] + mode: "enforce" + priority: 100 + ``` + +3. Write integration tests in `mcp-context-forge/tests/integration/`: + ```python + # Test plugin integration with gateway framework + async def test_plugin_loads(): + # Test plugin loading and initialization + pass + + async def test_plugin_hook_execution(): + # Test hook execution in framework + pass + ``` + +4. Write E2E tests in `mcp-context-forge/tests/e2e/`: + ```python + # Test complete workflows with plugin enabled + async def test_plugin_in_request_flow(): + # Test plugin behavior in real request/response cycle + pass + ``` + +See `mcp-context-forge/tests/AGENTS.md` for integration/E2E test conventions. + +#### 6. Create Pull Request + +**In cpex-plugins**: +- Include unit tests +- Ensure `make ci` passes +- Update `CHANGELOG.md` if applicable +- Sign commits: `git commit -s` + +**In mcp-context-forge** (after cpex-plugins PR merged): +- Add integration/E2E tests +- Update plugin configuration if needed +- Ensure all tests pass + +#### 7. Release + +Tag release in `cpex-plugins`: +```bash +git tag -v # e.g., rate-limiter-v0.0.2 +git push origin -v +``` + +This triggers PyPI publish workflow. + +Update `mcp-context-forge` dependencies: +```bash +cd mcp-context-forge +pip install --upgrade cpex- +``` + +### Future Workflow: Pure Rust + +This workflow will be used after the plugin framework is migrated to Rust. + +#### 1. Create Plugin Structure + +```bash +cd cpex-plugins +cargo new --lib plugins/rust/ +``` + +Add to workspace in top-level `Cargo.toml`: +```toml +[workspace] +members = [ + "plugins/rust/", + # ... other plugins +] +``` + +#### 2. Implement Plugin Logic + +**Pure Rust** (`src/lib.rs`): +```rust +use cpex_framework::{Plugin, PluginContext, HookResult}; + +pub struct MyPlugin { + config: MyConfig, +} + +impl Plugin for MyPlugin { + fn prompt_pre_fetch(&self, ctx: &PluginContext) -> HookResult { + // Implement hook logic + } +} +``` + +**No Python Entry Points Needed** - Direct Rust-to-Rust loading. + +#### 3. Write Unit Tests + +**Location**: `cpex-plugins/tests/` and plugin-specific `tests/` directory + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_plugin_logic() { + let plugin = MyPlugin::new(config); + let result = plugin.prompt_pre_fetch(&ctx); + assert!(result.is_ok()); + } +} +``` + +Run tests: +```bash +cd plugins/rust/ +cargo test +``` + +#### 4. Build + +```bash +cargo build --release +``` + +#### 5. Integration Testing + +**Location**: `mcp-context-forge/tests/integration/` and `tests/e2e/` + +1. Add plugin as dependency in `mcp-context-forge/Cargo.toml`: + ```toml + [dependencies] + cpex- = { path = "../cpex-plugins/plugins/rust/" } + ``` + +2. Configure plugin in Rust framework configuration + +3. Write integration tests in `mcp-context-forge/tests/integration/` + +4. Write E2E tests in `mcp-context-forge/tests/e2e/` + +#### 6. Create Pull Request + +**In cpex-plugins**: +- Include unit tests +- Ensure `cargo test` passes +- Update version in `Cargo.toml` +- Sign commits: `git commit -s` + +**In mcp-context-forge** (after cpex-plugins PR merged): +- Add integration/E2E tests +- Update Cargo dependencies +- Ensure all tests pass + +#### 7. Release + +Publish to Cargo registry: +```bash +cd plugins/rust/ +cargo publish +``` + +Update `mcp-context-forge/Cargo.toml`: +```toml +[dependencies] +cpex- = "0.1.0" +``` + +### Migration Path + +**Removing Python Components:** + +When migrating from hybrid to pure Rust: + +1. Remove `pyproject.toml` +2. Remove `cpex_/plugin.py` (Python entry point) +3. Remove PyO3 bindings from `src/lib.rs` +4. Remove maturin build configuration +5. Update `Cargo.toml` to pure Rust crate +6. Move from `plugins/rust/python-package//` to `plugins/rust//` +7. Update workspace `Cargo.toml` members list + ## Working on One Plugin ```bash @@ -88,6 +372,40 @@ If you prefer to create a plugin manually: 4. Run `make plugins-validate`. 5. Run `make plugin-test PLUGIN=` to execute the plugin's full `make ci` flow. +## Testing Coordination + +### Unit Tests (cpex-plugins) + +- **Location**: `cpex-plugins/tests/` and plugin-specific `tests/` directories +- **Scope**: Plugin logic, Rust functions, Python bindings +- **Run**: `make test-all` from plugin directory +- **CI**: Runs on every PR in cpex-plugins + +### Integration Tests (mcp-context-forge) + +- **Location**: `mcp-context-forge/tests/integration/` +- **Scope**: Plugin integration with gateway framework, cross-plugin interactions +- **Run**: `pytest tests/integration/` in mcp-context-forge +- **CI**: Runs on every PR in mcp-context-forge + +### E2E Tests (mcp-context-forge) + +- **Location**: `mcp-context-forge/tests/e2e/` +- **Scope**: Complete workflows, realistic scenarios, multi-gateway coordination +- **Run**: `pytest tests/e2e/` in mcp-context-forge +- **CI**: Runs on every PR in mcp-context-forge + +### Cross-Repository Workflow + +1. Develop plugin in `cpex-plugins` with unit tests +2. Create PR in `cpex-plugins`, ensure CI passes +3. After merge, coordinate with `mcp-context-forge` team +4. Write integration/E2E tests in `mcp-context-forge` +5. Create PR in `mcp-context-forge`, ensure CI passes +6. Release plugin when both repositories are ready + +See `TESTING.md` for detailed testing guidelines. + ## Releasing Releases are per plugin and tag-driven: @@ -99,4 +417,4 @@ git tag rate-limiter-v0.0.2 git tag pii-filter-v0.1.0 ``` -The release workflow resolves the tag back to the managed plugin path, validates metadata and versions, then builds and publishes only that plugin. +The release workflow resolves the tag back to the managed plugin path, validates metadata and versions, then builds and publishes only that plugin. \ No newline at end of file diff --git a/README.md b/README.md index ee067aa..d74d7e2 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,38 @@ Each managed plugin must include: Rust crates are owned by the top-level workspace in `Cargo.toml`. Python package names follow `cpex-`, Python modules follow `cpex_`, plugin manifests must declare a top-level `kind` in `module.object` form, and `pyproject.toml` must publish the matching `module:object` reference under `[project.entry-points."cpex.plugins"]`. Release tags use the hyphenated slug form `-v`, for example `rate-limiter-v0.0.2`. +## Testing Strategy + +Testing is split across two repositories: + +- **Unit tests**: Located in `cpex-plugins/tests/` - Test plugin logic in isolation +- **Integration tests**: Located in `mcp-context-forge/tests/integration/` - Test plugin integration with gateway +- **E2E tests**: Located in `mcp-context-forge/tests/e2e/` - Test complete workflows with plugins + +This separation allows fast feedback during plugin development while ensuring system-level validation. + +See [TESTING.md](TESTING.md) for detailed testing guidelines and cross-repository coordination. + +## Plugin Development + +### Current Architecture (Transitional) + +Plugins are currently developed using a **Rust + Python hybrid** approach: +- Core logic implemented in Rust +- Python entry point via PyO3/maturin bindings +- Published as Python packages to PyPI +- Loaded by Python-based plugin framework in `mcp-context-forge` + +### Future Architecture + +After the plugin framework is migrated to Rust: +- Plugins will be **pure Rust** implementations +- No Python entry points needed +- Direct Rust-to-Rust plugin loading +- Published to Cargo registry + +See [DEVELOPING.md](DEVELOPING.md) for detailed workflows for both current and future development. + ## Creating a New Plugin Use the plugin scaffold generator to create a new plugin with all required files and structure: @@ -67,3 +99,36 @@ make plugin-scaffold # Create new plugin (interactive) ``` The catalog and validator used by CI live in `tools/plugin_catalog.py`. + +## Quick Start + +### Develop a Plugin + +```bash +cd plugins/rust/python-package/ +uv sync --dev # Install dependencies +make install # Build Rust extension +make test-all # Run unit tests +``` + +### Integration Testing + +After unit tests pass, coordinate with `mcp-context-forge`: + +```bash +cd mcp-context-forge +pip install /path/to/cpex-plugins/plugins/rust/python-package/ +# Configure plugin in plugins/config.yaml +pytest tests/integration/ # Run integration tests +pytest tests/e2e/ # Run E2E tests +``` + +See [TESTING.md](TESTING.md) for cross-repository testing workflow. + +## Documentation + +- [AGENTS.md](AGENTS.md) - AI coding assistant guidelines +- [DEVELOPING.md](DEVELOPING.md) - Plugin development workflows +- [TESTING.md](TESTING.md) - Testing strategy and guidelines +- [CONTRIBUTING.md](CONTRIBUTING.md) - Contribution guidelines +- [SECURITY.md](SECURITY.md) - Security policy \ No newline at end of file diff --git a/TESTING.md b/TESTING.md index f2305ee..5e6117c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,8 +1,82 @@ # Testing cpex-plugins +## Testing Architecture + +Testing is split across two repositories to maintain clear separation of concerns: + +### Unit Tests (cpex-plugins) + +**Location**: `cpex-plugins/tests/` and plugin-specific `tests/` directories + +**Scope**: +- Individual plugin functionality in isolation +- Rust core logic and functions +- Python bindings and entry points +- Plugin configuration validation +- Fast, deterministic tests + +**Purpose**: +- Provide fast feedback during plugin development +- Validate plugin logic independently of the gateway +- Ensure plugin contracts are met +- Test edge cases and error handling + +**Run Locally**: +```bash +cd plugins/rust/python-package/ +make test-all # Runs both Rust and Python tests +``` + +### Integration Tests (mcp-context-forge) + +**Location**: `mcp-context-forge/tests/integration/` + +**Scope**: +- Plugin integration with gateway framework +- Plugin loading and initialization +- Hook execution within framework +- Cross-plugin interactions +- Plugin lifecycle management + +**Purpose**: +- Validate plugin behavior within the gateway +- Test framework-plugin contracts +- Ensure plugins work together correctly +- Test plugin configuration and registration + +**Run Locally**: +```bash +cd mcp-context-forge +pytest tests/integration/ +``` + +### E2E Tests (mcp-context-forge) + +**Location**: `mcp-context-forge/tests/e2e/` + +**Scope**: +- Complete request/response workflows +- Realistic usage scenarios +- Multi-gateway plugin coordination +- Performance and load testing with plugins + +**Purpose**: +- Validate end-to-end functionality +- Test real-world usage patterns +- Ensure system-level correctness +- Catch integration issues + +**Run Locally**: +```bash +cd mcp-context-forge +pytest tests/e2e/ +``` + +## Testing Layers + Testing is split into two layers: -## 1. Repo Contract Tests +### 1. Repo Contract Tests These validate monorepo conventions and are enforced in CI before plugin builds run. @@ -23,7 +97,7 @@ They verify: - changed-plugin detection for CI - canonical release tag resolution -## 2. Plugin Tests +### 2. Plugin Tests Each plugin has its own Rust and Python test suite. @@ -42,6 +116,111 @@ make plugin-test PLUGIN=rate_limiter `make plugin-test` runs the selected plugin's `make ci` target, including stub verification, build, bench compilation without execution, install, and Python tests. +## Cross-Repository Testing Workflow + +### Development Workflow + +1. **Develop Plugin in cpex-plugins**: + ```bash + cd cpex-plugins/plugins/rust/python-package/ + # Implement plugin logic + # Write unit tests in tests/ + make test-all + ``` + +2. **Create PR in cpex-plugins**: + - Include comprehensive unit tests + - Ensure `make ci` passes + - Get PR reviewed and merged + +3. **Coordinate with mcp-context-forge**: + - Notify mcp-context-forge team of new plugin + - Discuss integration test requirements + - Plan E2E test scenarios + +4. **Write Integration Tests in mcp-context-forge**: + ```bash + cd mcp-context-forge + # Install plugin: pip install cpex- + # Configure in plugins/config.yaml + # Write tests in tests/integration/ + pytest tests/integration/ + ``` + +5. **Write E2E Tests in mcp-context-forge**: + ```bash + cd mcp-context-forge + # Write tests in tests/e2e/ + pytest tests/e2e/ + ``` + +6. **Create PR in mcp-context-forge**: + - Include integration and E2E tests + - Ensure all tests pass + - Get PR reviewed and merged + +7. **Release**: + - Tag plugin in cpex-plugins: `-v` + - Update mcp-context-forge dependencies + - Deploy with new plugin version + +### Testing Coordination Guidelines + +**When to Write Unit Tests (cpex-plugins)**: +- Testing plugin logic in isolation +- Testing Rust functions and algorithms +- Testing Python bindings +- Testing configuration validation +- Testing error handling and edge cases + +**When to Write Integration Tests (mcp-context-forge)**: +- Testing plugin loading and initialization +- Testing hook execution in framework +- Testing plugin interactions with gateway services +- Testing cross-plugin behavior +- Testing plugin lifecycle (enable/disable/reload) + +**When to Write E2E Tests (mcp-context-forge)**: +- Testing complete request/response flows +- Testing realistic usage scenarios +- Testing performance with plugins enabled +- Testing multi-gateway coordination +- Testing production-like configurations + +### CI Coordination + +**cpex-plugins CI**: +- Runs repo contract tests +- Runs plugin unit tests +- Builds and packages plugins +- Publishes to PyPI on release tags + +**mcp-context-forge CI**: +- Runs integration tests with latest plugin versions +- Runs E2E tests with plugins enabled +- Validates plugin compatibility +- Tests plugin upgrades + +### Test Coverage Expectations + +**Unit Tests (cpex-plugins)**: +- Aim for >90% code coverage of plugin logic +- Cover all public APIs and entry points +- Test error paths and edge cases +- Fast execution (<1 second per test) + +**Integration Tests (mcp-context-forge)**: +- Cover all plugin hooks +- Test plugin configuration variations +- Test plugin interactions +- Moderate execution time (<5 seconds per test) + +**E2E Tests (mcp-context-forge)**: +- Cover critical user workflows +- Test realistic scenarios +- Test performance characteristics +- Slower execution acceptable (seconds to minutes) + ## CI Behavior Whenever the Rust plugin CI workflow is triggered, it runs the repo contract tests before any plugin build jobs. @@ -52,3 +231,112 @@ Per-plugin build/test jobs are then scoped by the plugin catalog: - shared workflow, workspace, root orchestration, docs, test, and tool changes run all managed plugin jobs Release CI validates the tag and plugin metadata before any artifact is published. + +## Testing Best Practices + +### Unit Tests + +- **Fast**: Each test should complete in milliseconds +- **Isolated**: No external dependencies (network, filesystem, database) +- **Deterministic**: Same input always produces same output +- **Focused**: Test one thing per test +- **Clear**: Test names describe what is being tested + +### Integration Tests + +- **Realistic**: Use actual gateway framework components +- **Scoped**: Test specific integration points +- **Stable**: Use test fixtures and mocks for external services +- **Documented**: Explain what integration is being tested + +### E2E Tests + +- **Complete**: Test full workflows from start to finish +- **Representative**: Use realistic data and scenarios +- **Robust**: Handle timing and async operations correctly +- **Maintainable**: Use page objects and test helpers + +## Running Tests + +### Local Development + +```bash +# In cpex-plugins +cd plugins/rust/python-package/ +make test-all # Run all plugin tests + +# In mcp-context-forge +cd mcp-context-forge +pytest tests/integration/ # Run integration tests +pytest tests/e2e/ # Run E2E tests +``` + +### CI Pipeline + +```bash +# cpex-plugins CI +make plugins-validate # Validate repo structure +make plugin-test PLUGIN= # Test specific plugin + +# mcp-context-forge CI +make test # Run unit tests +pytest tests/integration/ # Run integration tests +pytest tests/e2e/ # Run E2E tests +``` + +## Debugging Test Failures + +### Unit Test Failures (cpex-plugins) + +1. Run tests locally: `make test-all` +2. Check Rust test output: `cargo test -- --nocapture` +3. Check Python test output: `pytest -v` +4. Use debugger: `rust-gdb` or `pdb` + +### Integration Test Failures (mcp-context-forge) + +1. Check plugin installation: `pip list | grep cpex` +2. Verify plugin configuration: `cat plugins/config.yaml` +3. Check gateway logs: `tail -f logs/gateway.log` +4. Run with verbose output: `pytest -vv tests/integration/` + +### E2E Test Failures (mcp-context-forge) + +1. Check full system logs +2. Verify all services are running +3. Check network connectivity +4. Run with debug logging: `LOG_LEVEL=DEBUG pytest tests/e2e/` + +## Test Documentation + +For detailed testing conventions in mcp-context-forge, see: +- `mcp-context-forge/tests/AGENTS.md` - Testing conventions and workflows +- `mcp-context-forge/plugins/AGENTS.md` - Plugin framework testing + +## Future: Pure Rust Testing + +After the plugin framework is migrated to Rust: + +### Unit Tests (cpex-plugins) + +```bash +cd plugins/rust/ +cargo test # Run Rust tests +cargo test -- --nocapture # With output +``` + +### Integration Tests (mcp-context-forge) + +```bash +cd mcp-context-forge +cargo test --test integration # Run integration tests +``` + +### E2E Tests (mcp-context-forge) + +```bash +cd mcp-context-forge +cargo test --test e2e # Run E2E tests +``` + +Python test infrastructure will be removed after framework migration. \ No newline at end of file From 12f9309b005e3e5172da92055e9667b6a9fb5e6b Mon Sep 17 00:00:00 2001 From: Suresh Kumar Moharajan Date: Fri, 1 May 2026 14:49:42 +0100 Subject: [PATCH 08/11] docs: clarify plugins are pure Python or pure Rust with no dual-path fallback Python entry points in Rust plugins are a packaging and distribution layer only, not a parallel implementation or Rust fallback. Each plugin uses one language for its core logic. Signed-off-by: Suresh Kumar Moharajan --- AGENTS.md | 8 ++++---- README.md | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e4c70a..444ceae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ This is a monorepo of standalone plugin packages for the ContextForge Plugin Extensibility (CPEX) Framework. Each plugin lives in its own top-level directory with independent build configuration. -- Plugins are Rust+Python (PyO3/maturin) or pure Python. +- Plugins are implemented as **pure Python** or **pure Rust**. Each plugin uses one language for its core logic — there is no dual-path where a plugin ships both Rust and Python implementations with a Rust fallback. For Rust plugins, Python entry points (PyO3/maturin) are a packaging and distribution layer only, not a parallel implementation. - Each plugin has its own `pyproject.toml`, `Cargo.toml`, `Makefile`, and `tests/`. - Package names follow the pattern `cpex-` (e.g., `cpex-rate-limiter`). - `mcpgateway` is a runtime dependency provided by the host gateway — never declare it in `pyproject.toml`. @@ -52,13 +52,13 @@ See `mcp-context-forge/tests/AGENTS.md` for integration/E2E test conventions. ### Current Workflow: Rust + Python Hybrid **Architecture:** -- Plugins implemented in Rust (core logic) -- Python entry point via PyO3/maturin bindings +- Plugin logic implemented entirely in Rust — no Python fallback implementation +- Python entry points (PyO3/maturin) are a packaging and distribution layer only - Published as Python packages to PyPI - Loaded by Python-based plugin framework in gateway **Why Python Entry Points?** -The plugin framework is currently implemented in Python (`mcpgateway/plugins/framework/`). Python entry points allow the framework to discover and load plugins dynamically. This is a transitional architecture. +The plugin framework is currently implemented in Python (`mcpgateway/plugins/framework/`). Python entry points allow the framework to discover and load plugins dynamically. This is a transitional packaging layer — all plugin logic remains in Rust. This is not a dual-path architecture. **Development Steps:** diff --git a/README.md b/README.md index d74d7e2..864e44c 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,11 @@ See [TESTING.md](TESTING.md) for detailed testing guidelines and cross-repositor ### Current Architecture (Transitional) -Plugins are currently developed using a **Rust + Python hybrid** approach: -- Core logic implemented in Rust -- Python entry point via PyO3/maturin bindings +Plugins are implemented as **pure Python** or **pure Rust** — each plugin uses one language for its logic. There is no dual-path where a plugin ships both Rust and Python implementations with a Rust fallback. + +For Rust plugins, the current approach wraps the Rust implementation with PyO3/maturin bindings as a packaging layer: +- Plugin logic implemented entirely in Rust +- Python entry points (PyO3/maturin) are a packaging and distribution layer only, not a parallel implementation - Published as Python packages to PyPI - Loaded by Python-based plugin framework in `mcp-context-forge` From 3ba2720d027fbd531408e0554237a2103197b111 Mon Sep 17 00:00:00 2001 From: Suresh Kumar Moharajan Date: Fri, 1 May 2026 15:27:08 +0100 Subject: [PATCH 09/11] docs: add plugin-framework integration tests tier to testing strategy cpex-plugins/tests/ holds both unit tests and plugin-framework integration tests (make test-integration). Gateway integration and E2E tests live in mcp-context-forge. Update AGENTS.md, TESTING.md, and README.md to reflect this distinction. Signed-off-by: Suresh Kumar Moharajan --- AGENTS.md | 31 +++++++++++++++++++------------ README.md | 26 ++++++++++++++++++-------- TESTING.md | 54 ++++++++++++++++++++++++++++++++++++++++++------------ 3 files changed, 79 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa885bd..a46be6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,11 +23,16 @@ This is a monorepo of standalone plugin packages for the ContextForge Plugin Ext - Run during plugin development and CI - Scope: Plugin logic, Rust functions, Python bindings -- **Integration tests**: Located in `mcp-context-forge/tests/integration/` - - Test plugin integration with the gateway framework +- **Plugin-framework integration tests**: Located in `cpex-plugins/tests/` + - Test plugin integration with the local plugin framework (PyO3 bindings, Python ↔ Rust interface) + - Run via `make test-integration` within the plugin directory + - Scope: PyO3 entry points, plugin loading by the Python framework, hook dispatch + +- **Gateway integration tests**: Located in `mcp-context-forge/tests/integration/` + - Test plugin integration with the full gateway - Test cross-plugin interactions - Test plugin lifecycle management - - Scope: Plugin loading, hook execution, framework interaction + - Scope: Plugin loading in gateway context, hook execution, framework interaction - **E2E tests**: Located in `mcp-context-forge/tests/e2e/` - Test complete workflows with plugins enabled @@ -39,10 +44,10 @@ This is a monorepo of standalone plugin packages for the ContextForge Plugin Ext When developing a plugin: -1. Write unit tests in `cpex-plugins/tests/` alongside plugin code -2. Run local tests: `make test-all` from plugin directory +1. Write unit tests and plugin-framework integration tests in `cpex-plugins/tests/` alongside plugin code +2. Run local tests: `make test-all` and `make test-integration` from plugin directory 3. After plugin PR is merged, coordinate with `mcp-context-forge` team -4. Write integration/E2E tests in `mcp-context-forge/tests/` +4. Write gateway integration/E2E tests in `mcp-context-forge/tests/` 5. Ensure both repositories' CI passes before release See `mcp-context-forge/tests/AGENTS.md` for integration/E2E test conventions. @@ -73,12 +78,14 @@ The plugin framework is currently implemented in Python (`mcpgateway/plugins/fra - Implement Python bindings in `cpex_/plugin.py` - Update `plugin-manifest.yaml` -3. **Write Unit Tests** (in `cpex-plugins/tests/`): +3. **Write Tests** (in `cpex-plugins/tests/`): ```bash cd plugins/rust/python-package/ - # Add Rust tests in src/ - # Add Python tests in tests/ - make test-all # Run both Rust and Python tests + # Add Rust unit tests in src/ + # Add Python unit tests in tests/ + # Add plugin-framework integration tests in tests/ (run via make test-integration) + make test-all # Run Rust + Python unit tests + make test-integration # Run plugin-framework integration tests ``` 4. **Build and Install**: @@ -88,11 +95,11 @@ The plugin framework is currently implemented in Python (`mcpgateway/plugins/fra ``` 5. **Create PR in cpex-plugins**: - - Include unit tests + - Include unit tests and plugin-framework integration tests - Ensure `make ci` passes - Tag release: `-v` -6. **Integration Testing** (in `mcp-context-forge`): +6. **Gateway Integration Testing** (in `mcp-context-forge`): - Install plugin: `pip install cpex-` - Configure in `plugins/config.yaml` - Write integration tests in `tests/integration/` diff --git a/README.md b/README.md index c531831..45fb1f0 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,14 @@ Rust crates are owned by the top-level workspace in `Cargo.toml`. Python package ## Testing Strategy -Testing is split across two repositories: +Testing spans two repositories: -- **Unit tests**: Located in `cpex-plugins/tests/` - Test plugin logic in isolation -- **Integration tests**: Located in `mcp-context-forge/tests/integration/` - Test plugin integration with gateway -- **E2E tests**: Located in `mcp-context-forge/tests/e2e/` - Test complete workflows with plugins +- **Unit tests**: `cpex-plugins/tests/` — test plugin logic in isolation +- **Plugin-framework integration tests**: `cpex-plugins/tests/` — test PyO3 bindings and plugin loading by the Python framework (`make test-integration`) +- **Gateway integration tests**: `mcp-context-forge/tests/integration/` — test plugin integration with the full gateway +- **E2E tests**: `mcp-context-forge/tests/e2e/` — test complete workflows with plugins -This separation allows fast feedback during plugin development while ensuring system-level validation. +`cpex-plugins/tests/` covers both unit and plugin-framework integration tests. Gateway integration and E2E tests live in `mcp-context-forge`. See [TESTING.md](TESTING.md) for detailed testing guidelines and cross-repository coordination. @@ -115,15 +116,24 @@ make install # Build Rust extension make test-all # Run unit tests ``` -### Integration Testing +### Plugin-Framework Integration Testing -After unit tests pass, coordinate with `mcp-context-forge`: +After unit tests pass, run plugin-framework integration tests within `cpex-plugins`: + +```bash +cd plugins/rust/python-package/ +make test-integration # Test PyO3 bindings and framework loading +``` + +### Gateway Integration Testing + +After the plugin PR is merged, coordinate with `mcp-context-forge`: ```bash cd mcp-context-forge pip install /path/to/cpex-plugins/plugins/rust/python-package/ # Configure plugin in plugins/config.yaml -pytest tests/integration/ # Run integration tests +pytest tests/integration/ # Run gateway integration tests pytest tests/e2e/ # Run E2E tests ``` diff --git a/TESTING.md b/TESTING.md index 211d973..a195d8a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2,7 +2,7 @@ ## Testing Architecture -Testing is split across two repositories to maintain clear separation of concerns: +Testing spans two repositories. `cpex-plugins` owns unit tests and plugin-framework integration tests; `mcp-context-forge` owns gateway integration and E2E tests. ### Unit Tests (cpex-plugins) @@ -24,23 +24,44 @@ Testing is split across two repositories to maintain clear separation of concern **Run Locally**: ```bash cd plugins/rust/python-package/ -make test-all # Runs both Rust and Python tests +make test-all # Runs both Rust and Python unit tests ``` -### Integration Tests (mcp-context-forge) +### Plugin-Framework Integration Tests (cpex-plugins) + +**Location**: `cpex-plugins/tests/` (plugin-specific `tests/` directories) + +**Scope**: +- PyO3 entry points and Python ↔ Rust interface +- Plugin loading by the Python plugin framework +- Hook dispatch through the framework layer +- Coverage of PyO3 paths (run as part of Rust coverage) + +**Purpose**: +- Validate that the Rust implementation is correctly exposed through PyO3 bindings +- Ensure the plugin framework can discover, load, and invoke the plugin +- Keep PyO3 code paths covered without requiring a full gateway + +**Run Locally**: +```bash +cd plugins/rust/python-package/ +make test-integration # Runs plugin-framework integration tests +``` + +### Gateway Integration Tests (mcp-context-forge) **Location**: `mcp-context-forge/tests/integration/` **Scope**: -- Plugin integration with gateway framework -- Plugin loading and initialization -- Hook execution within framework +- Plugin integration with the full gateway +- Plugin loading and initialization in gateway context +- Hook execution within the gateway framework - Cross-plugin interactions - Plugin lifecycle management **Purpose**: - Validate plugin behavior within the gateway -- Test framework-plugin contracts +- Test framework-plugin contracts at the gateway level - Ensure plugins work together correctly - Test plugin configuration and registration @@ -181,11 +202,13 @@ make plugin-mutants PLUGIN=retry_with_backoff cd cpex-plugins/plugins/rust/python-package/ # Implement plugin logic # Write unit tests in tests/ - make test-all + # Write plugin-framework integration tests in tests/ + make test-all # Run unit tests + make test-integration # Run plugin-framework integration tests ``` 2. **Create PR in cpex-plugins**: - - Include comprehensive unit tests + - Include unit tests and plugin-framework integration tests - Ensure `make ci` passes - Get PR reviewed and merged @@ -229,9 +252,15 @@ make plugin-mutants PLUGIN=retry_with_backoff - Testing configuration validation - Testing error handling and edge cases -**When to Write Integration Tests (mcp-context-forge)**: -- Testing plugin loading and initialization -- Testing hook execution in framework +**When to Write Plugin-Framework Integration Tests (cpex-plugins)**: +- Testing PyO3 entry points end-to-end +- Testing plugin loading by the Python framework +- Testing hook dispatch through the framework layer +- Ensuring PyO3 paths are covered in Rust coverage + +**When to Write Gateway Integration Tests (mcp-context-forge)**: +- Testing plugin loading and initialization in the gateway +- Testing hook execution in the full gateway framework - Testing plugin interactions with gateway services - Testing cross-plugin behavior - Testing plugin lifecycle (enable/disable/reload) @@ -248,6 +277,7 @@ make plugin-mutants PLUGIN=retry_with_backoff **cpex-plugins CI**: - Runs repo contract tests - Runs plugin unit tests +- Runs plugin-framework integration tests (`make test-integration`) - Builds and packages plugins - Publishes to PyPI on release tags From 0cd9813e89c7a1a278bed18270fc4f3b6a623f5c Mon Sep 17 00:00:00 2001 From: Suresh Kumar Moharajan Date: Fri, 1 May 2026 15:38:31 +0100 Subject: [PATCH 10/11] docs: explicitly document plugin-framework integration tests as distinct layer Add a dedicated "Plugin-Framework Integration Tests" layer (layer 3) in the Testing Layers section. cpex-plugins/tests/ holds both unit tests (make test-all) and integration tests between plugin and framework (make test-integration). Gateway integration/E2E tests remain in mcp-context-forge. Update Running Tests and Debugging sections to match. Signed-off-by: Suresh Kumar Moharajan --- TESTING.md | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/TESTING.md b/TESTING.md index a195d8a..8c46c1a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -95,7 +95,7 @@ pytest tests/e2e/ ## Testing Layers -Testing is split into two layers: +`cpex-plugins` has three local testing layers. Gateway integration and E2E tests live in `mcp-context-forge`. ### 1. Repo Contract Tests @@ -118,9 +118,9 @@ They verify: - changed-plugin detection for CI - canonical release tag resolution -### 2. Plugin Tests +### 2. Plugin Unit Tests -Each plugin has its own Rust and Python test suite. +Each plugin has its own Rust and Python unit test suite. ```bash cd plugins/rust/python-package/rate_limiter @@ -140,7 +140,18 @@ make plugin-test PLUGIN=rate_limiter `make plugin-test` runs the selected plugin's `make ci` target, including stub verification, build, bench compilation where configured, install, and Python tests. -## 3. Rust Coverage +### 3. Plugin-Framework Integration Tests + +Each plugin also has integration tests between the plugin and the Python plugin framework. These live in the plugin's `tests/` directory alongside unit tests and test the PyO3 interface — ensuring the Rust implementation is correctly exposed through Python bindings and that the framework can discover, load, and invoke the plugin. + +```bash +cd plugins/rust/python-package/rate_limiter +make test-integration +``` + +These tests are distinct from gateway integration tests in `mcp-context-forge`: they exercise the plugin ↔ framework boundary within this repository, without requiring a running gateway. + +## 4. Rust Coverage CI enforces at least 90% line coverage for each Rust plugin selected by the plugin catalog. The coverage job instruments Rust, runs Rust unit tests, then runs each plugin's repo-level Python integration tests so PyO3 paths are counted. @@ -180,7 +191,7 @@ Rust unit tests use `cargo nextest run`. Coverage uses `cargo llvm-cov nextest - Criterion benchmarks are verified in CI with `cargo nextest run --benches -E 'kind(bench)' --no-run`, which compiles benchmark test targets without rerunning normal unit tests or collecting noisy performance measurements on shared CI runners. -## 4. Mutation Testing +## 5. Mutation Testing Mutation testing runs in PR CI on Ubuntu for Rust code touched by the pull request diff. It is also available locally through cargo-mutants and runs Rust tests with nextest. @@ -349,11 +360,12 @@ Release CI validates the tag and plugin metadata before any artifact is publishe ```bash # In cpex-plugins cd plugins/rust/python-package/ -make test-all # Run all plugin tests +make test-all # Run unit tests (Rust + Python) +make test-integration # Run plugin-framework integration tests # In mcp-context-forge cd mcp-context-forge -pytest tests/integration/ # Run integration tests +pytest tests/integration/ # Run gateway integration tests pytest tests/e2e/ # Run E2E tests ``` @@ -361,12 +373,13 @@ pytest tests/e2e/ # Run E2E tests ```bash # cpex-plugins CI -make plugins-validate # Validate repo structure -make plugin-test PLUGIN= # Test specific plugin +make plugins-validate # Validate repo structure +make plugin-test PLUGIN= # Run unit tests for specific plugin +# make test-integration is run as part of the coverage job # mcp-context-forge CI make test # Run unit tests -pytest tests/integration/ # Run integration tests +pytest tests/integration/ # Run gateway integration tests pytest tests/e2e/ # Run E2E tests ``` @@ -379,7 +392,14 @@ pytest tests/e2e/ # Run E2E tests 3. Check Python test output: `pytest -v` 4. Use debugger: `rust-gdb` or `pdb` -### Integration Test Failures (mcp-context-forge) +### Plugin-Framework Integration Test Failures (cpex-plugins) + +1. Run tests locally: `make test-integration` +2. Check PyO3 binding output: `pytest -v tests/` +3. Verify Rust extension is built: `make install` +4. Check framework loading: `pytest -vv tests/` + +### Gateway Integration Test Failures (mcp-context-forge) 1. Check plugin installation: `pip list | grep cpex` 2. Verify plugin configuration: `cat plugins/config.yaml` From 4499e1ffe17a6b1bae4bf9690d16f0027e0b84df Mon Sep 17 00:00:00 2001 From: Suresh Kumar Moharajan Date: Fri, 1 May 2026 16:04:50 +0100 Subject: [PATCH 11/11] docs: move unit test location from cpex-plugins/tests to plugin directory Unit tests belong in the plugin folder, not the repo-level tests dir: - Python: plugins/rust/python-package//tests/ - Rust: inline mod tests in source files Plugin-framework integration tests similarly live in the plugin's own tests/ directory alongside unit tests. Signed-off-by: Suresh Kumar Moharajan --- AGENTS.md | 14 ++++++++------ README.md | 6 +++--- TESTING.md | 4 +++- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a46be6c..51c391e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,13 +17,15 @@ This is a monorepo of standalone plugin packages for the ContextForge Plugin Ext ### Test Location by Type -- **Unit tests**: Located in `cpex-plugins/tests/` +- **Unit tests**: Located within each plugin's own directory + - Python: `plugins/rust/python-package//tests/` (current hybrid) or `plugins/python//tests/` (pure Python) + - Rust: inline `mod tests` within source files (e.g., `src/lib.rs`) - Test individual plugin functionality in isolation - Fast, deterministic tests - Run during plugin development and CI - Scope: Plugin logic, Rust functions, Python bindings -- **Plugin-framework integration tests**: Located in `cpex-plugins/tests/` +- **Plugin-framework integration tests**: Located in `plugins/rust/python-package//tests/` - Test plugin integration with the local plugin framework (PyO3 bindings, Python ↔ Rust interface) - Run via `make test-integration` within the plugin directory - Scope: PyO3 entry points, plugin loading by the Python framework, hook dispatch @@ -44,7 +46,7 @@ This is a monorepo of standalone plugin packages for the ContextForge Plugin Ext When developing a plugin: -1. Write unit tests and plugin-framework integration tests in `cpex-plugins/tests/` alongside plugin code +1. Write unit tests in the plugin's own directory (Rust: inline `mod tests`; Python: `plugins/rust/python-package//tests/`) and plugin-framework integration tests in `plugins/rust/python-package//tests/` 2. Run local tests: `make test-all` and `make test-integration` from plugin directory 3. After plugin PR is merged, coordinate with `mcp-context-forge` team 4. Write gateway integration/E2E tests in `mcp-context-forge/tests/` @@ -78,10 +80,10 @@ The plugin framework is currently implemented in Python (`mcpgateway/plugins/fra - Implement Python bindings in `cpex_/plugin.py` - Update `plugin-manifest.yaml` -3. **Write Tests** (in `cpex-plugins/tests/`): +3. **Write Tests**: ```bash cd plugins/rust/python-package/ - # Add Rust unit tests in src/ + # Add Rust unit tests inline in src/ using mod tests # Add Python unit tests in tests/ # Add plugin-framework integration tests in tests/ (run via make test-integration) make test-all # Run Rust + Python unit tests @@ -139,7 +141,7 @@ The plugin framework is currently implemented in Python (`mcpgateway/plugins/fra - Implement plugin traits from Rust framework - Update `Cargo.toml` -3. **Write Unit Tests** (in `cpex-plugins/tests/`): +3. **Write Unit Tests** (inline `mod tests` in source files): ```bash cd plugins/rust/ cargo test # Run Rust tests diff --git a/README.md b/README.md index 45fb1f0..52ea022 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,12 @@ Rust crates are owned by the top-level workspace in `Cargo.toml`. Python package Testing spans two repositories: -- **Unit tests**: `cpex-plugins/tests/` — test plugin logic in isolation -- **Plugin-framework integration tests**: `cpex-plugins/tests/` — test PyO3 bindings and plugin loading by the Python framework (`make test-integration`) +- **Unit tests**: within each plugin's own directory — Python in `plugins/rust/python-package//tests/`, Rust inline via `mod tests` in source files +- **Plugin-framework integration tests**: `plugins/rust/python-package//tests/` — test PyO3 bindings and plugin loading by the Python framework (`make test-integration`) - **Gateway integration tests**: `mcp-context-forge/tests/integration/` — test plugin integration with the full gateway - **E2E tests**: `mcp-context-forge/tests/e2e/` — test complete workflows with plugins -`cpex-plugins/tests/` covers both unit and plugin-framework integration tests. Gateway integration and E2E tests live in `mcp-context-forge`. +Unit tests and plugin-framework integration tests live in the plugin's own directory. Gateway integration and E2E tests live in `mcp-context-forge`. See [TESTING.md](TESTING.md) for detailed testing guidelines and cross-repository coordination. diff --git a/TESTING.md b/TESTING.md index 8c46c1a..12c490a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,9 @@ Testing spans two repositories. `cpex-plugins` owns unit tests and plugin-framew ### Unit Tests (cpex-plugins) -**Location**: `cpex-plugins/tests/` and plugin-specific `tests/` directories +**Location**: Within each plugin's own directory +- Python: `plugins/rust/python-package//tests/` (current hybrid) or `plugins/python//tests/` (pure Python) +- Rust: inline `mod tests` within source files (e.g., `src/lib.rs`) **Scope**: - Individual plugin functionality in isolation