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.
@@ -0,0 +1,3 @@
1
+ """ZeroCommerce MCP Server — natural language commerce management."""
2
+
3
+ __version__ = "0.1.0"
@@ -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}