Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,56 @@ All notable changes to ozon-mcp follow [keep-a-changelog](https://keepachangelog

## [Unreleased]

## [0.6.1] — 2026-04-19

Hotfix for a silent protocol-level crash on every execution-layer tool
call. Plus a round of defensive hardening and logging improvements so
the next regression is visible instead of invisible.

### Fixed

- **structlog writes to stderr, not stdout.** `_configure_logging()`
was pointing stdlib `logging` at stderr but leaving structlog on its
default `PrintLogger(file=sys.stdout)`. Every `log.info("ozon_request",
...)` in `transport/base.py` emitted a JSON line onto the stdio
channel that MCP uses for JSON-RPC framing, so the client disconnected
with `Connection closed` on the first real API call. Static tools
(no logging) kept working, which masked the bug. Now configured with
`PrintLoggerFactory(file=sys.stderr)`.
- **UTF-8 stderr on Windows.** `sys.stderr.reconfigure(encoding="utf-8")`
runs at startup so Cyrillic log fields and tracebacks don't crash the
cp1252 codec. `JSONRenderer(ensure_ascii=False)` makes those logs
readable rather than `\uXXXX`-escaped.

### Added

- **`@safe_tool` decorator** (`tools/_safety.py`) wraps every MCP
tool handler in a `BaseException` guard. Unhandled exceptions become
a structured `error_type="internal"` envelope plus a full traceback
on ERROR — the process stays alive and the caller gets a
typed response. Applied to `ozon_call_method`, `ozon_fetch_all`,
`ozon_get_subscription_status`, `ozon_list_methods_for_subscription`.
- **Transport catch-all.** `BaseClient.request()` now converts any
non-`httpx.HTTPError` exception (SSLError, ProxyError, mid-request
cancel, ...) into an `OzonError` with `unexpected transport error:`
prefix. `_auth_headers()` and `_rate_limits.for_call()` were moved
inside the try-block — previously a broken OAuth refresh or rate
config could raise before the guard kicked in.
- **Request/response DEBUG logs** in `transport/base.py` — request
body and response body truncated to 1 KB, `duration_ms` on every
response, structured event names (`ozon_request`, `ozon_request_body`,
`ozon_response`, `ozon_response_body`).
- **Optional rotating file log.** Setting `OZON_LOG_FILE=/path/to/log`
adds a `RotatingFileHandler` (5 MB × 3 backups) parallel to stderr.
Useful because MCP client stderr is often not surfaced to the user.
- **Asyncio task exception handler** — unawaited task crashes are now
logged via structlog (`asyncio_task_crashed`) instead of printing to
stderr with no context.
- **`ozon-mcp --diagnose`** CLI flag. Runs a single `/v1/seller/info`
call against the Ozon API and either prints `OK subscription=...` or
a full traceback. Skips the MCP stdio loop entirely — the supported
way to verify credentials without a client.

## [0.6.0] — 2026-04-17

Six-phase release focused on production-grade reliability and on
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "ozon-mcp"
version = "0.6.0"
version = "0.6.1"
description = "Ozon Seller API & Performance API knowledge-rich MCP server for AI agents"
readme = "README.md"
license = { text = "MIT" }
Expand Down
77 changes: 77 additions & 0 deletions scripts/diagnose_live_call.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Out-of-band diagnostic: make a real /v1/seller/info call and print the
full traceback of any crash.

Why this exists: the MCP stdio protocol silently closes the pipe when the
server process dies, so clients see only ``Connection closed`` with no
stack trace. Running the transport layer directly from Python surfaces
the real exception.

Usage (from the ozon-mcp repo root)::

OZON_CLIENT_ID=... OZON_API_KEY=... OZON_LOG_LEVEL=DEBUG \\
uv run python scripts/diagnose_live_call.py

On success prints the subscription tier and exits 0. On any failure
prints the full traceback to stderr and exits non-zero.
"""

from __future__ import annotations

import asyncio
import sys
import traceback

from ozon_mcp.config import Config
from ozon_mcp.knowledge import load_knowledge
from ozon_mcp.server import _configure_logging
from ozon_mcp.transport.ratelimit import RateLimitRegistry
from ozon_mcp.transport.seller import SellerClient


async def _probe() -> int:
config = Config()
if not config.has_seller_credentials():
print("ERROR: OZON_CLIENT_ID / OZON_API_KEY not set in env.", file=sys.stderr)
return 2

_configure_logging(config.log_level)
knowledge = load_knowledge()
rate_limits = RateLimitRegistry(knowledge)

client = SellerClient(
config.seller_client_id(),
config.seller_api_key(),
rate_limits=rate_limits,
)
try:
response = await client.request(
"POST",
"/v1/seller/info",
json_body={},
operation_id="SellerAPI_SellerInfo",
)
except BaseException:
print("---- diagnose: request raised ----", file=sys.stderr)
traceback.print_exc()
return 1
finally:
await client.aclose()

sub = response.get("subscription") if isinstance(response, dict) else None
print("OK", file=sys.stderr)
print(f"subscription={sub}", file=sys.stderr)
return 0


def main() -> None:
try:
rc = asyncio.run(_probe())
except BaseException:
print("---- diagnose: top-level raised ----", file=sys.stderr)
traceback.print_exc()
sys.exit(3)
sys.exit(rc)


if __name__ == "__main__":
main()
163 changes: 163 additions & 0 deletions scripts/diagnose_mcp_stdio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Drive the ozon-mcp stdio server end-to-end — initialize, call a tool,
capture stderr so we can see the traceback when the server dies.

This mirrors exactly what Claude Code does: spawn ``ozon-mcp``, speak
JSON-RPC over stdin/stdout, pipe stderr separately. Unlike the MCP
client libraries, we DO NOT hide server stderr — we print it all
on exit so any unhandled exception is visible.

Usage::

OZON_CLIENT_ID=... OZON_API_KEY=... \\
uv run python scripts/diagnose_mcp_stdio.py
"""

from __future__ import annotations

import asyncio
import contextlib
import json
import os
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager


async def _read_message(stream: asyncio.StreamReader) -> dict | None:
"""Read one newline-delimited JSON-RPC message from the server."""
line = await stream.readline()
if not line:
return None
try:
return json.loads(line.decode("utf-8"))
except json.JSONDecodeError:
print(f"<<< non-json from server: {line!r}", file=sys.stderr)
return None


def _send(stream: asyncio.StreamWriter, payload: dict) -> None:
blob = (json.dumps(payload) + "\n").encode("utf-8")
stream.write(blob)


@asynccontextmanager
async def _spawn() -> AsyncIterator[asyncio.subprocess.Process]:
env = os.environ.copy()
env.setdefault("OZON_LOG_LEVEL", "DEBUG")
env.setdefault("PYTHONIOENCODING", "utf-8")
# Run via uv from the repo root.
proc = await asyncio.create_subprocess_exec(
"uv",
"run",
"ozon-mcp",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
try:
yield proc
finally:
if proc.returncode is None:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except TimeoutError:
proc.kill()
await proc.wait()


async def _pump_stderr(proc: asyncio.subprocess.Process) -> None:
assert proc.stderr is not None
while True:
line = await proc.stderr.readline()
if not line:
return
sys.stderr.write("[server] " + line.decode("utf-8", errors="replace"))
sys.stderr.flush()


async def _main() -> int:
async with _spawn() as proc:
assert proc.stdin is not None and proc.stdout is not None
stderr_task = asyncio.create_task(_pump_stderr(proc))

# 1. initialize
_send(
proc.stdin,
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "diagnose", "version": "0"},
},
},
)
await proc.stdin.drain()
init_resp = await asyncio.wait_for(_read_message(proc.stdout), timeout=30)
print("INIT:", init_resp, file=sys.stderr)

