mcpheroctl 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcpheroctl-0.1.0/PKG-INFO +12 -0
- mcpheroctl-0.1.0/pyproject.toml +22 -0
- mcpheroctl-0.1.0/src/mcpheroctl/__init__.py +3 -0
- mcpheroctl-0.1.0/src/mcpheroctl/cli.py +66 -0
- mcpheroctl-0.1.0/src/mcpheroctl/commands/__init__.py +0 -0
- mcpheroctl-0.1.0/src/mcpheroctl/commands/auth.py +108 -0
- mcpheroctl-0.1.0/src/mcpheroctl/commands/server.py +182 -0
- mcpheroctl-0.1.0/src/mcpheroctl/commands/wizard.py +521 -0
- mcpheroctl-0.1.0/src/mcpheroctl/core/__init__.py +0 -0
- mcpheroctl-0.1.0/src/mcpheroctl/core/client.py +199 -0
- mcpheroctl-0.1.0/src/mcpheroctl/core/config.py +59 -0
- mcpheroctl-0.1.0/src/mcpheroctl/core/output.py +103 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mcpheroctl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool for interacting with the MCPHero platform
|
|
5
|
+
Author: Andrew
|
|
6
|
+
Author-email: Andrew <contact@arteriali.st>
|
|
7
|
+
Requires-Dist: typer>=0.15.0
|
|
8
|
+
Requires-Dist: httpx>=0.28.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0.0
|
|
10
|
+
Requires-Dist: tenacity>=9.0.0
|
|
11
|
+
Requires-Dist: rich>=13.0.0
|
|
12
|
+
Requires-Python: >=3.12
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcpheroctl"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CLI tool for interacting with the MCPHero platform"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Andrew", email = "contact@arteriali.st" }
|
|
7
|
+
]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"typer>=0.15.0",
|
|
11
|
+
"httpx>=0.28.0",
|
|
12
|
+
"pydantic>=2.0.0",
|
|
13
|
+
"tenacity>=9.0.0",
|
|
14
|
+
"rich>=13.0.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
mcpheroctl = "mcpheroctl.cli:app"
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.10.3,<0.11.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""mcpheroctl – CLI tool for interacting with the MCPHero platform.
|
|
2
|
+
|
|
3
|
+
Designed for both human operators and AI agent workflows.
|
|
4
|
+
Follows the agent-first CLI guidelines: structured JSON output,
|
|
5
|
+
meaningful exit codes, noun-verb grammar, and actionable errors.
|
|
6
|
+
|
|
7
|
+
Exit codes:
|
|
8
|
+
0 success
|
|
9
|
+
1 general failure
|
|
10
|
+
2 usage error (bad arguments)
|
|
11
|
+
3 resource not found
|
|
12
|
+
4 permission denied / not authenticated
|
|
13
|
+
5 conflict (resource already exists)
|
|
14
|
+
6 not implemented
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from mcpheroctl import __version__
|
|
22
|
+
from mcpheroctl.commands.auth import auth_app
|
|
23
|
+
from mcpheroctl.commands.server import server_app
|
|
24
|
+
from mcpheroctl.commands.wizard import wizard_app
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="mcpheroctl",
|
|
28
|
+
help="CLI tool for interacting with the MCPHero platform.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
rich_markup_mode=None,
|
|
31
|
+
add_completion=True,
|
|
32
|
+
pretty_exceptions_enable=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Register sub-apps (noun-verb pattern)
|
|
36
|
+
app.add_typer(auth_app, name="auth")
|
|
37
|
+
app.add_typer(server_app, name="server")
|
|
38
|
+
app.add_typer(wizard_app, name="wizard")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def version_callback(value: bool) -> None:
|
|
42
|
+
if value:
|
|
43
|
+
typer.echo(f"mcpheroctl {__version__}")
|
|
44
|
+
raise typer.Exit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.callback()
|
|
48
|
+
def main(
|
|
49
|
+
_: bool = typer.Option( # pyright: ignore[reportCallInDefaultInitializer]
|
|
50
|
+
False,
|
|
51
|
+
"--version",
|
|
52
|
+
"-v",
|
|
53
|
+
help="Show version and exit.",
|
|
54
|
+
callback=version_callback,
|
|
55
|
+
is_eager=True,
|
|
56
|
+
),
|
|
57
|
+
) -> None:
|
|
58
|
+
"""MCPHero CLI – manage MCP servers and run the creation wizard.
|
|
59
|
+
|
|
60
|
+
Authenticate first with `mcpheroctl auth login --token <TOKEN>`, then
|
|
61
|
+
use `mcpheroctl server` and `mcpheroctl wizard` to interact with the
|
|
62
|
+
platform.
|
|
63
|
+
|
|
64
|
+
Use --json on any command for structured JSON output (stdout).
|
|
65
|
+
Human-readable messages go to stderr.
|
|
66
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Auth commands for mcpheroctl."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from mcpheroctl.core.config import Config, load_config, save_config
|
|
10
|
+
from mcpheroctl.core.output import die, info, print_result, success
|
|
11
|
+
|
|
12
|
+
auth_app = typer.Typer(
|
|
13
|
+
name="auth",
|
|
14
|
+
help="Manage API authentication.",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
rich_markup_mode=None,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@auth_app.command("login")
|
|
21
|
+
def login(
|
|
22
|
+
token: Annotated[
|
|
23
|
+
str, typer.Option("--token", "-t", help="Organization API token.", prompt=False)
|
|
24
|
+
],
|
|
25
|
+
base_url: Annotated[
|
|
26
|
+
str | None, typer.Option("--base-url", help="Override the API base URL.")
|
|
27
|
+
] = None,
|
|
28
|
+
output_json: Annotated[
|
|
29
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
30
|
+
] = False,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Authenticate with the MCPHero API using an organization API token.
|
|
33
|
+
|
|
34
|
+
Saves the token to ~/.config/mcpheroctl/config.json for future use.
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
mcpheroctl auth login --token myorg_sk_abc123
|
|
38
|
+
mcpheroctl auth login --token myorg_sk_abc123 --base-url https://custom.api.com/api
|
|
39
|
+
"""
|
|
40
|
+
config: Config = load_config()
|
|
41
|
+
config.api_token = token
|
|
42
|
+
if base_url is not None:
|
|
43
|
+
config.base_url = base_url
|
|
44
|
+
save_config(config)
|
|
45
|
+
if output_json:
|
|
46
|
+
print_result(
|
|
47
|
+
{"status": "authenticated", "base_url": config.base_url}, use_json=True
|
|
48
|
+
)
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
success(f"Token saved. API: {config.base_url}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@auth_app.command("status")
|
|
55
|
+
def status(
|
|
56
|
+
output_json: Annotated[
|
|
57
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
58
|
+
] = False,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Check current authentication status.
|
|
61
|
+
|
|
62
|
+
Examples:
|
|
63
|
+
mcpheroctl auth status
|
|
64
|
+
mcpheroctl auth status --json
|
|
65
|
+
"""
|
|
66
|
+
config: Config = load_config()
|
|
67
|
+
is_authenticated: bool = config.api_token is not None
|
|
68
|
+
if output_json:
|
|
69
|
+
print_result(
|
|
70
|
+
{
|
|
71
|
+
"authenticated": is_authenticated,
|
|
72
|
+
"base_url": config.base_url,
|
|
73
|
+
"token_preview": f"...{config.api_token[-8:]}"
|
|
74
|
+
if config.api_token
|
|
75
|
+
else None,
|
|
76
|
+
},
|
|
77
|
+
use_json=True,
|
|
78
|
+
)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if is_authenticated:
|
|
82
|
+
success(f"Authenticated (token: ...{config.api_token[-8:]})") # pyright: ignore[reportOptionalSubscript]
|
|
83
|
+
info(f"API: {config.base_url}")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
die("Not authenticated. Run `mcpheroctl auth login --token <TOKEN>`.", code=4)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@auth_app.command("logout")
|
|
90
|
+
def logout(
|
|
91
|
+
output_json: Annotated[
|
|
92
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
93
|
+
] = False,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Remove stored credentials.
|
|
96
|
+
|
|
97
|
+
Examples:
|
|
98
|
+
mcpheroctl auth logout
|
|
99
|
+
mcpheroctl auth logout --json
|
|
100
|
+
"""
|
|
101
|
+
config: Config = load_config()
|
|
102
|
+
config.api_token = None
|
|
103
|
+
save_config(config)
|
|
104
|
+
if output_json:
|
|
105
|
+
print_result({"status": "logged_out"}, use_json=True)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
success("Credentials removed.")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Server management commands for mcpheroctl."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from mcpheroctl.core.client import APIError, MCPHeroClient
|
|
10
|
+
from mcpheroctl.core.output import (
|
|
11
|
+
EXIT_GENERAL_FAILURE,
|
|
12
|
+
EXIT_NOT_FOUND,
|
|
13
|
+
die,
|
|
14
|
+
print_result,
|
|
15
|
+
success,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
server_app = typer.Typer(
|
|
19
|
+
name="server",
|
|
20
|
+
help="Manage MCP servers.",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
rich_markup_mode=None,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _client() -> MCPHeroClient:
|
|
27
|
+
return MCPHeroClient()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _handle_api_error(exc: APIError, *, use_json: bool) -> None:
|
|
31
|
+
"""Translate APIError into a structured exit."""
|
|
32
|
+
code = EXIT_GENERAL_FAILURE
|
|
33
|
+
error_type = "api_error"
|
|
34
|
+
if exc.status_code == 404:
|
|
35
|
+
code = EXIT_NOT_FOUND
|
|
36
|
+
error_type = "not_found"
|
|
37
|
+
elif exc.status_code in (401, 403):
|
|
38
|
+
code = 4
|
|
39
|
+
error_type = "permission_denied"
|
|
40
|
+
elif exc.status_code == 409:
|
|
41
|
+
code = 5
|
|
42
|
+
error_type = "conflict"
|
|
43
|
+
die(
|
|
44
|
+
exc.detail,
|
|
45
|
+
code=code,
|
|
46
|
+
error_type=error_type,
|
|
47
|
+
details={"status_code": exc.status_code},
|
|
48
|
+
use_json=use_json,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@server_app.command("list")
|
|
53
|
+
def list_servers(
|
|
54
|
+
customer_id: Annotated[
|
|
55
|
+
str | None,
|
|
56
|
+
typer.Argument(help="Customer UUID (optional when using org API key)."),
|
|
57
|
+
] = None,
|
|
58
|
+
output_json: Annotated[
|
|
59
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
60
|
+
] = False,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""List all MCP servers for a customer.
|
|
63
|
+
|
|
64
|
+
When authenticated with an org API key, customer_id is optional and
|
|
65
|
+
defaults to your organization. With admin API key, customer_id is required.
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
mcpheroctl server list
|
|
69
|
+
mcpheroctl server list 550e8400-e29b-41d4-a716-446655440000
|
|
70
|
+
mcpheroctl server list --json
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
result = _client().list_servers(customer_id)
|
|
74
|
+
print_result(result, use_json=output_json)
|
|
75
|
+
except APIError as exc:
|
|
76
|
+
_handle_api_error(exc, use_json=output_json)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@server_app.command("get")
|
|
80
|
+
def get_server(
|
|
81
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
82
|
+
output_json: Annotated[
|
|
83
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
84
|
+
] = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Get full details of an MCP server.
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
mcpheroctl server get 550e8400-e29b-41d4-a716-446655440000
|
|
90
|
+
mcpheroctl server get 550e8400-e29b-41d4-a716-446655440000 --json
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
result = _client().get_server(server_id)
|
|
94
|
+
print_result(result, use_json=output_json)
|
|
95
|
+
except APIError as exc:
|
|
96
|
+
_handle_api_error(exc, use_json=output_json)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@server_app.command("delete")
|
|
100
|
+
def delete_server(
|
|
101
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID to delete.")],
|
|
102
|
+
yes: Annotated[
|
|
103
|
+
bool, typer.Option("--yes", "-y", help="Skip confirmation prompt.")
|
|
104
|
+
] = False,
|
|
105
|
+
output_json: Annotated[
|
|
106
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
107
|
+
] = False,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Delete an MCP server and all its resources.
|
|
110
|
+
|
|
111
|
+
This is a destructive operation. Use --yes to skip the confirmation prompt
|
|
112
|
+
(required for non-interactive / agent usage).
|
|
113
|
+
|
|
114
|
+
Examples:
|
|
115
|
+
mcpheroctl server delete 550e8400-e29b-41d4-a716-446655440000 --yes
|
|
116
|
+
mcpheroctl server delete 550e8400-e29b-41d4-a716-446655440000 --yes --json
|
|
117
|
+
"""
|
|
118
|
+
if not yes:
|
|
119
|
+
confirm = typer.confirm(f"Delete server {server_id}? This cannot be undone")
|
|
120
|
+
if not confirm:
|
|
121
|
+
raise typer.Abort()
|
|
122
|
+
try:
|
|
123
|
+
result = _client().delete_server(server_id)
|
|
124
|
+
if output_json:
|
|
125
|
+
print_result(result, use_json=True)
|
|
126
|
+
else:
|
|
127
|
+
success(f"Server {server_id} deleted.")
|
|
128
|
+
except APIError as exc:
|
|
129
|
+
_handle_api_error(exc, use_json=output_json)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@server_app.command("update")
|
|
133
|
+
def update_server(
|
|
134
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID to update.")],
|
|
135
|
+
name: Annotated[str | None, typer.Option("--name", help="New server name.")] = None,
|
|
136
|
+
description: Annotated[
|
|
137
|
+
str | None, typer.Option("--description", help="New server description.")
|
|
138
|
+
] = None,
|
|
139
|
+
output_json: Annotated[
|
|
140
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
141
|
+
] = False,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Update an MCP server's name or description.
|
|
144
|
+
|
|
145
|
+
Examples:
|
|
146
|
+
mcpheroctl server update 550e8400-... --name "My Server"
|
|
147
|
+
mcpheroctl server update 550e8400-... --description "Does X, Y, Z" --json
|
|
148
|
+
"""
|
|
149
|
+
if name is None and description is None:
|
|
150
|
+
die(
|
|
151
|
+
"At least one of --name or --description is required.",
|
|
152
|
+
code=2,
|
|
153
|
+
use_json=output_json,
|
|
154
|
+
)
|
|
155
|
+
try:
|
|
156
|
+
result = _client().update_server(server_id, name=name, description=description)
|
|
157
|
+
if output_json:
|
|
158
|
+
print_result(result, use_json=True)
|
|
159
|
+
else:
|
|
160
|
+
success(f"Server {server_id} updated.")
|
|
161
|
+
except APIError as exc:
|
|
162
|
+
_handle_api_error(exc, use_json=output_json)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@server_app.command("api-key")
|
|
166
|
+
def get_api_key(
|
|
167
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
168
|
+
output_json: Annotated[
|
|
169
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
170
|
+
] = False,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Get the API key for a server.
|
|
173
|
+
|
|
174
|
+
Examples:
|
|
175
|
+
mcpheroctl server api-key 550e8400-e29b-41d4-a716-446655440000
|
|
176
|
+
mcpheroctl server api-key 550e8400-e29b-41d4-a716-446655440000 --json
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
result = _client().get_server_api_key(server_id)
|
|
180
|
+
print_result(result, use_json=output_json)
|
|
181
|
+
except APIError as exc:
|
|
182
|
+
_handle_api_error(exc, use_json=output_json)
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Wizard commands for mcpheroctl.
|
|
2
|
+
|
|
3
|
+
Covers the full MCP server creation wizard pipeline:
|
|
4
|
+
1. start – create server and kick off tool suggestion
|
|
5
|
+
2. list-tools – view suggested/current tools
|
|
6
|
+
3. refine-tools – refine tools via LLM with feedback
|
|
7
|
+
4. submit-tools – select tools to keep
|
|
8
|
+
5. suggest-env-vars – trigger env var suggestion
|
|
9
|
+
6. list-env-vars – view suggested env vars
|
|
10
|
+
7. refine-env-vars – refine env vars via LLM
|
|
11
|
+
8. submit-env-vars – provide env var values
|
|
12
|
+
9. set-auth – generate bearer token
|
|
13
|
+
10. generate-code – generate tool implementation code
|
|
14
|
+
11. regenerate-tool-code – regenerate code for a single tool
|
|
15
|
+
12. deploy – deploy to shared runtime
|
|
16
|
+
13. state – poll current wizard state
|
|
17
|
+
14. conversation – (stub) interactive conversation (not yet on backend)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Annotated
|
|
24
|
+
|
|
25
|
+
import typer
|
|
26
|
+
|
|
27
|
+
from mcpheroctl.core.client import APIError, MCPHeroClient
|
|
28
|
+
from mcpheroctl.core.output import (
|
|
29
|
+
EXIT_GENERAL_FAILURE,
|
|
30
|
+
EXIT_NOT_FOUND,
|
|
31
|
+
EXIT_NOT_IMPLEMENTED,
|
|
32
|
+
die,
|
|
33
|
+
info,
|
|
34
|
+
print_result,
|
|
35
|
+
success,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
wizard_app = typer.Typer(
|
|
39
|
+
name="wizard",
|
|
40
|
+
help="MCP server creation wizard pipeline.",
|
|
41
|
+
no_args_is_help=True,
|
|
42
|
+
rich_markup_mode=None,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _client() -> MCPHeroClient:
|
|
47
|
+
return MCPHeroClient()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _handle_api_error(exc: APIError, *, use_json: bool) -> None:
|
|
51
|
+
"""Translate APIError into a structured exit."""
|
|
52
|
+
code = EXIT_GENERAL_FAILURE
|
|
53
|
+
error_type = "api_error"
|
|
54
|
+
if exc.status_code == 404:
|
|
55
|
+
code = EXIT_NOT_FOUND
|
|
56
|
+
error_type = "not_found"
|
|
57
|
+
elif exc.status_code in (401, 403):
|
|
58
|
+
code = 4
|
|
59
|
+
error_type = "permission_denied"
|
|
60
|
+
elif exc.status_code == 409:
|
|
61
|
+
code = 5
|
|
62
|
+
error_type = "conflict"
|
|
63
|
+
die(
|
|
64
|
+
exc.detail,
|
|
65
|
+
code=code,
|
|
66
|
+
error_type=error_type,
|
|
67
|
+
details={"status_code": exc.status_code},
|
|
68
|
+
use_json=use_json,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Step 1: Start wizard
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@wizard_app.command("start")
|
|
78
|
+
def start(
|
|
79
|
+
spec: Annotated[
|
|
80
|
+
Path,
|
|
81
|
+
typer.Argument(
|
|
82
|
+
help="Path to a markdown file containing the system/server description.",
|
|
83
|
+
exists=True,
|
|
84
|
+
readable=True,
|
|
85
|
+
),
|
|
86
|
+
],
|
|
87
|
+
customer_id: Annotated[
|
|
88
|
+
str | None,
|
|
89
|
+
typer.Option(
|
|
90
|
+
"--customer-id",
|
|
91
|
+
"-c",
|
|
92
|
+
help="Customer UUID (optional when using org API key).",
|
|
93
|
+
),
|
|
94
|
+
] = None,
|
|
95
|
+
technical_details: Annotated[
|
|
96
|
+
list[Path] | None,
|
|
97
|
+
typer.Option(
|
|
98
|
+
"--technical-details",
|
|
99
|
+
"-d",
|
|
100
|
+
help="Path(s) to markdown files with technical details (API specs, schemas, etc.). Can be repeated.",
|
|
101
|
+
exists=True,
|
|
102
|
+
readable=True,
|
|
103
|
+
),
|
|
104
|
+
] = None,
|
|
105
|
+
output_json: Annotated[
|
|
106
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
107
|
+
] = False,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Start the MCP server creation wizard.
|
|
110
|
+
|
|
111
|
+
Reads the server description from a markdown spec file and optionally
|
|
112
|
+
accepts technical detail files. Triggers background tool suggestion.
|
|
113
|
+
|
|
114
|
+
When authenticated with an org API key, --customer-id is optional.
|
|
115
|
+
|
|
116
|
+
Examples:
|
|
117
|
+
mcpheroctl wizard start spec.md
|
|
118
|
+
mcpheroctl wizard start spec.md --customer-id 550e8400-e29b-41d4-a716-446655440000
|
|
119
|
+
mcpheroctl wizard start spec.md -d api_schema.md -d endpoints.md
|
|
120
|
+
"""
|
|
121
|
+
description = spec.read_text()
|
|
122
|
+
tech_details: list[str] | None = None
|
|
123
|
+
if technical_details:
|
|
124
|
+
tech_details = [p.read_text() for p in technical_details]
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
result = _client().wizard_start(description, customer_id, tech_details)
|
|
128
|
+
if output_json:
|
|
129
|
+
print_result(result, use_json=True)
|
|
130
|
+
else:
|
|
131
|
+
server_id = result.get("server_id", "unknown")
|
|
132
|
+
success(f"Wizard started. Server ID: {server_id}")
|
|
133
|
+
info(
|
|
134
|
+
"Tool suggestion is running in the background. Poll with `wizard state`."
|
|
135
|
+
)
|
|
136
|
+
except APIError as exc:
|
|
137
|
+
_handle_api_error(exc, use_json=output_json)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Step 1 tools management
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@wizard_app.command("list-tools")
|
|
146
|
+
def list_tools(
|
|
147
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
148
|
+
output_json: Annotated[
|
|
149
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
150
|
+
] = False,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""List current tools for a server in the wizard.
|
|
153
|
+
|
|
154
|
+
Examples:
|
|
155
|
+
mcpheroctl wizard list-tools SERVER_ID
|
|
156
|
+
mcpheroctl wizard list-tools SERVER_ID --json
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
result = _client().wizard_get_tools(server_id)
|
|
160
|
+
print_result(result, use_json=output_json)
|
|
161
|
+
except APIError as exc:
|
|
162
|
+
_handle_api_error(exc, use_json=output_json)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@wizard_app.command("refine-tools")
|
|
166
|
+
def refine_tools(
|
|
167
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
168
|
+
feedback: Annotated[
|
|
169
|
+
str, typer.Option("--feedback", "-f", help="Feedback text for LLM refinement.")
|
|
170
|
+
],
|
|
171
|
+
tool_id: Annotated[
|
|
172
|
+
list[str] | None,
|
|
173
|
+
typer.Option(
|
|
174
|
+
"--tool-id",
|
|
175
|
+
help="Tool UUID(s) to refine. Can be repeated. Omit to refine all.",
|
|
176
|
+
),
|
|
177
|
+
] = None,
|
|
178
|
+
output_json: Annotated[
|
|
179
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
180
|
+
] = False,
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Refine suggested tools based on feedback.
|
|
183
|
+
|
|
184
|
+
Triggers an LLM refinement in the background. Pass --tool-id multiple
|
|
185
|
+
times to target specific tools, or omit to refine all.
|
|
186
|
+
|
|
187
|
+
Examples:
|
|
188
|
+
mcpheroctl wizard refine-tools SERVER_ID --feedback "Add a search tool"
|
|
189
|
+
mcpheroctl wizard refine-tools SERVER_ID -f "Split into two" --tool-id UUID1 --tool-id UUID2
|
|
190
|
+
mcpheroctl wizard refine-tools SERVER_ID -f "Simplify parameters" --json
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
result = _client().wizard_refine_tools(server_id, feedback, tool_id)
|
|
194
|
+
if output_json:
|
|
195
|
+
print_result(result, use_json=True)
|
|
196
|
+
else:
|
|
197
|
+
success("Tool refinement started. Poll with `wizard state`.")
|
|
198
|
+
except APIError as exc:
|
|
199
|
+
_handle_api_error(exc, use_json=output_json)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@wizard_app.command("submit-tools")
|
|
203
|
+
def submit_tools(
|
|
204
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
205
|
+
tool_id: Annotated[
|
|
206
|
+
list[str],
|
|
207
|
+
typer.Option("--tool-id", help="Tool UUID(s) to keep. Can be repeated."),
|
|
208
|
+
],
|
|
209
|
+
output_json: Annotated[
|
|
210
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
211
|
+
] = False,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Submit selected tools to proceed to env vars step.
|
|
214
|
+
|
|
215
|
+
Pass --tool-id for each tool to keep. Unselected tools are deleted.
|
|
216
|
+
|
|
217
|
+
Examples:
|
|
218
|
+
mcpheroctl wizard submit-tools SERVER_ID --tool-id UUID1 --tool-id UUID2
|
|
219
|
+
mcpheroctl wizard submit-tools SERVER_ID --tool-id UUID1 --json
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
result = _client().wizard_submit_tools(server_id, tool_id)
|
|
223
|
+
if output_json:
|
|
224
|
+
print_result(result, use_json=True)
|
|
225
|
+
else:
|
|
226
|
+
success(f"Submitted {len(tool_id)} tool(s). Env var suggestion started.")
|
|
227
|
+
except APIError as exc:
|
|
228
|
+
_handle_api_error(exc, use_json=output_json)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---------------------------------------------------------------------------
|
|
232
|
+
# Step 2: Environment variables
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@wizard_app.command("suggest-env-vars")
|
|
237
|
+
def suggest_env_vars(
|
|
238
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
239
|
+
output_json: Annotated[
|
|
240
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
241
|
+
] = False,
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Trigger environment variable suggestion via LLM.
|
|
244
|
+
|
|
245
|
+
Examples:
|
|
246
|
+
mcpheroctl wizard suggest-env-vars SERVER_ID
|
|
247
|
+
mcpheroctl wizard suggest-env-vars SERVER_ID --json
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
result = _client().wizard_suggest_env_vars(server_id)
|
|
251
|
+
if output_json:
|
|
252
|
+
print_result(result, use_json=True)
|
|
253
|
+
else:
|
|
254
|
+
success("Env var suggestion started. Poll with `wizard state`.")
|
|
255
|
+
except APIError as exc:
|
|
256
|
+
_handle_api_error(exc, use_json=output_json)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@wizard_app.command("list-env-vars")
|
|
260
|
+
def list_env_vars(
|
|
261
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
262
|
+
output_json: Annotated[
|
|
263
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
264
|
+
] = False,
|
|
265
|
+
) -> None:
|
|
266
|
+
"""List current environment variables for a server.
|
|
267
|
+
|
|
268
|
+
Examples:
|
|
269
|
+
mcpheroctl wizard list-env-vars SERVER_ID
|
|
270
|
+
mcpheroctl wizard list-env-vars SERVER_ID --json
|
|
271
|
+
"""
|
|
272
|
+
try:
|
|
273
|
+
result = _client().wizard_get_env_vars(server_id)
|
|
274
|
+
print_result(result, use_json=output_json)
|
|
275
|
+
except APIError as exc:
|
|
276
|
+
_handle_api_error(exc, use_json=output_json)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@wizard_app.command("refine-env-vars")
|
|
280
|
+
def refine_env_vars(
|
|
281
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
282
|
+
feedback: Annotated[
|
|
283
|
+
str, typer.Option("--feedback", "-f", help="Feedback text for LLM refinement.")
|
|
284
|
+
],
|
|
285
|
+
output_json: Annotated[
|
|
286
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
287
|
+
] = False,
|
|
288
|
+
) -> None:
|
|
289
|
+
"""Refine suggested environment variables based on feedback.
|
|
290
|
+
|
|
291
|
+
Triggers an LLM refinement in the background.
|
|
292
|
+
|
|
293
|
+
Examples:
|
|
294
|
+
mcpheroctl wizard refine-env-vars SERVER_ID -f "Combine DB vars into DATABASE_URL"
|
|
295
|
+
mcpheroctl wizard refine-env-vars SERVER_ID --feedback "Add API key var" --json
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
result = _client().wizard_refine_env_vars(server_id, feedback)
|
|
299
|
+
if output_json:
|
|
300
|
+
print_result(result, use_json=True)
|
|
301
|
+
else:
|
|
302
|
+
success("Env var refinement started. Poll with `wizard state`.")
|
|
303
|
+
except APIError as exc:
|
|
304
|
+
_handle_api_error(exc, use_json=output_json)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@wizard_app.command("submit-env-vars")
|
|
308
|
+
def submit_env_vars(
|
|
309
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
310
|
+
var: Annotated[
|
|
311
|
+
list[str],
|
|
312
|
+
typer.Option("--var", help="Env var value as VAR_UUID=VALUE. Can be repeated."),
|
|
313
|
+
],
|
|
314
|
+
output_json: Annotated[
|
|
315
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
316
|
+
] = False,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Submit environment variable values.
|
|
319
|
+
|
|
320
|
+
Pass --var for each variable in the format UUID=VALUE.
|
|
321
|
+
|
|
322
|
+
Examples:
|
|
323
|
+
mcpheroctl wizard submit-env-vars SERVER_ID --var "UUID1=sk-abc123" --var "UUID2=https://api.example.com"
|
|
324
|
+
mcpheroctl wizard submit-env-vars SERVER_ID --var "UUID1=value1" --json
|
|
325
|
+
"""
|
|
326
|
+
values: dict[str, str] = {}
|
|
327
|
+
for v in var:
|
|
328
|
+
if "=" not in v:
|
|
329
|
+
die(
|
|
330
|
+
f"Invalid --var format: '{v}'. Expected VAR_UUID=VALUE.",
|
|
331
|
+
code=2,
|
|
332
|
+
use_json=output_json,
|
|
333
|
+
)
|
|
334
|
+
key, val = v.split("=", 1)
|
|
335
|
+
values[key.strip()] = val.strip()
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
result = _client().wizard_submit_env_vars(server_id, values)
|
|
339
|
+
if output_json:
|
|
340
|
+
print_result(result, use_json=True)
|
|
341
|
+
else:
|
|
342
|
+
success(f"Submitted {len(values)} env var value(s).")
|
|
343
|
+
except APIError as exc:
|
|
344
|
+
_handle_api_error(exc, use_json=output_json)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
# Step 3: Auth
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@wizard_app.command("set-auth")
|
|
353
|
+
def set_auth(
|
|
354
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
355
|
+
output_json: Annotated[
|
|
356
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
357
|
+
] = False,
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Set up bearer token authentication for the server.
|
|
360
|
+
|
|
361
|
+
Generates and returns a Bearer token.
|
|
362
|
+
|
|
363
|
+
Examples:
|
|
364
|
+
mcpheroctl wizard set-auth SERVER_ID
|
|
365
|
+
mcpheroctl wizard set-auth SERVER_ID --json
|
|
366
|
+
"""
|
|
367
|
+
try:
|
|
368
|
+
result = _client().wizard_set_auth(server_id)
|
|
369
|
+
if output_json:
|
|
370
|
+
print_result(result, use_json=True)
|
|
371
|
+
else:
|
|
372
|
+
token = result.get("bearer_token", "")
|
|
373
|
+
success(f"Auth set. Bearer token: {token}")
|
|
374
|
+
except APIError as exc:
|
|
375
|
+
_handle_api_error(exc, use_json=output_json)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# Step 4: Code generation
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@wizard_app.command("generate-code")
|
|
384
|
+
def generate_code(
|
|
385
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
386
|
+
output_json: Annotated[
|
|
387
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
388
|
+
] = False,
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Trigger code generation for all tools.
|
|
391
|
+
|
|
392
|
+
Runs in the background. Poll with `wizard state` to check progress.
|
|
393
|
+
|
|
394
|
+
Examples:
|
|
395
|
+
mcpheroctl wizard generate-code SERVER_ID
|
|
396
|
+
mcpheroctl wizard generate-code SERVER_ID --json
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
result = _client().wizard_generate_code(server_id)
|
|
400
|
+
if output_json:
|
|
401
|
+
print_result(result, use_json=True)
|
|
402
|
+
else:
|
|
403
|
+
success("Code generation started. Poll with `wizard state`.")
|
|
404
|
+
except APIError as exc:
|
|
405
|
+
_handle_api_error(exc, use_json=output_json)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@wizard_app.command("regenerate-tool-code")
|
|
409
|
+
def regenerate_tool_code(
|
|
410
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
411
|
+
tool_id: Annotated[str, typer.Argument(help="Tool UUID to regenerate code for.")],
|
|
412
|
+
output_json: Annotated[
|
|
413
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
414
|
+
] = False,
|
|
415
|
+
) -> None:
|
|
416
|
+
"""Regenerate code for a single tool (synchronous).
|
|
417
|
+
|
|
418
|
+
Waits for LLM to produce new code and returns it.
|
|
419
|
+
|
|
420
|
+
Examples:
|
|
421
|
+
mcpheroctl wizard regenerate-tool-code SERVER_ID TOOL_ID
|
|
422
|
+
mcpheroctl wizard regenerate-tool-code SERVER_ID TOOL_ID --json
|
|
423
|
+
"""
|
|
424
|
+
try:
|
|
425
|
+
result = _client().wizard_regenerate_tool_code(server_id, tool_id)
|
|
426
|
+
if output_json:
|
|
427
|
+
print_result(result, use_json=True)
|
|
428
|
+
else:
|
|
429
|
+
success(f"Code regenerated for tool {tool_id}.")
|
|
430
|
+
code = result.get("code", "")
|
|
431
|
+
if code:
|
|
432
|
+
info(f"Generated code:\n{code}")
|
|
433
|
+
except APIError as exc:
|
|
434
|
+
_handle_api_error(exc, use_json=output_json)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
# Step 5: Deploy
|
|
439
|
+
# ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@wizard_app.command("deploy")
|
|
443
|
+
def deploy(
|
|
444
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
445
|
+
output_json: Annotated[
|
|
446
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
447
|
+
] = False,
|
|
448
|
+
) -> None:
|
|
449
|
+
"""Deploy the MCP server to the shared runtime.
|
|
450
|
+
|
|
451
|
+
Examples:
|
|
452
|
+
mcpheroctl wizard deploy SERVER_ID
|
|
453
|
+
mcpheroctl wizard deploy SERVER_ID --json
|
|
454
|
+
"""
|
|
455
|
+
try:
|
|
456
|
+
result = _client().wizard_deploy(server_id)
|
|
457
|
+
if output_json:
|
|
458
|
+
print_result(result, use_json=True)
|
|
459
|
+
else:
|
|
460
|
+
url = result.get("server_url", "")
|
|
461
|
+
success(f"Deployed! Endpoint: {url}")
|
|
462
|
+
except APIError as exc:
|
|
463
|
+
_handle_api_error(exc, use_json=output_json)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# ---------------------------------------------------------------------------
|
|
467
|
+
# State polling
|
|
468
|
+
# ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@wizard_app.command("state")
|
|
472
|
+
def state(
|
|
473
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
474
|
+
output_json: Annotated[
|
|
475
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
476
|
+
] = False,
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Get the current wizard state for a server.
|
|
479
|
+
|
|
480
|
+
Useful for polling during background operations (tool suggestion,
|
|
481
|
+
code generation, etc.).
|
|
482
|
+
|
|
483
|
+
Examples:
|
|
484
|
+
mcpheroctl wizard state SERVER_ID
|
|
485
|
+
mcpheroctl wizard state SERVER_ID --json
|
|
486
|
+
"""
|
|
487
|
+
try:
|
|
488
|
+
result = _client().wizard_get_state(server_id)
|
|
489
|
+
print_result(result, use_json=output_json)
|
|
490
|
+
except APIError as exc:
|
|
491
|
+
_handle_api_error(exc, use_json=output_json)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ---------------------------------------------------------------------------
|
|
495
|
+
# Conversation (stub – frontend-only for now)
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@wizard_app.command("conversation")
|
|
500
|
+
def conversation(
|
|
501
|
+
server_id: Annotated[str, typer.Argument(help="Server UUID.")],
|
|
502
|
+
output_json: Annotated[
|
|
503
|
+
bool, typer.Option("--json", help="Output result as JSON.")
|
|
504
|
+
] = False,
|
|
505
|
+
) -> None:
|
|
506
|
+
"""Start an interactive conversation with the wizard.
|
|
507
|
+
|
|
508
|
+
NOTE: This feature currently only exists on the frontend and has not yet
|
|
509
|
+
been migrated to the backend API. This command will be implemented once
|
|
510
|
+
the backend endpoint is available.
|
|
511
|
+
|
|
512
|
+
Examples:
|
|
513
|
+
mcpheroctl wizard conversation SERVER_ID
|
|
514
|
+
"""
|
|
515
|
+
die(
|
|
516
|
+
"The conversation feature is not yet implemented on the backend.",
|
|
517
|
+
code=EXIT_NOT_IMPLEMENTED,
|
|
518
|
+
error_type="not_implemented",
|
|
519
|
+
details={"feature": "wizard_conversation", "server_id": server_id},
|
|
520
|
+
use_json=output_json,
|
|
521
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""HTTP client for the MCPHero API.
|
|
2
|
+
|
|
3
|
+
Uses httpx with tenacity retry logic for transient failures.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from httpx._client import Client
|
|
12
|
+
from tenacity import (
|
|
13
|
+
retry,
|
|
14
|
+
retry_if_exception_type,
|
|
15
|
+
stop_after_attempt,
|
|
16
|
+
wait_exponential,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from mcpheroctl.core.config import get_base_url, require_token
|
|
20
|
+
|
|
21
|
+
# Retry on transient network / 5xx errors
|
|
22
|
+
_RETRYABLE_STATUS_CODES = frozenset(range(500, 600))
|
|
23
|
+
|
|
24
|
+
# Default timeout for API calls (seconds)
|
|
25
|
+
_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class APIError(Exception):
|
|
29
|
+
"""Raised when the API returns an error response."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, status_code: int, detail: str, body: Any = None) -> None:
|
|
32
|
+
self.status_code: int = status_code
|
|
33
|
+
self.detail: str = detail
|
|
34
|
+
self.body: Any = body
|
|
35
|
+
super().__init__(f"HTTP {status_code}: {detail}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_client(token: str, base_url: str) -> httpx.Client:
|
|
39
|
+
return httpx.Client(
|
|
40
|
+
base_url=base_url,
|
|
41
|
+
headers={
|
|
42
|
+
"Authorization": f"Bearer {token}",
|
|
43
|
+
},
|
|
44
|
+
timeout=_TIMEOUT,
|
|
45
|
+
follow_redirects=True,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _handle_response(response: httpx.Response) -> Any:
|
|
50
|
+
"""Parse the response, raising APIError on non-2xx codes."""
|
|
51
|
+
if response.is_success:
|
|
52
|
+
if response.headers.get("content-type", "").startswith("application/json"):
|
|
53
|
+
return response.json()
|
|
54
|
+
return response.text
|
|
55
|
+
# Try to extract detail from JSON body
|
|
56
|
+
detail: str = response.text
|
|
57
|
+
try:
|
|
58
|
+
body = response.json()
|
|
59
|
+
if isinstance(body, dict):
|
|
60
|
+
detail = body.get("detail", detail)
|
|
61
|
+
except Exception:
|
|
62
|
+
body = None
|
|
63
|
+
raise APIError(response.status_code, detail, body)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MCPHeroClient:
|
|
67
|
+
"""Synchronous HTTP client for the MCPHero backend API."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self, *, token: str | None = None, base_url: str | None = None
|
|
71
|
+
) -> None:
|
|
72
|
+
self._token: str = token or require_token()
|
|
73
|
+
self._base_url: str = base_url or get_base_url()
|
|
74
|
+
self._client: Client = _build_client(self._token, self._base_url)
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
# Low-level request helpers with retry
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
@retry(
|
|
81
|
+
retry=retry_if_exception_type((httpx.TransportError, APIError)),
|
|
82
|
+
stop=stop_after_attempt(3),
|
|
83
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=5),
|
|
84
|
+
reraise=True,
|
|
85
|
+
)
|
|
86
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
87
|
+
response = self._client.request(method, path, **kwargs)
|
|
88
|
+
return _handle_response(response)
|
|
89
|
+
|
|
90
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
91
|
+
return self._request("GET", path, **kwargs)
|
|
92
|
+
|
|
93
|
+
def post(self, path: str, **kwargs: Any) -> Any:
|
|
94
|
+
return self._request("POST", path, **kwargs)
|
|
95
|
+
|
|
96
|
+
def patch(self, path: str, **kwargs: Any) -> Any:
|
|
97
|
+
return self._request("PATCH", path, **kwargs)
|
|
98
|
+
|
|
99
|
+
def delete(self, path: str, **kwargs: Any) -> Any:
|
|
100
|
+
return self._request("DELETE", path, **kwargs)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Server endpoints
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def list_servers(self, customer_id: str | None = None) -> Any:
|
|
107
|
+
"""List MCP servers. When using org API key, customer_id is optional."""
|
|
108
|
+
if customer_id is not None:
|
|
109
|
+
return self.get(f"/servers/list/{customer_id}")
|
|
110
|
+
return self.get("/servers/list")
|
|
111
|
+
|
|
112
|
+
def get_server(self, server_id: str) -> Any:
|
|
113
|
+
return self.get(f"/servers/{server_id}/details")
|
|
114
|
+
|
|
115
|
+
def delete_server(self, server_id: str) -> Any:
|
|
116
|
+
return self.delete(f"/servers/{server_id}")
|
|
117
|
+
|
|
118
|
+
def update_server(
|
|
119
|
+
self, server_id: str, *, name: str | None = None, description: str | None = None
|
|
120
|
+
) -> Any:
|
|
121
|
+
body: dict[str, Any] = {}
|
|
122
|
+
if name is not None:
|
|
123
|
+
body["name"] = name
|
|
124
|
+
if description is not None:
|
|
125
|
+
body["description"] = description
|
|
126
|
+
return self.patch(f"/servers/{server_id}", json=body)
|
|
127
|
+
|
|
128
|
+
def get_server_api_key(self, server_id: str) -> Any:
|
|
129
|
+
return self.get(f"/servers/{server_id}/api-key")
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Wizard endpoints
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def wizard_start(
|
|
136
|
+
self,
|
|
137
|
+
description: str,
|
|
138
|
+
customer_id: str | None = None,
|
|
139
|
+
technical_details: list[str] | None = None,
|
|
140
|
+
) -> Any:
|
|
141
|
+
body: dict[str, Any] = {"description": description}
|
|
142
|
+
if customer_id is not None:
|
|
143
|
+
body["customer_id"] = customer_id
|
|
144
|
+
if technical_details:
|
|
145
|
+
body["technical_details"] = technical_details
|
|
146
|
+
return self.post("/wizard/start", json=body)
|
|
147
|
+
|
|
148
|
+
def wizard_get_tools(self, server_id: str) -> Any:
|
|
149
|
+
return self.get(f"/wizard/{server_id}/tools")
|
|
150
|
+
|
|
151
|
+
def wizard_refine_tools(
|
|
152
|
+
self,
|
|
153
|
+
server_id: str,
|
|
154
|
+
feedback: str,
|
|
155
|
+
tool_ids: list[str] | None = None,
|
|
156
|
+
) -> Any:
|
|
157
|
+
body: dict[str, Any] = {"feedback": feedback}
|
|
158
|
+
if tool_ids:
|
|
159
|
+
body["tool_ids"] = tool_ids
|
|
160
|
+
return self.post(f"/wizard/{server_id}/tools/refine", json=body)
|
|
161
|
+
|
|
162
|
+
def wizard_submit_tools(self, server_id: str, selected_tool_ids: list[str]) -> Any:
|
|
163
|
+
return self.post(
|
|
164
|
+
f"/wizard/{server_id}/tools/submit",
|
|
165
|
+
json={"selected_tool_ids": selected_tool_ids},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def wizard_suggest_env_vars(self, server_id: str) -> Any:
|
|
169
|
+
return self.post(f"/wizard/{server_id}/env-vars/suggest")
|
|
170
|
+
|
|
171
|
+
def wizard_get_env_vars(self, server_id: str) -> Any:
|
|
172
|
+
return self.get(f"/wizard/{server_id}/env-vars")
|
|
173
|
+
|
|
174
|
+
def wizard_refine_env_vars(self, server_id: str, feedback: str) -> Any:
|
|
175
|
+
return self.post(
|
|
176
|
+
f"/wizard/{server_id}/env-vars/refine",
|
|
177
|
+
json={"feedback": feedback},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def wizard_submit_env_vars(self, server_id: str, values: dict[str, str]) -> Any:
|
|
181
|
+
return self.post(
|
|
182
|
+
f"/wizard/{server_id}/env-vars/submit",
|
|
183
|
+
json={"values": values},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def wizard_set_auth(self, server_id: str) -> Any:
|
|
187
|
+
return self.post(f"/wizard/{server_id}/auth")
|
|
188
|
+
|
|
189
|
+
def wizard_generate_code(self, server_id: str) -> Any:
|
|
190
|
+
return self.post(f"/wizard/{server_id}/generate-code")
|
|
191
|
+
|
|
192
|
+
def wizard_regenerate_tool_code(self, server_id: str, tool_id: str) -> Any:
|
|
193
|
+
return self.post(f"/wizard/{server_id}/tools/{tool_id}/regenerate-code")
|
|
194
|
+
|
|
195
|
+
def wizard_deploy(self, server_id: str) -> Any:
|
|
196
|
+
return self.post(f"/wizard/{server_id}/deploy")
|
|
197
|
+
|
|
198
|
+
def wizard_get_state(self, server_id: str) -> Any:
|
|
199
|
+
return self.get(f"/wizard/{server_id}/state")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Configuration management for mcpheroctl.
|
|
2
|
+
|
|
3
|
+
Handles reading/writing the API token and base URL to
|
|
4
|
+
~/.config/mcpheroctl/config.json.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
CONFIG_DIR = Path.home() / ".config" / "mcpheroctl"
|
|
14
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
15
|
+
|
|
16
|
+
# Default base URL for the MCPHero API
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.mcphero.app/api"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Config(BaseModel):
|
|
21
|
+
"""Persisted CLI configuration."""
|
|
22
|
+
|
|
23
|
+
api_token: str | None = None
|
|
24
|
+
base_url: str = DEFAULT_BASE_URL
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config() -> Config:
|
|
28
|
+
"""Load configuration from disk. Returns defaults if file does not exist."""
|
|
29
|
+
if not CONFIG_FILE.exists():
|
|
30
|
+
return Config()
|
|
31
|
+
try:
|
|
32
|
+
data = json.loads(CONFIG_FILE.read_text())
|
|
33
|
+
return Config.model_validate(data)
|
|
34
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
35
|
+
print(f"Warning: corrupt config at {CONFIG_FILE}: {exc}", file=sys.stderr)
|
|
36
|
+
return Config()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_config(config: Config) -> None:
|
|
40
|
+
"""Persist configuration to disk."""
|
|
41
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
_ = CONFIG_FILE.write_text(config.model_dump_json(indent=2) + "\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def require_token() -> str:
|
|
46
|
+
"""Return the stored API token or exit with an error message."""
|
|
47
|
+
config = load_config()
|
|
48
|
+
if not config.api_token:
|
|
49
|
+
print(
|
|
50
|
+
"Error: not authenticated. Run `mcpheroctl auth login --token <TOKEN>` first.",
|
|
51
|
+
file=sys.stderr,
|
|
52
|
+
)
|
|
53
|
+
raise SystemExit(4) # exit code 4 = permission denied / not authenticated
|
|
54
|
+
return config.api_token
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_base_url() -> str:
|
|
58
|
+
"""Return the configured API base URL."""
|
|
59
|
+
return load_config().base_url
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Structured output helpers for agent-first CLI design.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
- JSON to stdout, everything else to stderr.
|
|
5
|
+
- Meaningful exit codes (not just 0/1).
|
|
6
|
+
- Actionable error messages with error codes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any, NoReturn
|
|
14
|
+
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
# Rich console for human-readable stderr messages
|
|
18
|
+
err_console = Console(stderr=True)
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Exit codes (documented in --help)
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
EXIT_SUCCESS = 0
|
|
24
|
+
EXIT_GENERAL_FAILURE = 1
|
|
25
|
+
EXIT_USAGE_ERROR = 2
|
|
26
|
+
EXIT_NOT_FOUND = 3
|
|
27
|
+
EXIT_PERMISSION_DENIED = 4
|
|
28
|
+
EXIT_CONFLICT = 5
|
|
29
|
+
EXIT_NOT_IMPLEMENTED = 6
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Output helpers
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def print_json(data: Any) -> None:
|
|
38
|
+
"""Print structured JSON to stdout."""
|
|
39
|
+
if hasattr(data, "model_dump"):
|
|
40
|
+
data = data.model_dump(mode="json")
|
|
41
|
+
json.dump(data, sys.stdout, indent=2, default=str)
|
|
42
|
+
_ = sys.stdout.write("\n") # noqa: F821
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def print_result(data: Any, *, use_json: bool) -> None:
|
|
46
|
+
"""Print *data* as JSON (stdout) or as a human table (stderr).
|
|
47
|
+
|
|
48
|
+
When *use_json* is False we still pretty-print via Rich to stderr so that
|
|
49
|
+
stdout stays clean for piping.
|
|
50
|
+
"""
|
|
51
|
+
if use_json:
|
|
52
|
+
print_json(data)
|
|
53
|
+
else:
|
|
54
|
+
if hasattr(data, "model_dump"):
|
|
55
|
+
data = data.model_dump(mode="json")
|
|
56
|
+
err_console.print_json(data=data)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def info(message: str) -> None:
|
|
60
|
+
"""Print an informational message to stderr."""
|
|
61
|
+
err_console.print(f"[bold blue]ℹ[/] {message}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def success(message: str) -> None:
|
|
65
|
+
"""Print a success message to stderr."""
|
|
66
|
+
err_console.print(f"[bold green]✔[/] {message}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def warn(message: str) -> None:
|
|
70
|
+
"""Print a warning message to stderr."""
|
|
71
|
+
err_console.print(f"[bold yellow]⚠[/] {message}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def error_msg(message: str) -> None:
|
|
75
|
+
"""Print an error message to stderr (does not exit)."""
|
|
76
|
+
err_console.print(f"[bold red]✖[/] {message}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def die(
|
|
80
|
+
message: str,
|
|
81
|
+
*,
|
|
82
|
+
code: int = EXIT_GENERAL_FAILURE,
|
|
83
|
+
error_type: str | None = None,
|
|
84
|
+
details: dict[str, Any] | None = None,
|
|
85
|
+
use_json: bool = False,
|
|
86
|
+
) -> NoReturn:
|
|
87
|
+
"""Print an error and exit with the given code.
|
|
88
|
+
|
|
89
|
+
When *use_json* is True, a structured error object is emitted to stdout
|
|
90
|
+
(matching agent-first guidelines rule 7).
|
|
91
|
+
"""
|
|
92
|
+
if use_json:
|
|
93
|
+
payload: dict[str, Any] = {
|
|
94
|
+
"error": error_type or "error",
|
|
95
|
+
"message": message,
|
|
96
|
+
}
|
|
97
|
+
if details:
|
|
98
|
+
payload.update(details)
|
|
99
|
+
print_json(payload)
|
|
100
|
+
else:
|
|
101
|
+
error_msg(message)
|
|
102
|
+
|
|
103
|
+
raise SystemExit(code)
|