Skip to content

Commit c04f65e

Browse files
feat: add binding_reference_id to tool plugin bindings and expand plugin config schemas (#4143)
* feat: add binding_reference_id to tool plugin bindings Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com> * feat: expand plugin config schemas and fix tests for binding-reference-id Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com> * fix: suppress detect-secrets false positives and update test configs for expanded schemas Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com> * fix: address PR review — add missing tests, input validation, remove WXO refs, fix list_bindings exclusivity Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com> * fix: remove trailing blank lines (end-of-file-fixer) Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com> * fix: warn when both team_id and binding_reference_id supplied to list_bindings Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com> * fix: remove wxo references from db docstring and test binding IDs Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com> * fix: add idempotency guards to alembic migration and pattern validation to binding_reference_id Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com> * fix(plugins): require all plugin config fields to be explicitly set Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com> * fix(schemas): restore defaults on plugin config classes Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com> --------- Signed-off-by: Madhu Mohan Jaishankar <madhu.mohan.jaishankar@ibm.com>
1 parent 70c2158 commit c04f65e

File tree

9 files changed

+1217
-111
lines changed

9 files changed

+1217
-111
lines changed

docs/docs/api/plugin-bindings-api.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ Non-admin callers may only create bindings for teams they belong to. Attempting
5454
- **Updated in place** if a row already exists (the `id`, `created_at`, and `created_by` are preserved).
5555
- **Inserted** if no matching row exists.
5656

57+
**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.
58+
5759
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.
5860

5961
#### Request body
@@ -68,6 +70,7 @@ On success, returns **all created/updated** bindings and immediately invalidates
6870
"plugin_id": "<PLUGIN_ID>",
6971
"mode": "enforce | permissive | disabled",
7072
"priority": 10,
73+
"binding_reference_id": "<EXTERNAL_BINDING_ID>",
7174
"config": { /* plugin-specific — see below */ }
7275
}
7376
]
@@ -84,6 +87,7 @@ On success, returns **all created/updated** bindings and immediately invalidates
8487
| `policies[].plugin_id` | enum string ||| `OUTPUT_LENGTH_GUARD`, `RATE_LIMITER`, or `SECRETS_DETECTION` |
8588
| `policies[].mode` | enum string || `enforce` | `enforce` = fail on violation; `permissive` = log only; `disabled` = skip |
8689
| `policies[].priority` | int (1–1000) || `50` | Lower runs first |
90+
| `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. |
8791
| `policies[].config` | object ||| All config fields for the plugin must be present (full replace, no partial patch) |
8892

8993
#### `mode` semantics
@@ -102,9 +106,18 @@ On success, returns **all created/updated** bindings and immediately invalidates
102106

103107
List all bindings across all teams (admin use).
104108

109+
| Query param | Type | Required | Description |
110+
|------------------------|--------|----------|------------------------------------------------------|
111+
| `binding_reference_id` | string || Filter — return only bindings with this reference ID |
112+
105113
```bash
114+
# All bindings
106115
curl -s -H "Authorization: Bearer $TOKEN" \
107116
http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings | jq
117+
118+
# Filtered by external reference ID
119+
curl -s -H "Authorization: Bearer $TOKEN" \
120+
"http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings?binding_reference_id=<EXTERNAL_REFERENCE_ID>" | jq
108121
```
109122

110123
---
@@ -113,14 +126,32 @@ curl -s -H "Authorization: Bearer $TOKEN" \
113126

114127
List all bindings for a specific team.
115128

129+
| Query param | Type | Required | Description |
130+
|------------------------|--------|----------|------------------------------------------------------------------------------------------------------|
131+
| `binding_reference_id` | string || If provided, **takes precedence over `team_id`** — returns all bindings with this reference ID across all teams |
132+
116133
```bash
134+
# All bindings for a team
117135
curl -s -H "Authorization: Bearer $TOKEN" \
118136
http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings/<YOUR_TEAM_ID> | jq
137+
138+
# Filtered by external reference ID (team_id is ignored when binding_reference_id is present)
139+
curl -s -H "Authorization: Bearer $TOKEN" \
140+
"http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings/<YOUR_TEAM_ID>?binding_reference_id=<EXTERNAL_REFERENCE_ID>" | jq
119141
```
120142

121143
---
122144

123-
### `DELETE /v1/tools/plugin_bindings/{binding_id}`
145+
### `DELETE /v1/tools/plugin_bindings?binding_reference_id={ref}`
146+
147+
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.
148+
149+
Returns the deleted records. Returns an empty list (not an error) if no bindings matched.
150+
151+
```bash
152+
curl -s -X DELETE \
153+
-H "Authorization: Bearer $TOKEN" \
154+
"http://<GATEWAY_HOST>:<GATEWAY_PORT>/v1/tools/plugin_bindings?binding_reference_id=<EXTERNAL_REFERENCE_ID>" | jq
124155

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

