Skip to content

Commit d3490e7

Browse files
Merge pull request #21 from shibbirmcc/#15-implement-kong-plugins-management-tools
#15 Implement Kong Plugins Management Tools #15
2 parents d63b694 + 7630204 commit d3490e7

File tree

7 files changed

+526
-6
lines changed

7 files changed

+526
-6
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,11 @@ docker run -p 9000:9000 -e FASTMCP_PORT=9000 kong-mcp-server
164164
- `kong_create_route`: Create new route
165165
- `kong_update_route`: Update existing route
166166
- `kong_delete_route`: Delete route
167-
167+
- **Kong Plugins**: Management and retrieval of Kong plugins with filtering and scoping support
168+
- `kong_get_plugins`: Retrieve all plugins with optional filtering and pagination
169+
- `kong_get_plugins_by_service`: Retrieve plugins scoped to a specific service
170+
- `kong_get_plugins_by_route`: Retrieve plugins scoped to a specific route
171+
- `kong_get_plugins_by_consumer`: Retrieve plugins scoped to a specific consumer
168172
### Adding New Tools
169173

170174
1. Create a new module in `src/kong_mcp_server/tools/`

src/kong_mcp_server/kong_client.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ async def delete_route(self, route_id: str) -> None:
338338
"""
339339
await self.delete(f"/routes/{route_id}")
340340

341-
async def get_plugins(self, **params: Any) -> List[Dict[str, Any]]:
341+
async def get_plugins_as_list(self, **params: Any) -> List[Dict[str, Any]]:
342342
"""Get all Kong plugins.
343343
344344
Args:
@@ -401,3 +401,56 @@ async def health_check(self) -> Dict[str, Any]:
401401
Kong status information
402402
"""
403403
return await self.get("/status")
404+
405+
async def get_plugins(
406+
self, params: Optional[Dict[str, Any]] = None
407+
) -> Dict[str, Any]:
408+
"""
409+
GET /plugins with optional query params.
410+
Returns the full Kong pagination envelope: {"data": [...], "offset": "..."}
411+
"""
412+
return await self._request(
413+
"GET",
414+
"/plugins",
415+
params=params,
416+
json_data=None,
417+
)
418+
419+
async def get_plugins_by_service(
420+
self, service_id: str, params: Optional[Dict[str, Any]] = None
421+
) -> Dict[str, Any]:
422+
"""
423+
GET /plugins by service id.
424+
Returns the full Kong pagination envelope: {"data": [...], "offset": "..."}
425+
"""
426+
return await self._request(
427+
"GET",
428+
f"/services/{service_id}/plugins",
429+
params=params,
430+
json_data=None,
431+
)
432+
433+
async def get_plugins_by_route(
434+
self, route_id: str, params: Optional[Dict[str, Any]] = None
435+
) -> Dict[str, Any]:
436+
"""
437+
GET /plugins by route id.
438+
Returns the full Kong pagination envelope: {"data": [...], "offset": "..."}
439+
"""
440+
return await self._request(
441+
"GET",
442+
f"/routes/{route_id}/plugins",
443+
params=params,
444+
json_data=None,
445+
)
446+
447+
async def get_plugins_by_consumer(
448+
self, consumer_id: str, params: Optional[Dict[str, Any]] = None
449+
) -> Dict[str, Any]:
450+
"""
451+
GET /plugins by consumer id.
452+
Returns the full Kong pagination envelope: {"data": [...], "offset": "..."}
453+
"""
454+
return await self._request(
455+
"GET", f"/consumers/{consumer_id}/plugins", params=params, json_data=None
456+
)
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Kong plugins management tools."""
2+
3+
from typing import Any, Dict, Optional
4+
5+
from kong_mcp_server.kong_client import KongClient
6+
7+
8+
async def get_plugins(
9+
name: Optional[str] = None, offset: Optional[str] = None, size: Optional[int] = None
10+
) -> Dict[str, Any]:
11+
"""Retrieve Kong plugins.
12+
13+
Args:
14+
name: Filter by plugin name (e.g., "rate-limiting", "rate-limiting-advanced")
15+
offset: Pagination cursor returned by Kong from a previous call
16+
size: Page size (Kong default is typically 100)
17+
18+
Returns:
19+
List of Kong plugins data.
20+
"""
21+
if size is not None:
22+
if size < 1 or size > 1000:
23+
raise ValueError("Size must be between 1 and 1000")
24+
25+
params: Dict[str, Any] = {}
26+
if name:
27+
params["name"] = name
28+
if offset:
29+
params["offset"] = offset
30+
if size:
31+
params["size"] = size
32+
33+
async with KongClient() as client:
34+
response = await client.get_plugins(params=params)
35+
next_offset = response.get("offset")
36+
if not next_offset and "next" in response:
37+
next_offset = response.get("next")
38+
39+
return {
40+
"data": response.get("data", []),
41+
"offset": next_offset,
42+
}
43+
44+
45+
async def get_plugins_by_service(
46+
service_id: str, size: Optional[int] = None, offset: Optional[str] = None
47+
) -> dict[str, Any]:
48+
"""
49+
Retrieve plugins associated with a specific Kong service.
50+
51+
Args:
52+
service_id: The ID (or name) of the Kong service.
53+
size: Optional number of plugins to return.
54+
offset: Optional pagination cursor.
55+
56+
Returns:
57+
A dictionary with:
58+
- data: List of plugin objects
59+
- next: URL for next page if pagination is present
60+
"""
61+
params: Dict[str, Any] = {}
62+
if size:
63+
params["size"] = size
64+
if offset:
65+
params["offset"] = offset
66+
67+
async with KongClient() as client:
68+
return await client.get_plugins_by_service(service_id=service_id, params=params)
69+
70+
71+
async def get_plugins_by_route(
72+
route_id: str, size: Optional[int] = None, offset: Optional[str] = None
73+
) -> dict[str, Any]:
74+
"""
75+
Retrieve plugins associated with a specific Kong route.
76+
77+
Args:
78+
route_id: The ID (or name) of the Kong route.
79+
size: Optional number of plugins to return.
80+
offset: Optional pagination cursor.
81+
82+
Returns:
83+
A dictionary with:
84+
- data: List of plugin objects
85+
- next: URL for next page if pagination is present
86+
"""
87+
params: Dict[str, Any] = {}
88+
if size:
89+
params["size"] = size
90+
if offset:
91+
params["offset"] = offset
92+
93+
async with KongClient() as client:
94+
return await client.get_plugins_by_route(route_id=route_id, params=params)
95+
96+
97+
async def get_plugins_by_consumer(
98+
consumer_id: str, size: Optional[int] = None, offset: Optional[str] = None
99+
) -> dict[str, Any]:
100+
"""
101+
Retrieve plugins associated with a specific Kong consumer.
102+
103+
Args:
104+
consumer_id: The ID (or name) of the Kong consumer.
105+
size: Optional number of plugins to return.
106+
offset: Optional pagination cursor.
107+
108+
Returns:
109+
A dictionary with:
110+
- data: List of plugin objects
111+
- next: URL for next page if pagination is present
112+
"""
113+
params: Dict[str, Any] = {}
114+
if size:
115+
params["size"] = size
116+
if offset:
117+
params["offset"] = offset
118+
119+
async with KongClient() as client:
120+
return await client.get_plugins_by_consumer(
121+
consumer_id=consumer_id, params=params
122+
)

