Skip to content

Commit 69f1d3f

Browse files
authored
Enhance OpenAPI parser to support directory input and JSON API files;… (#10)
* Enhance OpenAPI parser to support directory input and JSON API files; improve parameter handling and sanitization * Enhance testing framework by adding JSON fixtures for API responses and schemas; update generator script execution steps in CI workflow * Key Updates Made: 🔄 Structure & Organization Broader scope: Changed from "oneOf Test Case" to comprehensive "Test Suite" documentation Logical flow: Organized content from overview → setup → detailed test cases → verification Clear sections: Better categorization of different aspects being tested 📋 New Content Added Test Coverage overview: Explains what the entire test suite validates Input Formats section: Documents both YAML and JSON specification testing Automated Test Details: Explains how the parametrized tests work CI/CD Integration: Documents how tests integrate with GitHub Actions Test output examples: Shows what successful test runs look like 🎯 oneOf Test Case Repositioned Kept the oneOf content but placed it as "Test Case 1" in the detailed section Added JSON specifications as "Test Case 2" for completeness Maintained technical details about $ref resolution and oneOf handling 🚀 Enhanced Instructions Dual generation commands: Shows how to generate both server types Updated paths: Uses correct tests directory structure Comprehensive testing: Includes both automated and manual verification steps CI integration: Explains how the workflow generates and tests both cases ✅ What's Now Covered Multiple input formats (YAML + JSON directories) Adaptive testing approach (different tests for different server types) Comprehensive verification (automated + manual + CI) Clear examples (test output, commands, expected results) Technical details (oneOf, $ref, parameter resolution) The README now properly reflects the current sophisticated test setup that handles both generation sources and provides comprehensive validation of the generator's capabilities! 🎉
1 parent 6dd8943 commit 69f1d3f

File tree

12 files changed

+863
-112
lines changed

12 files changed

+863
-112
lines changed

.github/workflows/generate-and-test.yml

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
run: |
2929
pip install -e .
3030
31-
- name: Run generator script
31+
- name: Run generator script (OpenAPI YAML)
3232
run: |
3333
mkdir -p tests/out/
3434
python generator.py \
@@ -37,26 +37,40 @@ jobs:
3737
--api-url http://localhost:8000/api \
3838
--api-token "test-token"
3939
40-
- name: Run generator via CLI tool
40+
- name: Run generator script (JSON specifications)
41+
run: |
42+
python generator.py \
43+
tests/test_fixtures/ \
44+
--output-dir ./tests/out/ \
45+
--api-url http://localhost:8000/api \
46+
--api-token "test-token"
47+
48+
- name: Run generator via CLI tool (OpenAPI YAML)
4149
run: |
4250
mkdir -p tests/out_cli/
4351
mcp-generator tests/openapi.yaml --output-dir ./tests/out_cli/ --api-url http://localhost:8000/api --api-token "test-token"
4452
45-
- name: Run generator via Python module
53+
- name: Run generator via CLI tool (JSON specifications)
54+
run: |
55+
mcp-generator tests/test_fixtures/ --output-dir ./tests/out_cli/ --api-url http://localhost:8000/api --api-token "test-token"
56+
57+
- name: Run generator via Python module (OpenAPI YAML)
4658
run: |
4759
mkdir -p tests/out_module/
4860
python -m openapi_mcp_generator.cli tests/openapi.yaml --output-dir ./tests/out_module/ --api-url http://localhost:8000/api --api-token "test-token"
4961
50-
- name: Verify output directory exists
62+
- name: Run generator via Python module (JSON specifications)
5163
run: |
52-
ls ./tests/out/ | grep "openapi-mcp-reference-test-api-"
53-
shell: bash
64+
python -m openapi_mcp_generator.cli tests/test_fixtures/ --output-dir ./tests/out_module/ --api-url http://localhost:8000/api --api-token "test-token"
5465
55-
- name: Verify generated mcp_server.py
66+
- name: Verify output directories exist
5667
run: |
57-
GENERATED_DIR=$(ls ./tests/out/ | grep "openapi-mcp-reference-test-api-" | head -n 1)
58-
grep -q "async def getItems(id: int, verbose: bool, limit: int, ctx: Context) -> str:" ./tests/out/$GENERATED_DIR/mcp_server.py
59-
grep -q "def get_BadRequestDetails_schema" ./tests/out/$GENERATED_DIR/mcp_server.py
68+
echo "Checking tests/out/ directory:"
69+
ls ./tests/out/ | grep -E "(openapi-mcp-reference-test-api-|openapi-mcp-generated-api-)"
70+
echo "Checking tests/out_cli/ directory:"
71+
ls ./tests/out_cli/ | grep -E "(openapi-mcp-reference-test-api-|openapi-mcp-generated-api-)"
72+
echo "Checking tests/out_module/ directory:"
73+
ls ./tests/out_module/ | grep -E "(openapi-mcp-reference-test-api-|openapi-mcp-generated-api-)"
6074
shell: bash
6175

6276
- name: Install pytest

generator.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,33 @@
3939

4040
def parse_openapi_spec(filepath: str) -> Dict[str, Any]:
4141
"""
42-
Parse an OpenAPI specification file.
42+
Parse an OpenAPI specification file or directory.
4343
4444
Args:
45-
filepath: Path to the OpenAPI YAML file
45+
filepath: Path to the OpenAPI YAML file or directory
4646
4747
Returns:
4848
Dictionary containing the parsed OpenAPI specification
4949
5050
Raises:
51-
SystemExit: If the file cannot be read or parsed
51+
SystemExit: If the file/directory cannot be read or parsed
5252
"""
53+
# Try to use the modular parser first
54+
if USE_MODULAR:
55+
try:
56+
from openapi_mcp_generator.parser import parse_openapi_spec as modular_parse
57+
return modular_parse(filepath)
58+
except ImportError:
59+
pass
60+
61+
# Fallback to original implementation for single YAML files only
5362
if not os.path.exists(filepath):
5463
print(f"Error: OpenAPI specification file not found: {filepath}")
5564
sys.exit(1)
65+
66+
if os.path.isdir(filepath):
67+
print(f"Error: Directory processing requires the modular parser. Please install the package.")
68+
sys.exit(1)
5669

5770
try:
5871
with open(filepath, 'r', encoding='utf-8') as f:
@@ -101,7 +114,7 @@ def generate_tool_definitions(spec: Dict[str, Any]) -> str:
101114
# Get parameters
102115
parameters_definitions = []
103116
for param_obj in operation.get('parameters', []):
104-
actual_param = resolve_ref(param_obj, spec) if '$ref' in param_obj else param_obj
117+
actual_param = resolve_ref(spec, param_obj['$ref']) if '$ref' in param_obj else param_obj
105118
if not actual_param:
106119
print(f"Warning: Could not resolve parameter reference: {param_obj}")
107120
continue

openapi_mcp_generator/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
"""
66

77
from .generator import generate_mcp_server
8-
from .parser import parse_openapi_spec, sanitize_description
8+
from .parser import parse_openapi_spec, sanitize_description, sanitize_identifier, escape_string_literal
99
from .generators import generate_tool_definitions, generate_resource_definitions
1010

1111
__all__ = [
1212
'generate_mcp_server',
1313
'parse_openapi_spec',
1414
'sanitize_description',
15+
'sanitize_identifier',
16+
'escape_string_literal',
1517
'generate_tool_definitions',
1618
'generate_resource_definitions',
1719
]

openapi_mcp_generator/generators.py

Lines changed: 118 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
"""
77

88
import yaml
9-
from typing import Dict, Any, List
10-
from .parser import sanitize_description, resolve_ref
9+
from typing import Dict, Any, List, Tuple
10+
from .parser import sanitize_description, sanitize_identifier, escape_string_literal, resolve_ref
1111

1212

1313
def generate_tool_definitions(spec: Dict[str, Any]) -> str:
@@ -49,14 +49,17 @@ def _generate_tool(spec: Dict[str, Any], path: str, method: str, operation: Dict
4949
if 'operationId' not in operation:
5050
return ""
5151

52-
operation_id = operation['operationId']
53-
description = sanitize_description(operation.get('description', f"{method.upper()} {path}"))
52+
operation_id = sanitize_identifier(operation['operationId'])
53+
description = escape_string_literal(operation.get('description', f"{method.upper()} {path}"))
5454

55-
# Get parameters
56-
parameters_definitions = _get_parameter_definitions(spec, operation)
55+
# Get parameters separated by required vs optional
56+
required_params, optional_params = _get_parameter_definitions(spec, operation)
5757

58-
# Add ctx parameter
59-
parameters_definitions.append("ctx: Context")
58+
# Combine parameters in correct order: required params, ctx, optional params
59+
parameters_definitions = required_params + ["ctx: Context"] + optional_params
60+
61+
# Generate parameter processing code
62+
param_processing = _generate_parameter_processing(spec, operation, path)
6063

6164
# Create tool function
6265
return f"""
@@ -67,18 +70,13 @@ async def {operation_id}({', '.join(parameters_definitions)}) -> str:
6770
\"\"\"
6871
async with await get_http_client() as client:
6972
try:
70-
# Build the URL with path parameters
71-
url = "{path}"
72-
73-
# Extract query parameters
74-
query_params = {{}}
75-
# ... build query params from function args
73+
{param_processing}
7674
7775
# Make the request
7876
response = await client.{method}(
7977
url,
8078
params=query_params,
81-
# Add other parameters as needed
79+
json=request_body if request_body else None
8280
)
8381
8482
# Check if the request was successful
@@ -94,18 +92,21 @@ async def {operation_id}({', '.join(parameters_definitions)}) -> str:
9492
"""
9593

9694

97-
def _get_parameter_definitions(spec: Dict[str, Any], operation: Dict[str, Any]) -> List[str]:
95+
def _get_parameter_definitions(spec: Dict[str, Any], operation: Dict[str, Any]) -> Tuple[List[str], List[str]]:
9896
"""
99-
Get parameter definitions for a tool function.
97+
Get parameter definitions for a tool function, separated by required vs optional.
10098
10199
Args:
102100
spec: The parsed OpenAPI specification
103101
operation: The operation definition
104102
105103
Returns:
106-
List of parameter definition strings
104+
Tuple of (required_parameters, optional_parameters) definition strings
107105
"""
108-
parameters_definitions = []
106+
required_params = []
107+
optional_params = []
108+
seen_params = set() # Track seen parameter names to avoid duplicates
109+
109110
for param_obj in operation.get('parameters', []):
110111
actual_param = {}
111112
if '$ref' in param_obj:
@@ -118,12 +119,34 @@ def _get_parameter_definitions(spec: Dict[str, Any], operation: Dict[str, Any])
118119
print(f"Warning: Skipping parameter due to missing name or unresolved reference: {param_obj}")
119120
continue
120121

121-
param_name = actual_param['name']
122+
param_name = sanitize_identifier(actual_param['name'])
123+
124+
# Handle duplicate parameter names
125+
original_param_name = param_name
126+
counter = 1
127+
while param_name in seen_params:
128+
param_name = f"{original_param_name}_{counter}"
129+
counter += 1
130+
131+
seen_params.add(param_name)
122132
param_type = _get_param_type(actual_param)
123133

124-
parameters_definitions.append(f"{param_name}: {param_type}")
134+
# Separate required and optional parameters
135+
if actual_param.get('required', False):
136+
required_params.append(f"{param_name}: {param_type}")
137+
else:
138+
# Add default value for optional parameters
139+
if param_type == 'bool':
140+
param_type = f"{param_type} = False"
141+
elif param_type == 'str':
142+
param_type = f"{param_type} = ''"
143+
elif param_type in ['int', 'float']:
144+
param_type = f"{param_type} = 0"
145+
else:
146+
param_type = f"Optional[{param_type}] = None"
147+
optional_params.append(f"{param_name}: {param_type}")
125148

126-
return parameters_definitions
149+
return required_params, optional_params
127150

128151

129152
def _get_param_type(param: Dict[str, Any]) -> str:
@@ -151,6 +174,68 @@ def _get_param_type(param: Dict[str, Any]) -> str:
151174
return param_type
152175

153176

177+
def _generate_parameter_processing(spec: Dict[str, Any], operation: Dict[str, Any], path: str) -> str:
178+
"""
179+
Generate parameter processing code for a tool function.
180+
181+
Args:
182+
spec: The parsed OpenAPI specification
183+
operation: The operation definition
184+
path: The API path
185+
186+
Returns:
187+
String containing parameter processing code
188+
"""
189+
lines = []
190+
lines.append(" # Build the URL with path parameters")
191+
lines.append(f" url = \"{path}\"")
192+
lines.append("")
193+
lines.append(" # Extract query parameters")
194+
lines.append(" query_params = {}")
195+
lines.append(" request_body = None")
196+
lines.append("")
197+
198+
# Process parameters
199+
seen_params = set()
200+
for param_obj in operation.get('parameters', []):
201+
actual_param = {}
202+
if '$ref' in param_obj:
203+
ref_path = param_obj['$ref']
204+
actual_param = resolve_ref(spec, ref_path)
205+
else:
206+
actual_param = param_obj
207+
208+
if not actual_param or 'name' not in actual_param:
209+
continue
210+
211+
param_name = sanitize_identifier(actual_param['name'])
212+
original_param_name = param_name
213+
214+
# Handle duplicate parameter names
215+
counter = 1
216+
while param_name in seen_params:
217+
param_name = f"{original_param_name}_{counter}"
218+
counter += 1
219+
seen_params.add(param_name)
220+
221+
param_in = actual_param.get('in', 'query')
222+
original_name = actual_param['name']
223+
224+
if param_in == 'path':
225+
# Replace path parameters in URL
226+
lines.append(f" if {param_name} is not None:")
227+
lines.append(f" url = url.replace('{{{original_name}}}', str({param_name}))")
228+
elif param_in == 'query':
229+
# Add to query parameters
230+
lines.append(f" if {param_name} is not None:")
231+
lines.append(f" query_params['{original_name}'] = {param_name}")
232+
elif param_in == 'header':
233+
# We'll handle headers separately if needed
234+
pass
235+
236+
return "\n".join(lines)
237+
238+
154239
def generate_resource_definitions(spec: Dict[str, Any]) -> str:
155240
"""
156241
Generate MCP resource definitions from OpenAPI components.
@@ -185,9 +270,9 @@ def _generate_api_info_resource(spec: Dict[str, Any]) -> str:
185270
String containing the generated resource definition
186271
"""
187272
info = spec.get('info', {})
188-
api_title = info.get('title', 'API')
189-
api_version = info.get('version', '1.0.0')
190-
api_description = sanitize_description(info.get('description', 'API description'))
273+
api_title = escape_string_literal(info.get('title', 'API'))
274+
api_version = escape_string_literal(info.get('version', '1.0.0'))
275+
api_description = escape_string_literal(info.get('description', 'API description'))
191276

192277
return f"""
193278
@mcp.resource("api://info")
@@ -216,14 +301,18 @@ def _generate_schema_resources(spec: Dict[str, Any]) -> List[str]:
216301
schema_resources = []
217302

218303
for schema_name, schema in spec.get('components', {}).get('schemas', {}).items():
304+
safe_schema_name = sanitize_identifier(schema_name)
305+
escaped_schema_name = escape_string_literal(schema_name)
306+
schema_yaml = escape_string_literal(yaml.dump(schema, default_flow_style=False))
307+
219308
resource_def = f"""
220-
@mcp.resource("schema://{schema_name}")
221-
def get_{schema_name}_schema() -> str:
309+
@mcp.resource("schema://{escaped_schema_name}")
310+
def get_{safe_schema_name}_schema() -> str:
222311
\"\"\"
223-
Get the {schema_name} schema definition
312+
Get the {escaped_schema_name} schema definition
224313
\"\"\"
225314
return \"\"\"
226-
{yaml.dump(schema, default_flow_style=False)}
315+
{schema_yaml}
227316
\"\"\"
228317
"""
229318
schema_resources.append(resource_def)

0 commit comments

Comments
 (0)