@@ -152,6 +183,7 @@ All write operations (`POST`, `DELETE`) and read operations (`GET`) return the s
152183
"strategy": "truncate",
153184
"ellipsis": "..."
154185
},
186+
"binding_reference_id": "<EXTERNAL_REFERENCE_ID>",
155187
"created_at": "2026-04-07T17:00:00Z",
156188
"created_by": "admin@example.com",
157189
"updated_at": "2026-04-07T17:05:00Z",
@@ -456,7 +488,7 @@ curl -s -X POST \
456488
| `400` | Invalid request payload (missing fields, bad config values) |
457489
| `401` | Missing or invalid Bearer token |
458490
| `403` | Caller lacks `tools.manage_plugins` or configuring bindings for a team they don't belong to |
459-
| `404` | Binding ID not found (DELETE only) |
491+
| `404` | Binding ID not found (DELETE `/{binding_id}` only) |
460492

461493
### Example 400 — bad `OUTPUT_LENGTH_GUARD` config
462494

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# -*- coding: utf-8 -*-
2+
"""Add binding_reference_id to tool_plugin_bindings.
3+
4+
Revision ID: d3e4f5a6b7c8
5+
Revises: c2d3e4f5a6b7
6+
Create Date: 2026-04-10
7+
"""
8+
9+
# Standard
10+
from typing import Sequence, Union
11+
12+
# Third-Party
13+
import sqlalchemy as sa
14+
from alembic import op
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "d3e4f5a6b7c8" # pragma: allowlist secret
18+
down_revision: Union[str, Sequence[str], None] = "c2d3e4f5a6b7" # pragma: allowlist secret
19+
branch_labels: Union[str, Sequence[str], None] = None
20+
depends_on: Union[str, Sequence[str], None] = None
21+
22+
23+
def upgrade() -> None:
24+
"""Add nullable binding_reference_id column and index to tool_plugin_bindings."""
25+
bind = op.get_bind()
26+
inspector = sa.inspect(bind)
27+
28+
# Skip if table doesn't exist (fresh DB uses db.py models directly)
29+
if "tool_plugin_bindings" not in inspector.get_table_names():
30+
return
31+
32+
# Skip if column already exists (idempotent re-run guard)
33+
columns = [col["name"] for col in inspector.get_columns("tool_plugin_bindings")]
34+
if "binding_reference_id" not in columns:
35+
op.add_column(
36+
"tool_plugin_bindings",
37+
sa.Column("binding_reference_id", sa.String(255), nullable=True),
38+
)
39+
40+
# Add index only if it doesn't already exist
41+
indexes = [idx["name"] for idx in inspector.get_indexes("tool_plugin_bindings")]
42+
if "ix_tool_plugin_bindings_binding_reference_id" not in indexes:
43+
op.create_index(
44+
"ix_tool_plugin_bindings_binding_reference_id",
45+
"tool_plugin_bindings",
46+
["binding_reference_id"],
47+
)
48+
49+
50+
def downgrade() -> None:
51+
"""Remove binding_reference_id column and index from tool_plugin_bindings."""
52+
bind = op.get_bind()
53+
inspector = sa.inspect(bind)
54+
55+
# Skip if table doesn't exist
56+
if "tool_plugin_bindings" not in inspector.get_table_names():
57+
return
58+
59+
# Drop index if it exists
60+
indexes = [idx["name"] for idx in inspector.get_indexes("tool_plugin_bindings")]
61+
if "ix_tool_plugin_bindings_binding_reference_id" in indexes:
62+
op.drop_index(
63+
"ix_tool_plugin_bindings_binding_reference_id",
64+
table_name="tool_plugin_bindings",
65+
)
66+
67+
# Drop column if it exists
68+
columns = [col["name"] for col in inspector.get_columns("tool_plugin_bindings")]
69+
if "binding_reference_id" in columns:
70+
op.drop_column("tool_plugin_bindings", "binding_reference_id")

