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.
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, andcreated_byare 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 globalmode: "disabled"inconfig.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ΒΆ
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
backendis"redis", the Redis connection URL is configured viaredis_urlinplugins/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
["*"]astool_namesapplies 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ΒΆ
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ΒΆ
- When a tool is invoked,
tool_service.pycomputes a context ID:"{team_id}::{tool_name}". GatewayTenantPluginManagerFactory.get_config_from_db()fetches all DB bindings for the(team_id, tool_name)pair, including any wildcard*bindings.- For each binding, the DB
modeandconfigare merged over the globalconfig.yamlvalues (_merge_tenant_config). DB values always win. - A
TenantPluginManageris instantiated with the merged config and cached in memory, keyed by context ID. - On upsert or delete, the handling worker invalidates its local cache entry and broadcasts a
binding_changeframe on theplugin:invalidationRedis 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: