@three-ws/mcp-server 1.1.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.
@@ -0,0 +1,284 @@
1
+ // `agent_reputation` — paid MCP tool that reads ERC-8004 reputation for an
2
+ // agent (by agentId, EVM wallet, or "eip155:<chain>:<wallet>" CAIP-10 ID).
3
+ //
4
+ // Pricing: $0.01 USDC, settled `exact` in USDC on Solana mainnet.
5
+ //
6
+ // All reads are made directly against the canonical ERC-8004 reference
7
+ // deployments via ethers JsonRpcProvider — no third-party indexers, no
8
+ // cached snapshots, no fallback values. By default we query Base; the
9
+ // caller can override with `chain: "ethereum"|"base"|"arbitrum"|"optimism"|"polygon"|"bsc"`
10
+ // or pass a numeric chainId.
11
+ //
12
+ // The result includes:
13
+ // - aggregate reputation (totalScore + count + average) via getReputation
14
+ // - total ETH staked on the agent's vouches via getTotalStake
15
+ // - recent ReputationSubmitted + ReputationStaked events (latest 25)
16
+ // - the agent's URI + wallet (Identity Registry) when resolvable
17
+ //
18
+ // All numeric responses are returned both as decimal strings (for safe
19
+ // integer transport) and as parsed Number where the value fits in float64.
20
+
21
+ import { Contract, isAddress, ZeroAddress } from 'ethers';
22
+ import { z } from 'zod';
23
+
24
+ import { paid, toolError } from '../payments.js';
25
+ import { jsonSchemaFromZod } from './_shared.js';
26
+ import { makeEvmProvider, getEvmRpcUrls } from '../lib/evm-rpc.js';
27
+
28
+ const TOOL_NAME = 'agent_reputation';
29
+ const TOOL_DESCRIPTION =
30
+ 'ERC-8004 on-chain reputation for an agent: aggregate score + count + average from the canonical ReputationRegistry, total ETH staked on vouches, and the latest ReputationSubmitted/ReputationStaked events. Resolves agentId from a wallet via IdentityRegistry when needed. Reads default to Base; switch chains via "chain". Paid: $0.01 USDC.';
31
+
32
+ const IDENTITY_REGISTRY_ABI = [
33
+ 'function balanceOf(address owner) external view returns (uint256)',
34
+ 'function tokenOfOwnerByIndex(address owner, uint256 index) external view returns (uint256)',
35
+ 'function ownerOf(uint256 tokenId) external view returns (address)',
36
+ 'function tokenURI(uint256 tokenId) external view returns (string)',
37
+ 'function getAgentWallet(uint256 agentId) external view returns (address)',
38
+ 'function totalSupply() external view returns (uint256)',
39
+ ];
40
+
41
+ const REPUTATION_REGISTRY_ABI = [
42
+ 'function getReputation(uint256 agentId) external view returns (uint256 totalScore, uint256 count)',
43
+ 'function getTotalStake(uint256 agentId) external view returns (uint256)',
44
+ 'event ReputationSubmitted(uint256 indexed agentId, address indexed submitter, uint8 score, string comment)',
45
+ 'event ReputationStaked(uint256 indexed agentId, address indexed staker, uint8 score, uint256 value)',
46
+ ];
47
+
48
+ const IDENTITY_REGISTRY_MAINNET = '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432';
49
+ const REPUTATION_REGISTRY_MAINNET = '0x8004BAa17C55a88189AE136b182e5fdA19dE9b63';
50
+
51
+ // Canonical mainnet RPCs. Operators may pin custom endpoints via
52
+ // MCP_AGENT_REP_RPC_<chainId> to avoid rate-limiting the public defaults.
53
+ const CHAINS = {
54
+ base: { id: 8453, rpc: 'https://mainnet.base.org', name: 'Base' },
55
+ ethereum: { id: 1, rpc: 'https://eth.llamarpc.com', name: 'Ethereum' },
56
+ arbitrum: { id: 42161, rpc: 'https://arb1.arbitrum.io/rpc', name: 'Arbitrum One' },
57
+ optimism: { id: 10, rpc: 'https://mainnet.optimism.io', name: 'Optimism' },
58
+ polygon: { id: 137, rpc: 'https://polygon-rpc.com', name: 'Polygon' },
59
+ bsc: { id: 56, rpc: 'https://bsc-dataseed1.binance.org', name: 'BNB Chain' },
60
+ avalanche: { id: 43114, rpc: 'https://api.avax.network/ext/bc/C/rpc', name: 'Avalanche' },
61
+ celo: { id: 42220, rpc: 'https://forno.celo.org', name: 'Celo' },
62
+ linea: { id: 59144, rpc: 'https://rpc.linea.build', name: 'Linea' },
63
+ scroll: { id: 534352, rpc: 'https://rpc.scroll.io', name: 'Scroll' },
64
+ };
65
+ const CHAIN_BY_ID = Object.fromEntries(Object.values(CHAINS).map((c) => [c.id, c]));
66
+
67
+ function resolveChain(input) {
68
+ if (!input) return CHAINS.base;
69
+ if (typeof input === 'string') {
70
+ const lower = input.toLowerCase();
71
+ if (CHAINS[lower]) return CHAINS[lower];
72
+ const id = Number(input);
73
+ if (!Number.isNaN(id) && CHAIN_BY_ID[id]) return CHAIN_BY_ID[id];
74
+ }
75
+ if (typeof input === 'number' && CHAIN_BY_ID[input]) return CHAIN_BY_ID[input];
76
+ throw new Error(`unsupported chain "${input}" — known: ${Object.keys(CHAINS).join(', ')}`);
77
+ }
78
+
79
+ // Parse the agent identifier. Accepts: numeric ID, EVM wallet, or CAIP-10
80
+ // "eip155:<chainId>:<address>" (which can also override the chain selection).
81
+ function parseAgentInput(raw, defaultChain) {
82
+ const value = String(raw || '').trim();
83
+ if (!value) throw new Error('agent identifier is required');
84
+ if (/^\d+$/.test(value)) {
85
+ return { kind: 'agentId', agentId: BigInt(value), chain: defaultChain };
86
+ }
87
+ if (value.startsWith('eip155:')) {
88
+ const parts = value.split(':');
89
+ if (parts.length !== 3) throw new Error(`invalid CAIP-10 ID "${value}"`);
90
+ const chain = resolveChain(parts[1]);
91
+ const addr = parts[2];
92
+ if (!isAddress(addr)) throw new Error(`invalid wallet in CAIP-10 ID "${value}"`);
93
+ return { kind: 'wallet', wallet: addr, chain };
94
+ }
95
+ if (isAddress(value)) {
96
+ return { kind: 'wallet', wallet: value, chain: defaultChain };
97
+ }
98
+ throw new Error(
99
+ `could not parse agent identifier "${value}" — expected uint, EVM wallet, or eip155:<chain>:<addr>`,
100
+ );
101
+ }
102
+
103
+ async function resolveAgentId(provider, wallet) {
104
+ const id = new Contract(IDENTITY_REGISTRY_MAINNET, IDENTITY_REGISTRY_ABI, provider);
105
+ const bal = await id.balanceOf(wallet);
106
+ if (bal === 0n) return null;
107
+ const tokenId = await id.tokenOfOwnerByIndex(wallet, 0n);
108
+ return BigInt(tokenId);
109
+ }
110
+
111
+ async function readIdentity(provider, agentId) {
112
+ const id = new Contract(IDENTITY_REGISTRY_MAINNET, IDENTITY_REGISTRY_ABI, provider);
113
+ const [owner, agentWallet, uri] = await Promise.allSettled([
114
+ id.ownerOf(agentId),
115
+ id.getAgentWallet(agentId),
116
+ id.tokenURI(agentId),
117
+ ]);
118
+ return {
119
+ owner: owner.status === 'fulfilled' ? owner.value : null,
120
+ agentWallet: agentWallet.status === 'fulfilled' ? agentWallet.value : null,
121
+ uri: uri.status === 'fulfilled' ? uri.value : null,
122
+ errors: [owner, agentWallet, uri]
123
+ .filter((r) => r.status === 'rejected')
124
+ .map((r) => r.reason?.message || String(r.reason)),
125
+ };
126
+ }
127
+
128
+ async function readReputationAggregate(provider, agentId) {
129
+ const rep = new Contract(REPUTATION_REGISTRY_MAINNET, REPUTATION_REGISTRY_ABI, provider);
130
+ const [agg, totalStake] = await Promise.all([
131
+ rep.getReputation(agentId),
132
+ rep.getTotalStake(agentId),
133
+ ]);
134
+ const [totalScore, count] = agg;
135
+ const totalScoreNum = Number(totalScore);
136
+ const countNum = Number(count);
137
+ return {
138
+ totalScore: totalScore.toString(),
139
+ count: count.toString(),
140
+ average: countNum > 0 ? totalScoreNum / countNum : null,
141
+ totalStakeWei: totalStake.toString(),
142
+ };
143
+ }
144
+
145
+ // Walk the last LOG_WINDOW_BLOCKS blocks (configurable) for recent vouches.
146
+ // On chains where the registry has been quiet, this can return an empty
147
+ // array — that's the truth, not a failure.
148
+ const LOG_WINDOW_BLOCKS = Number(process.env.MCP_AGENT_REP_LOG_WINDOW || 200_000);
149
+
150
+ async function readRecentEvents(provider, agentId) {
151
+ const rep = new Contract(REPUTATION_REGISTRY_MAINNET, REPUTATION_REGISTRY_ABI, provider);
152
+ const latest = await provider.getBlockNumber();
153
+ const from = Math.max(0, latest - LOG_WINDOW_BLOCKS);
154
+ const [submitted, staked] = await Promise.all([
155
+ rep.queryFilter(rep.filters.ReputationSubmitted(agentId), from, latest),
156
+ rep.queryFilter(rep.filters.ReputationStaked(agentId), from, latest),
157
+ ]);
158
+ const submittedDecoded = submitted.map((e) => ({
159
+ kind: 'submitted',
160
+ blockNumber: e.blockNumber,
161
+ txHash: e.transactionHash,
162
+ submitter: e.args?.submitter,
163
+ score: Number(e.args?.score),
164
+ comment: e.args?.comment || '',
165
+ }));
166
+ const stakedDecoded = staked.map((e) => ({
167
+ kind: 'staked',
168
+ blockNumber: e.blockNumber,
169
+ txHash: e.transactionHash,
170
+ staker: e.args?.staker,
171
+ score: Number(e.args?.score),
172
+ valueWei: e.args?.value?.toString?.() || '0',
173
+ }));
174
+ return {
175
+ windowBlocks: LOG_WINDOW_BLOCKS,
176
+ fromBlock: from,
177
+ toBlock: latest,
178
+ events: [...submittedDecoded, ...stakedDecoded]
179
+ .sort((a, b) => b.blockNumber - a.blockNumber)
180
+ .slice(0, 25),
181
+ };
182
+ }
183
+
184
+ // Single source of truth: Zod shape with descriptions; JSON Schema derived.
185
+ const inputZodShape = {
186
+ address: z
187
+ .string()
188
+ .min(1)
189
+ .describe(
190
+ 'ERC-8004 agentId (uint), EVM wallet address (0x...), or CAIP-10 "eip155:<chainId>:<wallet>".',
191
+ ),
192
+ chain: z
193
+ .string()
194
+ .describe(
195
+ 'Chain to query (default: base). Accepts name or numeric chainId. Overridden by CAIP-10 input.',
196
+ )
197
+ .optional(),
198
+ };
199
+
200
+ const inputJsonSchema = jsonSchemaFromZod(inputZodShape);
201
+
202
+ export async function buildAgentReputationTool() {
203
+ const handler = await paid(
204
+ {
205
+ toolName: TOOL_NAME,
206
+ description: TOOL_DESCRIPTION,
207
+ scheme: 'exact',
208
+ priceUsd: '$0.01',
209
+ inputSchema: inputJsonSchema,
210
+ example: { address: '1', chain: 'base' },
211
+ outputExample: {
212
+ chain: 'base',
213
+ agentId: '1',
214
+ identity: { owner: '0x...', agentWallet: '0x...', uri: 'ipfs://...' },
215
+ reputation: { totalScore: '42', count: '6', average: 7, totalStakeWei: '0' },
216
+ events: [{ kind: 'submitted', score: 5, submitter: '0x...', comment: '' }],
217
+ },
218
+ },
219
+ async ({ address, chain }) => {
220
+ const defaultChain = resolveChain(chain);
221
+ const parsed = parseAgentInput(address, defaultChain);
222
+ // Endpoint failover: an operator override (MCP_AGENT_REP_RPC_<id>) is
223
+ // tried first, then the chain's built-in redundant public endpoints,
224
+ // each with a bounded request timeout. A single RPC outage no longer
225
+ // fails the lookup or hangs the paid call.
226
+ const overrides = [process.env[`MCP_AGENT_REP_RPC_${parsed.chain.id}`]].filter(Boolean);
227
+ const provider = makeEvmProvider(parsed.chain.id, { overrides, timeoutMs: 12_000 });
228
+
229
+ let agentId = parsed.kind === 'agentId' ? parsed.agentId : null;
230
+ let walletResolved = parsed.kind === 'wallet' ? parsed.wallet : null;
231
+ if (!agentId) {
232
+ agentId = await resolveAgentId(provider, walletResolved);
233
+ if (!agentId) {
234
+ return toolError(
235
+ 'no_agent_registered_for_wallet',
236
+ `no ERC-8004 agent is registered for ${walletResolved} on ${parsed.chain.name}`,
237
+ {
238
+ chain: parsed.chain.name,
239
+ chainId: parsed.chain.id,
240
+ input: address,
241
+ resolvedWallet: walletResolved,
242
+ identityRegistry: IDENTITY_REGISTRY_MAINNET,
243
+ reputationRegistry: REPUTATION_REGISTRY_MAINNET,
244
+ },
245
+ );
246
+ }
247
+ }
248
+
249
+ const [identity, reputation, events] = await Promise.all([
250
+ readIdentity(provider, agentId),
251
+ readReputationAggregate(provider, agentId),
252
+ readRecentEvents(provider, agentId),
253
+ ]);
254
+
255
+ const isZero = identity.owner === ZeroAddress;
256
+ return {
257
+ chain: parsed.chain.name,
258
+ chainId: parsed.chain.id,
259
+ agentId: agentId.toString(),
260
+ agentRegistry: `eip155:${parsed.chain.id}:${IDENTITY_REGISTRY_MAINNET}`,
261
+ reputationRegistry: REPUTATION_REGISTRY_MAINNET,
262
+ identity: isZero ? null : identity,
263
+ reputation,
264
+ events,
265
+ rpc: getEvmRpcUrls(parsed.chain.id, overrides),
266
+ fetchedAt: new Date().toISOString(),
267
+ };
268
+ },
269
+ );
270
+ return {
271
+ name: TOOL_NAME,
272
+ title: 'Agent reputation ($0.01)',
273
+ description: TOOL_DESCRIPTION,
274
+ inputSchema: inputZodShape,
275
+ // Read-only on-chain lookup — reputation events accrue between calls,
276
+ // so not idempotent.
277
+ annotations: {
278
+ readOnlyHint: true,
279
+ idempotentHint: false,
280
+ openWorldHint: true,
281
+ },
282
+ handler,
283
+ };
284
+ }
@@ -0,0 +1,108 @@
1
+ // `aixbt_intel` — paid MCP tool that surfaces aixbt's narrative intelligence
2
+ // feed (recent intel items: what's being said, where, and how reinforced) so a
3
+ // three.ws agent can react to live market narratives.
4
+ //
5
+ // Pricing: $0.01 USDC, settled `exact` on Solana.
6
+ //
7
+ // Implementation: calls GET /api/aixbt/intel on the three.ws API surface, which
8
+ // holds the aixbt API key server-side. This is the bridge the aixbt/three.ws
9
+ // thread described — agents tap aixbt intelligence over the same x402 rails.
10
+
11
+ import { z } from 'zod';
12
+
13
+ import { paid, toolError } from '../payments.js';
14
+ import { jsonSchemaFromZod } from './_shared.js';
15
+ import { resilientFetch } from '../lib/resilient-fetch.js';
16
+
17
+ const TOOL_NAME = 'aixbt_intel';
18
+ const TOOL_DESCRIPTION =
19
+ 'aixbt narrative intelligence feed: recent intel items detected across crypto — category, description, observation count, official-source flag, and the project/ticker it concerns. Optionally filter by category or chain. Powered by the live aixbt REST API. Paid: $0.01 USDC.';
20
+
21
+ function env(k, def) {
22
+ const v = process.env[k];
23
+ return v && String(v).trim() ? String(v).trim() : def;
24
+ }
25
+
26
+ const inputZodShape = {
27
+ limit: z
28
+ .number()
29
+ .int()
30
+ .min(1)
31
+ .max(50)
32
+ .describe('Max intel items to return (default 20).')
33
+ .optional(),
34
+ category: z.string().max(64).describe('Filter to a single aixbt intel category.').optional(),
35
+ chain: z
36
+ .string()
37
+ .max(32)
38
+ .describe('Filter to a chain (e.g. solana, base, ethereum).')
39
+ .optional(),
40
+ };
41
+
42
+ const inputJsonSchema = jsonSchemaFromZod(inputZodShape);
43
+
44
+ export async function buildAixbtIntelTool() {
45
+ const handler = await paid(
46
+ {
47
+ toolName: TOOL_NAME,
48
+ description: TOOL_DESCRIPTION,
49
+ scheme: 'exact',
50
+ priceUsd: '$0.01',
51
+ inputSchema: inputJsonSchema,
52
+ example: { limit: 10, chain: 'solana' },
53
+ outputExample: {
54
+ intel: [
55
+ {
56
+ category: 'partnership',
57
+ description: 'Protocol X integrates with aixbt intelligence feeds',
58
+ observations: 12,
59
+ official_source: true,
60
+ project: 'three.ws',
61
+ ticker: 'THREE',
62
+ source: 'aixbt',
63
+ },
64
+ ],
65
+ },
66
+ },
67
+ async ({ limit, category, chain }) => {
68
+ const base = env('MCP_AIXBT_BASE', 'https://three.ws');
69
+ const url = new URL(`${base.replace(/\/$/, '')}/api/aixbt/intel`);
70
+ if (limit) url.searchParams.set('limit', String(limit));
71
+ if (category) url.searchParams.set('category', category);
72
+ if (chain) url.searchParams.set('chain', chain);
73
+
74
+ let res;
75
+ try {
76
+ res = await resilientFetch(
77
+ url,
78
+ { headers: { accept: 'application/json' } },
79
+ { timeoutMs: 12_000, retries: 2, label: 'aixbt-intel' },
80
+ );
81
+ } catch (err) {
82
+ return toolError('upstream_unreachable', err?.message || 'fetch failed');
83
+ }
84
+ const data = await res.json().catch(() => null);
85
+ if (!res.ok || !data || data.error) {
86
+ return toolError(
87
+ data?.error || 'aixbt_intel_failed',
88
+ data?.error_description || `endpoint returned ${res.status}`,
89
+ );
90
+ }
91
+ return data;
92
+ },
93
+ );
94
+ return {
95
+ name: TOOL_NAME,
96
+ title: 'aixbt intel ($0.01)',
97
+ description: TOOL_DESCRIPTION,
98
+ inputSchema: inputZodShape,
99
+ // Read-only live intelligence feed — narratives update continuously,
100
+ // so not idempotent.
101
+ annotations: {
102
+ readOnlyHint: true,
103
+ idempotentHint: false,
104
+ openWorldHint: true,
105
+ },
106
+ handler,
107
+ };
108
+ }
@@ -0,0 +1,116 @@
1
+ // `aixbt_projects` — paid MCP tool that returns aixbt's momentum-ranked
2
+ // projects (spiking / climbing / active scores, market metrics, and the most
3
+ // recent intel per project) so a three.ws agent can scan what's trending and
4
+ // reason about where attention is flowing.
5
+ //
6
+ // Pricing: $0.01 USDC, settled `exact` on Solana.
7
+ //
8
+ // Implementation: calls GET /api/aixbt/projects on the three.ws API surface,
9
+ // which holds the aixbt API key server-side.
10
+
11
+ import { z } from 'zod';
12
+
13
+ import { paid, toolError } from '../payments.js';
14
+ import { jsonSchemaFromZod } from './_shared.js';
15
+ import { resilientFetch } from '../lib/resilient-fetch.js';
16
+
17
+ const TOOL_NAME = 'aixbt_projects';
18
+ const TOOL_DESCRIPTION =
19
+ 'aixbt momentum scan: projects ranked by aixbt spiking/climbing/active scores, with ticker, chain, market metrics (price, mcap, 24h volume + change) and recent intel. Filter by names (comma-separated) or chain. Powered by the live aixbt REST API. Paid: $0.01 USDC.';
20
+
21
+ function env(k, def) {
22
+ const v = process.env[k];
23
+ return v && String(v).trim() ? String(v).trim() : def;
24
+ }
25
+
26
+ const inputZodShape = {
27
+ limit: z
28
+ .number()
29
+ .int()
30
+ .min(1)
31
+ .max(50)
32
+ .describe('Max projects to return (default 20).')
33
+ .optional(),
34
+ names: z
35
+ .string()
36
+ .max(256)
37
+ .describe('Comma-separated project names/tickers to filter to.')
38
+ .optional(),
39
+ chain: z
40
+ .string()
41
+ .max(32)
42
+ .describe('Filter to a chain (e.g. solana, base, ethereum).')
43
+ .optional(),
44
+ };
45
+
46
+ const inputJsonSchema = jsonSchemaFromZod(inputZodShape);
47
+
48
+ export async function buildAixbtProjectsTool() {
49
+ const handler = await paid(
50
+ {
51
+ toolName: TOOL_NAME,
52
+ description: TOOL_DESCRIPTION,
53
+ scheme: 'exact',
54
+ priceUsd: '$0.01',
55
+ inputSchema: inputJsonSchema,
56
+ example: { limit: 10, chain: 'solana' },
57
+ outputExample: {
58
+ projects: [
59
+ {
60
+ name: 'three.ws',
61
+ ticker: 'THREE',
62
+ chain: 'solana',
63
+ scores: { spiking: 0.91, climbing: 0.74, active: 0.88 },
64
+ market: {
65
+ price_usd: 0.00464,
66
+ market_cap: null,
67
+ volume_24h: null,
68
+ change_24h: 45.9,
69
+ },
70
+ source: 'aixbt',
71
+ },
72
+ ],
73
+ },
74
+ },
75
+ async ({ limit, names, chain }) => {
76
+ const base = env('MCP_AIXBT_BASE', 'https://three.ws');
77
+ const url = new URL(`${base.replace(/\/$/, '')}/api/aixbt/projects`);
78
+ if (limit) url.searchParams.set('limit', String(limit));
79
+ if (names) url.searchParams.set('names', names);
80
+ if (chain) url.searchParams.set('chain', chain);
81
+
82
+ let res;
83
+ try {
84
+ res = await resilientFetch(
85
+ url,
86
+ { headers: { accept: 'application/json' } },
87
+ { timeoutMs: 12_000, retries: 2, label: 'aixbt-projects' },
88
+ );
89
+ } catch (err) {
90
+ return toolError('upstream_unreachable', err?.message || 'fetch failed');
91
+ }
92
+ const data = await res.json().catch(() => null);
93
+ if (!res.ok || !data || data.error) {
94
+ return toolError(
95
+ data?.error || 'aixbt_projects_failed',
96
+ data?.error_description || `endpoint returned ${res.status}`,
97
+ );
98
+ }
99
+ return data;
100
+ },
101
+ );
102
+ return {
103
+ name: TOOL_NAME,
104
+ title: 'aixbt projects ($0.01)',
105
+ description: TOOL_DESCRIPTION,
106
+ inputSchema: inputZodShape,
107
+ // Read-only live momentum rankings — scores shift continuously, so not
108
+ // idempotent.
109
+ annotations: {
110
+ readOnlyHint: true,
111
+ idempotentHint: false,
112
+ openWorldHint: true,
113
+ },
114
+ handler,
115
+ };
116
+ }