Skip to content

Tool Plugin Bindings APIΒΆ

Per-tool, per-tenant plugin policy configuration for ContextForge.

OverviewΒΆ

The Tool Plugin Bindings API lets you configure which plugins run on a specific tool for a specific team β€” and override the plugin's global config.yaml settings with team/tool-specific parameters.

A binding is a (team_id, tool_name, plugin_id) triple with an associated mode, priority, and plugin-specific config. Bindings are resolved at tool-invoke time and merged on top of the global plugin configuration.

Supported pluginsΒΆ

plugin_id is the plugin class name as registered in config.yaml. Any plugin loaded by the gateway can be used; the table below lists the commonly bound ones.

plugin_id Hook phase What it does
OutputLengthGuardPlugin tool_post_invoke Truncates or blocks responses that exceed a character limit
RateLimiterPlugin tool_pre_invoke Throttles calls per user, tenant, or tool
SecretsDetection tool_post_invoke Detects and optionally redacts/blocks secrets in outputs
SQLSanitizer tool_pre_invoke Blocks dangerous SQL patterns; strips comments

AuthenticationΒΆ

All endpoints require a Bearer JWT token.

Authorization: Bearer <token>

Generate a token:

export TOKEN=$(python -m mcpgateway.utils.create_jwt_token \
  --username admin@example.com --exp 10080 --secret "$JWT_SECRET_KEY")

Required permissionsΒΆ

Operation Required permission
Create / update tools.manage_plugins
Read (list) tools.read
Delete tools.manage_plugins

Non-admin callers may only create bindings for teams they belong to. Attempting to configure bindings for another team returns 403.


EndpointsΒΆ

POST /v1/tools/plugin_bindingsΒΆ

Upsert one or more bindings. Each (team_id, tool_name, plugin_id) triple is:

  • Updated in place if a row already exists (the id, created_at, and created_by are preserved).
  • Inserted if no matching row exists.

Stale tool pruning: when a policy includes a binding_reference_id, any existing binding that shares the same binding_reference_id and plugin_id but whose tool_name is not in the incoming tool_names list is automatically deleted. This keeps the stored state in sync when an external system sends a full replacement tool list on an update event.

On success, returns all created/updated bindings and immediately invalidates the in-process plugin cache so the new config takes effect on the very next tool call.

Request bodyΒΆ

{
  "teams": {
    "<team_id>": {
      "policies": [
        {
          "tool_names": ["<tool_name_1>", "<tool_name_2>"],
          "plugin_id": "<PLUGIN_ID>",
          "mode": "enforce | permissive | disabled",
          "priority": 10,
          "binding_reference_id": "<EXTERNAL_BINDING_ID>",
          "config": { /* plugin-specific β€” see below */ }
        }
      ]
    }
  }
}
Field Type Required Default Notes
teams object βœ… β€” Keys are team_id strings
teams.<id>.policies array βœ… β€” At least one item required
policies[].tool_names string[] βœ… β€” Use ["*"] to match all tools in the team
policies[].plugin_id string βœ… β€” Plugin class name as registered in config.yaml (e.g. OutputLengthGuardPlugin, SQLSanitizer, RateLimiterPlugin, SecretsDetection)
policies[].mode enum string ❌ enforce enforce = fail on violation; permissive = log only; disabled = skip
policies[].priority int (1–1000) ❌ 50 Lower runs first
policies[].binding_reference_id string ❌ null External reference ID for correlating this binding with an upstream system. Used for stale-tool pruning on update and bulk delete. Max 255 chars; must match ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$.
policies[].config object βœ… β€” All config fields for the plugin must be present (full replace, no partial patch)

mode semanticsΒΆ

Value Behaviour
enforce Plugin runs; violations raise an error / block the response
permissive Plugin runs; violations are logged but the response is still returned
disabled Plugin is skipped entirely for this binding

Note: A binding with mode: "enforce" overrides a global mode: "disabled" in config.yaml. Use this to selectively enable a plugin for one team without enabling it globally.


GET /v1/tools/plugin_bindingsΒΆ

List all bindings across all teams (admin use).

