openapi-to-mcp-cli 0.9.1__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.
- openapi_to_mcp/__init__.py +3 -0
- openapi_to_mcp/adapters/__init__.py +1 -0
- openapi_to_mcp/adapters/generator.py +206 -0
- openapi_to_mcp/adapters/spec_loader.py +135 -0
- openapi_to_mcp/adapters/testing/__init__.py +21 -0
- openapi_to_mcp/adapters/testing/models.py +46 -0
- openapi_to_mcp/adapters/testing/response_formatting.py +94 -0
- openapi_to_mcp/adapters/testing/server_tester.py +85 -0
- openapi_to_mcp/adapters/testing/stdio_transport.py +83 -0
- openapi_to_mcp/adapters/testing/streamable_http_transport.py +163 -0
- openapi_to_mcp/cli.py +45 -0
- openapi_to_mcp/commands/__init__.py +15 -0
- openapi_to_mcp/commands/diff.py +138 -0
- openapi_to_mcp/commands/doctor.py +97 -0
- openapi_to_mcp/commands/generate.py +78 -0
- openapi_to_mcp/commands/generation_context.py +109 -0
- openapi_to_mcp/commands/generation_models.py +163 -0
- openapi_to_mcp/commands/generation_service.py +142 -0
- openapi_to_mcp/commands/options.py +153 -0
- openapi_to_mcp/commands/policy_support.py +22 -0
- openapi_to_mcp/commands/run.py +102 -0
- openapi_to_mcp/commands/run_support.py +119 -0
- openapi_to_mcp/commands/runtime_overrides.py +164 -0
- openapi_to_mcp/commands/test_server.py +160 -0
- openapi_to_mcp/common/__init__.py +25 -0
- openapi_to_mcp/common/decorators.py +50 -0
- openapi_to_mcp/common/error_policy.py +12 -0
- openapi_to_mcp/common/exceptions.py +34 -0
- openapi_to_mcp/common/logger.py +78 -0
- openapi_to_mcp/common/performance_presets.py +79 -0
- openapi_to_mcp/common/terminal.py +61 -0
- openapi_to_mcp/common/tool_runtime.py +81 -0
- openapi_to_mcp/common/utils.py +89 -0
- openapi_to_mcp/diff/__init__.py +6 -0
- openapi_to_mcp/diff/analyzer.py +163 -0
- openapi_to_mcp/diff/models.py +70 -0
- openapi_to_mcp/diff/surface.py +110 -0
- openapi_to_mcp/doctor/__init__.py +6 -0
- openapi_to_mcp/doctor/analyzer.py +192 -0
- openapi_to_mcp/doctor/models.py +75 -0
- openapi_to_mcp/doctor/security.py +69 -0
- openapi_to_mcp/mapping/__init__.py +5 -0
- openapi_to_mcp/mapping/mapper.py +515 -0
- openapi_to_mcp/mapping/output_schema.py +122 -0
- openapi_to_mcp/mapping/tool_description.py +113 -0
- openapi_to_mcp/mapping/tool_examples.py +145 -0
- openapi_to_mcp/mapping/tool_grouping.py +87 -0
- openapi_to_mcp/mapping/utils.py +59 -0
- openapi_to_mcp/policy/__init__.py +7 -0
- openapi_to_mcp/policy/applier.py +208 -0
- openapi_to_mcp/policy/loader.py +56 -0
- openapi_to_mcp/policy/models.py +77 -0
- openapi_to_mcp/policy/parser.py +192 -0
- openapi_to_mcp/policy/settings.py +42 -0
- openapi_to_mcp/policy/validators.py +111 -0
- openapi_to_mcp/schema/__init__.py +5 -0
- openapi_to_mcp/schema/converter.py +199 -0
- openapi_to_mcp/schema/handlers/__init__.py +17 -0
- openapi_to_mcp/schema/handlers/array_schema.py +37 -0
- openapi_to_mcp/schema/handlers/base.py +76 -0
- openapi_to_mcp/schema/handlers/common.py +55 -0
- openapi_to_mcp/schema/handlers/composition.py +41 -0
- openapi_to_mcp/schema/handlers/number_schema.py +58 -0
- openapi_to_mcp/schema/handlers/object_schema.py +46 -0
- openapi_to_mcp/schema/handlers/reference.py +142 -0
- openapi_to_mcp/schema/handlers/string_schema.py +63 -0
- openapi_to_mcp/templates/.env.example.j2 +63 -0
- openapi_to_mcp/templates/README.md.j2 +137 -0
- openapi_to_mcp/templates/package.json.j2 +39 -0
- openapi_to_mcp/templates/src/custom/tools.ts.j2 +5 -0
- openapi_to_mcp/templates/src/index.ts.j2 +21 -0
- openapi_to_mcp/templates/src/runtime/audit.ts.j2 +98 -0
- openapi_to_mcp/templates/src/runtime/auth.ts.j2 +118 -0
- openapi_to_mcp/templates/src/runtime/cache.ts.j2 +100 -0
- openapi_to_mcp/templates/src/runtime/circuit_breaker.ts.j2 +153 -0
- openapi_to_mcp/templates/src/runtime/config.ts.j2 +169 -0
- openapi_to_mcp/templates/src/runtime/errors.ts.j2 +169 -0
- openapi_to_mcp/templates/src/runtime/executor.ts.j2 +135 -0
- openapi_to_mcp/templates/src/runtime/executor_support.ts.j2 +164 -0
- openapi_to_mcp/templates/src/runtime/generated.ts.j2 +60 -0
- openapi_to_mcp/templates/src/runtime/http_transport.ts.j2 +78 -0
- openapi_to_mcp/templates/src/runtime/limiter.ts.j2 +77 -0
- openapi_to_mcp/templates/src/runtime/observability.ts.j2 +24 -0
- openapi_to_mcp/templates/src/runtime/performance_preset.ts.j2 +60 -0
- openapi_to_mcp/templates/src/runtime/rate_limit.ts.j2 +47 -0
- openapi_to_mcp/templates/src/runtime/redaction.ts.j2 +111 -0
- openapi_to_mcp/templates/src/runtime/request.ts.j2 +185 -0
- openapi_to_mcp/templates/src/runtime/request_context.ts.j2 +48 -0
- openapi_to_mcp/templates/src/runtime/resilience.ts.j2 +120 -0
- openapi_to_mcp/templates/src/runtime/response.ts.j2 +32 -0
- openapi_to_mcp/templates/src/runtime/retry.ts.j2 +61 -0
- openapi_to_mcp/templates/src/runtime/serialization.ts.j2 +167 -0
- openapi_to_mcp/templates/src/runtime/tool_access.ts.j2 +122 -0
- openapi_to_mcp/templates/src/runtime/validation.ts.j2 +53 -0
- openapi_to_mcp/templates/src/server.ts.j2 +151 -0
- openapi_to_mcp/templates/src/transport_stdio.ts.j2 +18 -0
- openapi_to_mcp/templates/src/transport_streamable_http.ts.j2 +169 -0
- openapi_to_mcp/templates/tsconfig.json.j2 +21 -0
- openapi_to_mcp_cli-0.9.1.dist-info/METADATA +131 -0
- openapi_to_mcp_cli-0.9.1.dist-info/RECORD +103 -0
- openapi_to_mcp_cli-0.9.1.dist-info/WHEEL +4 -0
- openapi_to_mcp_cli-0.9.1.dist-info/entry_points.txt +2 -0
- openapi_to_mcp_cli-0.9.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Streamable HTTP transport for MCP server tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from mcp.types import CallToolResult, ListToolsResult
|
|
12
|
+
|
|
13
|
+
from openapi_to_mcp.adapters.testing.models import (
|
|
14
|
+
ServerConnectionError,
|
|
15
|
+
TransportStrategy,
|
|
16
|
+
UnsupportedMethodError,
|
|
17
|
+
)
|
|
18
|
+
from openapi_to_mcp.adapters.testing.response_formatting import format_mcp_response
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
DEFAULT_PROTOCOL_VERSION = "2025-11-25"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _normalize_result(
|
|
25
|
+
response: dict[str, Any], method: str, req_id: int
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
if "error" in response or not isinstance(response.get("result"), dict):
|
|
28
|
+
return response
|
|
29
|
+
model_type = ListToolsResult if method == "list" else CallToolResult
|
|
30
|
+
model = model_type.model_validate(response["result"])
|
|
31
|
+
formatted = format_mcp_response(model, req_id)
|
|
32
|
+
normalized = dict(response)
|
|
33
|
+
normalized["result"] = {
|
|
34
|
+
key: value for key, value in formatted.items() if key not in {"jsonrpc", "id"}
|
|
35
|
+
}
|
|
36
|
+
return normalized
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class StreamableHttpTransport(TransportStrategy):
|
|
40
|
+
"""Execute MCP JSON-RPC requests through streamable HTTP."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, endpoint_url: str) -> None:
|
|
43
|
+
if not endpoint_url:
|
|
44
|
+
raise ValueError("endpoint_url is required for streamable-http transport")
|
|
45
|
+
self.endpoint_url = endpoint_url
|
|
46
|
+
self._session_id: str | None = None
|
|
47
|
+
self._protocol_version = DEFAULT_PROTOCOL_VERSION
|
|
48
|
+
|
|
49
|
+
def _build_jsonrpc_payload(
|
|
50
|
+
self, method: str, params: dict[str, Any] | None, req_id: int
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
if method == "list":
|
|
53
|
+
return {
|
|
54
|
+
"jsonrpc": "2.0",
|
|
55
|
+
"id": req_id,
|
|
56
|
+
"method": "tools/list",
|
|
57
|
+
"params": {},
|
|
58
|
+
}
|
|
59
|
+
if method == "call":
|
|
60
|
+
if params is None or "tool_name" not in params:
|
|
61
|
+
raise ValueError("Missing 'tool_name' in params for tool call method.")
|
|
62
|
+
return {
|
|
63
|
+
"jsonrpc": "2.0",
|
|
64
|
+
"id": req_id,
|
|
65
|
+
"method": "tools/call",
|
|
66
|
+
"params": {
|
|
67
|
+
"name": params["tool_name"],
|
|
68
|
+
"arguments": params.get("tool_arguments", {}),
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
raise UnsupportedMethodError(f"Unsupported method for testing: {method}")
|
|
72
|
+
|
|
73
|
+
def _build_headers(self) -> dict[str, str]:
|
|
74
|
+
headers = {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
"Accept": "application/json, text/event-stream",
|
|
77
|
+
"MCP-Protocol-Version": self._protocol_version,
|
|
78
|
+
}
|
|
79
|
+
if self._session_id:
|
|
80
|
+
headers["Mcp-Session-Id"] = self._session_id
|
|
81
|
+
return headers
|
|
82
|
+
|
|
83
|
+
def _parse_json_response(self, response: requests.Response) -> dict[str, Any]:
|
|
84
|
+
try:
|
|
85
|
+
response_data = response.json()
|
|
86
|
+
except json.JSONDecodeError as error:
|
|
87
|
+
raise ServerConnectionError(
|
|
88
|
+
f"Invalid JSON response from streamable-http endpoint: {error}"
|
|
89
|
+
) from error
|
|
90
|
+
if not isinstance(response_data, dict):
|
|
91
|
+
raise ServerConnectionError(
|
|
92
|
+
"Unexpected non-object JSON response from streamable-http endpoint"
|
|
93
|
+
)
|
|
94
|
+
return response_data
|
|
95
|
+
|
|
96
|
+
def _initialize(self, req_id: int) -> None:
|
|
97
|
+
payload = {
|
|
98
|
+
"jsonrpc": "2.0",
|
|
99
|
+
"id": req_id,
|
|
100
|
+
"method": "initialize",
|
|
101
|
+
"params": {
|
|
102
|
+
"protocolVersion": self._protocol_version,
|
|
103
|
+
"capabilities": {},
|
|
104
|
+
"clientInfo": {
|
|
105
|
+
"name": "openapi-to-mcp-tester",
|
|
106
|
+
"version": "0.1.0",
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
response = requests.post(
|
|
111
|
+
self.endpoint_url,
|
|
112
|
+
json=payload,
|
|
113
|
+
timeout=30,
|
|
114
|
+
headers=self._build_headers(),
|
|
115
|
+
)
|
|
116
|
+
response.raise_for_status()
|
|
117
|
+
self._session_id = response.headers.get("Mcp-Session-Id")
|
|
118
|
+
response_data = self._parse_json_response(response)
|
|
119
|
+
if "error" in response_data:
|
|
120
|
+
raise ServerConnectionError(
|
|
121
|
+
f"Initialize request failed: {response_data['error']}"
|
|
122
|
+
)
|
|
123
|
+
self._send_initialized_notification()
|
|
124
|
+
|
|
125
|
+
def _send_initialized_notification(self) -> None:
|
|
126
|
+
response = requests.post(
|
|
127
|
+
self.endpoint_url,
|
|
128
|
+
json={
|
|
129
|
+
"jsonrpc": "2.0",
|
|
130
|
+
"method": "notifications/initialized",
|
|
131
|
+
"params": {},
|
|
132
|
+
},
|
|
133
|
+
timeout=30,
|
|
134
|
+
headers=self._build_headers(),
|
|
135
|
+
)
|
|
136
|
+
response.raise_for_status()
|
|
137
|
+
|
|
138
|
+
def _post_jsonrpc(
|
|
139
|
+
self, method: str, params: dict[str, Any] | None, req_id: int
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
self._initialize(req_id=req_id * 1000 + 1)
|
|
142
|
+
response = requests.post(
|
|
143
|
+
self.endpoint_url,
|
|
144
|
+
json=self._build_jsonrpc_payload(method, params, req_id),
|
|
145
|
+
timeout=30,
|
|
146
|
+
headers=self._build_headers(),
|
|
147
|
+
)
|
|
148
|
+
response.raise_for_status()
|
|
149
|
+
response_data = self._parse_json_response(response)
|
|
150
|
+
return _normalize_result(response_data, method, req_id)
|
|
151
|
+
|
|
152
|
+
async def connect_and_execute(
|
|
153
|
+
self, method: str, params: dict[str, Any] | None, req_id: int
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
"""Connect over streamable HTTP and return the JSON-RPC response."""
|
|
156
|
+
logger.info("Connecting to streamable-http endpoint at %s", self.endpoint_url)
|
|
157
|
+
try:
|
|
158
|
+
return await asyncio.to_thread(self._post_jsonrpc, method, params, req_id)
|
|
159
|
+
except Exception as error:
|
|
160
|
+
logger.exception("Error during streamable-http connection")
|
|
161
|
+
raise ServerConnectionError(
|
|
162
|
+
f"Failed to connect via streamable-http: {error}"
|
|
163
|
+
) from error
|
openapi_to_mcp/cli.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import rich_click as click
|
|
2
|
+
|
|
3
|
+
from openapi_to_mcp.commands import diff, doctor, generate, run, test_server
|
|
4
|
+
from openapi_to_mcp.common import configure_logger
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _configure_rich_click() -> None:
|
|
8
|
+
click.rich_click.TEXT_MARKUP = "rich"
|
|
9
|
+
click.rich_click.SHOW_ARGUMENTS = True
|
|
10
|
+
click.rich_click.GROUP_ARGUMENTS_OPTIONS = True
|
|
11
|
+
click.rich_click.OPTIONS_TABLE_HELP_SECTIONS = [
|
|
12
|
+
"help",
|
|
13
|
+
"metavar",
|
|
14
|
+
"envvar",
|
|
15
|
+
"default",
|
|
16
|
+
"required",
|
|
17
|
+
]
|
|
18
|
+
click.rich_click.STYLE_COMMAND = "bold yellow"
|
|
19
|
+
click.rich_click.STYLE_OPTION = "bold cyan"
|
|
20
|
+
click.rich_click.STYLE_SWITCH = "bold cyan"
|
|
21
|
+
click.rich_click.STYLE_METAVAR = "bold white"
|
|
22
|
+
click.rich_click.STYLE_OPTION_HELP = "white"
|
|
23
|
+
click.rich_click.STYLE_HEADER_TEXT = "bold white"
|
|
24
|
+
click.rich_click.STYLE_USAGE = "bold green"
|
|
25
|
+
click.rich_click.STYLE_ERRORS_SUGGESTION = "bold red"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_configure_rich_click()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
32
|
+
def cli() -> None:
|
|
33
|
+
"""A CLI tool to diagnose, diff, generate, run, and test MCP servers from OpenAPI specs."""
|
|
34
|
+
configure_logger()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
cli.add_command(generate)
|
|
38
|
+
cli.add_command(run)
|
|
39
|
+
cli.add_command(test_server)
|
|
40
|
+
cli.add_command(doctor)
|
|
41
|
+
cli.add_command(diff)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
cli()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Commands component for the openapi-to-mcp package."""
|
|
2
|
+
|
|
3
|
+
from openapi_to_mcp.commands.diff import diff
|
|
4
|
+
from openapi_to_mcp.commands.doctor import doctor
|
|
5
|
+
from openapi_to_mcp.commands.generate import generate
|
|
6
|
+
from openapi_to_mcp.commands.run import run_server as run
|
|
7
|
+
from openapi_to_mcp.commands.test_server import run_test_server as test_server
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"diff",
|
|
11
|
+
"doctor",
|
|
12
|
+
"generate",
|
|
13
|
+
"run",
|
|
14
|
+
"test_server",
|
|
15
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""CLI command for diffing MCP-surface changes between two specs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING, Literal
|
|
7
|
+
|
|
8
|
+
import rich_click as click
|
|
9
|
+
|
|
10
|
+
from openapi_to_mcp.adapters.spec_loader import SpecLoader
|
|
11
|
+
from openapi_to_mcp.common import MappingError
|
|
12
|
+
from openapi_to_mcp.common.exceptions import SpecLoaderError
|
|
13
|
+
from openapi_to_mcp.common.terminal import (
|
|
14
|
+
print_error_panel,
|
|
15
|
+
print_section,
|
|
16
|
+
print_success_panel,
|
|
17
|
+
print_warning_panel,
|
|
18
|
+
)
|
|
19
|
+
from openapi_to_mcp.diff import DiffAnalyzer, DiffReport
|
|
20
|
+
|
|
21
|
+
OutputFormat = Literal["json", "text"]
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from openapi_to_mcp.diff.models import FailMode
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.command()
|
|
28
|
+
@click.option(
|
|
29
|
+
"--before-openapi-json",
|
|
30
|
+
"-b",
|
|
31
|
+
required=True,
|
|
32
|
+
help="Old OpenAPI specification JSON or YAML path or URL.",
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--after-openapi-json",
|
|
36
|
+
"-a",
|
|
37
|
+
required=True,
|
|
38
|
+
help="New OpenAPI specification JSON or YAML path or URL.",
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--format",
|
|
42
|
+
"output_format",
|
|
43
|
+
type=click.Choice(["text", "json"], case_sensitive=False),
|
|
44
|
+
default="text",
|
|
45
|
+
show_default=True,
|
|
46
|
+
help="Output format for the diff report.",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--fail-on",
|
|
50
|
+
type=click.Choice(["none", "breaking"], case_sensitive=False),
|
|
51
|
+
default="none",
|
|
52
|
+
show_default=True,
|
|
53
|
+
help="Exit non-zero when the selected change class is present.",
|
|
54
|
+
)
|
|
55
|
+
def diff(
|
|
56
|
+
before_openapi_json: str,
|
|
57
|
+
after_openapi_json: str,
|
|
58
|
+
output_format: OutputFormat,
|
|
59
|
+
fail_on: FailMode,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Compare two OpenAPI specs as generated MCP tool surfaces."""
|
|
62
|
+
report = _build_report(before_openapi_json, after_openapi_json)
|
|
63
|
+
_render_report(report, output_format, fail_on)
|
|
64
|
+
raise click.exceptions.Exit(report.exit_code(fail_on))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_report(before_source: str, after_source: str) -> DiffReport:
|
|
68
|
+
before_spec = _load_spec(before_source)
|
|
69
|
+
after_spec = _load_spec(after_source)
|
|
70
|
+
try:
|
|
71
|
+
return DiffAnalyzer(before_spec, after_spec).analyze(
|
|
72
|
+
before_source, after_source
|
|
73
|
+
)
|
|
74
|
+
except MappingError as exc:
|
|
75
|
+
err_msg = (
|
|
76
|
+
f"Unable to diff MCP surface: {exc}. "
|
|
77
|
+
"Run `openapi-to-mcp doctor` on both specs first."
|
|
78
|
+
)
|
|
79
|
+
raise click.ClickException(err_msg) from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_spec(source: str) -> dict:
|
|
83
|
+
try:
|
|
84
|
+
return SpecLoader(source=source).load_and_validate()
|
|
85
|
+
except SpecLoaderError as exc:
|
|
86
|
+
raise click.ClickException(f"Failed to load spec `{source}`: {exc}") from exc
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _render_report(
|
|
90
|
+
report: DiffReport, output_format: OutputFormat, fail_on: FailMode
|
|
91
|
+
) -> None:
|
|
92
|
+
if output_format == "json":
|
|
93
|
+
click.echo(json.dumps(report.to_dict(fail_on), indent=2))
|
|
94
|
+
return
|
|
95
|
+
if not report.changes:
|
|
96
|
+
print_success_panel(
|
|
97
|
+
"Diff Report",
|
|
98
|
+
[
|
|
99
|
+
f"Before: {report.before_source}",
|
|
100
|
+
f"After: {report.after_source}",
|
|
101
|
+
"No MCP-surface changes detected.",
|
|
102
|
+
f"Exit code: {report.exit_code(fail_on)}",
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
return
|
|
106
|
+
_print_summary(report, fail_on)
|
|
107
|
+
for change in report.changes:
|
|
108
|
+
_render_change(change.to_dict())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _print_summary(report: DiffReport, fail_on: FailMode) -> None:
|
|
112
|
+
lines = [
|
|
113
|
+
f"Before: {report.before_source}",
|
|
114
|
+
f"After: {report.after_source}",
|
|
115
|
+
f"Breaking: {report.breaking_count()}",
|
|
116
|
+
f"Non-breaking: {report.non_breaking_count()}",
|
|
117
|
+
f"Exit code: {report.exit_code(fail_on)}",
|
|
118
|
+
]
|
|
119
|
+
if report.breaking_count() > 0:
|
|
120
|
+
print_error_panel("Diff Summary", lines)
|
|
121
|
+
return
|
|
122
|
+
print_warning_panel("Diff Summary", lines)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _render_change(change: dict[str, object]) -> None:
|
|
126
|
+
print_section(f"{_format_impact(change['impact'])}: {change['code']}")
|
|
127
|
+
click.echo(f"Location: {change['location']}")
|
|
128
|
+
click.echo(f"Issue: {change['message']}")
|
|
129
|
+
details = change.get("details")
|
|
130
|
+
if isinstance(details, dict) and details:
|
|
131
|
+
click.echo(f"Details: {json.dumps(details, sort_keys=True)}")
|
|
132
|
+
click.echo(f"Hint: {change['hint']}")
|
|
133
|
+
click.echo()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _format_impact(impact: object) -> str:
|
|
137
|
+
value = str(impact).replace("_", "-")
|
|
138
|
+
return value.upper()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Doctor command for spec-readiness diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import rich_click as click
|
|
9
|
+
|
|
10
|
+
from openapi_to_mcp.adapters.spec_loader import SpecLoader
|
|
11
|
+
from openapi_to_mcp.common.exceptions import SpecLoaderError
|
|
12
|
+
from openapi_to_mcp.common.terminal import (
|
|
13
|
+
print_error_panel,
|
|
14
|
+
print_section,
|
|
15
|
+
print_success_panel,
|
|
16
|
+
print_warning_panel,
|
|
17
|
+
)
|
|
18
|
+
from openapi_to_mcp.doctor import DoctorAnalyzer, DoctorReport
|
|
19
|
+
|
|
20
|
+
OutputFormat = Literal["json", "text"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.command()
|
|
24
|
+
@click.option(
|
|
25
|
+
"--openapi-json",
|
|
26
|
+
"-o",
|
|
27
|
+
required=True,
|
|
28
|
+
help="Path or URL to OpenAPI specification JSON or YAML file.",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--format",
|
|
32
|
+
"output_format",
|
|
33
|
+
type=click.Choice(["text", "json"], case_sensitive=False),
|
|
34
|
+
default="text",
|
|
35
|
+
show_default=True,
|
|
36
|
+
help="Output format for diagnostics.",
|
|
37
|
+
)
|
|
38
|
+
def doctor(openapi_json: str, output_format: OutputFormat) -> None:
|
|
39
|
+
"""Inspect an OpenAPI spec for MCP-generation readiness."""
|
|
40
|
+
report = _build_report(openapi_json)
|
|
41
|
+
_render_report(report, output_format)
|
|
42
|
+
raise click.exceptions.Exit(report.exit_code())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_report(source: str) -> DoctorReport:
|
|
46
|
+
try:
|
|
47
|
+
spec = SpecLoader(source=source).load_and_validate()
|
|
48
|
+
except SpecLoaderError as exc:
|
|
49
|
+
report = DoctorReport(source=source)
|
|
50
|
+
report.add_error(
|
|
51
|
+
"spec_load_failed",
|
|
52
|
+
str(exc),
|
|
53
|
+
source,
|
|
54
|
+
"Fix the spec source or validation error before generating an MCP server.",
|
|
55
|
+
)
|
|
56
|
+
return report
|
|
57
|
+
return DoctorAnalyzer(spec).analyze(source)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _render_report(report: DoctorReport, output_format: OutputFormat) -> None:
|
|
61
|
+
if output_format == "json":
|
|
62
|
+
click.echo(json.dumps(report.to_dict(), indent=2))
|
|
63
|
+
return
|
|
64
|
+
if not report.issues:
|
|
65
|
+
print_success_panel(
|
|
66
|
+
"Doctor Report",
|
|
67
|
+
[
|
|
68
|
+
f"Source: {report.source}",
|
|
69
|
+
"No readiness issues found.",
|
|
70
|
+
"Exit code: 0",
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
_print_summary_panel(report)
|
|
75
|
+
for issue in report.issues:
|
|
76
|
+
_render_issue(issue.to_dict())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _render_issue(issue: dict[str, str]) -> None:
|
|
80
|
+
print_section(f"{issue['severity'].upper()}: {issue['code']}")
|
|
81
|
+
click.echo(f"Location: {issue['location']}")
|
|
82
|
+
click.echo(f"Issue: {issue['message']}")
|
|
83
|
+
click.echo(f"Hint: {issue['hint']}")
|
|
84
|
+
click.echo()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _print_summary_panel(report: DoctorReport) -> None:
|
|
88
|
+
lines = [
|
|
89
|
+
f"Source: {report.source}",
|
|
90
|
+
f"Errors: {report.error_count()}",
|
|
91
|
+
f"Warnings: {report.warning_count()}",
|
|
92
|
+
f"Exit code: {report.exit_code()}",
|
|
93
|
+
]
|
|
94
|
+
if report.error_count() > 0:
|
|
95
|
+
print_error_panel("Doctor Summary", lines)
|
|
96
|
+
return
|
|
97
|
+
print_warning_panel("Doctor Summary", lines)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""CLI adapter for MCP project generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Unpack
|
|
8
|
+
|
|
9
|
+
import rich_click as click
|
|
10
|
+
|
|
11
|
+
from openapi_to_mcp.commands.generation_models import (
|
|
12
|
+
GenerateClickOptions,
|
|
13
|
+
GenerationRequest,
|
|
14
|
+
compose_generation_request,
|
|
15
|
+
generation_cli_values,
|
|
16
|
+
)
|
|
17
|
+
from openapi_to_mcp.commands.generation_service import generate_project
|
|
18
|
+
from openapi_to_mcp.commands.options import add_options, generate_options
|
|
19
|
+
from openapi_to_mcp.commands.policy_support import load_policy_and_settings
|
|
20
|
+
from openapi_to_mcp.common.exceptions import (
|
|
21
|
+
GenerationError,
|
|
22
|
+
MappingError,
|
|
23
|
+
NoToolsMappedError,
|
|
24
|
+
PolicyConfigError,
|
|
25
|
+
SchemaError,
|
|
26
|
+
SpecLoaderError,
|
|
27
|
+
)
|
|
28
|
+
from openapi_to_mcp.common.terminal import print_success_panel
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _generation_request(options: GenerateClickOptions) -> GenerationRequest:
|
|
34
|
+
policy, values = load_policy_and_settings(
|
|
35
|
+
generation_cli_values(options), options["config"]
|
|
36
|
+
)
|
|
37
|
+
return compose_generation_request(
|
|
38
|
+
options["openapi_json"], options["output_dir"], values, policy
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print_success(output_dir: str) -> None:
|
|
43
|
+
print_success_panel(
|
|
44
|
+
"MCP Server Generation Successful",
|
|
45
|
+
[
|
|
46
|
+
f"Files generated in: {output_dir}",
|
|
47
|
+
"Check the generated README for build and runtime instructions.",
|
|
48
|
+
],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.command()
|
|
53
|
+
@add_options(generate_options)
|
|
54
|
+
def generate(**options: Unpack[GenerateClickOptions]) -> None:
|
|
55
|
+
"""Generate a Node.js/TypeScript MCP server from an OpenAPI specification."""
|
|
56
|
+
try:
|
|
57
|
+
generate_project(request=_generation_request(options))
|
|
58
|
+
logger.info("MCP server generation successful.")
|
|
59
|
+
_print_success(options["output_dir"])
|
|
60
|
+
except NoToolsMappedError as error:
|
|
61
|
+
if error.is_error:
|
|
62
|
+
raise click.ClickException(str(error)) from error
|
|
63
|
+
click.echo(str(error))
|
|
64
|
+
except click.ClickException:
|
|
65
|
+
raise
|
|
66
|
+
except (
|
|
67
|
+
SpecLoaderError,
|
|
68
|
+
MappingError,
|
|
69
|
+
GenerationError,
|
|
70
|
+
PolicyConfigError,
|
|
71
|
+
SchemaError,
|
|
72
|
+
) as error:
|
|
73
|
+
raise click.ClickException(str(error)) from error
|
|
74
|
+
except Exception as error:
|
|
75
|
+
logger.critical(
|
|
76
|
+
"An unexpected critical error occurred: %s", error, exc_info=True
|
|
77
|
+
)
|
|
78
|
+
sys.exit(1)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Build template context values for generated MCP servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from openapi_to_mcp.common.performance_presets import performance_preset_context
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from openapi_to_mcp.commands.generation_models import GenerationSettings
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _server_name(provided: str | None, spec_info: dict[str, Any]) -> str:
|
|
17
|
+
if provided:
|
|
18
|
+
return provided
|
|
19
|
+
title = spec_info.get("title")
|
|
20
|
+
if title:
|
|
21
|
+
logger.info("Using server name from OpenAPI spec info.title: %s", title)
|
|
22
|
+
return str(title)
|
|
23
|
+
default = "openapi-mcp-server"
|
|
24
|
+
logger.warning(
|
|
25
|
+
"Server name not provided and not found in spec title. Using default: %s",
|
|
26
|
+
default,
|
|
27
|
+
)
|
|
28
|
+
return default
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _server_version(provided: str | None, spec_info: dict[str, Any]) -> str:
|
|
32
|
+
if provided:
|
|
33
|
+
return provided
|
|
34
|
+
version = spec_info.get("version")
|
|
35
|
+
if version:
|
|
36
|
+
logger.info("Using server version from OpenAPI spec info.version: %s", version)
|
|
37
|
+
return str(version)
|
|
38
|
+
default = "1.0.0"
|
|
39
|
+
logger.warning(
|
|
40
|
+
"Server version not provided and not found in spec version. Using default: %s",
|
|
41
|
+
default,
|
|
42
|
+
)
|
|
43
|
+
return default
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _swagger_base_url(spec: dict[str, Any]) -> str | None:
|
|
47
|
+
host = spec.get("host")
|
|
48
|
+
if not isinstance(host, str) or not host:
|
|
49
|
+
return None
|
|
50
|
+
base_path = spec.get("basePath", "")
|
|
51
|
+
schemes = spec.get("schemes", [])
|
|
52
|
+
scheme = schemes[0] if isinstance(schemes, list) and schemes else "https"
|
|
53
|
+
if not isinstance(base_path, str):
|
|
54
|
+
base_path = ""
|
|
55
|
+
if not isinstance(scheme, str) or not scheme:
|
|
56
|
+
return None
|
|
57
|
+
url = f"{scheme}://{host}{base_path}"
|
|
58
|
+
logger.info("Using base URL from Swagger 2 host/basePath: %s", url)
|
|
59
|
+
return url
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _base_url(spec: dict[str, Any]) -> str:
|
|
63
|
+
servers = spec.get("servers", [])
|
|
64
|
+
if isinstance(servers, list) and servers:
|
|
65
|
+
first_server = servers[0]
|
|
66
|
+
if isinstance(first_server, dict) and isinstance(first_server.get("url"), str):
|
|
67
|
+
url = first_server["url"]
|
|
68
|
+
logger.info("Using base URL from spec servers[0].url: %s", url)
|
|
69
|
+
return url
|
|
70
|
+
logger.warning(
|
|
71
|
+
"First server object in spec lacks a valid 'url' string. Using placeholder for .env."
|
|
72
|
+
)
|
|
73
|
+
swagger_url = _swagger_base_url(spec)
|
|
74
|
+
if swagger_url is not None:
|
|
75
|
+
return swagger_url
|
|
76
|
+
logger.warning(
|
|
77
|
+
"No 'servers' array found and no Swagger 2 host/basePath detected. Using placeholder for .env."
|
|
78
|
+
)
|
|
79
|
+
return "YOUR_API_BASE_URL_HERE"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_template_context(
|
|
83
|
+
spec: dict[str, Any],
|
|
84
|
+
settings: GenerationSettings,
|
|
85
|
+
public_tools: list[dict[str, Any]],
|
|
86
|
+
runtime_tools: dict[str, dict[str, Any]],
|
|
87
|
+
auth_env_vars: list[str],
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Build the Jinja context consumed by the project generator."""
|
|
90
|
+
spec_info = spec.get("info", {})
|
|
91
|
+
identity = settings.identity
|
|
92
|
+
transport = settings.transport
|
|
93
|
+
behavior = settings.behavior
|
|
94
|
+
return {
|
|
95
|
+
"server_name": _server_name(identity.name, spec_info),
|
|
96
|
+
"server_version": _server_version(identity.version, spec_info),
|
|
97
|
+
"server_description": spec_info.get("description", ""),
|
|
98
|
+
"transport": transport.kind,
|
|
99
|
+
"host": transport.host,
|
|
100
|
+
"port": transport.port,
|
|
101
|
+
"mcp_endpoint": transport.endpoint,
|
|
102
|
+
"strict": behavior.strict,
|
|
103
|
+
"runtime_validation": behavior.runtime_validation,
|
|
104
|
+
"tools": public_tools,
|
|
105
|
+
"runtime_tools": runtime_tools,
|
|
106
|
+
"auth_env_vars": auth_env_vars,
|
|
107
|
+
"api_base_url_comment": _base_url(spec),
|
|
108
|
+
"performance_presets": performance_preset_context(),
|
|
109
|
+
}
|