Skip to content

Commit 7200468

Browse files
authored
Merge pull request #18 from MatthewGrigsby/feature/plugin-support
feat: add read-only plugins commands (list/get/stats)
2 parents 4889daf + 96bd60d commit 7200468

File tree

4 files changed

+312
-0
lines changed

4 files changed

+312
-0
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,17 @@ cforge prompts execute <prompt-id>
9696
# MCP Servers
9797
cforge mcp-servers list
9898
cforge mcp-servers update <mcp-server-id> [file.json]
99+
100+
# Plugins (read-only admin API)
101+
cforge plugins list [--search text] [--mode MODE] [--hook HOOK] [--tag TAG] [--json]
102+
cforge plugins get <plugin-name>
103+
cforge plugins stats
99104
```
100105

106+
Plugin commands call `/admin/plugins` endpoints and require:
107+
- `MCPGATEWAY_ADMIN_API_ENABLED=true` on the gateway
108+
- A token with `admin.plugins` permission
109+
101110
### Server Operations
102111

103112
```bash
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# -*- coding: utf-8 -*-
2+
"""Location: ./cforge/commands/resources/plugins.py
3+
Copyright 2025
4+
SPDX-License-Identifier: Apache-2.0
5+
Authors: Matthew Grigsby
6+
7+
CLI command group: plugins
8+
9+
Note:
10+
The CLI currently exposes read-only operations (list/get/stats) for plugins.
11+
This matches the current capabilities of the gateway admin API: plugin
12+
configuration is loaded from a YAML file at gateway startup, and the gateway
13+
does not yet provide write endpoints for plugin CRUD/management. When
14+
mcp-context-forge adds server-side write operations, this CLI can be extended
15+
to support them.
16+
"""
17+
18+
# Standard
19+
from enum import Enum
20+
from typing import Any, Dict, Optional
21+
22+
# Third-Party
23+
import typer
24+
25+
# First-Party
26+
from cforge.common import (
27+
AuthenticationError,
28+
CLIError,
29+
get_console,
30+
handle_exception,
31+
make_authenticated_request,
32+
print_json,
33+
print_table,
34+
)
35+
36+
37+
class _CaseInsensitiveEnum(str, Enum):
38+
"""Enum that supports case-insensitive parsing for CLI options."""
39+
40+
@classmethod
41+
def _missing_(cls, value: object) -> Optional["_CaseInsensitiveEnum"]:
42+
"""Resolve unknown values by matching enum values case-insensitively.
43+
44+
Typer converts CLI strings into Enum members. Implementing `_missing_`
45+
allows `--mode EnFoRcE` to resolve to `PluginMode.ENFORCE`, while still
46+
rejecting unknown values.
47+
"""
48+
if not isinstance(value, str):
49+
return None
50+
value_folded = value.casefold()
51+
for member in cls:
52+
if member.value.casefold() == value_folded:
53+
return member
54+
return None
55+
56+
57+
class PluginMode(_CaseInsensitiveEnum):
58+
"""Valid plugin mode filters supported by the gateway admin API."""
59+
60+
ENFORCE = "enforce"
61+
PERMISSIVE = "permissive"
62+
DISABLED = "disabled"
63+
64+
65+
def _handle_plugins_exception(exception: Exception) -> None:
66+
"""Provide plugin-specific hints and raise a CLI error."""
67+
console = get_console()
68+
69+
if isinstance(exception, AuthenticationError):
70+
console.print("[yellow]Access denied. Requires admin.plugins permission.[/yellow]")
71+
elif isinstance(exception, CLIError) and "(404)" in str(exception):
72+
console.print("[yellow]Admin plugin API unavailable. Ensure MCPGATEWAY_ADMIN_API_ENABLED=true and gateway version supports /admin/plugins.[/yellow]")
73+
74+
handle_exception(exception)
75+
76+
77+
def plugins_list(
78+
search: Optional[str] = typer.Option(None, "--search", help="Search by plugin name, description, or author"),
79+
mode: Optional[PluginMode] = typer.Option(None, "--mode", help="Filter by mode"),
80+
hook: Optional[str] = typer.Option(None, "--hook", help="Filter by hook type"),
81+
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by plugin tag"),
82+
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
83+
) -> None:
84+
"""List all plugins with optional filtering."""
85+
console = get_console()
86+
87+
try:
88+
params: Dict[str, Any] = {}
89+
if search:
90+
params["search"] = search
91+
if mode:
92+
params["mode"] = mode.value
93+
if hook:
94+
params["hook"] = hook
95+
if tag:
96+
params["tag"] = tag
97+
98+
result = make_authenticated_request("GET", "/admin/plugins", params=params if params else None)
99+
100+
if json_output:
101+
print_json(result, "Plugins")
102+
else:
103+
plugins: list[dict[str, Any]] = result["plugins"]
104+
105+
if plugins:
106+
print_table(plugins, "Plugins", ["name", "version", "author", "mode", "status", "priority", "hooks", "tags"])
107+
else:
108+
console.print("[yellow]No plugins found[/yellow]")
109+
110+
except Exception as e:
111+
_handle_plugins_exception(e)
112+
113+
114+
def plugins_get(
115+
name: str = typer.Argument(..., help="Plugin name"),
116+
) -> None:
117+
"""Get details for a specific plugin."""
118+
try:
119+
result = make_authenticated_request("GET", f"/admin/plugins/{name}")
120+
print_json(result, f"Plugin {name}")
121+
122+
except Exception as e:
123+
_handle_plugins_exception(e)
124+
125+
126+
def plugins_stats() -> None:
127+
"""Get plugin statistics."""
128+
try:
129+
result = make_authenticated_request("GET", "/admin/plugins/stats")
130+
print_json(result, "Plugin Statistics")
131+
132+
except Exception as e:
133+
_handle_plugins_exception(e)

