Skip to content

Commit 03bbdc6

Browse files
authored
Merge pull request #4005 from IBM/3925-bug-pluginconditioncontent_types-field-is-defined-but-not-implemented
Fixes #3925: Implemented the `content_types` field functionality for `PluginCondition`
2 parents 3c9a702 + dd44313 commit 03bbdc6

11 files changed

Lines changed: 471 additions & 10 deletions

File tree

.secrets.baseline

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"files": "^.secrets.baseline$",
44
"lines": null
55
},
6-
"generated_at": "2026-04-02T08:08:58Z",
6+
"generated_at": "2026-04-07T13:00:53Z",
77
"plugins_used": [
88
{
99
"name": "AWSKeyDetector"
@@ -1462,15 +1462,15 @@
14621462
"hashed_secret": "38297d822c960daea26f148a2fede848dcc2083b",
14631463
"is_secret": false,
14641464
"is_verified": false,
1465-
"line_number": 619,
1465+
"line_number": 620,
14661466
"type": "Secret Keyword",
14671467
"verified_result": null
14681468
},
14691469
{
14701470
"hashed_secret": "7373eb3c8f036e7f058bfc66fc27870f01231645",
14711471
"is_secret": false,
14721472
"is_verified": false,
1473-
"line_number": 1261,
1473+
"line_number": 1349,
14741474
"type": "Secret Keyword",
14751475
"verified_result": null
14761476
}

docs/docs/architecture/plugins.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
# Plugin Framework Specification
23

34
**Version**: 1.0
@@ -1010,6 +1011,93 @@ class PluginCondition(BaseModel):
10101011
content_types: Optional[list[str]] = None # Execute for specific content types
10111012
```
10121013

1014+
#### Content Type Filtering
1015+
1016+
Plugins can be configured to execute only for specific content types using the `content_types` condition. This enables fine-grained control over when plugins process requests based on the HTTP `Content-Type` header.
1017+
1018+
**Configuration Example:**
1019+
1020+
```yaml
1021+
plugins:
1022+
- name: "JsonValidator"
1023+
kind: "plugins.json_validator.JsonValidator"
1024+
hooks: ["tool_pre_invoke"]
1025+
conditions:
1026+
- content_types: ["application/json"]
1027+
```
1028+
1029+
**Matching Behavior:**
1030+
1031+
- **Case-insensitive**: `APPLICATION/JSON` matches `application/json`
1032+
- **Parameter stripping**: `application/json; charset=utf-8` matches `application/json`
1033+
- **Multiple types**: Supports OR logic - plugin executes if any content type matches
1034+
- **Permissive default**: If `content_types` is not specified or request has no Content-Type header, plugin executes normally
1035+
1036+
**Common Content Types:**
1037+
1038+
| Content Type | Description | Use Case |
1039+
|--------------|-------------|----------|
1040+
| `application/json` | JSON data | API requests, structured data validation |
1041+
| `text/plain` | Plain text | Simple text processing, logging |
1042+
| `text/html` | HTML documents | Web scraping, content extraction |
1043+
| `application/xml` | XML data | Legacy API integration, SOAP services |
1044+
| `multipart/form-data` | File uploads | File validation, virus scanning |
1045+
| `application/x-www-form-urlencoded` | Form submissions | Form data validation |
1046+
1047+
**Example: JSON-Only Security Plugin**
1048+
1049+
```yaml
1050+
plugins:
1051+
- name: "JsonSecurityScanner"
1052+
kind: "plugins.security.json_scanner.JsonSecurityScanner"
1053+
hooks: ["tool_pre_invoke", "tool_post_invoke"]
1054+
mode: "enforce"
1055+
priority: 10
1056+
conditions:
1057+
- content_types: ["application/json"]
1058+
server_ids: ["production-api"]
1059+
```
1060+
1061+
**Example: Multi-Format Data Validator**
1062+
1063+
```yaml
1064+
plugins:
1065+
- name: "DataValidator"
1066+
kind: "plugins.validation.data_validator.DataValidator"
1067+
hooks: ["tool_pre_invoke"]
1068+
conditions:
1069+
- content_types:
1070+
- "application/json"
1071+
- "application/xml"
1072+
- "text/csv"
1073+
```
1074+
1075+
**Combined Conditions:**
1076+
1077+
Content type filtering works seamlessly with other conditions:
1078+
1079+
```yaml
1080+
plugins:
1081+
- name: "TeamJsonProcessor"
1082+
kind: "plugins.processors.json_processor.JsonProcessor"
1083+
hooks: ["tool_pre_invoke"]
1084+
conditions:
1085+
- content_types: ["application/json"]
1086+
tenant_ids: ["team-alpha", "team-beta"]
1087+
tools: ["data_analysis", "report_generation"]
1088+
```
1089+
1090+
**Security Considerations:**
1091+
1092+
!!! warning "Content-Type Spoofing"
1093+
Clients can set arbitrary Content-Type headers. Use `content_types` for filtering and optimization, not as a security boundary. Combine with other conditions (server_ids, tenant_ids) for defense-in-depth.
1094+
1095+
**Performance Benefits:**
1096+
1097+
- **Reduced overhead**: Skip expensive processing for irrelevant content types
1098+
- **Targeted validation**: Apply format-specific validators only when needed
1099+
- **Resource optimization**: Prevent unnecessary plugin execution
1100+
10131101
## Hook Reference Documentation
10141102

10151103
The plugin framework provides two main categories of hooks, each documented in detail in separate files:

docs/docs/using/plugins/index.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,8 +1167,42 @@ conditions:
11671167
server_ids: ["prod-server-1", "prod-server-2"]
11681168
tenant_ids: ["enterprise-tenant"]
11691169
user_patterns: ["admin-*", "support-*"]
1170+
content_types: ["application/json", "text/plain"]
11701171
```
11711172

1173+
**Content Type Filtering:**
1174+
1175+
Plugins can be configured to execute only for specific HTTP Content-Type headers:
1176+
1177+
```yaml
1178+
plugins:
1179+
- name: "JsonValidator"
1180+
kind: "plugins.validation.json_validator.JsonValidator"
1181+
hooks: ["tool_pre_invoke"]
1182+
mode: "enforce"
1183+
priority: 50
1184+
conditions:
1185+
- content_types: ["application/json"]
1186+
config:
1187+
strict_schema: true
1188+
1189+
- name: "MultiFormatProcessor"
1190+
kind: "plugins.processors.format_processor.FormatProcessor"
1191+
hooks: ["tool_pre_invoke", "tool_post_invoke"]
1192+
conditions:
1193+
- content_types:
1194+
- "application/json"
1195+
- "application/xml"
1196+
- "text/csv"
1197+
server_ids: ["data-api"]
1198+
```
1199+
1200+
**Key behaviors:**
1201+
- Case-insensitive matching (`APPLICATION/JSON` matches `application/json`)
1202+
- Ignores charset parameters (`application/json; charset=utf-8` matches `application/json`)
1203+
- If `content_types` is not specified, plugin executes for all content types
1204+
- If request has no Content-Type header, plugin executes normally (permissive default)
1205+
11721206
### 4. Logging and Monitoring
11731207

11741208
Use appropriate log levels:

mcpgateway/auth.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,11 +1188,14 @@ def _set_trace_for_user(user_obj: EmailUser, *, teams: Any = _UNSET, auth_method
11881188
# Get plugin contexts from request state if available
11891189
global_context = getattr(request.state, "plugin_global_context", None) if request else None
11901190
if not global_context:
1191+
# Extract content type from headers
1192+
content_type = headers.get("content-type") if headers else None
11911193
# Create global context
11921194
global_context = GlobalContext(
11931195
request_id=request_id,
11941196
server_id=None,
11951197
tenant_id=None,
1198+
content_type=content_type,
11961199
)
11971200

11981201
context_table = getattr(request.state, "plugin_context_table", None) if request else None
@@ -1726,11 +1729,14 @@ def _inject_userinfo_instate(request: Optional[object] = None, user: Optional[Em
17261729
# Get plugin contexts from request state if available
17271730
global_context = getattr(request.state, "plugin_global_context", None) if request else None
17281731
if not global_context:
1732+
# Extract content type from request headers
1733+
content_type = request.headers.get("content-type") if request and hasattr(request, "headers") else None
17291734
# Create global context
17301735
global_context = GlobalContext(
17311736
request_id=request_id,
17321737
server_id=None,
17331738
tenant_id=None,
1739+
content_type=content_type,
17341740
)
17351741

17361742
if user:

mcpgateway/middleware/http_auth_middleware.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ async def run_pre_request_hooks(
5757

5858
if global_context is None:
5959
request_id = get_correlation_id() or generate_correlation_id()
60-
global_context = GlobalContext(request_id=request_id, server_id=None, tenant_id=None)
60+
content_type = headers.get("content-type") if headers else None
61+
global_context = GlobalContext(request_id=request_id, server_id=None, tenant_id=None, content_type=content_type)
6162

6263
try:
6364
pre_result, context_table = await plugin_manager.invoke_hook(

mcpgateway/middleware/rbac.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import functools
1616
from functools import wraps
1717
import logging
18-
from typing import Callable, Generator, List, Optional
18+
from typing import Any, Callable, Generator, List, Optional
1919
import uuid
2020
import warnings
2121

@@ -598,7 +598,7 @@ def decorator(func: Callable) -> Callable:
598598
"""
599599

600600
@wraps(func)
601-
async def wrapper(*args, **kwargs):
601+
async def wrapper(*args, **kwargs: dict[str, Any]):
602602
"""Async wrapper function that performs permission check before calling original function.
603603
604604
Args:
@@ -659,10 +659,13 @@ async def wrapper(*args, **kwargs):
659659
global_context = plugin_global_context
660660
else:
661661
request_id = user_context.get("request_id") or uuid.uuid4().hex
662+
request: Optional[Request] = kwargs.get("request")
663+
content_type = request.headers.get("content-type") if request and hasattr(request, "headers") else None
662664
global_context = GlobalContext(
663665
request_id=request_id,
664666
server_id=None,
665667
tenant_id=None,
668+
content_type=content_type,
666669
)
667670

668671
# Invoke permission check hook, passing plugin contexts from HTTP_PRE_REQUEST hook

mcpgateway/plugins/framework/models.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import logging
1515
import os
1616
from pathlib import Path
17-
from typing import Any, Generic, Optional, Self, TypeVar, Union
17+
from typing import Any, Generic, Literal, Optional, Self, TypeVar, Union
1818

1919
# Third-Party
2020
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator, model_validator, PrivateAttr, ValidationInfo
@@ -222,12 +222,27 @@ def serialize_set(self, value: set[str] | None) -> list[str] | None:
222222
The set as a serializable list.
223223
"""
224224
if value:
225-
values = []
225+
values: list[Any] = []
226226
for key in value:
227227
values.append(key)
228228
return values
229229
return None
230230

231+
@field_validator("content_types")
232+
@classmethod
233+
def normalize_content_types(cls, value: str | None) -> list[str] | Literal[""] | None:
234+
"""Pre-normalize content types during initialization.
235+
236+
Args:
237+
value: str of content type.
238+
239+
Returns:
240+
Normalized content type.
241+
"""
242+
if value:
243+
return [each.split(sep=";")[0].strip().lower() for each in value]
244+
return value
245+
231246

232247
class AppliedTo(BaseModel):
233248
"""What tools/prompts/resources and fields the plugin will be applied to.
@@ -1382,6 +1397,7 @@ class GlobalContext(BaseModel):
13821397
user (str): user ID associated with the request.
13831398
tenant_id (str): tenant ID.
13841399
server_id (str): server ID.
1400+
content_type (Optional[str]): Content-Type header from the request.
13851401
metadata (Optional[dict[str,Any]]): a global shared metadata across plugins (Read-only from plugin's perspective).
13861402
state (Optional[dict[str,Any]]): a global shared state across plugins.
13871403
@@ -1401,15 +1417,44 @@ class GlobalContext(BaseModel):
14011417
'123'
14021418
>>> c.server_id
14031419
'srv1'
1420+
>>> ctx3 = GlobalContext(request_id="req-789", content_type="application/json")
1421+
>>> ctx3.content_type
1422+
'application/json'
1423+
>>> ctx4 = GlobalContext(request_id="req-999", content_type="application/json; charset=utf-8")
1424+
>>> ctx4.content_type
1425+
'application/json; charset=utf-8'
14041426
"""
14051427

14061428
request_id: str
14071429
user: Optional[Union[str, dict[str, Any]]] = None
14081430
tenant_id: Optional[str] = None
14091431
server_id: Optional[str] = None
1432+
content_type: Optional[str] = None
14101433
state: dict[str, Any] = Field(default_factory=dict)
14111434
metadata: dict[str, Any] = Field(default_factory=dict)
14121435

1436+
@field_validator("content_type")
1437+
@classmethod
1438+
def validate_content_type(cls, value: str | None) -> str | None:
1439+
"""Pre Validate content types during initialization.
1440+
1441+
Args:
1442+
value: str of content type.
1443+
1444+
Raises:
1445+
ValueError: if name is length > 200 or not a valid character.
1446+
1447+
Returns:
1448+
validated content type.
1449+
"""
1450+
if value is None:
1451+
return value
1452+
if len(value) > 200:
1453+
raise ValueError("Content-Type header too long")
1454+
if not value.isprintable():
1455+
raise ValueError("Content-Type contains invalid characters")
1456+
return value
1457+
14131458

14141459
class PluginContext(BaseModel):
14151460
"""The plugin's context, which lasts a request lifecycle.

mcpgateway/plugins/framework/utils.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,34 @@ def parse_class_name(name: str) -> tuple[str, str]:
184184
return ("", name)
185185

186186

187+
def normalize_content_type(content_type: str) -> str:
188+
"""Extract base content type without parameters.
189+
190+
Args:
191+
content_type: Raw content type string (e.g., 'application/json; charset=utf-8')
192+
193+
Returns:
194+
Normalized content type (e.g., 'application/json')
195+
196+
Examples:
197+
>>> normalize_content_type('application/json; charset=utf-8')
198+
'application/json'
199+
>>> normalize_content_type('text/html')
200+
'text/html'
201+
>>> normalize_content_type('TEXT/PLAIN')
202+
'text/plain'
203+
>>> normalize_content_type('application/json;charset=utf-8')
204+
'application/json'
205+
>>> normalize_content_type('')
206+
''
207+
>>> normalize_content_type(' ')
208+
''
209+
"""
210+
if not isinstance(content_type, str):
211+
return ""
212+
return content_type.split(";")[0].strip().lower()
213+
214+
187215
def matches(condition: PluginCondition, context: GlobalContext) -> bool:
188216
"""Check if conditions match the current context.
189217
@@ -207,6 +235,16 @@ def matches(condition: PluginCondition, context: GlobalContext) -> bool:
207235
>>> ctx3 = GlobalContext(request_id="req3", user="admin_user")
208236
>>> matches(cond2, ctx3)
209237
True
238+
>>> cond3 = PluginCondition(content_types=["application/json"])
239+
>>> ctx4 = GlobalContext(request_id="req4", content_type="application/json")
240+
>>> matches(cond3, ctx4)
241+
True
242+
>>> ctx5 = GlobalContext(request_id="req5", content_type="application/json; charset=utf-8")
243+
>>> matches(cond3, ctx5)
244+
True
245+
>>> ctx6 = GlobalContext(request_id="req6", content_type="text/plain")
246+
>>> matches(cond3, ctx6)
247+
False
210248
"""
211249
# Check server ID
212250
if condition.server_ids and context.server_id not in condition.server_ids:
@@ -220,6 +258,13 @@ def matches(condition: PluginCondition, context: GlobalContext) -> bool:
220258
if condition.user_patterns and context.user:
221259
if not any(pattern in context.user for pattern in condition.user_patterns):
222260
return False
261+
262+
# Check content types
263+
if condition.content_types and context.content_type:
264+
normalized_request = normalize_content_type(context.content_type)
265+
if normalized_request not in condition.content_types:
266+
return False
267+
223268
return True
224269

225270

0 commit comments

Comments
 (0)