src/kong_mcp_server/tools_config.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,34 @@
5555
"module": "kong_mcp_server.tools.kong_routes",
5656
"function": "delete_route",
5757
"enabled": true
58+
},
59+
"kong_get_plugins": {
60+
"name": "kong_get_plugins",
61+
"description": "Retrieve global Kong plugins with optional filtering by name and pagination.",
62+
"module": "kong_mcp_server.tools.kong_plugins",
63+
"function": "get_plugins",
64+
"enabled": true
65+
},
66+
"kong_get_plugins_by_service": {
67+
"name": "kong_get_plugins_by_service",
68+
"description": "Retrieve service scoped Kong plugins with optional filtering by name and pagination.",
69+
"module": "kong_mcp_server.tools.kong_plugins",
70+
"function": "get_plugins_by_service",
71+
"enabled": true
72+
},
73+
"kong_get_plugins_by_route": {
74+
"name": "kong_get_plugins_by_route",
75+
"description": "Retrieve route scoped Kong plugins with optional filtering by name and pagination.",
76+
"module": "kong_mcp_server.tools.kong_plugins",
77+
"function": "get_plugins_by_route",
78+
"enabled": true
79+
},
80+
"kong_get_plugins_by_consumer": {
81+
"name": "kong_get_plugins_by_consumer",
82+
"description": "Retrieve consumer scoped Kong plugins with optional filtering by name and pagination.",
83+
"module": "kong_mcp_server.tools.kong_plugins",
84+
"function": "get_plugins_by_consumer",
85+
"enabled": true
5886
}
5987
}
6088
}

tests/test_kong_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ async def test_get_plugins(self, config: KongClientConfig) -> None:
481481
mock_response = {"data": [{"id": "1", "name": "rate-limiting"}]}
482482

483483
with patch.object(client, "get", return_value=mock_response) as mock_get:
484-
result = await client.get_plugins()
484+
result = await client.get_plugins_as_list()
485485

486486
assert result == [{"id": "1", "name": "rate-limiting"}]
487487
mock_get.assert_called_once_with("/plugins", params={})

