universal-mcp 0.1.23rc2__py3-none-any.whl → 0.1.24rc3__py3-none-any.whl

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.
Files changed (69) hide show
  1. universal_mcp/agentr/__init__.py +6 -0
  2. universal_mcp/agentr/agentr.py +30 -0
  3. universal_mcp/{utils/agentr.py → agentr/client.py} +22 -7
  4. universal_mcp/agentr/integration.py +104 -0
  5. universal_mcp/agentr/registry.py +91 -0
  6. universal_mcp/agentr/server.py +51 -0
  7. universal_mcp/agents/__init__.py +6 -0
  8. universal_mcp/agents/auto.py +576 -0
  9. universal_mcp/agents/base.py +88 -0
  10. universal_mcp/agents/cli.py +27 -0
  11. universal_mcp/agents/codeact/__init__.py +243 -0
  12. universal_mcp/agents/codeact/sandbox.py +27 -0
  13. universal_mcp/agents/codeact/test.py +15 -0
  14. universal_mcp/agents/codeact/utils.py +61 -0
  15. universal_mcp/agents/hil.py +104 -0
  16. universal_mcp/agents/llm.py +10 -0
  17. universal_mcp/agents/react.py +58 -0
  18. universal_mcp/agents/simple.py +40 -0
  19. universal_mcp/agents/utils.py +111 -0
  20. universal_mcp/analytics.py +44 -14
  21. universal_mcp/applications/__init__.py +42 -75
  22. universal_mcp/applications/application.py +187 -133
  23. universal_mcp/applications/sample/app.py +245 -0
  24. universal_mcp/cli.py +14 -231
  25. universal_mcp/client/oauth.py +122 -18
  26. universal_mcp/client/token_store.py +62 -3
  27. universal_mcp/client/{client.py → transport.py} +127 -48
  28. universal_mcp/config.py +189 -49
  29. universal_mcp/exceptions.py +54 -6
  30. universal_mcp/integrations/__init__.py +0 -18
  31. universal_mcp/integrations/integration.py +185 -168
  32. universal_mcp/servers/__init__.py +2 -14
  33. universal_mcp/servers/server.py +84 -258
  34. universal_mcp/stores/store.py +126 -93
  35. universal_mcp/tools/__init__.py +3 -0
  36. universal_mcp/tools/adapters.py +20 -11
  37. universal_mcp/tools/func_metadata.py +1 -1
  38. universal_mcp/tools/manager.py +38 -53
  39. universal_mcp/tools/registry.py +41 -0
  40. universal_mcp/tools/tools.py +24 -3
  41. universal_mcp/types.py +10 -0
  42. universal_mcp/utils/common.py +245 -0
  43. universal_mcp/utils/installation.py +3 -4
  44. universal_mcp/utils/openapi/api_generator.py +71 -17
  45. universal_mcp/utils/openapi/api_splitter.py +0 -1
  46. universal_mcp/utils/openapi/cli.py +669 -0
  47. universal_mcp/utils/openapi/filters.py +114 -0
  48. universal_mcp/utils/openapi/openapi.py +315 -23
  49. universal_mcp/utils/openapi/postprocessor.py +275 -0
  50. universal_mcp/utils/openapi/preprocessor.py +63 -8
  51. universal_mcp/utils/openapi/test_generator.py +287 -0
  52. universal_mcp/utils/prompts.py +634 -0
  53. universal_mcp/utils/singleton.py +4 -1
  54. universal_mcp/utils/testing.py +196 -8
  55. universal_mcp-0.1.24rc3.dist-info/METADATA +68 -0
  56. universal_mcp-0.1.24rc3.dist-info/RECORD +70 -0
  57. universal_mcp/applications/README.md +0 -122
  58. universal_mcp/client/__main__.py +0 -30
  59. universal_mcp/client/agent.py +0 -96
  60. universal_mcp/integrations/README.md +0 -25
  61. universal_mcp/servers/README.md +0 -79
  62. universal_mcp/stores/README.md +0 -74
  63. universal_mcp/tools/README.md +0 -86
  64. universal_mcp-0.1.23rc2.dist-info/METADATA +0 -283
  65. universal_mcp-0.1.23rc2.dist-info/RECORD +0 -51
  66. /universal_mcp/{utils → tools}/docstring_parser.py +0 -0
  67. {universal_mcp-0.1.23rc2.dist-info → universal_mcp-0.1.24rc3.dist-info}/WHEEL +0 -0
  68. {universal_mcp-0.1.23rc2.dist-info → universal_mcp-0.1.24rc3.dist-info}/entry_points.txt +0 -0
  69. {universal_mcp-0.1.23rc2.dist-info → universal_mcp-0.1.24rc3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,669 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+
5
+ import litellm
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.prompt import Confirm, Prompt
10
+ from rich.status import Status
11
+ from rich.syntax import Syntax
12
+
13
+ console = Console()
14
+ app = typer.Typer(name="codegen")
15
+
16
+
17
+ def _validate_filter_config(filter_config: Path | None) -> None:
18
+ """
19
+ Validate filter configuration file if provided.
20
+
21
+ Args:
22
+ filter_config: Path to filter config file or None
23
+
24
+ Raises:
25
+ typer.Exit: If validation fails
26
+ """
27
+ if filter_config is None:
28
+ return
29
+
30
+ # Handle edge case of empty string or invalid path
31
+ if str(filter_config).strip() == "":
32
+ console.print("[red]Error: Filter configuration path cannot be empty[/red]")
33
+ raise typer.Exit(1)
34
+
35
+ if not filter_config.exists():
36
+ console.print(f"[red]Error: Filter configuration file '{filter_config}' does not exist[/red]")
37
+ raise typer.Exit(1)
38
+
39
+ if not filter_config.is_file():
40
+ console.print(f"[red]Error: Filter configuration path '{filter_config}' is not a file[/red]")
41
+ raise typer.Exit(1)
42
+
43
+
44
+ def _display_selective_mode_info(filter_config: Path | None, mode_name: str) -> None:
45
+ """Display selective processing mode information if filter config is provided."""
46
+ if filter_config:
47
+ console.print(f"[bold cyan]Selective {mode_name} Mode Enabled[/bold cyan]")
48
+ console.print(f"[cyan]Filter configuration: {filter_config}[/cyan]")
49
+ console.print()
50
+
51
+
52
+ def _model_callback(model: str) -> str:
53
+ """
54
+ Validates the model and checks if the required API key is set.
55
+ This callback is now silent on success.
56
+ """
57
+ if model is not None:
58
+ api_key_env_var = None
59
+ if "claude" in model:
60
+ api_key_env_var = "ANTHROPIC_API_KEY"
61
+ elif "gpt" in model:
62
+ api_key_env_var = "OPENAI_API_KEY"
63
+ elif "gemini" in model:
64
+ api_key_env_var = "GEMINI_API_KEY"
65
+ elif "perplexity" in model:
66
+ api_key_env_var = "PERPLEXITYAI_API_KEY"
67
+
68
+ if api_key_env_var and not os.getenv(api_key_env_var):
69
+ error_message = (
70
+ f"Environment variable '{api_key_env_var}' is not set. Please set it to use the '{model}' model."
71
+ )
72
+ raise typer.BadParameter(error_message)
73
+ elif not api_key_env_var:
74
+ pass
75
+
76
+ return model
77
+
78
+
79
+ @app.command()
80
+ def generate(
81
+ schema_path: Path = typer.Option(..., "--schema", "-s"),
82
+ output_path: Path = typer.Option(
83
+ None,
84
+ "--output",
85
+ "-o",
86
+ help="Output file path - should match the API name (e.g., 'twitter.py' for Twitter API)",
87
+ ),
88
+ class_name: str = typer.Option(
89
+ None,
90
+ "--class-name",
91
+ "-c",
92
+ help="Class name to use for the API client",
93
+ ),
94
+ filter_config: Path = typer.Option(
95
+ None,
96
+ "--filter-config",
97
+ "-f",
98
+ help="Path to JSON filter configuration file for selective API client generation.",
99
+ ),
100
+ ):
101
+ """Generates Python client code from an OpenAPI (Swagger) schema.
102
+
103
+ This command automates the creation of an API client class, including
104
+ methods for each API operation defined in the schema. The generated
105
+ code is designed to integrate with the Universal MCP application framework.
106
+
107
+ It's recommended that the output filename (if specified via -o) matches
108
+ the API's service name (e.g., 'twitter.py' for a Twitter API client)
109
+ as this convention is used for organizing applications within U-MCP.
110
+ If no output path is provided, the generated code will be printed to the console.
111
+
112
+ Selective Generation:
113
+ Use --filter-config to specify which API operations to generate methods for.
114
+ The JSON configuration format is:
115
+ {
116
+ "/users/{user-id}/profile": "get",
117
+ "/users/{user-id}/settings": "all",
118
+ "/orders": ["get", "post"]
119
+ }
120
+ """
121
+ # Import here to avoid circular imports
122
+ from universal_mcp.utils.openapi.api_generator import generate_api_from_schema
123
+
124
+ if not schema_path.exists():
125
+ console.print(f"[red]Error: Schema file {schema_path} does not exist[/red]")
126
+ raise typer.Exit(1)
127
+
128
+ # Validate filter config and display info
129
+ _validate_filter_config(filter_config)
130
+ _display_selective_mode_info(filter_config, "API Client Generation")
131
+
132
+ try:
133
+ app_file_data = generate_api_from_schema(
134
+ schema_path=schema_path,
135
+ output_path=output_path,
136
+ class_name=class_name,
137
+ filter_config_path=str(filter_config) if filter_config else None,
138
+ )
139
+ if isinstance(app_file_data, dict) and "code" in app_file_data:
140
+ console.print("[yellow]No output path specified, printing generated code to console:[/yellow]")
141
+ console.print(app_file_data["code"])
142
+ if "schemas_code" in app_file_data:
143
+ console.print("[yellow]Generated schemas code:[/yellow]")
144
+ console.print(app_file_data["schemas_code"])
145
+ elif isinstance(app_file_data, tuple) and len(app_file_data) == 2:
146
+ app_file, schemas_file = app_file_data
147
+ console.print("[green]API client successfully generated and installed.[/green]")
148
+ console.print(f"[blue]Application file: {app_file}[/blue]")
149
+ console.print(f"[blue]Schemas file: {schemas_file}[/blue]")
150
+ elif isinstance(app_file_data, Path):
151
+ # Legacy support for single path return
152
+ console.print("[green]API client successfully generated and installed.[/green]")
153
+ console.print(f"[blue]Application file: {app_file_data}[/blue]")
154
+ else:
155
+ # Handle the error case from api_generator if validation fails
156
+ if isinstance(app_file_data, dict) and "error" in app_file_data:
157
+ console.print(f"[red]{app_file_data['error']}[/red]")
158
+ raise typer.Exit(1)
159
+ else:
160
+ console.print("[red]Unexpected return value from API generator.[/red]")
161
+ raise typer.Exit(1)
162
+
163
+ except Exception as e:
164
+ console.print(f"[red]Error generating API client: {e}[/red]")
165
+ raise typer.Exit(1) from e
166
+
167
+
168
+ @app.command()
169
+ def readme(
170
+ file_path: Path = typer.Argument(..., help="Path to the Python file to process"),
171
+ ):
172
+ """Generate a README.md file for the API client."""
173
+ from universal_mcp.utils.openapi.readme import generate_readme
174
+
175
+ readme_file = generate_readme(file_path)
176
+ console.print(f"[green]README.md file generated at: {readme_file}[/green]")
177
+
178
+
179
+ @app.command()
180
+ def docgen(
181
+ file_path: Path = typer.Argument(..., help="Path to the Python file to process"),
182
+ model: str = typer.Option(
183
+ "perplexity/sonar",
184
+ "--model",
185
+ "-m",
186
+ help="Model to use for generating docstrings",
187
+ ),
188
+ ):
189
+ """Generate docstrings for Python files using LLMs.
190
+
191
+ This command uses litellm with structured output to generate high-quality
192
+ Google-style docstrings for all functions in the specified Python file.
193
+ """
194
+ from universal_mcp.utils.openapi.docgen import process_file
195
+
196
+ if not file_path.exists():
197
+ console.print(f"[red]Error: File not found: {file_path}[/red]")
198
+ raise typer.Exit(1)
199
+
200
+ try:
201
+ processed = process_file(str(file_path), model)
202
+ console.print(f"[green]Successfully processed {processed} functions[/green]")
203
+ except Exception as e:
204
+ console.print(f"[red]Error: {e}[/red]")
205
+ raise typer.Exit(1) from e
206
+
207
+
208
+ @app.command()
209
+ def generate_from_llm(
210
+ output_dir: Path = typer.Option(
211
+ None,
212
+ "--output-dir",
213
+ "-o",
214
+ help="Directory to save 'app.py'. If omitted, you will be prompted.",
215
+ resolve_path=True,
216
+ ),
217
+ model: str = typer.Option(
218
+ None,
219
+ "--model",
220
+ "-m",
221
+ help="The LLM model to use for generation. If omitted, you will be prompted.",
222
+ callback=_model_callback,
223
+ ),
224
+ ):
225
+ """
226
+ Generates an 'app.py' file from a natural language prompt using an LLM.
227
+ """
228
+ from universal_mcp.utils.prompts import APP_GENERATOR_SYSTEM_PROMPT
229
+
230
+ console.print(
231
+ Panel(
232
+ "🚀 [bold]Welcome to the Universal App Generator[/bold] 🚀",
233
+ title="[bold blue]MCP[/bold blue]",
234
+ border_style="blue",
235
+ expand=False,
236
+ )
237
+ )
238
+
239
+ if output_dir is None:
240
+ dir_str = Prompt.ask("[cyan]Enter the directory to save 'app.py'[/cyan]", default=str(Path.cwd()))
241
+ output_dir = Path(dir_str).resolve()
242
+
243
+ if model is None:
244
+ model_str = Prompt.ask("[cyan]Enter the LLM model to use[/cyan]", default="perplexity/sonar")
245
+ model = _model_callback(model_str)
246
+
247
+ prompt_guidance = (
248
+ "You can provide the application description (the prompt) in two ways:\n\n"
249
+ "1. [bold green]From a Text File (Recommended)[/bold green]\n"
250
+ " - Ideal for longer, detailed, or multi-line prompts.\n"
251
+ " - Allows you to easily copy, paste, and edit your prompt in a text editor.\n\n"
252
+ "2. [bold yellow]Directly in the Terminal[/bold yellow]\n"
253
+ " - Suitable for short, simple, single-line test prompts.\n"
254
+ " - Not recommended for complex applications as multi-line input is difficult."
255
+ )
256
+ console.print(
257
+ Panel(prompt_guidance, title="[bold]How to Provide Your Prompt[/bold]", border_style="blue", padding=(1, 2))
258
+ )
259
+
260
+ use_file = Confirm.ask("\n[bold]Would you like to provide the prompt from a text file?[/bold]", default=True)
261
+
262
+ prompt = ""
263
+ if use_file:
264
+ prompt_file = Prompt.ask("[cyan]Please enter the path to your prompt .txt file[/cyan]")
265
+ try:
266
+ prompt_path = Path(prompt_file).resolve()
267
+ if not prompt_path.exists() or not prompt_path.is_file():
268
+ console.print(f"[bold red]❌ File not found at: {prompt_path}[/bold red]")
269
+ raise typer.Exit(code=1) from None
270
+ prompt = prompt_path.read_text(encoding="utf-8")
271
+ except Exception as e:
272
+ console.print(f"[bold red]❌ Failed to read prompt file: {e}[/bold red]")
273
+ raise typer.Exit(code=1) from e
274
+ else:
275
+ prompt = Prompt.ask("[cyan]Please describe the application you want to build[/cyan]")
276
+ if not prompt.strip():
277
+ console.print("[bold red]❌ Prompt cannot be empty. Aborting.[/bold red]")
278
+ raise typer.Exit(code=1)
279
+
280
+ PROMPT_DISPLAY_LIMIT = 400
281
+ prompt_for_display = prompt
282
+
283
+ if len(prompt) > PROMPT_DISPLAY_LIMIT:
284
+ total_lines = len(prompt.splitlines())
285
+ total_chars = len(prompt)
286
+
287
+ prompt_for_display = (
288
+ f"{prompt[:PROMPT_DISPLAY_LIMIT]}...\n\n"
289
+ f"[italic grey50](Prompt truncated for display. "
290
+ f"Full prompt is {total_lines} lines, {total_chars} characters)"
291
+ f"[/italic]"
292
+ )
293
+
294
+ config_summary = (
295
+ f"[bold blue]📝 Using Prompt:[/bold blue]\n[grey70]{prompt_for_display}[/grey70]\n\n"
296
+ f"[bold blue]🤖 Using Model:[/bold blue] [cyan]{model}[/cyan]\n"
297
+ f"[bold blue]💾 Output Directory:[/bold blue] [cyan]{output_dir}[/cyan]"
298
+ )
299
+ console.print(
300
+ Panel(config_summary, title="[bold green]Configuration[/bold green]", border_style="green", padding=(1, 2))
301
+ )
302
+
303
+ try:
304
+ if not output_dir.exists():
305
+ output_dir.mkdir(parents=True, exist_ok=True)
306
+ except Exception as e:
307
+ console.print(f"[red]❌ Failed to create output directory '{output_dir}': {e}[/red]")
308
+ raise typer.Exit(code=1) from e
309
+
310
+ output_file_path = output_dir / "app.py"
311
+ console.print(f"\n[green]Will save generated file to:[/green] [cyan]{output_file_path}[/cyan]\n")
312
+
313
+ messages = [
314
+ {"role": "system", "content": APP_GENERATOR_SYSTEM_PROMPT},
315
+ {"role": "user", "content": prompt},
316
+ ]
317
+
318
+ response = None
319
+ with Status("[bold green]Generating app code... (this may take a moment)[/bold green]", console=console) as status:
320
+ try:
321
+ response = litellm.completion(
322
+ model=model,
323
+ messages=messages,
324
+ temperature=0.1,
325
+ timeout=120,
326
+ )
327
+ except Exception as e:
328
+ status.update("[bold red]❌ An error occurred during LLM API call.[/bold red]")
329
+ console.print(Panel(f"Error: {e}", title="[bold red]API Error[/bold red]", border_style="red"))
330
+ raise typer.Exit(code=1) from e
331
+
332
+ if not response or not response.choices:
333
+ console.print("[bold red]❌ Failed to get a valid response from the LLM.[/bold red]")
334
+ raise typer.Exit(code=1)
335
+
336
+ generated_content = response.choices[0].message.content
337
+ code_match = re.search(r"```python\n(.*?)\n```", generated_content, re.DOTALL)
338
+ if code_match:
339
+ final_code = code_match.group(1).strip()
340
+ else:
341
+ console.print(
342
+ "[yellow]Warning: LLM response did not contain a markdown code block. Using the raw response.[/yellow]"
343
+ )
344
+ final_code = generated_content.strip()
345
+
346
+ if not final_code:
347
+ console.print("[bold red]❌ The LLM returned an empty code block. Aborting.[/bold red]")
348
+ raise typer.Exit(code=1)
349
+
350
+ CODE_DISPLAY_LINE_LIMIT = 200
351
+ code_for_display = final_code
352
+ is_truncated = False
353
+
354
+ code_lines = final_code.splitlines()
355
+ num_lines = len(code_lines)
356
+
357
+ if num_lines > CODE_DISPLAY_LINE_LIMIT:
358
+ is_truncated = True
359
+ code_for_display = "\n".join(code_lines[:CODE_DISPLAY_LINE_LIMIT])
360
+
361
+ console.print(
362
+ Panel(
363
+ Syntax(code_for_display, "python", theme="monokai", line_numbers=True),
364
+ title="[bold magenta]Generated Code Preview: app.py[/bold magenta]",
365
+ border_style="magenta",
366
+ subtitle=f"Total lines: {num_lines}",
367
+ )
368
+ )
369
+
370
+ if is_truncated:
371
+ console.print(
372
+ f"[italic yellow]... Output truncated. Showing the first {CODE_DISPLAY_LINE_LIMIT} of {num_lines} lines. "
373
+ f"The full code has been saved to the {output_file_path}.[/italic yellow]\n"
374
+ )
375
+
376
+ try:
377
+ output_file_path.write_text(final_code, encoding="utf-8")
378
+ console.print(
379
+ Panel(
380
+ f"✅ [bold green]Success![/bold green]\nApplication code saved to [cyan]{output_file_path}[/cyan]",
381
+ title="[bold green]Complete[/bold green]",
382
+ border_style="green",
383
+ )
384
+ )
385
+ except Exception as e:
386
+ console.print(
387
+ Panel(
388
+ f"Failed to write the generated code to file: {e}",
389
+ title="[bold red]File Error[/bold red]",
390
+ border_style="red",
391
+ )
392
+ )
393
+ raise typer.Exit(code=1) from e
394
+
395
+
396
+ @app.command()
397
+ def init(
398
+ output_dir: Path | None = typer.Option(
399
+ None,
400
+ "--output-dir",
401
+ "-o",
402
+ help="Output directory for the project (must exist)",
403
+ ),
404
+ app_name: str | None = typer.Option(
405
+ None,
406
+ "--app-name",
407
+ "-a",
408
+ help="App name (letters, numbers and hyphens only , underscores not allowed)",
409
+ ),
410
+ integration_type: str | None = typer.Option(
411
+ None,
412
+ "--integration-type",
413
+ "-i",
414
+ help="Integration type (api_key, oauth, agentr, none)",
415
+ case_sensitive=False,
416
+ show_choices=True,
417
+ ),
418
+ ):
419
+ """Initialize a new MCP project using the cookiecutter template."""
420
+ from cookiecutter.main import cookiecutter
421
+ from typer import confirm, prompt
422
+
423
+ from .cli import generate, generate_from_llm
424
+
425
+ NAME_PATTERN = r"^[a-zA-Z0-9-]+$"
426
+
427
+ def validate_app_name(value: str, field_name: str) -> None:
428
+ if not re.match(NAME_PATTERN, value):
429
+ console.print(f"[red]❌ Invalid {field_name}; only letters, numbers, hyphens allowed[/red]")
430
+ raise typer.Exit(code=1)
431
+
432
+ if not app_name:
433
+ app_name = typer.prompt(
434
+ "Enter the app name",
435
+ default="app_name",
436
+ prompt_suffix=" (e.g., reddit, youtube): ",
437
+ ).strip()
438
+
439
+ validate_app_name(app_name, "app name")
440
+
441
+ app_name = app_name.lower()
442
+ if not output_dir:
443
+ path_str = typer.prompt(
444
+ "Enter the output directory for the project",
445
+ default=str(Path.cwd()),
446
+ prompt_suffix=": ",
447
+ ).strip()
448
+ output_dir = Path(path_str)
449
+
450
+ if not output_dir.exists():
451
+ try:
452
+ output_dir.mkdir(parents=True, exist_ok=True)
453
+ console.print(f"[green]✅ Created output directory at '{output_dir}'[/green]")
454
+ except Exception as e:
455
+ console.print(f"[red]❌ Failed to create output directory '{output_dir}': {e}[/red]")
456
+ raise typer.Exit(code=1) from e
457
+ elif not output_dir.is_dir():
458
+ console.print(f"[red]❌ Output path '{output_dir}' exists but is not a directory.[/red]")
459
+ raise typer.Exit(code=1)
460
+
461
+ if not integration_type:
462
+ integration_type = typer.prompt(
463
+ "Choose the integration type",
464
+ default="agentr",
465
+ prompt_suffix=" (api_key, oauth, agentr, none): ",
466
+ ).lower()
467
+ if integration_type not in ("api_key", "oauth", "agentr", "none"):
468
+ console.print("[red]❌ Integration type must be one of: api_key, oauth, agentr, none[/red]")
469
+ raise typer.Exit(code=1)
470
+
471
+ console.print("[blue]🚀 Generating project using cookiecutter...[/blue]")
472
+ try:
473
+ cookiecutter(
474
+ "https://github.com/AgentrDev/universal-mcp-app-template.git",
475
+ output_dir=str(output_dir),
476
+ no_input=True,
477
+ extra_context={
478
+ "app_name": app_name,
479
+ "integration_type": integration_type,
480
+ },
481
+ )
482
+ except Exception as exc:
483
+ console.print(f"❌ Project generation failed: {exc}")
484
+ raise typer.Exit(code=1) from exc
485
+
486
+ project_dir = output_dir / f"{app_name}"
487
+ console.print(f"✅ Project created at {project_dir}")
488
+
489
+ generate_client = confirm("Do you want to also generate api_client ?")
490
+
491
+ if not generate_client:
492
+ console.print("[yellow]Skipping API client generation.[/yellow]")
493
+ return
494
+
495
+ has_openapi_spec = confirm("Do you have openapi spec for the application just created ?")
496
+
497
+ app_dir = app_name.lower().replace("-", "_")
498
+ target_app_dir = project_dir / "src" / f"universal_mcp_{app_dir}"
499
+
500
+ try:
501
+ target_app_dir.mkdir(parents=True, exist_ok=True)
502
+ except Exception as e:
503
+ console.print(f"[red]❌ Failed to create target app directory '{target_app_dir}': {e}[/red]")
504
+ raise typer.Exit(code=1) from e
505
+
506
+ if has_openapi_spec:
507
+ schema_path_str = prompt("Enter the path to the OpenAPI schema file (JSON or YAML)")
508
+ schema_path = Path(schema_path_str)
509
+
510
+ if not schema_path.exists():
511
+ console.print(f"[red]Error: Schema file {schema_path} does not exist[/red]")
512
+ raise typer.Exit(1)
513
+
514
+ class_name = "".join([part.title() for part in app_name.split("-")]) + "App"
515
+
516
+ try:
517
+ console.print("\n[bold blue]Calling 'codegen generate' with provided schema...[/bold blue]")
518
+ generate(schema_path=schema_path, output_path=target_app_dir, class_name=class_name)
519
+
520
+ except typer.Exit as e:
521
+ console.print(f"[red]API client generation from schema failed. Exit code: {e.exit_code}[/red]")
522
+ raise
523
+ except Exception as e:
524
+ console.print(f"[red]An unexpected error occurred during API client generation from schema: {e}[/red]")
525
+ raise typer.Exit(code=1) from e
526
+
527
+ else:
528
+ try:
529
+ llm_model = prompt("Enter the LLM model to use", default="perplexity/sonar")
530
+ try:
531
+ _model_callback(llm_model)
532
+ except typer.BadParameter as e:
533
+ console.print(f"[red]Validation Error: {e}[/red]")
534
+ raise typer.Exit(code=1) from e
535
+
536
+ llm_prompt_text = prompt("Describe the application and its tools (natural language)")
537
+
538
+ console.print("\n[bold blue]Calling 'codegen generate-from-llm'...[/bold blue]")
539
+ generate_from_llm(output_dir=target_app_dir, model=llm_model, prompt=llm_prompt_text)
540
+
541
+ except typer.Exit as e:
542
+ console.print(f"[red]API client generation from LLM failed. Exit code: {e.exit_code}[/red]")
543
+ raise
544
+ except Exception as e:
545
+ console.print(f"[red]An unexpected error occurred during API client generation from LLM: {e}[/red]")
546
+ raise typer.Exit(code=1) from e
547
+
548
+
549
+ @app.command()
550
+ def preprocess(
551
+ schema_path: Path = typer.Option(
552
+ None, "--schema", "-s", help="Path to the input OpenAPI schema file (JSON or YAML)."
553
+ ),
554
+ output_path: Path = typer.Option(
555
+ None, "--output", "-o", help="Path to save the processed (enhanced) OpenAPI schema file."
556
+ ),
557
+ filter_config: Path = typer.Option(
558
+ None, "--filter-config", "-f", help="Path to JSON filter configuration file for selective processing."
559
+ ),
560
+ ):
561
+ """Enhances an OpenAPI schema's descriptions using an LLM.
562
+
563
+ This command takes an existing OpenAPI schema and uses a Large Language
564
+ Model (LLM) to automatically generate or improve the descriptions for
565
+ API paths, operations, parameters, and schemas. This is particularly
566
+ helpful for schemas that are auto-generated or lack comprehensive
567
+ human-written documentation, making the schema more understandable and
568
+ usable for client generation or manual review.
569
+
570
+ Use --filter-config to process only specific paths and methods defined
571
+ in a JSON configuration file. Format:
572
+ {
573
+ "/users/{user-id}/profile": "get",
574
+ "/users/{user-id}/settings": "all",
575
+ "/orders/{order-id}": ["get", "put", "delete"]
576
+ }
577
+ """
578
+ from universal_mcp.utils.openapi.preprocessor import run_preprocessing
579
+
580
+ # Validate filter config and display info
581
+ _validate_filter_config(filter_config)
582
+ _display_selective_mode_info(filter_config, "Processing")
583
+
584
+ run_preprocessing(
585
+ schema_path=schema_path,
586
+ output_path=output_path,
587
+ filter_config_path=str(filter_config) if filter_config else None,
588
+ )
589
+
590
+
591
+ @app.command()
592
+ def split_api(
593
+ input_app_file: Path = typer.Argument(..., help="Path to the generated app.py file to split"),
594
+ output_dir: Path = typer.Option(..., "--output-dir", "-o", help="Directory to save the split files"),
595
+ package_name: str = typer.Option(
596
+ None, "--package-name", "-p", help="Package name for absolute imports (e.g., 'hubspot')"
597
+ ),
598
+ ):
599
+ """Splits a single generated API client file into multiple files based on path groups."""
600
+ from universal_mcp.utils.openapi.api_splitter import split_generated_app_file
601
+
602
+ if not input_app_file.exists() or not input_app_file.is_file():
603
+ console.print(f"[red]Error: Input file {input_app_file} does not exist or is not a file.[/red]")
604
+ raise typer.Exit(1)
605
+
606
+ if not output_dir.exists():
607
+ output_dir.mkdir(parents=True, exist_ok=True)
608
+ console.print(f"[green]Created output directory: {output_dir}[/green]")
609
+ elif not output_dir.is_dir():
610
+ console.print(f"[red]Error: Output path {output_dir} is not a directory.[/red]")
611
+ raise typer.Exit(1)
612
+
613
+ try:
614
+ split_generated_app_file(input_app_file, output_dir, package_name)
615
+ console.print(f"[green]Successfully split {input_app_file} into {output_dir}[/green]")
616
+ except Exception as e:
617
+ console.print(f"[red]Error splitting API client: {e}[/red]")
618
+
619
+ raise typer.Exit(1) from e
620
+
621
+
622
+ @app.command()
623
+ def generate_tests(
624
+ app_name: str = typer.Argument(..., help="Name of the app (e.g., 'outlook')"),
625
+ class_name: str = typer.Argument(..., help="Name of the app class (e.g., 'OutlookApp')"),
626
+ output_dir: str = typer.Option("tests", "--output", "-o", help="Output directory for the test file"),
627
+ ):
628
+ """Generate automated test cases for an app"""
629
+ from universal_mcp.utils.openapi.test_generator import generate_test_cases
630
+
631
+ console.print(f"[blue]Generating test cases for {app_name} ({class_name})...[/blue]")
632
+
633
+ try:
634
+ response = generate_test_cases(app_name, class_name, output_dir)
635
+ console.print(f"[green]✅ Successfully generated {len(response.test_cases)} test cases![/green]")
636
+ except ImportError as e:
637
+ console.print(f"[red]Import Error: {e}[/red]")
638
+ console.print(f"[yellow]Make sure the module 'universal_mcp_{app_name}' is installed and available.[/yellow]")
639
+ raise typer.Exit(1) from e
640
+ except AttributeError as e:
641
+ console.print(f"[red]Attribute Error: {e}[/red]")
642
+ console.print(f"[yellow]Make sure the class '{class_name}' exists in 'universal_mcp_{app_name}.app'.[/yellow]")
643
+ raise typer.Exit(1) from e
644
+ except Exception as e:
645
+ console.print(f"[red]Error generating test cases: {e}[/red]")
646
+ raise typer.Exit(1) from e
647
+
648
+
649
+ @app.command()
650
+ def postprocess(
651
+ input_file: Path = typer.Argument(..., help="Path to the input API client Python file"),
652
+ output_file: Path = typer.Argument(..., help="Path to save the postprocessed API client Python file"),
653
+ ):
654
+ """Postprocess API client: add hint tags to docstrings based on HTTP method."""
655
+ from universal_mcp.utils.openapi.postprocessor import add_hint_tags_to_docstrings
656
+
657
+ if not input_file.exists() or not input_file.is_file():
658
+ console.print(f"[red]Error: Input file {input_file} does not exist or is not a file.[/red]")
659
+ raise typer.Exit(1)
660
+ try:
661
+ add_hint_tags_to_docstrings(str(input_file), str(output_file))
662
+ console.print(f"[green]Successfully postprocessed {input_file} and saved to {output_file}[/green]")
663
+ except Exception as e:
664
+ console.print(f"[red]Error during postprocessing: {e}[/red]")
665
+ raise typer.Exit(1) from e
666
+
667
+
668
+ if __name__ == "__main__":
669
+ app()