Query param Type Required Description
binding_reference_id string ❌ Filter β€” return only bindings with this reference ID
# All bindings
curl -s -H "Authorization: Bearer $TOKEN" \
  http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings | jq

# Filtered by external reference ID
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings?binding_reference_id=<EXTERNAL_REFERENCE_ID>" | jq

GET /v1/tools/plugin_bindings/{team_id}ΒΆ

List all bindings for a specific team.

Query param Type Required Description
binding_reference_id string ❌ If provided, takes precedence over team_id β€” returns all bindings with this reference ID across all teams
# All bindings for a team
curl -s -H "Authorization: Bearer $TOKEN" \
  http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings/<YOUR_TEAM_ID> | jq

# Filtered by external reference ID (team_id is ignored when binding_reference_id is present)
curl -s -H "Authorization: Bearer $TOKEN" \
  "http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings/<YOUR_TEAM_ID>?binding_reference_id=<EXTERNAL_REFERENCE_ID>" | jq

DELETE /v1/tools/plugin_bindings?binding_reference_id={ref}ΒΆ

Delete all bindings tagged with the given external reference ID. Intended for external systems that need to remove all bindings associated with one of their own reference objects without knowing the internal ContextForge UUIDs.

Returns the deleted records. Returns an empty list (not an error) if no bindings matched.

curl -s -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  "http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings?binding_reference_id=<EXTERNAL_REFERENCE_ID>" | jq

DELETE /v1/tools/plugin_bindings/{binding_id}ΒΆ

Delete a single binding by its UUID. Returns the deleted record.

curl -s -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings/3f2504e0-4f89-11d3-9a0c-0305e82c3301 | jq

Response schemaΒΆ

All write operations (POST, DELETE) and read operations (GET) return the same shape.

ToolPluginBindingResponseΒΆ

{
  "id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
  "team_id": "<YOUR_TEAM_ID>",
  "tool_name": "echo_text",
  "plugin_id": "OutputLengthGuardPlugin",
  "mode": "enforce",
  "priority": 10,
  "config": {
    "min_chars": 0,
    "max_chars": 2000,
    "strategy": "truncate",
    "ellipsis": "..."
  },
  "binding_reference_id": "<EXTERNAL_REFERENCE_ID>",
  "created_at": "2026-04-07T17:00:00Z",
  "created_by": "admin@example.com",
  "updated_at": "2026-04-07T17:05:00Z",
  "updated_by": "admin@example.com"
}

ToolPluginBindingListResponseΒΆ

{
  "bindings": [ /* array of ToolPluginBindingResponse */ ],
  "total": 3
}

Plugin config payloadsΒΆ

The config object in a policy item must include all fields for the plugin. On upsert the config is fully replaced β€” fields you omit revert to the plugin's built-in defaults.


OutputLengthGuardPluginΒΆ

Enforces a character or token budget on tool outputs. Responses that exceed the limit are either truncated (with an optional ellipsis suffix) or blocked entirely.

Hooks: tool_post_invoke

{
  "min_chars": 0,
  "max_chars": 2000,
  "min_tokens": 0,
  "max_tokens": null,
  "chars_per_token": 4,
  "limit_mode": "character",
  "strategy": "truncate",
  "ellipsis": "\u2026",
  "word_boundary": false,
  "max_text_length": 1000000,
  "max_structure_size": 10000,
  "max_recursion_depth": 100
}
Field Type Default Constraints Description
min_chars integer 0 >= 0 Minimum allowed character count (0 = no minimum)
max_chars integer | null null β€” Maximum allowed character count; null or 0 disables the check
min_tokens integer 0 >= 0 Minimum allowed token count (0 = no minimum)
max_tokens integer | null null β€” Maximum allowed token count; null or 0 disables the check
chars_per_token integer 4 1–10 Characters-per-token ratio used to estimate token count
limit_mode "character" | "token" "character" β€” Which limit to enforce β€” character count or estimated token count
strategy "truncate" | "block" "truncate" β€” truncate = cut output at the limit; block = return an error
ellipsis string "…" max length 20 Suffix appended when truncating
word_boundary boolean false β€” Truncate at word boundaries to avoid mid-word cuts
max_text_length integer 1000000 >= 1 Maximum raw text size (bytes) to process; prevents memory exhaustion
max_structure_size integer 10000 >= 1 Maximum items in a list or dict; prevents DoS on large structured outputs
max_recursion_depth integer 100 >= 1 Maximum nesting depth; prevents stack overflow on deeply nested outputs

