Skip to content

Commit 0c5fdad

Browse files
Merge pull request #9 from openapi/dev
Refactor environment setup and improve pytest coverage
2 parents c24d360 + aa2890d commit 0c5fdad

13 files changed

Lines changed: 135 additions & 72 deletions

File tree

docs/env/README.md

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,6 @@ cache backend, and any platform-specific notes.
2626
| `MCP_CACHE_PORT` | `11211` | Memcached port |
2727
| `MCP_CACHE_URL` | _(none)_ | Redis connection URL (used when `MCP_CACHE_BACKEND=redis`) |
2828

29-
### Legacy variables (GCP Cloud Run — deprecated)
30-
31-
These are still read by the current codebase for backward compatibility with the
32-
existing GCP deployment. Prefer the explicit variables above for new deployments.
33-
34-
| Variable | Description |
35-
|---|---|
36-
| `K_SERVICE` | Cloud Run service name — auto-injects `MCP_BASE_URL`, `MCP_OPENAPI_ENV`, Memcached IPs |
37-
3829
---
3930

4031
## Why storage matters

docs/env/gcp.md

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,20 +43,6 @@ MCP_CACHE_PORT=11211
4343

4444
---
4545

46-
## Legacy variables (backward compatibility)
47-
48-
If you are using a Cloud Run deployment without migrating to the
49-
explicit variables above, the following variables are still read:
50-
51-
| Variable | Effect |
52-
|---|---|
53-
| `K_SERVICE` | Auto-set by Cloud Run. Derives `MCP_BASE_URL` (`K_SERVICE.replace("-",".")`) and `MCP_OPENAPI_ENV` (from service name prefix), and selects the Memcached VPC IP. |
54-
55-
> **Migration path:** set `MCP_BASE_URL`, `MCP_OPENAPI_ENV`, `MCP_CACHE_HOST` explicitly
56-
> and stop relying on `K_SERVICE`. This makes the server portable to any platform.
57-
58-
---
59-
6046
## Cloud Run deployment
6147

6248
### Cloud Run service YAML

docs/testing.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ MCP_URL=https://abcd1234.ngrok-free.app SANDBOX=1 make test-openai-sandbox
126126
| Data | real | simulated |
127127
| Token variable | `OPENAPI_TOKEN` | `OPENAPI_SANDBOX_TOKEN` |
128128

129-
The MCP server switches between production and sandbox based on the `K_SERVICE` environment
130-
variable (handled automatically by the test runners).
129+
The MCP server switches between production and sandbox based on the `MCP_OPENAPI_ENV`
130+
environment variable (handled automatically by the test runners).
131131

