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 @@
|
|
|
1
|
+
"""Adapter components for interfacing with external systems."""
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Adapter for generating MCP server files from templates."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import jinja2
|
|
8
|
+
|
|
9
|
+
from openapi_to_mcp.common.exceptions import GenerationError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
RUNTIME_TEMPLATE_NAMES = (
|
|
14
|
+
"audit",
|
|
15
|
+
"request_context",
|
|
16
|
+
"auth",
|
|
17
|
+
"cache",
|
|
18
|
+
"circuit_breaker",
|
|
19
|
+
"config",
|
|
20
|
+
"errors",
|
|
21
|
+
"executor",
|
|
22
|
+
"executor_support",
|
|
23
|
+
"generated",
|
|
24
|
+
"http_transport",
|
|
25
|
+
"limiter",
|
|
26
|
+
"observability",
|
|
27
|
+
"performance_preset",
|
|
28
|
+
"rate_limit",
|
|
29
|
+
"resilience",
|
|
30
|
+
"retry",
|
|
31
|
+
"request",
|
|
32
|
+
"redaction",
|
|
33
|
+
"response",
|
|
34
|
+
"serialization",
|
|
35
|
+
"tool_access",
|
|
36
|
+
"validation",
|
|
37
|
+
)
|
|
38
|
+
CUSTOM_TEMPLATE_NAMES = ("tools",)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Generator:
|
|
42
|
+
"""Handles rendering templates and writing MCP server files."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, output_dir: str, context: dict[str, Any]) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Initialize the generator.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
output_dir: The target directory for generated files.
|
|
50
|
+
context: Data for template rendering (server_name, tools, etc.).
|
|
51
|
+
"""
|
|
52
|
+
self.output_path = Path(output_dir)
|
|
53
|
+
self.context = context
|
|
54
|
+
# Template directory is relative to this file's parent's parent
|
|
55
|
+
# This ensures it works with the new directory structure
|
|
56
|
+
self.template_dir = Path(__file__).parent.parent / "templates"
|
|
57
|
+
self.env: jinja2.Environment | None = None
|
|
58
|
+
|
|
59
|
+
def generate_files(self) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Generate all necessary MCP server files from templates.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
GenerationError: If template rendering or file writing fails.
|
|
65
|
+
"""
|
|
66
|
+
self._setup_environment()
|
|
67
|
+
if not self.env:
|
|
68
|
+
err_msg = "Internal error: Jinja2 environment not initialized."
|
|
69
|
+
raise GenerationError(err_msg)
|
|
70
|
+
|
|
71
|
+
self._ensure_output_directories()
|
|
72
|
+
self._generate_static_files()
|
|
73
|
+
self._generate_transport_file()
|
|
74
|
+
|
|
75
|
+
def _setup_environment(self) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Initialize the Jinja2 template environment.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
GenerationError: If the template directory is not found.
|
|
81
|
+
"""
|
|
82
|
+
if not self.template_dir.is_dir():
|
|
83
|
+
err_msg = f"Template directory not found at {self.template_dir}"
|
|
84
|
+
raise GenerationError(err_msg)
|
|
85
|
+
|
|
86
|
+
self.env = jinja2.Environment(
|
|
87
|
+
loader=jinja2.FileSystemLoader(self.template_dir),
|
|
88
|
+
autoescape=jinja2.select_autoescape(["html", "xml", "j2"]),
|
|
89
|
+
trim_blocks=True,
|
|
90
|
+
lstrip_blocks=True,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _ensure_output_directories(self) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Create the output directory and src subdirectory.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
GenerationError: If directory creation fails.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
self.output_path.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
src_path = self.output_path / "src"
|
|
103
|
+
src_path.mkdir(exist_ok=True)
|
|
104
|
+
runtime_path = src_path / "runtime"
|
|
105
|
+
runtime_path.mkdir(exist_ok=True)
|
|
106
|
+
custom_path = src_path / "custom"
|
|
107
|
+
custom_path.mkdir(exist_ok=True)
|
|
108
|
+
except OSError as e:
|
|
109
|
+
err_msg = (
|
|
110
|
+
f"Failed to create output directories in '{self.output_path}': {e}"
|
|
111
|
+
)
|
|
112
|
+
raise GenerationError(err_msg) from e
|
|
113
|
+
|
|
114
|
+
def _generate_static_files(self) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Generate overwriteable templates and preserved customization templates.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
GenerationError: If template rendering or file writing fails.
|
|
120
|
+
"""
|
|
121
|
+
generated_templates = {
|
|
122
|
+
"package.json.j2": self.output_path / "package.json",
|
|
123
|
+
"tsconfig.json.j2": self.output_path / "tsconfig.json",
|
|
124
|
+
"src/server.ts.j2": self.output_path / "src" / "server.ts",
|
|
125
|
+
"README.md.j2": self.output_path / "README.md",
|
|
126
|
+
".env.example.j2": self.output_path / ".env.example",
|
|
127
|
+
"src/index.ts.j2": self.output_path / "src" / "index.ts",
|
|
128
|
+
}
|
|
129
|
+
generated_templates.update(self._runtime_templates())
|
|
130
|
+
|
|
131
|
+
for template_name, output_file in generated_templates.items():
|
|
132
|
+
self._render_and_write(template_name, output_file)
|
|
133
|
+
for template_name, output_file in self._custom_templates().items():
|
|
134
|
+
self._render_if_missing(template_name, output_file)
|
|
135
|
+
|
|
136
|
+
def _runtime_templates(self) -> dict[str, Path]:
|
|
137
|
+
runtime_output = self.output_path / "src" / "runtime"
|
|
138
|
+
return {
|
|
139
|
+
f"src/runtime/{name}.ts.j2": runtime_output / f"{name}.ts"
|
|
140
|
+
for name in RUNTIME_TEMPLATE_NAMES
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def _custom_templates(self) -> dict[str, Path]:
|
|
144
|
+
custom_output = self.output_path / "src" / "custom"
|
|
145
|
+
return {
|
|
146
|
+
f"src/custom/{name}.ts.j2": custom_output / f"{name}.ts"
|
|
147
|
+
for name in CUSTOM_TEMPLATE_NAMES
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def _generate_transport_file(self) -> None:
|
|
151
|
+
"""
|
|
152
|
+
Generate the appropriate transport file based on context.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
GenerationError: If template rendering or file writing fails.
|
|
156
|
+
"""
|
|
157
|
+
transport_output_file = self.output_path / "src" / "transport.ts"
|
|
158
|
+
|
|
159
|
+
if self.context.get("transport") == "streamable-http":
|
|
160
|
+
transport_template_name = "src/transport_streamable_http.ts.j2"
|
|
161
|
+
logger.info(
|
|
162
|
+
"Rendering streamable-http transport template to src/transport.ts"
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
transport_template_name = "src/transport_stdio.ts.j2"
|
|
166
|
+
logger.info("Rendering STDIO transport template to src/transport.ts")
|
|
167
|
+
|
|
168
|
+
self._render_and_write(transport_template_name, transport_output_file)
|
|
169
|
+
|
|
170
|
+
def _render_and_write(self, template_name: str, output_file: Path) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Render a single template and write it to the output file.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
template_name: The name of the template file.
|
|
176
|
+
output_file: The output file path.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
GenerationError: If template rendering or file writing fails.
|
|
180
|
+
"""
|
|
181
|
+
if not self.env:
|
|
182
|
+
err_msg = "Internal error: Jinja2 environment not initialized."
|
|
183
|
+
raise GenerationError(err_msg)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
template = self.env.get_template(template_name)
|
|
187
|
+
rendered_content = template.render(self.context)
|
|
188
|
+
|
|
189
|
+
with output_file.open("w", encoding="utf-8") as f:
|
|
190
|
+
f.write(rendered_content)
|
|
191
|
+
|
|
192
|
+
except jinja2.TemplateNotFound as e:
|
|
193
|
+
err_msg = (
|
|
194
|
+
f"Required template '{template_name}' not found in {self.template_dir}."
|
|
195
|
+
)
|
|
196
|
+
raise GenerationError(err_msg) from e
|
|
197
|
+
except Exception as e:
|
|
198
|
+
err_msg = f"Error rendering or writing template '{template_name}' to '{output_file}': {e}"
|
|
199
|
+
raise GenerationError(err_msg) from e
|
|
200
|
+
|
|
201
|
+
def _render_if_missing(self, template_name: str, output_file: Path) -> None:
|
|
202
|
+
"""Render a template only when the target file does not already exist."""
|
|
203
|
+
if output_file.exists():
|
|
204
|
+
logger.info("Preserving existing customization file: %s", output_file)
|
|
205
|
+
return
|
|
206
|
+
self._render_and_write(template_name, output_file)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
import yaml
|
|
6
|
+
from openapi_spec_validator import validate as validate_spec
|
|
7
|
+
|
|
8
|
+
from openapi_to_mcp.common.exceptions import SpecLoaderError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SpecLoader:
|
|
14
|
+
"""Handles loading and validation of OpenAPI specifications from files or URLs."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, source: str) -> None:
|
|
17
|
+
"""
|
|
18
|
+
Initialize the spec loader with a source.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
source: Path or URL to the OpenAPI specification
|
|
22
|
+
"""
|
|
23
|
+
self.source = source
|
|
24
|
+
self.spec = None
|
|
25
|
+
self._content = None
|
|
26
|
+
|
|
27
|
+
def load_and_validate(self) -> dict:
|
|
28
|
+
"""
|
|
29
|
+
Load the OpenAPI spec from the source and validate it.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The loaded and validated specification dictionary.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
SpecLoaderError: If loading or validation fails.
|
|
36
|
+
"""
|
|
37
|
+
logger.info("Loading OpenAPI spec from: %s", self.source)
|
|
38
|
+
self._load_content()
|
|
39
|
+
self._parse_and_validate()
|
|
40
|
+
logger.info("OpenAPI spec loaded and validated successfully.")
|
|
41
|
+
return self.spec
|
|
42
|
+
|
|
43
|
+
def _load_content(self) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Load the raw content from file or URL.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
SpecLoaderError: If loading fails.
|
|
49
|
+
"""
|
|
50
|
+
if self.source.startswith(("http://", "https://")):
|
|
51
|
+
self._load_from_url()
|
|
52
|
+
else:
|
|
53
|
+
self._load_from_file()
|
|
54
|
+
|
|
55
|
+
def _load_from_url(self) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Load content from a URL.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
SpecLoaderError: If the URL request fails.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
logger.debug("Fetching spec from URL: %s", self.source)
|
|
64
|
+
response = requests.get(self.source, timeout=10)
|
|
65
|
+
response.raise_for_status()
|
|
66
|
+
self._content = response.text
|
|
67
|
+
logger.debug("Successfully fetched spec from URL.")
|
|
68
|
+
except requests.exceptions.Timeout as e:
|
|
69
|
+
logger.exception(
|
|
70
|
+
"Timeout occurred while fetching spec from URL: %s", self.source
|
|
71
|
+
)
|
|
72
|
+
err_msg = f"Timeout fetching OpenAPI spec from URL '{self.source}'"
|
|
73
|
+
raise SpecLoaderError(err_msg) from e
|
|
74
|
+
except requests.exceptions.RequestException as e:
|
|
75
|
+
logger.exception("Error fetching spec from URL '%s'", self.source)
|
|
76
|
+
err_msg = f"Error fetching OpenAPI spec from URL '{self.source}': {e}"
|
|
77
|
+
raise SpecLoaderError(err_msg) from e
|
|
78
|
+
|
|
79
|
+
def _load_from_file(self) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Load content from a file.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
SpecLoaderError: If the file cannot be read.
|
|
85
|
+
"""
|
|
86
|
+
try:
|
|
87
|
+
source_path = Path(self.source)
|
|
88
|
+
logger.debug("Reading spec from file: %s", source_path)
|
|
89
|
+
if not source_path.is_file():
|
|
90
|
+
raise FileNotFoundError(f"File not found: {source_path}") # noqa: TRY301
|
|
91
|
+
with source_path.open("r", encoding="utf-8") as f:
|
|
92
|
+
self._content = f.read()
|
|
93
|
+
logger.debug("Successfully read spec from file.")
|
|
94
|
+
except FileNotFoundError as e:
|
|
95
|
+
logger.exception("OpenAPI spec file not found at '%s'", self.source)
|
|
96
|
+
err_msg = f"OpenAPI spec file not found at '{self.source}'"
|
|
97
|
+
raise SpecLoaderError(err_msg) from e
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.exception("Error reading OpenAPI spec file '%s'", self.source)
|
|
100
|
+
err_msg = f"Error reading OpenAPI spec file '{self.source}': {e}"
|
|
101
|
+
raise SpecLoaderError(err_msg) from e
|
|
102
|
+
|
|
103
|
+
def _parse_and_validate(self) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Parse the loaded content and validate the spec.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
SpecLoaderError: If parsing or validation fails.
|
|
109
|
+
"""
|
|
110
|
+
if self._content is None:
|
|
111
|
+
logger.error("Internal error: Spec content not loaded before parsing.")
|
|
112
|
+
err_msg = "Internal error: Spec content not loaded before parsing."
|
|
113
|
+
raise SpecLoaderError(err_msg)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
logger.debug("Parsing spec content (YAML/JSON).")
|
|
117
|
+
self.spec = yaml.safe_load(self._content)
|
|
118
|
+
if not isinstance(self.spec, dict):
|
|
119
|
+
err_msg = "Parsed specification is not a valid dictionary structure."
|
|
120
|
+
exception = SpecLoaderError(err_msg)
|
|
121
|
+
logger.error(err_msg, exc_info=exception)
|
|
122
|
+
raise exception # noqa: TRY301
|
|
123
|
+
|
|
124
|
+
logger.debug("Validating OpenAPI spec structure.")
|
|
125
|
+
validate_spec(self.spec)
|
|
126
|
+
logger.debug("OpenAPI spec structure validation successful.")
|
|
127
|
+
|
|
128
|
+
except yaml.YAMLError as e:
|
|
129
|
+
logger.exception("Error parsing OpenAPI spec content (YAML/JSON)")
|
|
130
|
+
err_msg = f"Error parsing OpenAPI spec content (YAML/JSON): {e}"
|
|
131
|
+
raise SpecLoaderError(err_msg) from e
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.exception("OpenAPI spec validation failed")
|
|
134
|
+
err_msg = f"OpenAPI spec validation failed: {e}"
|
|
135
|
+
raise SpecLoaderError(err_msg) from e
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Testing components for MCP servers."""
|
|
2
|
+
|
|
3
|
+
from openapi_to_mcp.adapters.testing.models import (
|
|
4
|
+
ConnectionSettings,
|
|
5
|
+
ServerTestRequest,
|
|
6
|
+
)
|
|
7
|
+
from openapi_to_mcp.adapters.testing.response_formatting import (
|
|
8
|
+
McpResult,
|
|
9
|
+
format_mcp_error,
|
|
10
|
+
format_mcp_response,
|
|
11
|
+
)
|
|
12
|
+
from openapi_to_mcp.adapters.testing.server_tester import execute_mcp_server
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ConnectionSettings",
|
|
16
|
+
"McpResult",
|
|
17
|
+
"ServerTestRequest",
|
|
18
|
+
"execute_mcp_server",
|
|
19
|
+
"format_mcp_error",
|
|
20
|
+
"format_mcp_response",
|
|
21
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Models and contracts for MCP server testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from openapi_to_mcp.common import OpenApiMcpError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ConnectionSettings:
|
|
13
|
+
"""Connection details for an MCP server test."""
|
|
14
|
+
|
|
15
|
+
server_cmd: str | None = None
|
|
16
|
+
endpoint_url: str | None = None
|
|
17
|
+
env: dict[str, str] | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ServerTestRequest:
|
|
22
|
+
"""Describe one MCP request and its connection settings."""
|
|
23
|
+
|
|
24
|
+
transport: str
|
|
25
|
+
method: str
|
|
26
|
+
params: dict[str, Any] | None = None
|
|
27
|
+
req_id: int = 1
|
|
28
|
+
connection: ConnectionSettings = field(default_factory=ConnectionSettings)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnsupportedMethodError(OpenApiMcpError):
|
|
32
|
+
"""Raised when an unsupported MCP method is requested."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ServerConnectionError(OpenApiMcpError):
|
|
36
|
+
"""Raised when an MCP server connection fails."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TransportStrategy:
|
|
40
|
+
"""Base contract for MCP testing transports."""
|
|
41
|
+
|
|
42
|
+
async def connect_and_execute(
|
|
43
|
+
self, method: str, params: dict[str, Any] | None, req_id: int
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
"""Connect to the server and execute the requested method."""
|
|
46
|
+
raise NotImplementedError("Subclasses must implement this method")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""JSON-RPC response formatting for MCP server tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Protocol, TypeGuard, cast
|
|
7
|
+
|
|
8
|
+
from mcp.types import CallToolResult, ListToolsResult
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from mcp import MCPError
|
|
12
|
+
|
|
13
|
+
McpResult = ListToolsResult | CallToolResult | Mapping[str, Any]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _ModelLike(Protocol):
|
|
17
|
+
def model_dump(self, *, mode: str, by_alias: bool) -> dict[str, Any]:
|
|
18
|
+
"""Serialize model fields with their declared aliases."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_model_like(value: object) -> TypeGuard[_ModelLike]:
|
|
23
|
+
fields = getattr(type(value), "model_fields", None)
|
|
24
|
+
return callable(getattr(value, "model_dump", None)) and isinstance(fields, Mapping)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _model_fields(value: _ModelLike) -> Mapping[str, object]:
|
|
28
|
+
return cast(
|
|
29
|
+
"Mapping[str, object]",
|
|
30
|
+
type(value).model_fields,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _field_alias(field_name: str, field: object) -> str:
|
|
35
|
+
serialization_alias = getattr(field, "serialization_alias", None)
|
|
36
|
+
if isinstance(serialization_alias, str):
|
|
37
|
+
return serialization_alias
|
|
38
|
+
alias = getattr(field, "alias", None)
|
|
39
|
+
return alias if isinstance(alias, str) else field_name
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _serialize_model_member(value: object, dumped: object) -> object:
|
|
43
|
+
if _is_model_like(value):
|
|
44
|
+
return _serialize_model(value)
|
|
45
|
+
if isinstance(value, list) and isinstance(dumped, list):
|
|
46
|
+
return [
|
|
47
|
+
_serialize_model_member(item, serialized)
|
|
48
|
+
for item, serialized in zip(value, dumped, strict=True)
|
|
49
|
+
]
|
|
50
|
+
return dumped
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _serialize_model(value: _ModelLike) -> dict[str, Any]:
|
|
54
|
+
payload = cast(
|
|
55
|
+
"dict[str, Any]",
|
|
56
|
+
value.model_dump(mode="json", by_alias=True),
|
|
57
|
+
)
|
|
58
|
+
for field_name, field in _model_fields(value).items():
|
|
59
|
+
alias = _field_alias(field_name, field)
|
|
60
|
+
if alias not in payload:
|
|
61
|
+
continue
|
|
62
|
+
output_key = "meta" if alias == "_meta" else alias
|
|
63
|
+
serialized = _serialize_model_member(
|
|
64
|
+
getattr(value, field_name),
|
|
65
|
+
payload[alias],
|
|
66
|
+
)
|
|
67
|
+
if output_key != alias:
|
|
68
|
+
del payload[alias]
|
|
69
|
+
payload[output_key] = serialized
|
|
70
|
+
return payload
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def format_mcp_response(value: McpResult | None, req_id: int) -> dict[str, Any]:
|
|
74
|
+
"""Serialize an MCP result into a JSON-RPC response dictionary."""
|
|
75
|
+
if value is None:
|
|
76
|
+
return {
|
|
77
|
+
"jsonrpc": "2.0",
|
|
78
|
+
"id": req_id,
|
|
79
|
+
"error": {
|
|
80
|
+
"code": -32603,
|
|
81
|
+
"message": "Internal error: No response data received from server.",
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
response = dict(value) if isinstance(value, Mapping) else _serialize_model(value)
|
|
85
|
+
response.setdefault("jsonrpc", "2.0")
|
|
86
|
+
response.setdefault("id", req_id)
|
|
87
|
+
return response
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def format_mcp_error(error: MCPError, req_id: int) -> dict[str, Any]:
|
|
91
|
+
"""Serialize an MCP protocol error into a JSON-RPC error response."""
|
|
92
|
+
payload: dict[str, Any] = {"code": error.code, "message": error.message}
|
|
93
|
+
payload["data"] = error.data
|
|
94
|
+
return {"jsonrpc": "2.0", "id": req_id, "error": payload}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Orchestrate requests across MCP server testing transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from mcp import MCPError
|
|
9
|
+
|
|
10
|
+
from openapi_to_mcp.adapters.testing.models import (
|
|
11
|
+
ConnectionSettings,
|
|
12
|
+
ServerConnectionError,
|
|
13
|
+
ServerTestRequest,
|
|
14
|
+
TransportStrategy,
|
|
15
|
+
UnsupportedMethodError,
|
|
16
|
+
)
|
|
17
|
+
from openapi_to_mcp.adapters.testing.response_formatting import (
|
|
18
|
+
format_mcp_error,
|
|
19
|
+
)
|
|
20
|
+
from openapi_to_mcp.adapters.testing.stdio_transport import (
|
|
21
|
+
StdioTransport,
|
|
22
|
+
)
|
|
23
|
+
from openapi_to_mcp.adapters.testing.streamable_http_transport import (
|
|
24
|
+
DEFAULT_PROTOCOL_VERSION,
|
|
25
|
+
StreamableHttpTransport,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _create_transport_strategy(request: ServerTestRequest) -> TransportStrategy:
|
|
32
|
+
"""Create the transport strategy selected by a server test request."""
|
|
33
|
+
connection = request.connection
|
|
34
|
+
if request.transport == "stdio":
|
|
35
|
+
if not connection.server_cmd:
|
|
36
|
+
raise ValueError("server_cmd is required for stdio transport")
|
|
37
|
+
return StdioTransport(connection.server_cmd, connection.env)
|
|
38
|
+
if request.transport == "streamable-http":
|
|
39
|
+
if not connection.endpoint_url:
|
|
40
|
+
raise ValueError("endpoint_url is required for streamable-http transport")
|
|
41
|
+
return StreamableHttpTransport(connection.endpoint_url)
|
|
42
|
+
raise ValueError(f"Unsupported transport type: {request.transport}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def execute_mcp_server(request: ServerTestRequest) -> dict[str, Any]:
|
|
46
|
+
"""Execute one MCP server test request using its configured transport."""
|
|
47
|
+
logger.info(
|
|
48
|
+
"Testing MCP server via %s transport. Method: %s",
|
|
49
|
+
request.transport,
|
|
50
|
+
request.method,
|
|
51
|
+
)
|
|
52
|
+
try:
|
|
53
|
+
strategy = _create_transport_strategy(request)
|
|
54
|
+
return await strategy.connect_and_execute(
|
|
55
|
+
request.method,
|
|
56
|
+
request.params,
|
|
57
|
+
request.req_id,
|
|
58
|
+
)
|
|
59
|
+
except MCPError as error:
|
|
60
|
+
logger.exception(
|
|
61
|
+
"MCP error during %s test for method '%s'",
|
|
62
|
+
request.transport,
|
|
63
|
+
request.method,
|
|
64
|
+
)
|
|
65
|
+
return format_mcp_error(error, request.req_id)
|
|
66
|
+
except Exception:
|
|
67
|
+
logger.exception(
|
|
68
|
+
"Unexpected error during %s MCP test for method '%s'",
|
|
69
|
+
request.transport,
|
|
70
|
+
request.method,
|
|
71
|
+
)
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"DEFAULT_PROTOCOL_VERSION",
|
|
77
|
+
"ConnectionSettings",
|
|
78
|
+
"ServerConnectionError",
|
|
79
|
+
"ServerTestRequest",
|
|
80
|
+
"StdioTransport",
|
|
81
|
+
"StreamableHttpTransport",
|
|
82
|
+
"TransportStrategy",
|
|
83
|
+
"UnsupportedMethodError",
|
|
84
|
+
"execute_mcp_server",
|
|
85
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Stdio transport for MCP server tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import shlex
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from mcp import ClientSession, MCPError, StdioServerParameters, stdio_client
|
|
10
|
+
|
|
11
|
+
from openapi_to_mcp.adapters.testing.models import (
|
|
12
|
+
ServerConnectionError,
|
|
13
|
+
TransportStrategy,
|
|
14
|
+
UnsupportedMethodError,
|
|
15
|
+
)
|
|
16
|
+
from openapi_to_mcp.adapters.testing.response_formatting import (
|
|
17
|
+
format_mcp_error,
|
|
18
|
+
format_mcp_response,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from mcp.types import CallToolResult, ListToolsResult
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def perform_mcp_request(
|
|
28
|
+
session: ClientSession, method: str, params: dict[str, Any] | None
|
|
29
|
+
) -> ListToolsResult | CallToolResult:
|
|
30
|
+
"""Perform one tools/list or tools/call request through a client session."""
|
|
31
|
+
if method == "list":
|
|
32
|
+
logger.info("Sending ListTools request")
|
|
33
|
+
return await session.list_tools()
|
|
34
|
+
if method == "call":
|
|
35
|
+
if params is None or "tool_name" not in params:
|
|
36
|
+
raise ValueError("Missing 'tool_name' in params for tool call method.")
|
|
37
|
+
tool_name = params["tool_name"]
|
|
38
|
+
logger.info("Sending CallTool request for tool: %s", tool_name)
|
|
39
|
+
return await session.call_tool(
|
|
40
|
+
name=tool_name,
|
|
41
|
+
arguments=params.get("tool_arguments"),
|
|
42
|
+
)
|
|
43
|
+
raise UnsupportedMethodError(f"Unsupported method for testing: {method}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StdioTransport(TransportStrategy):
|
|
47
|
+
"""Execute MCP requests through a managed stdio client session."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, server_cmd: str, env: dict[str, str] | None = None) -> None:
|
|
50
|
+
if not server_cmd:
|
|
51
|
+
raise ValueError("server_cmd is required for stdio transport")
|
|
52
|
+
self.server_cmd = server_cmd
|
|
53
|
+
self.env = env
|
|
54
|
+
|
|
55
|
+
async def connect_and_execute(
|
|
56
|
+
self, method: str, params: dict[str, Any] | None, req_id: int
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""Connect over stdio and return a JSON-RPC response dictionary."""
|
|
59
|
+
command = shlex.split(self.server_cmd)
|
|
60
|
+
stdio_params = StdioServerParameters(
|
|
61
|
+
command=command[0],
|
|
62
|
+
args=command[1:],
|
|
63
|
+
env=self.env,
|
|
64
|
+
)
|
|
65
|
+
try:
|
|
66
|
+
async with (
|
|
67
|
+
stdio_client(stdio_params) as (read, write),
|
|
68
|
+
ClientSession(read, write) as session,
|
|
69
|
+
):
|
|
70
|
+
await session.initialize()
|
|
71
|
+
result = await perform_mcp_request(session, method, params)
|
|
72
|
+
return format_mcp_response(result, req_id)
|
|
73
|
+
except MCPError as error:
|
|
74
|
+
logger.info(
|
|
75
|
+
"Received MCP error response over stdio: %s",
|
|
76
|
+
error.message,
|
|
77
|
+
)
|
|
78
|
+
return format_mcp_error(error, req_id)
|
|
79
|
+
except Exception as error:
|
|
80
|
+
logger.exception("Error during stdio connection")
|
|
81
|
+
raise ServerConnectionError(
|
|
82
|
+
f"Failed to connect via stdio: {error}"
|
|
83
|
+
) from error
|