Validation: min_chars must be strictly less than max_chars when max_chars is set. Same applies to min_tokens / max_tokens.

Example β€” truncate at 500 charsΒΆ

curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "teams": {
      "<YOUR_TEAM_ID>": {
        "policies": [{
          "tool_names": ["echo_text"],
          "plugin_id": "OutputLengthGuardPlugin",
          "mode": "enforce",
          "priority": 10,
          "config": {
            "min_chars": 0,
            "max_chars": 500,
            "min_tokens": 0,
            "max_tokens": null,
            "chars_per_token": 4,
            "limit_mode": "character",
            "strategy": "truncate",
            "ellipsis": "\u2026",
            "word_boundary": false,
            "max_text_length": 1000000,
            "max_structure_size": 10000,
            "max_recursion_depth": 100
          }
        }]
      }
    }
  }' \
  http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings | jq

Example β€” block responses over 1000 charsΒΆ

{
  "min_chars": 0,
  "max_chars": 1000,
  "min_tokens": 0,
  "max_tokens": null,
  "chars_per_token": 4,
  "limit_mode": "character",
  "strategy": "block",
  "ellipsis": "\u2026",
  "word_boundary": false,
  "max_text_length": 1000000,
  "max_structure_size": 10000,
  "max_recursion_depth": 100
}

RateLimiterPluginΒΆ

Throttles tool invocations before they are dispatched. Limits can be set independently for the calling user, the tenant (team), or per individual tool.

Hooks: tool_pre_invoke

Rate strings use the format <count>/<period> where period is s (second) or m (minute).

{
  "by_user":   "60/m",
  "by_tenant": "600/m",
  "by_tool":   { "fast-time-get-system-time": "10/m", "fast-time-convert-time": "5/s" },
  "algorithm": "fixed_window",
  "backend":   "redis",
  "fail_mode": "open"
}
Field Type Default Format / Constraints Description
by_user string | null null <int>/s or <int>/m Rate limit per calling user; null disables
by_tenant string | null null <int>/s or <int>/m Rate limit per tenant (team); null disables
by_tool object (string β†’ string) | null null values: <int>/s or <int>/m Per-tool rate limits as a map of tool_name β†’ rate; null disables
algorithm "fixed_window" | "sliding_window" | "token_bucket" "fixed_window" β€” Counting algorithm to use
backend "memory" | "redis" "memory" β€” Storage backend for counters; "redis" shares state across replicas, "memory" is local to each worker
fail_mode "open" | "closed" "open" β€” Behaviour when the "redis" backend is unreachable: "open" falls back to in-process memory counters (availability-first); "closed" blocks requests until Redis recovers (enforcement-first); ignored when backend is "memory"

When backend is "redis", the Redis connection URL is configured via redis_url in plugins/config.yaml β€” it is not a binding payload field. Gateway operators set it once at deployment time; binding-API users should not override it per-tenant.

Validation: Each non-null rate string must match ^\d+/[sm]$.

Example β€” 30 calls/min per user, 300/min per tenantΒΆ

curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "teams": {
      "<YOUR_TEAM_ID>": {
        "policies": [{
          "tool_names": ["*"],
          "plugin_id": "RateLimiterPlugin",
          "mode": "enforce",
          "priority": 5,
          "config": {
            "by_user":   "30/m",
            "by_tenant": "300/m",
            "by_tool":   null,
            "algorithm": "fixed_window",
            "backend":   "redis",
            "fail_mode": "open"
          }
        }]
      }
    }
  }' \
  http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings | jq

Using ["*"] as tool_names applies the rate limit to every tool in the team.


SecretsDetectionΒΆ

