oceanalt-aml-mcp 0.4.0 → 0.5.0

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.
Files changed (2) hide show
  1. package/package.json +60 -19
  2. package/server.mjs +358 -309
package/package.json CHANGED
@@ -1,19 +1,60 @@
1
- {
2
- "name": "oceanalt-aml-mcp",
3
- "version": "0.4.0",
4
- "mcpName": "io.github.chrisssun18-bot/oceanalt-aml",
5
- "description": "OceanAlt AML 的 MCP server —— 让 Claude / 任意 MCP agent 原生调用地址合规筛查(免费)+ x402 付费判决/追踪/批量。",
6
- "type": "module",
7
- "bin": { "oceanalt-aml-mcp": "server.mjs" },
8
- "main": "server.mjs",
9
- "files": ["server.mjs", "README.md"],
10
- "keywords": ["mcp", "aml", "x402", "agent", "compliance", "oceanalt", "claude", "sanctions", "usdc", "web3"],
11
- "license": "MIT",
12
- "author": "OceanAlt (https://oceanalt.com)",
13
- "homepage": "https://oceanalt.com/en/api-docs",
14
- "repository": { "type": "git", "url": "git+https://github.com/OceanAlt666/oceanalt-aml-mcp.git" },
15
- "bugs": { "url": "https://oceanalt.com/en/partner" },
16
- "publishConfig": { "registry": "https://registry.npmjs.org/", "access": "public" },
17
- "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", "zod": "^3.23.0" },
18
- "optionalDependencies": { "@x402/core": ">=2.21.0", "@x402/evm": ">=2.21.0", "viem": ">=2.0.0" }
19
- }
1
+ {
2
+ "name": "oceanalt-aml-mcp",
3
+ "version": "0.5.0",
4
+ "mcpName": "io.github.OceanAlt/oceanalt-aml",
5
+ "description": "Should this payment go to this address? MCP server for pre-settlement payment compliance: screen a payee address for OFAC sanctions, mixers, scam and hack lists and on-chain risk across 7 EVM chains, Tron and Solana — with click-through evidence, not a black-box score. Free tools need no API key. Paid x402 tools add gateway verdicts, taint tracing and batch screening.",
6
+ "type": "module",
7
+ "bin": {
8
+ "oceanalt-aml-mcp": "server.mjs"
9
+ },
10
+ "main": "server.mjs",
11
+ "files": [
12
+ "server.mjs",
13
+ "README.md"
14
+ ],
15
+ "keywords": [
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "aml",
19
+ "compliance",
20
+ "sanctions",
21
+ "ofac",
22
+ "payee-screening",
23
+ "address-risk",
24
+ "agent-payments",
25
+ "agentic-payments",
26
+ "x402",
27
+ "stablecoin",
28
+ "usdc",
29
+ "crypto",
30
+ "web3",
31
+ "claude",
32
+ "ai-agent",
33
+ "pre-settlement",
34
+ "wallet-screening",
35
+ "oceanalt"
36
+ ],
37
+ "license": "MIT",
38
+ "author": "OceanAlt (https://oceanalt.com)",
39
+ "homepage": "https://oceanalt.com/en/api-docs",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/OceanAlt666/oceanalt-aml-mcp.git"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/OceanAlt666/oceanalt-aml-mcp/issues"
46
+ },
47
+ "publishConfig": {
48
+ "registry": "https://registry.npmjs.org/",
49
+ "access": "public"
50
+ },
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.0.0",
53
+ "zod": "^3.23.0"
54
+ },
55
+ "optionalDependencies": {
56
+ "@x402/core": ">=2.21.0",
57
+ "@x402/evm": ">=2.21.0",
58
+ "viem": ">=2.0.0"
59
+ }
60
+ }
package/server.mjs CHANGED
@@ -1,309 +1,358 @@
1
- #!/usr/bin/env node
2
- // OceanAlt AML — MCP server. Lets Claude / Cursor / any MCP client natively call
3
- // OceanAlt's on-chain address screening + compliance gateway. Ask "is this payee safe?"
4
- // BEFORE money moves — verdicts ship with clickable, verifiable evidence, not a black-box score.
5
- //
6
- // Two free tools (single screen + recent flagged list, no key, no signup) and three optional
7
- // x402 pay-per-call tools (gateway decision / deep taint trace / batch). The paid tools lazy-load
8
- // @x402 + viem and only activate when a payer wallet key (OCEANALT_PAYER_KEY) is set; if unset or
9
- // deps are missing they return a clear message and never crash. The facilitator verifies + settles
10
- // and pays gas — it never custodies your funds.
11
- //
12
- // This package is a thin (~9KB) client for OceanAlt's PUBLIC API: no proprietary logic, no data,
13
- // no keys, no scoring or lists on board — the AML engine runs server-side at oceanalt.com.
14
- //
15
- // MCP client config (Claude Desktop / Claude Code / Cursor …):
16
- // { "mcpServers": { "oceanalt-aml": { "command": "npx", "args": ["-y", "oceanalt-aml-mcp"],
17
- // "env": { "OCEANALT_PAYER_KEY": "0x… (optional, enables paid tools)" } } } }
18
-
19
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
21
- import { z } from "zod";
22
-
23
- const BASE = (process.env.OCEANALT_BASE || "https://oceanalt.com").replace(/\/$/, "");
24
- const TIMEOUT = 15000;
25
- // Supported EVM chains — kept in lockstep with the site's lib/aml.ts EVM_NETS (only chains proven live).
26
- const NETWORKS = ["ethereum", "base", "bsc", "polygon", "arbitrum", "optimism", "avalanche"];
27
-
28
- async function apiGet(path) {
29
- const ctrl = new AbortController();
30
- const t = setTimeout(() => ctrl.abort(), TIMEOUT);
31
- try {
32
- const res = await fetch(BASE + path, { signal: ctrl.signal, headers: { accept: "application/json" } });
33
- if (!res.ok) throw new Error(`OceanAlt ${path} → HTTP ${res.status}`);
34
- return await res.json();
35
- } finally { clearTimeout(t); }
36
- }
37
-
38
- const server = new McpServer({ name: "oceanalt-aml", version: "0.4.0" });
39
-
40
- server.registerTool(
41
- "screen_address",
42
- {
43
- title: "Screen a blockchain address for AML risk (free)",
44
- description:
45
- "Run an AML compliance screen on a single blockchain address BEFORE paying or receiving from it — the answer to \"is this counterparty safe?\". Checks OFAC sanctions, known mixers, community scam/phishing lists, stablecoin issuer freezes (USDT/USDC), and on-chain heuristics (address age, activity, one-hop taint from flagged addresses). Returns a verdict (clear | caution | risky), a 0–100 risk score, a blocked flag, and clickable verifiable evidence showing which list/label/on-chain path matched — receipts, not a black-box score. Supports EVM (0x…), Tron (T…), and Solana (base58). Free, no API key, no signup.",
46
- inputSchema: {
47
- address: z
48
- .string()
49
- .describe("Address to screen. EVM: 0x + 40 hex (e.g. 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045). Tron: T + 33 base58 (e.g. TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t). Solana: base58 public key."),
50
- network: z
51
- .enum(NETWORKS)
52
- .optional()
53
- .describe("EVM chain to screen on. Omit to auto-default to ethereum; ignored for Tron/Solana (auto-detected). One of: ethereum, base, bsc, polygon, arbitrum, optimism, avalanche."),
54
- lang: z.enum(["en", "zh"]).optional().describe("Language for human-readable fields (signals, note). Defaults to English."),
55
- },
56
- annotations: { title: "Screen address (AML, free)", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
57
- },
58
- async ({ address, network, lang }) => {
59
- const q = new URLSearchParams({ addr: address });
60
- if (network) q.set("network", network);
61
- if (lang) q.set("lang", lang);
62
- const r = await apiGet(`/api/risk?${q.toString()}`);
63
- const lines = [
64
- `Address: ${r.address}`,
65
- `Verdict: ${r.verdict} | Risk: ${r.risk}/100 | Blocked: ${r.blocked ? "yes" : "no"}`,
66
- r.advice ? `Advice: ${r.advice}` : "",
67
- (r.signals && r.signals.length) ? `Signals:\n- ${r.signals.join("\n- ")}` : "Signals: none",
68
- (r.evidence && r.evidence.length)
69
- ? `Evidence:\n${r.evidence.map((e) => `- ${e.label}: ${e.detail}${e.url ? ` (${e.url})` : ""}`).join("\n")}`
70
- : "",
71
- `Standard: ${r.standard || "https://oceanalt.com/en/rap"}`,
72
- ].filter(Boolean);
73
- return {
74
- content: [{ type: "text", text: lines.join("\n") }],
75
- structuredContent: r,
76
- };
77
- }
78
- );
79
-
80
- server.registerTool(
81
- "counterparty_control_baseline",
82
- {
83
- title: "Check a counterparty's control baseline before paying (free)",
84
- description:
85
- "Answers a different question from address screening. Screening asks whether an address is risky; this asks how much damage the agent on the other side could do if it were talked into something — whether a spending ceiling exists, whether payees are restricted, whether its credential can be revoked. It returns that party's self-attestation against the OceanAlt Agent Control Baseline, split into two sets that must NOT be conflated: `verified_by_oceanalt` (controls the gateway enforces on every payment — provable) and `self_claimed` (what the party states about its own side — NOT verified by OceanAlt). IMPORTANT: most parties have not attested. A result of found:false means no information, not a bad signal — never decline a payment on that basis alone. Free, no API key.",
86
- inputSchema: {
87
- entity: z.string().optional().describe("The counterparty's entity name, e.g. \"Acme Robotics Ltd\"."),
88
- agentId: z.string().optional().describe("Their agent id, if you have it instead of an entity name."),
89
- },
90
- annotations: { title: "Counterparty control baseline (free)", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
91
- },
92
- async ({ entity, agentId }) => {
93
- const q = new URLSearchParams();
94
- if (entity) q.set("entity", entity);
95
- if (agentId) q.set("agentId", agentId);
96
- if (!q.toString()) return { content: [{ type: "text", text: "Provide either entity or agentId." }], isError: true };
97
- const r = await apiGet(`/api/baseline/lookup?${q.toString()}`);
98
- const lines = r.found
99
- ? [
100
- `Party: ${r.entity || r.agentId || r.handle}`,
101
- `Status: ${r.status} (mandatory ${r.mandatory_met}/${r.mandatory_total}, advisory ${r.advisory_met}/${r.advisory_total})`,
102
- r.missing_mandatory?.length ? `Declared gaps: ${r.missing_mandatory.join(", ")}` : "",
103
- r.gap_plan ? `Their plan to close them: ${r.gap_plan}` : "",
104
- `Verified by OceanAlt (enforced on every payment): ${(r.verified_by_oceanalt || []).join(", ") || "none"}`,
105
- `Self-claimed (NOT verified by OceanAlt): ${(r.self_claimed || []).join(", ") || "none"}`,
106
- `Attested: ${r.attested_at?.slice(0, 10) || "?"} · Expires: ${r.expires_at?.slice(0, 10) || "?"}`,
107
- ]
108
- : [
109
- "No control-baseline attestation on record for this party.",
110
- "This is absence of information, not a negative signal — most parties have not attested.",
111
- "Do not decline on this basis alone; fall back to address screening (payment_decision).",
112
- ];
113
- return { content: [{ type: "text", text: [...lines.filter(Boolean), `Baseline: https://oceanalt.com/en/baseline`].join("\n") }], structuredContent: r };
114
- }
115
- );
116
-
117
- server.registerTool(
118
- "resolve_agent_identity",
119
- {
120
- title: "Resolve an agent's chain of accountability (free)",
121
- description:
122
- "Resolves who stands behind a paying agent: agent → principal → mandate → credential → wallet. Each link reports whether it holds, and `completeness` counts how many do — it is a factual count, not a score. Wherever the chain breaks is where accountability stops, which is what you actually need to know before accepting a payment from an autonomous agent. Publication is opt-in and only posture is exposed (that a ceiling exists, never its value), so found:false means the agent has not published — absence of information, not a bad signal. Free, no API key.",
123
- inputSchema: { agentId: z.string().describe("The agent id to resolve.") },
124
- annotations: { title: "Resolve agent identity (free)", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
125
- },
126
- async ({ agentId }) => {
127
- const r = await apiGet(`/api/registry/agent?agentId=${encodeURIComponent(agentId)}`);
128
- const lines = r.found
129
- ? [
130
- `Agent: ${r.name || r.agentId}`,
131
- `Chain of accountability: ${r.completeness}`,
132
- ...["agent", "principal", "mandate", "credential", "wallet"].map(
133
- (k) => ` ${r.chain?.[k]?.present ? "[ok]" : "[--]"} ${k}: ${r.chain?.[k]?.detail || ""}`
134
- ),
135
- r.baseline ? `Control baseline: ${r.baseline.status}` : "",
136
- r.note,
137
- ]
138
- : [
139
- "No published record for that agent id.",
140
- "Publication is opt-in, so this is absence of information rather than a negative signal.",
141
- "Fall back to address screening (payment_decision).",
142
- ];
143
- return { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }], structuredContent: r };
144
- }
145
- );
146
-
147
- server.registerTool(
148
- "recent_flagged",
149
- {
150
- title: "List recently flagged high-risk addresses (free)",
151
- description:
152
- "Return a representative sample of addresses on OceanAlt's reviewed risk list — OFAC-sanctioned, known mixers, and community-reported scam/phishing — each with its source category and the reason it was flagged. Useful for grounding, showing an agent/user what gets blocked and why, or a quick read on the current threat surface. Takes no arguments. Free, no API key.",
153
- inputSchema: {},
154
- annotations: { title: "Recent flagged addresses (free)", readOnlyHint: true, openWorldHint: true },
155
- },
156
- async () => {
157
- const r = await apiGet(`/api/risk/recent`);
158
- const items = Array.isArray(r) ? r : (r.items || []);
159
- // Live shape: { total, items:[{ address, source, sourceLabel, sourceLabelEn, reason }] }.
160
- // Fallbacks keep it robust if the response shape ever changes.
161
- const fmt = (it) => {
162
- const addr = it.address || it.addr || "";
163
- const label = it.sourceLabelEn || it.sourceLabel || it.source || it.verdict || "";
164
- const reason = it.reason || (it.signals && it.signals[0]) || it.top || "";
165
- return `- ${addr}${label ? ` | ${label}` : ""}${reason ? ` | ${reason}` : ""}`;
166
- };
167
- const header = (!Array.isArray(r) && typeof r.total === "number") ? `Total on risk list: ${r.total}\nSample:\n` : "";
168
- const text = items.length ? header + items.slice(0, 20).map(fmt).join("\n") : "No flagged addresses available.";
169
- return { content: [{ type: "text", text }], structuredContent: r };
170
- }
171
- );
172
-
173
- server.registerTool(
174
- "payment_decision",
175
- {
176
- title: "Decide whether to pay an address before an agent pays (free)",
177
- description:
178
- "\"Before your agent pays, call OceanAlt once.\" Pass the payee address the agent is about to pay; get a machine-executable decision: allow | review | decline, plus verifiable evidence and retry semantics. Use this as a gate in an autonomous payment flow — if the decision is not \"allow\", do not pay. On allow/review it also returns a verifiable compliance attestation you can attach to the settlement so the payment carries proof it passed OceanAlt's decision. Free, no API key, no signup. (For the paid variant that also settles the payment on x402 rails, use compliance_decision.)",
179
- inputSchema: {
180
- to: z.string().describe("Payee address the agent is about to pay. EVM: 0x + 40 hex. Tron: T + 33 base58."),
181
- amountUsdc: z.number().optional().describe("Amount in USDC (optional; recorded/echoed, not required for the decision)."),
182
- purpose: z.string().optional().describe("Short purpose/memo for the payment (optional)."),
183
- network: z.enum(NETWORKS).optional().describe("EVM chain for the payee (same set as screen_address). Optional; ignored for Tron."),
184
- lang: z.enum(["en", "zh"]).optional().describe("Language for human-readable fields (signals, note, advice). Defaults to English."),
185
- },
186
- annotations: { title: "Payment decision (free)", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
187
- },
188
- async ({ to, amountUsdc, purpose, network, lang }) => {
189
- const q = new URLSearchParams({ to });
190
- if (amountUsdc != null) q.set("amountUsdc", String(amountUsdc));
191
- if (purpose) q.set("purpose", purpose);
192
- if (network) q.set("network", network);
193
- if (lang) q.set("lang", lang);
194
- const r = await apiGet(`/api/decide?${q.toString()}`);
195
- const lines = [
196
- `Decision: ${r.decision} (allow=${r.allow})`,
197
- `Address: ${r.address} | Verdict: ${r.verdict} | Risk: ${r.risk == null ? "n/a" : r.risk + "/100"}`,
198
- r.advice ? `Advice: ${r.advice}` : "",
199
- r.retry_hint ? `Retry: ${r.retry_hint}` : "",
200
- (r.signals && r.signals.length) ? `Signals:\n- ${r.signals.join("\n- ")}` : "",
201
- (r.evidence && r.evidence.length)
202
- ? `Evidence:\n${r.evidence.map((e) => `- ${e.label}: ${e.detail}${e.url ? ` (${e.url})` : ""}`).join("\n")}`
203
- : "",
204
- r.disclaimer ? `Note: ${r.disclaimer}` : "",
205
- r.settlement ? `Attestation (attach to your settlement): ${r.settlement.attestation_id} · verify: ${r.settlement.verify_url}` : "",
206
- `Standard: ${r.standard || "https://oceanalt.com/en/rap"}`,
207
- ].filter(Boolean);
208
- return { content: [{ type: "text", text: lines.join("\n") }], structuredContent: r };
209
- }
210
- );
211
-
212
- // ── Paid (x402) tools ────────────────────────────────────────────────────────
213
- // Require a payer wallet private key (env OCEANALT_PAYER_KEY, 0x-prefixed) plus the on-demand deps
214
- // @x402/core @x402/evm viem. Like the official SDK: the MCP signs one EIP-3009 authorization; the
215
- // facilitator verifies, settles, and pays gas — funds are never custodied.
216
- const PAYER_KEY = process.env.OCEANALT_PAYER_KEY || "";
217
-
218
- async function payFetch(method, path, body) {
219
- if (!PAYER_KEY) throw new Error("Paid endpoint (x402): set OCEANALT_PAYER_KEY (payer wallet private key) in your MCP env to enable it — it signs one EIP-3009 USDC authorization locally; the facilitator pays gas. Without it, only the free tools screen_address / recent_flagged are available.");
220
- let x402core, x402evm, viemAccounts;
221
- try {
222
- [x402core, x402evm, viemAccounts] = await Promise.all([
223
- import("@x402/core/client"), import("@x402/evm/exact/client"), import("viem/accounts"),
224
- ]);
225
- } catch {
226
- throw new Error("Paid tools need extra deps: run `npm i @x402/core @x402/evm viem` in the MCP's environment.");
227
- }
228
- const account = viemAccounts.privateKeyToAccount(PAYER_KEY);
229
- const client = new x402core.x402Client();
230
- x402evm.registerExactEvmScheme(client, { signer: account });
231
- const http = new x402core.x402HTTPClient(client);
232
- const url = BASE + path;
233
- const init = { method, headers: { "content-type": "application/json" } };
234
- if (body !== undefined) init.body = JSON.stringify(body);
235
- const ctrl = new AbortController();
236
- const t = setTimeout(() => ctrl.abort(), 30000);
237
- try {
238
- const r1 = await fetch(url, { ...init, signal: ctrl.signal });
239
- if (r1.status !== 402) return { data: await r1.json().catch(() => null), status: r1.status, paid: false }; // not gated, or already allowed
240
- const pr = http.getPaymentRequiredResponse((n) => r1.headers.get(n));
241
- const payload = await http.createPaymentPayload(pr);
242
- const payHeaders = http.encodePaymentSignatureHeader(payload);
243
- const r2 = await fetch(url, { ...init, headers: { ...init.headers, ...payHeaders }, signal: ctrl.signal });
244
- const data = await r2.json().catch(() => null);
245
- let settlement = null;
246
- try { settlement = http.getPaymentSettleResponse((n) => r2.headers.get(n)); } catch { /* no receipt */ }
247
- return { data, status: r2.status, paid: r2.status === 200, settlement };
248
- } finally { clearTimeout(t); }
249
- }
250
-
251
- function payText(title, r) {
252
- const head = r.paid ? `Paid and settled${r.settlement ? " (on-chain receipt attached)" : ""}` : r.status === 402 ? "Payment required but not settled" : `HTTP ${r.status}`;
253
- return `[${title}] ${head}\n${JSON.stringify(r.data, null, 2)}`;
254
- }
255
-
256
- server.registerTool(
257
- "compliance_decision",
258
- {
259
- title: "Gateway compliance decision for a payment (paid, x402 $0.30)",
260
- description:
261
- "Run OceanAlt's full compliance gateway on a proposed payment and get a DECISION — allow | review | decline — plus advice and verifiable evidence, not just raw data. Combines AML screening of the payee with RAP (Responsible Agentic Payments) gate checks. Use this when an agent needs a go/no-go call before releasing funds. PAID via x402: the first request returns HTTP 402, this server automatically signs one USDC authorization and retries — this moves REAL money and requires OCEANALT_PAYER_KEY. Costs $0.30 in real USDC settled on Base mainnet (eip155:8453). Always confirm the live network/price at https://oceanalt.com/api/x402 and use a dedicated low-balance payer wallet.",
262
- inputSchema: {
263
- to: z.string().describe("Payee address the agent intends to pay. EVM (0x + 40 hex) or Tron (T + 33 base58)."),
264
- amountUsdc: z.number().optional().describe("Payment amount in USDC. Optional; used only for record / mandate-limit checks, not required to get a decision."),
265
- purpose: z.string().optional().describe("Short free-text note on what the payment is for. Optional (max ~120 chars)."),
266
- network: z.enum(NETWORKS).optional().describe("EVM chain for the payee (same set as screen_address). Optional; ignored for Tron."),
267
- },
268
- annotations: { title: "Compliance decision (paid $0.30)", readOnlyHint: false, openWorldHint: true },
269
- },
270
- async ({ to, amountUsdc, purpose, network }) => {
271
- const r = await payFetch("POST", "/api/x402/decision", { to, amountUsdc, purpose, network });
272
- return { content: [{ type: "text", text: payText("Gateway compliance decision", r) }], structuredContent: r };
273
- }
274
- );
275
-
276
- server.registerTool(
277
- "deep_trace",
278
- {
279
- title: "Deep taint trace of a Tron address (paid, x402 $0.20)",
280
- description:
281
- "Trace a Tron (TRON) USDT address up to 3 hops back along its largest incoming transfers to see whether its funds touch a Tether-frozen, sanctioned, mixer, or scam address upstream — deeper than a single-address screen. Tron only (T…). PAID via x402: moves REAL money and requires OCEANALT_PAYER_KEY. Costs $0.20 in real USDC settled on Base mainnet (eip155:8453) — confirm the live network/price at https://oceanalt.com/api/x402 and use a dedicated low-balance payer wallet. Note: follows only the main funds path, depth ≤3, not exhaustive — no hit does not prove the address is clean.",
282
- inputSchema: { address: z.string().describe("Tron address to trace (T + 33 base58). USDT on TRON.") },
283
- annotations: { title: "Deep taint trace (paid $0.20)", readOnlyHint: false, openWorldHint: true },
284
- },
285
- async ({ address }) => {
286
- const r = await payFetch("GET", `/api/x402/trace?addr=${encodeURIComponent(address)}`);
287
- return { content: [{ type: "text", text: payText("Deep taint trace", r) }], structuredContent: r };
288
- }
289
- );
290
-
291
- server.registerTool(
292
- "batch_screen",
293
- {
294
- title: "Batch-screen up to 25 addresses (paid, x402 $0.10)",
295
- description:
296
- "Screen up to 25 blockchain addresses in a single call. Returns a per-address verdict (clear | caution | risky | invalid), risk score, blocked flag, and top signal, plus a summary count, sorted risky-first. Cheaper per address than screening one at a time. EVM (0x…) and Tron (T…). PAID via x402: moves REAL money and requires OCEANALT_PAYER_KEY. Costs $0.10 in real USDC settled on Base mainnet (eip155:8453) — confirm the live network/price at https://oceanalt.com/api/x402 and use a dedicated low-balance payer wallet.",
297
- inputSchema: { addresses: z.array(z.string()).max(25).describe("Addresses to screen (max 25), each 0x… (EVM) or T… (Tron). Duplicates and blank entries are ignored.") },
298
- annotations: { title: "Batch screen (paid $0.10)", readOnlyHint: false, openWorldHint: true },
299
- },
300
- async ({ addresses }) => {
301
- const r = await payFetch("POST", "/api/x402/batch", { addresses });
302
- return { content: [{ type: "text", text: payText("Batch address screen", r) }], structuredContent: r };
303
- }
304
- );
305
-
306
- const transport = new StdioServerTransport();
307
- await server.connect(transport);
308
- // Once connected, don't print to stdout (stdio transport owns it); diagnostics go to stderr.
309
- console.error(`[oceanalt-aml-mcp] v0.3 started, base = ${BASE}, paid tools ${PAYER_KEY ? "enabled" : "disabled (OCEANALT_PAYER_KEY not set — free tools only)"}`);
1
+ #!/usr/bin/env node
2
+ // OceanAlt AML — MCP server. Lets Claude / Cursor / any MCP client natively call
3
+ // OceanAlt's on-chain address screening + compliance gateway. Ask "is this payee safe?"
4
+ // BEFORE money moves — verdicts ship with clickable, verifiable evidence, not a black-box score.
5
+ //
6
+ // Two free tools (single screen + recent flagged list, no key, no signup) and three optional
7
+ // x402 pay-per-call tools (gateway decision / deep taint trace / batch). The paid tools lazy-load
8
+ // @x402 + viem and only activate when a payer wallet key (OCEANALT_PAYER_KEY) is set; if unset or
9
+ // deps are missing they return a clear message and never crash. The facilitator verifies + settles
10
+ // and pays gas — it never custodies your funds.
11
+ //
12
+ // This package is a thin (~9KB) client for OceanAlt's PUBLIC API: no proprietary logic, no data,
13
+ // no keys, no scoring or lists on board — the AML engine runs server-side at oceanalt.com.
14
+ //
15
+ // MCP client config (Claude Desktop / Claude Code / Cursor …):
16
+ // { "mcpServers": { "oceanalt-aml": { "command": "npx", "args": ["-y", "oceanalt-aml-mcp"],
17
+ // "env": { "OCEANALT_PAYER_KEY": "0x… (optional, enables paid tools)" } } } }
18
+
19
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
21
+ import { z } from "zod";
22
+ import { readFileSync } from "node:fs";
23
+
24
+ const BASE = (process.env.OCEANALT_BASE || "https://oceanalt.com").replace(/\/$/, "");
25
+ const TIMEOUT = 15000;
26
+ // Supported EVM chains — kept in lockstep with the site's lib/aml.ts EVM_NETS (only chains proven live).
27
+ const NETWORKS = ["ethereum", "base", "bsc", "polygon", "arbitrum", "optimism", "avalanche"];
28
+
29
+ async function apiPost(path, body) {
30
+ const ctrl = new AbortController();
31
+ const t = setTimeout(() => ctrl.abort(), TIMEOUT);
32
+ try {
33
+ const res = await fetch(BASE + path, { method: "POST", signal: ctrl.signal, headers: { accept: "application/json", "content-type": "application/json" }, body: JSON.stringify(body) });
34
+ if (!res.ok && res.status >= 500) throw new Error(`OceanAlt ${path} → HTTP ${res.status}`);
35
+ return await res.json();
36
+ } finally { clearTimeout(t); }
37
+ }
38
+
39
+ async function apiGet(path) {
40
+ const ctrl = new AbortController();
41
+ const t = setTimeout(() => ctrl.abort(), TIMEOUT);
42
+ try {
43
+ const res = await fetch(BASE + path, { signal: ctrl.signal, headers: { accept: "application/json" } });
44
+ if (!res.ok) throw new Error(`OceanAlt ${path} → HTTP ${res.status}`);
45
+ return await res.json();
46
+ } finally { clearTimeout(t); }
47
+ }
48
+
49
+ // 版本号从 package.json 读,不写死。
50
+ // 2026-09-08:这里曾经写死 "0.4.0",而启动日志写死 "v0.3",package.json 又是第三个数 ——
51
+ // 三处各说各话,发布时还漏过一次版本号(commit 52f351c)。单一事实源之后不会再漂。
52
+ // npm 打包永远会带上 package.json,所以正常情况读得到;万一读不到也不能让 server 起不来。
53
+ const PKG_VERSION = (() => {
54
+ try { return JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version; }
55
+ catch { return "0.0.0-unknown"; }
56
+ })();
57
+ const server = new McpServer({ name: "oceanalt-aml", version: PKG_VERSION });
58
+
59
+ server.registerTool(
60
+ "screen_address",
61
+ {
62
+ title: "Screen a blockchain address for AML risk (free)",
63
+ description:
64
+ "Run an AML compliance screen on a single blockchain address BEFORE paying or receiving from it — the answer to \"is this counterparty safe?\". Checks OFAC sanctions, known mixers, community scam/phishing lists, stablecoin issuer freezes (USDT/USDC), and on-chain heuristics (address age, activity, one-hop taint from flagged addresses). Returns a verdict (clear | caution | risky), a 0–100 risk score, a blocked flag, and clickable verifiable evidence showing which list/label/on-chain path matched — receipts, not a black-box score. Supports EVM (0x…), Tron (T…), and Solana (base58). Free, no API key, no signup.",
65
+ inputSchema: {
66
+ address: z
67
+ .string()
68
+ .describe("Address to screen. EVM: 0x + 40 hex (e.g. 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045). Tron: T + 33 base58 (e.g. TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t). Solana: base58 public key."),
69
+ network: z
70
+ .enum(NETWORKS)
71
+ .optional()
72
+ .describe("EVM chain to screen on. Omit to auto-default to ethereum; ignored for Tron/Solana (auto-detected). One of: ethereum, base, bsc, polygon, arbitrum, optimism, avalanche."),
73
+ lang: z.enum(["en", "zh"]).optional().describe("Language for human-readable fields (signals, note). Defaults to English."),
74
+ },
75
+ annotations: { title: "Screen address (AML, free)", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
76
+ },
77
+ async ({ address, network, lang }) => {
78
+ const q = new URLSearchParams({ addr: address });
79
+ if (network) q.set("network", network);
80
+ if (lang) q.set("lang", lang);
81
+ const r = await apiGet(`/api/risk?${q.toString()}`);
82
+ const lines = [
83
+ `Address: ${r.address}`,
84
+ `Verdict: ${r.verdict} | Risk: ${r.risk}/100 | Blocked: ${r.blocked ? "yes" : "no"}`,
85
+ r.advice ? `Advice: ${r.advice}` : "",
86
+ (r.signals && r.signals.length) ? `Signals:\n- ${r.signals.join("\n- ")}` : "Signals: none",
87
+ (r.evidence && r.evidence.length)
88
+ ? `Evidence:\n${r.evidence.map((e) => `- ${e.label}: ${e.detail}${e.url ? ` (${e.url})` : ""}`).join("\n")}`
89
+ : "",
90
+ `Standard: ${r.standard || "https://oceanalt.com/en/rap"}`,
91
+ ].filter(Boolean);
92
+ return {
93
+ content: [{ type: "text", text: lines.join("\n") }],
94
+ structuredContent: r,
95
+ };
96
+ }
97
+ );
98
+
99
+ server.registerTool(
100
+ "payee_decide",
101
+ {
102
+ title: "Ask the payee whether it accepts this payment (free)",
103
+ description:
104
+ "The other half of a safe agent payment. Before settling, ask the registered payee (by its OceanAlt registry sid — see https://oceanalt.com/registry.json) whether it accepts this incoming payment. Returns status accept | decline | hold_request_info | not_ready, machine-readable reason codes, requested_fields (only on hold), verifiable evidence and a signature. Rule-based, not model judgement: payee readiness comes from the public registry enforcement ladder (https://oceanalt.com/en/registry/rules), the payer address is screened in reverse, and amount / purpose / KYA requirements come from the payee's own policy. decline is final — re-paying does not change it. Free, no API key.",
105
+ inputSchema: {
106
+ payee: z.string().describe("Payee sid from the OceanAlt registry, e.g. oceanalt-200lab-premium"),
107
+ payerAddress: z.string().optional().describe("The paying wallet address (screened in reverse)"),
108
+ payerAgentId: z.string().optional().describe("The paying agent's KYA id, if the payee requires KYA"),
109
+ amountUsdc: z.number().optional().describe("Amount in USDC"),
110
+ purpose: z.string().optional().describe("Purpose of the payment, e.g. api-call"),
111
+ },
112
+ annotations: { title: "Payee decision (free)", readOnlyHint: false, idempotentHint: false, openWorldHint: true },
113
+ },
114
+ async ({ payee, payerAddress, payerAgentId, amountUsdc, purpose }) => {
115
+ const r = await apiPost("/api/payee/decide", { payee, payer: { address: payerAddress, agentId: payerAgentId }, amountUsdc, purpose });
116
+ const lines = [
117
+ `Status: ${r.status}${r.final ? " (final)" : ""}`,
118
+ `Reasons: ${(r.reasons || []).join(", ")}`,
119
+ (r.requested_fields && r.requested_fields.length) ? `Requested fields: ${r.requested_fields.join(", ")}` : "",
120
+ r.expires_at ? `Expires: ${r.expires_at}` : "",
121
+ r.payee_ref ? `Payee: ${r.payee_ref.name} · ${r.payee_ref.rating_status} · open disputes ${r.payee_ref.open_disputes}` : "",
122
+ r.next ? `Next: ${r.next}` : "",
123
+ r.signature ? `Verify: ${r.signature.verify_url}` : "",
124
+ ].filter(Boolean);
125
+ return { content: [{ type: "text", text: lines.join(String.fromCharCode(10)) }], structuredContent: r };
126
+ }
127
+ );
128
+
129
+ server.registerTool(
130
+ "counterparty_control_baseline",
131
+ {
132
+ title: "Check a counterparty's control baseline before paying (free)",
133
+ description:
134
+ "Answers a different question from address screening. Screening asks whether an address is risky; this asks how much damage the agent on the other side could do if it were talked into something — whether a spending ceiling exists, whether payees are restricted, whether its credential can be revoked. It returns that party's self-attestation against the OceanAlt Agent Control Baseline, split into two sets that must NOT be conflated: `verified_by_oceanalt` (controls the gateway enforces on every payment — provable) and `self_claimed` (what the party states about its own side — NOT verified by OceanAlt). IMPORTANT: most parties have not attested. A result of found:false means no information, not a bad signal — never decline a payment on that basis alone. Free, no API key.",
135
+ inputSchema: {
136
+ entity: z.string().optional().describe("The counterparty's entity name, e.g. \"Acme Robotics Ltd\"."),
137
+ agentId: z.string().optional().describe("Their agent id, if you have it instead of an entity name."),
138
+ },
139
+ annotations: { title: "Counterparty control baseline (free)", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
140
+ },
141
+ async ({ entity, agentId }) => {
142
+ const q = new URLSearchParams();
143
+ if (entity) q.set("entity", entity);
144
+ if (agentId) q.set("agentId", agentId);
145
+ if (!q.toString()) return { content: [{ type: "text", text: "Provide either entity or agentId." }], isError: true };
146
+ const r = await apiGet(`/api/baseline/lookup?${q.toString()}`);
147
+ const lines = r.found
148
+ ? [
149
+ `Party: ${r.entity || r.agentId || r.handle}`,
150
+ `Status: ${r.status} (mandatory ${r.mandatory_met}/${r.mandatory_total}, advisory ${r.advisory_met}/${r.advisory_total})`,
151
+ r.missing_mandatory?.length ? `Declared gaps: ${r.missing_mandatory.join(", ")}` : "",
152
+ r.gap_plan ? `Their plan to close them: ${r.gap_plan}` : "",
153
+ `Verified by OceanAlt (enforced on every payment): ${(r.verified_by_oceanalt || []).join(", ") || "none"}`,
154
+ `Self-claimed (NOT verified by OceanAlt): ${(r.self_claimed || []).join(", ") || "none"}`,
155
+ `Attested: ${r.attested_at?.slice(0, 10) || "?"} · Expires: ${r.expires_at?.slice(0, 10) || "?"}`,
156
+ ]
157
+ : [
158
+ "No control-baseline attestation on record for this party.",
159
+ "This is absence of information, not a negative signal — most parties have not attested.",
160
+ "Do not decline on this basis alone; fall back to address screening (payment_decision).",
161
+ ];
162
+ return { content: [{ type: "text", text: [...lines.filter(Boolean), `Baseline: https://oceanalt.com/en/baseline`].join("\n") }], structuredContent: r };
163
+ }
164
+ );
165
+
166
+ server.registerTool(
167
+ "resolve_agent_identity",
168
+ {
169
+ title: "Resolve an agent's chain of accountability (free)",
170
+ description:
171
+ "Resolves who stands behind a paying agent: agent → principal → mandate → credential → wallet. Each link reports whether it holds, and `completeness` counts how many do — it is a factual count, not a score. Wherever the chain breaks is where accountability stops, which is what you actually need to know before accepting a payment from an autonomous agent. Publication is opt-in and only posture is exposed (that a ceiling exists, never its value), so found:false means the agent has not published — absence of information, not a bad signal. Free, no API key.",
172
+ inputSchema: { agentId: z.string().describe("The agent id to resolve.") },
173
+ annotations: { title: "Resolve agent identity (free)", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
174
+ },
175
+ async ({ agentId }) => {
176
+ const r = await apiGet(`/api/registry/agent?agentId=${encodeURIComponent(agentId)}`);
177
+ const lines = r.found
178
+ ? [
179
+ `Agent: ${r.name || r.agentId}`,
180
+ `Chain of accountability: ${r.completeness}`,
181
+ ...["agent", "principal", "mandate", "credential", "wallet"].map(
182
+ (k) => ` ${r.chain?.[k]?.present ? "[ok]" : "[--]"} ${k}: ${r.chain?.[k]?.detail || ""}`
183
+ ),
184
+ r.baseline ? `Control baseline: ${r.baseline.status}` : "",
185
+ r.note,
186
+ ]
187
+ : [
188
+ "No published record for that agent id.",
189
+ "Publication is opt-in, so this is absence of information rather than a negative signal.",
190
+ "Fall back to address screening (payment_decision).",
191
+ ];
192
+ return { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }], structuredContent: r };
193
+ }
194
+ );
195
+
196
+ server.registerTool(
197
+ "recent_flagged",
198
+ {
199
+ title: "List recently flagged high-risk addresses (free)",
200
+ description:
201
+ "Return a representative sample of addresses on OceanAlt's reviewed risk list — OFAC-sanctioned, known mixers, and community-reported scam/phishing — each with its source category and the reason it was flagged. Useful for grounding, showing an agent/user what gets blocked and why, or a quick read on the current threat surface. Takes no arguments. Free, no API key.",
202
+ inputSchema: {},
203
+ annotations: { title: "Recent flagged addresses (free)", readOnlyHint: true, openWorldHint: true },
204
+ },
205
+ async () => {
206
+ const r = await apiGet(`/api/risk/recent`);
207
+ const items = Array.isArray(r) ? r : (r.items || []);
208
+ // Live shape: { total, items:[{ address, source, sourceLabel, sourceLabelEn, reason }] }.
209
+ // Fallbacks keep it robust if the response shape ever changes.
210
+ const fmt = (it) => {
211
+ const addr = it.address || it.addr || "";
212
+ const label = it.sourceLabelEn || it.sourceLabel || it.source || it.verdict || "";
213
+ const reason = it.reason || (it.signals && it.signals[0]) || it.top || "";
214
+ return `- ${addr}${label ? ` | ${label}` : ""}${reason ? ` | ${reason}` : ""}`;
215
+ };
216
+ const header = (!Array.isArray(r) && typeof r.total === "number") ? `Total on risk list: ${r.total}\nSample:\n` : "";
217
+ const text = items.length ? header + items.slice(0, 20).map(fmt).join("\n") : "No flagged addresses available.";
218
+ return { content: [{ type: "text", text }], structuredContent: r };
219
+ }
220
+ );
221
+
222
+ server.registerTool(
223
+ "payment_decision",
224
+ {
225
+ title: "Decide whether to pay an address before an agent pays (free)",
226
+ description:
227
+ "\"Before your agent pays, call OceanAlt once.\" Pass the payee address the agent is about to pay; get a machine-executable decision: allow | review | decline, plus verifiable evidence and retry semantics. Use this as a gate in an autonomous payment flow — if the decision is not \"allow\", do not pay. On allow/review it also returns a verifiable compliance attestation you can attach to the settlement so the payment carries proof it passed OceanAlt's decision. Free, no API key, no signup. (For the paid variant that also settles the payment on x402 rails, use compliance_decision.)",
228
+ inputSchema: {
229
+ to: z.string().describe("Payee address the agent is about to pay. EVM: 0x + 40 hex. Tron: T + 33 base58."),
230
+ amountUsdc: z.number().optional().describe("Amount in USDC (optional; recorded/echoed, not required for the decision)."),
231
+ purpose: z.string().optional().describe("Short purpose/memo for the payment (optional)."),
232
+ network: z.enum(NETWORKS).optional().describe("EVM chain for the payee (same set as screen_address). Optional; ignored for Tron."),
233
+ lang: z.enum(["en", "zh"]).optional().describe("Language for human-readable fields (signals, note, advice). Defaults to English."),
234
+ },
235
+ annotations: { title: "Payment decision (free)", readOnlyHint: true, idempotentHint: true, openWorldHint: true },
236
+ },
237
+ async ({ to, amountUsdc, purpose, network, lang }) => {
238
+ const q = new URLSearchParams({ to });
239
+ if (amountUsdc != null) q.set("amountUsdc", String(amountUsdc));
240
+ if (purpose) q.set("purpose", purpose);
241
+ if (network) q.set("network", network);
242
+ if (lang) q.set("lang", lang);
243
+ const r = await apiGet(`/api/decide?${q.toString()}`);
244
+ const lines = [
245
+ `Decision: ${r.decision} (allow=${r.allow})`,
246
+ `Address: ${r.address} | Verdict: ${r.verdict} | Risk: ${r.risk == null ? "n/a" : r.risk + "/100"}`,
247
+ r.advice ? `Advice: ${r.advice}` : "",
248
+ r.retry_hint ? `Retry: ${r.retry_hint}` : "",
249
+ (r.signals && r.signals.length) ? `Signals:\n- ${r.signals.join("\n- ")}` : "",
250
+ (r.evidence && r.evidence.length)
251
+ ? `Evidence:\n${r.evidence.map((e) => `- ${e.label}: ${e.detail}${e.url ? ` (${e.url})` : ""}`).join("\n")}`
252
+ : "",
253
+ r.disclaimer ? `Note: ${r.disclaimer}` : "",
254
+ r.settlement ? `Attestation (attach to your settlement): ${r.settlement.attestation_id} · verify: ${r.settlement.verify_url}` : "",
255
+ `Standard: ${r.standard || "https://oceanalt.com/en/rap"}`,
256
+ ].filter(Boolean);
257
+ return { content: [{ type: "text", text: lines.join("\n") }], structuredContent: r };
258
+ }
259
+ );
260
+
261
+ // ── Paid (x402) tools ────────────────────────────────────────────────────────
262
+ // Require a payer wallet private key (env OCEANALT_PAYER_KEY, 0x-prefixed) plus the on-demand deps
263
+ // @x402/core @x402/evm viem. Like the official SDK: the MCP signs one EIP-3009 authorization; the
264
+ // facilitator verifies, settles, and pays gas — funds are never custodied.
265
+ const PAYER_KEY = process.env.OCEANALT_PAYER_KEY || "";
266
+
267
+ async function payFetch(method, path, body) {
268
+ if (!PAYER_KEY) throw new Error("Paid endpoint (x402): set OCEANALT_PAYER_KEY (payer wallet private key) in your MCP env to enable it — it signs one EIP-3009 USDC authorization locally; the facilitator pays gas. Without it, only the free tools screen_address / recent_flagged are available.");
269
+ let x402core, x402evm, viemAccounts;
270
+ try {
271
+ [x402core, x402evm, viemAccounts] = await Promise.all([
272
+ import("@x402/core/client"), import("@x402/evm/exact/client"), import("viem/accounts"),
273
+ ]);
274
+ } catch {
275
+ throw new Error("Paid tools need extra deps: run `npm i @x402/core @x402/evm viem` in the MCP's environment.");
276
+ }
277
+ const account = viemAccounts.privateKeyToAccount(PAYER_KEY);
278
+ const client = new x402core.x402Client();
279
+ x402evm.registerExactEvmScheme(client, { signer: account });
280
+ const http = new x402core.x402HTTPClient(client);
281
+ const url = BASE + path;
282
+ const init = { method, headers: { "content-type": "application/json" } };
283
+ if (body !== undefined) init.body = JSON.stringify(body);
284
+ const ctrl = new AbortController();
285
+ const t = setTimeout(() => ctrl.abort(), 30000);
286
+ try {
287
+ const r1 = await fetch(url, { ...init, signal: ctrl.signal });
288
+ if (r1.status !== 402) return { data: await r1.json().catch(() => null), status: r1.status, paid: false }; // not gated, or already allowed
289
+ const pr = http.getPaymentRequiredResponse((n) => r1.headers.get(n));
290
+ const payload = await http.createPaymentPayload(pr);
291
+ const payHeaders = http.encodePaymentSignatureHeader(payload);
292
+ const r2 = await fetch(url, { ...init, headers: { ...init.headers, ...payHeaders }, signal: ctrl.signal });
293
+ const data = await r2.json().catch(() => null);
294
+ let settlement = null;
295
+ try { settlement = http.getPaymentSettleResponse((n) => r2.headers.get(n)); } catch { /* no receipt */ }
296
+ return { data, status: r2.status, paid: r2.status === 200, settlement };
297
+ } finally { clearTimeout(t); }
298
+ }
299
+
300
+ function payText(title, r) {
301
+ const head = r.paid ? `Paid and settled${r.settlement ? " (on-chain receipt attached)" : ""}` : r.status === 402 ? "Payment required but not settled" : `HTTP ${r.status}`;
302
+ return `[${title}] ${head}\n${JSON.stringify(r.data, null, 2)}`;
303
+ }
304
+
305
+ server.registerTool(
306
+ "compliance_decision",
307
+ {
308
+ title: "Gateway compliance decision for a payment (paid, x402 $0.30)",
309
+ description:
310
+ "Run OceanAlt's full compliance gateway on a proposed payment and get a DECISION — allow | review | decline — plus advice and verifiable evidence, not just raw data. Combines AML screening of the payee with RAP (Responsible Agentic Payments) gate checks. Use this when an agent needs a go/no-go call before releasing funds. PAID via x402: the first request returns HTTP 402, this server automatically signs one USDC authorization and retries — this moves REAL money and requires OCEANALT_PAYER_KEY. Costs $0.30 in real USDC settled on Base mainnet (eip155:8453). Always confirm the live network/price at https://oceanalt.com/api/x402 and use a dedicated low-balance payer wallet.",
311
+ inputSchema: {
312
+ to: z.string().describe("Payee address the agent intends to pay. EVM (0x + 40 hex) or Tron (T + 33 base58)."),
313
+ amountUsdc: z.number().optional().describe("Payment amount in USDC. Optional; used only for record / mandate-limit checks, not required to get a decision."),
314
+ purpose: z.string().optional().describe("Short free-text note on what the payment is for. Optional (max ~120 chars)."),
315
+ network: z.enum(NETWORKS).optional().describe("EVM chain for the payee (same set as screen_address). Optional; ignored for Tron."),
316
+ },
317
+ annotations: { title: "Compliance decision (paid $0.30)", readOnlyHint: false, openWorldHint: true },
318
+ },
319
+ async ({ to, amountUsdc, purpose, network }) => {
320
+ const r = await payFetch("POST", "/api/x402/decision", { to, amountUsdc, purpose, network });
321
+ return { content: [{ type: "text", text: payText("Gateway compliance decision", r) }], structuredContent: r };
322
+ }
323
+ );
324
+
325
+ server.registerTool(
326
+ "deep_trace",
327
+ {
328
+ title: "Deep taint trace of a Tron address (paid, x402 $0.20)",
329
+ description:
330
+ "Trace a Tron (TRON) USDT address up to 3 hops back along its largest incoming transfers to see whether its funds touch a Tether-frozen, sanctioned, mixer, or scam address upstream — deeper than a single-address screen. Tron only (T…). PAID via x402: moves REAL money and requires OCEANALT_PAYER_KEY. Costs $0.20 in real USDC settled on Base mainnet (eip155:8453) — confirm the live network/price at https://oceanalt.com/api/x402 and use a dedicated low-balance payer wallet. Note: follows only the main funds path, depth ≤3, not exhaustive — no hit does not prove the address is clean.",
331
+ inputSchema: { address: z.string().describe("Tron address to trace (T + 33 base58). USDT on TRON.") },
332
+ annotations: { title: "Deep taint trace (paid $0.20)", readOnlyHint: false, openWorldHint: true },
333
+ },
334
+ async ({ address }) => {
335
+ const r = await payFetch("GET", `/api/x402/trace?addr=${encodeURIComponent(address)}`);
336
+ return { content: [{ type: "text", text: payText("Deep taint trace", r) }], structuredContent: r };
337
+ }
338
+ );
339
+
340
+ server.registerTool(
341
+ "batch_screen",
342
+ {
343
+ title: "Batch-screen up to 25 addresses (paid, x402 $0.10)",
344
+ description:
345
+ "Screen up to 25 blockchain addresses in a single call. Returns a per-address verdict (clear | caution | risky | invalid), risk score, blocked flag, and top signal, plus a summary count, sorted risky-first. Cheaper per address than screening one at a time. EVM (0x…) and Tron (T…). PAID via x402: moves REAL money and requires OCEANALT_PAYER_KEY. Costs $0.10 in real USDC settled on Base mainnet (eip155:8453) — confirm the live network/price at https://oceanalt.com/api/x402 and use a dedicated low-balance payer wallet.",
346
+ inputSchema: { addresses: z.array(z.string()).max(25).describe("Addresses to screen (max 25), each 0x… (EVM) or T… (Tron). Duplicates and blank entries are ignored.") },
347
+ annotations: { title: "Batch screen (paid $0.10)", readOnlyHint: false, openWorldHint: true },
348
+ },
349
+ async ({ addresses }) => {
350
+ const r = await payFetch("POST", "/api/x402/batch", { addresses });
351
+ return { content: [{ type: "text", text: payText("Batch address screen", r) }], structuredContent: r };
352
+ }
353
+ );
354
+
355
+ const transport = new StdioServerTransport();
356
+ await server.connect(transport);
357
+ // Once connected, don't print to stdout (stdio transport owns it); diagnostics go to stderr.
358
+ console.error(`[oceanalt-aml-mcp] v${PKG_VERSION} started, base = ${BASE}, paid tools ${PAYER_KEY ? "enabled" : "disabled (OCEANALT_PAYER_KEY not set — free tools only)"}`);