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.
@@ -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,3 @@
1
+ """mcpheroctl - CLI tool for interacting with the MCPHero platform."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)