mcpgateway/db.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6578,6 +6578,7 @@ class ToolPluginBinding(Base):
65786578
mode (str): ``"enforce"`` | ``"permissive"`` | ``"disabled"``.
65796579
priority (int): Execution priority — lower numbers run first.
65806580
config (dict): Plugin-specific JSON configuration blob.
6581+
binding_reference_id (str): Optional external reference ID for bulk delete and stale-tool pruning.
65816582
created_at (datetime): Row creation timestamp (UTC).
65826583
created_by (str): Email of the user who created the binding.
65836584
updated_at (datetime): Last update timestamp (UTC).
@@ -6608,6 +6609,7 @@ class ToolPluginBinding(Base):
66086609
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="enforce")
66096610
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=50)
66106611
config: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
6612+
binding_reference_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
66116613
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, nullable=False)
66126614
created_by: Mapped[str] = mapped_column(String(255), nullable=False)
66136615
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now, nullable=False)
@@ -6620,6 +6622,7 @@ class ToolPluginBinding(Base):
66206622
UniqueConstraint("team_id", "tool_name", "plugin_id", name="uq_tool_plugin_binding"),
66216623
Index("ix_tool_plugin_bindings_team_id", "team_id"),
66226624
Index("ix_tool_plugin_bindings_tool_name", "tool_name"),
6625+
Index("ix_tool_plugin_bindings_binding_reference_id", "binding_reference_id"),
66236626
)
66246627

66256628
def __repr__(self) -> str:

