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.
- visceral_consignment_mcp-0.1.0/.gitignore +4 -0
- visceral_consignment_mcp-0.1.0/PKG-INFO +130 -0
- visceral_consignment_mcp-0.1.0/README.md +111 -0
- visceral_consignment_mcp-0.1.0/pyproject.toml +39 -0
- visceral_consignment_mcp-0.1.0/src/visceral_mcp/__init__.py +3 -0
- visceral_consignment_mcp-0.1.0/src/visceral_mcp/server.py +226 -0
- visceral_consignment_mcp-0.1.0/tests/test_server.py +92 -0
|
@@ -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,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()
|