@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,118 @@
|
|
|
1
|
+
// `sentiment_pulse` — paid MCP tool that returns a real-time sentiment
|
|
2
|
+
// pulse for a Solana token by pulling recent pump.fun comments and
|
|
3
|
+
// scoring them with the three.ws lexicon scorer. Callers may attach
|
|
4
|
+
// additional texts (e.g. X posts they have collected) to fold into the
|
|
5
|
+
// overall score.
|
|
6
|
+
//
|
|
7
|
+
// Pricing: $0.003 USDC, settled `exact` in USDC on Solana mainnet.
|
|
8
|
+
//
|
|
9
|
+
// Implementation: calls POST /api/social/sentiment-pulse on the three.ws
|
|
10
|
+
// API surface. No keys are required — the endpoint relies on the public
|
|
11
|
+
// pump.fun frontend-api-v3 replies route.
|
|
12
|
+
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
|
|
15
|
+
import { paid, toolError } from '../payments.js';
|
|
16
|
+
import { jsonSchemaFromZod } from './_shared.js';
|
|
17
|
+
import { resilientFetch } from '../lib/resilient-fetch.js';
|
|
18
|
+
|
|
19
|
+
const TOOL_NAME = 'sentiment_pulse';
|
|
20
|
+
const TOOL_DESCRIPTION =
|
|
21
|
+
'Sentiment pulse for a Solana token: fetches the most recent pump.fun comments via frontend-api-v3, optionally folds in caller-supplied snippets (e.g. recent X cashtag posts), and scores the combined stream with the three.ws deterministic lexicon. Returns overall + per-source breakdown with examples. Pairs naturally with pump_snapshot. Paid: $0.003 USDC.';
|
|
22
|
+
|
|
23
|
+
function env(k, def) {
|
|
24
|
+
const v = process.env[k];
|
|
25
|
+
return v && String(v).trim() ? String(v).trim() : def;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SOLANA_MINT_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
29
|
+
|
|
30
|
+
// Single source of truth: Zod shape carries the base58 refinement + bounds +
|
|
31
|
+
// descriptions; JSON Schema derived. (The previous JSON Schema declared a
|
|
32
|
+
// `default: 100` on limit, which Zod cannot express as a JSON-Schema default
|
|
33
|
+
// while keeping the field optional — the handler already treats an absent
|
|
34
|
+
// limit as "use the endpoint default", so dropping the advertised default is
|
|
35
|
+
// the correct, drift-free outcome.)
|
|
36
|
+
const inputZodShape = {
|
|
37
|
+
token: z
|
|
38
|
+
.string()
|
|
39
|
+
.min(32)
|
|
40
|
+
.max(44)
|
|
41
|
+
.refine((v) => SOLANA_MINT_RE.test(v), 'token must be a base58 Solana mint pubkey')
|
|
42
|
+
.describe('Solana SPL or pump.fun mint pubkey (base58).'),
|
|
43
|
+
limit: z.number().int().min(1).max(200).describe('Max pump.fun comments to score.').optional(),
|
|
44
|
+
extraTexts: z
|
|
45
|
+
.array(z.string().max(2000))
|
|
46
|
+
.max(200)
|
|
47
|
+
.describe('Extra text snippets to include (e.g. X posts you have already collected).')
|
|
48
|
+
.optional(),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const inputJsonSchema = jsonSchemaFromZod(inputZodShape);
|
|
52
|
+
|
|
53
|
+
export async function buildSentimentPulseTool() {
|
|
54
|
+
const handler = await paid(
|
|
55
|
+
{
|
|
56
|
+
toolName: TOOL_NAME,
|
|
57
|
+
description: TOOL_DESCRIPTION,
|
|
58
|
+
scheme: 'exact',
|
|
59
|
+
priceUsd: '$0.003',
|
|
60
|
+
inputSchema: inputJsonSchema,
|
|
61
|
+
example: { token: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', limit: 100 },
|
|
62
|
+
outputExample: {
|
|
63
|
+
ok: true,
|
|
64
|
+
token: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3',
|
|
65
|
+
overall: { score: 0.42, posPct: 58, negPct: 16, neuPct: 26, count: 100 },
|
|
66
|
+
breakdown: { pumpfun: { score: 0.4, count: 90 }, extra: { score: 0.5, count: 10 } },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
async ({ token, limit, extraTexts }) => {
|
|
70
|
+
const endpoint = env(
|
|
71
|
+
'MCP_SENTIMENT_PULSE_ENDPOINT',
|
|
72
|
+
'https://three.ws/api/social/sentiment-pulse',
|
|
73
|
+
);
|
|
74
|
+
let res;
|
|
75
|
+
try {
|
|
76
|
+
// Read-only scoring computation — safe to retry on a transient blip.
|
|
77
|
+
res = await resilientFetch(
|
|
78
|
+
endpoint,
|
|
79
|
+
{
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'content-type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ token, limit, extraTexts }),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
timeoutMs: 15_000,
|
|
86
|
+
retries: 2,
|
|
87
|
+
retryNonIdempotent: true,
|
|
88
|
+
label: 'sentiment-pulse',
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return toolError('upstream_unreachable', err?.message || 'fetch failed');
|
|
93
|
+
}
|
|
94
|
+
const data = await res.json().catch(() => null);
|
|
95
|
+
if (!res.ok || !data || data.ok === false) {
|
|
96
|
+
return toolError(
|
|
97
|
+
data?.code || data?.error || 'sentiment_failed',
|
|
98
|
+
data?.message || `endpoint returned ${res.status}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return data;
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
return {
|
|
105
|
+
name: TOOL_NAME,
|
|
106
|
+
title: 'Sentiment pulse ($0.003)',
|
|
107
|
+
description: TOOL_DESCRIPTION,
|
|
108
|
+
inputSchema: inputZodShape,
|
|
109
|
+
// Read-only live market feed — re-calls with the same args return
|
|
110
|
+
// fresh data, so not idempotent.
|
|
111
|
+
annotations: {
|
|
112
|
+
readOnlyHint: true,
|
|
113
|
+
idempotentHint: false,
|
|
114
|
+
openWorldHint: true,
|
|
115
|
+
},
|
|
116
|
+
handler,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// `text_to_avatar` — paid MCP tool that generates a textured 3D GLB avatar
|
|
2
|
+
// from either a text prompt or one/more reference image URLs, by driving
|
|
3
|
+
// Replicate's text-to-3D / image-to-3D pipeline (Tencent Hunyuan-3D 3.1 by
|
|
4
|
+
// default, configurable via REPLICATE_TEXT_TO_AVATAR_MODEL).
|
|
5
|
+
//
|
|
6
|
+
// Pricing: $0.15 USDC, settled `exact` in USDC on Solana mainnet.
|
|
7
|
+
//
|
|
8
|
+
// Behavior: synchronously submits a Replicate prediction and polls until the
|
|
9
|
+
// prediction reaches a terminal state or the configured timeout fires. The
|
|
10
|
+
// returned GLB URL is the Replicate-hosted output. To avoid the caller having
|
|
11
|
+
// to chase signed-URL expiration, this tool re-fetches the GLB and rehosts it
|
|
12
|
+
// on the three.ws R2 bucket when MCP_TEXT_TO_AVATAR_REHOST is enabled
|
|
13
|
+
// (default off; opt-in because rehosting writes a public object).
|
|
14
|
+
//
|
|
15
|
+
// Environment:
|
|
16
|
+
// REPLICATE_API_TOKEN — required.
|
|
17
|
+
// REPLICATE_TEXT_TO_AVATAR_MODEL — required version hash. Pin a
|
|
18
|
+
// commercial-OK image/text-to-3D model
|
|
19
|
+
// (e.g. tencent/hunyuan-3d-3.1 latest).
|
|
20
|
+
// MCP_TEXT_TO_AVATAR_TIMEOUT_MS — optional, defaults to 110_000.
|
|
21
|
+
// MCP_TEXT_TO_AVATAR_POLL_MS — optional, defaults to 2_000.
|
|
22
|
+
// MCP_TEXT_TO_AVATAR_REHOST — "1" to rehost via MCP_REHOST_ENDPOINT.
|
|
23
|
+
// MCP_REHOST_ENDPOINT — three.ws URL that ingests external
|
|
24
|
+
// GLB URLs.
|
|
25
|
+
// MCP_REHOST_KEY — bearer for MCP_REHOST_ENDPOINT.
|
|
26
|
+
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
|
|
29
|
+
import { paid, toolError } from '../payments.js';
|
|
30
|
+
import { jsonSchemaFromZod } from './_shared.js';
|
|
31
|
+
|
|
32
|
+
const TOOL_NAME = 'text_to_avatar';
|
|
33
|
+
const TOOL_DESCRIPTION =
|
|
34
|
+
'Generate a textured 3D GLB avatar from a text prompt or one or more reference image URLs. Drives Replicate (Hunyuan-3D 3.1 by default, configurable) and polls the prediction synchronously until a GLB is produced or the timeout fires. Returns the GLB URL, the source prompt/images, the picked model version, the prediction id, and timing metadata. Paid: $0.15 USDC.';
|
|
35
|
+
|
|
36
|
+
const REPLICATE_BASE = 'https://api.replicate.com/v1';
|
|
37
|
+
|
|
38
|
+
function env(k, def) {
|
|
39
|
+
const v = process.env[k];
|
|
40
|
+
return v && String(v).trim() ? String(v).trim() : def;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function authHeaders() {
|
|
44
|
+
const token = env('REPLICATE_API_TOKEN');
|
|
45
|
+
if (!token) {
|
|
46
|
+
const err = new Error('REPLICATE_API_TOKEN is not configured on the MCP server');
|
|
47
|
+
err.code = 'not_configured';
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
return { authorization: `Bearer ${token}`, 'content-type': 'application/json' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractGlbUrl(output) {
|
|
54
|
+
if (!output) return null;
|
|
55
|
+
if (typeof output === 'string') return output;
|
|
56
|
+
if (Array.isArray(output)) {
|
|
57
|
+
for (const v of output) {
|
|
58
|
+
if (typeof v === 'string' && /\.glb(\?|$)/i.test(v)) return v;
|
|
59
|
+
}
|
|
60
|
+
for (const v of output) {
|
|
61
|
+
if (typeof v === 'string' && /^https?:\/\//.test(v)) return v;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (typeof output === 'object') {
|
|
65
|
+
for (const key of ['glb', 'mesh', 'mesh_url', 'output_url', 'url', 'model']) {
|
|
66
|
+
if (typeof output[key] === 'string') return output[key];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function submitPrediction({ version, input }) {
|
|
73
|
+
const res = await fetch(`${REPLICATE_BASE}/predictions`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: authHeaders(),
|
|
76
|
+
body: JSON.stringify({ version, input }),
|
|
77
|
+
});
|
|
78
|
+
const data = await res.json().catch(() => ({}));
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
const err = new Error(data?.detail || data?.title || `replicate returned ${res.status}`);
|
|
81
|
+
err.code = 'provider_error';
|
|
82
|
+
err.providerStatus = res.status;
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
return data;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function pollPrediction(predictionId, { timeoutMs, intervalMs }) {
|
|
89
|
+
const deadline = Date.now() + timeoutMs;
|
|
90
|
+
let last = null;
|
|
91
|
+
// Per-fetch ceiling so a single hung HTTP roundtrip can't block the whole
|
|
92
|
+
// poll loop past `deadline`. Without this, Node's fetch has no default
|
|
93
|
+
// timeout — a Replicate edge-stall would dangle the request indefinitely
|
|
94
|
+
// and the outer loop never gets a chance to re-check Date.now() < deadline.
|
|
95
|
+
const perFetchTimeoutMs = Math.max(intervalMs * 3, 10_000);
|
|
96
|
+
while (Date.now() < deadline) {
|
|
97
|
+
let r;
|
|
98
|
+
try {
|
|
99
|
+
r = await fetch(`${REPLICATE_BASE}/predictions/${encodeURIComponent(predictionId)}`, {
|
|
100
|
+
headers: authHeaders(),
|
|
101
|
+
signal: AbortSignal.timeout(perFetchTimeoutMs),
|
|
102
|
+
});
|
|
103
|
+
} catch (err) {
|
|
104
|
+
// Aborted polls are transient; resume the loop until `deadline`
|
|
105
|
+
// expires. Other network failures bubble up as provider errors so
|
|
106
|
+
// the caller sees them instead of an opaque _timedOut.
|
|
107
|
+
if (err?.name === 'AbortError' || err?.name === 'TimeoutError') {
|
|
108
|
+
await new Promise((res) => setTimeout(res, intervalMs));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const e = new Error(`replicate poll fetch failed: ${err?.message || err}`);
|
|
112
|
+
e.code = 'provider_error';
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
const data = await r.json().catch(() => ({}));
|
|
116
|
+
if (!r.ok) {
|
|
117
|
+
const err = new Error(data?.detail || `replicate poll returned ${r.status}`);
|
|
118
|
+
err.code = 'provider_error';
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
last = data;
|
|
122
|
+
const s = data.status;
|
|
123
|
+
if (s === 'succeeded' || s === 'failed' || s === 'canceled') return data;
|
|
124
|
+
await new Promise((res) => setTimeout(res, intervalMs));
|
|
125
|
+
}
|
|
126
|
+
return { ...last, _timedOut: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function rehostIfRequested(glbUrl, { prompt, images }) {
|
|
130
|
+
if (env('MCP_TEXT_TO_AVATAR_REHOST', '0') !== '1') return null;
|
|
131
|
+
const endpoint = env('MCP_REHOST_ENDPOINT', 'https://three.ws/api/avatars/ingest-url');
|
|
132
|
+
const ingestKey = env('MCP_REHOST_KEY');
|
|
133
|
+
if (!ingestKey) return null;
|
|
134
|
+
try {
|
|
135
|
+
const r = await fetch(endpoint, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: {
|
|
138
|
+
'content-type': 'application/json',
|
|
139
|
+
authorization: `Bearer ${ingestKey}`,
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
source_url: glbUrl,
|
|
143
|
+
name: (prompt || 'mcp-text-to-avatar').slice(0, 80),
|
|
144
|
+
source: 'mcp',
|
|
145
|
+
source_meta: { provider: 'replicate', prompt, images },
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
if (!r.ok) return { error: `rehost failed: ${r.status}` };
|
|
149
|
+
return await r.json();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return { error: err?.message || 'rehost call failed' };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Single source of truth: Zod shape carries descriptions + bounds; JSON Schema
|
|
156
|
+
// derived. (No required fields — the handler enforces "prompt OR images".)
|
|
157
|
+
const inputZodShape = {
|
|
158
|
+
prompt: z.string().max(1000).describe('Natural-language description of the avatar to generate.').optional(),
|
|
159
|
+
images: z
|
|
160
|
+
.array(z.string().url())
|
|
161
|
+
.max(4)
|
|
162
|
+
.describe('Optional reference image URLs. When provided, the model performs image-to-3D reconstruction.')
|
|
163
|
+
.optional(),
|
|
164
|
+
seed: z.number().int().min(0).max(2147483647).optional(),
|
|
165
|
+
texture: z.boolean().describe('Request PBR textures when supported (default true).').optional(),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const inputJsonSchema = jsonSchemaFromZod(inputZodShape);
|
|
169
|
+
|
|
170
|
+
export async function buildTextToAvatarTool() {
|
|
171
|
+
const handler = await paid(
|
|
172
|
+
{
|
|
173
|
+
toolName: TOOL_NAME,
|
|
174
|
+
description: TOOL_DESCRIPTION,
|
|
175
|
+
scheme: 'exact',
|
|
176
|
+
priceUsd: '$0.15',
|
|
177
|
+
inputSchema: inputJsonSchema,
|
|
178
|
+
example: { prompt: 'a cheerful cyberpunk fox in a red hoodie' },
|
|
179
|
+
outputExample: {
|
|
180
|
+
ok: true,
|
|
181
|
+
predictionId: 'qb...8',
|
|
182
|
+
glbUrl: 'https://replicate.delivery/.../mesh.glb',
|
|
183
|
+
prompt: 'a cheerful cyberpunk fox in a red hoodie',
|
|
184
|
+
model: 'tencent/hunyuan-3d-3.1@<version-hash>',
|
|
185
|
+
durationMs: 41000,
|
|
186
|
+
preview: 'https://three.ws/viewer?src=https%3A%2F%2Freplicate.delivery%2F...',
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
async ({ prompt, images, seed, texture }) => {
|
|
190
|
+
const version = env('REPLICATE_TEXT_TO_AVATAR_MODEL');
|
|
191
|
+
if (!version) {
|
|
192
|
+
return toolError(
|
|
193
|
+
'not_configured',
|
|
194
|
+
'REPLICATE_TEXT_TO_AVATAR_MODEL is not set on the MCP server. Pin a commercial-OK image/text-to-3D version (e.g. tencent/hunyuan-3d-3.1 latest).',
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (!prompt && (!images || images.length === 0)) {
|
|
198
|
+
return toolError('invalid_input', 'Provide either prompt or images[].');
|
|
199
|
+
}
|
|
200
|
+
const input = {
|
|
201
|
+
prompt: prompt || undefined,
|
|
202
|
+
image: images && images.length ? images[0] : undefined,
|
|
203
|
+
images: images && images.length ? images : undefined,
|
|
204
|
+
seed: typeof seed === 'number' ? seed : undefined,
|
|
205
|
+
texture: typeof texture === 'boolean' ? texture : true,
|
|
206
|
+
};
|
|
207
|
+
Object.keys(input).forEach((k) => input[k] === undefined && delete input[k]);
|
|
208
|
+
|
|
209
|
+
const started = Date.now();
|
|
210
|
+
let submitted;
|
|
211
|
+
try {
|
|
212
|
+
submitted = await submitPrediction({ version, input });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return toolError(err.code || 'provider_error', err.message);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const timeoutMs = Number(env('MCP_TEXT_TO_AVATAR_TIMEOUT_MS', '110000'));
|
|
218
|
+
const intervalMs = Number(env('MCP_TEXT_TO_AVATAR_POLL_MS', '2000'));
|
|
219
|
+
let finalState;
|
|
220
|
+
try {
|
|
221
|
+
finalState = await pollPrediction(submitted.id, { timeoutMs, intervalMs });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
return toolError(err.code || 'provider_error', err.message, {
|
|
224
|
+
predictionId: submitted.id,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const durationMs = Date.now() - started;
|
|
229
|
+
|
|
230
|
+
if (finalState._timedOut) {
|
|
231
|
+
return toolError('timeout', `prediction did not finish within ${timeoutMs}ms`, {
|
|
232
|
+
predictionId: submitted.id,
|
|
233
|
+
status: finalState.status,
|
|
234
|
+
resumeUrl: `${REPLICATE_BASE}/predictions/${submitted.id}`,
|
|
235
|
+
durationMs,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (finalState.status === 'failed' || finalState.status === 'canceled') {
|
|
240
|
+
return toolError(
|
|
241
|
+
'prediction_failed',
|
|
242
|
+
finalState.error || `prediction ended with status ${finalState.status}`,
|
|
243
|
+
{ predictionId: submitted.id, durationMs },
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const glbUrl = extractGlbUrl(finalState.output);
|
|
248
|
+
if (!glbUrl) {
|
|
249
|
+
return toolError('no_glb_in_output', 'prediction succeeded but no GLB url was found in output', {
|
|
250
|
+
rawOutput: finalState.output,
|
|
251
|
+
predictionId: submitted.id,
|
|
252
|
+
durationMs,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const rehost = await rehostIfRequested(glbUrl, { prompt, images });
|
|
257
|
+
const preview = `https://three.ws/viewer?src=${encodeURIComponent(glbUrl)}`;
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
predictionId: submitted.id,
|
|
262
|
+
glbUrl,
|
|
263
|
+
rehosted: rehost,
|
|
264
|
+
prompt: prompt || null,
|
|
265
|
+
images: images || null,
|
|
266
|
+
seed: typeof seed === 'number' ? seed : null,
|
|
267
|
+
model: version,
|
|
268
|
+
durationMs,
|
|
269
|
+
preview,
|
|
270
|
+
fetchedAt: new Date().toISOString(),
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
);
|
|
274
|
+
return {
|
|
275
|
+
name: TOOL_NAME,
|
|
276
|
+
title: 'Text → 3D avatar ($0.15)',
|
|
277
|
+
description: TOOL_DESCRIPTION,
|
|
278
|
+
inputSchema: inputZodShape,
|
|
279
|
+
// Creates a hosted GLB artifact via external generation APIs; destroys
|
|
280
|
+
// nothing, and every call mints a fresh asset.
|
|
281
|
+
annotations: {
|
|
282
|
+
readOnlyHint: false,
|
|
283
|
+
destructiveHint: false,
|
|
284
|
+
idempotentHint: false,
|
|
285
|
+
openWorldHint: true,
|
|
286
|
+
},
|
|
287
|
+
handler,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// `vanity_grinder` — paid MCP tool that brute-forces a Solana keypair whose
|
|
2
|
+
// base58 public key starts with a chosen prefix (and optionally ends with a
|
|
3
|
+
// chosen suffix).
|
|
4
|
+
//
|
|
5
|
+
// Pricing: flat `exact` price in USDC on Solana (default $0.05, override with
|
|
6
|
+
// MCP_VANITY_PRICE_USD). Metered/`upto` billing is not available on Solana —
|
|
7
|
+
// @x402/svm ships no `upto` scheme — so this tool charges a single flat fee
|
|
8
|
+
// regardless of attempt count.
|
|
9
|
+
//
|
|
10
|
+
// Output (over the same MCP channel — clients should treat as secret):
|
|
11
|
+
// - address (base58)
|
|
12
|
+
// - privateKey64 (base58, full 64-byte secret key as @solana/web3.js expects)
|
|
13
|
+
// - iterations + durationMs
|
|
14
|
+
//
|
|
15
|
+
// The grinder is the same async-yielding routine in api/_lib/pump-vanity.js
|
|
16
|
+
// (BASE58_ALPHABET, 6-char max pattern, lowercase ignoreCase support). It
|
|
17
|
+
// runs in-process — there is no external service.
|
|
18
|
+
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
import { paid } from '../payments.js';
|
|
22
|
+
import { jsonSchemaFromZod } from './_shared.js';
|
|
23
|
+
import { grindMintKeypair, estimateAttempts, BASE58_ALPHABET } from '../lib/pump-vanity.js';
|
|
24
|
+
import bs58 from 'bs58';
|
|
25
|
+
|
|
26
|
+
const TOOL_NAME = 'vanity_grinder';
|
|
27
|
+
|
|
28
|
+
// Flat price charged per successful grind, in USDC on Solana.
|
|
29
|
+
const PRICE_USD = process.env.MCP_VANITY_PRICE_USD?.trim() || '$0.05';
|
|
30
|
+
|
|
31
|
+
// Hard upper bound on grinder iterations. Each iteration is a synchronous
|
|
32
|
+
// ed25519 keypair generation; the loop yields periodically but still pins the
|
|
33
|
+
// single-threaded event loop while running. Cap it so a single paid call
|
|
34
|
+
// cannot monopolize the process. 1.5M attempts is enough to reliably find a
|
|
35
|
+
// 4-char prefix (~58^4 ≈ 11.3M expected, but most 1-3 char requests land far
|
|
36
|
+
// sooner); anything longer is rejected up front by the difficulty guard below.
|
|
37
|
+
const MAX_ITERATIONS_CAP = 1_500_000;
|
|
38
|
+
const DEFAULT_MAX_ITERATIONS = 1_500_000;
|
|
39
|
+
|
|
40
|
+
// Reject requests whose expected attempts dwarf the iteration cap rather than
|
|
41
|
+
// grinding to the cap and timing out. We allow a generous multiple of the cap
|
|
42
|
+
// (vanity grinding is probabilistic, so a request with expected ~= cap can
|
|
43
|
+
// still succeed), but a prefix that is, say, 10x harder than the cap will
|
|
44
|
+
// almost always time out and waste the caller's time and payment.
|
|
45
|
+
const DIFFICULTY_REJECT_MULTIPLE = 4;
|
|
46
|
+
|
|
47
|
+
const TOOL_DESCRIPTION =
|
|
48
|
+
`Generate a Solana keypair whose base58 address starts with a chosen prefix (and optionally ends with a chosen suffix). ` +
|
|
49
|
+
`SECURITY: the returned \`privateKey64\` is a REAL, fully-funded-capable private key. It transits the MCP channel in plaintext, ` +
|
|
50
|
+
`so the MCP host (Claude Desktop, Cursor, any proxy) MAY LOG the entire response. Treat the whole tool result as a secret, ` +
|
|
51
|
+
`import it into a wallet immediately, and never reuse a key that may have been logged. ` +
|
|
52
|
+
`Billed via x402 \`exact\` (flat ${PRICE_USD} USDC on Solana).`;
|
|
53
|
+
|
|
54
|
+
// Single source of truth: Zod shape carries descriptions + bounds; JSON Schema
|
|
55
|
+
// derived. Previously the JSON Schema and Zod both capped prefix/suffix at 6
|
|
56
|
+
// chars and maxIterations at MAX_ITERATIONS_CAP — kept identical here.
|
|
57
|
+
const inputZodShape = {
|
|
58
|
+
prefix: z
|
|
59
|
+
.string()
|
|
60
|
+
.min(1)
|
|
61
|
+
.max(6)
|
|
62
|
+
.describe(
|
|
63
|
+
`Base58 prefix the address must start with. Allowed chars: ${BASE58_ALPHABET}. 1-6 chars (longer prefixes are exponentially harder and are rejected).`,
|
|
64
|
+
),
|
|
65
|
+
suffix: z
|
|
66
|
+
.string()
|
|
67
|
+
.min(1)
|
|
68
|
+
.max(6)
|
|
69
|
+
.describe('Optional base58 suffix the address must end with. 1-6 chars.')
|
|
70
|
+
.optional(),
|
|
71
|
+
ignoreCase: z.boolean().describe('Case-insensitive match (folds upper+lower base58 chars).').optional(),
|
|
72
|
+
maxIterations: z
|
|
73
|
+
.number()
|
|
74
|
+
.int()
|
|
75
|
+
.min(1)
|
|
76
|
+
.max(MAX_ITERATIONS_CAP)
|
|
77
|
+
.describe(`Hard cap on grinder iterations. Default ${DEFAULT_MAX_ITERATIONS}, clamped to ${MAX_ITERATIONS_CAP}.`)
|
|
78
|
+
.optional(),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const inputJsonSchema = jsonSchemaFromZod(inputZodShape);
|
|
82
|
+
|
|
83
|
+
// Up-front difficulty guard. Returns null if the request is feasible within the
|
|
84
|
+
// iteration cap, or a helpful Error (status 400) if the requested prefix/suffix
|
|
85
|
+
// is so hard it would grind to the cap and time out. Exported for tests.
|
|
86
|
+
export function assertGrindFeasible({ prefix, suffix, ignoreCase = false, maxIterations = DEFAULT_MAX_ITERATIONS } = {}) {
|
|
87
|
+
const cap = Math.min(Math.max(1, Math.floor(maxIterations)), MAX_ITERATIONS_CAP);
|
|
88
|
+
const expected = estimateAttempts({ prefix, suffix, ignoreCase });
|
|
89
|
+
if (expected > cap * DIFFICULTY_REJECT_MULTIPLE) {
|
|
90
|
+
const pattern = [prefix ? `prefix '${prefix}'` : null, suffix ? `suffix '${suffix}'` : null]
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join(' + ');
|
|
93
|
+
return Object.assign(
|
|
94
|
+
new Error(
|
|
95
|
+
`Pattern too hard: ${pattern} needs ~${Math.round(expected).toLocaleString()} expected attempts, ` +
|
|
96
|
+
`but the grinder caps at ${cap.toLocaleString()} iterations. ` +
|
|
97
|
+
`Use a shorter prefix/suffix (each extra base58 char is ~${ignoreCase ? 33 : 58}x harder)` +
|
|
98
|
+
`${ignoreCase ? '' : ', or enable ignoreCase to roughly halve the difficulty per char'}.`,
|
|
99
|
+
),
|
|
100
|
+
{ status: 400, code: 'vanity_too_hard' },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function buildVanityGrinderTool() {
|
|
107
|
+
const handler = await paid(
|
|
108
|
+
{
|
|
109
|
+
toolName: TOOL_NAME,
|
|
110
|
+
description: TOOL_DESCRIPTION,
|
|
111
|
+
priceUsd: PRICE_USD,
|
|
112
|
+
inputSchema: inputJsonSchema,
|
|
113
|
+
example: { prefix: 'pump' },
|
|
114
|
+
outputExample: {
|
|
115
|
+
address: 'pumpXYZ...',
|
|
116
|
+
privateKey64: '5x...base58...',
|
|
117
|
+
iterations: 12345,
|
|
118
|
+
durationMs: 230,
|
|
119
|
+
priceUsd: PRICE_USD,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
async ({ prefix, suffix, ignoreCase = false, maxIterations = DEFAULT_MAX_ITERATIONS }) => {
|
|
123
|
+
// Clamp the caller-supplied cap into the safe range so a single paid
|
|
124
|
+
// call can never schedule millions of synchronous keypair generations.
|
|
125
|
+
const cap = Math.min(Math.max(1, Math.floor(maxIterations)), MAX_ITERATIONS_CAP);
|
|
126
|
+
|
|
127
|
+
// Reject impossible-to-satisfy patterns up front instead of grinding
|
|
128
|
+
// to the cap and timing out (event-loop DoS / wasted payment).
|
|
129
|
+
const infeasible = assertGrindFeasible({ prefix, suffix, ignoreCase, maxIterations: cap });
|
|
130
|
+
if (infeasible) throw infeasible;
|
|
131
|
+
|
|
132
|
+
const expected = estimateAttempts({ prefix, suffix, ignoreCase });
|
|
133
|
+
const grind = await grindMintKeypair({
|
|
134
|
+
prefix,
|
|
135
|
+
suffix,
|
|
136
|
+
ignoreCase,
|
|
137
|
+
maxIterations: cap,
|
|
138
|
+
});
|
|
139
|
+
const address = grind.keypair.publicKey.toBase58();
|
|
140
|
+
// @solana/web3.js Keypair.secretKey is the full 64-byte ed25519 secret
|
|
141
|
+
// (32-byte seed || 32-byte pubkey). Wallets like Phantom import this
|
|
142
|
+
// directly as base58. The client is responsible for storing it.
|
|
143
|
+
//
|
|
144
|
+
// NOTE: privateKey64 is intentionally NEVER passed to console.log/error
|
|
145
|
+
// anywhere in this path — the only sink is the structured tool result.
|
|
146
|
+
const privateKey64 = bs58.encode(Buffer.from(grind.keypair.secretKey));
|
|
147
|
+
return {
|
|
148
|
+
address,
|
|
149
|
+
privateKey64,
|
|
150
|
+
iterations: grind.iterations,
|
|
151
|
+
estimatedIterations: Math.round(expected),
|
|
152
|
+
durationMs: grind.durationMs,
|
|
153
|
+
prefix,
|
|
154
|
+
suffix: suffix || null,
|
|
155
|
+
ignoreCase,
|
|
156
|
+
priceUsd: PRICE_USD,
|
|
157
|
+
_secretWarning:
|
|
158
|
+
'privateKey64 is a REAL Solana private key. It just transited the MCP channel in plaintext, ' +
|
|
159
|
+
'so the MCP host (Claude Desktop / Cursor / any proxy) MAY have logged this entire response. ' +
|
|
160
|
+
'Import it into a wallet now, treat the whole result as a secret, and do not reuse a key you suspect was logged.',
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
return {
|
|
165
|
+
name: TOOL_NAME,
|
|
166
|
+
title: `Solana vanity grinder (${PRICE_USD})`,
|
|
167
|
+
description: TOOL_DESCRIPTION,
|
|
168
|
+
inputSchema: inputZodShape,
|
|
169
|
+
// Pure local compute with no external interaction, but output is a
|
|
170
|
+
// freshly-random keypair every call — never idempotent.
|
|
171
|
+
annotations: {
|
|
172
|
+
readOnlyHint: true,
|
|
173
|
+
idempotentHint: false,
|
|
174
|
+
openWorldHint: false,
|
|
175
|
+
},
|
|
176
|
+
handler,
|
|
177
|
+
};
|
|
178
|
+
}
|