cforge/main.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@
9797
a2a_toggle,
9898
a2a_invoke,
9999
)
100+
from cforge.commands.resources.plugins import (
101+
plugins_get,
102+
plugins_list,
103+
plugins_stats,
104+
)
100105

101106
# Get the main app singleton
102107
app = get_app()
@@ -232,6 +237,17 @@
232237
a2a_app.command("toggle")(a2a_toggle)
233238
a2a_app.command("invoke")(a2a_invoke)
234239

240+
# ---------------------------------------------------------------------------
241+
# Plugins command group
242+
# ---------------------------------------------------------------------------
243+
244+
plugins_app = typer.Typer(help="Manage gateway plugins (read-only)")
245+
app.add_typer(plugins_app, name="plugins", rich_help_panel="Resources")
246+
247+
plugins_app.command("list")(plugins_list)
248+
plugins_app.command("get")(plugins_get)
249+
plugins_app.command("stats")(plugins_stats)
250+
235251
# ---------------------------------------------------------------------------
236252
# Metrics command group
237253
# ---------------------------------------------------------------------------
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# -*- coding: utf-8 -*-
2+
"""Location: ./tests/commands/resources/test_plugins.py
3+
Copyright 2025
4+
SPDX-License-Identifier: Apache-2.0
5+
Authors: Matthew Grigsby
6+
7+
Tests for the plugins commands.
8+
"""
9+
10+
# Third-Party
11+
import pytest
12+
import typer
13+
14+
# First-Party
15+
from cforge.commands.resources.plugins import PluginMode, plugins_get, plugins_list, plugins_stats
16+
from cforge.common import AuthenticationError, CLIError
17+
from tests.conftest import invoke_typer_command, patch_functions
18+
19+
20+
class TestPluginCommands:
21+
"""Tests for plugins commands."""
22+
23+
def test_plugin_mode_enum_is_case_insensitive(self) -> None:
24+
"""Typer Enum choices should accept case-insensitive values."""
25+
assert PluginMode("EnFoRcE") == PluginMode.ENFORCE
26+
27+
def test_plugin_mode_enum_missing_non_string(self) -> None:
28+
"""Non-string values should not be coerced into Enum members."""
29+
assert PluginMode._missing_(123) is None
30+
31+
def test_plugin_mode_enum_missing_unknown_value(self) -> None:
32+
"""Unknown strings should not be coerced into Enum members."""
33+
assert PluginMode._missing_("nope") is None
34+
35+
def test_plugins_list_success(self, mock_console) -> None:
36+
"""Test plugins list command with table output."""
37+
mock_response = {
38+
"plugins": [
39+
{"name": "pii_filter", "version": "1.0.0", "author": "ContextForge", "mode": "enforce", "status": "enabled", "priority": 10, "hooks": ["tool_pre_invoke"], "tags": ["security"]}
40+
],
41+
"total": 1,
42+
"enabled_count": 1,
43+
"disabled_count": 0,
44+
}
45+
46+
with patch_functions(
47+
"cforge.commands.resources.plugins",
48+
get_console=mock_console,
49+
make_authenticated_request={"return_value": mock_response},
50+
print_table=None,
51+
) as mocks:
52+
invoke_typer_command(plugins_list)
53+
mocks.print_table.assert_called_once()
54+
55+
def test_plugins_list_json_output(self, mock_console) -> None:
56+
"""Test plugins list with JSON output."""
57+
mock_response = {"plugins": [], "total": 0, "enabled_count": 0, "disabled_count": 0}
58+
with patch_functions(
59+
"cforge.commands.resources.plugins",
60+
get_console=mock_console,
61+
make_authenticated_request={"return_value": mock_response},
62+
print_json=None,
63+
) as mocks:
64+
invoke_typer_command(plugins_list, json_output=True)
65+
mocks.print_json.assert_called_once()
66+
67+
def test_plugins_list_no_results(self, mock_console) -> None:
68+
"""Test plugins list with no results."""
69+
mock_response = {"plugins": [], "total": 0, "enabled_count": 0, "disabled_count": 0}
70+
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"return_value": mock_response}):
71+
invoke_typer_command(plugins_list)
72+
73+
assert any("No plugins found" in str(call) for call in mock_console.print.call_args_list)
74+
75+
def test_plugins_list_with_filters(self, mock_console) -> None:
76+
"""Test plugins list with all filters."""
77+
with patch_functions(
78+
"cforge.commands.resources.plugins",
79+
get_console=mock_console,
80+
make_authenticated_request={"return_value": {"plugins": [], "total": 0, "enabled_count": 0, "disabled_count": 0}},
81+
print_table=None,
82+
) as mocks:
83+
invoke_typer_command(plugins_list, search="pii", mode=PluginMode.ENFORCE, hook="tool_pre_invoke", tag="security")
84+
85+
call_args = mocks.make_authenticated_request.call_args
86+
assert call_args[0][0] == "GET"
87+
assert call_args[0][1] == "/admin/plugins"
88+
assert call_args[1]["params"] == {"search": "pii", "mode": "enforce", "hook": "tool_pre_invoke", "tag": "security"}
89+
90+
def test_plugins_list_error(self, mock_console) -> None:
91+
"""Test plugins list error handling."""
92+
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"side_effect": Exception("API error")}):
93+
with pytest.raises(typer.Exit):
94+
invoke_typer_command(plugins_list)
95+
96+
def test_plugins_get_success(self, mock_console) -> None:
97+
"""Test plugins get command."""
98+
mock_plugin = {"name": "pii_filter", "version": "1.0.0"}
99+
with patch_functions(
100+
"cforge.commands.resources.plugins",
101+
get_console=mock_console,
102+
make_authenticated_request={"return_value": mock_plugin},
103+
print_json=None,
104+
) as mocks:
105+
invoke_typer_command(plugins_get, name="pii_filter")
106+
mocks.print_json.assert_called_once()
107+
108+
def test_plugins_get_error(self, mock_console) -> None:
109+
"""Test plugins get error handling."""
110+
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"side_effect": Exception("API error")}):
111+
with pytest.raises(typer.Exit):
112+
invoke_typer_command(plugins_get, name="pii_filter")
113+
114+
def test_plugins_stats_success(self, mock_console) -> None:
115+
"""Test plugins stats command."""
116+
mock_stats = {"total_plugins": 4, "enabled_plugins": 3, "disabled_plugins": 1, "plugins_by_hook": {"tool_pre_invoke": 3}, "plugins_by_mode": {"enforce": 3, "disabled": 1}}
117+
with patch_functions(
118+
"cforge.commands.resources.plugins",
119+
get_console=mock_console,
120+
make_authenticated_request={"return_value": mock_stats},
121+
print_json=None,
122+
) as mocks:
123+
invoke_typer_command(plugins_stats)
124+
mocks.print_json.assert_called_once()
125+
126+
def test_plugins_stats_error(self, mock_console) -> None:
127+
"""Test plugins stats error handling."""
128+
with patch_functions("cforge.commands.resources.plugins", get_console=mock_console, make_authenticated_request={"side_effect": Exception("API error")}):
129+
with pytest.raises(typer.Exit):
130+
invoke_typer_command(plugins_stats)
131+
132+
def test_plugins_list_forbidden_shows_permission_hint(self, mock_console) -> None:
133+
"""Test plugins list shows a targeted hint on forbidden/admin failures."""
134+
with patch_functions(
135+
"cforge.commands.resources.plugins",
136+
get_console=mock_console,
137+
make_authenticated_request={"side_effect": AuthenticationError("Authentication required but not configured")},
138+
):
139+
with pytest.raises(typer.Exit):
140+
invoke_typer_command(plugins_list)
141+
142+
assert any("Requires admin.plugins permission" in str(call) for call in mock_console.print.call_args_list)
143+
144+
def test_plugins_list_not_found_shows_admin_api_hint(self, mock_console) -> None:
145+
"""Test plugins list shows an admin-api hint on 404."""
146+
with patch_functions(
147+
"cforge.commands.resources.plugins",
148+
get_console=mock_console,
149+
make_authenticated_request={"side_effect": CLIError("API request failed (404): Not Found")},
150+
):
151+
with pytest.raises(typer.Exit):
152+
invoke_typer_command(plugins_list)
153+
154+
assert any("Admin plugin API unavailable" in str(call) for call in mock_console.print.call_args_list)

0 commit comments

Comments
 (0)