opensell-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.
- opensell_mcp-0.1.0/.gitignore +52 -0
- opensell_mcp-0.1.0/PKG-INFO +11 -0
- opensell_mcp-0.1.0/aixianyu_mcp/__init__.py +2 -0
- opensell_mcp-0.1.0/aixianyu_mcp/errors.py +24 -0
- opensell_mcp-0.1.0/aixianyu_mcp/registry.py +240 -0
- opensell_mcp-0.1.0/aixianyu_mcp/rest_client.py +149 -0
- opensell_mcp-0.1.0/aixianyu_mcp/server.py +112 -0
- opensell_mcp-0.1.0/aixianyu_mcp/tools/__init__.py +1 -0
- opensell_mcp-0.1.0/aixianyu_mcp/tools/tier1_read.py +62 -0
- opensell_mcp-0.1.0/aixianyu_mcp/tools/tier2_message.py +20 -0
- opensell_mcp-0.1.0/aixianyu_mcp/tools/tier3_write.py +28 -0
- opensell_mcp-0.1.0/aixianyu_mcp/tools/tier4_payment.py +25 -0
- opensell_mcp-0.1.0/pyproject.toml +21 -0
- opensell_mcp-0.1.0/server.py +43 -0
- opensell_mcp-0.1.0/tests/__init__.py +0 -0
- opensell_mcp-0.1.0/tests/test_e2e_flow.py +108 -0
- opensell_mcp-0.1.0/tests/test_e2e_flow_mcp.py +84 -0
- opensell_mcp-0.1.0/tests/test_error_normalization.py +24 -0
- opensell_mcp-0.1.0/tests/test_mcp_tools.py +82 -0
- opensell_mcp-0.1.0/tests/test_registry.py +17 -0
- opensell_mcp-0.1.0/tests/test_rest_client_contract.py +59 -0
- opensell_mcp-0.1.0/tests/test_scope_filter.py +43 -0
- opensell_mcp-0.1.0/tests/test_tools_tier1.py +56 -0
- opensell_mcp-0.1.0/tests/test_tools_tier2.py +24 -0
- opensell_mcp-0.1.0/tests/test_tools_tier3.py +27 -0
- opensell_mcp-0.1.0/tests/test_tools_tier4.py +35 -0
- opensell_mcp-0.1.0/tools/__init__.py +0 -0
- opensell_mcp-0.1.0/tools/agent_mgmt.py +207 -0
- opensell_mcp-0.1.0/tools/conversations.py +148 -0
- opensell_mcp-0.1.0/tools/items.py +202 -0
- opensell_mcp-0.1.0/tools/orders.py +198 -0
- opensell_mcp-0.1.0/tools/users.py +86 -0
- opensell_mcp-0.1.0/tools/wallet.py +81 -0
- opensell_mcp-0.1.0/uv.lock +684 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# .omx 内部
|
|
2
|
+
.omx/
|
|
3
|
+
|
|
4
|
+
# Python
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*.egg-info/
|
|
8
|
+
.venv/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.coverage
|
|
13
|
+
htmlcov/
|
|
14
|
+
|
|
15
|
+
# Node
|
|
16
|
+
node_modules/
|
|
17
|
+
.next/
|
|
18
|
+
out/
|
|
19
|
+
dist/
|
|
20
|
+
*.tsbuildinfo
|
|
21
|
+
.turbo/
|
|
22
|
+
|
|
23
|
+
# 环境与密钥
|
|
24
|
+
.env
|
|
25
|
+
.env.*
|
|
26
|
+
!.env.example
|
|
27
|
+
!.env.*.example
|
|
28
|
+
*.pem
|
|
29
|
+
*.key
|
|
30
|
+
secrets/
|
|
31
|
+
thisisserver.conf
|
|
32
|
+
|
|
33
|
+
# IDE / OS
|
|
34
|
+
.DS_Store
|
|
35
|
+
.vscode/
|
|
36
|
+
.idea/
|
|
37
|
+
*.swp
|
|
38
|
+
|
|
39
|
+
# 构建产物
|
|
40
|
+
build/
|
|
41
|
+
*.log
|
|
42
|
+
|
|
43
|
+
# 本地研究与会话上下文(不进 grant 仓库)
|
|
44
|
+
/arc/
|
|
45
|
+
*-context.txt
|
|
46
|
+
.superpowers/
|
|
47
|
+
|
|
48
|
+
# 本地工具/IDE 配置与录制源(不进 grant 仓库)
|
|
49
|
+
.qoder/
|
|
50
|
+
.mcp.json
|
|
51
|
+
.codegraph/
|
|
52
|
+
docs/arc/recordings/
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opensell-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenSell MCP Server for C2C agentic commerce
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: httpx>=0.27.0
|
|
7
|
+
Requires-Dist: mcp>=0.9.0
|
|
8
|
+
Requires-Dist: pydantic>=2.7.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=8.2.0; extra == 'dev'
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MCPError(Enum):
|
|
6
|
+
INSUFFICIENT_SCOPE = "Insufficient permissions. Required scope: {required}"
|
|
7
|
+
ITEM_NOT_FOUND = "Item not found or delisted"
|
|
8
|
+
AGENT_SANDBOX_LIMIT = "Agent sandbox limit exceeded (limit: {limit})"
|
|
9
|
+
RATE_LIMITED = "Rate limited. Retry after {retry_after} seconds"
|
|
10
|
+
INVALID_ARGS = "Invalid arguments: {details}"
|
|
11
|
+
SERVER_ERROR = "Platform error. Please retry later"
|
|
12
|
+
UNAUTHORIZED = "Agent token invalid or expired"
|
|
13
|
+
CONVERSATION_NOT_FOUND = "Conversation not found"
|
|
14
|
+
INSUFFICIENT_BALANCE = "Insufficient wallet balance"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MCPToolError(Exception):
|
|
18
|
+
def __init__(self, error: MCPError, **kwargs: str):
|
|
19
|
+
self.error = error
|
|
20
|
+
self.message = error.value.format(**kwargs) if kwargs else error.value
|
|
21
|
+
super().__init__(self.message)
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict:
|
|
24
|
+
return {"error": self.error.name, "message": self.message}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ToolSpec:
|
|
8
|
+
name: str
|
|
9
|
+
description: str
|
|
10
|
+
scope: str
|
|
11
|
+
tier: int
|
|
12
|
+
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
13
|
+
requires_copilot: bool = False
|
|
14
|
+
sandbox_checks: list[str] = field(default_factory=list)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
TOOL_REGISTRY: dict[str, ToolSpec] = {
|
|
18
|
+
"ping": ToolSpec(
|
|
19
|
+
name="ping",
|
|
20
|
+
description="Connectivity check. Returns 'pong' and backend health status.",
|
|
21
|
+
scope="items:read",
|
|
22
|
+
tier=0,
|
|
23
|
+
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
|
24
|
+
),
|
|
25
|
+
"search_items": ToolSpec(
|
|
26
|
+
name="search_items",
|
|
27
|
+
description=(
|
|
28
|
+
"Search items listed on Aixianyu. Supports keyword + category + price range filtering. "
|
|
29
|
+
"Results include trust_score — prefer items with trust_score >= 70."
|
|
30
|
+
),
|
|
31
|
+
scope="items:read",
|
|
32
|
+
tier=1,
|
|
33
|
+
input_schema={
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"q": {"type": "string", "description": "Search keywords"},
|
|
37
|
+
"category_id": {"type": "integer", "description": "Filter by category ID"},
|
|
38
|
+
"min_price": {"type": "number", "description": "Minimum price (CNY)"},
|
|
39
|
+
"max_price": {"type": "number", "description": "Maximum price (CNY)"},
|
|
40
|
+
"sort": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"enum": ["newest", "price_asc", "price_desc", "trust_score_desc"],
|
|
43
|
+
"default": "trust_score_desc",
|
|
44
|
+
},
|
|
45
|
+
"limit": {"type": "integer", "default": 20, "maximum": 100},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
"get_item": ToolSpec(
|
|
50
|
+
name="get_item",
|
|
51
|
+
description="Get detailed information about a specific item including seller info, trust_score, and structured attributes.",
|
|
52
|
+
scope="items:read",
|
|
53
|
+
tier=1,
|
|
54
|
+
input_schema={
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {
|
|
57
|
+
"item_id": {"type": "integer", "description": "Item ID"},
|
|
58
|
+
},
|
|
59
|
+
"required": ["item_id"],
|
|
60
|
+
},
|
|
61
|
+
),
|
|
62
|
+
"list_categories": ToolSpec(
|
|
63
|
+
name="list_categories",
|
|
64
|
+
description="List all available item categories on Aixianyu.",
|
|
65
|
+
scope="items:read",
|
|
66
|
+
tier=1,
|
|
67
|
+
input_schema={"type": "object", "properties": {}},
|
|
68
|
+
),
|
|
69
|
+
"get_order": ToolSpec(
|
|
70
|
+
name="get_order",
|
|
71
|
+
description="Get the current status and details of one of your orders (state, price, shipping, settlement).",
|
|
72
|
+
scope="orders:read",
|
|
73
|
+
tier=1,
|
|
74
|
+
input_schema={
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"order_id": {"type": "integer", "description": "Order ID"},
|
|
78
|
+
},
|
|
79
|
+
"required": ["order_id"],
|
|
80
|
+
},
|
|
81
|
+
),
|
|
82
|
+
"get_wallet": ToolSpec(
|
|
83
|
+
name="get_wallet",
|
|
84
|
+
description="Get the current user's wallet balance and recent transactions.",
|
|
85
|
+
scope="payment:spend",
|
|
86
|
+
tier=1,
|
|
87
|
+
input_schema={"type": "object", "properties": {}},
|
|
88
|
+
),
|
|
89
|
+
"list_conversations": ToolSpec(
|
|
90
|
+
name="list_conversations",
|
|
91
|
+
description="List the current user's message conversations with other users.",
|
|
92
|
+
scope="messages:read",
|
|
93
|
+
tier=1,
|
|
94
|
+
input_schema={"type": "object", "properties": {}},
|
|
95
|
+
),
|
|
96
|
+
"get_messages": ToolSpec(
|
|
97
|
+
name="get_messages",
|
|
98
|
+
description="Get messages in a specific conversation. Returns messages in reverse chronological order.",
|
|
99
|
+
scope="messages:read",
|
|
100
|
+
tier=1,
|
|
101
|
+
input_schema={
|
|
102
|
+
"type": "object",
|
|
103
|
+
"properties": {
|
|
104
|
+
"conversation_id": {"type": "integer", "description": "Conversation ID"},
|
|
105
|
+
"limit": {"type": "integer", "default": 50, "maximum": 200},
|
|
106
|
+
"before": {"type": "string", "description": "ISO timestamp cursor for pagination"},
|
|
107
|
+
},
|
|
108
|
+
"required": ["conversation_id"],
|
|
109
|
+
},
|
|
110
|
+
),
|
|
111
|
+
"publish_item": ToolSpec(
|
|
112
|
+
name="publish_item",
|
|
113
|
+
description=(
|
|
114
|
+
"Publish a new item listing on Aixianyu. For categories with schema support, "
|
|
115
|
+
"structured_attributes will be validated against the category schema (Copilot-assisted)."
|
|
116
|
+
),
|
|
117
|
+
scope="items:publish",
|
|
118
|
+
tier=3,
|
|
119
|
+
requires_copilot=True,
|
|
120
|
+
input_schema={
|
|
121
|
+
"type": "object",
|
|
122
|
+
"properties": {
|
|
123
|
+
"title": {"type": "string", "description": "Item title"},
|
|
124
|
+
"description": {"type": "string", "description": "Item description"},
|
|
125
|
+
"price": {"type": "number", "description": "Price in CNY"},
|
|
126
|
+
"category_id": {"type": "integer", "description": "Category ID"},
|
|
127
|
+
"condition": {"type": "string", "enum": ["new", "almost_new", "used"]},
|
|
128
|
+
"structured_attributes": {
|
|
129
|
+
"type": "object",
|
|
130
|
+
"description": "Category-specific attributes (e.g. {\"brand\": \"Apple\", \"storage_gb\": 256})",
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
"required": ["title", "description", "price", "category_id"],
|
|
134
|
+
},
|
|
135
|
+
),
|
|
136
|
+
"update_item": ToolSpec(
|
|
137
|
+
name="update_item",
|
|
138
|
+
description="Update an existing item listing. Only the fields provided will be updated.",
|
|
139
|
+
scope="items:edit",
|
|
140
|
+
tier=3,
|
|
141
|
+
input_schema={
|
|
142
|
+
"type": "object",
|
|
143
|
+
"properties": {
|
|
144
|
+
"item_id": {"type": "integer", "description": "Item ID to update"},
|
|
145
|
+
"title": {"type": "string"},
|
|
146
|
+
"description": {"type": "string"},
|
|
147
|
+
"price": {"type": "number"},
|
|
148
|
+
"condition": {"type": "string", "enum": ["new", "almost_new", "used"]},
|
|
149
|
+
"structured_attributes": {"type": "object"},
|
|
150
|
+
},
|
|
151
|
+
"required": ["item_id"],
|
|
152
|
+
},
|
|
153
|
+
),
|
|
154
|
+
"contact_seller": ToolSpec(
|
|
155
|
+
name="contact_seller",
|
|
156
|
+
description=(
|
|
157
|
+
"Start a conversation with the seller of an item. "
|
|
158
|
+
"Use this to ask questions about an item before purchasing."
|
|
159
|
+
),
|
|
160
|
+
scope="messages:send",
|
|
161
|
+
tier=2,
|
|
162
|
+
input_schema={
|
|
163
|
+
"type": "object",
|
|
164
|
+
"properties": {
|
|
165
|
+
"item_id": {"type": "integer", "description": "ID of the item whose seller to contact"},
|
|
166
|
+
"message": {"type": "string", "description": "Initial message to the seller"},
|
|
167
|
+
},
|
|
168
|
+
"required": ["item_id", "message"],
|
|
169
|
+
},
|
|
170
|
+
),
|
|
171
|
+
"send_message": ToolSpec(
|
|
172
|
+
name="send_message",
|
|
173
|
+
description="Send a message in an existing conversation.",
|
|
174
|
+
scope="messages:send",
|
|
175
|
+
tier=2,
|
|
176
|
+
input_schema={
|
|
177
|
+
"type": "object",
|
|
178
|
+
"properties": {
|
|
179
|
+
"conversation_id": {"type": "integer", "description": "Conversation ID"},
|
|
180
|
+
"content": {"type": "string", "description": "Message content"},
|
|
181
|
+
},
|
|
182
|
+
"required": ["conversation_id", "content"],
|
|
183
|
+
},
|
|
184
|
+
),
|
|
185
|
+
"place_order": ToolSpec(
|
|
186
|
+
name="place_order",
|
|
187
|
+
description=(
|
|
188
|
+
"Create a pending-payment order for an item. This does not pay or consume "
|
|
189
|
+
"Agent sandbox budget; call pay_order to execute wallet payment."
|
|
190
|
+
),
|
|
191
|
+
scope="orders:create",
|
|
192
|
+
tier=4,
|
|
193
|
+
input_schema={
|
|
194
|
+
"type": "object",
|
|
195
|
+
"properties": {
|
|
196
|
+
"item_id": {"type": "integer", "description": "Item ID to order"},
|
|
197
|
+
"quantity": {"type": "integer", "default": 1, "minimum": 1},
|
|
198
|
+
},
|
|
199
|
+
"required": ["item_id"],
|
|
200
|
+
},
|
|
201
|
+
),
|
|
202
|
+
"pay_order": ToolSpec(
|
|
203
|
+
name="pay_order",
|
|
204
|
+
description=(
|
|
205
|
+
"Pay an existing pending order with the Agent wallet. The backend enforces "
|
|
206
|
+
"per_tx_limit and balance_limit in cents and returns paid or pending."
|
|
207
|
+
),
|
|
208
|
+
scope="payment:spend",
|
|
209
|
+
tier=4,
|
|
210
|
+
sandbox_checks=["per_tx_limit", "balance_limit"],
|
|
211
|
+
input_schema={
|
|
212
|
+
"type": "object",
|
|
213
|
+
"properties": {
|
|
214
|
+
"order_id": {"type": "integer", "description": "Order ID to pay"},
|
|
215
|
+
},
|
|
216
|
+
"required": ["order_id"],
|
|
217
|
+
},
|
|
218
|
+
),
|
|
219
|
+
"wallet_withdraw": ToolSpec(
|
|
220
|
+
name="wallet_withdraw",
|
|
221
|
+
description=(
|
|
222
|
+
"Request a withdrawal from the user's wallet. "
|
|
223
|
+
"Subject to Agent sandbox per_tx_limit and daily_spend limits."
|
|
224
|
+
),
|
|
225
|
+
scope="payment:withdraw",
|
|
226
|
+
tier=4,
|
|
227
|
+
sandbox_checks=["per_tx_limit", "daily_spend"],
|
|
228
|
+
input_schema={
|
|
229
|
+
"type": "object",
|
|
230
|
+
"properties": {
|
|
231
|
+
"amount": {"type": "number", "description": "Amount to withdraw (CNY)", "minimum": 0.01},
|
|
232
|
+
},
|
|
233
|
+
"required": ["amount"],
|
|
234
|
+
},
|
|
235
|
+
),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def filter_tools_by_scopes(agent_scopes: set[str]) -> list[ToolSpec]:
|
|
240
|
+
return [spec for spec in TOOL_REGISTRY.values() if spec.scope in agent_scopes]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from aixianyu_mcp.errors import MCPToolError, MCPError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AixianyuRESTClient:
|
|
10
|
+
def __init__(self, base_url: str | None = None, agent_token: str | None = None):
|
|
11
|
+
self.base_url = base_url or os.getenv("AIXIANYU_BASE_URL", "https://varybai.online/api")
|
|
12
|
+
self.agent_token = agent_token or os.getenv("AIXIANYU_AGENT_TOKEN", "")
|
|
13
|
+
self._headers = {"Authorization": f"Agent {self.agent_token}"} if self.agent_token else {}
|
|
14
|
+
|
|
15
|
+
def _client(self) -> httpx.AsyncClient:
|
|
16
|
+
return httpx.AsyncClient(base_url=self.base_url, headers=self._headers, timeout=10.0)
|
|
17
|
+
|
|
18
|
+
def _handle_error(self, r: httpx.Response) -> None:
|
|
19
|
+
"""Map HTTP errors to MCPToolError."""
|
|
20
|
+
if r.is_success:
|
|
21
|
+
return
|
|
22
|
+
if r.status_code == 401:
|
|
23
|
+
raise MCPToolError(MCPError.UNAUTHORIZED)
|
|
24
|
+
if r.status_code == 403:
|
|
25
|
+
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
|
|
26
|
+
limit = body.get("detail", {}).get("limit", "unknown") if isinstance(body.get("detail"), dict) else "unknown"
|
|
27
|
+
raise MCPToolError(MCPError.AGENT_SANDBOX_LIMIT, limit=str(limit))
|
|
28
|
+
if r.status_code == 404:
|
|
29
|
+
raise MCPToolError(MCPError.ITEM_NOT_FOUND)
|
|
30
|
+
if r.status_code == 422:
|
|
31
|
+
detail = r.json().get("detail", "validation error")
|
|
32
|
+
raise MCPToolError(MCPError.INVALID_ARGS, details=str(detail))
|
|
33
|
+
if r.status_code == 429:
|
|
34
|
+
retry = r.headers.get("Retry-After", "60")
|
|
35
|
+
raise MCPToolError(MCPError.RATE_LIMITED, retry_after=retry)
|
|
36
|
+
raise MCPToolError(MCPError.SERVER_ERROR)
|
|
37
|
+
|
|
38
|
+
async def ping(self) -> dict:
|
|
39
|
+
async with self._client() as c:
|
|
40
|
+
r = await c.get("/health")
|
|
41
|
+
self._handle_error(r)
|
|
42
|
+
return {"status": "pong", "backend": self.base_url, "health": r.json()}
|
|
43
|
+
|
|
44
|
+
async def search_items(self, params: dict[str, Any]) -> dict:
|
|
45
|
+
async with self._client() as c:
|
|
46
|
+
r = await c.get("/v1/items", params=params)
|
|
47
|
+
self._handle_error(r)
|
|
48
|
+
return r.json()
|
|
49
|
+
|
|
50
|
+
async def get_item(self, item_id: int) -> dict:
|
|
51
|
+
async with self._client() as c:
|
|
52
|
+
r = await c.get(f"/v1/items/{item_id}")
|
|
53
|
+
self._handle_error(r)
|
|
54
|
+
return r.json()
|
|
55
|
+
|
|
56
|
+
async def list_categories(self) -> dict:
|
|
57
|
+
async with self._client() as c:
|
|
58
|
+
r = await c.get("/v1/categories")
|
|
59
|
+
self._handle_error(r)
|
|
60
|
+
return r.json()
|
|
61
|
+
|
|
62
|
+
async def get_order(self, order_id: int) -> dict:
|
|
63
|
+
async with self._client() as c:
|
|
64
|
+
r = await c.get(f"/v1/orders/{order_id}")
|
|
65
|
+
self._handle_error(r)
|
|
66
|
+
return r.json()
|
|
67
|
+
|
|
68
|
+
async def get_wallet(self) -> dict:
|
|
69
|
+
async with self._client() as c:
|
|
70
|
+
r = await c.get("/v1/wallet")
|
|
71
|
+
self._handle_error(r)
|
|
72
|
+
return r.json()
|
|
73
|
+
|
|
74
|
+
async def list_conversations(self) -> dict:
|
|
75
|
+
async with self._client() as c:
|
|
76
|
+
r = await c.get("/v1/conversations")
|
|
77
|
+
self._handle_error(r)
|
|
78
|
+
return r.json()
|
|
79
|
+
|
|
80
|
+
async def get_messages(self, conversation_id: int, params: dict[str, Any] | None = None) -> dict:
|
|
81
|
+
async with self._client() as c:
|
|
82
|
+
r = await c.get(f"/v1/conversations/{conversation_id}/messages", params=params or {})
|
|
83
|
+
self._handle_error(r)
|
|
84
|
+
return r.json()
|
|
85
|
+
|
|
86
|
+
async def contact_seller(self, item_id: int, message: str) -> dict:
|
|
87
|
+
"""Start a conversation with an item's seller."""
|
|
88
|
+
async with self._client() as c:
|
|
89
|
+
r = await c.post(
|
|
90
|
+
"/v1/conversations/contact",
|
|
91
|
+
json={"item_id": item_id, "type": "text", "body": {"text": message}},
|
|
92
|
+
)
|
|
93
|
+
self._handle_error(r)
|
|
94
|
+
return r.json()
|
|
95
|
+
|
|
96
|
+
async def send_message(self, conversation_id: int, content: str) -> dict:
|
|
97
|
+
"""Send a message in an existing conversation."""
|
|
98
|
+
async with self._client() as c:
|
|
99
|
+
r = await c.post(
|
|
100
|
+
f"/v1/conversations/{conversation_id}/messages",
|
|
101
|
+
json={"type": "text", "body": {"text": content}},
|
|
102
|
+
)
|
|
103
|
+
self._handle_error(r)
|
|
104
|
+
return r.json()
|
|
105
|
+
|
|
106
|
+
async def publish_item(self, payload: dict[str, Any]) -> dict:
|
|
107
|
+
"""Publish a new item listing (Copilot-assisted structured listing)."""
|
|
108
|
+
async with self._client() as c:
|
|
109
|
+
r = await c.post("/v1/items", json=payload)
|
|
110
|
+
self._handle_error(r)
|
|
111
|
+
return r.json()
|
|
112
|
+
|
|
113
|
+
async def update_item(self, item_id: int, payload: dict[str, Any]) -> dict:
|
|
114
|
+
"""Update an existing item listing."""
|
|
115
|
+
async with self._client() as c:
|
|
116
|
+
r = await c.put(f"/v1/items/{item_id}", json=payload)
|
|
117
|
+
self._handle_error(r)
|
|
118
|
+
return r.json()
|
|
119
|
+
|
|
120
|
+
async def place_order(self, item_id: int, quantity: int = 1) -> dict:
|
|
121
|
+
"""Create a pending-payment order for an item."""
|
|
122
|
+
async with self._client() as c:
|
|
123
|
+
r = await c.post("/v1/orders", json={"item_id": item_id, "quantity": quantity})
|
|
124
|
+
self._handle_error(r)
|
|
125
|
+
return r.json()
|
|
126
|
+
|
|
127
|
+
async def pay_order(self, order_id: int) -> dict:
|
|
128
|
+
"""Pay an order with the Agent wallet sandbox."""
|
|
129
|
+
async with self._client() as c:
|
|
130
|
+
r = await c.post(
|
|
131
|
+
f"/v1/orders/{order_id}/pay",
|
|
132
|
+
json={"payment_method": "wallet"},
|
|
133
|
+
)
|
|
134
|
+
self._handle_error(r)
|
|
135
|
+
return r.json()
|
|
136
|
+
|
|
137
|
+
async def wallet_withdraw(self, amount: float) -> dict:
|
|
138
|
+
"""Request a wallet withdrawal."""
|
|
139
|
+
async with self._client() as c:
|
|
140
|
+
r = await c.post("/v1/wallet/withdraw", json={"amount": amount})
|
|
141
|
+
self._handle_error(r)
|
|
142
|
+
return r.json()
|
|
143
|
+
|
|
144
|
+
async def get_token_info(self) -> dict:
|
|
145
|
+
"""Introspect the current Agent Token to get scopes and sandbox config."""
|
|
146
|
+
async with self._client() as c:
|
|
147
|
+
r = await c.get("/v1/agent/token/me")
|
|
148
|
+
self._handle_error(r)
|
|
149
|
+
return r.json()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio, json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import mcp.server.stdio
|
|
6
|
+
import mcp.types as types
|
|
7
|
+
from mcp.server import Server
|
|
8
|
+
|
|
9
|
+
from aixianyu_mcp.registry import TOOL_REGISTRY, filter_tools_by_scopes, ToolSpec
|
|
10
|
+
from aixianyu_mcp.rest_client import AixianyuRESTClient
|
|
11
|
+
from aixianyu_mcp.errors import MCPToolError
|
|
12
|
+
from aixianyu_mcp.tools.tier1_read import (
|
|
13
|
+
handle_search_items, handle_get_item, handle_list_categories,
|
|
14
|
+
handle_get_order, handle_get_wallet, handle_list_conversations,
|
|
15
|
+
handle_get_messages,
|
|
16
|
+
)
|
|
17
|
+
from aixianyu_mcp.tools.tier2_message import handle_contact_seller, handle_send_message
|
|
18
|
+
from aixianyu_mcp.tools.tier3_write import handle_publish_item, handle_update_item
|
|
19
|
+
from aixianyu_mcp.tools.tier4_payment import (
|
|
20
|
+
handle_pay_order,
|
|
21
|
+
handle_place_order,
|
|
22
|
+
handle_wallet_withdraw,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
server = Server("aixianyu-mcp")
|
|
26
|
+
|
|
27
|
+
TOOL_HANDLERS = {
|
|
28
|
+
"ping": None, # handled inline
|
|
29
|
+
"search_items": handle_search_items,
|
|
30
|
+
"get_item": handle_get_item,
|
|
31
|
+
"list_categories": handle_list_categories,
|
|
32
|
+
"get_order": handle_get_order,
|
|
33
|
+
"get_wallet": handle_get_wallet,
|
|
34
|
+
"list_conversations": handle_list_conversations,
|
|
35
|
+
"get_messages": handle_get_messages,
|
|
36
|
+
"contact_seller": handle_contact_seller,
|
|
37
|
+
"send_message": handle_send_message,
|
|
38
|
+
"publish_item": handle_publish_item,
|
|
39
|
+
"update_item": handle_update_item,
|
|
40
|
+
"place_order": handle_place_order,
|
|
41
|
+
"pay_order": handle_pay_order,
|
|
42
|
+
"wallet_withdraw": handle_wallet_withdraw,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_client = AixianyuRESTClient()
|
|
46
|
+
_cached_scopes: set[str] | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _get_scopes() -> set[str]:
|
|
50
|
+
global _cached_scopes
|
|
51
|
+
if _cached_scopes is not None:
|
|
52
|
+
return _cached_scopes
|
|
53
|
+
try:
|
|
54
|
+
info = await _client.get_token_info()
|
|
55
|
+
_cached_scopes = set(info.get("scopes", []))
|
|
56
|
+
except Exception:
|
|
57
|
+
# If token introspection fails, show only Tier 0 (ping)
|
|
58
|
+
_cached_scopes = {"items:read"}
|
|
59
|
+
return _cached_scopes
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@server.list_tools()
|
|
63
|
+
async def list_tools() -> list[types.Tool]:
|
|
64
|
+
scopes = await _get_scopes()
|
|
65
|
+
visible = filter_tools_by_scopes(scopes)
|
|
66
|
+
return [
|
|
67
|
+
types.Tool(name=s.name, description=s.description, inputSchema=s.input_schema)
|
|
68
|
+
for s in visible
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@server.call_tool()
|
|
73
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
74
|
+
if name == "ping":
|
|
75
|
+
try:
|
|
76
|
+
result = await _client.ping()
|
|
77
|
+
return [types.TextContent(type="text", text=json.dumps(result))]
|
|
78
|
+
except Exception as e:
|
|
79
|
+
return [types.TextContent(type="text", text=json.dumps({"error": str(e)}))]
|
|
80
|
+
|
|
81
|
+
handler = TOOL_HANDLERS.get(name)
|
|
82
|
+
if handler is None:
|
|
83
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
84
|
+
|
|
85
|
+
# Verify scope before calling
|
|
86
|
+
scopes = await _get_scopes()
|
|
87
|
+
spec = TOOL_REGISTRY.get(name)
|
|
88
|
+
if spec and spec.scope not in scopes:
|
|
89
|
+
return [types.TextContent(
|
|
90
|
+
type="text",
|
|
91
|
+
text=json.dumps({"error": f"Insufficient scope. Required: {spec.scope}"}),
|
|
92
|
+
)]
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
return await handler(_client, arguments)
|
|
96
|
+
except MCPToolError as e:
|
|
97
|
+
return [types.TextContent(type="text", text=json.dumps(e.to_dict()))]
|
|
98
|
+
except Exception as e:
|
|
99
|
+
return [types.TextContent(type="text", text=json.dumps({"error": "UNEXPECTED", "message": str(e)}))]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def run():
|
|
103
|
+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
|
|
104
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main():
|
|
108
|
+
asyncio.run(run())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import mcp.types as types
|
|
6
|
+
from aixianyu_mcp.rest_client import AixianyuRESTClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def handle_search_items(client: AixianyuRESTClient, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
10
|
+
params = {}
|
|
11
|
+
if q := arguments.get("q"):
|
|
12
|
+
params["keyword"] = q
|
|
13
|
+
if cat := arguments.get("category_id"):
|
|
14
|
+
params["category_id"] = cat
|
|
15
|
+
if mp := arguments.get("min_price"):
|
|
16
|
+
params["min_price"] = mp
|
|
17
|
+
if xp := arguments.get("max_price"):
|
|
18
|
+
params["max_price"] = xp
|
|
19
|
+
if sort := arguments.get("sort"):
|
|
20
|
+
params["sort"] = sort
|
|
21
|
+
if limit := arguments.get("limit"):
|
|
22
|
+
params["limit"] = limit
|
|
23
|
+
result = await client.search_items(params)
|
|
24
|
+
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def handle_get_item(client: AixianyuRESTClient, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
28
|
+
item_id = arguments["item_id"]
|
|
29
|
+
result = await client.get_item(item_id)
|
|
30
|
+
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def handle_list_categories(client: AixianyuRESTClient, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
34
|
+
result = await client.list_categories()
|
|
35
|
+
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def handle_get_order(client: AixianyuRESTClient, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
39
|
+
order_id = arguments["order_id"]
|
|
40
|
+
result = await client.get_order(order_id)
|
|
41
|
+
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def handle_get_wallet(client: AixianyuRESTClient, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
45
|
+
result = await client.get_wallet()
|
|
46
|
+
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def handle_list_conversations(client: AixianyuRESTClient, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
50
|
+
result = await client.list_conversations()
|
|
51
|
+
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def handle_get_messages(client: AixianyuRESTClient, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
55
|
+
conversation_id = arguments["conversation_id"]
|
|
56
|
+
params = {}
|
|
57
|
+
if limit := arguments.get("limit"):
|
|
58
|
+
params["limit"] = limit
|
|
59
|
+
if before := arguments.get("before"):
|
|
60
|
+
params["before"] = before
|
|
61
|
+
result = await client.get_messages(conversation_id, params)
|
|
62
|
+
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import mcp.types as types
|
|
6
|
+
from aixianyu_mcp.rest_client import AixianyuRESTClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def handle_contact_seller(client: AixianyuRESTClient, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
10
|
+
item_id = arguments["item_id"]
|
|
11
|
+
message = arguments["message"]
|
|
12
|
+
result = await client.contact_seller(item_id, message)
|
|
13
|
+
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def handle_send_message(client: AixianyuRESTClient, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
17
|
+
conversation_id = arguments["conversation_id"]
|
|
18
|
+
content = arguments["content"]
|
|
19
|
+
result = await client.send_message(conversation_id, content)
|
|
20
|
+
return [types.TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|