From bb88930d75c8a4cf3e8ef98bb69e3dfb820970d9 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Tue, 19 Aug 2025 17:11:09 -0400 Subject: [PATCH 01/26] Initial OPA plugin template Signed-off-by: Shriti Priya --- plugins/external/opa/.dockerignore | 363 ++++++++++++++ plugins/external/opa/.env.template | 23 + plugins/external/opa/.ruff.toml | 63 +++ plugins/external/opa/Containerfile | 47 ++ plugins/external/opa/MANIFEST.in | 67 +++ plugins/external/opa/Makefile | 449 ++++++++++++++++++ plugins/external/opa/README.md | 65 +++ .../external/opa/opapluginfilter/__init__.py | 23 + .../opa/opapluginfilter/plugin-manifest.yaml | 9 + .../external/opa/opapluginfilter/plugin.py | 84 ++++ plugins/external/opa/pyproject.toml | 98 ++++ .../opa/resources/plugins/config.yaml | 28 ++ .../opa/resources/runtime/config.yaml | 71 +++ plugins/external/opa/run-server.sh | 43 ++ plugins/external/opa/tests/__init__.py | 0 plugins/external/opa/tests/pytest.ini | 13 + plugins/external/opa/tests/test_all.py | 73 +++ .../opa/tests/test_opapluginfilter.py | 31 ++ 18 files changed, 1550 insertions(+) create mode 100644 plugins/external/opa/.dockerignore create mode 100644 plugins/external/opa/.env.template create mode 100644 plugins/external/opa/.ruff.toml create mode 100644 plugins/external/opa/Containerfile create mode 100644 plugins/external/opa/MANIFEST.in create mode 100644 plugins/external/opa/Makefile create mode 100644 plugins/external/opa/README.md create mode 100644 plugins/external/opa/opapluginfilter/__init__.py create mode 100644 plugins/external/opa/opapluginfilter/plugin-manifest.yaml create mode 100644 plugins/external/opa/opapluginfilter/plugin.py create mode 100644 plugins/external/opa/pyproject.toml create mode 100644 plugins/external/opa/resources/plugins/config.yaml create mode 100644 plugins/external/opa/resources/runtime/config.yaml create mode 100755 plugins/external/opa/run-server.sh create mode 100644 plugins/external/opa/tests/__init__.py create mode 100644 plugins/external/opa/tests/pytest.ini create mode 100644 plugins/external/opa/tests/test_all.py create mode 100644 plugins/external/opa/tests/test_opapluginfilter.py diff --git a/plugins/external/opa/.dockerignore b/plugins/external/opa/.dockerignore new file mode 100644 index 000000000..e9a71f900 --- /dev/null +++ b/plugins/external/opa/.dockerignore @@ -0,0 +1,363 @@ +# syntax=docker/dockerfile:1 +#---------------------------------------------------------------------- +# Docker Build Context Optimization +# +# This .dockerignore file excludes unnecessary files from the Docker +# build context to improve build performance and security. +#---------------------------------------------------------------------- + +#---------------------------------------------------------------------- +# 1. Development and source directories (not needed in production) +#---------------------------------------------------------------------- +agent_runtimes/ +charts/ +deployment/ +docs/ +deployment/k8s/ +mcp-servers/ +tests/ +test/ +attic/ +*.md +.benchmarks/ + +# Development environment directories +.devcontainer/ +.github/ +.vscode/ +.idea/ + +#---------------------------------------------------------------------- +# 2. Version control +#---------------------------------------------------------------------- +.git/ +.gitignore +.gitattributes +.gitmodules + +#---------------------------------------------------------------------- +# 3. Python build artifacts and caches +#---------------------------------------------------------------------- +# Byte-compiled files +__pycache__/ +*.py[cod] +*.pyc +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.wily/ + +# PyInstaller +*.manifest +*.spec + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ +.pytype/ + +# Cython debug symbols +cython_debug/ + +#---------------------------------------------------------------------- +# 4. Virtual environments +#---------------------------------------------------------------------- +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.python37/ +.python39/ +.python-version + +# PDM +pdm.lock +.pdm.toml +.pdm-python + +#---------------------------------------------------------------------- +# 5. Package managers and dependencies +#---------------------------------------------------------------------- +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm +.yarn + +# pip +pip-log.txt +pip-delete-this-directory.txt + +#---------------------------------------------------------------------- +# 6. Docker and container files (avoid recursive copies) +#---------------------------------------------------------------------- +Dockerfile +Dockerfile.* +Containerfile +Containerfile.* +docker-compose.yml +docker-compose.*.yml +podman-compose*.yaml +.dockerignore + +#---------------------------------------------------------------------- +# 7. IDE and editor files +#---------------------------------------------------------------------- +# JetBrains +.idea/ +*.iml +*.iws +*.ipr + +# VSCode +.vscode/ +*.code-workspace + +# Vim +*.swp +*.swo +*~ + +# Emacs +*~ +\#*\# +.\#* + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +#---------------------------------------------------------------------- +# 8. Build tools and CI/CD configurations +#---------------------------------------------------------------------- +# Testing configurations +.coveragerc +.pylintrc +.flake8 +pytest.ini +tox.ini +.pytest.ini + +# Linting and formatting +.hadolint.yaml +.pre-commit-config.yaml +.pycodestyle +.pyre_configuration +.pyspelling.yaml +.ruff.toml +.shellcheckrc + +# Build configurations +Makefile +setup.cfg +pyproject.toml.bak +MANIFEST.in + +# CI/CD +.travis.* +.gitlab-ci.yml +.circleci/ +.github/ +azure-pipelines.yml +Jenkinsfile + +# Code quality +sonar-code.properties +sonar-project.properties +.scannerwork/ +whitesource.config +.whitesource + +# Other tools +.bumpversion.cfg +.editorconfig +mypy.ini + +#---------------------------------------------------------------------- +# 9. Application runtime files (should not be in image) +#---------------------------------------------------------------------- +# Databases +*.db +*.sqlite +*.sqlite3 +mcp.db +db.sqlite3 + +# Logs +*.log +logs/ +log/ + +# Certificates and secrets +certs/ +*.pem +*.key +*.crt +*.csr +.env +.env.* + +# Generated files +public/ +static/ +media/ + +# Application instances +instance/ +local_settings.py + +#---------------------------------------------------------------------- +# 10. Framework-specific files +#---------------------------------------------------------------------- +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +media/ + +# Flask +instance/ +.webassets-cache + +# Scrapy +.scrapy + +# Sphinx documentation +docs/_build/ +docs/build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints +*.ipynb + +# IPython +profile_default/ +ipython_config.py + +# celery +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +#---------------------------------------------------------------------- +# 11. Backup and temporary files +#---------------------------------------------------------------------- +*.bak +*.backup +*.tmp +*.temp +*.orig +*.rej +.backup/ +backup/ +tmp/ +temp/ + +#---------------------------------------------------------------------- +# 12. Documentation and miscellaneous +#---------------------------------------------------------------------- +*.md +!README.md +LICENSE +CHANGELOG +AUTHORS +CONTRIBUTORS +TODO +TODO.md +DEVELOPING.md +CONTRIBUTING.md + +# Spelling +.spellcheck-en.txt +*.dic + +# Shell scripts (if not needed in container) +test.sh +scripts/test/ +scripts/dev/ + +#---------------------------------------------------------------------- +# 13. OS-specific files +#---------------------------------------------------------------------- +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# Linux +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* + +#---------------------------------------------------------------------- +# End of .dockerignore +#---------------------------------------------------------------------- diff --git a/plugins/external/opa/.env.template b/plugins/external/opa/.env.template new file mode 100644 index 000000000..6d9faf358 --- /dev/null +++ b/plugins/external/opa/.env.template @@ -0,0 +1,23 @@ +##################################### +# Plugins Settings +##################################### + +# Enable the plugin framework +PLUGINS_ENABLED=false + +# Enable auto-completion for plugins CLI +PLUGINS_CLI_COMPLETION=false + +# Set markup mode for plugins CLI +# Valid options: +# rich: use rich markup +# markdown: allow markdown in help strings +# disabled: disable markup +# If unset (commented out), uses "rich" if rich is detected, otherwise disables it. +PLUGINS_CLI_MARKUP_MODE=rich + +# Configuration path for plugin loader +PLUGINS_CONFIG=./resources/plugins/config.yaml + +# Configuration path for chuck mcp runtime +CHUK_MCP_CONFIG_PATH=./resources/runtime/config.yaml diff --git a/plugins/external/opa/.ruff.toml b/plugins/external/opa/.ruff.toml new file mode 100644 index 000000000..443a275df --- /dev/null +++ b/plugins/external/opa/.ruff.toml @@ -0,0 +1,63 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", + "docs", + "test" +] + +# 200 line length +line-length = 200 +indent-width = 4 + +# Assume Python 3.11 +target-version = "py311" + +[lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +select = ["E4", "E7", "E9", "F"] +ignore = [] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" diff --git a/plugins/external/opa/Containerfile b/plugins/external/opa/Containerfile new file mode 100644 index 000000000..d2d5f6748 --- /dev/null +++ b/plugins/external/opa/Containerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.7 +ARG UBI=python-312-minimal + +FROM registry.access.redhat.com/ubi9/${UBI} AS builder + +ARG PYTHON_VERSION=3.12 + +ARG VERSION +ARG COMMIT_ID +ARG SKILLS_SDK_COMMIT_ID +ARG SKILLS_SDK_VERSION +ARG BUILD_TIME_SKILLS_INSTALL + +ENV APP_HOME=/app + +USER 0 + +# Image pre-requisites +RUN INSTALL_PKGS="git make gcc gcc-c++ python${PYTHON_VERSION}-devel" && \ + microdnf -y --setopt=tsflags=nodocs --setopt=install_weak_deps=0 install $INSTALL_PKGS && \ + microdnf -y clean all --enablerepo='*' + +# Setup alias from HOME to APP_HOME +RUN mkdir -p ${APP_HOME} && \ + chown -R 1001:0 ${APP_HOME} && \ + ln -s ${HOME} ${APP_HOME} && \ + mkdir -p ${HOME}/resources/config && \ + chown -R 1001:0 ${HOME}/resources/config + +USER 1001 + +# Install plugin package +COPY . . +RUN pip install --no-cache-dir uv && python -m uv pip install . + +# Make default cache directory writable +RUN mkdir -p -m 0776 ${HOME}/.cache + +# Update labels +LABEL maintainer="Context Forge MCP Gateway Team" \ + name="mcp/mcppluginserver" \ + version="${VERSION}" \ + url="https://github.com/IBM/mcp-context-forge" \ + description="MCP Plugin Server for the Context Forge MCP Gateway" + +# App entrypoint +ENTRYPOINT ["sh", "-c", "${HOME}/run-server.sh"] diff --git a/plugins/external/opa/MANIFEST.in b/plugins/external/opa/MANIFEST.in new file mode 100644 index 000000000..e62a7e45a --- /dev/null +++ b/plugins/external/opa/MANIFEST.in @@ -0,0 +1,67 @@ +# ────────────────────────────────────────────────────────────── +# MANIFEST.in - source-distribution contents for opapluginfilter +# ────────────────────────────────────────────────────────────── + +# 1️⃣ Core project files that SDists/Wheels should always carry +include LICENSE +include README.md +include pyproject.toml +include Containerfile + +# 2️⃣ Top-level config, examples and helper scripts +include *.py +include *.md +include *.example +include *.lock +include *.properties +include *.toml +include *.yaml +include *.yml +include *.json +include *.sh +include *.txt +recursive-include async_testing *.py +recursive-include async_testing *.yaml + +# 3️⃣ Tooling/lint configuration dot-files (explicit so they're not lost) +include .env.make +include .interrogaterc +include .jshintrc +include whitesource.config +include .darglint +include .dockerignore +include .flake8 +include .htmlhintrc +include .pycodestyle +include .pylintrc +include .whitesource +include .coveragerc +# include .gitignore # purely optional but many projects ship it +include .bumpversion.cfg +include .yamllint +include .editorconfig +include .snyk + +# 4️⃣ Runtime data that lives *inside* the package at import time +recursive-include resources/plugins *.yaml +recursive-include opapluginfilter *.yaml + +# 5️⃣ (Optional) include MKDocs-based docs in the sdist +# graft docs + +# 6️⃣ Never publish caches, compiled or build outputs, deployment, agent_runtimes, etc. +global-exclude __pycache__ *.py[cod] *.so *.dylib +prune build +prune dist +prune .eggs +prune *.egg-info +prune charts +prune k8s +prune .devcontainer +exclude CLAUDE.* +exclude llms-full.txt + +# Exclude deployment, mcp-servers and agent_runtimes +prune deployment +prune mcp-servers +prune agent_runtimes diff --git a/plugins/external/opa/Makefile b/plugins/external/opa/Makefile new file mode 100644 index 000000000..6440ff000 --- /dev/null +++ b/plugins/external/opa/Makefile @@ -0,0 +1,449 @@ + +REQUIRED_BUILD_BINS := uv + +SHELL := /bin/bash +.SHELLFLAGS := -eu -o pipefail -c + +# Project variables +PACKAGE_NAME = opapluginfilter +PROJECT_NAME = opapluginfilter +TARGET ?= opapluginfilter + +# Virtual-environment variables +VENVS_DIR ?= $(HOME)/.venv +VENV_DIR ?= $(VENVS_DIR)/$(PROJECT_NAME) + +# ============================================================================= +# Linters +# ============================================================================= + +black: + @echo "🎨 black $(TARGET)..." && $(VENV_DIR)/bin/black -l 200 $(TARGET) + +black-check: + @echo "🎨 black --check $(TARGET)..." && $(VENV_DIR)/bin/black -l 200 --check --diff $(TARGET) + +ruff: + @echo "⚡ ruff $(TARGET)..." && $(VENV_DIR)/bin/ruff check $(TARGET) && $(VENV_DIR)/bin/ruff format $(TARGET) + +ruff-check: + @echo "⚡ ruff check $(TARGET)..." && $(VENV_DIR)/bin/ruff check $(TARGET) + +ruff-fix: + @echo "⚡ ruff check --fix $(TARGET)..." && $(VENV_DIR)/bin/ruff check --fix $(TARGET) + +ruff-format: + @echo "⚡ ruff format $(TARGET)..." && $(VENV_DIR)/bin/ruff format $(TARGET) + +# ============================================================================= +# Container runtime configuration and operations +# ============================================================================= + +# Container resource limits +CONTAINER_MEMORY = 2048m +CONTAINER_CPUS = 2 + +# Auto-detect container runtime if not specified - DEFAULT TO DOCKER +CONTAINER_RUNTIME ?= $(shell command -v docker >/dev/null 2>&1 && echo docker || echo podman) + +# Alternative: Always default to docker unless explicitly overridden +# CONTAINER_RUNTIME ?= docker + +# Container port +CONTAINER_PORT ?= 8000 +CONTAINER_INTERNAL_PORT ?= 8000 + +print-runtime: + @echo Using container runtime: $(CONTAINER_RUNTIME) + +# Base image name (without any prefix) +IMAGE_BASE ?= mcpgateway/$(PROJECT_NAME) +IMAGE_TAG ?= latest + +# Handle runtime-specific image naming +ifeq ($(CONTAINER_RUNTIME),podman) + # Podman adds localhost/ prefix for local builds + IMAGE_LOCAL := localhost/$(IMAGE_BASE):$(IMAGE_TAG) + IMAGE_LOCAL_DEV := localhost/$(IMAGE_BASE)-dev:$(IMAGE_TAG) + IMAGE_PUSH := $(IMAGE_BASE):$(IMAGE_TAG) +else + # Docker doesn't add prefix + IMAGE_LOCAL := $(IMAGE_BASE):$(IMAGE_TAG) + IMAGE_LOCAL_DEV := $(IMAGE_BASE)-dev:$(IMAGE_TAG) + IMAGE_PUSH := $(IMAGE_BASE):$(IMAGE_TAG) +endif + +print-image: + @echo "🐳 Container Runtime: $(CONTAINER_RUNTIME)" + @echo "Using image: $(IMAGE_LOCAL)" + @echo "Development image: $(IMAGE_LOCAL_DEV)" + @echo "Push image: $(IMAGE_PUSH)" + + + +# Function to get the actual image name as it appears in image list +define get_image_name +$(shell $(CONTAINER_RUNTIME) images --format "{{.Repository}}:{{.Tag}}" | grep -E "(localhost/)?$(IMAGE_BASE):$(IMAGE_TAG)" | head -1) +endef + +# Function to normalize image name for operations +define normalize_image +$(if $(findstring localhost/,$(1)),$(1),$(if $(filter podman,$(CONTAINER_RUNTIME)),localhost/$(1),$(1))) +endef + +# Containerfile to use (can be overridden) +#CONTAINER_FILE ?= Containerfile +CONTAINER_FILE ?= $(shell [ -f "Containerfile" ] && echo "Containerfile" || echo "Dockerfile") + +# Define COMMA for the conditional Z flag +COMMA := , + +container-info: + @echo "🐳 Container Runtime Configuration" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo "Runtime: $(CONTAINER_RUNTIME)" + @echo "Base Image: $(IMAGE_BASE)" + @echo "Tag: $(IMAGE_TAG)" + @echo "Local Image: $(IMAGE_LOCAL)" + @echo "Push Image: $(IMAGE_PUSH)" + @echo "Actual Image: $(call get_image_name)" + @echo "Container File: $(CONTAINER_FILE)" + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Auto-detect platform based on uname +PLATFORM ?= linux/$(shell uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') + +container-build: + @echo "🔨 Building with $(CONTAINER_RUNTIME) for platform $(PLATFORM)..." + $(CONTAINER_RUNTIME) build \ + --platform=$(PLATFORM) \ + -f $(CONTAINER_FILE) \ + --tag $(IMAGE_BASE):$(IMAGE_TAG) \ + . + @echo "✅ Built image: $(call get_image_name)" + $(CONTAINER_RUNTIME) images $(IMAGE_BASE):$(IMAGE_TAG) + +container-run: container-check-image + @echo "🚀 Running with $(CONTAINER_RUNTIME)..." + -$(CONTAINER_RUNTIME) stop $(PROJECT_NAME) 2>/dev/null || true + -$(CONTAINER_RUNTIME) rm $(PROJECT_NAME) 2>/dev/null || true + $(CONTAINER_RUNTIME) run --name $(PROJECT_NAME) \ + --env-file=.env \ + -p $(CONTAINER_PORT):$(CONTAINER_INTERNAL_PORT) \ + --restart=always \ + --memory=$(CONTAINER_MEMORY) --cpus=$(CONTAINER_CPUS) \ + --health-cmd="curl --fail http://localhost:$(CONTAINER_INTERNAL_PORT)/health || exit 1" \ + --health-interval=1m --health-retries=3 \ + --health-start-period=30s --health-timeout=10s \ + -d $(call get_image_name) + @sleep 2 + @echo "✅ Container started" + @echo "🔍 Health check status:" + @$(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{.State.Health.Status}}' 2>/dev/null || echo "No health check configured" + +container-run-host: container-check-image + @echo "🚀 Running with $(CONTAINER_RUNTIME)..." + -$(CONTAINER_RUNTIME) stop $(PROJECT_NAME) 2>/dev/null || true + -$(CONTAINER_RUNTIME) rm $(PROJECT_NAME) 2>/dev/null || true + $(CONTAINER_RUNTIME) run --name $(PROJECT_NAME) \ + --env-file=.env \ + --network=host \ + -p $(CONTAINER_PORT):$(CONTAINER_INTERNAL_PORT) \ + --restart=always \ + --memory=$(CONTAINER_MEMORY) --cpus=$(CONTAINER_CPUS) \ + --health-cmd="curl --fail http://localhost:$(CONTAINER_INTERNAL_PORT)/health || exit 1" \ + --health-interval=1m --health-retries=3 \ + --health-start-period=30s --health-timeout=10s \ + -d $(call get_image_name) + @sleep 2 + @echo "✅ Container started" + @echo "🔍 Health check status:" + @$(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{.State.Health.Status}}' 2>/dev/null || echo "No health check configured" + +container-push: container-check-image + @echo "📤 Preparing to push image..." + @# For Podman, we need to remove localhost/ prefix for push + @if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ + actual_image=$$($(CONTAINER_RUNTIME) images --format "{{.Repository}}:{{.Tag}}" | grep -E "$(IMAGE_BASE):$(IMAGE_TAG)" | head -1); \ + if echo "$$actual_image" | grep -q "^localhost/"; then \ + echo "🏷️ Tagging for push (removing localhost/ prefix)..."; \ + $(CONTAINER_RUNTIME) tag "$$actual_image" $(IMAGE_PUSH); \ + fi; \ + fi + $(CONTAINER_RUNTIME) push $(IMAGE_PUSH) + @echo "✅ Pushed: $(IMAGE_PUSH)" + +container-check-image: + @echo "🔍 Checking for image..." + @if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ + if ! $(CONTAINER_RUNTIME) image exists $(IMAGE_LOCAL) 2>/dev/null && \ + ! $(CONTAINER_RUNTIME) image exists $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null; then \ + echo "❌ Image not found: $(IMAGE_LOCAL)"; \ + echo "💡 Run 'make container-build' first"; \ + exit 1; \ + fi; \ + else \ + if ! $(CONTAINER_RUNTIME) images -q $(IMAGE_LOCAL) 2>/dev/null | grep -q . && \ + ! $(CONTAINER_RUNTIME) images -q $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null | grep -q .; then \ + echo "❌ Image not found: $(IMAGE_LOCAL)"; \ + echo "💡 Run 'make container-build' first"; \ + exit 1; \ + fi; \ + fi + @echo "✅ Image found" + +container-stop: + @echo "🛑 Stopping container..." + -$(CONTAINER_RUNTIME) stop $(PROJECT_NAME) 2>/dev/null || true + -$(CONTAINER_RUNTIME) rm $(PROJECT_NAME) 2>/dev/null || true + @echo "✅ Container stopped and removed" + +container-logs: + @echo "📜 Streaming logs (Ctrl+C to exit)..." + $(CONTAINER_RUNTIME) logs -f $(PROJECT_NAME) + +container-shell: + @echo "🔧 Opening shell in container..." + @if ! $(CONTAINER_RUNTIME) ps -q -f name=$(PROJECT_NAME) | grep -q .; then \ + echo "❌ Container $(PROJECT_NAME) is not running"; \ + echo "💡 Run 'make container-run' first"; \ + exit 1; \ + fi + @$(CONTAINER_RUNTIME) exec -it $(PROJECT_NAME) /bin/bash 2>/dev/null || \ + $(CONTAINER_RUNTIME) exec -it $(PROJECT_NAME) /bin/sh + +container-health: + @echo "🏥 Checking container health..." + @if ! $(CONTAINER_RUNTIME) ps -q -f name=$(PROJECT_NAME) | grep -q .; then \ + echo "❌ Container $(PROJECT_NAME) is not running"; \ + exit 1; \ + fi + @echo "Status: $$($(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{.State.Health.Status}}' 2>/dev/null || echo 'No health check')" + @echo "Logs:" + @$(CONTAINER_RUNTIME) inspect $(PROJECT_NAME) --format='{{range .State.Health.Log}}{{.Output}}{{end}}' 2>/dev/null || true + +container-build-multi: + @echo "🔨 Building multi-architecture image..." + @if [ "$(CONTAINER_RUNTIME)" = "docker" ]; then \ + if ! docker buildx inspect $(PROJECT_NAME)-builder >/dev/null 2>&1; then \ + echo "📦 Creating buildx builder..."; \ + docker buildx create --name $(PROJECT_NAME)-builder; \ + fi; \ + docker buildx use $(PROJECT_NAME)-builder; \ + docker buildx build \ + --platform=linux/amd64,linux/arm64 \ + -f $(CONTAINER_FILE) \ + --tag $(IMAGE_BASE):$(IMAGE_TAG) \ + --push \ + .; \ + elif [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ + echo "📦 Building manifest with Podman..."; \ + $(CONTAINER_RUNTIME) build --platform=linux/amd64,linux/arm64 \ + -f $(CONTAINER_FILE) \ + --manifest $(IMAGE_BASE):$(IMAGE_TAG) \ + .; \ + echo "💡 To push: podman manifest push $(IMAGE_BASE):$(IMAGE_TAG)"; \ + else \ + echo "❌ Multi-arch builds require Docker buildx or Podman"; \ + exit 1; \ + fi + +# Helper targets for debugging image issues +image-list: + @echo "📋 Images matching $(IMAGE_BASE):" + @$(CONTAINER_RUNTIME) images --format "table {{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.Created}}\t{{.Size}}" | \ + grep -E "(IMAGE|$(IMAGE_BASE))" || echo "No matching images found" + +image-clean: + @echo "🧹 Removing all $(IMAGE_BASE) images..." + @$(CONTAINER_RUNTIME) images --format "{{.Repository}}:{{.Tag}}" | \ + grep -E "(localhost/)?$(IMAGE_BASE)" | \ + xargs $(XARGS_FLAGS) $(CONTAINER_RUNTIME) rmi -f 2>/dev/null + @echo "✅ Images cleaned" + +# Fix image naming issues +image-retag: + @echo "🏷️ Retagging images for consistency..." + @if [ "$(CONTAINER_RUNTIME)" = "podman" ]; then \ + if $(CONTAINER_RUNTIME) image exists $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null; then \ + $(CONTAINER_RUNTIME) tag $(IMAGE_BASE):$(IMAGE_TAG) $(IMAGE_LOCAL) 2>/dev/null || true; \ + fi; \ + else \ + if $(CONTAINER_RUNTIME) images -q $(IMAGE_LOCAL) 2>/dev/null | grep -q .; then \ + $(CONTAINER_RUNTIME) tag $(IMAGE_LOCAL) $(IMAGE_BASE):$(IMAGE_TAG) 2>/dev/null || true; \ + fi; \ + fi + @echo "✅ Images retagged" # This always shows success + +# Runtime switching helpers +use-docker: + @echo "export CONTAINER_RUNTIME=docker" + @echo "💡 Run: export CONTAINER_RUNTIME=docker" + +use-podman: + @echo "export CONTAINER_RUNTIME=podman" + @echo "💡 Run: export CONTAINER_RUNTIME=podman" + +show-runtime: + @echo "Current runtime: $(CONTAINER_RUNTIME)" + @echo "Detected from: $$(command -v $(CONTAINER_RUNTIME) || echo 'not found')" # Added + @echo "To switch: make use-docker or make use-podman" + + + +# ============================================================================= +# Targets +# ============================================================================= + +.PHONY: venv +venv: + @rm -Rf "$(VENV_DIR)" + @test -d "$(VENVS_DIR)" || mkdir -p "$(VENVS_DIR)" + @python3 -m venv "$(VENV_DIR)" + @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m pip install --upgrade pip setuptools pdm uv" + @echo -e "✅ Virtual env created.\n💡 Enter it with:\n . $(VENV_DIR)/bin/activate\n" + +.PHONY: install +install: venv + $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`))) + @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install ." + +.PHONY: install-dev +install-dev: venv + $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`))) + @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install -e .[dev]" + +.PHONY: install-editable +install-editable: venv + $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`))) + @/bin/bash -c "source $(VENV_DIR)/bin/activate && python3 -m uv pip install -e .[dev]" + +.PHONY: uninstall +uninstall: + pip uninstall $(PACKAGE_NAME) + +.PHONY: dist +dist: clean ## Build wheel + sdist into ./dist + @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv + @/bin/bash -eu -c "\ + source $(VENV_DIR)/bin/activate && \ + python3 -m pip install --quiet --upgrade pip build && \ + python3 -m build" + @echo '🛠 Wheel & sdist written to ./dist' + +.PHONY: wheel +wheel: ## Build wheel only + @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv + @/bin/bash -eu -c "\ + source $(VENV_DIR)/bin/activate && \ + python3 -m pip install --quiet --upgrade pip build && \ + python3 -m build -w" + @echo '🛠 Wheel written to ./dist' + +.PHONY: sdist +sdist: ## Build source distribution only + @test -d "$(VENV_DIR)" || $(MAKE) --no-print-directory venv + @/bin/bash -eu -c "\ + source $(VENV_DIR)/bin/activate && \ + python3 -m pip install --quiet --upgrade pip build && \ + python3 -m build -s" + @echo '🛠 Source distribution written to ./dist' + +.PHONY: verify +verify: dist ## Build, run metadata & manifest checks + @/bin/bash -c "source $(VENV_DIR)/bin/activate && \ + twine check dist/* && \ + check-manifest && \ + pyroma -d ." + @echo "✅ Package verified - ready to publish." + +.PHONY: lint-fix +lint-fix: + @# Handle file arguments + @target_file="$(word 2,$(MAKECMDGOALS))"; \ + if [ -n "$$target_file" ] && [ "$$target_file" != "" ]; then \ + actual_target="$$target_file"; \ + else \ + actual_target="$(TARGET)"; \ + fi; \ + for target in $$(echo $$actual_target); do \ + if [ ! -e "$$target" ]; then \ + echo "❌ File/directory not found: $$target"; \ + exit 1; \ + fi; \ + done; \ + echo "🔧 Fixing lint issues in $$actual_target..."; \ + $(MAKE) --no-print-directory black TARGET="$$actual_target"; \ + $(MAKE) --no-print-directory ruff-fix TARGET="$$actual_target" + +.PHONY: lint-check +lint-check: + @# Handle file arguments + @target_file="$(word 2,$(MAKECMDGOALS))"; \ + if [ -n "$$target_file" ] && [ "$$target_file" != "" ]; then \ + actual_target="$$target_file"; \ + else \ + actual_target="$(TARGET)"; \ + fi; \ + for target in $$(echo $$actual_target); do \ + if [ ! -e "$$target" ]; then \ + echo "❌ File/directory not found: $$target"; \ + exit 1; \ + fi; \ + done; \ + echo "🔧 Fixing lint issues in $$actual_target..."; \ + $(MAKE) --no-print-directory black-check TARGET="$$actual_target"; \ + $(MAKE) --no-print-directory ruff-check TARGET="$$actual_target" + +.PHONY: lock +lock: + $(foreach bin,$(REQUIRED_BUILD_BINS), $(if $(shell command -v $(bin) 2> /dev/null),,$(error Couldn't find `$(bin)`. Please run `make init`))) + uv lock + +.PHONY: test +test: + pytest tests + +.PHONY: serve +serve: + @echo "Implement me." + +.PHONY: build +build: + @$(MAKE) container-build + +.PHONY: start +start: + @$(MAKE) container-run + +.PHONY: stop +stop: + @$(MAKE) container-stop + +.PHONY: clean +clean: + find . -type f -name '*.py[co]' -delete -o -type d -name __pycache__ -delete + rm -rf *.egg-info .pytest_cache tests/.pytest_cache build dist .ruff_cache .coverage + +.PHONY: help +help: + @echo "This Makefile is offered for convenience." + @echo "" + @echo "The following are the valid targets for this Makefile:" + @echo "...install Install package from sources" + @echo "...install-dev Install package from sources with dev packages" + @echo "...install-editable Install package from sources in editabled mode" + @echo "...uninstall Uninstall package" + @echo "...dist Clean-build wheel *and* sdist into ./dist" + @echo "...wheel Build wheel only" + @echo "...sdist Build source distribution only" + @echo "...verify Build + twine + check-manifest + pyroma (no upload)" + @echo "...serve Start API server locally" + @echo "...build Build API server container image" + @echo "...start Start the API server container" + @echo "...start Stop the API server container" + @echo "...lock Lock dependencies" + @echo "...lint-fix Check and fix lint errors" + @echo "...lint-check Check for lint errors" + @echo "...test Run all tests" + @echo "...clean Remove all artifacts and builds" diff --git a/plugins/external/opa/README.md b/plugins/external/opa/README.md new file mode 100644 index 000000000..7f35db096 --- /dev/null +++ b/plugins/external/opa/README.md @@ -0,0 +1,65 @@ +# OPAPluginFilter for Context Forge MCP Gateway + +An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies. + + +## Installation + +To install dependencies with dev packages (required for linting and testing): + +```bash +make install-dev +``` + +Alternatively, you can also install it in editable mode: + +```bash +make install-editable +``` + +## Setting up the development environment + +1. Copy .env.template .env +2. Enable plugins in `.env` + +## Testing + +Test modules are created under the `tests` directory. + +To run all tests, use the following command: + +```bash +make test +``` + +**Note:** To enable logging, set `log_cli = true` in `tests/pytest.ini`. + +## Code Linting + +Before checking in any code for the project, please lint the code. This can be done using: + +```bash +make lint-fix +``` + +## Runtime (server) + +This project uses [chuck-mcp-runtime](https://github.com/chrishayuk/chuk-mcp-runtime) to run external plugins as a standardized MCP server. + +To build the container image: + +```bash +make build +``` + +To run the container: + +```bash +make start +``` + +To stop the container: + +```bash +make stop +``` diff --git a/plugins/external/opa/opapluginfilter/__init__.py b/plugins/external/opa/opapluginfilter/__init__.py new file mode 100644 index 000000000..9e70f83b8 --- /dev/null +++ b/plugins/external/opa/opapluginfilter/__init__.py @@ -0,0 +1,23 @@ +"""MCP Gateway OPAPluginFilter Plugin - An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Shriti Priya + +""" + +import importlib.metadata + +# Package version +try: + __version__ = importlib.metadata.version("opapluginfilter") +except Exception: + __version__ = "0.1.0" + +__author__ = "Shriti Priya" +__copyright__ = "Copyright 2025" +__license__ = "Apache 2.0" +__description__ = "An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies" +__url__ = "https://ibm.github.io/mcp-context-forge/" +__download_url__ = "https://github.com/IBM/mcp-context-forge" +__packages__ = ["opapluginfilter"] diff --git a/plugins/external/opa/opapluginfilter/plugin-manifest.yaml b/plugins/external/opa/opapluginfilter/plugin-manifest.yaml new file mode 100644 index 000000000..b7ac415f2 --- /dev/null +++ b/plugins/external/opa/opapluginfilter/plugin-manifest.yaml @@ -0,0 +1,9 @@ +description: "An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies" +author: "Shriti Priya" +version: "0.1.0" +available_hooks: + - "prompt_pre_hook" + - "prompt_post_hook" + - "tool_pre_hook" + - "tool_post_hook" +default_configs: diff --git a/plugins/external/opa/opapluginfilter/plugin.py b/plugins/external/opa/opapluginfilter/plugin.py new file mode 100644 index 000000000..e3237ee10 --- /dev/null +++ b/plugins/external/opa/opapluginfilter/plugin.py @@ -0,0 +1,84 @@ +"""An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Shriti Priya + +This module loads configurations for plugins. +""" + +# First-Party +from mcpgateway.plugins.framework import ( + Plugin, + PluginConfig, + PluginContext, + PromptPosthookPayload, + PromptPosthookResult, + PromptPrehookPayload, + PromptPrehookResult, + ToolPostInvokePayload, + ToolPostInvokeResult, + ToolPreInvokePayload, + ToolPreInvokeResult, +) + + +class OPAPluginFilter(Plugin): + """An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies.""" + + def __init__(self, config: PluginConfig): + """Entry init block for plugin. + + Args: + logger: logger that the skill can make use of + config: the skill configuration + """ + super().__init__(config) + + async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: + """The plugin hook run before a prompt is retrieved and rendered. + + Args: + payload: The prompt payload to be analyzed. + context: contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + return PromptPrehookResult(continue_processing=True) + + async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: + """Plugin hook run after a prompt is rendered. + + Args: + payload: The prompt payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + return PromptPosthookResult(continue_processing=True) + + async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: + """Plugin hook run before a tool is invoked. + + Args: + payload: The tool payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool can proceed. + """ + return ToolPreInvokeResult(continue_processing=True) + + async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: + """Plugin hook run after a tool is invoked. + + Args: + payload: The tool result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool result should proceed. + """ + return ToolPostInvokeResult(continue_processing=True) diff --git a/plugins/external/opa/pyproject.toml b/plugins/external/opa/pyproject.toml new file mode 100644 index 000000000..2e789fcad --- /dev/null +++ b/plugins/external/opa/pyproject.toml @@ -0,0 +1,98 @@ +# ---------------------------------------------------------------- +# 💡 Build system (PEP 517) +# - setuptools ≥ 77 gives SPDX licence support (PEP 639) +# - wheel is needed by most build front-ends +# ---------------------------------------------------------------- +[build-system] +requires = ["setuptools>=77", "wheel"] +build-backend = "setuptools.build_meta" + +# ---------------------------------------------------------------- +# 📦 Core project metadata (PEP 621) +# ---------------------------------------------------------------- +[project] +name = "opapluginfilter" +version = "0.1.0" +description = "An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies" +keywords = ["MCP","API","gateway","tools", + "agents","agentic ai","model context protocol","multi-agent","fastapi", + "json-rpc","sse","websocket","federation","security","authentication" +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Framework :: FastAPI", + "Framework :: AsyncIO", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", + "Topic :: Software Development :: Libraries :: Application Frameworks" +] +readme = "README.md" +requires-python = ">=3.11,<3.14" +license = "Apache-2.0" +license-files = ["LICENSE"] + +maintainers = [ + {name = "Shriti Priya", email = "shritip@ibm.com"} +] + +authors = [ + {name = "Shriti Priya", email = "shritip@ibm.com"} +] + +dependencies = [ + "chuk-mcp-runtime>=0.6.5", + "mcp-contextforge-gateway", +] + +# URLs +[project.urls] +Homepage = "https://ibm.github.io/mcp-context-forge/" +Documentation = "https://ibm.github.io/mcp-context-forge/" +Repository = "https://github.com/IBM/mcp-context-forge" +"Bug Tracker" = "https://github.com/IBM/mcp-context-forge/issues" +Changelog = "https://github.com/IBM/mcp-context-forge/blob/main/CHANGELOG.md" + +[tool.uv.sources] +mcp-contextforge-gateway = { git = "https://github.com/IBM/mcp-context-forge.git", rev = "main" } + +# ---------------------------------------------------------------- +# Optional dependency groups (extras) +# ---------------------------------------------------------------- +[project.optional-dependencies] +dev = [ + "black>=25.1.0", + "pytest>=8.4.1", + "pytest-asyncio>=1.1.0", + "pytest-cov>=6.2.1", + "pytest-dotenv>=0.5.2", + "pytest-env>=1.1.5", + "pytest-examples>=0.0.18", + "pytest-md-report>=0.7.0", + "pytest-rerunfailures>=15.1", + "pytest-trio>=0.8.0", + "pytest-xdist>=3.8.0", + "ruff>=0.12.9", + "unimport>=1.2.1", + "uv>=0.8.11", +] + +# -------------------------------------------------------------------- +# 🔧 setuptools-specific configuration +# -------------------------------------------------------------------- +[tool.setuptools] +include-package-data = true # ensure wheels include the data files + +# Automatic discovery: keep every package that starts with "opapluginfilter" +[tool.setuptools.packages.find] +include = ["opapluginfilter*"] +exclude = ["tests*"] + +## Runtime data files ------------------------------------------------ +[tool.setuptools.package-data] +opapluginfilter = [ + "resources/plugins/config.yaml", +] diff --git a/plugins/external/opa/resources/plugins/config.yaml b/plugins/external/opa/resources/plugins/config.yaml new file mode 100644 index 000000000..715e34f82 --- /dev/null +++ b/plugins/external/opa/resources/plugins/config.yaml @@ -0,0 +1,28 @@ +plugins: + - name: "OPAPluginFilter" + kind: "opapluginfilter.plugin.OPAPluginFilter" + description: "An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies" + version: "0.1.0" + author: "Shriti Priya" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + tags: ["plugin"] + mode: "enforce" # enforce | permissive | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + # Plugin config dict passed to the plugin constructor + +# Plugin directories to scan +plugin_dirs: + - "opapluginfilter" + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: false + enable_plugin_api: true + plugin_health_check_interval: 60 diff --git a/plugins/external/opa/resources/runtime/config.yaml b/plugins/external/opa/resources/runtime/config.yaml new file mode 100644 index 000000000..37cfc7943 --- /dev/null +++ b/plugins/external/opa/resources/runtime/config.yaml @@ -0,0 +1,71 @@ +# config.yaml +host: + name: "opapluginfilter" + log_level: "INFO" + +server: + type: "streamable-http" # "stdio" or "sse" or "streamable-http" + #auth: "bearer" # this line is needed to enable bearer auth + +# Logging configuration - controls all logging behavior +logging: + level: "WARNING" # Changed from INFO to WARNING for quieter default + format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + reset_handlers: true + quiet_libraries: true + + # Specific logger overrides to silence noisy components + loggers: + # Your existing overrides + "chuk_mcp_runtime.proxy": "WARNING" + "chuk_mcp_runtime.proxy.manager": "WARNING" + "chuk_mcp_runtime.proxy.tool_wrapper": "WARNING" + "chuk_tool_processor.mcp.stream_manager": "WARNING" + "chuk_tool_processor.mcp.register": "WARNING" + "chuk_tool_processor.mcp.setup_stdio": "WARNING" + "chuk_mcp_runtime.common.tool_naming": "WARNING" + "chuk_mcp_runtime.common.openai_compatibility": "WARNING" + + # NEW: Add the noisy loggers you're seeing + "chuk_sessions.session_manager": "ERROR" + "chuk_mcp_runtime.session.native": "ERROR" + "chuk_mcp_runtime.tools.artifacts": "ERROR" + "chuk_mcp_runtime.tools.session": "ERROR" + "chuk_artifacts.store": "ERROR" + "chuk_mcp_runtime.entry": "WARNING" # Keep some info but less chatty + "chuk_mcp_runtime.server": "WARNING" # Server start/stop messages + +# optional overrides +sse: + host: "0.0.0.0" + port: 8000 + sse_path: "/sse" + message_path: "/messages/" + health_path: "/health" + log_level: "info" + access_log: true + +streamable-http: + host: "0.0.0.0" + port: 8000 + mcp_path: "/mcp" + stateless: true + json_response: true + health_path: "/health" + log_level: "info" + access_log: true + +proxy: + enabled: false + namespace: "proxy" + openai_compatible: false # ← set to true if you want underscores + +# Session tools (disabled by default - must enable explicitly) +session_tools: + enabled: false # Must explicitly enable + +# Artifact storage (disabled by default - must enable explicitly) +artifacts: + enabled: false # Must explicitly enable + storage_provider: "filesystem" + session_provider: "memory" diff --git a/plugins/external/opa/run-server.sh b/plugins/external/opa/run-server.sh new file mode 100755 index 000000000..d73f57de5 --- /dev/null +++ b/plugins/external/opa/run-server.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +#─────────────────────────────────────────────────────────────────────────────── +# Script : run-server.sh +# Purpose: Launch the MCP Gateway's Plugin API +# +# Description: +# This script launches an API server using +# chuck runtime. +# +# Environment Variables: +# API_SERVER_SCRIPT : Path to the server script (optional, auto-detected) +# PLUGINS_CONFIG_PATH : Path to the plugin config (optional, default: ./resources/plugins/config.yaml) +# CHUK_MCP_CONFIG_PATH : Path to the chuck-mcp-runtime config (optional, default: ./resources/runtime/config.yaml) +# +# Usage: +# ./run-server.sh # Run server +#─────────────────────────────────────────────────────────────────────────────── + +# Exit immediately on error, undefined variable, or pipe failure +set -euo pipefail + +#──────────────────────────────────────────────────────────────────────────────── +# SECTION 1: Script Location Detection +# Determine the absolute path of the API server script +#──────────────────────────────────────────────────────────────────────────────── +if [[ -z "${API_SERVER_SCRIPT:-}" ]]; then + API_SERVER_SCRIPT="$(python -c 'import mcpgateway.plugins.framework.external.mcp.server.runtime as server; print(server.__file__)')" + echo "✓ API server script path auto-detected: ${API_SERVER_SCRIPT}" +else + echo "✓ Using provided API server script path: ${API_SERVER_SCRIPT}" +fi + +#──────────────────────────────────────────────────────────────────────────────── +# SECTION 2: Run the API server +# Run the API server from configuration +#──────────────────────────────────────────────────────────────────────────────── + +PLUGINS_CONFIG_PATH=${PLUGINS_CONFIG_PATH:-./resources/plugins/config.yaml} +CHUK_MCP_CONFIG_PATH=${CHUK_MCP_CONFIG_PATH:-./resources/runtime/config.yaml} + +echo "✓ Using plugin config from: ${PLUGINS_CONFIG_PATH}" +echo "✓ Running API server with config from: ${CHUK_MCP_CONFIG_PATH}" +python ${API_SERVER_SCRIPT} diff --git a/plugins/external/opa/tests/__init__.py b/plugins/external/opa/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/external/opa/tests/pytest.ini b/plugins/external/opa/tests/pytest.ini new file mode 100644 index 000000000..ff60648e6 --- /dev/null +++ b/plugins/external/opa/tests/pytest.ini @@ -0,0 +1,13 @@ +[pytest] +log_cli = false +log_cli_level = INFO +log_cli_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s +log_cli_date_format = %Y-%m-%d %H:%M:%S +log_level = INFO +log_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s +log_date_format = %Y-%m-%d %H:%M:%S +addopts = --cov --cov-report term-missing +env_files = .env +pythonpath = . src +filterwarnings = + ignore::DeprecationWarning:pydantic.* diff --git a/plugins/external/opa/tests/test_all.py b/plugins/external/opa/tests/test_all.py new file mode 100644 index 000000000..8accde750 --- /dev/null +++ b/plugins/external/opa/tests/test_all.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +"""Tests for registered plugins.""" + +# Third-Party +import asyncio +import pytest + +# First-Party +from mcpgateway.models import Message, PromptResult, Role, TextContent +from mcpgateway.plugins.framework import ( + PluginManager, + GlobalContext, + PromptPrehookPayload, + PromptPosthookPayload, + PromptResult, + ToolPreInvokePayload, + ToolPostInvokePayload, +) + + +@pytest.fixture(scope="module", autouse=True) +def plugin_manager(): + """Initialize plugin manager.""" + plugin_manager = PluginManager("./resources/plugins/config.yaml") + asyncio.run(plugin_manager.initialize()) + yield plugin_manager + asyncio.run(plugin_manager.shutdown()) + + +@pytest.mark.asyncio +async def test_prompt_pre_hook(plugin_manager: PluginManager): + """Test prompt pre hook across all registered plugins.""" + # Customize payload for testing + payload = PromptPrehookPayload(name="test_prompt", args={"arg0": "This is an argument"}) + global_context = GlobalContext(request_id="1") + result, _ = await plugin_manager.prompt_pre_fetch(payload, global_context) + # Assert expected behaviors + assert result.continue_processing + + +@pytest.mark.asyncio +async def test_prompt_post_hook(plugin_manager: PluginManager): + """Test prompt post hook across all registered plugins.""" + # Customize payload for testing + message = Message(content=TextContent(type="text", text="prompt"), role=Role.USER) + prompt_result = PromptResult(messages=[message]) + payload = PromptPosthookPayload(name="test_prompt", result=prompt_result) + global_context = GlobalContext(request_id="1") + result, _ = await plugin_manager.prompt_post_fetch(payload, global_context) + # Assert expected behaviors + assert result.continue_processing + + +@pytest.mark.asyncio +async def test_tool_pre_hook(plugin_manager: PluginManager): + """Test tool pre hook across all registered plugins.""" + # Customize payload for testing + payload = ToolPreInvokePayload(name="test_prompt", args={"arg0": "This is an argument"}) + global_context = GlobalContext(request_id="1") + result, _ = await plugin_manager.tool_pre_invoke(payload, global_context) + # Assert expected behaviors + assert result.continue_processing + + +@pytest.mark.asyncio +async def test_tool_post_hook(plugin_manager: PluginManager): + """Test tool post hook across all registered plugins.""" + # Customize payload for testing + payload = ToolPostInvokePayload(name="test_tool", result={"output0": "output value"}) + global_context = GlobalContext(request_id="1") + result, _ = await plugin_manager.tool_post_invoke(payload, global_context) + # Assert expected behaviors + assert result.continue_processing diff --git a/plugins/external/opa/tests/test_opapluginfilter.py b/plugins/external/opa/tests/test_opapluginfilter.py new file mode 100644 index 000000000..d37d0c22b --- /dev/null +++ b/plugins/external/opa/tests/test_opapluginfilter.py @@ -0,0 +1,31 @@ +"""Tests for plugin.""" + +# Third-Party +import pytest + +# First-Party +from opapluginfilter.plugin import OPAPluginFilter +from mcpgateway.plugins.framework import ( + PluginConfig, + PluginContext, + PromptPrehookPayload, +) + + +@pytest.mark.asyncio +async def test_opapluginfilter(): + """Test plugin prompt prefetch hook.""" + config = PluginConfig( + name="test", + kind="opapluginfilter.OPAPluginFilter", + hooks=["prompt_pre_fetch"], + config={"setting_one": "test_value"}, + ) + + plugin = OPAPluginFilter(config) + + # Test your plugin logic + payload = PromptPrehookPayload(name="test_prompt", args={"arg0": "This is an argument"}) + context = PluginContext(request_id="1", server_id="2") + result = await plugin.prompt_pre_fetch(payload, context) + assert result.continue_processing From 28686593b090d5a09ad5343d810bf8db7254aa83 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Tue, 19 Aug 2025 17:31:44 -0400 Subject: [PATCH 02/26] Adding opa server installation, tool invoke with policy evaluations Signed-off-by: Shriti Priya --- plugins/external/config.yaml | 7 ++++ plugins/external/opa/Containerfile | 5 +++ .../external/opa/opapluginfilter/plugin.py | 40 +++++++++++++++++++ .../external/opa/opapluginfilter/schema.py | 34 ++++++++++++++++ .../external/opa/opaserver/rego/example.rego | 3 ++ .../opa/resources/plugins/config.yaml | 2 + plugins/external/opa/run-server.sh | 8 ++++ 7 files changed, 99 insertions(+) create mode 100644 plugins/external/opa/opapluginfilter/schema.py create mode 100644 plugins/external/opa/opaserver/rego/example.rego diff --git a/plugins/external/config.yaml b/plugins/external/config.yaml index 68a9c6d2b..8908f8a12 100644 --- a/plugins/external/config.yaml +++ b/plugins/external/config.yaml @@ -6,6 +6,13 @@ plugins: mcp: proto: STREAMABLEHTTP url: http://127.0.0.1:3000/mcp + + - name: "opa_policy_filter" + kind: "external" + priority: 10 # adjust the priority + mcp: + proto: STREAMABLEHTTP + url: http://127.0.0.1:8000/mcp # Plugin directories to scan plugin_dirs: diff --git a/plugins/external/opa/Containerfile b/plugins/external/opa/Containerfile index d2d5f6748..a75c082f5 100644 --- a/plugins/external/opa/Containerfile +++ b/plugins/external/opa/Containerfile @@ -29,6 +29,11 @@ RUN mkdir -p ${APP_HOME} && \ USER 1001 +# Install opa in container +RUN curl -L -o /usr/local/bin/opa https://openpolicyagent.org/downloads/v0.63.0/opa_linux_amd64_static +RUN chmod +x /usr/local/bin/opa +RUN opa version + # Install plugin package COPY . . RUN pip install --no-cache-dir uv && python -m uv pip install . diff --git a/plugins/external/opa/opapluginfilter/plugin.py b/plugins/external/opa/opapluginfilter/plugin.py index e3237ee10..998d3b98c 100644 --- a/plugins/external/opa/opapluginfilter/plugin.py +++ b/plugins/external/opa/opapluginfilter/plugin.py @@ -7,6 +7,9 @@ This module loads configurations for plugins. """ +# Third-Party +import requests + # First-Party from mcpgateway.plugins.framework import ( Plugin, @@ -21,6 +24,16 @@ ToolPreInvokePayload, ToolPreInvokeResult, ) +from mcpgateway.plugins.framework.models import PluginConfig, PluginViolation +from mcpgateway.services.logging_service import LoggingService +from opapluginfilter.schema import ( + BaseOPAInputKeys, + OPAConfig +) + +# Initialize logging service first +logging_service = LoggingService() +logger = logging_service.get_logger(__name__) class OPAPluginFilter(Plugin): @@ -34,6 +47,16 @@ def __init__(self, config: PluginConfig): config: the skill configuration """ super().__init__(config) + self.opa_config = OPAConfig.model_validate(self._config.config) + + def _evaluate_opa_policy(self, url: str, input_dict: BaseOPAInputKeys) -> bool: + payload = input_dict.model_dump() + rsp = requests.post(url, json=payload) + logger.info(f"OPA connection response '{rsp}'") + if rsp.json()["result"].get("allow"): + return True + else: + return False async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: """The plugin hook run before a prompt is retrieved and rendered. @@ -69,6 +92,23 @@ async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginCo Returns: The result of the plugin's analysis, including whether the tool can proceed. """ + + logger.debug(f"Processing tool pre-invoke for tool '{payload.name}' with {len(payload.args) if payload.args else 0} arguments") + + if not payload.args: + return ToolPreInvokeResult() + + opa_input = BaseOPAInputKeys(kind="tools/call", user = "admin", tool = {"name" : payload.name, "args" : payload.args}, request_ip = "none", headers = {}, response = {}) + url = self.opa_config.server_url + logger.info(f"Processing tool pre-invoke for tool '{payload.name}' with {len(payload.args) if payload.args else 0} arguments") + decision = self._evaluate_opa_policy(url,opa_input) + if not decision: + violation = PluginViolation( + reason="tool invocation not allowed", + description="OPA policy failed", + code="deny", + details={},) + return ToolPreInvokeResult(modified_payload=payload, violation=violation, continue_processing=False) return ToolPreInvokeResult(continue_processing=True) async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: diff --git a/plugins/external/opa/opapluginfilter/schema.py b/plugins/external/opa/opapluginfilter/schema.py new file mode 100644 index 000000000..eb7674eaa --- /dev/null +++ b/plugins/external/opa/opapluginfilter/schema.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel +from typing import Optional +from typing import Optional, Dict, Any + +class BaseOPAInputKeys(BaseModel): + kind : str + user : str + tool : Dict[str, Any] + request_ip : str + headers : Dict[str, str] + response : Dict[str, str] + + +class OPAInput(BaseModel): + input : BaseOPAInputKeys + + +class OPAResult(BaseModel): + allow : bool = True + patch : Optional[Dict[str, Any]] = None + reason: Optional[str] = None + +class OPAConfig(BaseModel): + """Configuration for the PII Filter plugin.""" + + # Enable/disable detection for specific PII types + policy: str = "None" + server_url: str = "None" + + +POLICY_BUNDLE_PATH = None +POLICY_BUNDLE_URL = None +POLICY_POLL_SEC = None +POLICY_ENABLED= None \ No newline at end of file diff --git a/plugins/external/opa/opaserver/rego/example.rego b/plugins/external/opa/opaserver/rego/example.rego new file mode 100644 index 000000000..7a38ddb0a --- /dev/null +++ b/plugins/external/opa/opaserver/rego/example.rego @@ -0,0 +1,3 @@ +package httpapi.authz + +default allow := true \ No newline at end of file diff --git a/plugins/external/opa/resources/plugins/config.yaml b/plugins/external/opa/resources/plugins/config.yaml index 715e34f82..dab0ce28b 100644 --- a/plugins/external/opa/resources/plugins/config.yaml +++ b/plugins/external/opa/resources/plugins/config.yaml @@ -14,6 +14,8 @@ plugins: tenant_ids: [] # Apply to all tenants config: # Plugin config dict passed to the plugin constructor + policy: "all path requests must have IBM" + server_url: http://127.0.0.1:8181/v1/data/httpapi/authz # Plugin directories to scan plugin_dirs: diff --git a/plugins/external/opa/run-server.sh b/plugins/external/opa/run-server.sh index d73f57de5..d15cfe53c 100755 --- a/plugins/external/opa/run-server.sh +++ b/plugins/external/opa/run-server.sh @@ -30,6 +30,14 @@ else echo "✓ Using provided API server script path: ${API_SERVER_SCRIPT}" fi +#──────────────────────────────────────────────────────────────────────────────── +# SECTION 1.1: Run OPA server +# Run OPA server with a policy rego file +#──────────────────────────────────────────────────────────────────────────────── + +echo "Running OPA server" +opa run --server opaserver/rego/example.rego & + #──────────────────────────────────────────────────────────────────────────────── # SECTION 2: Run the API server # Run the API server from configuration From ad6f8c4cedcd765a60d3ee719c855b95fb8404af Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Wed, 20 Aug 2025 17:25:44 -0400 Subject: [PATCH 03/26] Sample policy holders for pre/post tool, resource and prompt invocations, url changes and opa version (arm architecture 1.7.0) Signed-off-by: Shriti Priya --- mcpgateway/main.py | 2 + plugins/config.yaml | 7 +++ plugins/external/config.yaml | 2 +- plugins/external/opa/Containerfile | 9 +-- .../external/opa/opapluginfilter/plugin.py | 56 ++++++++++++++----- .../external/opa/opaserver/rego/example.rego | 50 ++++++++++++++++- .../opa/resources/plugins/config.yaml | 3 +- 7 files changed, 107 insertions(+), 22 deletions(-) diff --git a/mcpgateway/main.py b/mcpgateway/main.py index 768ac28e3..609591126 100644 --- a/mcpgateway/main.py +++ b/mcpgateway/main.py @@ -2694,6 +2694,8 @@ async def handle_rpc(request: Request, db: Session = Depends(get_db), user: str result = await tool_service.invoke_tool(db=db, name=method, arguments=params, request_headers=headers) if hasattr(result, "model_dump"): result = result.model_dump(by_alias=True, exclude_none=True) + except PluginViolationError: + return JSONResponse(status_code=403, content={"detail": "policy_deny"}) except (ValueError, Exception): # If not a tool, try forwarding to gateway try: diff --git a/plugins/config.yaml b/plugins/config.yaml index c10adf8d3..684197f6a 100644 --- a/plugins/config.yaml +++ b/plugins/config.yaml @@ -110,6 +110,13 @@ plugins: - pattern: "secret\\s*[:=]\\s*\\S+" replacement: "secret: [REDACTED]" + - name: "OPAPluginFilter" + kind: "external" + priority: 10 # adjust the priority + mcp: + proto: STREAMABLEHTTP + url: http://127.0.0.1:8000/mcp + # Plugin directories to scan plugin_dirs: - "plugins/native" # Built-in plugins diff --git a/plugins/external/config.yaml b/plugins/external/config.yaml index 8908f8a12..4c78e47ce 100644 --- a/plugins/external/config.yaml +++ b/plugins/external/config.yaml @@ -7,7 +7,7 @@ plugins: proto: STREAMABLEHTTP url: http://127.0.0.1:3000/mcp - - name: "opa_policy_filter" + - name: "OPAPluginFilter" kind: "external" priority: 10 # adjust the priority mcp: diff --git a/plugins/external/opa/Containerfile b/plugins/external/opa/Containerfile index a75c082f5..4045c6d3a 100644 --- a/plugins/external/opa/Containerfile +++ b/plugins/external/opa/Containerfile @@ -16,7 +16,7 @@ ENV APP_HOME=/app USER 0 # Image pre-requisites -RUN INSTALL_PKGS="git make gcc gcc-c++ python${PYTHON_VERSION}-devel" && \ +RUN INSTALL_PKGS="git make gcc gcc-c++ python${PYTHON_VERSION}-devel procps-ng vim" && \ microdnf -y --setopt=tsflags=nodocs --setopt=install_weak_deps=0 install $INSTALL_PKGS && \ microdnf -y clean all --enablerepo='*' @@ -27,13 +27,14 @@ RUN mkdir -p ${APP_HOME} && \ mkdir -p ${HOME}/resources/config && \ chown -R 1001:0 ${HOME}/resources/config -USER 1001 - # Install opa in container -RUN curl -L -o /usr/local/bin/opa https://openpolicyagent.org/downloads/v0.63.0/opa_linux_amd64_static +RUN curl -L -o /usr/local/bin/opa https://openpolicyagent.org/downloads/v1.7.0/opa_linux_arm64_static RUN chmod +x /usr/local/bin/opa RUN opa version +USER 1001 + + # Install plugin package COPY . . RUN pip install --no-cache-dir uv && python -m uv pip install . diff --git a/plugins/external/opa/opapluginfilter/plugin.py b/plugins/external/opa/opapluginfilter/plugin.py index 998d3b98c..81c9f2615 100644 --- a/plugins/external/opa/opapluginfilter/plugin.py +++ b/plugins/external/opa/opapluginfilter/plugin.py @@ -4,9 +4,12 @@ SPDX-License-Identifier: Apache-2.0 Authors: Shriti Priya -This module loads configurations for plugins. +This module loads configurations for plugins and applies hooks on pre/post requests for tools, prompts and resources. """ +# Standard +from typing import Any + # Third-Party import requests @@ -28,7 +31,8 @@ from mcpgateway.services.logging_service import LoggingService from opapluginfilter.schema import ( BaseOPAInputKeys, - OPAConfig + OPAConfig, + OPAInput ) # Initialize logging service first @@ -49,14 +53,21 @@ def __init__(self, config: PluginConfig): super().__init__(config) self.opa_config = OPAConfig.model_validate(self._config.config) - def _evaluate_opa_policy(self, url: str, input_dict: BaseOPAInputKeys) -> bool: + def _evaluate_opa_policy(self, url: str, input_dict: OPAInput) -> tuple[bool,Any]: payload = input_dict.model_dump() + logger.info(f"OPA url {url}, OPA payload {payload}") rsp = requests.post(url, json=payload) logger.info(f"OPA connection response '{rsp}'") - if rsp.json()["result"].get("allow"): - return True + if rsp.status_code == 200: + json_response = rsp.json() + decision = json_response.get("result",None) + logger.info(f"OPA server response '{json_response}'") + if isinstance(decision,bool): + return decision, json_response + else: + logger.debug(f"OPA sent a none response {json_response}") else: - return False + logger.debug(f"OPA error: {rsp}") async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: """The plugin hook run before a prompt is retrieved and rendered. @@ -83,7 +94,8 @@ async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: Plugi return PromptPosthookResult(continue_processing=True) async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: - """Plugin hook run before a tool is invoked. + """OPA Plugin hook run before a tool is invoked. This hook takes in payload and context and further evaluates rego + policies on the input by sending the request to opa server. Args: payload: The tool payload to be analyzed. @@ -98,21 +110,22 @@ async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginCo if not payload.args: return ToolPreInvokeResult() - opa_input = BaseOPAInputKeys(kind="tools/call", user = "admin", tool = {"name" : payload.name, "args" : payload.args}, request_ip = "none", headers = {}, response = {}) - url = self.opa_config.server_url - logger.info(f"Processing tool pre-invoke for tool '{payload.name}' with {len(payload.args) if payload.args else 0} arguments") - decision = self._evaluate_opa_policy(url,opa_input) + opa_input = BaseOPAInputKeys(kind="tools/call", user = "none", tool = {"name" : payload.name, "args" : payload.args}, request_ip = "none", headers = {}, response = {}) + opa_server_url = self.opa_config.server_url + policy_url = opa_server_url + "/allow_pre_tool" + decision, decision_context = self._evaluate_opa_policy(policy_url,input_dict=OPAInput(input=opa_input)) if not decision: violation = PluginViolation( reason="tool invocation not allowed", - description="OPA policy failed", + description="OPA policy failed on tool preinvocation", code="deny", - details={},) + details=decision_context,) return ToolPreInvokeResult(modified_payload=payload, violation=violation, continue_processing=False) return ToolPreInvokeResult(continue_processing=True) async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: - """Plugin hook run after a tool is invoked. + """Plugin hook run after a tool is invoked. The response of the tool passes through this hook and opa policy is evaluated on it + for it to be allowed or denied. Args: payload: The tool result payload to be analyzed. @@ -121,4 +134,19 @@ async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: Plugin Returns: The result of the plugin's analysis, including whether the tool result should proceed. """ + logger.info(f"OPA tool post request {payload} , {context}") + result = payload.result + opa_server_url = self.opa_config.server_url + policy_url = opa_server_url + "/allow_post_tool" + for content in result.content: + opa_input = BaseOPAInputKeys(kind="tools/call", user = "none", tool = {"name" : payload.name, "args" : content}, request_ip = "none", headers = {}, response = {}) + decision, decision_context = self._evaluate_opa_policy(policy_url,input_dict=OPAInput(input=opa_input)) + if not decision: + violation = PluginViolation( + reason="tool invocation not allowed", + description="OPA policy failed on tool postinvocation", + code="deny", + details=decision_context,) + return ToolPreInvokeResult(modified_payload=payload, violation=violation, continue_processing=False) + return ToolPostInvokeResult(continue_processing=True) diff --git a/plugins/external/opa/opaserver/rego/example.rego b/plugins/external/opa/opaserver/rego/example.rego index 7a38ddb0a..880ed8a9e 100644 --- a/plugins/external/opa/opaserver/rego/example.rego +++ b/plugins/external/opa/opaserver/rego/example.rego @@ -1,3 +1,49 @@ -package httpapi.authz -default allow := true \ No newline at end of file +# Package for sample rego policies +# Copyright 2025 +# SPDX-License-Identifier: Apache-2.0 +# Authors: Shriti Priya +# This file is responsible for rego policies for each type of requests made, it could be prompt, resource or tool requests + +package example + + + +# Default policy values for all the policies +default allow_pre_tool := false +default allow_post_tool := false +default allow_pre_prompt := false +default allow_post_prompt := false +default allow_pre_resource := false +default allow_post_resource := false + + +# Policies applied for pre tool invocations +allow_pre_tool if { + contains(input.tool.args.repo_path, "IBM") +} + +# Policies applied for post tool invocations +allow_post_tool if { + contains(input.tool.args.repo_path, "IBM") +} + +# Policies applied for pre prompt invocations +allow_pre_prompt if { + input.prompt.args.text == "allowed-word" +} + +# Policies applied for post prompt invocations +allow_post_path if { + input.prompt.args.text == "allowed-word" +} + +# Policies applied for pre resource invocations +allow_pre_resource if { + input.uri == "allowed-domain" +} + +# Policies applied for post resource invocations +allow_post_resource if { + input.uri == "allowed-domain" +} \ No newline at end of file diff --git a/plugins/external/opa/resources/plugins/config.yaml b/plugins/external/opa/resources/plugins/config.yaml index dab0ce28b..5e8a1de97 100644 --- a/plugins/external/opa/resources/plugins/config.yaml +++ b/plugins/external/opa/resources/plugins/config.yaml @@ -15,7 +15,8 @@ plugins: config: # Plugin config dict passed to the plugin constructor policy: "all path requests must have IBM" - server_url: http://127.0.0.1:8181/v1/data/httpapi/authz + server_url: "http://127.0.0.1:8181/v1/data/example" + # Plugin directories to scan plugin_dirs: From 59c69cb37ec5bea8542db01466479f50d016a87d Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Fri, 22 Aug 2025 11:37:42 -0600 Subject: [PATCH 04/26] feat: add shared context capabilities and fixed error issues. Signed-off-by: Teryl Taylor --- mcpgateway/plugins/framework/constants.py | 2 + .../plugins/framework/external/mcp/client.py | 90 ++++---- .../framework/external/mcp/server/server.py | 19 +- mcpgateway/plugins/framework/manager.py | 68 ++++-- mcpgateway/plugins/framework/models.py | 21 +- plugins/resource_filter/resource_filter.py | 4 +- .../configs/context_multiplugins.yaml | 44 ++++ .../fixtures/configs/context_plugin.yaml | 30 +++ .../context_stdio_external_plugins.yaml | 27 +++ .../fixtures/configs/error_plugin.yaml | 30 +++ .../error_plugin_raise_error_false.yaml | 30 +++ .../configs/error_stdio_external_plugin.yaml | 22 ++ .../plugins/fixtures/plugins/context.py | 201 ++++++++++++++++++ .../plugins/fixtures/plugins/error.py | 98 +++++++++ .../external/mcp/server/test_runtime.py | 35 +-- .../external/mcp/test_client_stdio.py | 92 +++++++- .../mcp/test_client_streamable_http.py | 8 +- .../framework/loader/test_plugin_loader.py | 6 +- .../plugins/framework/test_context.py | 143 +++++++++++++ .../plugins/framework/test_errors.py | 33 ++- .../framework/test_manager_extended.py | 29 +-- .../plugins/framework/test_resource_hooks.py | 31 +-- .../plugins/pii_filter/test_pii_filter.py | 12 +- .../resource_filter/test_resource_filter.py | 3 +- 24 files changed, 950 insertions(+), 128 deletions(-) create mode 100644 tests/unit/mcpgateway/plugins/fixtures/configs/context_multiplugins.yaml create mode 100644 tests/unit/mcpgateway/plugins/fixtures/configs/context_plugin.yaml create mode 100644 tests/unit/mcpgateway/plugins/fixtures/configs/context_stdio_external_plugins.yaml create mode 100644 tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin.yaml create mode 100644 tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin_raise_error_false.yaml create mode 100644 tests/unit/mcpgateway/plugins/fixtures/configs/error_stdio_external_plugin.yaml create mode 100644 tests/unit/mcpgateway/plugins/fixtures/plugins/context.py create mode 100644 tests/unit/mcpgateway/plugins/fixtures/plugins/error.py create mode 100644 tests/unit/mcpgateway/plugins/framework/test_context.py diff --git a/mcpgateway/plugins/framework/constants.py b/mcpgateway/plugins/framework/constants.py index dbb729ae5..065b85d1f 100644 --- a/mcpgateway/plugins/framework/constants.py +++ b/mcpgateway/plugins/framework/constants.py @@ -23,5 +23,7 @@ PLUGIN_NAME = "plugin_name" PAYLOAD = "payload" CONTEXT = "context" +RESULT = "result" +ERROR = "error" GET_PLUGIN_CONFIG = "get_plugin_config" IGNORE_CONFIG_EXTERNAL = "ignore_config_external" diff --git a/mcpgateway/plugins/framework/external/mcp/client.py b/mcpgateway/plugins/framework/external/mcp/client.py index 02641e91e..93866bd48 100644 --- a/mcpgateway/plugins/framework/external/mcp/client.py +++ b/mcpgateway/plugins/framework/external/mcp/client.py @@ -9,33 +9,28 @@ """ # Standard +import asyncio from contextlib import AsyncExitStack import json import logging import os -from typing import Any, Optional +from typing import Any, Optional, Type, TypeVar # Third-Party from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from mcp.client.streamable_http import streamablehttp_client +from pydantic import BaseModel # First-Party from mcpgateway.plugins.framework.base import Plugin -from mcpgateway.plugins.framework.constants import ( - CONTEXT, - GET_PLUGIN_CONFIG, - IGNORE_CONFIG_EXTERNAL, - NAME, - PAYLOAD, - PLUGIN_NAME, - PYTHON, - PYTHON_SUFFIX, -) +from mcpgateway.plugins.framework.constants import CONTEXT, ERROR, GET_PLUGIN_CONFIG, IGNORE_CONFIG_EXTERNAL, NAME, PAYLOAD, PLUGIN_NAME, PYTHON, PYTHON_SUFFIX, RESULT +from mcpgateway.plugins.framework.errors import PluginError from mcpgateway.plugins.framework.models import ( HookType, PluginConfig, PluginContext, + PluginErrorModel, PromptPosthookPayload, PromptPosthookResult, PromptPrehookPayload, @@ -51,6 +46,8 @@ ) from mcpgateway.schemas import TransportType +P = TypeVar("P", bound=BaseModel) + logger = logging.getLogger(__name__) @@ -69,6 +66,7 @@ def __init__(self, config: PluginConfig) -> None: self._http: Optional[Any] self._stdio: Optional[Any] self._write: Optional[Any] + self._current_task = asyncio.current_task() async def initialize(self) -> None: """Initialize the plugin's connection to the MCP server. @@ -143,6 +141,38 @@ async def __connect_to_http_server(self, uri: str): tools = response.tools logger.info("\nConnected to plugin MCP (http) server with tools: %s", " ".join([tool.name for tool in tools])) + async def __invoke_hook(self, payload_result_model: Type[P], hook_type: HookType, payload: BaseModel, context: PluginContext) -> P: + """Invoke an external plugin hook using the MCP protocol. + + Args: + payload_result_model: The type of result payload for the hook. + hook_type: The type of hook invoked (i.e., prompt_pre_hook) + payload: The payload to be passed to the hook. + context: The plugin context passed to the run. + + Returns: + The resulting payload from the plugin. + """ + + result = await self._session.call_tool(hook_type, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context}) + try: + for content in result.content: + res = json.loads(content.text) + if CONTEXT in res: + cxt = PluginContext.model_validate(res[CONTEXT]) + context.state = cxt.state + context.metadata = cxt.metadata + context.global_context.state = cxt.global_context.state + if RESULT in res: + return payload_result_model.model_validate(res[RESULT]) + if ERROR in res: + error = PluginErrorModel.model_validate(res[ERROR]) + raise PluginError(error) + except Exception as ex: + logger.exception(ex) + raise + raise PluginError(error=PluginErrorModel(message=f"Received invalid response. Result = {result}", plugin_name=self.name)) + async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: """Plugin hook run before a prompt is retrieved and rendered. @@ -154,11 +184,7 @@ async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginC The prompt prehook with name and arguments as modified or blocked by the plugin. """ - result = await self._session.call_tool(HookType.PROMPT_PRE_FETCH, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context}) - for content in result.content: - res = json.loads(content.text) - return PromptPrehookResult.model_validate(res) - return PromptPrehookResult(continue_processing=True) + return await self.__invoke_hook(payload_result_model=PromptPrehookResult, hook_type=HookType.PROMPT_PRE_FETCH, payload=payload, context=context) async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: """Plugin hook run after a prompt is rendered. @@ -170,13 +196,7 @@ async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: Plugi Returns: A set of prompt messages as modified or blocked by the plugin. """ - - result = await self._session.call_tool(HookType.PROMPT_POST_FETCH, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context}) - print(result) - for content in result.content: - res = json.loads(content.text) - return PromptPosthookResult.model_validate(res) - return PromptPosthookResult(continue_processing=True) + return await self.__invoke_hook(payload_result_model=PromptPosthookResult, hook_type=HookType.PROMPT_POST_FETCH, payload=payload, context=context) async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: """Plugin hook run before a tool is invoked. @@ -189,11 +209,7 @@ async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginCo The tool prehook with name and arguments as modified or blocked by the plugin. """ - result = await self._session.call_tool(HookType.TOOL_PRE_INVOKE, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context}) - for content in result.content: - res = json.loads(content.text) - return ToolPreInvokeResult.model_validate(res) - return ToolPreInvokeResult(continue_processing=True) + return await self.__invoke_hook(payload_result_model=ToolPreInvokeResult, hook_type=HookType.TOOL_PRE_INVOKE, payload=payload, context=context) async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: """Plugin hook run after a tool is invoked. @@ -206,11 +222,7 @@ async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: Plugin The tool posthook with name and arguments as modified or blocked by the plugin. """ - result = await self._session.call_tool(HookType.TOOL_POST_INVOKE, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context}) - for content in result.content: - res = json.loads(content.text) - return ToolPostInvokeResult.model_validate(res) - return ToolPostInvokeResult(continue_processing=True) + return await self.__invoke_hook(payload_result_model=ToolPostInvokeResult, hook_type=HookType.TOOL_POST_INVOKE, payload=payload, context=context) async def resource_pre_fetch(self, payload: ResourcePreFetchPayload, context: PluginContext) -> ResourcePreFetchResult: """Plugin hook run before a resource is fetched. @@ -223,11 +235,7 @@ async def resource_pre_fetch(self, payload: ResourcePreFetchPayload, context: Pl The resource prehook with name and arguments as modified or blocked by the plugin. """ - result = await self._session.call_tool(HookType.RESOURCE_PRE_FETCH, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context}) - for content in result.content: - res = json.loads(content.text) - return ResourcePreFetchResult.model_validate(res) - return ResourcePreFetchResult(continue_processing=True) + return await self.__invoke_hook(payload_result_model=ResourcePreFetchResult, hook_type=HookType.RESOURCE_PRE_FETCH, payload=payload, context=context) async def resource_post_fetch(self, payload: ResourcePostFetchPayload, context: PluginContext) -> ResourcePostFetchResult: """Plugin hook run after a resource is fetched. @@ -240,11 +248,7 @@ async def resource_post_fetch(self, payload: ResourcePostFetchPayload, context: The resource posthook with name and arguments as modified or blocked by the plugin. """ - result = await self._session.call_tool(HookType.RESOURCE_POST_FETCH, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context}) - for content in result.content: - res = json.loads(content.text) - return ResourcePostFetchResult.model_validate(res) - return ResourcePostFetchResult(continue_processing=True) + return await self.__invoke_hook(payload_result_model=ResourcePostFetchResult, hook_type=HookType.RESOURCE_POST_FETCH, payload=payload, context=context) async def __get_plugin_config(self) -> PluginConfig | None: """Retrieve plugin configuration for the current plugin on the remote MCP server. diff --git a/mcpgateway/plugins/framework/external/mcp/server/server.py b/mcpgateway/plugins/framework/external/mcp/server/server.py index 5b52aeb1b..a2a12c210 100644 --- a/mcpgateway/plugins/framework/external/mcp/server/server.py +++ b/mcpgateway/plugins/framework/external/mcp/server/server.py @@ -21,6 +21,7 @@ # First-Party from mcpgateway.plugins.framework.base import Plugin +from mcpgateway.plugins.framework.constants import CONTEXT, ERROR, RESULT from mcpgateway.plugins.framework.errors import convert_exception_to_error from mcpgateway.plugins.framework.loader.config import ConfigLoader from mcpgateway.plugins.framework.manager import DEFAULT_PLUGIN_TIMEOUT, PluginManager @@ -117,36 +118,42 @@ async def invoke_hook( >>> import asyncio >>> import os >>> os.environ["PYTHONPATH"] = "." - >>> from mcpgateway.plugins.framework import PromptPrehookPayload, PluginContext, PromptPrehookResult + >>> from mcpgateway.plugins.framework import GlobalContext, PromptPrehookPayload, PluginContext, PromptPrehookResult >>> server = ExternalPluginServer(config_path="./tests/unit/mcpgateway/plugins/fixtures/configs/valid_multiple_plugins_filter.yaml") >>> def prompt_pre_fetch_func(plugin: Plugin, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: ... return plugin.prompt_pre_fetch(payload, context) >>> payload = PromptPrehookPayload(name="test_prompt", args={"user": "This is so innovative"}) - >>> context = PluginContext(request_id="1", server_id="2") + >>> context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) >>> initialized = asyncio.run(server.initialize()) >>> initialized True >>> result = asyncio.run(server.invoke_hook(PromptPrehookPayload, prompt_pre_fetch_func, "DenyListPlugin", payload.model_dump(), context.model_dump())) >>> result is not None True - >>> result["continue_processing"] + >>> result["result"]["continue_processing"] False """ global_plugin_manager = PluginManager() plugin_timeout = global_plugin_manager.config.plugin_settings.plugin_timeout if global_plugin_manager.config else DEFAULT_PLUGIN_TIMEOUT plugin = global_plugin_manager.get_plugin(plugin_name) + result_payload: dict[str, Any] = {} try: if plugin: _payload = payload_model.model_validate(payload) _context = PluginContext.model_validate(context) result = await asyncio.wait_for(hook_function(plugin, _payload, _context), plugin_timeout) - return result.model_dump() + result_payload[RESULT] = result.model_dump() + if _context.state or _context.metadata or _context.global_context.state: + result_payload[CONTEXT] = _context.model_dump() + return result_payload raise ValueError(f"Unable to retrieve plugin {plugin_name} to execute.") except asyncio.TimeoutError: - return PluginErrorModel(message=f"Plugin {plugin_name} timed out from execution after {plugin_timeout} seconds.", plugin_name=plugin_name).model_dump() + result_payload[ERROR] = PluginErrorModel(message=f"Plugin {plugin_name} timed out from execution after {plugin_timeout} seconds.", plugin_name=plugin_name).model_dump() + return result_payload except Exception as ex: logger.exception(ex) - return convert_exception_to_error(ex, plugin_name=plugin_name).model_dump() + result_payload[ERROR] = convert_exception_to_error(ex, plugin_name=plugin_name).model_dump() + return result_payload async def initialize(self) -> bool: """Initialize the plugin server. diff --git a/mcpgateway/plugins/framework/manager.py b/mcpgateway/plugins/framework/manager.py index 7f5a36a92..f0abf4a0c 100644 --- a/mcpgateway/plugins/framework/manager.py +++ b/mcpgateway/plugins/framework/manager.py @@ -28,12 +28,14 @@ # Standard import asyncio +from copy import deepcopy import logging import time from typing import Any, Callable, Coroutine, Dict, Generic, Optional, Tuple, TypeVar # First-Party from mcpgateway.plugins.framework.base import Plugin, PluginRef +from mcpgateway.plugins.framework.errors import convert_exception_to_error, PluginError from mcpgateway.plugins.framework.loader.config import ConfigLoader from mcpgateway.plugins.framework.loader.plugin import PluginLoader from mcpgateway.plugins.framework.models import ( @@ -43,9 +45,9 @@ PluginCondition, PluginContext, PluginContextTable, + PluginErrorModel, PluginMode, PluginResult, - PluginViolation, PromptPosthookPayload, PromptPosthookResult, PromptPrehookPayload, @@ -111,13 +113,15 @@ class PluginExecutor(Generic[T]): >>> # ) """ - def __init__(self, timeout: int = DEFAULT_PLUGIN_TIMEOUT): + def __init__(self, config: Optional[Config] = None, timeout: int = DEFAULT_PLUGIN_TIMEOUT): """Initialize the plugin executor. Args: timeout: Maximum execution time per plugin in seconds. + config: the plugin manager configuration. """ self.timeout = timeout + self.config = config async def execute( self, @@ -177,18 +181,28 @@ async def execute( logger.debug(f"Skipping plugin {pluginref.name} - conditions not met") continue + tmp_global_context = GlobalContext( + request_id=global_context.request_id, + user=global_context.user, + tenant_id=global_context.tenant_id, + server_id=global_context.server_id, + state={} if not global_context.state else deepcopy(global_context.state), + ) # Get or create local context for this plugin local_context_key = global_context.request_id + pluginref.uuid if local_contexts and local_context_key in local_contexts: local_context = local_contexts[local_context_key] + local_context.global_context = tmp_global_context else: - local_context = PluginContext(request_id=global_context.request_id, user=global_context.user, tenant_id=global_context.tenant_id, server_id=global_context.server_id) + local_context = PluginContext(global_context=tmp_global_context) res_local_contexts[local_context_key] = local_context try: # Execute plugin with timeout protection result = await self._execute_with_timeout(pluginref, plugin_run, current_payload or payload, local_context) - + if local_context.global_context: + global_context.state.update(local_context.global_context.state) + global_context.metadata.update(local_context.global_context.metadata) # Aggregate metadata from all plugins if result.metadata: combined_metadata.update(result.metadata) @@ -211,25 +225,37 @@ async def execute( except asyncio.TimeoutError: logger.error(f"Plugin {pluginref.name} timed out after {self.timeout}s") + if self.config.plugin_settings.fail_on_plugin_error: + raise PluginError(error=PluginErrorModel(message=f"Plugin {pluginref.name} exceeded {self.timeout}s timeout", plugin_name=pluginref.name)) + """ if pluginref.plugin.mode == PluginMode.ENFORCE: violation = PluginViolation( reason="Plugin timeout", - description=f"Plugin {pluginref.name} exceeded {self.timeout}s timeout", + description=f"Plugin {pluginref.name} exceeded {self._timeout}s timeout", code="PLUGIN_TIMEOUT", - details={"timeout": self.timeout, "plugin": pluginref.name}, + details={"timeout": self._timeout, "plugin": pluginref.name}, ) return (PluginResult[T](continue_processing=False, violation=violation, modified_payload=current_payload, metadata=combined_metadata), res_local_contexts) # In permissive mode, continue with next plugin + """ continue + except PluginError as pe: + logger.error(f"Plugin {pluginref.name} failed with error: {str(pe)}", exc_info=True) + if self.config.plugin_settings.fail_on_plugin_error: + raise except Exception as e: logger.error(f"Plugin {pluginref.name} failed with error: {str(e)}", exc_info=True) + if self.config.plugin_settings.fail_on_plugin_error: + raise PluginError(error=convert_exception_to_error(e, pluginref.name)) + """ if pluginref.plugin.mode == PluginMode.ENFORCE: violation = PluginViolation( reason="Plugin error", description=f"Plugin {pluginref.name} encountered an error: {str(e)}", code="PLUGIN_ERROR", details={"error": str(e), "plugin": pluginref.name} ) return (PluginResult[T](continue_processing=False, violation=violation, modified_payload=current_payload, metadata=combined_metadata), res_local_contexts) # In permissive mode, continue with next plugin + """ continue return (PluginResult[T](continue_processing=True, modified_payload=current_payload, violation=None, metadata=combined_metadata), res_local_contexts) @@ -286,11 +312,11 @@ async def pre_prompt_fetch(plugin: PluginRef, payload: PromptPrehookPayload, con Examples: >>> from mcpgateway.plugins.framework.base import PluginRef - >>> from mcpgateway.plugins.framework import Plugin, PromptPrehookPayload, PluginContext, GlobalContext + >>> from mcpgateway.plugins.framework import GlobalContext, Plugin, PromptPrehookPayload, PluginContext, GlobalContext >>> # Assuming you have a plugin instance: >>> # plugin_ref = PluginRef(my_plugin) >>> payload = PromptPrehookPayload(name="test", args={"key": "value"}) - >>> context = PluginContext(request_id="123") + >>> context = PluginContext(global_context=GlobalContext(request_id="123")) >>> # In async context: >>> # result = await pre_prompt_fetch(plugin_ref, payload, context) """ @@ -310,13 +336,13 @@ async def post_prompt_fetch(plugin: PluginRef, payload: PromptPosthookPayload, c Examples: >>> from mcpgateway.plugins.framework.base import PluginRef - >>> from mcpgateway.plugins.framework import Plugin, PromptPosthookPayload, PluginContext, GlobalContext + >>> from mcpgateway.plugins.framework import GlobalContext, Plugin, PromptPosthookPayload, PluginContext, GlobalContext >>> from mcpgateway.models import PromptResult >>> # Assuming you have a plugin instance: >>> # plugin_ref = PluginRef(my_plugin) >>> result = PromptResult(messages=[]) >>> payload = PromptPosthookPayload(name="test", result=result) - >>> context = PluginContext(request_id="123") + >>> context = PluginContext(global_context=GlobalContext(request_id="123")) >>> # In async context: >>> # result = await post_prompt_fetch(plugin_ref, payload, context) """ @@ -336,11 +362,11 @@ async def pre_tool_invoke(plugin: PluginRef, payload: ToolPreInvokePayload, cont Examples: >>> from mcpgateway.plugins.framework.base import PluginRef - >>> from mcpgateway.plugins.framework import Plugin, ToolPreInvokePayload, PluginContext, GlobalContext + >>> from mcpgateway.plugins.framework import GlobalContext, Plugin, ToolPreInvokePayload, PluginContext, GlobalContext >>> # Assuming you have a plugin instance: >>> # plugin_ref = PluginRef(my_plugin) >>> payload = ToolPreInvokePayload(name="calculator", args={"operation": "add", "a": 5, "b": 3}) - >>> context = PluginContext(request_id="123") + >>> context = PluginContext(global_context=GlobalContext(request_id="123")) >>> # In async context: >>> # result = await pre_tool_invoke(plugin_ref, payload, context) """ @@ -360,11 +386,11 @@ async def post_tool_invoke(plugin: PluginRef, payload: ToolPostInvokePayload, co Examples: >>> from mcpgateway.plugins.framework.base import PluginRef - >>> from mcpgateway.plugins.framework import Plugin, ToolPostInvokePayload, PluginContext, GlobalContext + >>> from mcpgateway.plugins.framework import GlobalContext, Plugin, ToolPostInvokePayload, PluginContext, GlobalContext >>> # Assuming you have a plugin instance: >>> # plugin_ref = PluginRef(my_plugin) >>> payload = ToolPostInvokePayload(name="calculator", result={"result": 8, "status": "success"}) - >>> context = PluginContext(request_id="123") + >>> context = PluginContext(global_context=GlobalContext(request_id="123")) >>> # In async context: >>> # result = await post_tool_invoke(plugin_ref, payload, context) """ @@ -384,11 +410,11 @@ async def pre_resource_fetch(plugin: PluginRef, payload: ResourcePreFetchPayload Examples: >>> from mcpgateway.plugins.framework.base import PluginRef - >>> from mcpgateway.plugins.framework import Plugin, ResourcePreFetchPayload, PluginContext, GlobalContext + >>> from mcpgateway.plugins.framework import GlobalContext, Plugin, ResourcePreFetchPayload, PluginContext, GlobalContext >>> # Assuming you have a plugin instance: >>> # plugin_ref = PluginRef(my_plugin) >>> payload = ResourcePreFetchPayload(uri="file:///data.txt", metadata={"cache": True}) - >>> context = PluginContext(request_id="123") + >>> context = PluginContext(global_context=GlobalContext(request_id="123")) >>> # In async context: >>> # result = await pre_resource_fetch(plugin_ref, payload, context) """ @@ -408,13 +434,13 @@ async def post_resource_fetch(plugin: PluginRef, payload: ResourcePostFetchPaylo Examples: >>> from mcpgateway.plugins.framework.base import PluginRef - >>> from mcpgateway.plugins.framework import Plugin, ResourcePostFetchPayload, PluginContext, GlobalContext + >>> from mcpgateway.plugins.framework import GlobalContext, Plugin, ResourcePostFetchPayload, PluginContext, GlobalContext >>> from mcpgateway.models import ResourceContent >>> # Assuming you have a plugin instance: >>> # plugin_ref = PluginRef(my_plugin) >>> content = ResourceContent(type="resource", uri="file:///data.txt", text="Data") >>> payload = ResourcePostFetchPayload(uri="file:///data.txt", content=content) - >>> context = PluginContext(request_id="123") + >>> context = PluginContext(global_context=GlobalContext(request_id="123")) >>> # In async context: >>> # result = await post_resource_fetch(plugin_ref, payload, context) """ @@ -495,6 +521,12 @@ def __init__(self, config: str = "", timeout: int = DEFAULT_PLUGIN_TIMEOUT): self._post_tool_executor.timeout = timeout self._resource_pre_executor.timeout = timeout self._resource_post_executor.timeout = timeout + self._pre_prompt_executor.config = self._config + self._post_prompt_executor.config = self._config + self._pre_tool_executor.config = self._config + self._post_tool_executor.config = self._config + self._resource_pre_executor.config = self._config + self._resource_post_executor.config = self._config # Initialize context tracking if not already done if not hasattr(self, "_context_store"): diff --git a/mcpgateway/plugins/framework/models.py b/mcpgateway/plugins/framework/models.py index 055400754..4d6457da4 100644 --- a/mcpgateway/plugins/framework/models.py +++ b/mcpgateway/plugins/framework/models.py @@ -639,6 +639,8 @@ class GlobalContext(BaseModel): user (str): user ID associated with the request. tenant_id (str): tenant ID. server_id (str): server ID. + metadata (Optional[dict[str,Any]]): a global shared metadata across plugins. + state (Optional[dict[str,Any]]): a global shared state across plugins. Examples: >>> ctx = GlobalContext(request_id="req-123") @@ -662,17 +664,32 @@ class GlobalContext(BaseModel): user: Optional[str] = None tenant_id: Optional[str] = None server_id: Optional[str] = None + state: dict[str, Any] = {} + metadata: dict[str, Any] = {} -class PluginContext(GlobalContext): +class PluginContext(BaseModel): """The plugin's context, which lasts a request lifecycle. Attributes: - metadata: context metadata. state: the inmemory state of the request. + global_context: the context that is shared across plugins. + metadata: plugin meta data. + + Examples: + >>> gctx = GlobalContext(request_id="req-123") + >>> ctx = PluginContext(global_context=gctx) + >>> ctx.global_context.request_id + 'req-123' + >>> ctx.global_context.user is None + True + >>> ctx.state["somekey"] = "some value" + >>> ctx.state["somekey"] + 'some value' """ state: dict[str, Any] = {} + global_context: GlobalContext metadata: dict[str, Any] = {} def get_state(self, key: str, default: Any = None) -> Any: diff --git a/plugins/resource_filter/resource_filter.py b/plugins/resource_filter/resource_filter.py index 7d118e78e..7d0a9b7ae 100644 --- a/plugins/resource_filter/resource_filter.py +++ b/plugins/resource_filter/resource_filter.py @@ -157,8 +157,8 @@ async def resource_pre_fetch( **payload.metadata, "validated": True, "protocol": parsed.scheme, - "request_id": context.request_id, - "user": context.user, + "request_id": context.global_context.request_id, + "user": context.global_context.user, "resource_filter_plugin": "pre_fetch_validated", "allowed_size": self.max_content_size } diff --git a/tests/unit/mcpgateway/plugins/fixtures/configs/context_multiplugins.yaml b/tests/unit/mcpgateway/plugins/fixtures/configs/context_multiplugins.yaml new file mode 100644 index 000000000..727347a9f --- /dev/null +++ b/tests/unit/mcpgateway/plugins/fixtures/configs/context_multiplugins.yaml @@ -0,0 +1,44 @@ +plugins: + # Self-contained Search Replace Plugin + - name: "ContextPlugin" + kind: "tests.unit.mcpgateway.plugins.fixtures.plugins.context.ContextPlugin" + description: "A context plugin." + version: "0.1" + author: "MCP Context Forge Team" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_post_invoke", "tool_pre_invoke"] + tags: ["plugin", "error"] + mode: "enforce" # enforce | permissive | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - prompts: ["test_prompt"] + server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + - name: "ContextPlugin2" + kind: "tests.unit.mcpgateway.plugins.fixtures.plugins.context.ContextPlugin2" + description: "A context plugin." + version: "0.1" + author: "MCP Context Forge Team" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_post_invoke", "tool_pre_invoke"] + tags: ["plugin", "error"] + mode: "enforce" # enforce | permissive | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - prompts: ["test_prompt"] + server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + +# Plugin directories to scan +plugin_dirs: + - "plugins/native" # Built-in plugins + - "plugins/custom" # Custom organization plugins + - "/etc/mcpgateway/plugins" # System-wide plugins + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: true + enable_plugin_api: true + plugin_health_check_interval: 60 \ No newline at end of file diff --git a/tests/unit/mcpgateway/plugins/fixtures/configs/context_plugin.yaml b/tests/unit/mcpgateway/plugins/fixtures/configs/context_plugin.yaml new file mode 100644 index 000000000..d2d742694 --- /dev/null +++ b/tests/unit/mcpgateway/plugins/fixtures/configs/context_plugin.yaml @@ -0,0 +1,30 @@ +plugins: + # Self-contained Search Replace Plugin + - name: "ContextPlugin" + kind: "tests.unit.mcpgateway.plugins.fixtures.plugins.context.ContextPlugin" + description: "A context plugin." + version: "0.1" + author: "MCP Context Forge Team" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_post_invoke", "tool_pre_invoke"] + tags: ["plugin", "error"] + mode: "enforce" # enforce | permissive | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - prompts: ["test_prompt"] + server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + +# Plugin directories to scan +plugin_dirs: + - "plugins/native" # Built-in plugins + - "plugins/custom" # Custom organization plugins + - "/etc/mcpgateway/plugins" # System-wide plugins + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: true + enable_plugin_api: true + plugin_health_check_interval: 60 \ No newline at end of file diff --git a/tests/unit/mcpgateway/plugins/fixtures/configs/context_stdio_external_plugins.yaml b/tests/unit/mcpgateway/plugins/fixtures/configs/context_stdio_external_plugins.yaml new file mode 100644 index 000000000..506357c29 --- /dev/null +++ b/tests/unit/mcpgateway/plugins/fixtures/configs/context_stdio_external_plugins.yaml @@ -0,0 +1,27 @@ +# plugins/config.yaml - Main plugin configuration file + +plugins: + - name: "ContextPlugin" + kind: "external" + mcp: + proto: STDIO + script: mcpgateway/plugins/framework/external/mcp/server/runtime.py + - name: "ContextPlugin2" + kind: "external" + mcp: + proto: STDIO + script: mcpgateway/plugins/framework/external/mcp/server/runtime.py + +# Plugin directories to scan +plugin_dirs: + - "plugins/native" # Built-in plugins + - "plugins/custom" # Custom organization plugins + - "/etc/mcpgateway/plugins" # System-wide plugins + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: true + enable_plugin_api: true + plugin_health_check_interval: 60 diff --git a/tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin.yaml b/tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin.yaml new file mode 100644 index 000000000..3df854e5a --- /dev/null +++ b/tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin.yaml @@ -0,0 +1,30 @@ +plugins: + # Self-contained Search Replace Plugin + - name: "ErrorPlugin" + kind: "tests.unit.mcpgateway.plugins.fixtures.plugins.error.ErrorPlugin" + description: "An error plugin." + version: "0.1" + author: "MCP Context Forge Team" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_post_invoke", "tool_pre_invoke"] + tags: ["plugin", "error"] + mode: "enforce" # enforce | permissive | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - prompts: ["test_prompt"] + server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + +# Plugin directories to scan +plugin_dirs: + - "plugins/native" # Built-in plugins + - "plugins/custom" # Custom organization plugins + - "/etc/mcpgateway/plugins" # System-wide plugins + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: true + enable_plugin_api: true + plugin_health_check_interval: 60 diff --git a/tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin_raise_error_false.yaml b/tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin_raise_error_false.yaml new file mode 100644 index 000000000..d2f8d95d2 --- /dev/null +++ b/tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin_raise_error_false.yaml @@ -0,0 +1,30 @@ +plugins: + # Self-contained Search Replace Plugin + - name: "ErrorPlugin" + kind: "tests.unit.mcpgateway.plugins.fixtures.plugins.error.ErrorPlugin" + description: "An error plugin." + version: "0.1" + author: "MCP Context Forge Team" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_post_invoke", "tool_pre_invoke"] + tags: ["plugin", "error"] + mode: "enforce" # enforce | permissive | disabled + priority: 150 + conditions: + # Apply to specific tools/servers + - prompts: ["test_prompt"] + server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + +# Plugin directories to scan +plugin_dirs: + - "plugins/native" # Built-in plugins + - "plugins/custom" # Custom organization plugins + - "/etc/mcpgateway/plugins" # System-wide plugins + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: false + enable_plugin_api: true + plugin_health_check_interval: 60 diff --git a/tests/unit/mcpgateway/plugins/fixtures/configs/error_stdio_external_plugin.yaml b/tests/unit/mcpgateway/plugins/fixtures/configs/error_stdio_external_plugin.yaml new file mode 100644 index 000000000..27d91f066 --- /dev/null +++ b/tests/unit/mcpgateway/plugins/fixtures/configs/error_stdio_external_plugin.yaml @@ -0,0 +1,22 @@ +# plugins/config.yaml - Main plugin configuration file + +plugins: + - name: "ErrorPlugin" + kind: "external" + mcp: + proto: STDIO + script: mcpgateway/plugins/framework/external/mcp/server/runtime.py + +# Plugin directories to scan +plugin_dirs: + - "plugins/native" # Built-in plugins + - "plugins/custom" # Custom organization plugins + - "/etc/mcpgateway/plugins" # System-wide plugins + +# Global plugin settings +plugin_settings: + parallel_execution_within_band: true + plugin_timeout: 30 + fail_on_plugin_error: true + enable_plugin_api: true + plugin_health_check_interval: 60 diff --git a/tests/unit/mcpgateway/plugins/fixtures/plugins/context.py b/tests/unit/mcpgateway/plugins/fixtures/plugins/context.py new file mode 100644 index 000000000..2dddaf3d6 --- /dev/null +++ b/tests/unit/mcpgateway/plugins/fixtures/plugins/context.py @@ -0,0 +1,201 @@ + +""" +Context plugin. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +""" + + +from mcpgateway.plugins.framework import ( + Plugin, + PluginContext, + PromptPosthookPayload, + PromptPosthookResult, + PromptPrehookPayload, + PromptPrehookResult, + ResourcePostFetchPayload, + ResourcePostFetchResult, + ResourcePreFetchPayload, + ResourcePreFetchResult, + ToolPostInvokePayload, + ToolPostInvokeResult, + ToolPreInvokePayload, + ToolPreInvokeResult, +) + +class ContextPlugin(Plugin): + """A simple Context plugin.""" + + async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: + """The plugin hook run before a prompt is retrieved and rendered. + + Args: + payload: The prompt payload to be analyzed. + context: contextual information about the hook call. + + """ + context.state["key1"] = "value1" + return PromptPrehookResult(continue_processing=True) + + async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: + """Plugin hook run after a prompt is rendered. + + Args: + payload: The prompt payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + if "key1" not in context.state or context.state["key1"] != "value1": + raise ValueError("key1 not in context!! It should be!!") + return PromptPosthookResult(continue_processing=True) + + + async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: + """Plugin hook run before a tool is invoked. + + Args: + payload: The tool payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool can proceed. + """ + context.state["key2"] = "value2" + context.global_context.state["globkey1"] = "globvalue1" + return ToolPreInvokeResult(continue_processing=True) + + async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: + """Plugin hook run after a tool is invoked. + + Args: + payload: The tool result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool result should proceed. + """ + if "key2" not in context.state or context.state["key2"] != "value2": + raise ValueError("key2 not in context!! It should be!!") + if "globkey1" not in context.global_context.state or context.global_context.state["globkey1"] != "globvalue1": + raise ValueError("globkey1 not in context!! It should be!!") + context.state["key3"] = "value3" + context.global_context.state["globkey2"] = "globvalue2" + return ToolPostInvokeResult(continue_processing=True) + + async def resource_post_fetch(self, payload: ResourcePostFetchPayload, context: PluginContext) -> ResourcePostFetchResult: + """Plugin hook run after a resource was fetched. + + Args: + payload: The resource result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the resource result should proceed. + """ + return ResourcePostFetchResult(continue_processing=True) + + async def resource_pre_fetch(self, payload: ResourcePreFetchPayload, context: PluginContext) -> ResourcePreFetchResult: + """Plugin hook run before a resource was fetched. + + Args: + payload: The resource result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the resource result should proceed. + """ + return ResourcePreFetchResult(continue_processing=True) + +class ContextPlugin2(Plugin): + """A simple Context plugin.""" + + async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: + """The plugin hook run before a prompt is retrieved and rendered. + + Args: + payload: The prompt payload to be analyzed. + context: contextual information about the hook call. + + """ + if "key1" in context.state: + raise ValueError("key1 should not be in ContextPlugin2's context") + #context.state["cp2key1"] = "cp2value1" + return PromptPrehookResult(continue_processing=True) + + async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: + """Plugin hook run after a prompt is rendered. + + Args: + payload: The prompt payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + if "key1" not in context.state or context.state["key1"] != "value1": + raise ValueError("key1 not in context!! It should be!!") + return PromptPosthookResult(continue_processing=True) + + + async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: + """Plugin hook run before a tool is invoked. + + Args: + payload: The tool payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool can proceed. + """ + if "key2" in context.state: + raise ValueError("key2 should not be in ContextPlugin2's context") + context.state["cp2key1"] = "cp2value1" + if "globkey1" not in context.global_context.state: + raise ValueError("globkey1 should be in ContextPlugin2's context") + context.global_context.state["gcp2globkey1"] = "gcp2globvalue1" + return ToolPreInvokeResult(continue_processing=True) + + async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: + """Plugin hook run after a tool is invoked. + + Args: + payload: The tool result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool result should proceed. + """ + if "key2" in context.state: + raise ValueError("key2 should not be in ContextPlugin2's context") + if "globkey1" not in context.global_context.state or context.global_context.state["globkey1"] != "globvalue1": + raise ValueError("globkey1 not in context!! It should be!!") + context.state["cp2key2"] = "cp2value2" + context.global_context.state["gcp2globkey2"] = "gcp2globvalue2" + return ToolPostInvokeResult(continue_processing=True) + + async def resource_post_fetch(self, payload: ResourcePostFetchPayload, context: PluginContext) -> ResourcePostFetchResult: + """Plugin hook run after a resource was fetched. + + Args: + payload: The resource result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the resource result should proceed. + """ + return ResourcePostFetchResult(continue_processing=True) + + async def resource_pre_fetch(self, payload: ResourcePreFetchPayload, context: PluginContext) -> ResourcePreFetchResult: + """Plugin hook run before a resource was fetched. + + Args: + payload: The resource result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the resource result should proceed. + """ + return ResourcePreFetchResult(continue_processing=True) \ No newline at end of file diff --git a/tests/unit/mcpgateway/plugins/fixtures/plugins/error.py b/tests/unit/mcpgateway/plugins/fixtures/plugins/error.py new file mode 100644 index 000000000..c67daea3f --- /dev/null +++ b/tests/unit/mcpgateway/plugins/fixtures/plugins/error.py @@ -0,0 +1,98 @@ + +""" +Error plugin. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +""" + + +from mcpgateway.plugins.framework import ( + Plugin, + PluginContext, + PromptPosthookPayload, + PromptPosthookResult, + PromptPrehookPayload, + PromptPrehookResult, + ResourcePostFetchPayload, + ResourcePostFetchResult, + ResourcePreFetchPayload, + ResourcePreFetchResult, + ToolPostInvokePayload, + ToolPostInvokeResult, + ToolPreInvokePayload, + ToolPreInvokeResult, +) + +class ErrorPlugin(Plugin): + """A simple error plugin.""" + + async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: + """The plugin hook run before a prompt is retrieved and rendered. + + Args: + payload: The prompt payload to be analyzed. + context: contextual information about the hook call. + + """ + raise ValueError("Sadly! Prompt prefetch is broken!") + + async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: PluginContext) -> PromptPosthookResult: + """Plugin hook run after a prompt is rendered. + + Args: + payload: The prompt payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the prompt can proceed. + """ + raise ValueError("Sadly! Prompt postfetch is broken!") + + async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: + """Plugin hook run before a tool is invoked. + + Args: + payload: The tool payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool can proceed. + """ + raise ValueError("Sadly! Tool prefetch is broken!") + + async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: + """Plugin hook run after a tool is invoked. + + Args: + payload: The tool result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the tool result should proceed. + """ + raise ValueError("Sadly! Tool postfetch is broken!") + + async def resource_post_fetch(self, payload: ResourcePostFetchPayload, context: PluginContext) -> ResourcePostFetchResult: + """Plugin hook run after a resource was fetched. + + Args: + payload: The resource result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the resource result should proceed. + """ + return ResourcePostFetchResult(continue_processing=True) + + async def resource_pre_fetch(self, payload: ResourcePreFetchPayload, context: PluginContext) -> ResourcePreFetchResult: + """Plugin hook run before a resource was fetched. + + Args: + payload: The resource result payload to be analyzed. + context: Contextual information about the hook call. + + Returns: + The result of the plugin's analysis, including whether the resource result should proceed. + """ + return ResourcePreFetchResult(continue_processing=True) \ No newline at end of file diff --git a/tests/unit/mcpgateway/plugins/framework/external/mcp/server/test_runtime.py b/tests/unit/mcpgateway/plugins/framework/external/mcp/server/test_runtime.py index 54d956f98..e70565ddd 100644 --- a/tests/unit/mcpgateway/plugins/framework/external/mcp/server/test_runtime.py +++ b/tests/unit/mcpgateway/plugins/framework/external/mcp/server/test_runtime.py @@ -16,6 +16,7 @@ # First-Party from mcpgateway.models import Message, PromptResult, Role, TextContent from mcpgateway.plugins.framework import ( + GlobalContext, PluginContext, PromptPosthookPayload, PromptPrehookPayload, @@ -55,10 +56,11 @@ async def test_get_plugin_config(monkeypatch, server): async def test_prompt_pre_fetch(monkeypatch, server): monkeypatch.setattr(runtime, "SERVER", server) payload = PromptPrehookPayload(name="test_prompt", args={"user": "This is so innovative"}) - context = PluginContext(request_id="1", server_id="2") + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) result = await runtime.prompt_pre_fetch("DenyListPlugin", payload=payload, context=context) assert result - assert not result["continue_processing"] + assert result["result"] + assert not result["result"]["continue_processing"] @pytest.mark.asyncio @@ -67,21 +69,23 @@ async def test_prompt_post_fetch(monkeypatch, server): message = Message(content=TextContent(type="text", text="crap prompt"), role=Role.USER) prompt_result = PromptResult(messages=[message]) payload = PromptPosthookPayload(name="test_prompt", result=prompt_result) - context = PluginContext(request_id="1", server_id="2") + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) result = await runtime.prompt_post_fetch("ReplaceBadWordsPlugin", payload=payload, context=context) assert result - assert result["continue_processing"] - assert "crap" not in result["modified_payload"] + assert result["result"] + assert result["result"]["continue_processing"] + assert "crap" not in result["result"]["modified_payload"] @pytest.mark.asyncio async def test_tool_pre_invoke(monkeypatch, server): monkeypatch.setattr(runtime, "SERVER", server) payload = ToolPreInvokePayload(name="test_tool", args={"arg0": "Good argument"}) - context = PluginContext(request_id="1", server_id="2") + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) result = await runtime.tool_pre_invoke("ReplaceBadWordsPlugin", payload=payload, context=context) assert result - assert result["continue_processing"] + assert result["result"] + assert result["result"]["continue_processing"] @pytest.mark.asyncio @@ -90,28 +94,31 @@ async def test_tool_post_invoke(monkeypatch, server): message = Message(content=TextContent(type="text", text="crap result"), role=Role.USER) prompt_result = ToolPostInvokeResult(messages=[message]) payload = ToolPostInvokePayload(name="test_prompt", result=prompt_result) - context = PluginContext(request_id="1", server_id="2") + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) result = await runtime.tool_post_invoke("ReplaceBadWordsPlugin", payload=payload, context=context) assert result - assert result["continue_processing"] - assert "crap" not in result["modified_payload"] + assert result["result"] + assert result["result"]["continue_processing"] + assert "crap" not in result["result"]["modified_payload"] @pytest.mark.asyncio async def test_resource_pre_fetch(monkeypatch, server): monkeypatch.setattr(runtime, "SERVER", server) payload = ResourcePreFetchPayload(uri="resource", metadata={"arg0": "Good argument"}) - context = PluginContext(request_id="1", server_id="2") + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) result = await runtime.resource_pre_fetch("ResourceFilterExample", payload=payload, context=context) assert result - assert not result["continue_processing"] + assert result["result"] + assert not result["result"]["continue_processing"] @pytest.mark.asyncio async def test_tool_post_invoke(monkeypatch, server): monkeypatch.setattr(runtime, "SERVER", server) payload = ResourcePostFetchPayload(uri="resource", content="content") - context = PluginContext(request_id="1", server_id="2") + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) result = await runtime.resource_post_fetch("ResourceFilterExample", payload=payload, context=context) assert result - assert result["continue_processing"] + assert result["result"] + assert result["result"]["continue_processing"] diff --git a/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_stdio.py b/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_stdio.py index d35655142..a9bf490e2 100644 --- a/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_stdio.py +++ b/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_stdio.py @@ -9,6 +9,7 @@ from contextlib import AsyncExitStack import json import os +import re import sys from typing import Optional import pytest @@ -20,6 +21,7 @@ ConfigLoader, GlobalContext, PluginConfig, + PluginError, PluginLoader, PluginManager, PluginContext, @@ -42,7 +44,7 @@ async def test_client_load_stdio(): loader = PluginLoader() plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) prompt = PromptPrehookPayload(name="test_prompt", args = {"text": "That was innovative!"}) - result = await plugin.prompt_pre_fetch(prompt, PluginContext(request_id="1", server_id="2")) + result = await plugin.prompt_pre_fetch(prompt, PluginContext(global_context=GlobalContext(request_id="1", server_id="2"))) assert result.violation assert result.violation.reason == "Prompt not allowed" assert result.violation.description == "A deny word was found in the prompt" @@ -65,7 +67,7 @@ async def test_client_load_stdio_overrides(): loader = PluginLoader() plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) prompt = PromptPrehookPayload(name="test_prompt", args = {"text": "That was innovative!"}) - result = await plugin.prompt_pre_fetch(prompt, PluginContext(request_id="1", server_id="2")) + result = await plugin.prompt_pre_fetch(prompt, PluginContext(global_context=GlobalContext(request_id="1", server_id="2"))) assert result.violation assert result.violation.reason == "Prompt not allowed" assert result.violation.description == "A deny word was found in the prompt" @@ -90,7 +92,7 @@ async def test_client_load_stdio_post_prompt(): loader = PluginLoader() plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) prompt = PromptPrehookPayload(name="test_prompt", args = {"user": "What a crapshow!"}) - context = PluginContext(request_id="1", server_id="2") + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) result = await plugin.prompt_pre_fetch(prompt, context) assert result.modified_payload.args["user"] == "What a yikesshow!" config = plugin.config @@ -206,3 +208,87 @@ async def test_hooks(): # Assert expected behaviors assert result.continue_processing await plugin_manager.shutdown() + +@pytest.mark.asyncio +async def test_errors(): + os.environ["PLUGINS_CONFIG_PATH"] = "tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin.yaml" + os.environ["PYTHONPATH"] = "." + plugin_manager = PluginManager(config="tests/unit/mcpgateway/plugins/fixtures/configs/error_stdio_external_plugin.yaml") + await plugin_manager.initialize() + payload = PromptPrehookPayload(name="test_prompt", args={"arg0": "This is a crap argument"}) + global_context = GlobalContext(request_id="1") + escaped_regex = re.escape("ValueError('Sadly! Prompt prefetch is broken!')") + with pytest.raises(PluginError, match=escaped_regex): + await plugin_manager.prompt_pre_fetch(payload, global_context) + + await plugin_manager.shutdown() + + +@pytest.mark.skip(reason="Fails on await manager.shutdown().") +@pytest.mark.asyncio +async def test_shared_context_across_pre_post_hooks_multi_plugins(): + os.environ["PLUGINS_CONFIG_PATH"] = "tests/unit/mcpgateway/plugins/fixtures/configs/context_multiplugins.yaml" + os.environ["PYTHONPATH"] = "." + manager = PluginManager("./tests/unit/mcpgateway/plugins/fixtures/configs/context_stdio_external_plugins.yaml") + await manager.initialize() + assert manager.initialized + + # Test tool pre-invoke with transformation - use correct tool name from config + tool_payload = ToolPreInvokePayload(name="test_tool", args={"input": "This is bad data", "quality": "wrong"}) + global_context = GlobalContext(request_id="1", server_id="2") + result, contexts = await manager.tool_pre_invoke(tool_payload, global_context=global_context) + + assert len(contexts) == 2 + ctxs = [contexts[key] for key in contexts.keys()] + assert len(ctxs) == 2 + context1 = ctxs[0] + context2 = ctxs[1] + assert context1.state + assert "key2" in context1.state + assert "cp2key1" not in context1.state + assert context1.state["key2"] == "value2" + assert len(context1.state) == 1 + assert context1.global_context.state["globkey1"] == "globvalue1" + assert "gcp2globkey1" not in context1.global_context.state + assert len(context1.global_context.state) + assert not context1.global_context.metadata + + assert context2.state + assert len(context2.state) == 1 + assert "cp2key1" in context2.state + assert "key2" not in context2.state + assert context2.global_context.state["globkey1"] == "globvalue1" + assert context2.global_context.state["gcp2globkey1"] == "gcp2globvalue1" + + # Should continue processing with transformations applied + assert result.continue_processing + assert result.modified_payload is None + # Test tool post-invoke with transformation + tool_result_payload = ToolPostInvokePayload(name="test_tool", result={"output": "Result was bad", "status": "wrong format"}) + result, contexts = await manager.tool_post_invoke(tool_result_payload, global_context=global_context, local_contexts=contexts) + + ctxs = [contexts[key] for key in contexts.keys()] + assert len(ctxs) == 2 + context1 = ctxs[0] + context2 = ctxs[1] + assert context1.state + assert len(context1.state) == 2 + assert context1.state["key3"] == "value3" + assert context1.state["key2"] == "value2" + assert "cp2key1" not in context1.state + assert "cp2key2" not in context1.state + assert context1.global_context.state["globkey1"] == "globvalue1" + assert context1.global_context.state["gcp2globkey1"] == "gcp2globvalue1" + assert "gcp2globkey2" not in context1.global_context.state + assert context1.global_context.state["globkey2"] == "globvalue2" + + assert context2.global_context.state["globkey1"] == "globvalue1" + assert context2.global_context.state["gcp2globkey1"] == "gcp2globvalue1" + assert context2.global_context.state["gcp2globkey2"] == "gcp2globvalue2" + assert context2.global_context.state["globkey2"] == "globvalue2" + + assert "key3" not in context2.state + assert "key2" not in context2.state + assert "cp2key1" in context2.state + + await manager.shutdown() diff --git a/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_streamable_http.py b/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_streamable_http.py index 46ecb7c31..dc24ba239 100644 --- a/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_streamable_http.py +++ b/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_streamable_http.py @@ -14,7 +14,7 @@ import pytest from mcpgateway.models import Message, PromptResult, Role, TextContent -from mcpgateway.plugins.framework import ConfigLoader, PluginLoader, PluginContext, PromptPrehookPayload, PromptPosthookPayload +from mcpgateway.plugins.framework import ConfigLoader, GlobalContext, PluginLoader, PluginContext, PromptPrehookPayload, PromptPosthookPayload @pytest.fixture(autouse=True) def server_proc(): @@ -43,7 +43,7 @@ async def test_client_load_streamable_http(server_proc): loader = PluginLoader() plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) prompt = PromptPrehookPayload(name="test_prompt", args = {"user": "What a crapshow!"}) - context = PluginContext(request_id="1", server_id="2") + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) result = await plugin.prompt_pre_fetch(prompt, context) assert result.modified_payload.args["user"] == "What a yikesshow!" config = plugin.config @@ -91,7 +91,7 @@ async def test_client_load_strhttp_overrides(server_proc1): loader = PluginLoader() plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) prompt = PromptPrehookPayload(name="test_prompt", args = {"text": "That was innovative!"}) - result = await plugin.prompt_pre_fetch(prompt, PluginContext(request_id="1", server_id="2")) + result = await plugin.prompt_pre_fetch(prompt, PluginContext(global_context=GlobalContext(request_id="1", server_id="2"))) assert result.violation assert result.violation.reason == "Prompt not allowed" assert result.violation.description == "A deny word was found in the prompt" @@ -135,7 +135,7 @@ async def test_client_load_strhttp_post_prompt(server_proc2): loader = PluginLoader() plugin = await loader.load_and_instantiate_plugin(config.plugins[0]) prompt = PromptPrehookPayload(name="test_prompt", args = {"user": "What a crapshow!"}) - context = PluginContext(request_id="1", server_id="2") + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) result = await plugin.prompt_pre_fetch(prompt, context) assert result.modified_payload.args["user"] == "What a yikesshow!" config = plugin.config diff --git a/tests/unit/mcpgateway/plugins/framework/loader/test_plugin_loader.py b/tests/unit/mcpgateway/plugins/framework/loader/test_plugin_loader.py index 2fa8ef453..36ad3eef4 100644 --- a/tests/unit/mcpgateway/plugins/framework/loader/test_plugin_loader.py +++ b/tests/unit/mcpgateway/plugins/framework/loader/test_plugin_loader.py @@ -14,9 +14,9 @@ from mcpgateway.models import Message, PromptResult, Role, TextContent from mcpgateway.plugins.framework.loader.config import ConfigLoader from mcpgateway.plugins.framework.loader.plugin import PluginLoader -from mcpgateway.plugins.framework.models import PluginContext, PluginMode, PromptPosthookPayload, PromptPrehookPayload +from mcpgateway.plugins.framework.models import GlobalContext, PluginContext, PluginMode, PromptPosthookPayload, PromptPrehookPayload from plugins.regex_filter.search_replace import SearchReplaceConfig, SearchReplacePlugin -from unittest.mock import patch, MagicMock +from unittest.mock import patch def test_config_loader_load(): @@ -52,7 +52,7 @@ async def test_plugin_loader_load(): assert plugin.hooks[0] == "prompt_pre_fetch" assert plugin.hooks[1] == "prompt_post_fetch" - context = PluginContext(request_id="1", server_id="2") + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) prompt = PromptPrehookPayload(name="test_prompt", args = {"user": "What a crapshow!"}) result = await plugin.prompt_pre_fetch(prompt, context=context) assert len(result.modified_payload.args) == 1 diff --git a/tests/unit/mcpgateway/plugins/framework/test_context.py b/tests/unit/mcpgateway/plugins/framework/test_context.py new file mode 100644 index 000000000..443ca42e5 --- /dev/null +++ b/tests/unit/mcpgateway/plugins/framework/test_context.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +""" +Tests for context passing plugins. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +""" + +import pytest +import re +from mcpgateway.plugins.framework import ( + GlobalContext, + PluginError, + PluginManager, + ToolPreInvokePayload, + ToolPostInvokePayload, + ToolPostInvokeResult, + ToolPreInvokeResult, +) + + +@pytest.mark.asyncio +async def test_shared_context_across_pre_post_hooks(): + manager = PluginManager("./tests/unit/mcpgateway/plugins/fixtures/configs/context_plugin.yaml") + await manager.initialize() + assert manager.initialized + + # Test tool pre-invoke with transformation - use correct tool name from config + tool_payload = ToolPreInvokePayload(name="test_tool", args={"input": "This is bad data", "quality": "wrong"}) + global_context = GlobalContext(request_id="1", server_id="2") + result, contexts = await manager.tool_pre_invoke(tool_payload, global_context=global_context) + + assert len(contexts) == 1 + context = next(iter(contexts.values())) + assert context.state + assert "key2" in context.state + assert context.state["key2"] == "value2" + assert context.global_context.state["globkey1"] == "globvalue1" + assert len(context.global_context.state) + assert not context.global_context.metadata + + # Should continue processing with transformations applied + assert result.continue_processing + assert result.modified_payload is None + + # Test tool post-invoke with transformation + tool_result_payload = ToolPostInvokePayload(name="test_tool", result={"output": "Result was bad", "status": "wrong format"}) + result, contexts = await manager.tool_post_invoke(tool_result_payload, global_context=global_context, local_contexts=contexts) + + assert len(contexts) == 1 + context = next(iter(contexts.values())) + assert context.state + assert len(context.state) == 2 + assert "key2" in context.state + assert context.state["key2"] == "value2" + assert context.state["key3"] == "value3" + assert context.global_context.state + assert context.global_context.state["globkey1"] == "globvalue1" + assert context.global_context.state["globkey2"] == "globvalue2" + assert len(context.global_context.state) == 2 + + # Should continue processing with transformations applied + assert result.continue_processing + assert result.modified_payload is None + await manager.shutdown() + +@pytest.mark.asyncio +async def test_shared_context_across_pre_post_hooks_multi_plugins(): + manager = PluginManager("./tests/unit/mcpgateway/plugins/fixtures/configs/context_multiplugins.yaml") + await manager.initialize() + assert manager.initialized + + # Test tool pre-invoke with transformation - use correct tool name from config + tool_payload = ToolPreInvokePayload(name="test_tool", args={"input": "This is bad data", "quality": "wrong"}) + global_context = GlobalContext(request_id="1", server_id="2") + result, contexts = await manager.tool_pre_invoke(tool_payload, global_context=global_context) + + assert len(contexts) == 2 + ctxs = [contexts[key] for key in contexts.keys()] + assert len(ctxs) == 2 + context1 = ctxs[0] + context2 = ctxs[1] + assert context1.state + assert "key2" in context1.state + assert "cp2key1" not in context1.state + assert context1.state["key2"] == "value2" + assert len(context1.state) == 1 + assert context1.global_context.state["globkey1"] == "globvalue1" + assert "gcp2globkey1" not in context1.global_context.state + assert len(context1.global_context.state) + assert not context1.global_context.metadata + + assert context2.state + assert len(context2.state) == 1 + assert "cp2key1" in context2.state + assert "key2" not in context2.state + assert context2.global_context.state["globkey1"] == "globvalue1" + assert context2.global_context.state["gcp2globkey1"] == "gcp2globvalue1" + + # Should continue processing with transformations applied + assert result.continue_processing + assert result.modified_payload is None + # Test tool post-invoke with transformation + tool_result_payload = ToolPostInvokePayload(name="test_tool", result={"output": "Result was bad", "status": "wrong format"}) + result, contexts = await manager.tool_post_invoke(tool_result_payload, global_context=global_context, local_contexts=contexts) + + ctxs = [contexts[key] for key in contexts.keys()] + assert len(ctxs) == 2 + context1 = ctxs[0] + context2 = ctxs[1] + assert context1.state + assert len(context1.state) == 2 + assert context1.state["key3"] == "value3" + assert context1.state["key2"] == "value2" + assert "cp2key1" not in context1.state + assert "cp2key2" not in context1.state + assert context1.global_context.state["globkey1"] == "globvalue1" + assert context1.global_context.state["gcp2globkey1"] == "gcp2globvalue1" + assert "gcp2globkey2" not in context1.global_context.state + assert context1.global_context.state["globkey2"] == "globvalue2" + + assert context2.global_context.state["globkey1"] == "globvalue1" + assert context2.global_context.state["gcp2globkey1"] == "gcp2globvalue1" + assert context2.global_context.state["gcp2globkey2"] == "gcp2globvalue2" + assert context2.global_context.state["globkey2"] == "globvalue2" + + assert "key3" not in context2.state + assert "key2" not in context2.state + assert "cp2key1" in context2.state + """ + assert "key2" in context.state + assert context.state["key2"] == "value2" + assert context.state["key3"] == "value3" + assert context.global_context.state + assert context.global_context.state["globkey1"] == "globvalue1" + assert context.global_context.state["globkey2"] == "globvalue2" + assert len(context.global_context.state) == 2 + + # Should continue processing with transformations applied + assert result.continue_processing + assert result.modified_payload is None + """ + await manager.shutdown() diff --git a/tests/unit/mcpgateway/plugins/framework/test_errors.py b/tests/unit/mcpgateway/plugins/framework/test_errors.py index c99dcf9f0..aad63cbb9 100644 --- a/tests/unit/mcpgateway/plugins/framework/test_errors.py +++ b/tests/unit/mcpgateway/plugins/framework/test_errors.py @@ -8,7 +8,14 @@ """ import pytest -from mcpgateway.plugins.framework.errors import convert_exception_to_error, PluginError +import re +from mcpgateway.plugins.framework.errors import convert_exception_to_error +from mcpgateway.plugins.framework import ( + GlobalContext, + PluginError, + PluginManager, + PromptPrehookPayload, +) @pytest.mark.asyncio @@ -21,3 +28,27 @@ async def test_convert_exception_to_error(): assert plugin_error.error.message == "ValueError('This is some error.')" assert plugin_error.error.plugin_name == "SomePluginName" + +@pytest.mark.asyncio +async def test_error_plugin(): + plugin_manager = PluginManager(config="tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin.yaml") + await plugin_manager.initialize() + payload = PromptPrehookPayload(name="test_prompt", args={"arg0": "This is a crap argument"}) + global_context = GlobalContext(request_id="1") + escaped_regex = re.escape("ValueError('Sadly! Prompt prefetch is broken!')") + with pytest.raises(PluginError, match=escaped_regex): + await plugin_manager.prompt_pre_fetch(payload, global_context) + + await plugin_manager.shutdown() + +async def test_error_plugin_raise_error_false(): + plugin_manager = PluginManager(config="tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin_raise_error_false.yaml") + await plugin_manager.initialize() + payload = PromptPrehookPayload(name="test_prompt", args={"arg0": "This is a crap argument"}) + global_context = GlobalContext(request_id="1") + result, _ = await plugin_manager.prompt_pre_fetch(payload, global_context) + assert result.continue_processing + assert not result.modified_payload + + await plugin_manager.shutdown() + diff --git a/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py b/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py index 0f6430de6..7388faedc 100644 --- a/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py +++ b/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py @@ -70,11 +70,11 @@ async def prompt_pre_fetch(self, payload, context): result, _ = await manager.prompt_pre_fetch(prompt, global_context=global_context) - # Should block in enforce mode - assert not result.continue_processing - assert result.violation is not None - assert result.violation.code == "PLUGIN_TIMEOUT" - assert "timeout" in result.violation.description.lower() + # Should pass since fail_on_plugin_error: false + assert result.continue_processing + #assert result.violation is not None + #assert result.violation.code == "PLUGIN_TIMEOUT" + #assert "timeout" in result.violation.description.lower() # Test with permissive mode plugin_config.mode = PluginMode.PERMISSIVE @@ -127,10 +127,10 @@ async def prompt_pre_fetch(self, payload, context): result, _ = await manager.prompt_pre_fetch(prompt, global_context=global_context) # Should block in enforce mode - assert not result.continue_processing - assert result.violation is not None - assert result.violation.code == "PLUGIN_ERROR" - assert "error" in result.violation.description.lower() + assert result.continue_processing + #assert result.violation is not None + #assert result.violation.code == "PLUGIN_ERROR" + #assert "error" in result.violation.description.lower() # Test with permissive mode plugin_config.mode = PluginMode.PERMISSIVE @@ -545,6 +545,7 @@ async def test_manager_initialization_edge_cases(): mock_logger.debug.assert_called_with("Skipping disabled plugin: DisabledPlugin") await manager3.shutdown() + await manager2.shutdown() @pytest.mark.asyncio @@ -577,8 +578,8 @@ async def test_manager_context_cleanup(): await manager.shutdown() - -def test_manager_constructor_context_init(): +@pytest.mark.asyncio +async def test_manager_constructor_context_init(): """Test manager constructor context initialization.""" # Test that managers share state and context store exists (covers lines 432-433) @@ -593,6 +594,8 @@ def test_manager_constructor_context_init(): # They should be the same instance due to shared state assert manager1._context_store is manager2._context_store + await manager1.shutdown() + await manager2.shutdown() @pytest.mark.asyncio @@ -631,7 +634,7 @@ async def test_base_plugin_coverage(): assert plugin_ref.mode == PluginMode.ENFORCE # Default mode # Test NotImplementedError for prompt_pre_fetch (covers lines 151-155) - context = PluginContext(request_id="test") + context = PluginContext(global_context=GlobalContext(request_id="test")) payload = PromptPrehookPayload(name="test", args={}) with pytest.raises(NotImplementedError, match="'prompt_pre_fetch' not implemented"): @@ -665,7 +668,7 @@ async def test_plugin_types_coverage(): from mcpgateway.plugins.framework.errors import PluginViolationError # Test PluginContext state methods (covers lines 266, 275) - plugin_ctx = PluginContext(request_id="test", user="testuser") + plugin_ctx = PluginContext(global_context=GlobalContext(request_id="test", user="testuser")) # Test get_state with default assert plugin_ctx.get_state("nonexistent", "default_value") == "default_value" diff --git a/tests/unit/mcpgateway/plugins/framework/test_resource_hooks.py b/tests/unit/mcpgateway/plugins/framework/test_resource_hooks.py index d5bf3bb58..857069fbb 100644 --- a/tests/unit/mcpgateway/plugins/framework/test_resource_hooks.py +++ b/tests/unit/mcpgateway/plugins/framework/test_resource_hooks.py @@ -13,14 +13,15 @@ from mcpgateway.models import ResourceContent from mcpgateway.plugins.framework.base import Plugin, PluginRef -from mcpgateway.plugins.framework.manager import PluginManager # Registry is imported for mocking -from mcpgateway.plugins.framework.models import ( +from mcpgateway.plugins.framework import ( GlobalContext, HookType, PluginCondition, PluginConfig, PluginContext, + PluginError, + PluginManager, PluginMode, PluginViolation, ResourcePostFetchPayload, @@ -61,7 +62,7 @@ async def test_plugin_resource_pre_fetch_default(self): ) plugin = Plugin(config) payload = ResourcePreFetchPayload(uri="file:///test.txt", metadata={}) - context = PluginContext(request_id="test-123") + context = PluginContext(global_context=GlobalContext(request_id="test-123")) with pytest.raises(NotImplementedError, match="'resource_pre_fetch' not implemented"): await plugin.resource_pre_fetch(payload, context) @@ -82,7 +83,7 @@ async def test_plugin_resource_post_fetch_default(self): plugin = Plugin(config) content = ResourceContent(type="resource", uri="file:///test.txt", text="Test content") payload = ResourcePostFetchPayload(uri="file:///test.txt", content=content) - context = PluginContext(request_id="test-123") + context = PluginContext(global_context=GlobalContext(request_id="test-123")) with pytest.raises(NotImplementedError, match="'resource_post_fetch' not implemented"): await plugin.resource_post_fetch(payload, context) @@ -116,7 +117,7 @@ async def resource_pre_fetch(self, payload, context): ) plugin = BlockingResourcePlugin(config) payload = ResourcePreFetchPayload(uri="file:///etc/passwd", metadata={}) - context = PluginContext(request_id="test-123") + context = PluginContext(global_context=GlobalContext(request_id="test-123")) result = await plugin.resource_pre_fetch(payload, context) @@ -163,7 +164,7 @@ async def resource_post_fetch(self, payload, context): text="Database config:\npassword: secret123\nport: 5432", ) payload = ResourcePostFetchPayload(uri="test://config", content=content) - context = PluginContext(request_id="test-123") + context = PluginContext(global_context=GlobalContext(request_id="test-123")) result = await plugin.resource_post_fetch(payload, context) @@ -410,6 +411,7 @@ async def resource_pre_fetch(self, payload, context): # Mock config mock_config = MagicMock() mock_config.plugin_settings = MagicMock() + mock_config.plugin_settings.fail_on_plugin_error = False MockConfig.return_value = mock_config manager = PluginManager("test_config.yaml") @@ -418,11 +420,16 @@ async def resource_pre_fetch(self, payload, context): payload = ResourcePreFetchPayload(uri="test://resource", metadata={}) global_context = GlobalContext(request_id="test-123") - - # Should handle error gracefully in permissive mode + # Should handle error gracefully when fail_on_plugin_error = False result, contexts = await manager.resource_pre_fetch(payload, global_context) assert result.continue_processing is True # Continues despite error + mock_config.plugin_settings.fail_on_plugin_error = True + # Should throw a plugin error since fail_on_plugin_error = True + with pytest.raises(PluginError): + result, contexts = await manager.resource_pre_fetch(payload, global_context) + + @pytest.mark.asyncio async def test_resource_uri_modification(self): """Test resource URI modification in pre-fetch.""" @@ -450,7 +457,7 @@ async def resource_pre_fetch(self, payload, context): ) plugin = URIModifierPlugin(config) payload = ResourcePreFetchPayload(uri="test://resource", metadata={}) - context = PluginContext(request_id="test-123") + context = PluginContext(global_context=GlobalContext(request_id="test-123")) result = await plugin.resource_pre_fetch(payload, context) @@ -466,8 +473,8 @@ class MetadataEnricherPlugin(Plugin): async def resource_pre_fetch(self, payload, context): # Add metadata payload.metadata["timestamp"] = "2024-01-01T00:00:00Z" - payload.metadata["user"] = context.user - payload.metadata["request_id"] = context.request_id + payload.metadata["user"] = context.global_context.user + payload.metadata["request_id"] = context.global_context.request_id return ResourcePreFetchResult( continue_processing=True, modified_payload=payload, @@ -484,7 +491,7 @@ async def resource_pre_fetch(self, payload, context): ) plugin = MetadataEnricherPlugin(config) payload = ResourcePreFetchPayload(uri="test://resource", metadata={}) - context = PluginContext(request_id="test-123", user="testuser") + context = PluginContext(global_context=GlobalContext(request_id="test-123", user="testuser")) result = await plugin.resource_pre_fetch(payload, context) diff --git a/tests/unit/mcpgateway/plugins/plugins/pii_filter/test_pii_filter.py b/tests/unit/mcpgateway/plugins/plugins/pii_filter/test_pii_filter.py index 0b1a33e1d..2b8578693 100644 --- a/tests/unit/mcpgateway/plugins/plugins/pii_filter/test_pii_filter.py +++ b/tests/unit/mcpgateway/plugins/plugins/pii_filter/test_pii_filter.py @@ -248,7 +248,7 @@ def plugin_config(self) -> PluginConfig: async def test_prompt_pre_fetch_with_pii(self, plugin_config): """Test pre-fetch hook with PII detection.""" plugin = PIIFilterPlugin(plugin_config) - context = PluginContext(request_id="test-1") + context = PluginContext(global_context=GlobalContext(request_id="test-1")) # Create payload with PII payload = PromptPrehookPayload(name="test_prompt", args={"user_input": "My email is john@example.com and SSN is 123-45-6789", "safe_input": "This has no PII"}) @@ -272,7 +272,7 @@ async def test_prompt_pre_fetch_blocking(self, plugin_config): # Enable blocking plugin_config.config["block_on_detection"] = True plugin = PIIFilterPlugin(plugin_config) - context = PluginContext(request_id="test-2") + context = PluginContext(global_context=GlobalContext(request_id="test-2")) payload = PromptPrehookPayload(name="test_prompt", args={"input": "My SSN is 123-45-6789"}) @@ -288,7 +288,7 @@ async def test_prompt_pre_fetch_blocking(self, plugin_config): async def test_prompt_post_fetch(self, plugin_config): """Test post-fetch hook with PII in messages.""" plugin = PIIFilterPlugin(plugin_config) - context = PluginContext(request_id="test-3") + context = PluginContext(global_context=GlobalContext(request_id="test-3")) # Create messages with PII messages = [ @@ -317,7 +317,7 @@ async def test_prompt_post_fetch(self, plugin_config): async def test_no_pii_detection(self, plugin_config): """Test that clean text passes through unmodified.""" plugin = PIIFilterPlugin(plugin_config) - context = PluginContext(request_id="test-4") + context = PluginContext(global_context=GlobalContext(request_id="test-4")) payload = PromptPrehookPayload(name="test_prompt", args={"input": "This text has no sensitive information"}) @@ -334,7 +334,7 @@ async def test_custom_patterns(self, plugin_config): plugin_config.config["custom_patterns"] = [{"type": "custom", "pattern": r"\bEMP\d{6}\b", "description": "Employee ID", "mask_strategy": "redact", "enabled": True}] plugin = PIIFilterPlugin(plugin_config) - context = PluginContext(request_id="test-5") + context = PluginContext(global_context=GlobalContext(request_id="test-5")) payload = PromptPrehookPayload(name="test_prompt", args={"input": "Employee ID: EMP123456"}) @@ -352,7 +352,7 @@ async def test_permissive_mode(self, plugin_config): plugin_config.config["block_on_detection"] = True # Should be ignored in permissive mode plugin = PIIFilterPlugin(plugin_config) - context = PluginContext(request_id="test-6") + context = PluginContext(global_context=GlobalContext(request_id="test-6")) payload = PromptPrehookPayload(name="test_prompt", args={"input": "SSN: 123-45-6789"}) diff --git a/tests/unit/mcpgateway/plugins/plugins/resource_filter/test_resource_filter.py b/tests/unit/mcpgateway/plugins/plugins/resource_filter/test_resource_filter.py index 8aee5cd68..e76487ad1 100644 --- a/tests/unit/mcpgateway/plugins/plugins/resource_filter/test_resource_filter.py +++ b/tests/unit/mcpgateway/plugins/plugins/resource_filter/test_resource_filter.py @@ -11,6 +11,7 @@ from mcpgateway.models import ResourceContent from mcpgateway.plugins.framework.models import ( + GlobalContext, HookType, PluginConfig, PluginContext, @@ -56,7 +57,7 @@ def plugin(self, plugin_config): @pytest.fixture def context(self): """Create a plugin context.""" - return PluginContext(request_id="test-123", user="testuser") + return PluginContext(global_context=GlobalContext(request_id="test-123", user="testuser")) @pytest.mark.asyncio async def test_allowed_protocol(self, plugin, context): From 9d48b032cafa444cdc43cb13301b3658d9de4201 Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Fri, 22 Aug 2025 12:00:51 -0600 Subject: [PATCH 05/26] fix: plugin cleanup to support multiple external plugins. Signed-off-by: Teryl Taylor --- mcpgateway/plugins/framework/registry.py | 4 +++- .../plugins/framework/external/mcp/test_client_stdio.py | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mcpgateway/plugins/framework/registry.py b/mcpgateway/plugins/framework/registry.py index 031759f8f..519c26ada 100644 --- a/mcpgateway/plugins/framework/registry.py +++ b/mcpgateway/plugins/framework/registry.py @@ -151,7 +151,9 @@ def plugin_count(self) -> int: async def shutdown(self) -> None: """Shutdown all plugins.""" - for plugin_ref in self._plugins.values(): + # Must cleanup the plugins in reverse of creating them to handle asyncio cleanup issues. + # https://github.com/microsoft/semantic-kernel/issues/12627 + for plugin_ref in reversed(self._plugins.values()): try: await plugin_ref.plugin.shutdown() except Exception as e: diff --git a/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_stdio.py b/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_stdio.py index a9bf490e2..722641b35 100644 --- a/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_stdio.py +++ b/tests/unit/mcpgateway/plugins/framework/external/mcp/test_client_stdio.py @@ -224,7 +224,6 @@ async def test_errors(): await plugin_manager.shutdown() -@pytest.mark.skip(reason="Fails on await manager.shutdown().") @pytest.mark.asyncio async def test_shared_context_across_pre_post_hooks_multi_plugins(): os.environ["PLUGINS_CONFIG_PATH"] = "tests/unit/mcpgateway/plugins/fixtures/configs/context_multiplugins.yaml" From f2ba4e6382dc713a4ee868d10d4fff6a3db09614 Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Fri, 22 Aug 2025 14:10:45 -0600 Subject: [PATCH 06/26] fix(lint): fixed linting issues Signed-off-by: Teryl Taylor --- .../plugins/framework/external/mcp/client.py | 108 ++++++++++++------ mcpgateway/plugins/framework/manager.py | 1 + .../configs/context_multiplugins.yaml | 4 +- .../fixtures/configs/context_plugin.yaml | 4 +- .../context_stdio_external_plugins.yaml | 2 +- .../fixtures/configs/error_plugin.yaml | 2 +- .../configs/error_stdio_external_plugin.yaml | 2 +- .../plugins/fixtures/plugins/context.py | 9 +- .../plugins/fixtures/plugins/error.py | 3 +- .../plugins/framework/test_errors.py | 1 - .../plugins/framework/test_resource_hooks.py | 4 +- 11 files changed, 87 insertions(+), 53 deletions(-) diff --git a/mcpgateway/plugins/framework/external/mcp/client.py b/mcpgateway/plugins/framework/external/mcp/client.py index 93866bd48..7facb160e 100644 --- a/mcpgateway/plugins/framework/external/mcp/client.py +++ b/mcpgateway/plugins/framework/external/mcp/client.py @@ -25,7 +25,7 @@ # First-Party from mcpgateway.plugins.framework.base import Plugin from mcpgateway.plugins.framework.constants import CONTEXT, ERROR, GET_PLUGIN_CONFIG, IGNORE_CONFIG_EXTERNAL, NAME, PAYLOAD, PLUGIN_NAME, PYTHON, PYTHON_SUFFIX, RESULT -from mcpgateway.plugins.framework.errors import PluginError +from mcpgateway.plugins.framework.errors import convert_exception_to_error, PluginError from mcpgateway.plugins.framework.models import ( HookType, PluginConfig, @@ -72,28 +72,35 @@ async def initialize(self) -> None: """Initialize the plugin's connection to the MCP server. Raises: - ValueError: if unable to retrieve plugin configuration of external plugin. + PluginError: if unable to retrieve plugin configuration of external plugin. """ if not self._config.mcp: - raise ValueError(f"The mcp section must be defined for external plugin {self.name}") + raise PluginError(error=PluginErrorModel(message="The mcp section must be defined for external plugin", plugin_name=self.name)) if self._config.mcp.proto == TransportType.STDIO: await self.__connect_to_stdio_server(self._config.mcp.script) elif self._config.mcp.proto == TransportType.STREAMABLEHTTP: await self.__connect_to_http_server(self._config.mcp.url) - config = await self.__get_plugin_config() + try: + config = await self.__get_plugin_config() - if not config: - raise ValueError(f"Unable to retrieve configuration for external plugin {self.name}") + if not config: + raise PluginError(error=PluginErrorModel(message="Unable to retrieve configuration for external plugin", plugin_name=self.name)) - current_config = self._config.model_dump(exclude_unset=True) - remote_config = config.model_dump(exclude_unset=True) - remote_config.update(current_config) + current_config = self._config.model_dump(exclude_unset=True) + remote_config = config.model_dump(exclude_unset=True) + remote_config.update(current_config) - context = {IGNORE_CONFIG_EXTERNAL: True} + context = {IGNORE_CONFIG_EXTERNAL: True} - self._config = PluginConfig.model_validate(remote_config, context=context) + self._config = PluginConfig.model_validate(remote_config, context=context) + except PluginError as pe: + logger.exception(pe) + raise + except Exception as e: + logger.exception(e) + raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) async def __connect_to_stdio_server(self, server_script_path: str) -> None: """Connect to an MCP plugin server via stdio. @@ -102,44 +109,55 @@ async def __connect_to_stdio_server(self, server_script_path: str) -> None: server_script_path: Path to the server script (.py). Raises: - ValueError: if stdio script is not a python script. + PluginError: if stdio script is not a python script or if there is a connection error. """ is_python = server_script_path.endswith(PYTHON_SUFFIX) if server_script_path else False if not is_python: - raise ValueError("Server script must be a .py file") + raise PluginError(error=PluginErrorModel(message="Server script must be a .py file", plugin_name=self.name)) current_env = os.environ.copy() - server_params = StdioServerParameters(command=PYTHON, args=[server_script_path], env=current_env) + try: + server_params = StdioServerParameters(command=PYTHON, args=[server_script_path], env=current_env) - stdio_transport = await self._exit_stack.enter_async_context(stdio_client(server_params)) - self._stdio, self._write = stdio_transport - self._session = await self._exit_stack.enter_async_context(ClientSession(self._stdio, self._write)) + stdio_transport = await self._exit_stack.enter_async_context(stdio_client(server_params)) + self._stdio, self._write = stdio_transport + self._session = await self._exit_stack.enter_async_context(ClientSession(self._stdio, self._write)) - await self._session.initialize() + await self._session.initialize() - # List available tools - response = await self._session.list_tools() - tools = response.tools - logger.info("\nConnected to plugin MCP server (stdio) with tools: %s", " ".join([tool.name for tool in tools])) + # List available tools + response = await self._session.list_tools() + tools = response.tools + logger.info("\nConnected to plugin MCP server (stdio) with tools: %s", " ".join([tool.name for tool in tools])) + except Exception as e: + logger.exception(e) + raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) - async def __connect_to_http_server(self, uri: str): + async def __connect_to_http_server(self, uri: str) -> None: """Connect to an MCP plugin server via streamable http. Args: uri: the URI of the mcp plugin server. + + Raises: + PluginError: if there is an external connection error. """ - http_transport = await self._exit_stack.enter_async_context(streamablehttp_client(uri)) - self._http, self._write, _ = http_transport - self._session = await self._exit_stack.enter_async_context(ClientSession(self._http, self._write)) + try: + http_transport = await self._exit_stack.enter_async_context(streamablehttp_client(uri)) + self._http, self._write, _ = http_transport + self._session = await self._exit_stack.enter_async_context(ClientSession(self._http, self._write)) - await self._session.initialize() + await self._session.initialize() - # List available tools - response = await self._session.list_tools() - tools = response.tools - logger.info("\nConnected to plugin MCP (http) server with tools: %s", " ".join([tool.name for tool in tools])) + # List available tools + response = await self._session.list_tools() + tools = response.tools + logger.info("\nConnected to plugin MCP (http) server with tools: %s", " ".join([tool.name for tool in tools])) + except Exception as e: + logger.exception(e) + raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) async def __invoke_hook(self, payload_result_model: Type[P], hook_type: HookType, payload: BaseModel, context: PluginContext) -> P: """Invoke an external plugin hook using the MCP protocol. @@ -150,12 +168,15 @@ async def __invoke_hook(self, payload_result_model: Type[P], hook_type: HookType payload: The payload to be passed to the hook. context: The plugin context passed to the run. + Raises: + PluginError: error passed from external plugin server. + Returns: The resulting payload from the plugin. """ - result = await self._session.call_tool(hook_type, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context}) try: + result = await self._session.call_tool(hook_type, {PLUGIN_NAME: self.name, PAYLOAD: payload, CONTEXT: context}) for content in result.content: res = json.loads(content.text) if CONTEXT in res: @@ -168,9 +189,12 @@ async def __invoke_hook(self, payload_result_model: Type[P], hook_type: HookType if ERROR in res: error = PluginErrorModel.model_validate(res[ERROR]) raise PluginError(error) - except Exception as ex: - logger.exception(ex) + except PluginError as pe: + logger.exception(pe) raise + except Exception as e: + logger.exception(e) + raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) raise PluginError(error=PluginErrorModel(message=f"Received invalid response. Result = {result}", plugin_name=self.name)) async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: @@ -253,13 +277,21 @@ async def resource_post_fetch(self, payload: ResourcePostFetchPayload, context: async def __get_plugin_config(self) -> PluginConfig | None: """Retrieve plugin configuration for the current plugin on the remote MCP server. + Raises: + PluginError: if there is a connection issue or validation issue. + Returns: A plugin configuration for the current plugin from a remote MCP server. """ - configs = await self._session.call_tool(GET_PLUGIN_CONFIG, {NAME: self.name}) - for content in configs.content: - conf = json.loads(content.text) - return PluginConfig.model_validate(conf) + try: + configs = await self._session.call_tool(GET_PLUGIN_CONFIG, {NAME: self.name}) + for content in configs.content: + conf = json.loads(content.text) + return PluginConfig.model_validate(conf) + except Exception as e: + logger.exception(e) + raise PluginError(error=convert_exception_to_error(e, plugin_name=self.name)) + return None async def shutdown(self) -> None: diff --git a/mcpgateway/plugins/framework/manager.py b/mcpgateway/plugins/framework/manager.py index f0abf4a0c..43d228916 100644 --- a/mcpgateway/plugins/framework/manager.py +++ b/mcpgateway/plugins/framework/manager.py @@ -149,6 +149,7 @@ async def execute( Raises: PayloadSizeError: If the payload exceeds MAX_PAYLOAD_SIZE. + PluginError: If there is an error inside a plugin. Examples: >>> # Execute plugins with timeout protection diff --git a/tests/unit/mcpgateway/plugins/fixtures/configs/context_multiplugins.yaml b/tests/unit/mcpgateway/plugins/fixtures/configs/context_multiplugins.yaml index 727347a9f..e0b7da505 100644 --- a/tests/unit/mcpgateway/plugins/fixtures/configs/context_multiplugins.yaml +++ b/tests/unit/mcpgateway/plugins/fixtures/configs/context_multiplugins.yaml @@ -39,6 +39,6 @@ plugin_dirs: plugin_settings: parallel_execution_within_band: true plugin_timeout: 30 - fail_on_plugin_error: true + fail_on_plugin_error: true enable_plugin_api: true - plugin_health_check_interval: 60 \ No newline at end of file + plugin_health_check_interval: 60 diff --git a/tests/unit/mcpgateway/plugins/fixtures/configs/context_plugin.yaml b/tests/unit/mcpgateway/plugins/fixtures/configs/context_plugin.yaml index d2d742694..bf144ee1e 100644 --- a/tests/unit/mcpgateway/plugins/fixtures/configs/context_plugin.yaml +++ b/tests/unit/mcpgateway/plugins/fixtures/configs/context_plugin.yaml @@ -25,6 +25,6 @@ plugin_dirs: plugin_settings: parallel_execution_within_band: true plugin_timeout: 30 - fail_on_plugin_error: true + fail_on_plugin_error: true enable_plugin_api: true - plugin_health_check_interval: 60 \ No newline at end of file + plugin_health_check_interval: 60 diff --git a/tests/unit/mcpgateway/plugins/fixtures/configs/context_stdio_external_plugins.yaml b/tests/unit/mcpgateway/plugins/fixtures/configs/context_stdio_external_plugins.yaml index 506357c29..37ae39de3 100644 --- a/tests/unit/mcpgateway/plugins/fixtures/configs/context_stdio_external_plugins.yaml +++ b/tests/unit/mcpgateway/plugins/fixtures/configs/context_stdio_external_plugins.yaml @@ -22,6 +22,6 @@ plugin_dirs: plugin_settings: parallel_execution_within_band: true plugin_timeout: 30 - fail_on_plugin_error: true + fail_on_plugin_error: true enable_plugin_api: true plugin_health_check_interval: 60 diff --git a/tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin.yaml b/tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin.yaml index 3df854e5a..011cd7e1b 100644 --- a/tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin.yaml +++ b/tests/unit/mcpgateway/plugins/fixtures/configs/error_plugin.yaml @@ -25,6 +25,6 @@ plugin_dirs: plugin_settings: parallel_execution_within_band: true plugin_timeout: 30 - fail_on_plugin_error: true + fail_on_plugin_error: true enable_plugin_api: true plugin_health_check_interval: 60 diff --git a/tests/unit/mcpgateway/plugins/fixtures/configs/error_stdio_external_plugin.yaml b/tests/unit/mcpgateway/plugins/fixtures/configs/error_stdio_external_plugin.yaml index 27d91f066..4eda913b6 100644 --- a/tests/unit/mcpgateway/plugins/fixtures/configs/error_stdio_external_plugin.yaml +++ b/tests/unit/mcpgateway/plugins/fixtures/configs/error_stdio_external_plugin.yaml @@ -17,6 +17,6 @@ plugin_dirs: plugin_settings: parallel_execution_within_band: true plugin_timeout: 30 - fail_on_plugin_error: true + fail_on_plugin_error: true enable_plugin_api: true plugin_health_check_interval: 60 diff --git a/tests/unit/mcpgateway/plugins/fixtures/plugins/context.py b/tests/unit/mcpgateway/plugins/fixtures/plugins/context.py index 2dddaf3d6..85f39daec 100644 --- a/tests/unit/mcpgateway/plugins/fixtures/plugins/context.py +++ b/tests/unit/mcpgateway/plugins/fixtures/plugins/context.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Context plugin. @@ -51,7 +52,7 @@ async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: Plugi if "key1" not in context.state or context.state["key1"] != "value1": raise ValueError("key1 not in context!! It should be!!") return PromptPosthookResult(continue_processing=True) - + async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: """Plugin hook run before a tool is invoked. @@ -108,7 +109,7 @@ async def resource_pre_fetch(self, payload: ResourcePreFetchPayload, context: Pl The result of the plugin's analysis, including whether the resource result should proceed. """ return ResourcePreFetchResult(continue_processing=True) - + class ContextPlugin2(Plugin): """A simple Context plugin.""" @@ -138,7 +139,7 @@ async def prompt_post_fetch(self, payload: PromptPosthookPayload, context: Plugi if "key1" not in context.state or context.state["key1"] != "value1": raise ValueError("key1 not in context!! It should be!!") return PromptPosthookResult(continue_processing=True) - + async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult: """Plugin hook run before a tool is invoked. @@ -198,4 +199,4 @@ async def resource_pre_fetch(self, payload: ResourcePreFetchPayload, context: Pl Returns: The result of the plugin's analysis, including whether the resource result should proceed. """ - return ResourcePreFetchResult(continue_processing=True) \ No newline at end of file + return ResourcePreFetchResult(continue_processing=True) diff --git a/tests/unit/mcpgateway/plugins/fixtures/plugins/error.py b/tests/unit/mcpgateway/plugins/fixtures/plugins/error.py index c67daea3f..339cc7c09 100644 --- a/tests/unit/mcpgateway/plugins/fixtures/plugins/error.py +++ b/tests/unit/mcpgateway/plugins/fixtures/plugins/error.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Error plugin. @@ -95,4 +96,4 @@ async def resource_pre_fetch(self, payload: ResourcePreFetchPayload, context: Pl Returns: The result of the plugin's analysis, including whether the resource result should proceed. """ - return ResourcePreFetchResult(continue_processing=True) \ No newline at end of file + return ResourcePreFetchResult(continue_processing=True) diff --git a/tests/unit/mcpgateway/plugins/framework/test_errors.py b/tests/unit/mcpgateway/plugins/framework/test_errors.py index aad63cbb9..2571b26a5 100644 --- a/tests/unit/mcpgateway/plugins/framework/test_errors.py +++ b/tests/unit/mcpgateway/plugins/framework/test_errors.py @@ -51,4 +51,3 @@ async def test_error_plugin_raise_error_false(): assert not result.modified_payload await plugin_manager.shutdown() - diff --git a/tests/unit/mcpgateway/plugins/framework/test_resource_hooks.py b/tests/unit/mcpgateway/plugins/framework/test_resource_hooks.py index 857069fbb..3bafca72a 100644 --- a/tests/unit/mcpgateway/plugins/framework/test_resource_hooks.py +++ b/tests/unit/mcpgateway/plugins/framework/test_resource_hooks.py @@ -420,12 +420,12 @@ async def resource_pre_fetch(self, payload, context): payload = ResourcePreFetchPayload(uri="test://resource", metadata={}) global_context = GlobalContext(request_id="test-123") - # Should handle error gracefully when fail_on_plugin_error = False + # Should handle error gracefully when fail_on_plugin_error = False result, contexts = await manager.resource_pre_fetch(payload, global_context) assert result.continue_processing is True # Continues despite error mock_config.plugin_settings.fail_on_plugin_error = True - # Should throw a plugin error since fail_on_plugin_error = True + # Should throw a plugin error since fail_on_plugin_error = True with pytest.raises(PluginError): result, contexts = await manager.resource_pre_fetch(payload, global_context) From 4670e29b7272fbe6b42063ba401d2b934040847b Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Fri, 22 Aug 2025 18:13:36 -0600 Subject: [PATCH 07/26] feat(error): update error handling with enforce_ignore_error Signed-off-by: Teryl Taylor --- mcpgateway/plugins/framework/manager.py | 27 +++------------- mcpgateway/plugins/framework/models.py | 6 +++- .../plugins/framework/test_errors.py | 11 ++++++- .../framework/test_manager_extended.py | 31 ++++++++++++++----- 4 files changed, 44 insertions(+), 31 deletions(-) diff --git a/mcpgateway/plugins/framework/manager.py b/mcpgateway/plugins/framework/manager.py index 43d228916..dedbba3d1 100644 --- a/mcpgateway/plugins/framework/manager.py +++ b/mcpgateway/plugins/framework/manager.py @@ -226,37 +226,20 @@ async def execute( except asyncio.TimeoutError: logger.error(f"Plugin {pluginref.name} timed out after {self.timeout}s") - if self.config.plugin_settings.fail_on_plugin_error: + if self.config.plugin_settings.fail_on_plugin_error or pluginref.plugin.mode == PluginMode.ENFORCE: raise PluginError(error=PluginErrorModel(message=f"Plugin {pluginref.name} exceeded {self.timeout}s timeout", plugin_name=pluginref.name)) - """ - if pluginref.plugin.mode == PluginMode.ENFORCE: - violation = PluginViolation( - reason="Plugin timeout", - description=f"Plugin {pluginref.name} exceeded {self._timeout}s timeout", - code="PLUGIN_TIMEOUT", - details={"timeout": self._timeout, "plugin": pluginref.name}, - ) - return (PluginResult[T](continue_processing=False, violation=violation, modified_payload=current_payload, metadata=combined_metadata), res_local_contexts) - # In permissive mode, continue with next plugin - """ + # In permissive or enforce_ignore_error mode, continue with next plugin continue except PluginError as pe: logger.error(f"Plugin {pluginref.name} failed with error: {str(pe)}", exc_info=True) - if self.config.plugin_settings.fail_on_plugin_error: + if self.config.plugin_settings.fail_on_plugin_error or pluginref.plugin.mode == PluginMode.ENFORCE: raise except Exception as e: logger.error(f"Plugin {pluginref.name} failed with error: {str(e)}", exc_info=True) - if self.config.plugin_settings.fail_on_plugin_error: + if self.config.plugin_settings.fail_on_plugin_error or pluginref.plugin.mode == PluginMode.ENFORCE: raise PluginError(error=convert_exception_to_error(e, pluginref.name)) - """ - if pluginref.plugin.mode == PluginMode.ENFORCE: - violation = PluginViolation( - reason="Plugin error", description=f"Plugin {pluginref.name} encountered an error: {str(e)}", code="PLUGIN_ERROR", details={"error": str(e), "plugin": pluginref.name} - ) - return (PluginResult[T](continue_processing=False, violation=violation, modified_payload=current_payload, metadata=combined_metadata), res_local_contexts) - # In permissive mode, continue with next plugin - """ + # In permissive or enforce_ignore_error mode, continue with next plugin continue return (PluginResult[T](continue_processing=True, modified_payload=current_payload, violation=None, metadata=combined_metadata), res_local_contexts) diff --git a/mcpgateway/plugins/framework/models.py b/mcpgateway/plugins/framework/models.py index 4d6457da4..79b6785a5 100644 --- a/mcpgateway/plugins/framework/models.py +++ b/mcpgateway/plugins/framework/models.py @@ -60,13 +60,16 @@ class PluginMode(str, Enum): """Plugin modes of operation. Attributes: - enforce: enforces the plugin result. + enforce: enforces the plugin result, and blocks execution when there is an error. + enforce_ignore_error: enforces the plugin result, but allows execution when there is an error. permissive: audits the result. disabled: plugin disabled. Examples: >>> PluginMode.ENFORCE + >>> PluginMode.ENFORCE_IGNORE_ERROR + >>> PluginMode.PERMISSIVE.value 'permissive' >>> PluginMode('disabled') @@ -76,6 +79,7 @@ class PluginMode(str, Enum): """ ENFORCE = "enforce" + ENFORCE_IGNORE_ERROR = "enforce_ignore_error" PERMISSIVE = "permissive" DISABLED = "disabled" diff --git a/tests/unit/mcpgateway/plugins/framework/test_errors.py b/tests/unit/mcpgateway/plugins/framework/test_errors.py index 2571b26a5..8bd6ea565 100644 --- a/tests/unit/mcpgateway/plugins/framework/test_errors.py +++ b/tests/unit/mcpgateway/plugins/framework/test_errors.py @@ -13,6 +13,7 @@ from mcpgateway.plugins.framework import ( GlobalContext, PluginError, + PluginMode, PluginManager, PromptPrehookPayload, ) @@ -46,8 +47,16 @@ async def test_error_plugin_raise_error_false(): await plugin_manager.initialize() payload = PromptPrehookPayload(name="test_prompt", args={"arg0": "This is a crap argument"}) global_context = GlobalContext(request_id="1") + with pytest.raises(PluginError): + result, _ = await plugin_manager.prompt_pre_fetch(payload, global_context) + #assert result.continue_processing + #assert not result.modified_payload + + await plugin_manager.shutdown() + plugin_manager.config.plugins[0].mode = PluginMode.ENFORCE_IGNORE_ERROR + await plugin_manager.initialize() result, _ = await plugin_manager.prompt_pre_fetch(payload, global_context) assert result.continue_processing assert not result.modified_payload - await plugin_manager.shutdown() + diff --git a/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py b/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py index 7388faedc..0b4c01fb6 100644 --- a/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py +++ b/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py @@ -8,19 +8,21 @@ """ import asyncio from unittest.mock import AsyncMock, MagicMock, patch +import re import pytest from mcpgateway.models import Message, PromptResult, Role, TextContent from mcpgateway.plugins.framework.base import Plugin -from mcpgateway.plugins.framework.manager import PluginManager -from mcpgateway.plugins.framework.models import ( - Config, +from mcpgateway.plugins.framework.models import Config +from mcpgateway.plugins.framework import ( GlobalContext, HookType, PluginCondition, PluginConfig, PluginContext, + PluginError, + PluginManager, PluginMode, PluginViolation, PluginResult, @@ -68,10 +70,12 @@ async def prompt_pre_fetch(self, payload, context): prompt = PromptPrehookPayload(name="test", args={}) global_context = GlobalContext(request_id="1") - result, _ = await manager.prompt_pre_fetch(prompt, global_context=global_context) + escaped_regex = re.escape("Plugin TimeoutPlugin exceeded 0.01s timeout") + with pytest.raises(PluginError, match=escaped_regex): + result, _ = await manager.prompt_pre_fetch(prompt, global_context=global_context) # Should pass since fail_on_plugin_error: false - assert result.continue_processing + # assert result.continue_processing #assert result.violation is not None #assert result.violation.code == "PLUGIN_TIMEOUT" #assert "timeout" in result.violation.description.lower() @@ -124,10 +128,12 @@ async def prompt_pre_fetch(self, payload, context): prompt = PromptPrehookPayload(name="test", args={}) global_context = GlobalContext(request_id="1") - result, _ = await manager.prompt_pre_fetch(prompt, global_context=global_context) + escaped_regex = re.escape("RuntimeError('Plugin error!')") + with pytest.raises(PluginError, match=escaped_regex): + result, _ = await manager.prompt_pre_fetch(prompt, global_context=global_context) # Should block in enforce mode - assert result.continue_processing + #assert result.continue_processing #assert result.violation is not None #assert result.violation.code == "PLUGIN_ERROR" #assert "error" in result.violation.description.lower() @@ -143,6 +149,17 @@ async def prompt_pre_fetch(self, payload, context): # Should continue in permissive mode assert result.continue_processing assert result.violation is None + + plugin_config.mode = PluginMode.ENFORCE_IGNORE_ERROR + with patch.object(manager._registry, 'get_plugins_for_hook') as mock_get: + plugin_ref = PluginRef(error_plugin) + mock_get.return_value = [plugin_ref] + + result, _ = await manager.prompt_pre_fetch(prompt, global_context=global_context) + + # Should continue in enforce_ignore_error mode + assert result.continue_processing + assert result.violation is None await manager.shutdown() From e78b1cb1f8879724edefa23781422a5ce884ad57 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Thu, 28 Aug 2025 17:36:26 -0400 Subject: [PATCH 08/26] Additiona of context-tool-policy mapping using applied_to Signed-off-by: Shriti Priya --- mcpgateway/plugins/framework/models.py | 28 ++++++- .../external/opa/opapluginfilter/plugin.py | 74 ++++++++++-------- .../external/opa/opapluginfilter/schema.py | 78 +++++++++++++------ .../opa/resources/plugins/config.yaml | 13 +++- 4 files changed, 132 insertions(+), 61 deletions(-) diff --git a/mcpgateway/plugins/framework/models.py b/mcpgateway/plugins/framework/models.py index 79b6785a5..d3280503c 100644 --- a/mcpgateway/plugins/framework/models.py +++ b/mcpgateway/plugins/framework/models.py @@ -84,7 +84,29 @@ class PluginMode(str, Enum): DISABLED = "disabled" -class ToolTemplate(BaseModel): + +class BaseTemplate(BaseModel): + """Base Template.The ToolTemplate, PromptTemplate and ResourceTemplate could be extended using this + + Attributes: + context (Optional[list[str]]): specifies the keys of context to be extracted. The context could be global (shared between the plugins) or + local (shared within the plugin). Example: global.key1. + extensions (Optional[dict[str, Any]]): add custom keys for your specific plugin. Example - 'policy' + key for opa plugin. + + Examples: + >>> base = BaseTemplate(context=["global.key1.key2", "local.key1.key2"]) + >>> base.context + ["global.key1.key2", "local.key1.key2"] + >>> base = BaseTemplate(context=["global.key1.key2"], extensions={"policy" : "sample policy"}) + >>> base.extensions + {"policy" : "sample policy"} + """ + + context: Optional[list[str]] = None + extensions: Optional[dict[str, Any]] = None + +class ToolTemplate(BaseTemplate): """Tool Template. Attributes: @@ -110,7 +132,7 @@ class ToolTemplate(BaseModel): result: bool = False -class PromptTemplate(BaseModel): +class PromptTemplate(BaseTemplate): """Prompt Template. Attributes: @@ -134,7 +156,7 @@ class PromptTemplate(BaseModel): result: bool = False -class ResourceTemplate(BaseModel): +class ResourceTemplate(BaseTemplate): """Resource Template. Attributes: diff --git a/plugins/external/opa/opapluginfilter/plugin.py b/plugins/external/opa/opapluginfilter/plugin.py index 81c9f2615..76448a14a 100644 --- a/plugins/external/opa/opapluginfilter/plugin.py +++ b/plugins/external/opa/opapluginfilter/plugin.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies. Copyright 2025 @@ -52,9 +53,24 @@ def __init__(self, config: PluginConfig): """ super().__init__(config) self.opa_config = OPAConfig.model_validate(self._config.config) + self.opa_context_key = "opa_policy_context" - def _evaluate_opa_policy(self, url: str, input_dict: OPAInput) -> tuple[bool,Any]: - payload = input_dict.model_dump() + + def _evaluate_opa_policy(self, url: str, input: OPAInput) -> tuple[bool,Any]: + """Function to evaluate OPA policy. Makes a request to opa server with url and input. + + Args: + url: The url to call opa server + input: Contains the payload of input to be sent to opa server for policy evaluation. + + Returns: + True, json_response if the opa policy is allowed else false. The json response is the actual response returned by OPA server. + If OPA server encountered any error, the return would be True (to gracefully exit) and None would be the json_response, marking + an issue with the OPA server running. + + """ + + payload = input.model_dump() logger.info(f"OPA url {url}, OPA payload {payload}") rsp = requests.post(url, json=payload) logger.info(f"OPA connection response '{rsp}'") @@ -68,6 +84,7 @@ def _evaluate_opa_policy(self, url: str, input_dict: OPAInput) -> tuple[bool,Any logger.debug(f"OPA sent a none response {json_response}") else: logger.debug(f"OPA error: {rsp}") + return True, None async def prompt_pre_fetch(self, payload: PromptPrehookPayload, context: PluginContext) -> PromptPrehookResult: """The plugin hook run before a prompt is retrieved and rendered. @@ -105,22 +122,32 @@ async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginCo The result of the plugin's analysis, including whether the tool can proceed. """ - logger.debug(f"Processing tool pre-invoke for tool '{payload.name}' with {len(payload.args) if payload.args else 0} arguments") - + logger.info(f"Processing tool pre-invoke for tool '{payload.name}' with {len(payload.args) if payload.args else 0} arguments") + logger.info(f"Processing tool context {context}") + if not payload.args: return ToolPreInvokeResult() - opa_input = BaseOPAInputKeys(kind="tools/call", user = "none", tool = {"name" : payload.name, "args" : payload.args}, request_ip = "none", headers = {}, response = {}) - opa_server_url = self.opa_config.server_url - policy_url = opa_server_url + "/allow_pre_tool" - decision, decision_context = self._evaluate_opa_policy(policy_url,input_dict=OPAInput(input=opa_input)) - if not decision: - violation = PluginViolation( - reason="tool invocation not allowed", - description="OPA policy failed on tool preinvocation", - code="deny", - details=decision_context,) - return ToolPreInvokeResult(modified_payload=payload, violation=violation, continue_processing=False) + + # Get the tool for which policy needs to be applied + policy_apply_config = self._config.applied_to + if policy_apply_config and policy_apply_config.tools: + for tool in policy_apply_config.tools: + tool_name = tool.name + if payload.name == tool_name: + tool_context = [item.rsplit('.', 1)[-1] for item in tool.context] if tool.context else None + policy_context = {k : context.global_context.state[self.opa_context_key][k] for k in tool_context} + tool_policy = tool.extensions.get("policy",None) if tool.extensions else None + opa_input = BaseOPAInputKeys(kind="tools/call", user = "none", payload=payload.model_dump(), context=policy_context, request_ip = "none", headers = {}, response = {}) + opa_server_url = self.opa_config.opa_base_url + tool_policy + "/allow" + decision, decision_context = self._evaluate_opa_policy(opa_server_url,input_dict=OPAInput(input=opa_input)) + if not decision: + violation = PluginViolation( + reason="tool invocation not allowed", + description="OPA policy failed on tool preinvocation", + code="deny", + details=decision_context,) + return ToolPreInvokeResult(modified_payload=payload, violation=violation, continue_processing=False) return ToolPreInvokeResult(continue_processing=True) async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult: @@ -134,19 +161,4 @@ async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: Plugin Returns: The result of the plugin's analysis, including whether the tool result should proceed. """ - logger.info(f"OPA tool post request {payload} , {context}") - result = payload.result - opa_server_url = self.opa_config.server_url - policy_url = opa_server_url + "/allow_post_tool" - for content in result.content: - opa_input = BaseOPAInputKeys(kind="tools/call", user = "none", tool = {"name" : payload.name, "args" : content}, request_ip = "none", headers = {}, response = {}) - decision, decision_context = self._evaluate_opa_policy(policy_url,input_dict=OPAInput(input=opa_input)) - if not decision: - violation = PluginViolation( - reason="tool invocation not allowed", - description="OPA policy failed on tool postinvocation", - code="deny", - details=decision_context,) - return ToolPreInvokeResult(modified_payload=payload, violation=violation, continue_processing=False) - - return ToolPostInvokeResult(continue_processing=True) + return ToolPostInvokeResult(continue_processing=True) \ No newline at end of file diff --git a/plugins/external/opa/opapluginfilter/schema.py b/plugins/external/opa/opapluginfilter/schema.py index eb7674eaa..410872438 100644 --- a/plugins/external/opa/opapluginfilter/schema.py +++ b/plugins/external/opa/opapluginfilter/schema.py @@ -1,34 +1,66 @@ +# -*- coding: utf-8 -*- +"""A schema file for OPA plugin. + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Shriti Priya + +This module defines schema for OPA plugin. +""" + +# Standard +from typing import Optional, Any + +# Third-Party from pydantic import BaseModel -from typing import Optional -from typing import Optional, Dict, Any class BaseOPAInputKeys(BaseModel): - kind : str - user : str - tool : Dict[str, Any] - request_ip : str - headers : Dict[str, str] - response : Dict[str, str] + """BaseOPAInputKeys + Attributes: + kind (Optional[str]) : specifying if it is a tool/call, or prompt, or resource request. + user (Optional[str]): specifies user information like admin etc. + request_ip (Optional[str]): specifies the IP of the request. + headers (Optional[dict[str, str]]): specifies the headers for the request. + response (Optional[dict[str, str]]) : specifies the response for the request. + payload (dict[str, Any]) : required payload for the request. + context (Optional[dict[str, Any]]) : context provided for policy evaluation. -class OPAInput(BaseModel): - input : BaseOPAInputKeys + Examples: + >>> opa_input = BaseOPAInputKeys(payload={"input" : {"repo_path" : "/path/file"}}, context = {"opa_policy_context" : {"context1" : "value1"}}) + >>> opa_input.payload + '{"input" : {"repo_path" : "/path/file"}' + >>> opa_input.context + '{"opa_policy_context" : {"context1" : "value1"}}' + """ + kind : Optional[str] = None + user : Optional[str] = None + request_ip : Optional[str] = None + headers : Optional[dict[str, str]] = None + response : Optional[dict[str, str]] = None + payload: dict[str, Any] + context: Optional[dict[str, Any]] = None -class OPAResult(BaseModel): - allow : bool = True - patch : Optional[Dict[str, Any]] = None - reason: Optional[str] = None -class OPAConfig(BaseModel): - """Configuration for the PII Filter plugin.""" +class OPAInput(BaseModel): + """OPAInput + + Attributes: + input (BaseOPAInputKeys) : specifies the input to be passed to opa server for policy evaluation - # Enable/disable detection for specific PII types - policy: str = "None" - server_url: str = "None" + Examples: + >>> opa_input = OPAInput(input=BaseOPAInputKeys(payload={"input" : {"repo_path" : "/path/file"}}, context = {"opa_policy_context" : {"context1" : "value1"}})) + >>> opa_input.input.payload + '{"input" : {"repo_path" : "/path/file"}' + >>> opa_input.input.context + '{"opa_policy_context" : {"context1" : "value1"}}' + """ + input : BaseOPAInputKeys + +class OPAConfig(BaseModel): + """Configuration for the OPA plugin.""" -POLICY_BUNDLE_PATH = None -POLICY_BUNDLE_URL = None -POLICY_POLL_SEC = None -POLICY_ENABLED= None \ No newline at end of file + # Base url on which opa server is running + opa_base_url: str = "None" diff --git a/plugins/external/opa/resources/plugins/config.yaml b/plugins/external/opa/resources/plugins/config.yaml index 5e8a1de97..705e5890a 100644 --- a/plugins/external/opa/resources/plugins/config.yaml +++ b/plugins/external/opa/resources/plugins/config.yaml @@ -7,16 +7,21 @@ plugins: hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] tags: ["plugin"] mode: "enforce" # enforce | permissive | disabled - priority: 150 + priority: 10 + applied_to: + tools: + - name: "fast-time-git-status" + context: + - "global.opa_policy_context.git_context" + extensions: + policy: "example" conditions: # Apply to specific tools/servers - server_ids: [] # Apply to all servers tenant_ids: [] # Apply to all tenants config: # Plugin config dict passed to the plugin constructor - policy: "all path requests must have IBM" - server_url: "http://127.0.0.1:8181/v1/data/example" - + opa_base_url: "http://127.0.0.1:8181/v1/data/" # Plugin directories to scan plugin_dirs: From 08c1ebded0dbb15711b16000967ae8d94c0acbbd Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Thu, 28 Aug 2025 17:41:39 -0400 Subject: [PATCH 09/26] Changes in plugin config schema Signed-off-by: Shriti Priya --- mcpgateway/plugins/framework/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcpgateway/plugins/framework/models.py b/mcpgateway/plugins/framework/models.py index d3280503c..9069856a5 100644 --- a/mcpgateway/plugins/framework/models.py +++ b/mcpgateway/plugins/framework/models.py @@ -330,7 +330,7 @@ class PluginConfig(BaseModel): mode: PluginMode = PluginMode.ENFORCE priority: Optional[int] = None # Lower = higher priority conditions: Optional[list[PluginCondition]] = None # When to apply - applied_to: Optional[list[AppliedTo]] = None # Fields to apply to. + applied_to: Optional[AppliedTo] = None # Fields to apply to. config: Optional[dict[str, Any]] = None mcp: Optional[MCPConfig] = None From 881bf301e4df70ec94eae95f234397a873f94828 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Thu, 28 Aug 2025 17:44:42 -0400 Subject: [PATCH 10/26] Schema update models.py Signed-off-by: Shriti Priya --- mcpgateway/plugins/framework/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mcpgateway/plugins/framework/models.py b/mcpgateway/plugins/framework/models.py index 9069856a5..0df475f87 100644 --- a/mcpgateway/plugins/framework/models.py +++ b/mcpgateway/plugins/framework/models.py @@ -127,7 +127,7 @@ class ToolTemplate(BaseTemplate): True """ - tool_name: str + name: str fields: Optional[list[str]] = None result: bool = False @@ -237,6 +237,8 @@ class AppliedTo(BaseModel): tools (Optional[list[ToolTemplate]]): tools and fields to be applied. prompts (Optional[list[PromptTemplate]]): prompts and fields to be applied. resources (Optional[list[ResourceTemplate]]): resources and fields to be applied. + global_context (Optional[list[str]]): keys in the context to be applied on globally + local_context(Optional[list[str]]): keys in the context to be applied on locally """ tools: Optional[list[ToolTemplate]] = None From cb0908042af1f1dce38c462a33ae16de1ed8521f Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Thu, 28 Aug 2025 17:48:39 -0400 Subject: [PATCH 11/26] updated schema Signed-off-by: Shriti Priya --- mcpgateway/plugins/framework/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mcpgateway/plugins/framework/models.py b/mcpgateway/plugins/framework/models.py index 0df475f87..7670a3132 100644 --- a/mcpgateway/plugins/framework/models.py +++ b/mcpgateway/plugins/framework/models.py @@ -84,7 +84,6 @@ class PluginMode(str, Enum): DISABLED = "disabled" - class BaseTemplate(BaseModel): """Base Template.The ToolTemplate, PromptTemplate and ResourceTemplate could be extended using this @@ -106,6 +105,7 @@ class BaseTemplate(BaseModel): context: Optional[list[str]] = None extensions: Optional[dict[str, Any]] = None + class ToolTemplate(BaseTemplate): """Tool Template. @@ -132,7 +132,7 @@ class ToolTemplate(BaseTemplate): result: bool = False -class PromptTemplate(BaseTemplate): +class PromptTemplate(BaseModel): """Prompt Template. Attributes: @@ -156,7 +156,7 @@ class PromptTemplate(BaseTemplate): result: bool = False -class ResourceTemplate(BaseTemplate): +class ResourceTemplate(BaseModel): """Resource Template. Attributes: From ff2e26915adb34fc41753b73bccf33d1faf1e0b3 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Thu, 28 Aug 2025 18:51:45 -0400 Subject: [PATCH 12/26] Adding endpoint to policy Signed-off-by: Shriti Priya --- mcpgateway/plugins/framework/models.py | 5 +- plugins/external/config.yaml | 2 +- plugins/external/opa/README.md | 115 ++++++++++++------ .../external/opa/opapluginfilter/__init__.py | 1 + .../external/opa/opapluginfilter/plugin.py | 21 +++- .../external/opa/opaserver/rego/example.rego | 36 +----- .../opa/resources/plugins/config.yaml | 1 + .../opa/tests/test_opapluginfilter.py | 1 + 8 files changed, 102 insertions(+), 80 deletions(-) diff --git a/mcpgateway/plugins/framework/models.py b/mcpgateway/plugins/framework/models.py index 7670a3132..b51e066ac 100644 --- a/mcpgateway/plugins/framework/models.py +++ b/mcpgateway/plugins/framework/models.py @@ -84,6 +84,7 @@ class PluginMode(str, Enum): DISABLED = "disabled" + class BaseTemplate(BaseModel): """Base Template.The ToolTemplate, PromptTemplate and ResourceTemplate could be extended using this @@ -132,7 +133,7 @@ class ToolTemplate(BaseTemplate): result: bool = False -class PromptTemplate(BaseModel): +class PromptTemplate(BaseTemplate): """Prompt Template. Attributes: @@ -156,7 +157,7 @@ class PromptTemplate(BaseModel): result: bool = False -class ResourceTemplate(BaseModel): +class ResourceTemplate(BaseTemplate): """Resource Template. Attributes: diff --git a/plugins/external/config.yaml b/plugins/external/config.yaml index 4c78e47ce..d9632f3a9 100644 --- a/plugins/external/config.yaml +++ b/plugins/external/config.yaml @@ -6,7 +6,7 @@ plugins: mcp: proto: STREAMABLEHTTP url: http://127.0.0.1:3000/mcp - + - name: "OPAPluginFilter" kind: "external" priority: 10 # adjust the priority diff --git a/plugins/external/opa/README.md b/plugins/external/opa/README.md index 7f35db096..0af3c159e 100644 --- a/plugins/external/opa/README.md +++ b/plugins/external/opa/README.md @@ -1,65 +1,104 @@ -# OPAPluginFilter for Context Forge MCP Gateway +# OPA Plugin for MCP Gateway + +> Author: Shriti Priya +> Version: 0.1.0 An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies. +The OPA plugin is composed of two components: +1. OPA server +2. The pre/post hooks on tools/prompts for OP. A plugin behaving as OPA client and calling the OPA server. -## Installation +### OPA Server +To define a policy file you need to go into opaserver/rego and create a sample policy file for you. +Example -`example.rego` is present. +Once you have this file created in this location, when building the server, the opa binaries will be downloaded and a container will be build. +In the `run_server.sh` file, the opa server will run as a background service in the container with the rego policy file. -To install dependencies with dev packages (required for linting and testing): +### OPA Plugin +The OPA plugin runs as an external plugin with pre/post tool invocations. So everytime, a tool invocation is made, and +if OPAPluginFilter has been defined in config.yaml file, the tool invocation will pass through this OPA Plugin. -```bash -make install-dev -``` -Alternatively, you can also install it in editable mode: +## Installation -```bash -make install-editable +1. Copy .env.example .env +2. Enable plugins in `.env` using `PLUGINS_ENABLED=true` +3. Add the plugin configuration to `plugins/config.yaml`: + +```yaml +plugins: + - name: "OPAPluginFilter" + kind: "opapluginfilter.plugin.OPAPluginFilter" + description: "An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies" + version: "0.1.0" + author: "Shriti Priya" + hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + tags: ["plugin"] + mode: "enforce" # enforce | permissive | disabled + priority: 10 + applied_to: + tools: + - name: "fast-time-git-status" + context: + - "global.opa_policy_context.git_context" + extensions: + policy: "example" + policy_endpoint: "allow" + conditions: + # Apply to specific tools/servers + - server_ids: [] # Apply to all servers + tenant_ids: [] # Apply to all tenants + config: + # Plugin config dict passed to the plugin constructor + opa_base_url: "http://127.0.0.1:8181/v1/data/" +``` +The `applied_to` key in config.yaml, has been used to selectively apply policies and provide context for a specific tool. + +In the example above: +```applied_to: + tools: + - name: "fast-time-git-status" + context: + - "global.opa_policy_context.git_context" + extensions: + policy: "example" + policy_endpoint: "allow" ``` -## Setting up the development environment - -1. Copy .env.template .env -2. Enable plugins in `.env` +Here, using this, you can provide the `name` of the tool you want to apply policy on, you can also provide +context to the tool with the prefix `global` if it needs to check the context in global context provided. +The key `opa_policy_context` is used to get context for policies and you can have multiple contexts within this key using `git_context` key. +You can also provide policy within the `extensions` key where you can provide information to the plugin +related to which policy to run and what endpoint to call for that policy. +In the `config` key in `config.yaml` file OPAPlugin consists of the following things: +`opa_base_url` : It is the base url on which opa server is running. ## Testing -Test modules are created under the `tests` directory. -To run all tests, use the following command: -```bash -make test -``` +## License + +Apache-2.0 + +## Support + +For issues or questions, please open an issue in the MCP Gateway repository. + + + + + -**Note:** To enable logging, set `log_cli = true` in `tests/pytest.ini`. -## Code Linting -Before checking in any code for the project, please lint the code. This can be done using: -```bash -make lint-fix -``` -## Runtime (server) -This project uses [chuck-mcp-runtime](https://github.com/chrishayuk/chuk-mcp-runtime) to run external plugins as a standardized MCP server. -To build the container image: -```bash -make build -``` -To run the container: -```bash -make start -``` -To stop the container: -```bash -make stop -``` diff --git a/plugins/external/opa/opapluginfilter/__init__.py b/plugins/external/opa/opapluginfilter/__init__.py index 9e70f83b8..5a0409e37 100644 --- a/plugins/external/opa/opapluginfilter/__init__.py +++ b/plugins/external/opa/opapluginfilter/__init__.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """MCP Gateway OPAPluginFilter Plugin - An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies. Copyright 2025 diff --git a/plugins/external/opa/opapluginfilter/plugin.py b/plugins/external/opa/opapluginfilter/plugin.py index 76448a14a..4fc03a70a 100644 --- a/plugins/external/opa/opapluginfilter/plugin.py +++ b/plugins/external/opa/opapluginfilter/plugin.py @@ -129,18 +129,27 @@ async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginCo return ToolPreInvokeResult() + tool_context = [] + policy_context = {} + tool_policy = None + tool_policy_endpoint = None # Get the tool for which policy needs to be applied policy_apply_config = self._config.applied_to if policy_apply_config and policy_apply_config.tools: for tool in policy_apply_config.tools: tool_name = tool.name if payload.name == tool_name: - tool_context = [item.rsplit('.', 1)[-1] for item in tool.context] if tool.context else None - policy_context = {k : context.global_context.state[self.opa_context_key][k] for k in tool_context} - tool_policy = tool.extensions.get("policy",None) if tool.extensions else None + if tool.context: + tool_context = [ctx.rsplit('.', 1)[-1] for ctx in tool.context] + if self.opa_context_key in context.global_context.state: + policy_context = {k : context.global_context.state[self.opa_context_key][k] for k in tool_context} + if tool.extensions: + tool_policy = tool.extensions.get("policy",None) + tool_policy_endpoint = tool.extensions.get("policy_endpoint",None) + opa_input = BaseOPAInputKeys(kind="tools/call", user = "none", payload=payload.model_dump(), context=policy_context, request_ip = "none", headers = {}, response = {}) - opa_server_url = self.opa_config.opa_base_url + tool_policy + "/allow" - decision, decision_context = self._evaluate_opa_policy(opa_server_url,input_dict=OPAInput(input=opa_input)) + opa_server_url = "{opa_url}{policy}/{policy_endpoint}".format(opa_url = self.opa_config.opa_base_url, policy=tool_policy, policy_endpoint=tool_policy_endpoint) + decision, decision_context = self._evaluate_opa_policy(url=opa_server_url,input=OPAInput(input=opa_input)) if not decision: violation = PluginViolation( reason="tool invocation not allowed", @@ -161,4 +170,4 @@ async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: Plugin Returns: The result of the plugin's analysis, including whether the tool result should proceed. """ - return ToolPostInvokeResult(continue_processing=True) \ No newline at end of file + return ToolPostInvokeResult(continue_processing=True) diff --git a/plugins/external/opa/opaserver/rego/example.rego b/plugins/external/opa/opaserver/rego/example.rego index 880ed8a9e..87860161e 100644 --- a/plugins/external/opa/opaserver/rego/example.rego +++ b/plugins/external/opa/opaserver/rego/example.rego @@ -10,40 +10,10 @@ package example # Default policy values for all the policies -default allow_pre_tool := false -default allow_post_tool := false -default allow_pre_prompt := false -default allow_post_prompt := false -default allow_pre_resource := false -default allow_post_resource := false +default allow := false # Policies applied for pre tool invocations -allow_pre_tool if { - contains(input.tool.args.repo_path, "IBM") +allow if { + contains(input.payload.args.repo_path, "IBM") } - -# Policies applied for post tool invocations -allow_post_tool if { - contains(input.tool.args.repo_path, "IBM") -} - -# Policies applied for pre prompt invocations -allow_pre_prompt if { - input.prompt.args.text == "allowed-word" -} - -# Policies applied for post prompt invocations -allow_post_path if { - input.prompt.args.text == "allowed-word" -} - -# Policies applied for pre resource invocations -allow_pre_resource if { - input.uri == "allowed-domain" -} - -# Policies applied for post resource invocations -allow_post_resource if { - input.uri == "allowed-domain" -} \ No newline at end of file diff --git a/plugins/external/opa/resources/plugins/config.yaml b/plugins/external/opa/resources/plugins/config.yaml index 705e5890a..5ea03130b 100644 --- a/plugins/external/opa/resources/plugins/config.yaml +++ b/plugins/external/opa/resources/plugins/config.yaml @@ -15,6 +15,7 @@ plugins: - "global.opa_policy_context.git_context" extensions: policy: "example" + policy_endpoint: "allow" conditions: # Apply to specific tools/servers - server_ids: [] # Apply to all servers diff --git a/plugins/external/opa/tests/test_opapluginfilter.py b/plugins/external/opa/tests/test_opapluginfilter.py index d37d0c22b..34080ecf3 100644 --- a/plugins/external/opa/tests/test_opapluginfilter.py +++ b/plugins/external/opa/tests/test_opapluginfilter.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """Tests for plugin.""" # Third-Party From ed0abf3cdeb861cf44a1ac09a654824e363bc0a7 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Fri, 29 Aug 2025 09:54:25 -0400 Subject: [PATCH 13/26] documentation for OPA Plugin Signed-off-by: Shriti Priya --- plugins/external/opa/README.md | 67 +++++++++++++++++-- .../opa/resources/plugins/config.yaml | 2 +- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/plugins/external/opa/README.md b/plugins/external/opa/README.md index 0af3c159e..bf44c398f 100644 --- a/plugins/external/opa/README.md +++ b/plugins/external/opa/README.md @@ -16,15 +16,13 @@ Once you have this file created in this location, when building the server, the In the `run_server.sh` file, the opa server will run as a background service in the container with the rego policy file. ### OPA Plugin -The OPA plugin runs as an external plugin with pre/post tool invocations. So everytime, a tool invocation is made, and -if OPAPluginFilter has been defined in config.yaml file, the tool invocation will pass through this OPA Plugin. +The OPA plugin runs as an external plugin with pre/post tool invocations. So everytime, a tool invocation is made, and if OPAPluginFilter has been defined in config.yaml file, the tool invocation will pass through this OPA Plugin. ## Installation -1. Copy .env.example .env -2. Enable plugins in `.env` using `PLUGINS_ENABLED=true` -3. Add the plugin configuration to `plugins/config.yaml`: +1. In the folder `external/opa`, copy .env.example .env +2. Add the plugin configuration to `plugins/external/opa/resources/plugins/config.yaml`: ```yaml plugins: @@ -74,9 +72,66 @@ related to which policy to run and what endpoint to call for that policy. In the `config` key in `config.yaml` file OPAPlugin consists of the following things: `opa_base_url` : It is the base url on which opa server is running. -## Testing +3. Now suppose i have a sample policy, in `example.rego` file that allows a tool invocation only when "IBM" key word is present in the repo_path. Add the sample policy file or policy rego file that you defined, in `plugins/external/opa/opaserver/rego`. +3. Once you have your plugin defined in `config.yaml` and policy added in the rego file, run the following commands to build your OPA Plugin external MCP server using: +* make build -> This will build a docker image named `opapluginfilter` +Verification point: +docker images mcpgateway/opapluginfilter:latest +REPOSITORY TAG IMAGE ID CREATED SIZE +mcpgateway/opapluginfilter latest a94428dd9c64 1 second ago 810MB + +* make start -> This will start the OPA Plugin server +Verification point: +✅ Container started +🔍 Health check status: +starting + +## Testing with gateway + +1. Add server fast-time that exposes git tools in the mcp gateway +curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"fast-time","url":"http://localhost:9000/sse"}' \ + http://localhost:4444/gateways + +2. This adds server to the gateway and exposes all the tools for git. You would see `fast-time-git-status` as the tool appearing in the tools tab of mcp gateway. + +3. The next step is to enable the opa plugin which you can do by adding `PLUGINS_ENABLED=true` and the following blob in `plugins/config.yaml` file. This will indicate that OPA Plugin is running as an external MCP server. + + ```yaml + - name: "OPAPluginFilter" + kind: "external" + priority: 10 # adjust the priority + mcp: + proto: STREAMABLEHTTP + url: http://127.0.0.1:8000/mcp + ``` + +2. To test this plugin with the above tool `fast-time-git-status` you can either invoke it through the UI +```curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"fast-time","url":"http://localhost:9000/sse"}' \ + http://localhost:4444/gateways``` + + +```curl -X POST -H "Content-Type: application/json" \ + -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ + -d '{"jsonrpc":"2.0","id":1,"method":"fast-time-git-status","params":{"repo_path":"path/BIM"}}' \ + http://localhost:4444/rpc``` + +This should output policy_deny because +```{"detail":"policy_deny"}``` + + + +curl -X POST -H "Content-Type: application/json" \ + -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ + -d '{"jsonrpc":"2.0","id":1,"method":"fast-time-git-status","params":{"repo_path":"path/IBM"}}' \ + http://localhost:4444/rpc + +```{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"/Users/shritipriya/Documents/2025/271-PR/mcp-context-forge/path/IBM"}],"is_error":false},"id":1}``` ## License diff --git a/plugins/external/opa/resources/plugins/config.yaml b/plugins/external/opa/resources/plugins/config.yaml index 5ea03130b..1fe205b3e 100644 --- a/plugins/external/opa/resources/plugins/config.yaml +++ b/plugins/external/opa/resources/plugins/config.yaml @@ -7,7 +7,7 @@ plugins: hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] tags: ["plugin"] mode: "enforce" # enforce | permissive | disabled - priority: 10 + priority: 30 applied_to: tools: - name: "fast-time-git-status" From c954595b3abd2a9c0d50d018f4e6d1fc76d2c300 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Fri, 29 Aug 2025 10:36:16 -0400 Subject: [PATCH 14/26] documentation update Signed-off-by: Shriti Priya --- plugins/external/opa/README.md | 16 ++++++++-------- .../external/opa/tests/test_opapluginfilter.py | 13 ++++++++----- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/plugins/external/opa/README.md b/plugins/external/opa/README.md index bf44c398f..995814c35 100644 --- a/plugins/external/opa/README.md +++ b/plugins/external/opa/README.md @@ -77,24 +77,24 @@ In the `config` key in `config.yaml` file OPAPlugin consists of the following th 3. Once you have your plugin defined in `config.yaml` and policy added in the rego file, run the following commands to build your OPA Plugin external MCP server using: * make build -> This will build a docker image named `opapluginfilter` -Verification point: +```Verification point: docker images mcpgateway/opapluginfilter:latest REPOSITORY TAG IMAGE ID CREATED SIZE -mcpgateway/opapluginfilter latest a94428dd9c64 1 second ago 810MB +mcpgateway/opapluginfilter latest a94428dd9c64 1 second ago 810MB``` * make start -> This will start the OPA Plugin server -Verification point: +```Verification point: ✅ Container started 🔍 Health check status: -starting +starting``` ## Testing with gateway 1. Add server fast-time that exposes git tools in the mcp gateway -curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ +```curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"fast-time","url":"http://localhost:9000/sse"}' \ - http://localhost:4444/gateways + http://localhost:4444/gateways``` 2. This adds server to the gateway and exposes all the tools for git. You would see `fast-time-git-status` as the tool appearing in the tools tab of mcp gateway. @@ -126,10 +126,10 @@ This should output policy_deny because -curl -X POST -H "Content-Type: application/json" \ +```curl -X POST -H "Content-Type: application/json" \ -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -d '{"jsonrpc":"2.0","id":1,"method":"fast-time-git-status","params":{"repo_path":"path/IBM"}}' \ - http://localhost:4444/rpc + http://localhost:4444/rpc``` ```{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"/Users/shritipriya/Documents/2025/271-PR/mcp-context-forge/path/IBM"}],"is_error":false},"id":1}``` diff --git a/plugins/external/opa/tests/test_opapluginfilter.py b/plugins/external/opa/tests/test_opapluginfilter.py index 34080ecf3..f3929a191 100644 --- a/plugins/external/opa/tests/test_opapluginfilter.py +++ b/plugins/external/opa/tests/test_opapluginfilter.py @@ -9,7 +9,8 @@ from mcpgateway.plugins.framework import ( PluginConfig, PluginContext, - PromptPrehookPayload, + ToolPreInvokePayload, + GlobalContext ) @@ -19,14 +20,16 @@ async def test_opapluginfilter(): config = PluginConfig( name="test", kind="opapluginfilter.OPAPluginFilter", - hooks=["prompt_pre_fetch"], + hooks=["tool_pre_invoke"], config={"setting_one": "test_value"}, ) plugin = OPAPluginFilter(config) # Test your plugin logic - payload = PromptPrehookPayload(name="test_prompt", args={"arg0": "This is an argument"}) - context = PluginContext(request_id="1", server_id="2") - result = await plugin.prompt_pre_fetch(payload, context) + payload = ToolPreInvokePayload(name="test_tool", args={"repo_path": "This is an argument"}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) + result = await plugin.tool_pre_invoke(payload, context) + import pdb + pdb.set_trace() assert result.continue_processing From f4032970910a1cc0cd53c5028fd2586d3cc49883 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Fri, 29 Aug 2025 10:41:33 -0400 Subject: [PATCH 15/26] documentation update Signed-off-by: Shriti Priya --- plugins/external/opa/README.md | 46 +++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/plugins/external/opa/README.md b/plugins/external/opa/README.md index 995814c35..9d1cbcd83 100644 --- a/plugins/external/opa/README.md +++ b/plugins/external/opa/README.md @@ -7,7 +7,7 @@ An OPA plugin that enforces rego policies on requests and allows/denies requests The OPA plugin is composed of two components: 1. OPA server -2. The pre/post hooks on tools/prompts for OP. A plugin behaving as OPA client and calling the OPA server. +2. The pre hooks on tools that talks to OPA server running as background service within the same container. Whenever a tool is invoked, if OPA Plugin is in action, a policy will be applied on the tool call to allow/deny it. ### OPA Server To define a policy file you need to go into opaserver/rego and create a sample policy file for you. @@ -77,24 +77,30 @@ In the `config` key in `config.yaml` file OPAPlugin consists of the following th 3. Once you have your plugin defined in `config.yaml` and policy added in the rego file, run the following commands to build your OPA Plugin external MCP server using: * make build -> This will build a docker image named `opapluginfilter` -```Verification point: +```bash +Verification point: docker images mcpgateway/opapluginfilter:latest REPOSITORY TAG IMAGE ID CREATED SIZE -mcpgateway/opapluginfilter latest a94428dd9c64 1 second ago 810MB``` +mcpgateway/opapluginfilter latest a94428dd9c64 1 second ago 810MB +``` * make start -> This will start the OPA Plugin server -```Verification point: +```bash +Verification point: ✅ Container started 🔍 Health check status: -starting``` +starting +``` ## Testing with gateway 1. Add server fast-time that exposes git tools in the mcp gateway -```curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ +```bash +curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"fast-time","url":"http://localhost:9000/sse"}' \ - http://localhost:4444/gateways``` + http://localhost:4444/gateways +``` 2. This adds server to the gateway and exposes all the tools for git. You would see `fast-time-git-status` as the tool appearing in the tools tab of mcp gateway. @@ -110,28 +116,38 @@ starting``` ``` 2. To test this plugin with the above tool `fast-time-git-status` you can either invoke it through the UI -```curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ +```bash +curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"fast-time","url":"http://localhost:9000/sse"}' \ - http://localhost:4444/gateways``` + http://localhost:4444/gateways +``` -```curl -X POST -H "Content-Type: application/json" \ +```bash +curl -X POST -H "Content-Type: application/json" \ -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -d '{"jsonrpc":"2.0","id":1,"method":"fast-time-git-status","params":{"repo_path":"path/BIM"}}' \ - http://localhost:4444/rpc``` + http://localhost:4444/rpc +``` This should output policy_deny because -```{"detail":"policy_deny"}``` +```bash +{"detail":"policy_deny"} +``` -```curl -X POST -H "Content-Type: application/json" \ +```bash +curl -X POST -H "Content-Type: application/json" \ -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -d '{"jsonrpc":"2.0","id":1,"method":"fast-time-git-status","params":{"repo_path":"path/IBM"}}' \ - http://localhost:4444/rpc``` + http://localhost:4444/rpc +``` -```{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"/Users/shritipriya/Documents/2025/271-PR/mcp-context-forge/path/IBM"}],"is_error":false},"id":1}``` +```bash +{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"/Users/shritipriya/Documents/2025/271-PR/mcp-context-forge/path/IBM"}],"is_error":false},"id":1} +``` ## License From 56248587b42b33e1414e7b15aa4c89ae652f864c Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Fri, 29 Aug 2025 10:46:31 -0400 Subject: [PATCH 16/26] documentation update Signed-off-by: Shriti Priya --- plugins/external/opa/README.md | 35 +++++++++------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/plugins/external/opa/README.md b/plugins/external/opa/README.md index 9d1cbcd83..c6d1a616f 100644 --- a/plugins/external/opa/README.md +++ b/plugins/external/opa/README.md @@ -52,18 +52,6 @@ plugins: opa_base_url: "http://127.0.0.1:8181/v1/data/" ``` The `applied_to` key in config.yaml, has been used to selectively apply policies and provide context for a specific tool. - -In the example above: -```applied_to: - tools: - - name: "fast-time-git-status" - context: - - "global.opa_policy_context.git_context" - extensions: - policy: "example" - policy_endpoint: "allow" -``` - Here, using this, you can provide the `name` of the tool you want to apply policy on, you can also provide context to the tool with the prefix `global` if it needs to check the context in global context provided. The key `opa_policy_context` is used to get context for policies and you can have multiple contexts within this key using `git_context` key. @@ -117,36 +105,31 @@ curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ 2. To test this plugin with the above tool `fast-time-git-status` you can either invoke it through the UI ```bash +# 1️⃣ Add fast-time server to mcpgateway curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name":"fast-time","url":"http://localhost:9000/sse"}' \ http://localhost:4444/gateways -``` - -```bash +# 2️⃣ Check if policies are in action. +# Deny case curl -X POST -H "Content-Type: application/json" \ -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -d '{"jsonrpc":"2.0","id":1,"method":"fast-time-git-status","params":{"repo_path":"path/BIM"}}' \ http://localhost:4444/rpc -``` - -This should output policy_deny because -```bash -{"detail":"policy_deny"} -``` - +>>>> +`{"detail":"policy_deny"}` -```bash +# 3️⃣ Check if policies are in action +# Allow case curl -X POST -H "Content-Type: application/json" \ -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -d '{"jsonrpc":"2.0","id":1,"method":"fast-time-git-status","params":{"repo_path":"path/IBM"}}' \ http://localhost:4444/rpc -``` -```bash -{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"/Users/shritipriya/Documents/2025/271-PR/mcp-context-forge/path/IBM"}],"is_error":false},"id":1} +>>>> +`{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"/Users/shritipriya/Documents/2025/271-PR/mcp-context-forge/path/IBM"}],"is_error":false},"id":1}` ``` ## License From 323fdae53e6da897c75fb6c4f7f3f16eb2271233 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Fri, 29 Aug 2025 10:47:49 -0400 Subject: [PATCH 17/26] documentation update Signed-off-by: Shriti Priya --- plugins/external/opa/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/external/opa/README.md b/plugins/external/opa/README.md index c6d1a616f..d018bc382 100644 --- a/plugins/external/opa/README.md +++ b/plugins/external/opa/README.md @@ -63,7 +63,7 @@ In the `config` key in `config.yaml` file OPAPlugin consists of the following th 3. Now suppose i have a sample policy, in `example.rego` file that allows a tool invocation only when "IBM" key word is present in the repo_path. Add the sample policy file or policy rego file that you defined, in `plugins/external/opa/opaserver/rego`. 3. Once you have your plugin defined in `config.yaml` and policy added in the rego file, run the following commands to build your OPA Plugin external MCP server using: -* make build -> This will build a docker image named `opapluginfilter` +* `make build`: This will build a docker image named `opapluginfilter` ```bash Verification point: @@ -72,7 +72,7 @@ REPOSITORY TAG IMAGE ID CREATED SIZE mcpgateway/opapluginfilter latest a94428dd9c64 1 second ago 810MB ``` -* make start -> This will start the OPA Plugin server +* `make start`: This will start the OPA Plugin server ```bash Verification point: ✅ Container started @@ -118,7 +118,7 @@ curl -X POST -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"fast-time-git-status","params":{"repo_path":"path/BIM"}}' \ http://localhost:4444/rpc ->>>> +>>> `{"detail":"policy_deny"}` # 3️⃣ Check if policies are in action @@ -128,7 +128,7 @@ curl -X POST -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"fast-time-git-status","params":{"repo_path":"path/IBM"}}' \ http://localhost:4444/rpc ->>>> +>>> `{"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"/Users/shritipriya/Documents/2025/271-PR/mcp-context-forge/path/IBM"}],"is_error":false},"id":1}` ``` From 7735cd0fbdc714843d5235ef9db60d2b3abfed2b Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Thu, 4 Sep 2025 09:14:28 -0400 Subject: [PATCH 18/26] fix: flake8 and doctest Signed-off-by: Shriti Priya --- mcpgateway/plugins/framework/models.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/mcpgateway/plugins/framework/models.py b/mcpgateway/plugins/framework/models.py index b51e066ac..704f8d7dc 100644 --- a/mcpgateway/plugins/framework/models.py +++ b/mcpgateway/plugins/framework/models.py @@ -84,7 +84,6 @@ class PluginMode(str, Enum): DISABLED = "disabled" - class BaseTemplate(BaseModel): """Base Template.The ToolTemplate, PromptTemplate and ResourceTemplate could be extended using this @@ -97,7 +96,7 @@ class BaseTemplate(BaseModel): Examples: >>> base = BaseTemplate(context=["global.key1.key2", "local.key1.key2"]) >>> base.context - ["global.key1.key2", "local.key1.key2"] + ['global.key1.key2', 'local.key1.key2'] >>> base = BaseTemplate(context=["global.key1.key2"], extensions={"policy" : "sample policy"}) >>> base.extensions {"policy" : "sample policy"} @@ -128,7 +127,7 @@ class ToolTemplate(BaseTemplate): True """ - name: str + tool_name: str fields: Optional[list[str]] = None result: bool = False From d9eaa4f5a2c12fac36f385fa7024bb7994956028 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Thu, 4 Sep 2025 09:26:58 -0400 Subject: [PATCH 19/26] fix: solving doctest errors Signed-off-by: Shriti Priya --- mcpgateway/plugins/framework/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcpgateway/plugins/framework/models.py b/mcpgateway/plugins/framework/models.py index 704f8d7dc..d6cd2efdf 100644 --- a/mcpgateway/plugins/framework/models.py +++ b/mcpgateway/plugins/framework/models.py @@ -99,7 +99,7 @@ class BaseTemplate(BaseModel): ['global.key1.key2', 'local.key1.key2'] >>> base = BaseTemplate(context=["global.key1.key2"], extensions={"policy" : "sample policy"}) >>> base.extensions - {"policy" : "sample policy"} + {'policy' : 'sample policy'} """ context: Optional[list[str]] = None From a659ddd927e1dea3a43a543ea4944b92ad179c50 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Thu, 4 Sep 2025 09:49:56 -0400 Subject: [PATCH 20/26] fix:doctest Signed-off-by: Shriti Priya --- mcpgateway/plugins/framework/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcpgateway/plugins/framework/models.py b/mcpgateway/plugins/framework/models.py index d6cd2efdf..07c395f27 100644 --- a/mcpgateway/plugins/framework/models.py +++ b/mcpgateway/plugins/framework/models.py @@ -99,7 +99,7 @@ class BaseTemplate(BaseModel): ['global.key1.key2', 'local.key1.key2'] >>> base = BaseTemplate(context=["global.key1.key2"], extensions={"policy" : "sample policy"}) >>> base.extensions - {'policy' : 'sample policy'} + {'policy': 'sample policy'} """ context: Optional[list[str]] = None From 18928e950a5a6f94b4c4254cf6e57cf2ff519615 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Thu, 4 Sep 2025 16:12:11 -0400 Subject: [PATCH 21/26] Adding tool_name variable change Signed-off-by: Shriti Priya --- plugins/external/opa/opapluginfilter/plugin.py | 2 +- plugins/external/opa/resources/plugins/config.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/external/opa/opapluginfilter/plugin.py b/plugins/external/opa/opapluginfilter/plugin.py index 4fc03a70a..c77e36485 100644 --- a/plugins/external/opa/opapluginfilter/plugin.py +++ b/plugins/external/opa/opapluginfilter/plugin.py @@ -137,7 +137,7 @@ async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginCo policy_apply_config = self._config.applied_to if policy_apply_config and policy_apply_config.tools: for tool in policy_apply_config.tools: - tool_name = tool.name + tool_name = tool.tool_name if payload.name == tool_name: if tool.context: tool_context = [ctx.rsplit('.', 1)[-1] for ctx in tool.context] diff --git a/plugins/external/opa/resources/plugins/config.yaml b/plugins/external/opa/resources/plugins/config.yaml index 1fe205b3e..5958a5f01 100644 --- a/plugins/external/opa/resources/plugins/config.yaml +++ b/plugins/external/opa/resources/plugins/config.yaml @@ -10,7 +10,7 @@ plugins: priority: 30 applied_to: tools: - - name: "fast-time-git-status" + - tool_name: "fast-time-git-status" context: - "global.opa_policy_context.git_context" extensions: From 2a0fdd635c91fc980f6382c2957d17558c0a958c Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Tue, 9 Sep 2025 16:01:58 -0400 Subject: [PATCH 22/26] test cases for opapluginfilter Signed-off-by: Shriti Priya --- .../external/opa/tests/server/opa_server.py | 50 ++++++++++++++ .../opa/tests/test_opapluginfilter.py | 68 +++++++++++++++++-- 2 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 plugins/external/opa/tests/server/opa_server.py diff --git a/plugins/external/opa/tests/server/opa_server.py b/plugins/external/opa/tests/server/opa_server.py new file mode 100644 index 000000000..1d2258203 --- /dev/null +++ b/plugins/external/opa/tests/server/opa_server.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +"""Test cases for OPA plugin + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Shriti Priya + +This module mocks up an opa server for testing. +""" + + +# Standard +import json +import threading + +# Third-Party +from http.server import BaseHTTPRequestHandler, HTTPServer + + +# This class mocks up the post request for OPA server to evaluate policies. +class MockOPAHandler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path == "/v1/data/example/allow": + content_length = int(self.headers.get('Content-Length', 0)) + post_body = self.rfile.read(content_length).decode('utf-8') + try: + data = json.loads(post_body) + if "IBM" in data["input"]["payload"]["args"]["repo_path"]: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"result": true}') + else: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"result": false}') + # Process data dictionary... + except json.JSONDecodeError: + # Handle invalid JSON + self.send_response(400) + self.end_headers() + self.wfile.write(b"Invalid JSON") + return + +# This creates a mock up server for OPA at port 8181 +def run_mock_opa(): + server = HTTPServer(('localhost', 8181), MockOPAHandler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server diff --git a/plugins/external/opa/tests/test_opapluginfilter.py b/plugins/external/opa/tests/test_opapluginfilter.py index f3929a191..1c9e93185 100644 --- a/plugins/external/opa/tests/test_opapluginfilter.py +++ b/plugins/external/opa/tests/test_opapluginfilter.py @@ -1,5 +1,13 @@ # -*- coding: utf-8 -*- -"""Tests for plugin.""" +"""Test cases for OPA plugin + +Copyright 2025 +SPDX-License-Identifier: Apache-2.0 +Authors: Shriti Priya + +This module contains test cases for running opa plugin. +""" + # Third-Party import pytest @@ -13,23 +21,71 @@ GlobalContext ) +from tests.server.opa_server import run_mock_opa + @pytest.mark.asyncio -async def test_opapluginfilter(): +# Test for when opaplugin is not applied to a tool +async def test_benign_opapluginfilter(): """Test plugin prompt prefetch hook.""" config = PluginConfig( name="test", kind="opapluginfilter.OPAPluginFilter", hooks=["tool_pre_invoke"], - config={"setting_one": "test_value"}, + applied_to = {"tools" : [{"tool_name": "fast-time-git-status", "context": ["global.opa_policy_context.git_context"], "extensions": {"policy": "example", "policy_endpoint" : "allow"}}]}, + config={"opa_base_url": "http://127.0.0.1:8181/v1/data/"} ) + mock_server = run_mock_opa() + plugin = OPAPluginFilter(config) # Test your plugin logic - payload = ToolPreInvokePayload(name="test_tool", args={"repo_path": "This is an argument"}) + payload = ToolPreInvokePayload(name="fast-time-git-status", args={"repo_path": "/path/IBM"}) context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) result = await plugin.tool_pre_invoke(payload, context) - import pdb - pdb.set_trace() + mock_server.shutdown() assert result.continue_processing + + +@pytest.mark.asyncio +# Test for when opaplugin is not applied to a tool +async def test_malign_opapluginfilter(): + """Test plugin prompt prefetch hook.""" + config = PluginConfig( + name="test", + kind="opapluginfilter.OPAPluginFilter", + hooks=["tool_pre_invoke"], + applied_to = {"tools" : [{"tool_name": "fast-time-git-status", "context": ["global.opa_policy_context.git_context"], "extensions": {"policy": "example", "policy_endpoint" : "allow"}}]}, + config={"opa_base_url": "http://127.0.0.1:8181/v1/data/"} + ) + mock_server = run_mock_opa() + plugin = OPAPluginFilter(config) + + # Test your plugin logic + payload = ToolPreInvokePayload(name="fast-time-git-status", args={"repo_path": "/path/IM"}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) + result = await plugin.tool_pre_invoke(payload, context) + mock_server.shutdown() + assert not result.continue_processing and result.violation.code == "deny" + +@pytest.mark.asyncio +# Test for opa plugin not applied to any of the tools +async def test_applied_to_opaplugin(): + """Test plugin prompt prefetch hook.""" + config = PluginConfig( + name="test", + kind="opapluginfilter.OPAPluginFilter", + hooks=["tool_pre_invoke"], + applied_to = {}, + config={"opa_base_url": "http://127.0.0.1:8181/v1/data/"} + ) + mock_server = run_mock_opa() + plugin = OPAPluginFilter(config) + + # Test your plugin logic + payload = ToolPreInvokePayload(name="fast-time-git-status", args={"repo_path": "/path/IM"}) + context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) + result = await plugin.tool_pre_invoke(payload, context) + mock_server.shutdown() + assert result.continue_processing \ No newline at end of file From 15f4f2c50ecf0f5411eedffab9120beeebfcca07 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Tue, 9 Sep 2025 16:45:46 -0400 Subject: [PATCH 23/26] Update manifest.in with exclude Signed-off-by: Shriti Priya --- MANIFEST.in | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 334ec3931..fba572e97 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -96,3 +96,13 @@ exclude llms-full.txt prune deployment prune mcp-servers prune agent_runtimes + +# Exclude opa +exclude plugins/external/opa/.dockerignore +exclude plugins/external/opa/.env.template +exclude plugins/external/opa/.ruff.toml +exclude plugins/external/opa/Containerfile +exclude plugins/external/opa/MANIFEST.in +exclude plugins/external/opa/opaserver/rego/example.rego +exclude plugins/external/opa/pyproject.toml +exclude plugins/external/opa/run-server.sh From 5195644b5058f8c69621503db8b83627a41f5388 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Tue, 9 Sep 2025 17:04:31 -0400 Subject: [PATCH 24/26] updated prehook Signed-off-by: Shriti Priya --- plugins/external/opa/resources/plugins/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/external/opa/resources/plugins/config.yaml b/plugins/external/opa/resources/plugins/config.yaml index 5958a5f01..42725e6b2 100644 --- a/plugins/external/opa/resources/plugins/config.yaml +++ b/plugins/external/opa/resources/plugins/config.yaml @@ -4,7 +4,7 @@ plugins: description: "An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies" version: "0.1.0" author: "Shriti Priya" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + hooks: ["tool_pre_invoke"] tags: ["plugin"] mode: "enforce" # enforce | permissive | disabled priority: 30 From 3a0fa92a8f517b9f74273a992e61951e66245ec6 Mon Sep 17 00:00:00 2001 From: Shriti Priya Date: Tue, 9 Sep 2025 17:08:21 -0400 Subject: [PATCH 25/26] updating documentation Signed-off-by: Shriti Priya --- plugins/external/opa/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/external/opa/README.md b/plugins/external/opa/README.md index d018bc382..044bc6d34 100644 --- a/plugins/external/opa/README.md +++ b/plugins/external/opa/README.md @@ -31,13 +31,13 @@ plugins: description: "An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies" version: "0.1.0" author: "Shriti Priya" - hooks: ["prompt_pre_fetch", "prompt_post_fetch", "tool_pre_invoke", "tool_post_invoke"] + hooks: ["tool_pre_invoke"] tags: ["plugin"] mode: "enforce" # enforce | permissive | disabled priority: 10 applied_to: tools: - - name: "fast-time-git-status" + - tool_name: "fast-time-git-status" context: - "global.opa_policy_context.git_context" extensions: From c0038b6ac33971a1edbfc3a284099ca05a0617f1 Mon Sep 17 00:00:00 2001 From: Mihai Criveti Date: Tue, 9 Sep 2025 23:13:16 +0100 Subject: [PATCH 26/26] rebase Signed-off-by: Mihai Criveti --- MANIFEST.in | 2 +- plugins/external/opa/README.md | 43 ++++++------------- .../external/opa/tests/server/opa_server.py | 2 +- .../opa/tests/test_opapluginfilter.py | 10 ++--- .../framework/test_manager_extended.py | 2 +- 5 files changed, 20 insertions(+), 39 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index fba572e97..93d628fc6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -97,7 +97,7 @@ prune deployment prune mcp-servers prune agent_runtimes -# Exclude opa +# Exclude opa exclude plugins/external/opa/.dockerignore exclude plugins/external/opa/.env.template exclude plugins/external/opa/.ruff.toml diff --git a/plugins/external/opa/README.md b/plugins/external/opa/README.md index 044bc6d34..93e2f1c98 100644 --- a/plugins/external/opa/README.md +++ b/plugins/external/opa/README.md @@ -6,16 +6,16 @@ An OPA plugin that enforces rego policies on requests and allows/denies requests as per policies. The OPA plugin is composed of two components: -1. OPA server +1. OPA server 2. The pre hooks on tools that talks to OPA server running as background service within the same container. Whenever a tool is invoked, if OPA Plugin is in action, a policy will be applied on the tool call to allow/deny it. ### OPA Server -To define a policy file you need to go into opaserver/rego and create a sample policy file for you. +To define a policy file you need to go into opaserver/rego and create a sample policy file for you. Example -`example.rego` is present. -Once you have this file created in this location, when building the server, the opa binaries will be downloaded and a container will be build. +Once you have this file created in this location, when building the server, the opa binaries will be downloaded and a container will be build. In the `run_server.sh` file, the opa server will run as a background service in the container with the rego policy file. -### OPA Plugin +### OPA Plugin The OPA plugin runs as an external plugin with pre/post tool invocations. So everytime, a tool invocation is made, and if OPAPluginFilter has been defined in config.yaml file, the tool invocation will pass through this OPA Plugin. @@ -51,9 +51,9 @@ plugins: # Plugin config dict passed to the plugin constructor opa_base_url: "http://127.0.0.1:8181/v1/data/" ``` -The `applied_to` key in config.yaml, has been used to selectively apply policies and provide context for a specific tool. -Here, using this, you can provide the `name` of the tool you want to apply policy on, you can also provide -context to the tool with the prefix `global` if it needs to check the context in global context provided. +The `applied_to` key in config.yaml, has been used to selectively apply policies and provide context for a specific tool. +Here, using this, you can provide the `name` of the tool you want to apply policy on, you can also provide +context to the tool with the prefix `global` if it needs to check the context in global context provided. The key `opa_policy_context` is used to get context for policies and you can have multiple contexts within this key using `git_context` key. You can also provide policy within the `extensions` key where you can provide information to the plugin related to which policy to run and what endpoint to call for that policy. @@ -72,7 +72,7 @@ REPOSITORY TAG IMAGE ID CREATED SIZE mcpgateway/opapluginfilter latest a94428dd9c64 1 second ago 810MB ``` -* `make start`: This will start the OPA Plugin server +* `make start`: This will start the OPA Plugin server ```bash Verification point: ✅ Container started @@ -82,7 +82,7 @@ starting ## Testing with gateway -1. Add server fast-time that exposes git tools in the mcp gateway +1. Add server fast-time that exposes git tools in the mcp gateway ```bash curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -H "Content-Type: application/json" \ @@ -93,7 +93,7 @@ curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ 2. This adds server to the gateway and exposes all the tools for git. You would see `fast-time-git-status` as the tool appearing in the tools tab of mcp gateway. 3. The next step is to enable the opa plugin which you can do by adding `PLUGINS_ENABLED=true` and the following blob in `plugins/config.yaml` file. This will indicate that OPA Plugin is running as an external MCP server. - + ```yaml - name: "OPAPluginFilter" kind: "external" @@ -111,7 +111,7 @@ curl -s -X POST -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -d '{"name":"fast-time","url":"http://localhost:9000/sse"}' \ http://localhost:4444/gateways -# 2️⃣ Check if policies are in action. +# 2️⃣ Check if policies are in action. # Deny case curl -X POST -H "Content-Type: application/json" \ -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ @@ -121,8 +121,8 @@ curl -X POST -H "Content-Type: application/json" \ >>> `{"detail":"policy_deny"}` -# 3️⃣ Check if policies are in action -# Allow case +# 3️⃣ Check if policies are in action +# Allow case curl -X POST -H "Content-Type: application/json" \ -H "Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN" \ -d '{"jsonrpc":"2.0","id":1,"method":"fast-time-git-status","params":{"repo_path":"path/IBM"}}' \ @@ -139,20 +139,3 @@ Apache-2.0 ## Support For issues or questions, please open an issue in the MCP Gateway repository. - - - - - - - - - - - - - - - - - diff --git a/plugins/external/opa/tests/server/opa_server.py b/plugins/external/opa/tests/server/opa_server.py index 1d2258203..5f969a321 100644 --- a/plugins/external/opa/tests/server/opa_server.py +++ b/plugins/external/opa/tests/server/opa_server.py @@ -37,7 +37,7 @@ def do_POST(self): self.wfile.write(b'{"result": false}') # Process data dictionary... except json.JSONDecodeError: - # Handle invalid JSON + # Handle invalid JSON self.send_response(400) self.end_headers() self.wfile.write(b"Invalid JSON") diff --git a/plugins/external/opa/tests/test_opapluginfilter.py b/plugins/external/opa/tests/test_opapluginfilter.py index 1c9e93185..867ac3036 100644 --- a/plugins/external/opa/tests/test_opapluginfilter.py +++ b/plugins/external/opa/tests/test_opapluginfilter.py @@ -20,6 +20,7 @@ ToolPreInvokePayload, GlobalContext ) +from mcpgateway.plugins.framework.models import AppliedTo, ToolTemplate from tests.server.opa_server import run_mock_opa @@ -32,11 +33,10 @@ async def test_benign_opapluginfilter(): name="test", kind="opapluginfilter.OPAPluginFilter", hooks=["tool_pre_invoke"], - applied_to = {"tools" : [{"tool_name": "fast-time-git-status", "context": ["global.opa_policy_context.git_context"], "extensions": {"policy": "example", "policy_endpoint" : "allow"}}]}, config={"opa_base_url": "http://127.0.0.1:8181/v1/data/"} ) mock_server = run_mock_opa() - + plugin = OPAPluginFilter(config) @@ -56,7 +56,6 @@ async def test_malign_opapluginfilter(): name="test", kind="opapluginfilter.OPAPluginFilter", hooks=["tool_pre_invoke"], - applied_to = {"tools" : [{"tool_name": "fast-time-git-status", "context": ["global.opa_policy_context.git_context"], "extensions": {"policy": "example", "policy_endpoint" : "allow"}}]}, config={"opa_base_url": "http://127.0.0.1:8181/v1/data/"} ) mock_server = run_mock_opa() @@ -68,7 +67,7 @@ async def test_malign_opapluginfilter(): result = await plugin.tool_pre_invoke(payload, context) mock_server.shutdown() assert not result.continue_processing and result.violation.code == "deny" - + @pytest.mark.asyncio # Test for opa plugin not applied to any of the tools async def test_applied_to_opaplugin(): @@ -77,7 +76,6 @@ async def test_applied_to_opaplugin(): name="test", kind="opapluginfilter.OPAPluginFilter", hooks=["tool_pre_invoke"], - applied_to = {}, config={"opa_base_url": "http://127.0.0.1:8181/v1/data/"} ) mock_server = run_mock_opa() @@ -88,4 +86,4 @@ async def test_applied_to_opaplugin(): context = PluginContext(global_context=GlobalContext(request_id="1", server_id="2")) result = await plugin.tool_pre_invoke(payload, context) mock_server.shutdown() - assert result.continue_processing \ No newline at end of file + assert result.continue_processing diff --git a/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py b/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py index c070f66a8..b5e2a3965 100644 --- a/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py +++ b/tests/unit/mcpgateway/plugins/framework/test_manager_extended.py @@ -152,7 +152,7 @@ async def prompt_pre_fetch(self, payload, context): # Should continue in permissive mode assert result.continue_processing assert result.violation is None - + plugin_config.mode = PluginMode.ENFORCE_IGNORE_ERROR with patch.object(manager._registry, 'get_plugins_for_hook') as mock_get: plugin_ref = PluginRef(error_plugin)