tests/test_kong_integration.py

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,51 @@ def mock_kong_responses(self) -> Dict[str, Any]:
5757
"plugins": {
5858
"data": [
5959
{
60-
"id": "plugin-1",
60+
"id": "plugin-global-1",
61+
"name": "cors",
62+
"config": {},
63+
"created_at": 1618846400,
64+
}
65+
],
66+
"offset": None,
67+
},
68+
"plugins_by_service": {
69+
"data": [
70+
{
71+
"id": "plugin-service-1",
6172
"name": "rate-limiting",
62-
"config": {"minute": 100, "hour": 1000},
6373
"service": {"id": "service-1"},
74+
"config": {"minute": 100},
6475
"created_at": 1618846400,
6576
}
66-
]
77+
],
78+
"offset": None,
79+
},
80+
"plugins_by_route": {
81+
"data": [
82+
{
83+
"id": "plugin-route-1",
84+
"name": "jwt",
85+
"route": {"id": "route-1"},
86+
"config": {"key": "value"},
87+
"created_at": 1618846400,
88+
}
89+
],
90+
"offset": None,
6791
},
92+
"plugins_by_consumer": {
93+
"data": [
94+
{
95+
"id": "plugin-consumer-1",
96+
"name": "key-auth",
97+
"consumer": {"id": "consumer-1"},
98+
"config": {},
99+
"created_at": 1618846400,
100+
}
101+
],
102+
"offset": None,
103+
},
104+
"consumers": {"data": [{"id": "consumer-1", "username": "test-consumer"}]},
68105
"status": {
69106
"database": {"reachable": True},
70107
"server": {"connections_accepted": 100},
@@ -213,6 +250,93 @@ async def test_kong_client_route_operations_integration(
213250
assert routes[0]["name"] == "test-route"
214251
mock_request.assert_called_with("GET", "/routes", params={"size": 10})
215252

253+
@pytest.mark.asyncio
254+
async def test_kong_client_plugins_operations_integration(
255+
self, mock_kong_responses: Dict[str, Any]
256+
) -> None:
257+
config = KongClientConfig(base_url="http://kong-admin:8001")
258+
async with KongClient(config) as client:
259+
with patch.object(client, "_request") as mock_request:
260+
mock_response = mock_kong_responses["plugins"]
261+
mock_request.return_value = mock_response
262+
263+
result = await client.get_plugins(params={"name": "jwt", "size": 10})
264+
265+
assert result["data"] == mock_response["data"]
266+
assert result["offset"] == mock_response.get(
267+
"offset"
268+
) or mock_response.get("next")
269+
# Validate call parameters
270+
mock_request.assert_called_with(
271+
"GET",
272+
"/plugins",
273+
params={"name": "jwt", "size": 10},
274+
json_data=None,
275+
)
276+
277+
async def test_get_plugins_by_service(self, mock_kong_responses: Dict[str, Any]):
278+
"""Test get_plugins_by_service tool."""
279+
config = KongClientConfig(base_url="http://kong-admin:8001")
280+
async with KongClient(config) as client:
281+
with patch.object(client, "_request") as mock_request:
282+
service_id = "service-1"
283+
mock_response = mock_kong_responses["plugins_by_service"]
284+
mock_request.return_value = mock_response
285+
286+
result = await client.get_plugins_by_service(
287+
service_id, params={"size": 5}
288+
)
289+
290+
assert result["data"] == mock_response["data"]
291+
mock_request.assert_called_with(
292+
"GET",
293+
f"/services/{service_id}/plugins",
294+
params={"size": 5},
295+
json_data=None,
296+
)
297+
298+
async def test_get_plugins_by_route(self, mock_kong_responses: Dict[str, Any]):
299+
"""Test get_plugins_by_route tool."""
300+
config = KongClientConfig(base_url="http://kong-admin:8001")
301+
async with KongClient(config) as client:
302+
with patch.object(client, "_request") as mock_request:
303+
route_id = "route-1"
304+
mock_response = mock_kong_responses["plugins_by_route"]
305+
mock_request.return_value = mock_response
306+
307+
result = await client.get_plugins_by_route(
308+
route_id, params={"offset": "cursor0"}
309+
)
310+
311+
assert result["data"] == mock_response["data"]
312+
mock_request.assert_called_with(
313+
"GET",
314+
f"/routes/{route_id}/plugins",
315+
params={"offset": "cursor0"},
316+
json_data=None,
317+
)
318+
319+
async def test_get_plugins_by_consumer(self, mock_kong_responses: Dict[str, Any]):
320+
"""Test get_plugins_by_consumer tool."""
321+
config = KongClientConfig(base_url="http://kong-admin:8001")
322+
async with KongClient(config) as client:
323+
with patch.object(client, "_request") as mock_request:
324+
consumer_id = "consumer-1"
325+
mock_response = mock_kong_responses["plugins_by_consumer"]
326+
mock_request.return_value = mock_response
327+
328+
result = await client.get_plugins_by_consumer(
329+
consumer_id, params={"size": 3}
330+
)
331+
332+
assert result["data"] == mock_response["data"]
333+
mock_request.assert_called_with(
334+
"GET",
335+
f"/consumers/{consumer_id}/plugins",
336+
params={"size": 3},
337+
json_data=None,
338+
)
339+
216340
@pytest.mark.asyncio
217341
async def test_kong_client_error_handling_integration(self) -> None:
218342
"""Test Kong client error handling in integration scenarios."""

0 commit comments

Comments
 (0)