vulcai-mcp-forge-cli 0.1.0__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.
- mcp_forge/__init__.py +107 -0
- mcp_forge/cli.py +473 -0
- mcp_forge/config_loader.py +60 -0
- mcp_forge/discovery/__init__.py +34 -0
- mcp_forge/discovery/cli_app.py +294 -0
- mcp_forge/discovery/codebase/__init__.py +237 -0
- mcp_forge/discovery/codebase/bash.py +179 -0
- mcp_forge/discovery/codebase/cobol.py +243 -0
- mcp_forge/discovery/codebase/cpp.py +329 -0
- mcp_forge/discovery/codebase/csharp.py +243 -0
- mcp_forge/discovery/codebase/dart.py +296 -0
- mcp_forge/discovery/codebase/elixir.py +210 -0
- mcp_forge/discovery/codebase/fortran.py +222 -0
- mcp_forge/discovery/codebase/go.py +242 -0
- mcp_forge/discovery/codebase/groovy.py +255 -0
- mcp_forge/discovery/codebase/java.py +237 -0
- mcp_forge/discovery/codebase/js.py +93 -0
- mcp_forge/discovery/codebase/julia.py +267 -0
- mcp_forge/discovery/codebase/kotlin.py +259 -0
- mcp_forge/discovery/codebase/lua.py +94 -0
- mcp_forge/discovery/codebase/php.py +239 -0
- mcp_forge/discovery/codebase/powershell.py +237 -0
- mcp_forge/discovery/codebase/python.py +189 -0
- mcp_forge/discovery/codebase/r_lang.py +206 -0
- mcp_forge/discovery/codebase/ruby.py +182 -0
- mcp_forge/discovery/codebase/rust.py +272 -0
- mcp_forge/discovery/codebase/scala.py +250 -0
- mcp_forge/discovery/codebase/swift.py +272 -0
- mcp_forge/discovery/codebase/typescript.py +268 -0
- mcp_forge/discovery/graphql.py +241 -0
- mcp_forge/discovery/openapi.py +370 -0
- mcp_forge/discovery/web.py +486 -0
- mcp_forge/models.py +123 -0
- mcp_forge/pipeline/__init__.py +0 -0
- mcp_forge/pipeline/local.py +35 -0
- mcp_forge/pipeline/remote.py +101 -0
- mcp_forge/pipeline/selector.py +34 -0
- mcp_forge/schemas/__init__.py +0 -0
- mcp_forge/schemas/payload.py +146 -0
- mcp_forge/ui/__init__.py +0 -0
- mcp_forge/ui/auto.py +107 -0
- mcp_forge/ui/guided.py +362 -0
- mcp_forge/ui/semi.py +285 -0
- mcp_forge/validation/__init__.py +3 -0
- mcp_forge/validation/functional_test.py +384 -0
- mcp_forge/validation/smoke_test.py +180 -0
- mcp_forge/validation/validator.py +109 -0
- vulcai_mcp_forge_cli-0.1.0.dist-info/METADATA +271 -0
- vulcai_mcp_forge_cli-0.1.0.dist-info/RECORD +51 -0
- vulcai_mcp_forge_cli-0.1.0.dist-info/WHEEL +4 -0
- vulcai_mcp_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
mcp_forge/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mcp-forge — Public Python API
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
from mcp_forge import generate, run_smoke_test, run_functional_test
|
|
7
|
+
from mcp_forge.models import ForgeConfig, ForgeMode, SourceType, NameStyle
|
|
8
|
+
|
|
9
|
+
config = ForgeConfig(
|
|
10
|
+
source="https://petstore3.swagger.io/api/v3/openapi.json",
|
|
11
|
+
mode=ForgeMode.AUTO,
|
|
12
|
+
output="../mcp-forge-output",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
generated = generate(config)
|
|
16
|
+
print(f"Server written to: {generated.output_dir}")
|
|
17
|
+
print(f"Tools: {generated.tools_count}")
|
|
18
|
+
|
|
19
|
+
result = run_functional_test(generated.output_dir)
|
|
20
|
+
print(result.summary())
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from mcp_forge.models import (
|
|
25
|
+
DiscoveredTool,
|
|
26
|
+
DiscoveryResult,
|
|
27
|
+
ForgeConfig,
|
|
28
|
+
ForgeMode,
|
|
29
|
+
GeneratedServer,
|
|
30
|
+
NameStyle,
|
|
31
|
+
ParameterType,
|
|
32
|
+
SourceType,
|
|
33
|
+
ToolParameter,
|
|
34
|
+
)
|
|
35
|
+
from mcp_forge.validation.smoke_test import SmokeTestResult, run_smoke_test
|
|
36
|
+
from mcp_forge.validation.functional_test import (
|
|
37
|
+
FunctionalTestResult,
|
|
38
|
+
ToolCallResult,
|
|
39
|
+
run_functional_test,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from importlib.metadata import version as _v
|
|
44
|
+
__version__ = _v("mcp-forge")
|
|
45
|
+
except Exception:
|
|
46
|
+
__version__ = "0.1.0"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"generate",
|
|
50
|
+
"ForgeConfig",
|
|
51
|
+
"ForgeMode",
|
|
52
|
+
"SourceType",
|
|
53
|
+
"NameStyle",
|
|
54
|
+
"ParameterType",
|
|
55
|
+
"DiscoveredTool",
|
|
56
|
+
"ToolParameter",
|
|
57
|
+
"DiscoveryResult",
|
|
58
|
+
"GeneratedServer",
|
|
59
|
+
"run_smoke_test",
|
|
60
|
+
"SmokeTestResult",
|
|
61
|
+
"run_functional_test",
|
|
62
|
+
"FunctionalTestResult",
|
|
63
|
+
"ToolCallResult",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def generate(
|
|
68
|
+
config: ForgeConfig,
|
|
69
|
+
*,
|
|
70
|
+
enrich: bool = True,
|
|
71
|
+
) -> GeneratedServer:
|
|
72
|
+
"""
|
|
73
|
+
Generate a MCP server from a source.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
config: ForgeConfig describing the source and output options.
|
|
77
|
+
enrich: Use LLM to improve tool names/descriptions (requires ANTHROPIC_API_KEY).
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
GeneratedServer with output_dir, tools_count and generated file contents.
|
|
81
|
+
|
|
82
|
+
Example::
|
|
83
|
+
|
|
84
|
+
from mcp_forge import generate
|
|
85
|
+
from mcp_forge.models import ForgeConfig, ForgeMode
|
|
86
|
+
|
|
87
|
+
config = ForgeConfig(
|
|
88
|
+
source="https://petstore3.swagger.io/api/v3/openapi.json",
|
|
89
|
+
mode=ForgeMode.AUTO,
|
|
90
|
+
output="../mcp-forge-output",
|
|
91
|
+
)
|
|
92
|
+
server = generate(config, enrich=False)
|
|
93
|
+
print(server.output_dir)
|
|
94
|
+
"""
|
|
95
|
+
from mcp_forge.discovery import discover
|
|
96
|
+
from mcp_forge.generation.generator import MCPGenerator
|
|
97
|
+
|
|
98
|
+
result = discover(config)
|
|
99
|
+
|
|
100
|
+
if enrich and not getattr(config, "no_llm", False):
|
|
101
|
+
try:
|
|
102
|
+
from mcp_forge.enrichment.llm import LLMEnricher
|
|
103
|
+
result = LLMEnricher(config).enrich(result)
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
return MCPGenerator(config).generate(result)
|
mcp_forge/cli.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mcp-forge — Point d'entrée CLI principal.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
mcp-forge run <source> [options]
|
|
6
|
+
mcp-forge --help
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Optional
|
|
13
|
+
|
|
14
|
+
# Dossier output par défaut : dans le répertoire courant de l'utilisateur
|
|
15
|
+
# (Path(__file__) pointe vers site-packages après pip install — on utilise cwd)
|
|
16
|
+
_DEFAULT_OUTPUT = str(Path.cwd().parent / "mcp-forge-output")
|
|
17
|
+
|
|
18
|
+
from dotenv import load_dotenv
|
|
19
|
+
load_dotenv() # Charge automatiquement .env si présent
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from rich import print as rprint
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
|
|
26
|
+
from mcp_forge.config_loader import load_yaml_config
|
|
27
|
+
from mcp_forge.models import ForgeConfig, ForgeMode, NameStyle, SourceType
|
|
28
|
+
|
|
29
|
+
def _version_callback(value: bool) -> None:
|
|
30
|
+
if value:
|
|
31
|
+
from mcp_forge import __version__
|
|
32
|
+
typer.echo(f"mcp-forge {__version__}")
|
|
33
|
+
raise typer.Exit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(
|
|
37
|
+
name="mcp-forge",
|
|
38
|
+
help="Génère automatiquement un serveur MCP à partir d'une application ou d'un site web.",
|
|
39
|
+
add_completion=False,
|
|
40
|
+
rich_markup_mode="rich",
|
|
41
|
+
)
|
|
42
|
+
console = Console()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback()
|
|
46
|
+
def _main(
|
|
47
|
+
version: Annotated[Optional[bool], typer.Option(
|
|
48
|
+
"--version", "-V", callback=_version_callback, is_eager=True, help="Afficher la version et quitter"
|
|
49
|
+
)] = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
# Typer callback — gestion de --version uniquement, aucune logique supplémentaire.
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _detect_source_type(source: str) -> SourceType:
|
|
56
|
+
"""Détecte automatiquement le type de source."""
|
|
57
|
+
s = source.lower()
|
|
58
|
+
if s.endswith((".json", ".yaml", ".yml")):
|
|
59
|
+
return SourceType.OPENAPI
|
|
60
|
+
if "graphql" in s or s.endswith("/graphql"):
|
|
61
|
+
return SourceType.GRAPHQL
|
|
62
|
+
if s.startswith("http://") or s.startswith("https://"):
|
|
63
|
+
return SourceType.WEBSITE
|
|
64
|
+
if Path(source).is_dir():
|
|
65
|
+
return SourceType.CODEBASE
|
|
66
|
+
return SourceType.CLI_APP
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def run(
|
|
71
|
+
source: Annotated[Optional[str], typer.Argument(help="URL, fichier OpenAPI, commande CLI ou dossier de code")] = None,
|
|
72
|
+
mode: Annotated[Optional[ForgeMode], typer.Option("--mode", "-m", help="Mode de génération")] = None,
|
|
73
|
+
output: Annotated[Optional[str], typer.Option("--output", "-o", help="Dossier de sortie")] = None,
|
|
74
|
+
source_type: Annotated[Optional[SourceType], typer.Option("--type", "-t", help="Forcer le type de source")] = None,
|
|
75
|
+
name: Annotated[Optional[str], typer.Option("--name", "-n", help="Nom du serveur MCP généré")] = None,
|
|
76
|
+
no_llm: Annotated[Optional[bool], typer.Option("--no-llm", help="Désactiver l'enrichissement par LLM")] = None,
|
|
77
|
+
api_key: Annotated[Optional[str], typer.Option("--api-key", help="Clé API Anthropic (ou via ANTHROPIC_API_KEY)")] = None,
|
|
78
|
+
name_style: Annotated[Optional[NameStyle], typer.Option("--name-style", "-ns", help="Style de nommage : short ou exhaustive")] = None,
|
|
79
|
+
config_file: Annotated[Optional[str], typer.Option("--config", "-c", help="Fichier de configuration YAML (défaut : mcp-forge.yaml si présent)")] = None,
|
|
80
|
+
smoke_test: Annotated[bool, typer.Option("--smoke-test", help="Lancer le smoke test après génération")] = False,
|
|
81
|
+
functional_test: Annotated[bool, typer.Option("--functional-test", help="Lancer le test fonctionnel MCP après génération (démarre le serveur)")] = False,
|
|
82
|
+
local: Annotated[bool, typer.Option("--local", help="Forcer le pipeline local (pas d'appel à l'API Vulcai)")] = False,
|
|
83
|
+
):
|
|
84
|
+
"""
|
|
85
|
+
Lance la génération d'un serveur MCP depuis une [bold]source[/bold].
|
|
86
|
+
|
|
87
|
+
Exemples :
|
|
88
|
+
mcp-forge run https://api.example.com/openapi.json
|
|
89
|
+
mcp-forge run https://example.com --mode semi
|
|
90
|
+
mcp-forge run my-cli-app --mode guided
|
|
91
|
+
"""
|
|
92
|
+
console.print(Panel.fit(
|
|
93
|
+
"[bold blue]mcp-forge[/bold blue] — Générateur de serveurs MCP",
|
|
94
|
+
border_style="blue",
|
|
95
|
+
))
|
|
96
|
+
|
|
97
|
+
yaml_cfg = _load_yaml(config_file)
|
|
98
|
+
source, mode, output, source_type, name, no_llm, name_style, smoke_test, functional_test = \
|
|
99
|
+
_merge_options(yaml_cfg, source, mode, output, source_type, name, no_llm,
|
|
100
|
+
name_style, smoke_test, functional_test)
|
|
101
|
+
|
|
102
|
+
config, no_llm = _build_config(yaml_cfg, source, mode, output, source_type, name,
|
|
103
|
+
no_llm, name_style, api_key, force_local=local)
|
|
104
|
+
|
|
105
|
+
_print_summary(source, config.source_type, mode, output, no_llm, name_style)
|
|
106
|
+
|
|
107
|
+
generated = _dispatch(config, mode)
|
|
108
|
+
|
|
109
|
+
if generated is None or not generated.success:
|
|
110
|
+
raise typer.Exit(1)
|
|
111
|
+
|
|
112
|
+
if smoke_test:
|
|
113
|
+
_run_smoke_test_cmd(generated.output_dir)
|
|
114
|
+
if functional_test:
|
|
115
|
+
_run_functional_test_cmd(generated.output_dir)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Helpers internes — run()
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def _load_yaml(config_file: Optional[str]) -> dict:
|
|
123
|
+
try:
|
|
124
|
+
return load_yaml_config(config_file)
|
|
125
|
+
except FileNotFoundError as e:
|
|
126
|
+
console.print(f"[red][FAIL] {e}[/red]")
|
|
127
|
+
raise typer.Exit(1)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _merge_options(
|
|
131
|
+
yaml_cfg: dict,
|
|
132
|
+
source: Optional[str],
|
|
133
|
+
mode: Optional[ForgeMode],
|
|
134
|
+
output: Optional[str],
|
|
135
|
+
source_type: Optional[SourceType],
|
|
136
|
+
name: Optional[str],
|
|
137
|
+
no_llm: Optional[bool],
|
|
138
|
+
name_style: Optional[NameStyle],
|
|
139
|
+
smoke_test: bool,
|
|
140
|
+
functional_test: bool,
|
|
141
|
+
) -> tuple:
|
|
142
|
+
source = source or yaml_cfg.get("source")
|
|
143
|
+
if not source:
|
|
144
|
+
console.print("[red][FAIL] Source requise (argument ou champ 'source' dans mcp-forge.yaml)[/red]")
|
|
145
|
+
raise typer.Exit(1)
|
|
146
|
+
|
|
147
|
+
if not source.startswith("http://") and not source.startswith("https://"):
|
|
148
|
+
source = source.replace("\\", "/")
|
|
149
|
+
|
|
150
|
+
mode = mode or (ForgeMode(yaml_cfg["mode"]) if "mode" in yaml_cfg else ForgeMode.AUTO)
|
|
151
|
+
output = output or yaml_cfg.get("output", _DEFAULT_OUTPUT)
|
|
152
|
+
name = name or yaml_cfg.get("name")
|
|
153
|
+
no_llm = no_llm if no_llm is not None else yaml_cfg.get("no_llm", False)
|
|
154
|
+
smoke_test = smoke_test or yaml_cfg.get("smoke_test", False)
|
|
155
|
+
functional_test = functional_test or yaml_cfg.get("functional_test", False)
|
|
156
|
+
name_style = name_style or (NameStyle(yaml_cfg["name_style"]) if "name_style" in yaml_cfg else NameStyle.SHORT)
|
|
157
|
+
if source_type is None and "type" in yaml_cfg:
|
|
158
|
+
source_type = SourceType(yaml_cfg["type"])
|
|
159
|
+
|
|
160
|
+
return source, mode, output, source_type, name, no_llm, name_style, smoke_test, functional_test
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _build_config(
|
|
164
|
+
yaml_cfg: dict,
|
|
165
|
+
source: str,
|
|
166
|
+
mode: ForgeMode,
|
|
167
|
+
output: str,
|
|
168
|
+
source_type: Optional[SourceType],
|
|
169
|
+
name: Optional[str],
|
|
170
|
+
no_llm: bool,
|
|
171
|
+
name_style: NameStyle,
|
|
172
|
+
api_key: Optional[str],
|
|
173
|
+
force_local: bool = False,
|
|
174
|
+
) -> tuple:
|
|
175
|
+
resolved_type = source_type or _detect_source_type(source)
|
|
176
|
+
resolved_key = api_key or yaml_cfg.get("api_key") or os.environ.get("ANTHROPIC_API_KEY")
|
|
177
|
+
|
|
178
|
+
if not no_llm and not resolved_key and not os.environ.get("GROQ_API_KEY"):
|
|
179
|
+
console.print(
|
|
180
|
+
"[yellow]⚠ Aucune clé API trouvée.[/yellow] "
|
|
181
|
+
"L'enrichissement LLM sera désactivé. "
|
|
182
|
+
"Définissez ANTHROPIC_API_KEY ou GROQ_API_KEY dans votre .env."
|
|
183
|
+
)
|
|
184
|
+
no_llm = True
|
|
185
|
+
|
|
186
|
+
config = ForgeConfig(
|
|
187
|
+
mode=mode,
|
|
188
|
+
source=source,
|
|
189
|
+
source_type=resolved_type,
|
|
190
|
+
output_dir=output,
|
|
191
|
+
server_name=name or "",
|
|
192
|
+
use_llm_enrichment=not no_llm,
|
|
193
|
+
anthropic_api_key=resolved_key,
|
|
194
|
+
name_style=name_style,
|
|
195
|
+
force_local=force_local,
|
|
196
|
+
)
|
|
197
|
+
return config, no_llm
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _print_summary(
|
|
201
|
+
source: str, resolved_type: SourceType, mode: ForgeMode,
|
|
202
|
+
output: str, no_llm: bool, name_style: NameStyle,
|
|
203
|
+
) -> None:
|
|
204
|
+
console.print(f"[dim]Source :[/dim] {source}")
|
|
205
|
+
console.print(f"[dim]Type :[/dim] {resolved_type.value}")
|
|
206
|
+
console.print(f"[dim]Mode :[/dim] {mode.value}")
|
|
207
|
+
console.print(f"[dim]Sortie :[/dim] {output}")
|
|
208
|
+
console.print(f"[dim]Enrichiss. :[/dim] {'LLM (Claude)' if not no_llm else 'désactivé'}")
|
|
209
|
+
if not no_llm:
|
|
210
|
+
console.print(f"[dim]Nommage :[/dim] {name_style.value}")
|
|
211
|
+
console.print()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _dispatch(config: ForgeConfig, mode: ForgeMode):
|
|
215
|
+
if mode == ForgeMode.AUTO:
|
|
216
|
+
from mcp_forge.ui.auto import run_auto
|
|
217
|
+
return run_auto(config)
|
|
218
|
+
if mode == ForgeMode.SEMI:
|
|
219
|
+
from mcp_forge.ui.semi import run_semi
|
|
220
|
+
return run_semi(config)
|
|
221
|
+
if mode == ForgeMode.GUIDED:
|
|
222
|
+
from mcp_forge.ui.guided import run_guided
|
|
223
|
+
return run_guided(config)
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.command()
|
|
228
|
+
def test(
|
|
229
|
+
server: Annotated[str, typer.Argument(help="Chemin vers server.py (backslashes acceptés)")],
|
|
230
|
+
):
|
|
231
|
+
"""
|
|
232
|
+
Lance MCP Inspector sur un serveur généré.
|
|
233
|
+
|
|
234
|
+
Accepte les chemins Windows (backslashes) collés depuis l'Explorateur.
|
|
235
|
+
|
|
236
|
+
Exemples :
|
|
237
|
+
mcp-forge test output\\py_requests\\server.py
|
|
238
|
+
mcp-forge test output/py_requests/server.py
|
|
239
|
+
"""
|
|
240
|
+
import subprocess
|
|
241
|
+
normalized = server.replace("\\", "/")
|
|
242
|
+
server_path = Path(normalized)
|
|
243
|
+
if not server_path.exists():
|
|
244
|
+
# Essaie relatif au dossier mcp-forge
|
|
245
|
+
server_path = Path(__file__).parent / normalized
|
|
246
|
+
if not server_path.exists():
|
|
247
|
+
console.print(f"[red][FAIL] Fichier introuvable :[/red] {normalized}")
|
|
248
|
+
raise typer.Exit(1)
|
|
249
|
+
server_dir = server_path.resolve().parent
|
|
250
|
+
console.print(f"[dim]Lancement de MCP Inspector sur :[/dim] {server_dir / 'server.py'}")
|
|
251
|
+
# Installe les dépendances si requirements.txt présent (met en cache pour uv)
|
|
252
|
+
req_file = server_dir / "requirements.txt"
|
|
253
|
+
if req_file.exists():
|
|
254
|
+
import sys
|
|
255
|
+
subprocess.run(
|
|
256
|
+
[sys.executable, "-m", "pip", "install", "-q", "-r", str(req_file)],
|
|
257
|
+
cwd=str(server_dir),
|
|
258
|
+
)
|
|
259
|
+
# On se place dans le dossier du serveur pour éviter les espaces dans le chemin
|
|
260
|
+
result = subprocess.run(["mcp", "dev", "server.py"], cwd=str(server_dir))
|
|
261
|
+
if result.returncode != 0:
|
|
262
|
+
raise typer.Exit(result.returncode)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.command(name="smoke-test")
|
|
266
|
+
def smoke_test_cmd(
|
|
267
|
+
server_dir: Annotated[str, typer.Argument(help="Dossier du serveur MCP généré (contient server.py)")],
|
|
268
|
+
):
|
|
269
|
+
"""
|
|
270
|
+
Vérifie qu'un serveur MCP généré est valide sans le démarrer.
|
|
271
|
+
|
|
272
|
+
Contrôles effectués :
|
|
273
|
+
• server.py présent
|
|
274
|
+
• Syntaxe Python valide
|
|
275
|
+
• Import sans erreur (subprocess isolé)
|
|
276
|
+
• Objet mcp présent
|
|
277
|
+
• Au moins 1 @mcp.tool enregistré
|
|
278
|
+
• Cohérence avec le fichier VERSION
|
|
279
|
+
|
|
280
|
+
Exemples :
|
|
281
|
+
mcp-forge smoke-test output/my_server
|
|
282
|
+
mcp-forge smoke-test output\\my_server
|
|
283
|
+
"""
|
|
284
|
+
normalized = server_dir.replace("\\", "/")
|
|
285
|
+
_run_smoke_test_cmd(normalized)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _run_smoke_test_cmd(server_dir: str) -> None:
|
|
289
|
+
"""Exécute le smoke test et affiche les résultats. Exit 1 si échec."""
|
|
290
|
+
from mcp_forge.validation.smoke_test import run_smoke_test
|
|
291
|
+
|
|
292
|
+
normalized = server_dir.replace("\\", "/")
|
|
293
|
+
path = Path(normalized)
|
|
294
|
+
if not path.exists():
|
|
295
|
+
path = Path(__file__).parent / normalized
|
|
296
|
+
if not path.exists():
|
|
297
|
+
console.print(f"[red][FAIL] Dossier introuvable :[/red] {normalized}")
|
|
298
|
+
raise typer.Exit(1)
|
|
299
|
+
|
|
300
|
+
console.print(f"\n[bold]>> Smoke test :[/bold] {path}")
|
|
301
|
+
result = run_smoke_test(path)
|
|
302
|
+
|
|
303
|
+
for label, ok, detail in result.checks:
|
|
304
|
+
color = "green" if ok else "red"
|
|
305
|
+
mark = "OK " if ok else "FAIL"
|
|
306
|
+
suffix = f" -- {detail}" if detail else ""
|
|
307
|
+
console.print(f" [{color}]{mark}[/{color}] {label}{suffix}")
|
|
308
|
+
|
|
309
|
+
if result.success:
|
|
310
|
+
console.print(f"\n[green bold]Smoke test OK[/green bold] ({result.tools_found} tool(s))")
|
|
311
|
+
else:
|
|
312
|
+
console.print("\n[red bold]Smoke test ECHEC[/red bold]")
|
|
313
|
+
raise typer.Exit(1)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.command(name="functional-test")
|
|
317
|
+
def functional_test_cmd(
|
|
318
|
+
server_dir: Annotated[str, typer.Argument(help="Dossier du serveur MCP (contient server.py)")],
|
|
319
|
+
timeout: Annotated[float, typer.Option("--timeout", "-t", help="Délai max en secondes par étape")] = 15.0,
|
|
320
|
+
):
|
|
321
|
+
"""
|
|
322
|
+
Test fonctionnel : démarre le serveur MCP et vérifie ses tools via le protocole MCP.
|
|
323
|
+
|
|
324
|
+
Contrairement au smoke-test (import Python), ce test démarre réellement le serveur
|
|
325
|
+
et communique avec lui via JSON-RPC / stdio — comme un vrai client MCP.
|
|
326
|
+
|
|
327
|
+
Vérifications :
|
|
328
|
+
• Serveur démarre sans erreur
|
|
329
|
+
• Handshake MCP (initialize) réussi
|
|
330
|
+
• tools/list retourne au moins 1 tool
|
|
331
|
+
• Nombre de tools correspond au fichier VERSION
|
|
332
|
+
• Cohérence avec les @mcp.tool du source
|
|
333
|
+
|
|
334
|
+
Exemples :
|
|
335
|
+
mcp-forge functional-test output/my_server
|
|
336
|
+
mcp-forge functional-test output\\my_server --timeout 30
|
|
337
|
+
"""
|
|
338
|
+
normalized = server_dir.replace("\\", "/")
|
|
339
|
+
_run_functional_test_cmd(normalized, timeout=timeout)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
_FT_STATUS_COLOR = {"ok": "green", "warn": "yellow", "fail": "red", "skip": "dim"}
|
|
343
|
+
_FT_STATUS_MARK = {"ok": "OK ", "warn": "WARN", "fail": "FAIL", "skip": "SKIP"}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _print_ft_phase1(result) -> None:
|
|
347
|
+
for label, ok, detail in result.checks:
|
|
348
|
+
color = "green" if ok else "red"
|
|
349
|
+
mark = "OK " if ok else "FAIL"
|
|
350
|
+
suffix = f" -- {detail}" if detail else ""
|
|
351
|
+
console.print(f" [{color}]{mark}[/{color}] {label}{suffix}")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _print_ft_phase2(result) -> None:
|
|
355
|
+
if not result.tool_calls:
|
|
356
|
+
return
|
|
357
|
+
console.print("\n[bold]Phase 2 -- Appel des tools :[/bold]")
|
|
358
|
+
for tc in result.tool_calls:
|
|
359
|
+
color = _FT_STATUS_COLOR.get(tc.status, "white")
|
|
360
|
+
mark = _FT_STATUS_MARK.get(tc.status, "? ")
|
|
361
|
+
args_str = f" args={tc.args_used}" if tc.args_used else ""
|
|
362
|
+
suffix = f" -- {tc.detail}" if tc.detail else ""
|
|
363
|
+
console.print(f" [{color}]{mark}[/{color}] {tc.name}{args_str}{suffix}")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _run_functional_test_cmd(server_dir: str, timeout: float = 15.0) -> None:
|
|
367
|
+
"""Exécute le test fonctionnel MCP et affiche les résultats. Exit 1 si échec."""
|
|
368
|
+
from mcp_forge.validation.functional_test import run_functional_test
|
|
369
|
+
|
|
370
|
+
normalized = server_dir.replace("\\", "/")
|
|
371
|
+
path = Path(normalized)
|
|
372
|
+
if not path.exists():
|
|
373
|
+
path = Path(__file__).parent / normalized
|
|
374
|
+
if not path.exists():
|
|
375
|
+
console.print(f"[red]Dossier introuvable :[/red] {normalized}")
|
|
376
|
+
raise typer.Exit(1)
|
|
377
|
+
|
|
378
|
+
console.print(f"\n[bold]>> Test fonctionnel MCP :[/bold] {path}")
|
|
379
|
+
console.print("[dim] Démarrage du serveur...[/dim]")
|
|
380
|
+
result = run_functional_test(path, timeout=timeout)
|
|
381
|
+
|
|
382
|
+
_print_ft_phase1(result)
|
|
383
|
+
_print_ft_phase2(result)
|
|
384
|
+
|
|
385
|
+
if result.success:
|
|
386
|
+
console.print(f"\n[green bold]Test fonctionnel OK[/green bold] ({len(result.tools_listed)} tool(s))")
|
|
387
|
+
else:
|
|
388
|
+
console.print("\n[red bold]Test fonctionnel ECHEC[/red bold]")
|
|
389
|
+
raise typer.Exit(1)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@app.command(name="list")
|
|
393
|
+
def list_servers(
|
|
394
|
+
output: Annotated[Optional[str], typer.Option("--output", "-o", help="Dossier de sortie à inspecter")] = None,
|
|
395
|
+
):
|
|
396
|
+
"""
|
|
397
|
+
Liste les serveurs MCP générés dans le dossier de sortie.
|
|
398
|
+
|
|
399
|
+
Pour chaque serveur, affiche : nom, date de génération, source, nombre de tools.
|
|
400
|
+
"""
|
|
401
|
+
from rich.table import Table
|
|
402
|
+
|
|
403
|
+
output_dir = Path(output or _DEFAULT_OUTPUT)
|
|
404
|
+
if not output_dir.exists():
|
|
405
|
+
console.print(f"[yellow]Dossier introuvable :[/yellow] {output_dir}")
|
|
406
|
+
raise typer.Exit(0)
|
|
407
|
+
|
|
408
|
+
servers = sorted(
|
|
409
|
+
[d for d in output_dir.iterdir() if d.is_dir()],
|
|
410
|
+
key=lambda d: d.stat().st_mtime,
|
|
411
|
+
reverse=True,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if not servers:
|
|
415
|
+
console.print(f"[dim]Aucun serveur dans {output_dir}[/dim]")
|
|
416
|
+
raise typer.Exit(0)
|
|
417
|
+
|
|
418
|
+
table = Table(title=f"Serveurs MCP dans {output_dir}", show_lines=False)
|
|
419
|
+
table.add_column("Nom", style="bold cyan", no_wrap=True)
|
|
420
|
+
table.add_column("Tools", justify="right")
|
|
421
|
+
table.add_column("Genere le", style="dim")
|
|
422
|
+
table.add_column("Source", style="dim", overflow="fold")
|
|
423
|
+
|
|
424
|
+
for d in servers:
|
|
425
|
+
version_path = d / "VERSION"
|
|
426
|
+
if version_path.exists():
|
|
427
|
+
data = {}
|
|
428
|
+
for line in version_path.read_text(encoding="utf-8").splitlines():
|
|
429
|
+
if ":" in line:
|
|
430
|
+
k, _, v = line.partition(":")
|
|
431
|
+
data[k.strip()] = v.strip()
|
|
432
|
+
tools = data.get("tools_count", "?")
|
|
433
|
+
generated_at = data.get("generated_at", "?")
|
|
434
|
+
source = data.get("source", "?")
|
|
435
|
+
else:
|
|
436
|
+
tools = "?"
|
|
437
|
+
generated_at = "?"
|
|
438
|
+
source = "[dim](no VERSION file)[/dim]"
|
|
439
|
+
|
|
440
|
+
table.add_row(d.name, str(tools), generated_at, source)
|
|
441
|
+
|
|
442
|
+
console.print(table)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@app.command()
|
|
446
|
+
def info():
|
|
447
|
+
"""Affiche des informations sur mcp-forge et les types de sources supportés."""
|
|
448
|
+
rprint(Panel(
|
|
449
|
+
"""[bold]Sources supportées :[/bold]
|
|
450
|
+
|
|
451
|
+
[green]openapi[/green] Fichier ou URL OpenAPI/Swagger (.json, .yaml)
|
|
452
|
+
[green]website[/green] Site web ou API REST (URL http/https)
|
|
453
|
+
[green]graphql[/green] API GraphQL (URL avec /graphql)
|
|
454
|
+
[green]cli_app[/green] Application CLI locale
|
|
455
|
+
[green]codebase[/green] Dossier de code source
|
|
456
|
+
|
|
457
|
+
[bold]Modes :[/bold]
|
|
458
|
+
|
|
459
|
+
[blue]auto[/blue] Génération entièrement automatique
|
|
460
|
+
[blue]semi[/blue] Pause pour review des tools et du code généré
|
|
461
|
+
[blue]guided[/blue] Étape par étape avec choix interactif
|
|
462
|
+
|
|
463
|
+
[bold]Variables d'environnement :[/bold]
|
|
464
|
+
|
|
465
|
+
ANTHROPIC_API_KEY Clé API pour l'enrichissement LLM
|
|
466
|
+
""",
|
|
467
|
+
title="[bold blue]mcp-forge[/bold blue] — Informations",
|
|
468
|
+
border_style="blue",
|
|
469
|
+
))
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
if __name__ == "__main__":
|
|
473
|
+
app()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Chargement de la configuration mcp-forge depuis un fichier YAML.
|
|
3
|
+
|
|
4
|
+
Priorité : flags CLI > mcp-forge.yaml > valeurs par défaut
|
|
5
|
+
|
|
6
|
+
Format supporté (mcp-forge.yaml) :
|
|
7
|
+
source: https://api.example.com/openapi.json
|
|
8
|
+
mode: auto
|
|
9
|
+
output: ./generated
|
|
10
|
+
name: my_server
|
|
11
|
+
type: openapi
|
|
12
|
+
name_style: exhaustive
|
|
13
|
+
no_llm: false
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
_DEFAULT_CONFIG_FILE = "mcp-forge.yaml"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_yaml_config(config_path: str | None) -> dict[str, Any]:
|
|
24
|
+
"""Charge un fichier YAML de configuration.
|
|
25
|
+
|
|
26
|
+
Si config_path est None, cherche mcp-forge.yaml dans le répertoire courant.
|
|
27
|
+
Retourne un dict vide si aucun fichier trouvé.
|
|
28
|
+
"""
|
|
29
|
+
import yaml
|
|
30
|
+
|
|
31
|
+
path: Path | None = None
|
|
32
|
+
if config_path:
|
|
33
|
+
path = Path(config_path)
|
|
34
|
+
if not path.exists():
|
|
35
|
+
raise FileNotFoundError(f"Fichier de configuration introuvable : {config_path}")
|
|
36
|
+
else:
|
|
37
|
+
candidate = Path(_DEFAULT_CONFIG_FILE)
|
|
38
|
+
if candidate.exists():
|
|
39
|
+
path = candidate
|
|
40
|
+
|
|
41
|
+
if path is None:
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
with path.open(encoding="utf-8") as f:
|
|
45
|
+
data = yaml.safe_load(f) or {}
|
|
46
|
+
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def merge_config(yaml_cfg: dict[str, Any], **cli_flags: Any) -> dict[str, Any]:
|
|
51
|
+
"""Fusionne config YAML et flags CLI. Les flags CLI ont la priorité.
|
|
52
|
+
|
|
53
|
+
Un flag CLI est considéré comme "non défini" si sa valeur est None
|
|
54
|
+
(pour les optionnels) ou correspond à la valeur par défaut Typer.
|
|
55
|
+
"""
|
|
56
|
+
merged = dict(yaml_cfg)
|
|
57
|
+
for key, value in cli_flags.items():
|
|
58
|
+
if value is not None:
|
|
59
|
+
merged[key] = value
|
|
60
|
+
return merged
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modules de découverte des capacités d'une source.
|
|
3
|
+
"""
|
|
4
|
+
from .openapi import OpenAPIDiscovery
|
|
5
|
+
from .web import WebDiscovery
|
|
6
|
+
from .graphql import GraphQLDiscovery
|
|
7
|
+
from .cli_app import CLIAppDiscovery
|
|
8
|
+
from .codebase import CodebaseDiscovery
|
|
9
|
+
from mcp_forge.models import DiscoveryResult, ForgeConfig, SourceType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def discover(config: ForgeConfig) -> DiscoveryResult:
|
|
13
|
+
"""Point d'entrée principal : sélectionne et lance le bon module de découverte."""
|
|
14
|
+
discoverers = {
|
|
15
|
+
SourceType.OPENAPI: OpenAPIDiscovery,
|
|
16
|
+
SourceType.WEBSITE: WebDiscovery,
|
|
17
|
+
SourceType.GRAPHQL: GraphQLDiscovery,
|
|
18
|
+
SourceType.CLI_APP: CLIAppDiscovery,
|
|
19
|
+
SourceType.CODEBASE: CodebaseDiscovery,
|
|
20
|
+
}
|
|
21
|
+
cls = discoverers.get(config.source_type)
|
|
22
|
+
if cls is None:
|
|
23
|
+
raise ValueError(f"Type de source non supporté : {config.source_type}")
|
|
24
|
+
return cls(config).discover()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"discover",
|
|
29
|
+
"OpenAPIDiscovery",
|
|
30
|
+
"WebDiscovery",
|
|
31
|
+
"GraphQLDiscovery",
|
|
32
|
+
"CLIAppDiscovery",
|
|
33
|
+
"CodebaseDiscovery",
|
|
34
|
+
]
|