t402 1.5.3__py3-none-any.whl → 1.6.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.
- t402/__init__.py +37 -0
- t402/mcp/__init__.py +109 -0
- t402/mcp/constants.py +213 -0
- t402/mcp/server.py +527 -0
- t402/mcp/tools.py +169 -0
- t402/mcp/types.py +241 -0
- t402/networks.py +47 -3
- t402/svm.py +566 -0
- {t402-1.5.3.dist-info → t402-1.6.0.dist-info}/METADATA +41 -1
- {t402-1.5.3.dist-info → t402-1.6.0.dist-info}/RECORD +12 -6
- {t402-1.5.3.dist-info → t402-1.6.0.dist-info}/WHEEL +0 -0
- {t402-1.5.3.dist-info → t402-1.6.0.dist-info}/entry_points.txt +0 -0
t402/mcp/server.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
"""T402 MCP Server implementation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from typing import Any, Optional, TextIO
|
|
9
|
+
|
|
10
|
+
from .constants import (
|
|
11
|
+
ALL_NETWORKS,
|
|
12
|
+
LAYERZERO_SCAN_URL,
|
|
13
|
+
NATIVE_SYMBOLS,
|
|
14
|
+
get_explorer_tx_url,
|
|
15
|
+
get_token_address,
|
|
16
|
+
is_bridgeable_chain,
|
|
17
|
+
is_gasless_network,
|
|
18
|
+
is_valid_network,
|
|
19
|
+
)
|
|
20
|
+
from .tools import get_tool_definitions
|
|
21
|
+
from .types import (
|
|
22
|
+
BalanceInfo,
|
|
23
|
+
BridgeFeeResult,
|
|
24
|
+
BridgeResultData,
|
|
25
|
+
ContentBlock,
|
|
26
|
+
JSONRPCError,
|
|
27
|
+
JSONRPCResponse,
|
|
28
|
+
NetworkBalance,
|
|
29
|
+
PaymentResult,
|
|
30
|
+
ServerConfig,
|
|
31
|
+
ToolResult,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class T402McpServer:
|
|
36
|
+
"""T402 MCP Server.
|
|
37
|
+
|
|
38
|
+
Provides blockchain payment tools for AI agents via MCP protocol.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
config: Optional[ServerConfig] = None,
|
|
44
|
+
stdin: Optional[TextIO] = None,
|
|
45
|
+
stdout: Optional[TextIO] = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Create a new MCP server.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
config: Server configuration
|
|
51
|
+
stdin: Input stream (default: sys.stdin)
|
|
52
|
+
stdout: Output stream (default: sys.stdout)
|
|
53
|
+
"""
|
|
54
|
+
self.config = config or ServerConfig()
|
|
55
|
+
self._stdin = stdin or sys.stdin
|
|
56
|
+
self._stdout = stdout or sys.stdout
|
|
57
|
+
|
|
58
|
+
async def run(self) -> None:
|
|
59
|
+
"""Run the MCP server, processing requests until EOF."""
|
|
60
|
+
print("T402 MCP Server starting...", file=sys.stderr)
|
|
61
|
+
print(f"Demo mode: {self.config.demo_mode}", file=sys.stderr)
|
|
62
|
+
|
|
63
|
+
loop = asyncio.get_event_loop()
|
|
64
|
+
|
|
65
|
+
while True:
|
|
66
|
+
try:
|
|
67
|
+
# Read line from stdin
|
|
68
|
+
line = await loop.run_in_executor(None, self._stdin.readline)
|
|
69
|
+
if not line:
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
line = line.strip()
|
|
73
|
+
if not line:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# Process request
|
|
77
|
+
response = await self._handle_request(line)
|
|
78
|
+
|
|
79
|
+
# Write response
|
|
80
|
+
response_json = self._serialize_response(response)
|
|
81
|
+
self._stdout.write(response_json + "\n")
|
|
82
|
+
self._stdout.flush()
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
async def _handle_request(self, data: str) -> JSONRPCResponse:
|
|
89
|
+
"""Handle a single JSON-RPC request."""
|
|
90
|
+
try:
|
|
91
|
+
req = json.loads(data)
|
|
92
|
+
except json.JSONDecodeError as e:
|
|
93
|
+
return JSONRPCResponse(
|
|
94
|
+
jsonrpc="2.0",
|
|
95
|
+
id=None,
|
|
96
|
+
error=JSONRPCError(code=-32700, message="Parse error", data=str(e)),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
method = req.get("method", "")
|
|
100
|
+
req_id = req.get("id")
|
|
101
|
+
params = req.get("params", {})
|
|
102
|
+
|
|
103
|
+
response = JSONRPCResponse(jsonrpc="2.0", id=req_id)
|
|
104
|
+
|
|
105
|
+
if method == "initialize":
|
|
106
|
+
response.result = self._handle_initialize()
|
|
107
|
+
elif method == "tools/list":
|
|
108
|
+
response.result = self._handle_list_tools()
|
|
109
|
+
elif method == "tools/call":
|
|
110
|
+
response.result = await self._handle_call_tool(params)
|
|
111
|
+
elif method == "notifications/initialized":
|
|
112
|
+
response.result = {}
|
|
113
|
+
else:
|
|
114
|
+
response.error = JSONRPCError(
|
|
115
|
+
code=-32601, message="Method not found", data=method
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
def _handle_initialize(self) -> dict[str, Any]:
|
|
121
|
+
"""Handle the initialize request."""
|
|
122
|
+
return {
|
|
123
|
+
"protocolVersion": "2024-11-05",
|
|
124
|
+
"serverInfo": {"name": "t402", "version": "1.0.0"},
|
|
125
|
+
"capabilities": {"tools": {}},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
def _handle_list_tools(self) -> dict[str, Any]:
|
|
129
|
+
"""Handle the tools/list request."""
|
|
130
|
+
tools = get_tool_definitions()
|
|
131
|
+
return {"tools": [self._tool_to_dict(t) for t in tools]}
|
|
132
|
+
|
|
133
|
+
def _tool_to_dict(self, tool) -> dict[str, Any]:
|
|
134
|
+
"""Convert Tool to dictionary."""
|
|
135
|
+
return {
|
|
136
|
+
"name": tool.name,
|
|
137
|
+
"description": tool.description,
|
|
138
|
+
"inputSchema": {
|
|
139
|
+
"type": tool.inputSchema.type,
|
|
140
|
+
"properties": {
|
|
141
|
+
k: {
|
|
142
|
+
"type": v.type,
|
|
143
|
+
**({"description": v.description} if v.description else {}),
|
|
144
|
+
**({"enum": v.enum} if v.enum else {}),
|
|
145
|
+
**({"pattern": v.pattern} if v.pattern else {}),
|
|
146
|
+
}
|
|
147
|
+
for k, v in tool.inputSchema.properties.items()
|
|
148
|
+
},
|
|
149
|
+
"required": tool.inputSchema.required,
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async def _handle_call_tool(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
154
|
+
"""Handle the tools/call request."""
|
|
155
|
+
tool_name = params.get("name", "")
|
|
156
|
+
arguments = params.get("arguments", {})
|
|
157
|
+
|
|
158
|
+
if tool_name == "t402/getBalance":
|
|
159
|
+
result = await self._handle_get_balance(arguments)
|
|
160
|
+
elif tool_name == "t402/getAllBalances":
|
|
161
|
+
result = await self._handle_get_all_balances(arguments)
|
|
162
|
+
elif tool_name == "t402/pay":
|
|
163
|
+
result = await self._handle_pay(arguments)
|
|
164
|
+
elif tool_name == "t402/payGasless":
|
|
165
|
+
result = await self._handle_pay_gasless(arguments)
|
|
166
|
+
elif tool_name == "t402/getBridgeFee":
|
|
167
|
+
result = await self._handle_get_bridge_fee(arguments)
|
|
168
|
+
elif tool_name == "t402/bridge":
|
|
169
|
+
result = await self._handle_bridge(arguments)
|
|
170
|
+
else:
|
|
171
|
+
result = self._error_result(f"Unknown tool: {tool_name}")
|
|
172
|
+
|
|
173
|
+
return {"content": [asdict(c) for c in result.content], "isError": result.isError}
|
|
174
|
+
|
|
175
|
+
async def _handle_get_balance(self, args: dict[str, Any]) -> ToolResult:
|
|
176
|
+
"""Handle t402/getBalance tool."""
|
|
177
|
+
try:
|
|
178
|
+
_address = args.get("address", "") # noqa: F841 - reserved for future use
|
|
179
|
+
network = args.get("network", "")
|
|
180
|
+
|
|
181
|
+
if not is_valid_network(network):
|
|
182
|
+
return self._error_result(f"Invalid network: {network}")
|
|
183
|
+
|
|
184
|
+
# In demo mode or without web3, return placeholder data
|
|
185
|
+
result = NetworkBalance(
|
|
186
|
+
network=network,
|
|
187
|
+
native=BalanceInfo(
|
|
188
|
+
token=NATIVE_SYMBOLS.get(network, "ETH"),
|
|
189
|
+
balance="0.0",
|
|
190
|
+
raw="0",
|
|
191
|
+
),
|
|
192
|
+
tokens=[],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return self._text_result(self._format_balance_result(result))
|
|
196
|
+
|
|
197
|
+
except Exception as e:
|
|
198
|
+
return self._error_result(str(e))
|
|
199
|
+
|
|
200
|
+
async def _handle_get_all_balances(self, args: dict[str, Any]) -> ToolResult:
|
|
201
|
+
"""Handle t402/getAllBalances tool."""
|
|
202
|
+
try:
|
|
203
|
+
_address = args.get("address", "") # noqa: F841 - reserved for future use
|
|
204
|
+
|
|
205
|
+
results = []
|
|
206
|
+
for network in ALL_NETWORKS:
|
|
207
|
+
results.append(
|
|
208
|
+
NetworkBalance(
|
|
209
|
+
network=network,
|
|
210
|
+
native=BalanceInfo(
|
|
211
|
+
token=NATIVE_SYMBOLS.get(network, "ETH"),
|
|
212
|
+
balance="0.0",
|
|
213
|
+
raw="0",
|
|
214
|
+
),
|
|
215
|
+
tokens=[],
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return self._text_result(self._format_all_balances_result(results))
|
|
220
|
+
|
|
221
|
+
except Exception as e:
|
|
222
|
+
return self._error_result(str(e))
|
|
223
|
+
|
|
224
|
+
async def _handle_pay(self, args: dict[str, Any]) -> ToolResult:
|
|
225
|
+
"""Handle t402/pay tool."""
|
|
226
|
+
try:
|
|
227
|
+
to = args.get("to", "")
|
|
228
|
+
amount = args.get("amount", "")
|
|
229
|
+
token = args.get("token", "")
|
|
230
|
+
network = args.get("network", "")
|
|
231
|
+
|
|
232
|
+
if not is_valid_network(network):
|
|
233
|
+
return self._error_result(f"Invalid network: {network}")
|
|
234
|
+
|
|
235
|
+
token_addr = get_token_address(network, token)
|
|
236
|
+
if not token_addr:
|
|
237
|
+
return self._error_result(f"Token {token} not supported on {network}")
|
|
238
|
+
|
|
239
|
+
if not self.config.private_key and not self.config.demo_mode:
|
|
240
|
+
return self._error_result(
|
|
241
|
+
"Private key not configured. Set T402_PRIVATE_KEY or enable T402_DEMO_MODE"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Demo mode
|
|
245
|
+
if self.config.demo_mode:
|
|
246
|
+
result = PaymentResult(
|
|
247
|
+
tx_hash="0x" + "0" * 64 + "_demo",
|
|
248
|
+
from_address="0x" + "0" * 40,
|
|
249
|
+
to=to,
|
|
250
|
+
amount=amount,
|
|
251
|
+
token=token,
|
|
252
|
+
network=network,
|
|
253
|
+
explorer_url=get_explorer_tx_url(network, "0x_demo"),
|
|
254
|
+
demo_mode=True,
|
|
255
|
+
)
|
|
256
|
+
return self._text_result(self._format_payment_result(result))
|
|
257
|
+
|
|
258
|
+
return self._error_result("Real transactions require private key configuration")
|
|
259
|
+
|
|
260
|
+
except Exception as e:
|
|
261
|
+
return self._error_result(str(e))
|
|
262
|
+
|
|
263
|
+
async def _handle_pay_gasless(self, args: dict[str, Any]) -> ToolResult:
|
|
264
|
+
"""Handle t402/payGasless tool."""
|
|
265
|
+
try:
|
|
266
|
+
network = args.get("network", "")
|
|
267
|
+
|
|
268
|
+
if not is_gasless_network(network):
|
|
269
|
+
return self._error_result(f"Network {network} does not support gasless payments")
|
|
270
|
+
|
|
271
|
+
if not self.config.bundler_url and not self.config.demo_mode:
|
|
272
|
+
return self._error_result(
|
|
273
|
+
"Bundler URL not configured. Set T402_BUNDLER_URL or enable T402_DEMO_MODE"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Demo mode
|
|
277
|
+
if self.config.demo_mode:
|
|
278
|
+
result = PaymentResult(
|
|
279
|
+
tx_hash="0x" + "0" * 64 + "_gasless_demo",
|
|
280
|
+
from_address="0x" + "0" * 40,
|
|
281
|
+
to=args.get("to", ""),
|
|
282
|
+
amount=args.get("amount", ""),
|
|
283
|
+
token=args.get("token", ""),
|
|
284
|
+
network=network,
|
|
285
|
+
explorer_url=get_explorer_tx_url(network, "0x_demo"),
|
|
286
|
+
demo_mode=True,
|
|
287
|
+
)
|
|
288
|
+
return self._text_result(self._format_payment_result(result))
|
|
289
|
+
|
|
290
|
+
return self._error_result("Gasless payments require bundler configuration")
|
|
291
|
+
|
|
292
|
+
except Exception as e:
|
|
293
|
+
return self._error_result(str(e))
|
|
294
|
+
|
|
295
|
+
async def _handle_get_bridge_fee(self, args: dict[str, Any]) -> ToolResult:
|
|
296
|
+
"""Handle t402/getBridgeFee tool."""
|
|
297
|
+
try:
|
|
298
|
+
from_chain = args.get("fromChain", "")
|
|
299
|
+
to_chain = args.get("toChain", "")
|
|
300
|
+
amount = args.get("amount", "")
|
|
301
|
+
|
|
302
|
+
if not is_bridgeable_chain(from_chain):
|
|
303
|
+
return self._error_result(f"Chain {from_chain} does not support USDT0 bridging")
|
|
304
|
+
if not is_bridgeable_chain(to_chain):
|
|
305
|
+
return self._error_result(f"Chain {to_chain} does not support USDT0 bridging")
|
|
306
|
+
if from_chain == to_chain:
|
|
307
|
+
return self._error_result("Source and destination chains must be different")
|
|
308
|
+
|
|
309
|
+
result = BridgeFeeResult(
|
|
310
|
+
native_fee="0.001",
|
|
311
|
+
native_symbol=NATIVE_SYMBOLS.get(from_chain, "ETH"),
|
|
312
|
+
from_chain=from_chain,
|
|
313
|
+
to_chain=to_chain,
|
|
314
|
+
amount=amount,
|
|
315
|
+
estimated_time=300,
|
|
316
|
+
)
|
|
317
|
+
return self._text_result(self._format_bridge_fee_result(result))
|
|
318
|
+
|
|
319
|
+
except Exception as e:
|
|
320
|
+
return self._error_result(str(e))
|
|
321
|
+
|
|
322
|
+
async def _handle_bridge(self, args: dict[str, Any]) -> ToolResult:
|
|
323
|
+
"""Handle t402/bridge tool."""
|
|
324
|
+
try:
|
|
325
|
+
from_chain = args.get("fromChain", "")
|
|
326
|
+
to_chain = args.get("toChain", "")
|
|
327
|
+
amount = args.get("amount", "")
|
|
328
|
+
|
|
329
|
+
if not is_bridgeable_chain(from_chain):
|
|
330
|
+
return self._error_result(f"Chain {from_chain} does not support USDT0 bridging")
|
|
331
|
+
if not is_bridgeable_chain(to_chain):
|
|
332
|
+
return self._error_result(f"Chain {to_chain} does not support USDT0 bridging")
|
|
333
|
+
if from_chain == to_chain:
|
|
334
|
+
return self._error_result("Source and destination chains must be different")
|
|
335
|
+
|
|
336
|
+
if not self.config.private_key and not self.config.demo_mode:
|
|
337
|
+
return self._error_result(
|
|
338
|
+
"Private key not configured. Set T402_PRIVATE_KEY or enable T402_DEMO_MODE"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Demo mode
|
|
342
|
+
if self.config.demo_mode:
|
|
343
|
+
demo_guid = "0x" + "a" * 64
|
|
344
|
+
result = BridgeResultData(
|
|
345
|
+
tx_hash="0x" + "0" * 64 + "_bridge_demo",
|
|
346
|
+
message_guid=demo_guid,
|
|
347
|
+
from_chain=from_chain,
|
|
348
|
+
to_chain=to_chain,
|
|
349
|
+
amount=amount,
|
|
350
|
+
explorer_url=get_explorer_tx_url(from_chain, "0x_demo"),
|
|
351
|
+
tracking_url=LAYERZERO_SCAN_URL + demo_guid,
|
|
352
|
+
estimated_time=300,
|
|
353
|
+
demo_mode=True,
|
|
354
|
+
)
|
|
355
|
+
return self._text_result(self._format_bridge_result(result))
|
|
356
|
+
|
|
357
|
+
return self._error_result("Bridge functionality requires private key configuration")
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
return self._error_result(str(e))
|
|
361
|
+
|
|
362
|
+
# Result helpers
|
|
363
|
+
|
|
364
|
+
def _text_result(self, text: str) -> ToolResult:
|
|
365
|
+
"""Create a text result."""
|
|
366
|
+
return ToolResult(content=[ContentBlock(type="text", text=text)])
|
|
367
|
+
|
|
368
|
+
def _error_result(self, message: str) -> ToolResult:
|
|
369
|
+
"""Create an error result."""
|
|
370
|
+
return ToolResult(
|
|
371
|
+
content=[ContentBlock(type="text", text=f"Error: {message}")],
|
|
372
|
+
isError=True,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Formatting helpers
|
|
376
|
+
|
|
377
|
+
def _format_balance_result(self, result: NetworkBalance) -> str:
|
|
378
|
+
"""Format balance result as markdown."""
|
|
379
|
+
lines = [f"## Balance on {result.network}", ""]
|
|
380
|
+
|
|
381
|
+
if result.error:
|
|
382
|
+
lines.append(f"Error: {result.error}")
|
|
383
|
+
return "\n".join(lines)
|
|
384
|
+
|
|
385
|
+
if result.native:
|
|
386
|
+
lines.append(f"**Native ({result.native.token}):** {result.native.balance}")
|
|
387
|
+
lines.append("")
|
|
388
|
+
|
|
389
|
+
if result.tokens:
|
|
390
|
+
lines.append("**Tokens:**")
|
|
391
|
+
for token in result.tokens:
|
|
392
|
+
lines.append(f"- {token.token}: {token.balance}")
|
|
393
|
+
else:
|
|
394
|
+
lines.append("No token balances found.")
|
|
395
|
+
|
|
396
|
+
return "\n".join(lines)
|
|
397
|
+
|
|
398
|
+
def _format_all_balances_result(self, results: list[NetworkBalance]) -> str:
|
|
399
|
+
"""Format all balances result as markdown."""
|
|
400
|
+
lines = ["## Balances Across All Networks", ""]
|
|
401
|
+
|
|
402
|
+
for result in results:
|
|
403
|
+
if result.error:
|
|
404
|
+
lines.append(f"### {result.network}")
|
|
405
|
+
lines.append(f"❌ {result.error}")
|
|
406
|
+
lines.append("")
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
lines.append(f"### {result.network}")
|
|
410
|
+
if result.native:
|
|
411
|
+
lines.append(f"- Native ({result.native.token}): {result.native.balance}")
|
|
412
|
+
for token in result.tokens:
|
|
413
|
+
lines.append(f"- {token.token}: {token.balance}")
|
|
414
|
+
lines.append("")
|
|
415
|
+
|
|
416
|
+
return "\n".join(lines)
|
|
417
|
+
|
|
418
|
+
def _format_payment_result(self, result: PaymentResult) -> str:
|
|
419
|
+
"""Format payment result as markdown."""
|
|
420
|
+
lines = []
|
|
421
|
+
|
|
422
|
+
if result.demo_mode:
|
|
423
|
+
lines.extend([
|
|
424
|
+
"## Payment (Demo Mode)",
|
|
425
|
+
"",
|
|
426
|
+
"⚠️ This is a simulated transaction. No actual tokens were transferred.",
|
|
427
|
+
"",
|
|
428
|
+
])
|
|
429
|
+
else:
|
|
430
|
+
lines.extend(["## Payment Successful", ""])
|
|
431
|
+
|
|
432
|
+
lines.extend([
|
|
433
|
+
f"- **Amount:** {result.amount} {result.token}",
|
|
434
|
+
f"- **To:** {result.to}",
|
|
435
|
+
f"- **Network:** {result.network}",
|
|
436
|
+
f"- **Transaction:** [{self._truncate_hash(result.tx_hash)}]({result.explorer_url})",
|
|
437
|
+
])
|
|
438
|
+
|
|
439
|
+
return "\n".join(lines)
|
|
440
|
+
|
|
441
|
+
def _format_bridge_fee_result(self, result: BridgeFeeResult) -> str:
|
|
442
|
+
"""Format bridge fee result as markdown."""
|
|
443
|
+
return "\n".join([
|
|
444
|
+
"## Bridge Fee Quote",
|
|
445
|
+
"",
|
|
446
|
+
f"- **From:** {result.from_chain}",
|
|
447
|
+
f"- **To:** {result.to_chain}",
|
|
448
|
+
f"- **Amount:** {result.amount} USDT0",
|
|
449
|
+
f"- **Fee:** {result.native_fee} {result.native_symbol}",
|
|
450
|
+
f"- **Estimated Time:** ~{result.estimated_time} seconds",
|
|
451
|
+
])
|
|
452
|
+
|
|
453
|
+
def _format_bridge_result(self, result: BridgeResultData) -> str:
|
|
454
|
+
"""Format bridge result as markdown."""
|
|
455
|
+
lines = []
|
|
456
|
+
|
|
457
|
+
if result.demo_mode:
|
|
458
|
+
lines.extend([
|
|
459
|
+
"## Bridge (Demo Mode)",
|
|
460
|
+
"",
|
|
461
|
+
"⚠️ This is a simulated bridge. No actual tokens were transferred.",
|
|
462
|
+
"",
|
|
463
|
+
])
|
|
464
|
+
else:
|
|
465
|
+
lines.extend(["## Bridge Initiated", ""])
|
|
466
|
+
|
|
467
|
+
lines.extend([
|
|
468
|
+
f"- **Amount:** {result.amount} USDT0",
|
|
469
|
+
f"- **From:** {result.from_chain}",
|
|
470
|
+
f"- **To:** {result.to_chain}",
|
|
471
|
+
f"- **Transaction:** [{self._truncate_hash(result.tx_hash)}]({result.explorer_url})",
|
|
472
|
+
f"- **Track:** [LayerZero Scan]({result.tracking_url})",
|
|
473
|
+
f"- **Estimated Delivery:** ~{result.estimated_time} seconds",
|
|
474
|
+
])
|
|
475
|
+
|
|
476
|
+
return "\n".join(lines)
|
|
477
|
+
|
|
478
|
+
def _truncate_hash(self, hash_str: str) -> str:
|
|
479
|
+
"""Truncate a hash for display."""
|
|
480
|
+
if len(hash_str) <= 16:
|
|
481
|
+
return hash_str
|
|
482
|
+
return f"{hash_str[:8]}...{hash_str[-6:]}"
|
|
483
|
+
|
|
484
|
+
def _serialize_response(self, response: JSONRPCResponse) -> str:
|
|
485
|
+
"""Serialize response to JSON."""
|
|
486
|
+
data = {"jsonrpc": response.jsonrpc, "id": response.id}
|
|
487
|
+
|
|
488
|
+
if response.error:
|
|
489
|
+
data["error"] = {
|
|
490
|
+
"code": response.error.code,
|
|
491
|
+
"message": response.error.message,
|
|
492
|
+
}
|
|
493
|
+
if response.error.data:
|
|
494
|
+
data["error"]["data"] = response.error.data
|
|
495
|
+
else:
|
|
496
|
+
data["result"] = response.result
|
|
497
|
+
|
|
498
|
+
return json.dumps(data)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def load_config_from_env() -> ServerConfig:
|
|
502
|
+
"""Load server configuration from environment variables."""
|
|
503
|
+
config = ServerConfig(
|
|
504
|
+
private_key=os.environ.get("T402_PRIVATE_KEY"),
|
|
505
|
+
demo_mode=os.environ.get("T402_DEMO_MODE", "").lower() == "true",
|
|
506
|
+
bundler_url=os.environ.get("T402_BUNDLER_URL"),
|
|
507
|
+
paymaster_url=os.environ.get("T402_PAYMASTER_URL"),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Load network-specific RPC URLs
|
|
511
|
+
for network in ALL_NETWORKS:
|
|
512
|
+
env_key = f"T402_RPC_{network.upper()}"
|
|
513
|
+
if url := os.environ.get(env_key):
|
|
514
|
+
config.rpc_urls[network] = url
|
|
515
|
+
|
|
516
|
+
return config
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def run_server() -> None:
|
|
520
|
+
"""Run the MCP server (entry point for CLI)."""
|
|
521
|
+
config = load_config_from_env()
|
|
522
|
+
server = T402McpServer(config)
|
|
523
|
+
asyncio.run(server.run())
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
if __name__ == "__main__":
|
|
527
|
+
run_server()
|
t402/mcp/tools.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Tool definitions for T402 MCP Server."""
|
|
2
|
+
|
|
3
|
+
from .constants import ALL_NETWORKS, BRIDGEABLE_CHAINS, GASLESS_NETWORKS
|
|
4
|
+
from .types import InputSchema, Property, Tool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_tool_definitions() -> list[Tool]:
|
|
8
|
+
"""Get all available tool definitions."""
|
|
9
|
+
networks = list(ALL_NETWORKS)
|
|
10
|
+
bridgeable_chains = list(BRIDGEABLE_CHAINS)
|
|
11
|
+
gasless_networks = list(GASLESS_NETWORKS)
|
|
12
|
+
|
|
13
|
+
return [
|
|
14
|
+
Tool(
|
|
15
|
+
name="t402/getBalance",
|
|
16
|
+
description="Get token balances (native + stablecoins) for a wallet address on a specific network",
|
|
17
|
+
inputSchema=InputSchema(
|
|
18
|
+
type="object",
|
|
19
|
+
properties={
|
|
20
|
+
"address": Property(
|
|
21
|
+
type="string",
|
|
22
|
+
description="Ethereum address (0x...)",
|
|
23
|
+
pattern="^0x[a-fA-F0-9]{40}$",
|
|
24
|
+
),
|
|
25
|
+
"network": Property(
|
|
26
|
+
type="string",
|
|
27
|
+
description="Network to query",
|
|
28
|
+
enum=networks,
|
|
29
|
+
),
|
|
30
|
+
},
|
|
31
|
+
required=["address", "network"],
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
Tool(
|
|
35
|
+
name="t402/getAllBalances",
|
|
36
|
+
description="Get token balances across all supported networks for a wallet address",
|
|
37
|
+
inputSchema=InputSchema(
|
|
38
|
+
type="object",
|
|
39
|
+
properties={
|
|
40
|
+
"address": Property(
|
|
41
|
+
type="string",
|
|
42
|
+
description="Ethereum address (0x...)",
|
|
43
|
+
pattern="^0x[a-fA-F0-9]{40}$",
|
|
44
|
+
),
|
|
45
|
+
},
|
|
46
|
+
required=["address"],
|
|
47
|
+
),
|
|
48
|
+
),
|
|
49
|
+
Tool(
|
|
50
|
+
name="t402/pay",
|
|
51
|
+
description="Execute a stablecoin payment (USDC, USDT, or USDT0)",
|
|
52
|
+
inputSchema=InputSchema(
|
|
53
|
+
type="object",
|
|
54
|
+
properties={
|
|
55
|
+
"to": Property(
|
|
56
|
+
type="string",
|
|
57
|
+
description="Recipient address (0x...)",
|
|
58
|
+
pattern="^0x[a-fA-F0-9]{40}$",
|
|
59
|
+
),
|
|
60
|
+
"amount": Property(
|
|
61
|
+
type="string",
|
|
62
|
+
description="Amount to send (e.g., '10.5')",
|
|
63
|
+
pattern=r"^\d+(\.\d+)?$",
|
|
64
|
+
),
|
|
65
|
+
"token": Property(
|
|
66
|
+
type="string",
|
|
67
|
+
description="Token to send",
|
|
68
|
+
enum=["USDC", "USDT", "USDT0"],
|
|
69
|
+
),
|
|
70
|
+
"network": Property(
|
|
71
|
+
type="string",
|
|
72
|
+
description="Network to use",
|
|
73
|
+
enum=networks,
|
|
74
|
+
),
|
|
75
|
+
},
|
|
76
|
+
required=["to", "amount", "token", "network"],
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
Tool(
|
|
80
|
+
name="t402/payGasless",
|
|
81
|
+
description="Execute a gasless payment using ERC-4337 account abstraction (user pays no gas)",
|
|
82
|
+
inputSchema=InputSchema(
|
|
83
|
+
type="object",
|
|
84
|
+
properties={
|
|
85
|
+
"to": Property(
|
|
86
|
+
type="string",
|
|
87
|
+
description="Recipient address (0x...)",
|
|
88
|
+
pattern="^0x[a-fA-F0-9]{40}$",
|
|
89
|
+
),
|
|
90
|
+
"amount": Property(
|
|
91
|
+
type="string",
|
|
92
|
+
description="Amount to send (e.g., '10.5')",
|
|
93
|
+
pattern=r"^\d+(\.\d+)?$",
|
|
94
|
+
),
|
|
95
|
+
"token": Property(
|
|
96
|
+
type="string",
|
|
97
|
+
description="Token to send",
|
|
98
|
+
enum=["USDC", "USDT", "USDT0"],
|
|
99
|
+
),
|
|
100
|
+
"network": Property(
|
|
101
|
+
type="string",
|
|
102
|
+
description="Network to use (must support ERC-4337)",
|
|
103
|
+
enum=gasless_networks,
|
|
104
|
+
),
|
|
105
|
+
},
|
|
106
|
+
required=["to", "amount", "token", "network"],
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
Tool(
|
|
110
|
+
name="t402/getBridgeFee",
|
|
111
|
+
description="Get the fee quote for bridging USDT0 between chains via LayerZero",
|
|
112
|
+
inputSchema=InputSchema(
|
|
113
|
+
type="object",
|
|
114
|
+
properties={
|
|
115
|
+
"fromChain": Property(
|
|
116
|
+
type="string",
|
|
117
|
+
description="Source chain",
|
|
118
|
+
enum=bridgeable_chains,
|
|
119
|
+
),
|
|
120
|
+
"toChain": Property(
|
|
121
|
+
type="string",
|
|
122
|
+
description="Destination chain",
|
|
123
|
+
enum=bridgeable_chains,
|
|
124
|
+
),
|
|
125
|
+
"amount": Property(
|
|
126
|
+
type="string",
|
|
127
|
+
description="Amount to bridge (e.g., '100')",
|
|
128
|
+
pattern=r"^\d+(\.\d+)?$",
|
|
129
|
+
),
|
|
130
|
+
"recipient": Property(
|
|
131
|
+
type="string",
|
|
132
|
+
description="Recipient address on destination chain (0x...)",
|
|
133
|
+
pattern="^0x[a-fA-F0-9]{40}$",
|
|
134
|
+
),
|
|
135
|
+
},
|
|
136
|
+
required=["fromChain", "toChain", "amount", "recipient"],
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
Tool(
|
|
140
|
+
name="t402/bridge",
|
|
141
|
+
description="Bridge USDT0 between chains using LayerZero OFT",
|
|
142
|
+
inputSchema=InputSchema(
|
|
143
|
+
type="object",
|
|
144
|
+
properties={
|
|
145
|
+
"fromChain": Property(
|
|
146
|
+
type="string",
|
|
147
|
+
description="Source chain",
|
|
148
|
+
enum=bridgeable_chains,
|
|
149
|
+
),
|
|
150
|
+
"toChain": Property(
|
|
151
|
+
type="string",
|
|
152
|
+
description="Destination chain",
|
|
153
|
+
enum=bridgeable_chains,
|
|
154
|
+
),
|
|
155
|
+
"amount": Property(
|
|
156
|
+
type="string",
|
|
157
|
+
description="Amount to bridge (e.g., '100')",
|
|
158
|
+
pattern=r"^\d+(\.\d+)?$",
|
|
159
|
+
),
|
|
160
|
+
"recipient": Property(
|
|
161
|
+
type="string",
|
|
162
|
+
description="Recipient address on destination chain (0x...)",
|
|
163
|
+
pattern="^0x[a-fA-F0-9]{40}$",
|
|
164
|
+
),
|
|
165
|
+
},
|
|
166
|
+
required=["fromChain", "toChain", "amount", "recipient"],
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
]
|