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.
Files changed (103) hide show
  1. openapi_to_mcp/__init__.py +3 -0
  2. openapi_to_mcp/adapters/__init__.py +1 -0
  3. openapi_to_mcp/adapters/generator.py +206 -0
  4. openapi_to_mcp/adapters/spec_loader.py +135 -0
  5. openapi_to_mcp/adapters/testing/__init__.py +21 -0
  6. openapi_to_mcp/adapters/testing/models.py +46 -0
  7. openapi_to_mcp/adapters/testing/response_formatting.py +94 -0
  8. openapi_to_mcp/adapters/testing/server_tester.py +85 -0
  9. openapi_to_mcp/adapters/testing/stdio_transport.py +83 -0
  10. openapi_to_mcp/adapters/testing/streamable_http_transport.py +163 -0
  11. openapi_to_mcp/cli.py +45 -0
  12. openapi_to_mcp/commands/__init__.py +15 -0
  13. openapi_to_mcp/commands/diff.py +138 -0
  14. openapi_to_mcp/commands/doctor.py +97 -0
  15. openapi_to_mcp/commands/generate.py +78 -0
  16. openapi_to_mcp/commands/generation_context.py +109 -0
  17. openapi_to_mcp/commands/generation_models.py +163 -0
  18. openapi_to_mcp/commands/generation_service.py +142 -0
  19. openapi_to_mcp/commands/options.py +153 -0
  20. openapi_to_mcp/commands/policy_support.py +22 -0
  21. openapi_to_mcp/commands/run.py +102 -0
  22. openapi_to_mcp/commands/run_support.py +119 -0
  23. openapi_to_mcp/commands/runtime_overrides.py +164 -0
  24. openapi_to_mcp/commands/test_server.py +160 -0
  25. openapi_to_mcp/common/__init__.py +25 -0
  26. openapi_to_mcp/common/decorators.py +50 -0
  27. openapi_to_mcp/common/error_policy.py +12 -0
  28. openapi_to_mcp/common/exceptions.py +34 -0
  29. openapi_to_mcp/common/logger.py +78 -0
  30. openapi_to_mcp/common/performance_presets.py +79 -0
  31. openapi_to_mcp/common/terminal.py +61 -0
  32. openapi_to_mcp/common/tool_runtime.py +81 -0
  33. openapi_to_mcp/common/utils.py +89 -0
  34. openapi_to_mcp/diff/__init__.py +6 -0
  35. openapi_to_mcp/diff/analyzer.py +163 -0
  36. openapi_to_mcp/diff/models.py +70 -0
  37. openapi_to_mcp/diff/surface.py +110 -0
  38. openapi_to_mcp/doctor/__init__.py +6 -0
  39. openapi_to_mcp/doctor/analyzer.py +192 -0
  40. openapi_to_mcp/doctor/models.py +75 -0
  41. openapi_to_mcp/doctor/security.py +69 -0
  42. openapi_to_mcp/mapping/__init__.py +5 -0
  43. openapi_to_mcp/mapping/mapper.py +515 -0
  44. openapi_to_mcp/mapping/output_schema.py +122 -0
  45. openapi_to_mcp/mapping/tool_description.py +113 -0
  46. openapi_to_mcp/mapping/tool_examples.py +145 -0
  47. openapi_to_mcp/mapping/tool_grouping.py +87 -0
  48. openapi_to_mcp/mapping/utils.py +59 -0
  49. openapi_to_mcp/policy/__init__.py +7 -0
  50. openapi_to_mcp/policy/applier.py +208 -0
  51. openapi_to_mcp/policy/loader.py +56 -0
  52. openapi_to_mcp/policy/models.py +77 -0
  53. openapi_to_mcp/policy/parser.py +192 -0
  54. openapi_to_mcp/policy/settings.py +42 -0
  55. openapi_to_mcp/policy/validators.py +111 -0
  56. openapi_to_mcp/schema/__init__.py +5 -0
  57. openapi_to_mcp/schema/converter.py +199 -0
  58. openapi_to_mcp/schema/handlers/__init__.py +17 -0
  59. openapi_to_mcp/schema/handlers/array_schema.py +37 -0
  60. openapi_to_mcp/schema/handlers/base.py +76 -0
  61. openapi_to_mcp/schema/handlers/common.py +55 -0
  62. openapi_to_mcp/schema/handlers/composition.py +41 -0
  63. openapi_to_mcp/schema/handlers/number_schema.py +58 -0
  64. openapi_to_mcp/schema/handlers/object_schema.py +46 -0
  65. openapi_to_mcp/schema/handlers/reference.py +142 -0
  66. openapi_to_mcp/schema/handlers/string_schema.py +63 -0
  67. openapi_to_mcp/templates/.env.example.j2 +63 -0
  68. openapi_to_mcp/templates/README.md.j2 +137 -0
  69. openapi_to_mcp/templates/package.json.j2 +39 -0
  70. openapi_to_mcp/templates/src/custom/tools.ts.j2 +5 -0
  71. openapi_to_mcp/templates/src/index.ts.j2 +21 -0
  72. openapi_to_mcp/templates/src/runtime/audit.ts.j2 +98 -0
  73. openapi_to_mcp/templates/src/runtime/auth.ts.j2 +118 -0
  74. openapi_to_mcp/templates/src/runtime/cache.ts.j2 +100 -0
  75. openapi_to_mcp/templates/src/runtime/circuit_breaker.ts.j2 +153 -0
  76. openapi_to_mcp/templates/src/runtime/config.ts.j2 +169 -0
  77. openapi_to_mcp/templates/src/runtime/errors.ts.j2 +169 -0
  78. openapi_to_mcp/templates/src/runtime/executor.ts.j2 +135 -0
  79. openapi_to_mcp/templates/src/runtime/executor_support.ts.j2 +164 -0
  80. openapi_to_mcp/templates/src/runtime/generated.ts.j2 +60 -0
  81. openapi_to_mcp/templates/src/runtime/http_transport.ts.j2 +78 -0
  82. openapi_to_mcp/templates/src/runtime/limiter.ts.j2 +77 -0
  83. openapi_to_mcp/templates/src/runtime/observability.ts.j2 +24 -0
  84. openapi_to_mcp/templates/src/runtime/performance_preset.ts.j2 +60 -0
  85. openapi_to_mcp/templates/src/runtime/rate_limit.ts.j2 +47 -0
  86. openapi_to_mcp/templates/src/runtime/redaction.ts.j2 +111 -0
  87. openapi_to_mcp/templates/src/runtime/request.ts.j2 +185 -0
  88. openapi_to_mcp/templates/src/runtime/request_context.ts.j2 +48 -0
  89. openapi_to_mcp/templates/src/runtime/resilience.ts.j2 +120 -0
  90. openapi_to_mcp/templates/src/runtime/response.ts.j2 +32 -0
  91. openapi_to_mcp/templates/src/runtime/retry.ts.j2 +61 -0
  92. openapi_to_mcp/templates/src/runtime/serialization.ts.j2 +167 -0
  93. openapi_to_mcp/templates/src/runtime/tool_access.ts.j2 +122 -0
  94. openapi_to_mcp/templates/src/runtime/validation.ts.j2 +53 -0
  95. openapi_to_mcp/templates/src/server.ts.j2 +151 -0
  96. openapi_to_mcp/templates/src/transport_stdio.ts.j2 +18 -0
  97. openapi_to_mcp/templates/src/transport_streamable_http.ts.j2 +169 -0
  98. openapi_to_mcp/templates/tsconfig.json.j2 +21 -0
  99. openapi_to_mcp_cli-0.9.1.dist-info/METADATA +131 -0
  100. openapi_to_mcp_cli-0.9.1.dist-info/RECORD +103 -0
  101. openapi_to_mcp_cli-0.9.1.dist-info/WHEEL +4 -0
  102. openapi_to_mcp_cli-0.9.1.dist-info/entry_points.txt +2 -0
  103. openapi_to_mcp_cli-0.9.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,3 @@
1
+ """OpenAPI to MCP
2
+ This module provides functionality to convert OpenAPI specifications to MCP (Model Context Protocol) format.
3
+ """
@@ -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