Scans tool outputs for common secret patterns (AWS keys, GCP API keys, Slack tokens, private keys, JWTs, hex secrets, etc.). Can redact findings or block the response.

Hooks: tool_post_invoke

{
  "enabled": {
    "aws_access_key_id":     true,
    "aws_secret_access_key": true,
    "google_api_key":        true,
    "slack_token":           true,
    "private_key_block":     true,
    "jwt_like":              true,
    "hex_secret_32":         true,
    "base64_24":             false
  },
  "redact":              true,
  "redaction_text":      "[REDACTED]",
  "block_on_detection":  false,
  "min_findings_to_block": 1
}
Field Type Default Constraints Description
enabled object see below β€” Map of pattern name β†’ boolean; controls which detectors are active
redact boolean true β€” Replace detected secrets with redaction_text
redaction_text string "[REDACTED]" max length 50 Replacement text for redacted secrets
block_on_detection boolean false β€” Return an error response instead of (possibly redacted) output
min_findings_to_block integer 1 >= 1 Number of findings required before blocking triggers

Pattern names for enabled map:

Pattern key What it matches
aws_access_key_id AWS access keys (AKIA…)
aws_secret_access_key AWS secret keys (40-char alphanumeric)
google_api_key GCP API keys (AIza…)
slack_token Slack bot / user tokens (xox[bprs]-…)
private_key_block PEM private key blocks
jwt_like JWT-shaped tokens (three base64url segments)
hex_secret_32 32-character hexadecimal strings
base64_24 24+ char base64 strings (high false-positive rate β€” keep false unless needed)

Example β€” redact AWS keys and JWTs, block on any findingΒΆ

curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "teams": {
      "<YOUR_TEAM_ID>": {
        "policies": [{
          "tool_names": ["fetch_data", "query_db"],
          "plugin_id": "SecretsDetection",
          "mode": "enforce",
          "priority": 20,
          "config": {
            "enabled": {
              "aws_access_key_id":     true,
              "aws_secret_access_key": true,
              "google_api_key":        false,
              "slack_token":           false,
              "private_key_block":     true,
              "jwt_like":              true,
              "hex_secret_32":         false,
              "base64_24":             false
            },
            "redact":              true,
            "redaction_text":      "[REDACTED]",
            "block_on_detection":  true,
            "min_findings_to_block": 1
          }
        }]
      }
    }
  }' \
  http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings | jq

SQLSanitizerΒΆ

Inspects tool arguments for dangerous SQL patterns before the tool is invoked. Blocks statements such as DROP, TRUNCATE, ALTER, GRANT, and REVOKE, optionally blocks DELETE/UPDATE without a WHERE clause, and can strip SQL comments from the input.

Hooks: tool_pre_invoke

{
  "fields": ["sql", "query", "statement"],
  "blocked_statements": ["\\bDROP\\b", "\\bTRUNCATE\\b", "\\bALTER\\b", "\\bGRANT\\b", "\\bREVOKE\\b"],
  "block_delete_without_where": true,
  "block_update_without_where": true,
  "strip_comments": true,
  "require_parameterization": false,
  "block_on_violation": true
}
Field Type Default Description
fields string[] β€” Argument key names to inspect (e.g. sql, query, statement)
blocked_statements string[] β€” Regex patterns matched case-insensitively; any match blocks the call
block_delete_without_where boolean true Block DELETE statements that have no WHERE clause
block_update_without_where boolean true Block UPDATE statements that have no WHERE clause
strip_comments boolean true Remove -- line comments and /* */ block comments before passing the SQL on
require_parameterization boolean false Block any statement that contains literal string or numeric values instead of ?/$N placeholders
block_on_violation boolean true Return an error on violation; if false, violations are logged but the call proceeds

Example β€” protect a database query toolΒΆ

curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "teams": {
      "<YOUR_TEAM_ID>": {
        "policies": [{
          "tool_names": ["query_db"],
          "plugin_id": "SQLSanitizer",
          "mode": "enforce",
          "priority": 45,
          "binding_reference_id": "query-db-sql-sanitizer-v1",
          "config": {
            "fields": ["sql", "query", "statement"],
            "blocked_statements": ["\\bDROP\\b", "\\bTRUNCATE\\b", "\\bALTER\\b", "\\bGRANT\\b", "\\bREVOKE\\b"],
            "block_delete_without_where": true,
            "block_update_without_where": true,
            "strip_comments": true,
            "require_parameterization": false,
            "block_on_violation": true
          }
        }]
      }
    }
  }' \
  http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings | jq

Multi-plugin, multi-team exampleΒΆ

A single POST can configure multiple plugins across multiple teams:

curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "teams": {
      "team_id": {
        "policies": [
          {
            "tool_names": ["*"],
            "plugin_id": "RateLimiterPlugin",
            "mode": "enforce",
            "priority": 5,
            "config": {
              "by_user": "60/m",
              "by_tenant": "600/m",
              "by_tool": null,
              "algorithm": "fixed_window",
              "backend": "redis",
              "fail_mode": "open"
            }
          },
          {
            "tool_names": ["summarize_doc", "extract_invoice"],
            "plugin_id": "OutputLengthGuardPlugin",
            "mode": "enforce",
            "priority": 10,
            "config": {
              "min_chars": 0,
              "max_chars": 4000,
              "min_tokens": 0,
              "max_tokens": null,
              "chars_per_token": 4,
              "limit_mode": "character",
              "strategy": "truncate",
              "ellipsis": "\u2026",
              "word_boundary": false,
              "max_text_length": 1000000,
              "max_structure_size": 10000,
              "max_recursion_depth": 100
            }
          }
        ]
      },
      "team_beta": {
        "policies": [
          {
            "tool_names": ["*"],
            "plugin_id": "SecretsDetection",
            "mode": "enforce",
            "priority": 20,
            "config": {
              "enabled": {
                "aws_access_key_id":     true,
                "aws_secret_access_key": true,
                "google_api_key":        true,
                "slack_token":           true,
                "private_key_block":     true,
                "jwt_like":              true,
                "hex_secret_32":         true,
                "base64_24":             false
              },
              "redact":              true,
              "redaction_text":      "[REDACTED]",
              "block_on_detection":  false,
              "min_findings_to_block": 1
            }
          }
        ]
      }
    }
  }' \
  http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings | jq

Error responsesΒΆ

HTTP status When
400 Invalid request payload (missing fields, bad config values)
401 Missing or invalid Bearer token
403 Caller lacks tools.manage_plugins or configuring bindings for a team they don't belong to
404 Binding ID not found (DELETE /{binding_id} only)

Example 400 β€” bad OutputLengthGuardPlugin configΒΆ

{
  "detail": "Invalid OutputLengthGuardPlugin config: [min_chars must be less than max_chars]"
}

Example 400 β€” invalid rate stringΒΆ

{
  "detail": "Invalid RateLimiterPlugin config: [by_user: Rate string '5/h' is invalid. Use format '<count>/s' or '<count>/m']"
}

How bindings are resolved at runtimeΒΆ

  1. When a tool is invoked, tool_service.py computes a context ID: "{team_id}::{tool_name}".
  2. GatewayTenantPluginManagerFactory.get_config_from_db() fetches all DB bindings for the (team_id, tool_name) pair, including any wildcard * bindings.
  3. For each binding, the DB mode and config are merged over the global config.yaml values (_merge_tenant_config). DB values always win.
  4. A TenantPluginManager is instantiated with the merged config and cached in memory, keyed by context ID.
  5. On upsert or delete, the handling worker invalidates its local cache entry and broadcasts a binding_change frame on the plugin:invalidation Redis pub/sub channel, so peer workers evict within milliseconds. If pub/sub delivery fails, the cache TTL (default 30 seconds) bounds the worst-case drift. For wildcard bindings (tool_name="*"), every cached context for the team is evicted on the handling worker.

Priority execution orderΒΆ

Plugins with lower priority values run first. The default is 50. Example ordering:

priority 5  β†’ RateLimiterPlugin        (gate-keep before any work is done)
priority 10 β†’ OutputLengthGuardPlugin
priority 20 β†’ SecretsDetection