132132
---
133133

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,8 @@ dependencies = [
2424
[tool.setuptools]
2525
license-files = ["LICENSE"]
2626

27+
[tool.pytest.ini_options]
28+
testpaths = ["tests/pytest"]
29+
2730
[project.scripts]
2831
openapi-mcp-sdk = "openapi_mcp_sdk.cli:main"

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ fastmcp
1818
requests
1919
pydantic
2020
uvicorn
21+
pytest

src/openapi_mcp_sdk/main.py

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""ASGI entrypoint for the OpenAPI MCP server."""
2+
13
import os
24
import re
35
import sys
@@ -10,6 +12,23 @@
1012
from starlette.types import ASGIApp, Scope, Receive, Send, Message
1113
from .mcp_audit import McpAuditMiddleware
1214
from .storage_backend import read_file
15+
from .memory_store import get_callback_result, set_callback_result
16+
from .mcp_core import mcp
17+
from .apis import (
18+
async_tool,
19+
automotive,
20+
cap,
21+
company,
22+
docuengine,
23+
exchange,
24+
geocoding,
25+
info,
26+
pec,
27+
risk,
28+
sms,
29+
trust,
30+
visurecamerali,
31+
)
1332

1433
# ---------------------------------------------------------------------------
1534
# Bootstrap package logger early — before uvicorn configures its own logging.
@@ -59,12 +78,8 @@ def format(self, record: logging.LogRecord) -> str:
5978
# Fallback: at least mask tokens
6079
return self._TOKEN_RE.sub(r'\1token=***', result)
6180

62-
# Delegate everything else to the inner formatter
6381
def __getattr__(self, name):
6482
return getattr(self._inner, name)
65-
from .memory_store import get_callback_result, set_callback_result
66-
from .mcp_core import mcp # Import MCP instance with tools already registered in mcp_core.py
67-
from .apis import async_tool, company, cap, trust, visurecamerali, sms, risk, geocoding,automotive,exchange, pec, docuengine, info # Import tool modules (side-effect: triggers @mcp.tool registration)
6883

6984
# Create the MCP ASGI app mounted at root
7085
mcp_app = mcp.http_app(path='/')
@@ -168,19 +183,31 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
168183
client_ip = (scope.get("client") or ("?", 0))[0]
169184
async with registration_lock:
170185
if not getattr(app.state, "dynamic_tools_registered", False):
171-
_logger.info('%s %s "Initializing dynamic tools (token: %s...)"', "[JIT]", client_ip, token[:8])
186+
_logger.info(
187+
'%s %s "Initializing dynamic tools (token: %s...)"',
188+
"[JIT]",
189+
client_ip,
190+
token[:8],
191+
)
172192
success = await asyncio.to_thread(docuengine.init_dynamic_tools, token)
173193
if success:
174194
app.state.dynamic_tools_registered = True
175195
else:
176-
_logger.warning('%s %s "Registration failed — will retry on next request"', "[JIT]", client_ip)
196+
_logger.warning(
197+
'%s %s "Registration failed — will retry on next request"',
198+
"[JIT]",
199+
client_ip,
200+
)
177201

178202
await self.app(scope, receive, send)
179203

180204

181205
_OAUTH_NOT_SUPPORTED_BODY = json.dumps({
182206
"error": "oauth_not_supported",
183-
"message": "This server does not support OAuth. Use a pre-configured Bearer token in the Authorization header."
207+
"message": (
208+
"This server does not support OAuth. Use a pre-configured Bearer "
209+
"token in the Authorization header."
210+
),
184211
}).encode()
185212

186213
_OAUTH_DISCOVERY_PATHS = {
@@ -190,18 +217,25 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
190217
}
191218

192219
class RejectOAuthDiscoveryMiddleware:
193-
"""Return a JSON 404 for OAuth discovery endpoints so MCP clients fall back to Bearer auth."""
220+
"""Return a JSON 404 for OAuth discovery endpoints."""
194221
def __init__(self, app: ASGIApp) -> None:
195222
self.app = app
196223

197224
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
198-
if scope["type"] == "http" and scope["path"].rstrip("/") in {p.rstrip("/") for p in _OAUTH_DISCOVERY_PATHS}:
225+
if (
226+
scope["type"] == "http"
227+
and scope["path"].rstrip("/") in {p.rstrip("/") for p in _OAUTH_DISCOVERY_PATHS}
228+
):
199229
await send({
200230
"type": "http.response.start",
201231
"status": 404,
202232
"headers": [(b"content-type", b"application/json")],
203233
})
204-
await send({"type": "http.response.body", "body": _OAUTH_NOT_SUPPORTED_BODY, "more_body": False})
234+
await send({
235+
"type": "http.response.body",
236+
"body": _OAUTH_NOT_SUPPORTED_BODY,
237+
"more_body": False,
238+
})
205239
return
206240
await self.app(scope, receive, send)
207241

@@ -214,7 +248,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
214248
app.add_middleware(McpAuditMiddleware) # innermost — logs MCP JSON-RPC actions
215249
app.add_middleware(Enrich404Middleware) # wraps 404s in JSON
216250
app.add_middleware(TokenQuerystringMiddleware) # injects auth header + JIT registration
217-
app.add_middleware(RejectOAuthDiscoveryMiddleware) # short-circuits OAuth discovery paths
251+
app.add_middleware(RejectOAuthDiscoveryMiddleware) # short-circuits OAuth discovery paths
218252
# CORS must be outermost so it runs before anything else on every request,
219253
# including pre-flight OPTIONS. expose_headers exposes Mcp-Session-Id to browsers.
220254
# allow_credentials must NOT be True when allow_origins=["*"].
@@ -243,7 +277,9 @@ async def callbacks_endpoint(request: Request):
243277
return {"status": "error", "message": "Body not a valid JSON"}
244278

245279
cb_obj = callback.get("callback")
246-
custom = callback.get("custom") or (cb_obj.get("data") if isinstance(cb_obj, dict) else None)
280+
custom = callback.get("custom") or (
281+
cb_obj.get("data") if isinstance(cb_obj, dict) else None
282+
)
247283
if not custom:
248284
_logger.warning('%s %s "Missing callback.custom field"', "[CB]", client_ip)
249285
return {"status": "error", "message": "'callback.custom' missing from received data"}

src/openapi_mcp_sdk/mcp_audit.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import json
1515
import logging
16-
from starlette.types import ASGIApp, Scope, Receive, Send
16+
from starlette.types import ASGIApp, Scope, Receive, Send # pylint: disable=import-error
1717

1818
_log = logging.getLogger("openapi_mcp_sdk.audit")
1919

@@ -42,7 +42,7 @@ def _emit(body: bytes, client_ip: str) -> None:
4242
"""Parse a JSON-RPC body and emit a structured audit log line."""
4343
try:
4444
data = json.loads(body)
45-
except Exception:
45+
except json.JSONDecodeError:
4646
return
4747

4848
if not isinstance(data, dict) or "method" not in data:
@@ -79,7 +79,7 @@ def _emit(body: bytes, client_ip: str) -> None:
7979
_log.info('%s %s "%s"', _TAG, client_ip, label)
8080

8181

82-
class McpAuditMiddleware:
82+
class McpAuditMiddleware: # pylint: disable=too-few-public-methods
8383
"""Non-destructive ASGI middleware that logs MCP JSON-RPC calls.
8484
8585
Wraps the ``receive`` callable to buffer POST body chunks, emits the audit
Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
1-
# memory_store.py
2-
# Singleton in-memory store for callback results
1+
"""Shared storage for async callback results."""
2+
3+
from __future__ import annotations
4+
5+
import json
36
import os
47
import logging
58
from typing import Dict
69

710
logger = logging.getLogger(__name__)
811
try:
912
from pymemcache.client import base
10-
_pymemcache_available = True
13+
_PYMEMCACHE_AVAILABLE = True
1114
except ImportError:
1215
base = None # type: ignore[assignment]
13-
_pymemcache_available = False
14-
import json
16+
_PYMEMCACHE_AVAILABLE = False
1517

1618
callback_results = {}
1719

18-
MCP_STORAGE_BUCKET = os.getenv("MCP_STORAGE_BUCKET", "mcp-openapi-storage")
19-
2020
MCP_ENV = os.getenv("MCP_ENV", "production").strip().lower()
2121
if MCP_ENV not in {"dev", "staging", "production"}:
2222
logger.warning("Invalid MCP_ENV=%r, defaulting to 'production'", MCP_ENV)
@@ -25,14 +25,6 @@
2525
MCP_OPENAPI_ENV = os.getenv("MCP_OPENAPI_ENV", "").strip().lower()
2626
if MCP_OPENAPI_ENV == "sandbox":
2727
MCP_OPENAPI_ENV = "test"
28-
if not MCP_OPENAPI_ENV and K_SERVICE and K_SERVICE != "mcp-openapi-com":
29-
candidate = K_SERVICE.split("-")[0].strip().lower()
30-
if candidate == "alpha":
31-
candidate = "dev"
32-
if candidate == "sandbox":
33-
candidate = "test"
34-
if candidate in {"dev", "test"}:
35-
MCP_OPENAPI_ENV = candidate
3628
if MCP_OPENAPI_ENV not in {"", "dev", "test"}:
3729
logger.warning("Invalid MCP_OPENAPI_ENV=%r, defaulting to production", MCP_OPENAPI_ENV)
3830
MCP_OPENAPI_ENV = ""
@@ -47,11 +39,14 @@
4739
callbackUrl = callbackUrl.replace("alpha", "dev")
4840

4941
MEMCACHED_HOST = os.getenv("MCP_CACHE_HOST", '0.0.0.0')
50-
MEMCACHED_PORT = int(os.getenv("MCP_CACHE_PORT", 11211))
42+
MEMCACHED_PORT = int(os.getenv("MCP_CACHE_PORT", "11211"))
5143
# connect_timeout / timeout = 1 s: when Memcached is unreachable (e.g. local dev,
5244
# Docker without the VPC network) the client fails fast and the except block
5345
# falls back to the in-process dict, keeping every endpoint responsive.
54-
memcached_client = base.Client((MEMCACHED_HOST, MEMCACHED_PORT), connect_timeout=1, timeout=1) if _pymemcache_available else None
46+
memcached_client = (
47+
base.Client((MEMCACHED_HOST, MEMCACHED_PORT), connect_timeout=1, timeout=1)
48+
if _PYMEMCACHE_AVAILABLE else None
49+
)
5550

5651
# Funzioni aggiornate per supportare Memcached
5752
def get_callback_result(request_id: str):
@@ -62,11 +57,13 @@ def get_callback_result(request_id: str):
6257
if memcached_client is None:
6358
raise RuntimeError("pymemcache not available")
6459
return get_from_memcached(memcached_client, request_id)
65-
except Exception as e:
60+
except (RuntimeError, OSError, ValueError, TypeError) as e:
6661
# Fallback al dizionario in memoria
6762
logger.debug("memcached get failed, falling back to in-memory store: %s", e)
6863
if request_id not in callback_results:
69-
raise KeyError(f"Result not found for request_id: {request_id}")
64+
raise KeyError(
65+
f"Result not found for request_id: {request_id}"
66+
) from e
7067
return callback_results[request_id]
7168

7269

@@ -82,20 +79,20 @@ def set_callback_result(request_id: str, data: Dict, custom: Dict):
8279
if memcached_client is None:
8380
raise RuntimeError("pymemcache not available")
8481
save_to_memcached(memcached_client, request_id, result)
85-
except Exception as e:
82+
except (RuntimeError, OSError, ValueError, TypeError) as e:
8683
# Fallback al dizionario in memoria
8784
logger.debug("memcached set failed, falling back to in-memory store: %s", e)
8885
callback_results[request_id] = result
8986

9087
def save_to_memcached(client, key, value):
91-
# Serialize to JSON and encode as UTF-8
88+
"""Serialize a callback payload and store it in Memcached."""
9289
binary_value = json.dumps(value).encode('utf-8')
9390
client.set(key, binary_value)
9491

95-
# Retrieve data from Memcached
92+
9693
def get_from_memcached(client, key):
94+
"""Read a callback payload from Memcached and decode it from JSON."""
9795
binary_value = client.get(key)
9896
if binary_value is not None:
99-
# Decode from UTF-8 and deserialize from JSON
10097
return json.loads(binary_value.decode('utf-8'))
10198
return None

tests/integration/run-vs-claude.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ SERVER_PID=""
1414
SANDBOX="${SANDBOX:-0}"
1515
if [ "$SANDBOX" = "1" ]; then
1616
TOKEN="${OPENAPI_SANDBOX_TOKEN:-}"
17-
K_SERVICE_VALUE="test-openapi-mcp-server"
17+
MCP_OPENAPI_ENV_VALUE="test"
1818
else
1919
TOKEN="${OPENAPI_TOKEN:-}"
20-
K_SERVICE_VALUE=""
20+
MCP_OPENAPI_ENV_VALUE=""
2121
fi
2222

2323
cleanup() {
@@ -68,7 +68,7 @@ EOF
6868

6969
echo "Starting MCP server..."
7070
cd "$ROOT_DIR"
71-
K_SERVICE="$K_SERVICE_VALUE" PYTHONPATH=src uv run uvicorn openapi_mcp_sdk.main:app \
71+
MCP_OPENAPI_ENV="$MCP_OPENAPI_ENV_VALUE" PYTHONPATH=src uv run uvicorn openapi_mcp_sdk.main:app \
7272
--host 0.0.0.0 --port 8080 --log-level warning &
7373
SERVER_PID=$!
7474

tests/integration/run-vs-codex.sh

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ SERVER_PID=""
1515
SANDBOX="${SANDBOX:-0}"
1616
if [ "$SANDBOX" = "1" ]; then
1717
TOKEN="${OPENAPI_SANDBOX_TOKEN:-}"
18-
K_SERVICE_VALUE="test-openapi-mcp-server"
18+
MCP_OPENAPI_ENV_VALUE="test"
1919
else
2020
TOKEN="${OPENAPI_TOKEN:-}"
21-
K_SERVICE_VALUE=""
21+
MCP_OPENAPI_ENV_VALUE=""
2222
fi
2323

2424
cleanup() {
@@ -66,7 +66,7 @@ codex mcp add "$MCP_SERVER_NAME" \
6666

6767
echo "Starting MCP server..."
6868
cd "$ROOT_DIR"
69-
K_SERVICE="$K_SERVICE_VALUE" PYTHONPATH=src uv run uvicorn openapi_mcp_sdk.main:app \
69+
MCP_OPENAPI_ENV="$MCP_OPENAPI_ENV_VALUE" PYTHONPATH=src uv run uvicorn openapi_mcp_sdk.main:app \
7070
--host 0.0.0.0 --port 8080 --log-level warning &
7171
SERVER_PID=$!
7272

0 commit comments

Comments
 (0)