zerocommerce-mcp 0.1.0__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.
- zerocommerce_mcp/__init__.py +3 -0
- zerocommerce_mcp/auth.py +93 -0
- zerocommerce_mcp/client.py +116 -0
- zerocommerce_mcp/server.py +548 -0
- zerocommerce_mcp/tools/__init__.py +1 -0
- zerocommerce_mcp/tools/analytics.py +304 -0
- zerocommerce_mcp/tools/catalog.py +350 -0
- zerocommerce_mcp/tools/customers.py +245 -0
- zerocommerce_mcp/tools/orders.py +312 -0
- zerocommerce_mcp/tools/store_config.py +177 -0
- zerocommerce_mcp/tools/storefront.py +147 -0
- zerocommerce_mcp-0.1.0.dist-info/METADATA +110 -0
- zerocommerce_mcp-0.1.0.dist-info/RECORD +15 -0
- zerocommerce_mcp-0.1.0.dist-info/WHEEL +4 -0
- zerocommerce_mcp-0.1.0.dist-info/entry_points.txt +2 -0
zerocommerce_mcp/auth.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""API key validation for store-scoped MCP authentication (Refs #382).
|
|
2
|
+
|
|
3
|
+
Phase 1 (legacy): single admin API key from environment variable.
|
|
4
|
+
Phase 2 (current): per-store scoped keys (``zc_live_*``) verified via
|
|
5
|
+
the internal ``/api/v1/internal/store-api-keys/verify`` endpoint.
|
|
6
|
+
Falls back to the admin key if the presented key is not a store key.
|
|
7
|
+
|
|
8
|
+
When a store-scoped key is used, the ``store_slug`` is automatically
|
|
9
|
+
derived from the key and passed to all tool calls so the merchant does
|
|
10
|
+
not need to provide it manually.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Key prefix that identifies store-scoped keys
|
|
23
|
+
_STORE_KEY_PREFIX = "zc_live_"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class StoreContext:
|
|
28
|
+
"""Resolved store identity from a verified store-scoped API key."""
|
|
29
|
+
store_id: str
|
|
30
|
+
store_slug: str
|
|
31
|
+
scopes: list[str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_api_key() -> str:
|
|
35
|
+
"""Return the configured ZeroCommerce API key.
|
|
36
|
+
|
|
37
|
+
Raises ValueError if the key is not set so server startup fails
|
|
38
|
+
fast with a clear message.
|
|
39
|
+
"""
|
|
40
|
+
key = os.environ.get("ZEROCOMMERCE_API_KEY", "")
|
|
41
|
+
if not key:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"ZEROCOMMERCE_API_KEY environment variable is required. "
|
|
44
|
+
"Set it to a valid ZeroCommerce admin or store API key."
|
|
45
|
+
)
|
|
46
|
+
return key
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_store_key(key: str) -> bool:
|
|
50
|
+
"""Return True if the key looks like a store-scoped key (``zc_live_*``)."""
|
|
51
|
+
return key.startswith(_STORE_KEY_PREFIX)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def verify_store_key(
|
|
55
|
+
raw_key: str,
|
|
56
|
+
base_url: Optional[str] = None,
|
|
57
|
+
) -> Optional[StoreContext]:
|
|
58
|
+
"""Verify a store-scoped API key via the internal verification endpoint.
|
|
59
|
+
|
|
60
|
+
Returns a ``StoreContext`` on success, ``None`` on any failure.
|
|
61
|
+
"""
|
|
62
|
+
url = (
|
|
63
|
+
base_url
|
|
64
|
+
or os.environ.get("ZEROCOMMERCE_API_URL", "")
|
|
65
|
+
).rstrip("/")
|
|
66
|
+
|
|
67
|
+
if not url:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
72
|
+
resp = await client.post(
|
|
73
|
+
f"{url}/api/v1/internal/store-api-keys/verify",
|
|
74
|
+
json={"raw_key": raw_key},
|
|
75
|
+
)
|
|
76
|
+
if resp.status_code != 200:
|
|
77
|
+
return None
|
|
78
|
+
data = resp.json()
|
|
79
|
+
return StoreContext(
|
|
80
|
+
store_id=data["store_id"],
|
|
81
|
+
store_slug=data["store_slug"],
|
|
82
|
+
scopes=data.get("scopes", []),
|
|
83
|
+
)
|
|
84
|
+
except (httpx.HTTPError, KeyError, ValueError):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_store_slug(slug: str) -> str:
|
|
89
|
+
"""Basic validation for store slug format."""
|
|
90
|
+
slug = slug.strip().lower()
|
|
91
|
+
if not slug or len(slug) < 3 or len(slug) > 64:
|
|
92
|
+
raise ValueError(f"Invalid store slug: '{slug}'. Must be 3-64 characters.")
|
|
93
|
+
return slug
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Async HTTP client wrapper for the ZeroCommerce REST API.
|
|
2
|
+
|
|
3
|
+
All MCP tools delegate to this client rather than implementing business
|
|
4
|
+
logic themselves. Errors are caught and returned as human-readable
|
|
5
|
+
strings so the LLM always gets a usable response.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, Dict, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ZeroCommerceClient:
|
|
17
|
+
"""Thin async wrapper around the ZeroCommerce API."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: Optional[str] = None,
|
|
22
|
+
api_key: Optional[str] = None,
|
|
23
|
+
timeout: float = 30.0,
|
|
24
|
+
) -> None:
|
|
25
|
+
self.base_url = (
|
|
26
|
+
base_url
|
|
27
|
+
or os.environ.get("ZEROCOMMERCE_API_URL", "")
|
|
28
|
+
).rstrip("/")
|
|
29
|
+
self.api_key = api_key or os.environ.get("ZEROCOMMERCE_API_KEY", "")
|
|
30
|
+
self.timeout = timeout
|
|
31
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
32
|
+
|
|
33
|
+
async def _ensure_client(self) -> httpx.AsyncClient:
|
|
34
|
+
if self._client is None or self._client.is_closed:
|
|
35
|
+
self._client = httpx.AsyncClient(
|
|
36
|
+
base_url=self.base_url,
|
|
37
|
+
headers={
|
|
38
|
+
"X-API-Key": self.api_key,
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
},
|
|
41
|
+
timeout=self.timeout,
|
|
42
|
+
)
|
|
43
|
+
return self._client
|
|
44
|
+
|
|
45
|
+
async def close(self) -> None:
|
|
46
|
+
if self._client and not self._client.is_closed:
|
|
47
|
+
await self._client.aclose()
|
|
48
|
+
|
|
49
|
+
# ----- HTTP helpers -----
|
|
50
|
+
|
|
51
|
+
async def _handle_response(self, resp: httpx.Response) -> Any:
|
|
52
|
+
"""Parse response or raise a human-readable error string."""
|
|
53
|
+
if resp.status_code in (401, 403):
|
|
54
|
+
raise ApiError("Authentication failed. Check your API key.")
|
|
55
|
+
if resp.status_code == 404:
|
|
56
|
+
raise ApiError(f"Not found: {resp.url.path}")
|
|
57
|
+
if resp.status_code == 422:
|
|
58
|
+
detail = resp.json().get("detail", resp.text)
|
|
59
|
+
raise ApiError(f"Validation error: {detail}")
|
|
60
|
+
if resp.status_code >= 400:
|
|
61
|
+
raise ApiError(f"API error {resp.status_code}: {resp.text[:300]}")
|
|
62
|
+
if resp.status_code == 204:
|
|
63
|
+
return {"ok": True}
|
|
64
|
+
return resp.json()
|
|
65
|
+
|
|
66
|
+
async def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
67
|
+
client = await self._ensure_client()
|
|
68
|
+
try:
|
|
69
|
+
resp = await client.get(path, params=_clean_params(params))
|
|
70
|
+
return await self._handle_response(resp)
|
|
71
|
+
except httpx.HTTPError as exc:
|
|
72
|
+
raise ApiError(f"Service unavailable. Try again. ({exc})")
|
|
73
|
+
|
|
74
|
+
async def post(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
75
|
+
client = await self._ensure_client()
|
|
76
|
+
try:
|
|
77
|
+
resp = await client.post(path, json=body or {})
|
|
78
|
+
return await self._handle_response(resp)
|
|
79
|
+
except httpx.HTTPError as exc:
|
|
80
|
+
raise ApiError(f"Service unavailable. Try again. ({exc})")
|
|
81
|
+
|
|
82
|
+
async def put(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
83
|
+
client = await self._ensure_client()
|
|
84
|
+
try:
|
|
85
|
+
resp = await client.put(path, json=body or {})
|
|
86
|
+
return await self._handle_response(resp)
|
|
87
|
+
except httpx.HTTPError as exc:
|
|
88
|
+
raise ApiError(f"Service unavailable. Try again. ({exc})")
|
|
89
|
+
|
|
90
|
+
async def patch(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
91
|
+
client = await self._ensure_client()
|
|
92
|
+
try:
|
|
93
|
+
resp = await client.patch(path, json=body or {})
|
|
94
|
+
return await self._handle_response(resp)
|
|
95
|
+
except httpx.HTTPError as exc:
|
|
96
|
+
raise ApiError(f"Service unavailable. Try again. ({exc})")
|
|
97
|
+
|
|
98
|
+
async def delete(self, path: str) -> Any:
|
|
99
|
+
client = await self._ensure_client()
|
|
100
|
+
try:
|
|
101
|
+
resp = await client.delete(path)
|
|
102
|
+
return await self._handle_response(resp)
|
|
103
|
+
except httpx.HTTPError as exc:
|
|
104
|
+
raise ApiError(f"Service unavailable. Try again. ({exc})")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ApiError(Exception):
|
|
108
|
+
"""Human-readable API error surfaced to the LLM."""
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _clean_params(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
113
|
+
"""Strip None values so httpx doesn't send `?key=None`."""
|
|
114
|
+
if params is None:
|
|
115
|
+
return None
|
|
116
|
+
return {k: v for k, v in params.items() if v is not None}
|