Skip to content

Commit ca7a25b

Browse files
feat: add tools execute JSON-schema prompting and plugin UX fixes
Signed-off-by: Matthew Grigsby <38010437+MatthewGrigsby@users.noreply.github.com>
1 parent 1dd3e12 commit ca7a25b

File tree

8 files changed

+1007
-33
lines changed

8 files changed

+1007
-33
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ Here are some examples:
8383
cforge tools list [--mcp-server-id ID] [--json]
8484
cforge tools get <tool-id>
8585
cforge tools create [file.json]
86+
cforge tools execute <tool-id> # Interactive schema prompt
87+
cforge tools execute <tool-id> --data args.json # Use JSON args file
8688
cforge tools toggle <tool-id>
8789

8890
# Resources

cforge/commands/resources/plugins.py

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@
1616
"""
1717

1818
# Standard
19-
from enum import Enum
2019
from typing import Any, Dict, Optional
2120

2221
# Third-Party
2322
import typer
2423

2524
# First-Party
2625
from cforge.common import (
26+
CaseInsensitiveEnum,
2727
AuthenticationError,
2828
CLIError,
2929
get_console,
@@ -34,49 +34,47 @@
3434
)
3535

3636

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):
37+
class PluginMode(CaseInsensitiveEnum):
5838
"""Valid plugin mode filters supported by the gateway admin API."""
5939

6040
ENFORCE = "enforce"
6141
PERMISSIVE = "permissive"
6242
DISABLED = "disabled"
6343

6444

65-
def _handle_plugins_exception(exception: Exception) -> None:
45+
def _parse_plugin_mode(mode: Optional[str]) -> Optional[PluginMode]:
46+
"""Parse plugin mode with case-insensitive enum matching."""
47+
if mode is None:
48+
return None
49+
try:
50+
return PluginMode(mode)
51+
except ValueError as exc:
52+
choices = ", ".join(member.value for member in PluginMode)
53+
raise CLIError(f"Invalid value for '--mode': {mode!r}. Must be one of: {choices}.") from exc
54+
55+
56+
def _handle_plugins_exception(exception: Exception, operation: str, plugin_name: Optional[str] = None) -> None:
6657
"""Provide plugin-specific hints and raise a CLI error."""
6758
console = get_console()
6859

6960
if isinstance(exception, AuthenticationError):
7061
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]")
62+
elif isinstance(exception, CLIError):
63+
error_str = str(exception)
64+
if "(404)" in error_str:
65+
error_str_folded = error_str.casefold()
66+
if operation == "get" and "plugin" in error_str_folded and "not found" in error_str_folded:
67+
plugin_label = plugin_name or "requested plugin"
68+
console.print(f"[yellow]Plugin not found: {plugin_label}[/yellow]")
69+
else:
70+
console.print("[yellow]Admin plugin API unavailable. Ensure MCPGATEWAY_ADMIN_API_ENABLED=true and gateway version supports /admin/plugins.[/yellow]")
7371

7472
handle_exception(exception)
7573

7674

7775
def plugins_list(
7876
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"),
77+
mode: Optional[str] = typer.Option(None, "--mode", help="Filter by mode"),
8078
hook: Optional[str] = typer.Option(None, "--hook", help="Filter by hook type"),
8179
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by plugin tag"),
8280
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
@@ -88,8 +86,9 @@ def plugins_list(
8886
params: Dict[str, Any] = {}
8987
if search:
9088
params["search"] = search
91-
if mode:
92-
params["mode"] = mode.value
89+
parsed_mode = _parse_plugin_mode(mode)
90+
if parsed_mode:
91+
params["mode"] = parsed_mode.value
9392
if hook:
9493
params["hook"] = hook
9594
if tag:
@@ -108,7 +107,7 @@ def plugins_list(
108107
console.print("[yellow]No plugins found[/yellow]")
109108

110109
except Exception as e:
111-
_handle_plugins_exception(e)
110+
_handle_plugins_exception(e, operation="list")
112111

113112

114113
def plugins_get(
@@ -120,7 +119,7 @@ def plugins_get(
120119
print_json(result, f"Plugin {name}")
121120

122121
except Exception as e:
123-
_handle_plugins_exception(e)
122+
_handle_plugins_exception(e, operation="get", plugin_name=name)
124123

125124

126125
def plugins_stats() -> None:
@@ -130,4 +129,4 @@ def plugins_stats() -> None:
130129
print_json(result, "Plugin Statistics")
131130

132131
except Exception as e:
133-
_handle_plugins_exception(e)
132+
_handle_plugins_exception(e, operation="stats")

cforge/commands/resources/tools.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@
1717

1818
# First-Party
1919
from cforge.common import (
20+
CLIError,
2021
get_console,
2122
handle_exception,
2223
make_authenticated_request,
2324
print_json,
2425
print_table,
26+
prompt_for_json_schema,
2527
prompt_for_schema,
2628
)
2729
from mcpgateway.schemas import ToolCreate, ToolUpdate
@@ -179,3 +181,71 @@ def tools_toggle(
179181

180182
except Exception as e:
181183
handle_exception(e)
184+
185+
186+
def tools_execute(
187+
tool_id: str = typer.Argument(..., help="Tool ID"),
188+
data_file: Optional[Path] = typer.Option(None, "--data", help="JSON file containing tool arguments"),
189+
) -> None:
190+
"""Execute a tool by ID using optional dynamic schema prompting."""
191+
console = get_console()
192+
193+
try:
194+
tool_result = make_authenticated_request("GET", f"/tools/{tool_id}")
195+
assert isinstance(tool_result, dict)
196+
197+
tool_name = tool_result.get("name")
198+
if not isinstance(tool_name, str) or not tool_name:
199+
raise CLIError(f"Tool '{tool_id}' does not have a valid name")
200+
201+
raw_schema = tool_result.get("inputSchema")
202+
if raw_schema is None:
203+
raw_schema = tool_result.get("input_schema")
204+
if raw_schema is None:
205+
raw_schema = {"type": "object", "properties": {}}
206+
207+
if isinstance(raw_schema, str):
208+
input_schema = json.loads(raw_schema)
209+
elif isinstance(raw_schema, dict):
210+
input_schema = raw_schema
211+
else:
212+
raise CLIError("Tool input schema must be a JSON object")
213+
214+
if not isinstance(input_schema, dict):
215+
raise CLIError("Tool input schema must be a JSON object")
216+
if not input_schema:
217+
input_schema = {"type": "object", "properties": {}}
218+
219+
data: Dict[str, Any] = {}
220+
if data_file:
221+
if not data_file.exists():
222+
console.print(f"[red]File not found: {data_file}[/red]")
223+
raise typer.Exit(1)
224+
file_data = json.loads(data_file.read_text())
225+
if not isinstance(file_data, dict):
226+
raise CLIError("Data file must contain a JSON object")
227+
data = prompt_for_json_schema(input_schema, prefilled=file_data, prompt_optional=False)
228+
else:
229+
data = prompt_for_json_schema(input_schema)
230+
231+
rpc_payload: Dict[str, Any] = {"jsonrpc": "2.0", "id": f"cforge-tools-{tool_id}", "method": "tools/call", "params": {"name": tool_name, "arguments": data}}
232+
rpc_result = make_authenticated_request("POST", "/rpc", json_data=rpc_payload)
233+
234+
if isinstance(rpc_result, dict) and "error" in rpc_result:
235+
error = rpc_result["error"]
236+
if isinstance(error, dict):
237+
err_message = error.get("message", "Unknown error")
238+
err_code = error.get("code")
239+
if err_code is not None:
240+
raise CLIError(f"Tool execution failed ({err_code}): {err_message}")
241+
raise CLIError(f"Tool execution failed: {err_message}")
242+
raise CLIError(f"Tool execution failed: {error}")
243+
244+
console.print("[green]✓ Tool executed successfully![/green]")
245+
if isinstance(rpc_result, dict) and "result" in rpc_result:
246+
print_json(rpc_result["result"], "Tool Result")
247+
else:
248+
print_json(rpc_result, "Tool Result")
249+
250+
except Exception as e:
251+
handle_exception(e)

0 commit comments

Comments
 (0)