mcpgateway/routers/tool_plugin_bindings.py

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,18 @@
88
Provides endpoints for configuring per-tool per-tenant plugin policies.
99
1010
Endpoints:
11-
POST /v1/tools/plugin_bindings — Create or update bindings (upsert)
12-
GET /v1/tools/plugin_bindings — List all bindings
13-
GET /v1/tools/plugin_bindings/{team_id} — List bindings for a specific team
14-
DELETE /v1/tools/plugin_bindings/{id} — Delete a binding by ID
11+
POST /v1/tools/plugin_bindings — Create or update bindings (upsert)
12+
GET /v1/tools/plugin_bindings — List all bindings
13+
GET /v1/tools/plugin_bindings/{team_id} — List bindings for a specific team
14+
DELETE /v1/tools/plugin_bindings?binding_reference_id={ref} — Delete all bindings by external reference ID
15+
DELETE /v1/tools/plugin_bindings/{id} — Delete a binding by UUID
1516
"""
1617

1718
# Standard
18-
from typing import Any, Dict
19+
from typing import Any, Dict, List, Optional
1920

2021
# Third-Party
21-
from fastapi import APIRouter, Depends, HTTPException, status
22+
from fastapi import APIRouter, Depends, HTTPException, Query, status
2223
from sqlalchemy.orm import Session
2324

2425
# First-Party
@@ -107,12 +108,14 @@ async def upsert_tool_plugin_bindings(
107108
@router.get("/", response_model=ToolPluginBindingListResponse)
108109
@require_permission("tools.read")
109110
async def list_tool_plugin_bindings(
111+
binding_reference_id: Optional[str] = None,
110112
current_user_ctx: Dict[str, Any] = Depends(get_current_user_with_permissions),
111113
db: Session = Depends(get_db),
112114
) -> ToolPluginBindingListResponse:
113115
"""List all tool plugin bindings across all teams.
114116
115117
Args:
118+
binding_reference_id: Optional filter — return only bindings with this reference ID.
116119
current_user_ctx: Authenticated user context.
117120
db: Database session.
118121
@@ -124,7 +127,7 @@ async def list_tool_plugin_bindings(
124127
>>> asyncio.iscoroutinefunction(list_tool_plugin_bindings)
125128
True
126129
"""
127-
bindings = _service.list_bindings(db, team_id=None)
130+
bindings = _service.list_bindings(db, team_id=None, binding_reference_id=binding_reference_id)
128131
return ToolPluginBindingListResponse(bindings=bindings, total=len(bindings))
129132

130133

@@ -137,13 +140,15 @@ async def list_tool_plugin_bindings(
137140
@require_permission("tools.read")
138141
async def list_tool_plugin_bindings_for_team(
139142
team_id: str,
143+
binding_reference_id: Optional[str] = None,
140144
current_user_ctx: Dict[str, Any] = Depends(get_current_user_with_permissions),
141145
db: Session = Depends(get_db),
142146
) -> ToolPluginBindingListResponse:
143147
"""List all tool plugin bindings for a specific team.
144148
145149
Args:
146150
team_id: Team identifier to filter by.
151+
binding_reference_id: Optional filter — return only bindings with this reference ID.
147152
current_user_ctx: Authenticated user context.
148153
db: Database session.
149154
@@ -155,10 +160,51 @@ async def list_tool_plugin_bindings_for_team(
155160
>>> asyncio.iscoroutinefunction(list_tool_plugin_bindings_for_team)
156161
True
157162
"""
158-
bindings = _service.list_bindings(db, team_id=team_id)
163+
bindings = _service.list_bindings(db, team_id=team_id, binding_reference_id=binding_reference_id)
159164
return ToolPluginBindingListResponse(bindings=bindings, total=len(bindings))
160165

161166

167+
# ---------------------------------------------------------------------------
168+
# DELETE / — remove all bindings by external reference ID
169+
# ---------------------------------------------------------------------------
170+
171+
172+
@router.delete("/", response_model=ToolPluginBindingListResponse, status_code=status.HTTP_200_OK)
173+
@require_permission("tools.manage_plugins")
174+
async def delete_tool_plugin_bindings_by_reference(
175+
binding_reference_id: str = Query(..., min_length=1, description="External reference ID whose bindings to delete"),
176+
current_user_ctx: Dict[str, Any] = Depends(get_current_user_with_permissions),
177+
db: Session = Depends(get_db),
178+
) -> ToolPluginBindingListResponse:
179+
"""Delete all bindings associated with an external reference ID.
180+
181+
Intended for use by external systems that need to remove all ContextForge
182+
bindings tied to one of their own reference objects without knowing the
183+
internal ContextForge UUIDs.
184+
185+
Returns the deleted records (empty list if none matched — not an error).
186+
187+
Args:
188+
binding_reference_id: The external reference ID whose bindings to delete.
189+
current_user_ctx: Authenticated user context.
190+
db: Database session.
191+
192+
Returns:
193+
ToolPluginBindingListResponse: All deleted binding records.
194+
195+
Examples:
196+
>>> import asyncio
197+
>>> asyncio.iscoroutinefunction(delete_tool_plugin_bindings_by_reference)
198+
True
199+
"""
200+
deleted: List[ToolPluginBindingResponse] = _service.delete_bindings_by_reference(db, binding_reference_id)
201+
db.commit()
202+
# Invalidate cache for every affected (team_id, tool_name) pair.
203+
for ctx_id in {make_context_id(b.team_id, b.tool_name) for b in deleted}:
204+
await reload_plugin_context(ctx_id)
205+
return ToolPluginBindingListResponse(bindings=deleted, total=len(deleted))
206+
207+
162208
# ---------------------------------------------------------------------------
163209
# DELETE /{id} — remove a binding by its UUID
164210
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)