@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.
- package/LICENSE +180 -0
- package/README.md +304 -0
- package/package.json +79 -0
- package/server.json +54 -0
- package/src/index.js +201 -0
- package/src/lib/evm-rpc.js +130 -0
- package/src/lib/pose-presets.js +421 -0
- package/src/lib/pump-vanity.js +124 -0
- package/src/lib/resilient-fetch.js +194 -0
- package/src/lib/solana-rpc.js +130 -0
- package/src/payments.js +319 -0
- package/src/tools/_shared.js +41 -0
- package/src/tools/agenc-client.js +136 -0
- package/src/tools/agenc-get-agent.js +145 -0
- package/src/tools/agenc-get-task.js +187 -0
- package/src/tools/agenc-list-tasks.js +110 -0
- package/src/tools/agent-delegate-action.js +113 -0
- package/src/tools/agent-reputation.js +284 -0
- package/src/tools/aixbt-intel.js +108 -0
- package/src/tools/aixbt-projects.js +116 -0
- package/src/tools/ens-sns-resolve.js +209 -0
- package/src/tools/mesh-forge.js +379 -0
- package/src/tools/pose-seed.js +169 -0
- package/src/tools/pump-snapshot.js +262 -0
- package/src/tools/rig-mesh.js +207 -0
- package/src/tools/sentiment-pulse.js +118 -0
- package/src/tools/text-to-avatar.js +289 -0
- package/src/tools/vanity-grinder.js +178 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// `get_pose_seed` — paid MCP tool that maps a natural-language pose prompt to
|
|
2
|
+
// a deterministic seed + a complete joint-rotation map for the three.ws
|
|
3
|
+
// pose-studio mannequin.
|
|
4
|
+
//
|
|
5
|
+
// Pricing: $0.001 USDC, settled `exact` in USDC on Solana mainnet.
|
|
6
|
+
//
|
|
7
|
+
// Output (real, not synthetic): the picked preset's full Euler rotation set
|
|
8
|
+
// from src/pose-presets.js (the same data the public /pose page renders),
|
|
9
|
+
// plus a stable seed derived from sha256(prompt|presetId), and a previewUrl
|
|
10
|
+
// pointing at /pose with the seed param so the user can open the result.
|
|
11
|
+
//
|
|
12
|
+
// Selection algorithm: score every PRESET by token overlap against the
|
|
13
|
+
// prompt, fall back to label/group substring containment, then to a
|
|
14
|
+
// deterministic-by-seed pick across all presets. Always returns a real
|
|
15
|
+
// preset — there is no synthetic-pose codepath.
|
|
16
|
+
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
|
|
20
|
+
import { paid } from '../payments.js';
|
|
21
|
+
import { jsonSchemaFromZod } from './_shared.js';
|
|
22
|
+
import { PRESETS, PRESET_GROUPS } from '../lib/pose-presets.js';
|
|
23
|
+
|
|
24
|
+
const TOOL_NAME = 'get_pose_seed';
|
|
25
|
+
const TOOL_DESCRIPTION =
|
|
26
|
+
'Deterministic pose-studio seed + complete joint rotations for the three.ws mannequin, picked from the in-repo preset library by matching natural-language prompt tokens against preset labels, IDs, and groups. Returns the preset id, the full Euler-rotation pose map (radians), a sha256-derived seed, and a previewUrl on three.ws/pose. Paid: $0.001 USDC.';
|
|
27
|
+
|
|
28
|
+
const PREVIEW_BASE = process.env.MCP_POSE_PREVIEW_BASE || 'https://three.ws/pose';
|
|
29
|
+
|
|
30
|
+
function tokensOf(s) {
|
|
31
|
+
return String(s || '')
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.split(/[^a-z0-9]+/g)
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Build a stable token set per preset once. ID, label words, and group all
|
|
38
|
+
// contribute to the scoreable vocabulary so prompts like "running" hit `run`,
|
|
39
|
+
// "wave" hits `wave`, "warrior pose" hits the `warrior` action preset, etc.
|
|
40
|
+
const PRESET_INDEX = PRESETS.map((p) => {
|
|
41
|
+
const idTokens = tokensOf(p.id);
|
|
42
|
+
const labelTokens = tokensOf(p.label);
|
|
43
|
+
const groupTokens = tokensOf(p.group);
|
|
44
|
+
return {
|
|
45
|
+
preset: p,
|
|
46
|
+
all: new Set([...idTokens, ...labelTokens, ...groupTokens]),
|
|
47
|
+
idTokens,
|
|
48
|
+
labelTokens,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function scorePreset(promptTokens, entry) {
|
|
53
|
+
let score = 0;
|
|
54
|
+
for (const t of promptTokens) {
|
|
55
|
+
if (entry.all.has(t)) score += 3;
|
|
56
|
+
else {
|
|
57
|
+
// substring containment in id or label gives partial credit so
|
|
58
|
+
// "wav" hits "wave", "punch" hits "punch (right)".
|
|
59
|
+
for (const tok of [...entry.idTokens, ...entry.labelTokens]) {
|
|
60
|
+
if (tok.includes(t) || t.includes(tok)) {
|
|
61
|
+
score += 1;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return score;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pickPreset(prompt) {
|
|
71
|
+
const tokens = tokensOf(prompt);
|
|
72
|
+
if (tokens.length === 0) {
|
|
73
|
+
// Empty prompt is still legal — we deterministically pick a preset
|
|
74
|
+
// keyed off the prompt string (which may be whitespace) so the same
|
|
75
|
+
// caller gets the same result.
|
|
76
|
+
const hash = createHash('sha256').update(prompt).digest();
|
|
77
|
+
const idx = hash.readUInt32BE(0) % PRESETS.length;
|
|
78
|
+
return { entry: PRESET_INDEX[idx], score: 0, reason: 'no-match-deterministic-pick' };
|
|
79
|
+
}
|
|
80
|
+
let best = null;
|
|
81
|
+
let bestScore = -1;
|
|
82
|
+
for (const entry of PRESET_INDEX) {
|
|
83
|
+
const s = scorePreset(tokens, entry);
|
|
84
|
+
if (s > bestScore) {
|
|
85
|
+
best = entry;
|
|
86
|
+
bestScore = s;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (bestScore <= 0) {
|
|
90
|
+
const hash = createHash('sha256').update(prompt).digest();
|
|
91
|
+
const idx = hash.readUInt32BE(0) % PRESETS.length;
|
|
92
|
+
return { entry: PRESET_INDEX[idx], score: 0, reason: 'no-match-deterministic-pick' };
|
|
93
|
+
}
|
|
94
|
+
return { entry: best, score: bestScore, reason: 'token-match' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function deriveSeed(prompt, presetId) {
|
|
98
|
+
return createHash('sha256').update(`${prompt}|${presetId}`).digest('hex').slice(0, 16);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Single source of truth: declare the args once as a Zod shape (with the
|
|
102
|
+
// human-facing descriptions + bounds), and derive the JSON Schema the MCP
|
|
103
|
+
// client / bazaar sees from it. The previous hand-written JSON Schema had no
|
|
104
|
+
// length bounds; the Zod (min 1, max 500) is stricter and now wins.
|
|
105
|
+
const inputZodShape = {
|
|
106
|
+
prompt: z
|
|
107
|
+
.string()
|
|
108
|
+
.min(1)
|
|
109
|
+
.max(500)
|
|
110
|
+
.describe('Natural-language description of the pose, e.g. "warrior stance", "wave hello", "sitting cross-legged".'),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const inputJsonSchema = jsonSchemaFromZod(inputZodShape);
|
|
114
|
+
|
|
115
|
+
export async function buildPoseSeedTool() {
|
|
116
|
+
const handler = await paid(
|
|
117
|
+
{
|
|
118
|
+
toolName: TOOL_NAME,
|
|
119
|
+
description: TOOL_DESCRIPTION,
|
|
120
|
+
scheme: 'exact',
|
|
121
|
+
priceUsd: '$0.001',
|
|
122
|
+
inputSchema: inputJsonSchema,
|
|
123
|
+
example: { prompt: 'wave hello' },
|
|
124
|
+
outputExample: {
|
|
125
|
+
seed: '8c12...e0f9',
|
|
126
|
+
presetId: 'wave',
|
|
127
|
+
presetLabel: 'Wave hello',
|
|
128
|
+
group: 'Standing',
|
|
129
|
+
parameters: {
|
|
130
|
+
shoulderL: { x: 0, y: 0, z: 0.1 },
|
|
131
|
+
shoulderR: { x: 0, y: 0, z: -2.45 },
|
|
132
|
+
elbowR: { x: -1.2, y: 0, z: 0 },
|
|
133
|
+
},
|
|
134
|
+
previewUrl: 'https://three.ws/pose?seed=8c12...e0f9&preset=wave',
|
|
135
|
+
match: { score: 3, reason: 'token-match' },
|
|
136
|
+
groups: PRESET_GROUPS,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
async ({ prompt }) => {
|
|
140
|
+
const picked = pickPreset(prompt);
|
|
141
|
+
const seed = deriveSeed(prompt, picked.entry.preset.id);
|
|
142
|
+
const previewUrl = `${PREVIEW_BASE}?seed=${encodeURIComponent(seed)}&preset=${encodeURIComponent(picked.entry.preset.id)}`;
|
|
143
|
+
return {
|
|
144
|
+
seed,
|
|
145
|
+
presetId: picked.entry.preset.id,
|
|
146
|
+
presetLabel: picked.entry.preset.label,
|
|
147
|
+
group: picked.entry.preset.group,
|
|
148
|
+
parameters: picked.entry.preset.pose,
|
|
149
|
+
previewUrl,
|
|
150
|
+
match: { score: picked.score, reason: picked.reason },
|
|
151
|
+
groups: PRESET_GROUPS,
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
return {
|
|
156
|
+
name: TOOL_NAME,
|
|
157
|
+
title: 'Pose seed ($0.001)',
|
|
158
|
+
description: TOOL_DESCRIPTION,
|
|
159
|
+
inputSchema: inputZodShape,
|
|
160
|
+
// Pure deterministic local compute: same prompt → same pose preset,
|
|
161
|
+
// no external interaction.
|
|
162
|
+
annotations: {
|
|
163
|
+
readOnlyHint: true,
|
|
164
|
+
idempotentHint: true,
|
|
165
|
+
openWorldHint: false,
|
|
166
|
+
},
|
|
167
|
+
handler,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// `pump_snapshot` — paid MCP tool returning a real-time market snapshot for a
|
|
2
|
+
// Solana token (pump.fun or any SPL mint).
|
|
3
|
+
//
|
|
4
|
+
// Pricing: $0.005 USDC, settled `exact` in USDC on Solana mainnet.
|
|
5
|
+
//
|
|
6
|
+
// All data is fetched live from public APIs and Solana RPC. No fallback
|
|
7
|
+
// arrays, no mocked numbers. If a source is unreachable the field is null
|
|
8
|
+
// in the response so callers see the gap rather than fake data.
|
|
9
|
+
//
|
|
10
|
+
// Sources:
|
|
11
|
+
// - Jupiter Lite price API (lite-api.jup.ag/price/v3) → price, priceChange24h, liquidity
|
|
12
|
+
// - Dexscreener (api.dexscreener.com/latest/dex/tokens/<mint>) → volume24h, pair url, dex
|
|
13
|
+
// - pump.fun frontend-api-v3 (frontend-api-v3.pump.fun/coins/<mint>) → image, name, symbol, market_cap, creator
|
|
14
|
+
// - Solana RPC getTokenLargestAccounts → top holder distribution
|
|
15
|
+
// - Helius DAS getAsset (if HELIUS_API_KEY is set) → exact holder count when available
|
|
16
|
+
|
|
17
|
+
import { PublicKey } from '@solana/web3.js';
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
|
|
20
|
+
import { paid, toolError } from '../payments.js';
|
|
21
|
+
import { jsonSchemaFromZod } from './_shared.js';
|
|
22
|
+
import { fetchJson as resilientFetchJson } from '../lib/resilient-fetch.js';
|
|
23
|
+
import { withSolanaConnection, getSolanaEndpoints } from '../lib/solana-rpc.js';
|
|
24
|
+
|
|
25
|
+
const TOOL_NAME = 'pump_snapshot';
|
|
26
|
+
const TOOL_DESCRIPTION =
|
|
27
|
+
'Live snapshot for a Solana SPL or pump.fun token: USD price (Jupiter), 24h volume + DEX pair (Dexscreener), mint metadata + image (pump.fun frontend-api-v3), and on-chain top-holder distribution from Solana RPC getTokenLargestAccounts. Optional Helius DAS holder count when HELIUS_API_KEY is configured. Paid: $0.005 USDC.';
|
|
28
|
+
|
|
29
|
+
const HELIUS_API_KEY = process.env.HELIUS_API_KEY || '';
|
|
30
|
+
|
|
31
|
+
function isValidSolanaPubkey(s) {
|
|
32
|
+
try {
|
|
33
|
+
new PublicKey(s);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Every upstream read goes through the shared resilient layer: an 8s per-attempt
|
|
41
|
+
// timeout plus jittered retries on transient 429/5xx and network blips. These
|
|
42
|
+
// are all idempotent GETs (or a read-only JSON-RPC POST), so retrying is safe.
|
|
43
|
+
async function fetchJson(url, init = {}, timeoutMs = 8000) {
|
|
44
|
+
return resilientFetchJson(url, init, {
|
|
45
|
+
timeoutMs,
|
|
46
|
+
retries: 2,
|
|
47
|
+
retryNonIdempotent: init.method && init.method.toUpperCase() !== 'GET',
|
|
48
|
+
label: url,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function getJupiterPrice(mint) {
|
|
53
|
+
try {
|
|
54
|
+
const data = await fetchJson(`https://lite-api.jup.ag/price/v3?ids=${mint}`);
|
|
55
|
+
const entry = data?.[mint];
|
|
56
|
+
if (!entry) return null;
|
|
57
|
+
return {
|
|
58
|
+
usdPrice: entry.usdPrice ?? null,
|
|
59
|
+
priceChange24hPct: entry.priceChange24h ?? null,
|
|
60
|
+
liquidityUsd: entry.liquidity ?? null,
|
|
61
|
+
decimals: entry.decimals ?? null,
|
|
62
|
+
blockId: entry.blockId ?? null,
|
|
63
|
+
};
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { error: err.message };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function getDexscreener(mint) {
|
|
70
|
+
try {
|
|
71
|
+
const data = await fetchJson(`https://api.dexscreener.com/latest/dex/tokens/${mint}`);
|
|
72
|
+
const pairs = Array.isArray(data?.pairs) ? data.pairs : [];
|
|
73
|
+
if (pairs.length === 0) return null;
|
|
74
|
+
// Pick the pair with the largest 24h volume so a token with multiple
|
|
75
|
+
// DEX pools surfaces its primary venue.
|
|
76
|
+
const pair = pairs.reduce((best, p) => {
|
|
77
|
+
const v = Number(p?.volume?.h24 || 0);
|
|
78
|
+
return v > (best?.vol || 0) ? { pair: p, vol: v } : best;
|
|
79
|
+
}, null)?.pair;
|
|
80
|
+
if (!pair) return null;
|
|
81
|
+
return {
|
|
82
|
+
volume24hUsd: Number(pair.volume?.h24 || 0),
|
|
83
|
+
priceUsd: pair.priceUsd ? Number(pair.priceUsd) : null,
|
|
84
|
+
priceChange24hPct: pair.priceChange?.h24 ?? null,
|
|
85
|
+
liquidityUsd: pair.liquidity?.usd ?? null,
|
|
86
|
+
fdvUsd: pair.fdv ?? null,
|
|
87
|
+
marketCapUsd: pair.marketCap ?? null,
|
|
88
|
+
pairAddress: pair.pairAddress,
|
|
89
|
+
dex: pair.dexId,
|
|
90
|
+
chain: pair.chainId,
|
|
91
|
+
url: pair.url,
|
|
92
|
+
txns24h: pair.txns?.h24 ?? null,
|
|
93
|
+
};
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { error: err.message };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function getPumpFunMeta(mint) {
|
|
100
|
+
try {
|
|
101
|
+
const data = await fetchJson(`https://frontend-api-v3.pump.fun/coins/${mint}`);
|
|
102
|
+
if (!data || data.error) return null;
|
|
103
|
+
return {
|
|
104
|
+
name: data.name || null,
|
|
105
|
+
symbol: data.symbol || null,
|
|
106
|
+
description: data.description || null,
|
|
107
|
+
imageUrl: data.image_uri || null,
|
|
108
|
+
twitter: data.twitter || null,
|
|
109
|
+
telegram: data.telegram || null,
|
|
110
|
+
website: data.website || null,
|
|
111
|
+
creator: data.creator || null,
|
|
112
|
+
createdAtMs: data.created_timestamp || null,
|
|
113
|
+
complete: !!data.complete,
|
|
114
|
+
marketCapUsd: data.usd_market_cap ?? null,
|
|
115
|
+
marketCapQuote: data.market_cap ?? null,
|
|
116
|
+
totalSupply: data.total_supply_str || data.total_supply || null,
|
|
117
|
+
poolAddress: data.pool_address || null,
|
|
118
|
+
lastTradeTimestampMs: data.last_trade_timestamp || null,
|
|
119
|
+
athMarketCapUsd: data.ath_market_cap ?? null,
|
|
120
|
+
athMarketCapTimestampMs: data.ath_market_cap_timestamp || null,
|
|
121
|
+
program: data.program || null,
|
|
122
|
+
};
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return { error: err.message };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function getTopHolders(mint) {
|
|
129
|
+
try {
|
|
130
|
+
// Failover across the configured Solana endpoints: if the primary RPC is
|
|
131
|
+
// throttling or down, the next endpoint answers instead of failing.
|
|
132
|
+
const res = await withSolanaConnection((conn) =>
|
|
133
|
+
conn.getTokenLargestAccounts(new PublicKey(mint)),
|
|
134
|
+
);
|
|
135
|
+
const top = (res?.value || []).map((acct) => ({
|
|
136
|
+
address: acct.address.toBase58(),
|
|
137
|
+
uiAmount: acct.uiAmount,
|
|
138
|
+
amount: acct.amount,
|
|
139
|
+
decimals: acct.decimals,
|
|
140
|
+
}));
|
|
141
|
+
return {
|
|
142
|
+
topHolderCount: top.length,
|
|
143
|
+
topHolders: top,
|
|
144
|
+
};
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return { error: err.message };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function getHeliusHolderCount(mint) {
|
|
151
|
+
if (!HELIUS_API_KEY) return null;
|
|
152
|
+
try {
|
|
153
|
+
const url = `https://mainnet.helius-rpc.com/?api-key=${HELIUS_API_KEY}`;
|
|
154
|
+
const body = {
|
|
155
|
+
jsonrpc: '2.0',
|
|
156
|
+
id: 'getAsset',
|
|
157
|
+
method: 'getAsset',
|
|
158
|
+
params: { id: mint, options: { showFungible: true } },
|
|
159
|
+
};
|
|
160
|
+
const data = await fetchJson(url, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { 'content-type': 'application/json' },
|
|
163
|
+
body: JSON.stringify(body),
|
|
164
|
+
});
|
|
165
|
+
const supply = data?.result?.token_info?.supply ?? null;
|
|
166
|
+
const decimals = data?.result?.token_info?.decimals ?? null;
|
|
167
|
+
const priceInfo = data?.result?.token_info?.price_info ?? null;
|
|
168
|
+
return {
|
|
169
|
+
supply: supply !== null ? String(supply) : null,
|
|
170
|
+
decimals,
|
|
171
|
+
heliusPriceUsd: priceInfo?.price_per_token ?? null,
|
|
172
|
+
};
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return { error: err.message };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Single source of truth: Zod shape carries the base58 validity refinement AND
|
|
179
|
+
// the length bounds/description; the JSON Schema is derived from it. (The
|
|
180
|
+
// refine() predicate is the strict check; the min/max length bounds mirror the
|
|
181
|
+
// previous hand-written JSON Schema so the bazaar still advertises them.)
|
|
182
|
+
const inputZodShape = {
|
|
183
|
+
token: z
|
|
184
|
+
.string()
|
|
185
|
+
.min(32)
|
|
186
|
+
.max(64)
|
|
187
|
+
.refine((v) => isValidSolanaPubkey(v), 'must be a base58 Solana pubkey')
|
|
188
|
+
.describe('Solana SPL or pump.fun mint pubkey (base58).'),
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const inputJsonSchema = jsonSchemaFromZod(inputZodShape);
|
|
192
|
+
|
|
193
|
+
export async function buildPumpSnapshotTool() {
|
|
194
|
+
const handler = await paid(
|
|
195
|
+
{
|
|
196
|
+
toolName: TOOL_NAME,
|
|
197
|
+
description: TOOL_DESCRIPTION,
|
|
198
|
+
scheme: 'exact',
|
|
199
|
+
priceUsd: '$0.005',
|
|
200
|
+
inputSchema: inputJsonSchema,
|
|
201
|
+
example: { token: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3' },
|
|
202
|
+
outputExample: {
|
|
203
|
+
token: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3',
|
|
204
|
+
price: { usdPrice: 0.0415, priceChange24hPct: 2.5, liquidityUsd: 107732 },
|
|
205
|
+
volume24h: { volume24hUsd: 270780.6, dex: 'raydium' },
|
|
206
|
+
holders: { topHolderCount: 20, topHolders: [{ address: '...', uiAmount: 1234 }] },
|
|
207
|
+
meta: { name: 'Pyth Network', symbol: 'PYTH', imageUrl: 'https://...' },
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
async ({ token }) => {
|
|
211
|
+
if (!isValidSolanaPubkey(token)) {
|
|
212
|
+
return toolError('invalid_mint', 'token must be a base58 Solana pubkey', { token });
|
|
213
|
+
}
|
|
214
|
+
const [price, ds, meta, holders, helius] = await Promise.all([
|
|
215
|
+
getJupiterPrice(token),
|
|
216
|
+
getDexscreener(token),
|
|
217
|
+
getPumpFunMeta(token),
|
|
218
|
+
getTopHolders(token),
|
|
219
|
+
getHeliusHolderCount(token),
|
|
220
|
+
]);
|
|
221
|
+
// Price fallback: if Jupiter is unavailable but Dexscreener returned a
|
|
222
|
+
// pair price, surface it rather than leaving price null — two
|
|
223
|
+
// independent sources back the single most important field.
|
|
224
|
+
const priceUsd = price?.usdPrice ?? ds?.priceUsd ?? null;
|
|
225
|
+
const priceSource =
|
|
226
|
+
price?.usdPrice != null ? 'jupiter' : ds?.priceUsd != null ? 'dexscreener' : null;
|
|
227
|
+
return {
|
|
228
|
+
token,
|
|
229
|
+
fetchedAt: new Date().toISOString(),
|
|
230
|
+
price,
|
|
231
|
+
priceUsd,
|
|
232
|
+
priceSource,
|
|
233
|
+
volume24h: ds,
|
|
234
|
+
meta,
|
|
235
|
+
holders,
|
|
236
|
+
helius,
|
|
237
|
+
image: meta?.imageUrl || null,
|
|
238
|
+
sources: {
|
|
239
|
+
price: 'https://lite-api.jup.ag/price/v3',
|
|
240
|
+
volume24h: 'https://api.dexscreener.com',
|
|
241
|
+
meta: 'https://frontend-api-v3.pump.fun',
|
|
242
|
+
holders: getSolanaEndpoints(),
|
|
243
|
+
helius: HELIUS_API_KEY ? 'https://mainnet.helius-rpc.com' : null,
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
return {
|
|
249
|
+
name: TOOL_NAME,
|
|
250
|
+
title: 'Pump.fun snapshot ($0.005)',
|
|
251
|
+
description: TOOL_DESCRIPTION,
|
|
252
|
+
inputSchema: inputZodShape,
|
|
253
|
+
// Read-only live market snapshot — price/holders move between calls,
|
|
254
|
+
// so not idempotent.
|
|
255
|
+
annotations: {
|
|
256
|
+
readOnlyHint: true,
|
|
257
|
+
idempotentHint: false,
|
|
258
|
+
openWorldHint: true,
|
|
259
|
+
},
|
|
260
|
+
handler,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// `rig_mesh` — paid MCP tool: static GLB → rigged, animation-ready GLB.
|
|
2
|
+
//
|
|
3
|
+
// Pricing: $0.20 USDC, settled `exact` on Solana.
|
|
4
|
+
//
|
|
5
|
+
// Takes a GLB mesh URL and returns a rigged GLB — a humanoid skeleton plus
|
|
6
|
+
// per-vertex skin weights — produced by the three.ws auto-rig pipeline
|
|
7
|
+
// (/api/forge?action=rig, VAST-AI UniRig by default). Like mesh_forge, this is
|
|
8
|
+
// a thin x402-gated client over the prod pipeline: it holds no generation
|
|
9
|
+
// credentials; the USDC payment gates the call and all GPU work runs on
|
|
10
|
+
// three.ws prod.
|
|
11
|
+
//
|
|
12
|
+
// Composes with mesh_forge: forge a mesh (text or image → GLB), then feed the
|
|
13
|
+
// returned glbUrl here to get an animation-ready model that loads straight into
|
|
14
|
+
// the three.ws pose studio.
|
|
15
|
+
//
|
|
16
|
+
// Environment (all optional — sensible prod defaults):
|
|
17
|
+
// MESH_FORGE_API_BASE — three.ws origin. Default https://three.ws
|
|
18
|
+
// RIG_MESH_TIMEOUT_MS — overall rig poll budget. Default 180000.
|
|
19
|
+
// RIG_MESH_POLL_MS — poll interval. Default 3000.
|
|
20
|
+
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
|
|
23
|
+
import { paid, toolError } from '../payments.js';
|
|
24
|
+
import { jsonSchemaFromZod } from './_shared.js';
|
|
25
|
+
|
|
26
|
+
const TOOL_NAME = 'rig_mesh';
|
|
27
|
+
const TOOL_DESCRIPTION =
|
|
28
|
+
'Auto-rig a static 3D GLB mesh into an animation-ready model: adds a humanoid skeleton and per-vertex skin weights via the three.ws rig pipeline (VAST-AI UniRig by default). Takes a GLB URL, returns the rigged GLB URL and a three.ws pose-studio link. Pairs with mesh_forge — forge a mesh, then rig it. Paid: $0.20 USDC.';
|
|
29
|
+
|
|
30
|
+
function env(k, def) {
|
|
31
|
+
const v = process.env[k];
|
|
32
|
+
return v && String(v).trim() ? String(v).trim() : def;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function apiBase() {
|
|
36
|
+
return env('MESH_FORGE_API_BASE', 'https://three.ws').replace(/\/$/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function startRig(glbUrl) {
|
|
40
|
+
const base = apiBase();
|
|
41
|
+
const res = await fetch(`${base}/api/forge?action=rig`, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: { 'content-type': 'application/json' },
|
|
44
|
+
body: JSON.stringify({ glb_url: glbUrl }),
|
|
45
|
+
signal: AbortSignal.timeout(30_000),
|
|
46
|
+
});
|
|
47
|
+
const data = await res.json().catch(() => ({}));
|
|
48
|
+
if (res.status === 503) {
|
|
49
|
+
const e = new Error(data?.message || 'rigging is not configured on the three.ws deployment');
|
|
50
|
+
e.code = 'not_configured';
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
if (res.status === 501) {
|
|
54
|
+
const e = new Error(data?.message || 'auto-rigging is not enabled on the three.ws deployment');
|
|
55
|
+
e.code = 'not_configured';
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
if (res.status === 429) {
|
|
59
|
+
const e = new Error(data?.message || 'the rigger is busy; try again shortly');
|
|
60
|
+
e.code = 'rate_limited';
|
|
61
|
+
e.retryAfter = data?.retry_after;
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
if (!res.ok || !data?.job_id) {
|
|
65
|
+
const e = new Error(data?.message || `rig start returned ${res.status}`);
|
|
66
|
+
e.code = 'provider_error';
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
return data; // { job_id, creation_id, status, mode:'rig', source_glb_url }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function pollRig(jobId, { timeoutMs, intervalMs }) {
|
|
73
|
+
const base = apiBase();
|
|
74
|
+
const deadline = Date.now() + timeoutMs;
|
|
75
|
+
let last = null;
|
|
76
|
+
while (Date.now() < deadline) {
|
|
77
|
+
let res;
|
|
78
|
+
try {
|
|
79
|
+
res = await fetch(`${base}/api/forge?job=${encodeURIComponent(jobId)}`, {
|
|
80
|
+
headers: { accept: 'application/json' },
|
|
81
|
+
signal: AbortSignal.timeout(Math.max(intervalMs * 3, 15_000)),
|
|
82
|
+
});
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err?.name === 'AbortError' || err?.name === 'TimeoutError') {
|
|
85
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const e = new Error(`rig poll failed: ${err?.message || err}`);
|
|
89
|
+
e.code = 'provider_error';
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
const data = await res.json().catch(() => ({}));
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const e = new Error(data?.message || `rig poll returned ${res.status}`);
|
|
95
|
+
e.code = 'provider_error';
|
|
96
|
+
throw e;
|
|
97
|
+
}
|
|
98
|
+
last = data;
|
|
99
|
+
if (data.status === 'done' && data.glb_url) return data;
|
|
100
|
+
if (data.status === 'failed') {
|
|
101
|
+
const e = new Error(data.error || 'rigging failed');
|
|
102
|
+
e.code = 'rig_failed';
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
106
|
+
}
|
|
107
|
+
return { ...(last || {}), _timedOut: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const inputZodShape = {
|
|
111
|
+
glb_url: z
|
|
112
|
+
.string()
|
|
113
|
+
.url()
|
|
114
|
+
.describe('http(s) URL to the static GLB mesh to rig (e.g. the glbUrl returned by mesh_forge).'),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const inputJsonSchema = jsonSchemaFromZod(inputZodShape);
|
|
118
|
+
|
|
119
|
+
export async function buildRigMeshTool() {
|
|
120
|
+
const handler = await paid(
|
|
121
|
+
{
|
|
122
|
+
toolName: TOOL_NAME,
|
|
123
|
+
description: TOOL_DESCRIPTION,
|
|
124
|
+
scheme: 'exact',
|
|
125
|
+
priceUsd: '$0.20',
|
|
126
|
+
inputSchema: inputJsonSchema,
|
|
127
|
+
example: { glb_url: 'https://three.ws/cdn/creations/abc123/mesh.glb' },
|
|
128
|
+
outputExample: {
|
|
129
|
+
ok: true,
|
|
130
|
+
riggedGlbUrl: 'https://three.ws/cdn/creations/def456/rigged.glb',
|
|
131
|
+
sourceGlbUrl: 'https://three.ws/cdn/creations/abc123/mesh.glb',
|
|
132
|
+
poseStudioUrl: 'https://three.ws/pose?src=https%3A%2F%2Fthree.ws%2F...',
|
|
133
|
+
jobId: 'r9k2m7x4',
|
|
134
|
+
creationId: 'def456',
|
|
135
|
+
durationMs: 48000,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
async ({ glb_url }) => {
|
|
139
|
+
const started = Date.now();
|
|
140
|
+
|
|
141
|
+
let job;
|
|
142
|
+
try {
|
|
143
|
+
job = await startRig(glb_url);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return toolError(err.code || 'provider_error', err.message, {
|
|
146
|
+
...(err.retryAfter ? { retryAfter: err.retryAfter } : {}),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const timeoutMs = Number(env('RIG_MESH_TIMEOUT_MS', '180000'));
|
|
151
|
+
const intervalMs = Number(env('RIG_MESH_POLL_MS', '3000'));
|
|
152
|
+
let final;
|
|
153
|
+
try {
|
|
154
|
+
final = await pollRig(job.job_id, { timeoutMs, intervalMs });
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return toolError(err.code || 'provider_error', err.message, {
|
|
157
|
+
jobId: job.job_id,
|
|
158
|
+
creationId: job.creation_id ?? null,
|
|
159
|
+
durationMs: Date.now() - started,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const durationMs = Date.now() - started;
|
|
164
|
+
|
|
165
|
+
if (final._timedOut) {
|
|
166
|
+
return toolError('timeout', `rigging did not finish within ${timeoutMs}ms`, {
|
|
167
|
+
jobId: job.job_id,
|
|
168
|
+
creationId: job.creation_id ?? null,
|
|
169
|
+
status: final.status || 'running',
|
|
170
|
+
resumeUrl: `${apiBase()}/api/forge?job=${job.job_id}`,
|
|
171
|
+
durationMs,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const riggedGlbUrl = final.glb_url;
|
|
176
|
+
const poseStudioUrl = `${apiBase()}/pose?src=${encodeURIComponent(riggedGlbUrl)}`;
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
riggedGlbUrl,
|
|
181
|
+
sourceGlbUrl: glb_url,
|
|
182
|
+
poseStudioUrl,
|
|
183
|
+
jobId: job.job_id,
|
|
184
|
+
creationId: final.creation_id ?? job.creation_id ?? null,
|
|
185
|
+
durable: Boolean(final.durable),
|
|
186
|
+
durationMs,
|
|
187
|
+
fetchedAt: new Date().toISOString(),
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
name: TOOL_NAME,
|
|
194
|
+
title: 'Rig 3D mesh ($0.20)',
|
|
195
|
+
description: TOOL_DESCRIPTION,
|
|
196
|
+
inputSchema: inputZodShape,
|
|
197
|
+
// Creates a new hosted rigged-GLB artifact via external rigging APIs;
|
|
198
|
+
// the input mesh is never modified or deleted.
|
|
199
|
+
annotations: {
|
|
200
|
+
readOnlyHint: false,
|
|
201
|
+
destructiveHint: false,
|
|
202
|
+
idempotentHint: false,
|
|
203
|
+
openWorldHint: true,
|
|
204
|
+
},
|
|
205
|
+
handler,
|
|
206
|
+
};
|
|
207
|
+
}
|