Skip to content

Commit aa2890d

Browse files
test(pytest): add baseline pytest coverage and safe lint fixes
1 parent ba9a08b commit aa2890d

7 files changed

Lines changed: 124 additions & 28 deletions

File tree

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

src/openapi_mcp_sdk/memory_store.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
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

@@ -37,11 +39,14 @@
3739
callbackUrl = callbackUrl.replace("alpha", "dev")
3840

3941
MEMCACHED_HOST = os.getenv("MCP_CACHE_HOST", '0.0.0.0')
40-
MEMCACHED_PORT = int(os.getenv("MCP_CACHE_PORT", 11211))
42+
MEMCACHED_PORT = int(os.getenv("MCP_CACHE_PORT", "11211"))
4143
# connect_timeout / timeout = 1 s: when Memcached is unreachable (e.g. local dev,
4244
# Docker without the VPC network) the client fails fast and the except block
4345
# falls back to the in-process dict, keeping every endpoint responsive.
44-
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+
)
4550

4651
# Funzioni aggiornate per supportare Memcached
4752
def get_callback_result(request_id: str):
@@ -52,11 +57,13 @@ def get_callback_result(request_id: str):
5257
if memcached_client is None:
5358
raise RuntimeError("pymemcache not available")
5459
return get_from_memcached(memcached_client, request_id)
55-
except Exception as e:
60+
except (RuntimeError, OSError, ValueError, TypeError) as e:
5661
# Fallback al dizionario in memoria
5762
logger.debug("memcached get failed, falling back to in-memory store: %s", e)
5863
if request_id not in callback_results:
59-
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
6067
return callback_results[request_id]
6168

6269

@@ -72,20 +79,20 @@ def set_callback_result(request_id: str, data: Dict, custom: Dict):
7279
if memcached_client is None:
7380
raise RuntimeError("pymemcache not available")
7481
save_to_memcached(memcached_client, request_id, result)
75-
except Exception as e:
82+
except (RuntimeError, OSError, ValueError, TypeError) as e:
7683
# Fallback al dizionario in memoria
7784
logger.debug("memcached set failed, falling back to in-memory store: %s", e)
7885
callback_results[request_id] = result
7986

8087
def save_to_memcached(client, key, value):
81-
# Serialize to JSON and encode as UTF-8
88+
"""Serialize a callback payload and store it in Memcached."""
8289
binary_value = json.dumps(value).encode('utf-8')
8390
client.set(key, binary_value)
8491

85-
# Retrieve data from Memcached
92+
8693
def get_from_memcached(client, key):
94+
"""Read a callback payload from Memcached and decode it from JSON."""
8795
binary_value = client.get(key)
8896
if binary_value is not None:
89-
# Decode from UTF-8 and deserialize from JSON
9097
return json.loads(binary_value.decode('utf-8'))
9198
return None

tests/pytest/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import sys
2+
from pathlib import Path
3+
4+
5+
ROOT = Path(__file__).resolve().parents[2]
6+
SRC = ROOT / "src"
7+
8+
if str(SRC) not in sys.path:
9+
sys.path.insert(0, str(SRC))
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from openapi_mcp_sdk import storage_backend
6+
7+
8+
def test_save_and_read_file_with_local_backend(tmp_path, monkeypatch):
9+
monkeypatch.setenv("MCP_STORAGE_BACKEND", "local")
10+
monkeypatch.setenv("MCP_STORAGE_PATH", str(tmp_path))
11+
12+
storage_backend.save_file("nested/file.txt", b"hello", "text/plain")
13+
14+
content, content_type = storage_backend.read_file("nested/file.txt")
15+
16+
assert content == b"hello"
17+
assert content_type == "application/octet-stream"
18+
assert (tmp_path / "nested" / "file.txt").read_bytes() == b"hello"
19+
20+
21+
def test_read_file_raises_for_missing_local_file(tmp_path, monkeypatch):
22+
monkeypatch.setenv("MCP_STORAGE_BACKEND", "local")
23+
monkeypatch.setenv("MCP_STORAGE_PATH", str(tmp_path))
24+
25+
with pytest.raises(FileNotFoundError):
26+
storage_backend.read_file("missing.txt")
27+
28+
29+
def test_cloud_backends_require_bucket(monkeypatch):
30+
monkeypatch.setenv("MCP_STORAGE_BACKEND", "gcs")
31+
monkeypatch.delenv("MCP_STORAGE_BUCKET", raising=False)
32+
33+
with pytest.raises(RuntimeError, match="MCP_STORAGE_BUCKET"):
34+
storage_backend._storage_bucket()
35+
36+
37+
def test_storage_path_uses_mcp_storage_path(monkeypatch, tmp_path):
38+
monkeypatch.setenv("MCP_STORAGE_PATH", str(tmp_path))
39+
40+
assert storage_backend._storage_path() == Path(tmp_path)

0 commit comments

Comments
 (0)