_send(proc.stdin, {"jsonrpc": "2.0", "method": "notifications/initialized"})
await proc.stdin.drain()

# 2. call tool: ozon_get_subscription_status (no args)
_send(
proc.stdin,
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "ozon_get_subscription_status",
"arguments": {},
},
},
)
await proc.stdin.drain()
try:
call_resp = await asyncio.wait_for(_read_message(proc.stdout), timeout=60)
print("CALL1:", call_resp, file=sys.stderr)
except TimeoutError:
print("TIMEOUT waiting for tool response", file=sys.stderr)

# 3. call tool: ozon_call_method ProductAPI_GetProductList (10 items)
_send(
proc.stdin,
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "ozon_call_method",
"arguments": {
"operation_id": "ProductAPI_GetProductList",
"params": {"filter": {"visibility": "ALL"}, "limit": 10},
},
},
},
)
await proc.stdin.drain()
try:
call2_resp = await asyncio.wait_for(_read_message(proc.stdout), timeout=60)
print("CALL2:", call2_resp, file=sys.stderr)
except TimeoutError:
print("TIMEOUT waiting for tool response (call 2)", file=sys.stderr)

# Give the server a moment to flush stderr if it died.
await asyncio.sleep(0.3)
rc = proc.returncode
print(f"server returncode={rc}", file=sys.stderr)
stderr_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await stderr_task
return rc or 0


if __name__ == "__main__":
try:
sys.exit(asyncio.run(_main()))
except KeyboardInterrupt:
sys.exit(130)
2 changes: 1 addition & 1 deletion src/ozon_mcp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Ozon API knowledge-rich MCP server for AI agents."""

__version__ = "0.6.0"
__version__ = "0.6.1"
Loading