visceral-consignment-mcp 0.1.0__tar.gz

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,4 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.5
2
+ Name: visceral-consignment-mcp
3
+ Version: 0.1.0
4
+ Summary: Official MCP server for the Visceral Consignment external API (v1, read-only)
5
+ Project-URL: Homepage, https://visceralapps.com
6
+ Author-email: Visceral Apps <brian@bitfoundation.io>
7
+ License: Proprietary
8
+ Keywords: claude,consignment,mcp,model-context-protocol,shopify
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: End Users/Desktop
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Office/Business
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: mcp<3,>=2
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest>=8; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Visceral Consignment MCP Server
21
+
22
+ Official [MCP](https://modelcontextprotocol.io) server for the Visceral
23
+ Consignment **external API v1** — lets Claude (or any MCP client) answer
24
+ questions about a shop's consignors, payouts, and payout line items in plain
25
+ English.
26
+
27
+ **Read-only.** Every tool call is an authenticated HTTPS request to the
28
+ external REST API (`/api/external/v1/`), so the shop's API-key scoping,
29
+ tenant isolation, rate limits, usage metering, and secure-field masking all
30
+ apply unchanged. Bank details and other secure fields are always masked and
31
+ cannot be revealed through this server.
32
+
33
+ ## Prerequisites
34
+
35
+ 1. The external API enabled for the shop (pilot: enabled by Visceral support).
36
+ 2. A **read-only API key**: in Visceral, Settings → API → Create key. Name it
37
+ for the tool that will use it (e.g. "Claude assistant") — usage is tracked
38
+ per key and you can revoke it independently at any time.
39
+
40
+ ## Configuration
41
+
42
+ | Environment variable | Required | Meaning |
43
+ |---|---|---|
44
+ | `VISCERAL_API_KEY` | yes | The `vsk_...` key from Settings → API |
45
+ | `VISCERAL_API_URL` | no | API base URL. Default: `https://b.visceralapps.com/api/external/v1` |
46
+
47
+ ## Install & run
48
+
49
+ With [uv](https://docs.astral.sh/uv/) (recommended — installs in one command and
50
+ manages its own Python, so no Python setup is needed):
51
+
52
+ ```bash
53
+ uvx visceral-consignment-mcp
54
+ ```
55
+
56
+ Or with pip:
57
+
58
+ ```bash
59
+ pip install visceral-consignment-mcp
60
+ visceral-mcp
61
+ ```
62
+
63
+ The server speaks MCP over stdio (the standard transport for desktop clients).
64
+
65
+ ### Claude Desktop
66
+
67
+ Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
68
+
69
+ ```json
70
+ {
71
+ "mcpServers": {
72
+ "visceral-consignment": {
73
+ "command": "uvx",
74
+ "args": ["visceral-consignment-mcp"],
75
+ "env": {
76
+ "VISCERAL_API_KEY": "vsk_your_key_here"
77
+ }
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ ### Claude Code
84
+
85
+ ```bash
86
+ claude mcp add visceral-consignment \
87
+ --env VISCERAL_API_KEY=vsk_your_key_here \
88
+ -- uvx visceral-consignment-mcp
89
+ ```
90
+
91
+ Then ask things like:
92
+
93
+ > "Which consignors haven't had a completed payout since July?"
94
+ > "Summarize payout #918 — what sold and what did the consignor earn?"
95
+ > "How much did we pay out in total last month?"
96
+
97
+ ## Tools
98
+
99
+ | Tool | Purpose |
100
+ |---|---|
101
+ | `get_shop_info` | Confirm the connection: shop, key scopes, rate-limit headroom |
102
+ | `list_consignors` | Search/filter consignors (`search`, `email`, `active`, `updated_since`) |
103
+ | `get_consignor` | One consignor's full record (secure fields masked) |
104
+ | `list_payouts` | Filter payouts (`status`, `consignor_id`, `processed_since`, …) |
105
+ | `get_payout` | One payout's summary |
106
+ | `get_payout_line_items` | The sold items behind a payout, matching the CSV export |
107
+
108
+ ## Behavior notes
109
+
110
+ - Money values are decimal **strings** (`"184.50"`) — the server instructs
111
+ clients never to treat them as floats.
112
+ - On a short rate-limit (`429` with `Retry-After` ≤ 15s) the server waits and
113
+ retries once; longer waits surface as a readable error telling the model
114
+ how long to pause.
115
+ - Versioned with the external API: these tools track **v1** and follow the
116
+ same early-access stability contract (see the External API v1 guide).
117
+
118
+ ## Development
119
+
120
+ Source lives in `src/visceral_mcp/server.py` — one `@mcp.tool()` per
121
+ endpoint, a shared `_get()` helper for auth/errors/rate-limit handling.
122
+
123
+ ```bash
124
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
125
+ .venv/bin/pytest tests/ -q
126
+ ```
127
+
128
+ Tests inject an `httpx.MockTransport` via `server._transport` and call the
129
+ tool functions directly (the `@mcp.tool()` decorator returns the plain
130
+ function) — no network, no real key needed.
@@ -0,0 +1,111 @@
1
+ # Visceral Consignment MCP Server
2
+
3
+ Official [MCP](https://modelcontextprotocol.io) server for the Visceral
4
+ Consignment **external API v1** — lets Claude (or any MCP client) answer
5
+ questions about a shop's consignors, payouts, and payout line items in plain
6
+ English.
7
+
8
+ **Read-only.** Every tool call is an authenticated HTTPS request to the
9
+ external REST API (`/api/external/v1/`), so the shop's API-key scoping,
10
+ tenant isolation, rate limits, usage metering, and secure-field masking all
11
+ apply unchanged. Bank details and other secure fields are always masked and
12
+ cannot be revealed through this server.
13
+
14
+ ## Prerequisites
15
+
16
+ 1. The external API enabled for the shop (pilot: enabled by Visceral support).
17
+ 2. A **read-only API key**: in Visceral, Settings → API → Create key. Name it
18
+ for the tool that will use it (e.g. "Claude assistant") — usage is tracked
19
+ per key and you can revoke it independently at any time.
20
+
21
+ ## Configuration
22
+
23
+ | Environment variable | Required | Meaning |
24
+ |---|---|---|
25
+ | `VISCERAL_API_KEY` | yes | The `vsk_...` key from Settings → API |
26
+ | `VISCERAL_API_URL` | no | API base URL. Default: `https://b.visceralapps.com/api/external/v1` |
27
+
28
+ ## Install & run
29
+
30
+ With [uv](https://docs.astral.sh/uv/) (recommended — installs in one command and
31
+ manages its own Python, so no Python setup is needed):
32
+
33
+ ```bash
34
+ uvx visceral-consignment-mcp
35
+ ```
36
+
37
+ Or with pip:
38
+
39
+ ```bash
40
+ pip install visceral-consignment-mcp
41
+ visceral-mcp
42
+ ```
43
+
44
+ The server speaks MCP over stdio (the standard transport for desktop clients).
45
+
46
+ ### Claude Desktop
47
+
48
+ Add to `claude_desktop_config.json` (Settings → Developer → Edit Config):
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "visceral-consignment": {
54
+ "command": "uvx",
55
+ "args": ["visceral-consignment-mcp"],
56
+ "env": {
57
+ "VISCERAL_API_KEY": "vsk_your_key_here"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ### Claude Code
65
+
66
+ ```bash
67
+ claude mcp add visceral-consignment \
68
+ --env VISCERAL_API_KEY=vsk_your_key_here \
69
+ -- uvx visceral-consignment-mcp
70
+ ```
71
+
72
+ Then ask things like:
73
+
74
+ > "Which consignors haven't had a completed payout since July?"
75
+ > "Summarize payout #918 — what sold and what did the consignor earn?"
76
+ > "How much did we pay out in total last month?"
77
+
78
+ ## Tools
79
+
80
+ | Tool | Purpose |
81
+ |---|---|
82
+ | `get_shop_info` | Confirm the connection: shop, key scopes, rate-limit headroom |
83
+ | `list_consignors` | Search/filter consignors (`search`, `email`, `active`, `updated_since`) |
84
+ | `get_consignor` | One consignor's full record (secure fields masked) |
85
+ | `list_payouts` | Filter payouts (`status`, `consignor_id`, `processed_since`, …) |
86
+ | `get_payout` | One payout's summary |
87
+ | `get_payout_line_items` | The sold items behind a payout, matching the CSV export |
88
+
89
+ ## Behavior notes
90
+
91
+ - Money values are decimal **strings** (`"184.50"`) — the server instructs
92
+ clients never to treat them as floats.
93
+ - On a short rate-limit (`429` with `Retry-After` ≤ 15s) the server waits and
94
+ retries once; longer waits surface as a readable error telling the model
95
+ how long to pause.
96
+ - Versioned with the external API: these tools track **v1** and follow the
97
+ same early-access stability contract (see the External API v1 guide).
98
+
99
+ ## Development
100
+
101
+ Source lives in `src/visceral_mcp/server.py` — one `@mcp.tool()` per
102
+ endpoint, a shared `_get()` helper for auth/errors/rate-limit handling.
103
+
104
+ ```bash
105
+ python -m venv .venv && .venv/bin/pip install -e ".[dev]"
106
+ .venv/bin/pytest tests/ -q
107
+ ```
108
+
109
+ Tests inject an `httpx.MockTransport` via `server._transport` and call the
110
+ tool functions directly (the `@mcp.tool()` decorator returns the plain
111
+ function) — no network, no real key needed.
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "visceral-consignment-mcp"
3
+ version = "0.1.0"
4
+ description = "Official MCP server for the Visceral Consignment external API (v1, read-only)"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "Proprietary" }
8
+ authors = [
9
+ { name = "Visceral Apps", email = "brian@bitfoundation.io" },
10
+ ]
11
+ keywords = ["mcp", "model-context-protocol", "shopify", "consignment", "claude"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: End Users/Desktop",
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Office/Business",
17
+ ]
18
+ dependencies = [
19
+ "mcp>=2,<3",
20
+ "httpx>=0.27",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://visceralapps.com"
25
+
26
+ [project.scripts]
27
+ visceral-mcp = "visceral_mcp.server:main"
28
+ # Alias matching the package name so `uvx visceral-consignment-mcp` works bare.
29
+ visceral-consignment-mcp = "visceral_mcp.server:main"
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=8"]
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/visceral_mcp"]
@@ -0,0 +1,3 @@
1
+ """Official MCP server for the Visceral Consignment external API (v1)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,226 @@
1
+ """
2
+ Visceral Consignment MCP server.
3
+
4
+ A thin, read-only MCP wrapper over the Visceral external API v1
5
+ (`/api/external/v1/`). Every tool call is an authenticated HTTPS request to
6
+ the REST API, so the shop's API-key scoping, tenant isolation, rate limits,
7
+ usage metering, and secure-field masking all apply exactly as documented in
8
+ the External API v1 guide.
9
+
10
+ Configuration (environment variables):
11
+ VISCERAL_API_KEY required — a `vsk_...` key from Settings -> API
12
+ VISCERAL_API_URL optional — API base URL
13
+ (default: https://b.visceralapps.com/api/external/v1)
14
+
15
+ Run over stdio (the default MCP transport for Claude Desktop / Claude Code):
16
+ visceral-mcp
17
+ """
18
+ import asyncio
19
+ import os
20
+ import sys
21
+ from typing import Any, Optional
22
+
23
+ import httpx
24
+ from mcp.server.mcpserver import MCPServer
25
+
26
+ DEFAULT_API_URL = "https://b.visceralapps.com/api/external/v1"
27
+
28
+ # Injectable for tests (httpx.MockTransport); None means real HTTPS.
29
+ _transport: Optional[httpx.AsyncBaseTransport] = None
30
+
31
+ mcp = MCPServer(
32
+ "visceral-consignment",
33
+ instructions=(
34
+ "Read-only access to one Visceral Consignment shop: consignor records, "
35
+ "payouts, and payout line items. Money values are decimal strings — "
36
+ "never floats. Timestamps are ISO-8601 UTC. Lists are paginated; use "
37
+ "the `page` parameter and the returned `count` to fetch more. Prefer "
38
+ "`updated_since` filters over re-fetching everything. If a tool "
39
+ "reports a rate limit, wait the indicated seconds before retrying. "
40
+ "Secure fields (e.g. bank details) are always masked and cannot be "
41
+ "revealed through this server."
42
+ ),
43
+ )
44
+
45
+
46
+ def _api_url() -> str:
47
+ return os.environ.get("VISCERAL_API_URL", DEFAULT_API_URL).rstrip("/")
48
+
49
+
50
+ def _api_key() -> str:
51
+ key = os.environ.get("VISCERAL_API_KEY", "").strip()
52
+ if not key:
53
+ raise RuntimeError(
54
+ "VISCERAL_API_KEY is not set. Create a read-only API key in "
55
+ "Visceral under Settings -> API and set it in this MCP server's "
56
+ "environment configuration."
57
+ )
58
+ return key
59
+
60
+
61
+ async def _get(path: str, params: Optional[dict[str, Any]] = None) -> Any:
62
+ """GET a v1 endpoint; one polite retry on a short rate-limit wait."""
63
+ clean_params = {k: v for k, v in (params or {}).items() if v is not None}
64
+ headers = {"Authorization": f"Bearer {_api_key()}"}
65
+ url = f"{_api_url()}/{path.lstrip('/')}"
66
+
67
+ async with httpx.AsyncClient(transport=_transport, timeout=30.0) as client:
68
+ for attempt in (1, 2):
69
+ response = await client.get(url, params=clean_params, headers=headers)
70
+ if response.status_code == 429:
71
+ retry_after = int(response.headers.get("Retry-After", "60") or 60)
72
+ if attempt == 1 and retry_after <= 15:
73
+ await asyncio.sleep(retry_after)
74
+ continue
75
+ raise RuntimeError(
76
+ f"Rate limited by the Visceral API. Wait {retry_after} "
77
+ "seconds before calling this tool again."
78
+ )
79
+ break
80
+
81
+ if response.status_code == 401:
82
+ raise RuntimeError(
83
+ "The API key was rejected (invalid, revoked, or expired). "
84
+ "Check VISCERAL_API_KEY, or create a new key in Settings -> API."
85
+ )
86
+ if response.status_code == 403:
87
+ raise RuntimeError(
88
+ "Access denied: the external API is not enabled for this shop, "
89
+ "or the key is missing the required scope."
90
+ )
91
+ if response.status_code == 404:
92
+ raise RuntimeError(
93
+ "Not found. The record does not exist on this shop (ids from "
94
+ "other shops are never visible)."
95
+ )
96
+ if response.status_code >= 400:
97
+ raise RuntimeError(
98
+ f"Visceral API error {response.status_code}: {response.text[:500]}"
99
+ )
100
+ return response.json()
101
+
102
+
103
+ @mcp.tool()
104
+ async def get_shop_info() -> dict:
105
+ """Identify the connected shop and this connection's status.
106
+
107
+ Returns the shop name, the API key's name and scopes, current rate-limit
108
+ headroom, and how many API requests were made today. Call this first to
109
+ confirm the connection works and to know which shop you are looking at.
110
+ """
111
+ return await _get("ping/")
112
+
113
+
114
+ @mcp.tool()
115
+ async def list_consignors(
116
+ search: Optional[str] = None,
117
+ email: Optional[str] = None,
118
+ active: Optional[bool] = None,
119
+ updated_since: Optional[str] = None,
120
+ page: int = 1,
121
+ page_size: int = 25,
122
+ ) -> dict:
123
+ """List the shop's consignors (vendors), newest first.
124
+
125
+ Args:
126
+ search: Match against consignor name or email (contains).
127
+ email: Exact email address (case-insensitive).
128
+ active: Filter to active (True) or archived (False) consignors.
129
+ updated_since: ISO-8601 datetime; only records changed after it.
130
+ page: Page number (response includes total `count`).
131
+ page_size: Records per page (max 250; keep small to save context).
132
+
133
+ Each record includes contact details, the commission profile, and
134
+ `effective_name` (the display name the shop uses). Secure custom fields
135
+ (e.g. bank details) are always masked.
136
+ """
137
+ return await _get("consignors/", {
138
+ "search": search, "email": email,
139
+ "active": active, "updated_since": updated_since,
140
+ "page": page, "page_size": page_size,
141
+ })
142
+
143
+
144
+ @mcp.tool()
145
+ async def get_consignor(consignor_id: int) -> dict:
146
+ """Fetch one consignor by id, including contact details, commission
147
+ profile, and custom fields (secure values masked)."""
148
+ return await _get(f"consignors/{consignor_id}/")
149
+
150
+
151
+ @mcp.tool()
152
+ async def list_payouts(
153
+ status: Optional[str] = None,
154
+ consignor_id: Optional[int] = None,
155
+ processed_since: Optional[str] = None,
156
+ updated_since: Optional[str] = None,
157
+ created_since: Optional[str] = None,
158
+ created_before: Optional[str] = None,
159
+ page: int = 1,
160
+ page_size: int = 25,
161
+ ) -> dict:
162
+ """List the shop's payouts (what the shop pays each consignor), newest first.
163
+
164
+ Args:
165
+ status: One of DRAFT, PROCESSING, COMPLETED, FAILED. Only COMPLETED
166
+ payouts represent money actually paid.
167
+ consignor_id: Limit to one consignor's payouts.
168
+ processed_since: ISO-8601 datetime; payouts completed after it.
169
+ updated_since: ISO-8601 datetime; payouts changed after it.
170
+ created_since: ISO-8601 datetime; payouts created after it.
171
+ created_before: ISO-8601 datetime; payouts created before it.
172
+ page: Page number (response includes total `count`).
173
+ page_size: Records per page (max 250; keep small to save context).
174
+
175
+ `amount` is the calculated payout; when `actual_paid_amount` is set the
176
+ shop adjusted the figure — use it instead. `payout_number` is the
177
+ display number; always reference payouts by `id` in follow-up calls.
178
+ """
179
+ return await _get("payouts/", {
180
+ "status": status, "consignor_id": consignor_id,
181
+ "processed_since": processed_since, "updated_since": updated_since,
182
+ "created_since": created_since, "created_before": created_before,
183
+ "page": page, "page_size": page_size,
184
+ })
185
+
186
+
187
+ @mcp.tool()
188
+ async def get_payout(payout_id: int) -> dict:
189
+ """Fetch one payout by id: amount, status, payment method/reference,
190
+ dates, and the number of line items behind it."""
191
+ return await _get(f"payouts/{payout_id}/")
192
+
193
+
194
+ @mcp.tool()
195
+ async def get_payout_line_items(
196
+ payout_id: int,
197
+ page: int = 1,
198
+ page_size: int = 50,
199
+ ) -> dict:
200
+ """List the sold items behind one payout — what sold, on which order,
201
+ for how much, and what the consignor earns per line.
202
+
203
+ Per line: `description`, `order_number`, `order_date`, `item_price`
204
+ (per-unit, after discounts, excluding tax), `quantity`, `discount`
205
+ (line total), `tax_amount`, `payout_amount` (the consignor's earning),
206
+ `processing_fee` (proportional processor fee; null when no snapshot
207
+ exists), and `sku`. Matches the shop's payout line-item CSV export.
208
+ """
209
+ return await _get(f"payouts/{payout_id}/line-items/", {
210
+ "page": page, "page_size": page_size,
211
+ })
212
+
213
+
214
+ def main() -> None:
215
+ # Fail fast with a readable message when the key is missing, instead of
216
+ # erroring on the first tool call.
217
+ try:
218
+ _api_key()
219
+ except RuntimeError as exc:
220
+ print(f"visceral-mcp: {exc}", file=sys.stderr)
221
+ raise SystemExit(1)
222
+ mcp.run()
223
+
224
+
225
+ if __name__ == "__main__":
226
+ main()
@@ -0,0 +1,92 @@
1
+ """Unit tests for the Visceral MCP server (run with pytest; needs mcp + httpx).
2
+
3
+ The MCP stdio handshake itself is covered manually:
4
+ echo the initialize/tools-list JSON-RPC lines into `visceral-mcp`
5
+ (see README § Development).
6
+ """
7
+ import asyncio
8
+ import os
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ os.environ.setdefault("VISCERAL_API_KEY", "vsk_test_key")
14
+
15
+ from visceral_mcp import server
16
+
17
+
18
+ def test_all_tools_registered_with_descriptions():
19
+ tools = asyncio.run(server.mcp.list_tools())
20
+ names = sorted(t.name for t in tools)
21
+ assert names == sorted([
22
+ "get_shop_info", "list_consignors", "get_consignor",
23
+ "list_payouts", "get_payout", "get_payout_line_items",
24
+ ])
25
+ assert all(t.description for t in tools)
26
+
27
+
28
+ def test_instructions_carry_money_and_masking_guidance():
29
+ assert "decimal strings" in server.mcp.instructions
30
+ assert "masked" in server.mcp.instructions
31
+
32
+
33
+ def _with_transport(handler):
34
+ server._transport = httpx.MockTransport(handler)
35
+
36
+
37
+ def teardown_function():
38
+ server._transport = None
39
+
40
+
41
+ def test_list_payouts_sends_auth_and_strips_none_params():
42
+ seen = {}
43
+
44
+ def handler(request: httpx.Request) -> httpx.Response:
45
+ seen["url"] = str(request.url)
46
+ seen["auth"] = request.headers.get("Authorization")
47
+ return httpx.Response(200, json={"count": 0, "results": []})
48
+
49
+ _with_transport(handler)
50
+ result = asyncio.run(server.list_payouts(status="COMPLETED", page_size=5))
51
+ assert result == {"count": 0, "results": []}
52
+ assert seen["auth"] == "Bearer vsk_test_key"
53
+ assert "status=COMPLETED" in seen["url"]
54
+ assert "consignor_id" not in seen["url"]
55
+
56
+
57
+ @pytest.mark.parametrize("status,fragment", [
58
+ (401, "rejected"),
59
+ (403, "not enabled"),
60
+ (404, "Not found"),
61
+ ])
62
+ def test_error_statuses_map_to_readable_messages(status, fragment):
63
+ _with_transport(lambda request: httpx.Response(status))
64
+ with pytest.raises(RuntimeError, match=fragment):
65
+ asyncio.run(server._get("consignors/1/"))
66
+
67
+
68
+ def test_long_rate_limit_raises_with_wait_guidance():
69
+ _with_transport(
70
+ lambda request: httpx.Response(429, headers={"Retry-After": "60"}))
71
+ with pytest.raises(RuntimeError, match="60"):
72
+ asyncio.run(server._get("ping/"))
73
+
74
+
75
+ def test_short_rate_limit_retries_once():
76
+ calls = {"n": 0}
77
+
78
+ def handler(request: httpx.Request) -> httpx.Response:
79
+ calls["n"] += 1
80
+ if calls["n"] == 1:
81
+ return httpx.Response(429, headers={"Retry-After": "0"})
82
+ return httpx.Response(200, json={"ok": True})
83
+
84
+ _with_transport(handler)
85
+ assert asyncio.run(server._get("ping/")) == {"ok": True}
86
+ assert calls["n"] == 2
87
+
88
+
89
+ def test_missing_key_raises_setup_message(monkeypatch):
90
+ monkeypatch.delenv("VISCERAL_API_KEY", raising=False)
91
+ with pytest.raises(RuntimeError, match="VISCERAL_API_KEY"):
92
+ server._api_key()