@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,209 @@
|
|
|
1
|
+
// `ens_sns_resolve` — paid MCP tool that resolves a human-readable name
|
|
2
|
+
// to one or more on-chain addresses across ENS (Ethereum) and SNS (Solana).
|
|
3
|
+
//
|
|
4
|
+
// Pricing: $0.0005 USDC, settled `exact` in USDC on Solana mainnet.
|
|
5
|
+
//
|
|
6
|
+
// Resolution paths:
|
|
7
|
+
// - ENS (foo.eth, sub.foo.eth) → Ethereum mainnet via the configured
|
|
8
|
+
// RPC (MAINNET_RPC_URL or MCP_ENS_RPC_URL), with a 3-second timeout.
|
|
9
|
+
// Falls back to ethers' default public provider rotation.
|
|
10
|
+
// - SNS (foo.sol) → Solana via Bonfida sns-api.bonfida.com
|
|
11
|
+
// (the same source the three.ws site uses). Returns the owner wallet
|
|
12
|
+
// plus reverse-lookup of any other domains the wallet holds.
|
|
13
|
+
//
|
|
14
|
+
// Inputs accepting "foo" or "foo.eth/.sol" disambiguate via the suffix.
|
|
15
|
+
// Inputs ending in neither are tried against both — whichever resolves wins.
|
|
16
|
+
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
import { paid, toolError } from '../payments.js';
|
|
20
|
+
import { jsonSchemaFromZod } from './_shared.js';
|
|
21
|
+
import { resilientFetch } from '../lib/resilient-fetch.js';
|
|
22
|
+
import { makeEvmProvider } from '../lib/evm-rpc.js';
|
|
23
|
+
|
|
24
|
+
const TOOL_NAME = 'ens_sns_resolve';
|
|
25
|
+
const TOOL_DESCRIPTION =
|
|
26
|
+
"Resolve a human-readable name to addresses across ENS (Ethereum) and SNS (Solana). For .eth: returns Ethereum address via ethers. For .sol: returns Solana owner wallet via Bonfida SNS plus the wallet's other owned .sol domains. Names without a suffix are tried against both registries. Paid: $0.0005 USDC.";
|
|
27
|
+
|
|
28
|
+
const ENS_RE = /^(?:[a-z0-9-]+\.)*[a-z0-9-]+\.eth$/i;
|
|
29
|
+
const SOL_RE = /^[a-z0-9-]{1,63}(?:\.sol)?$/i;
|
|
30
|
+
const SNS_API = 'https://sns-api.bonfida.com';
|
|
31
|
+
|
|
32
|
+
function env(k, def) {
|
|
33
|
+
const v = process.env[k];
|
|
34
|
+
return v && String(v).trim() ? String(v).trim() : def;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function withTimeout(promise, ms, label) {
|
|
38
|
+
const timeout = new Promise((_, rej) =>
|
|
39
|
+
setTimeout(() => rej(new Error(`${label} timed out after ${ms}ms`)), ms),
|
|
40
|
+
);
|
|
41
|
+
return Promise.race([promise, timeout]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function resolveEns(name) {
|
|
45
|
+
// Ethereum mainnet (chainId 1) with endpoint failover: any operator override
|
|
46
|
+
// is tried first, then the built-in redundant public endpoints. Every RPC
|
|
47
|
+
// call is timeout-bounded inside the provider, and the outer withTimeout
|
|
48
|
+
// races the whole resolution as a final guard.
|
|
49
|
+
const overrides = [env('MCP_ENS_RPC_URL'), env('MAINNET_RPC_URL')].filter(Boolean);
|
|
50
|
+
const provider = makeEvmProvider(1, { overrides, timeoutMs: 8000 });
|
|
51
|
+
const rpcUrl = overrides[0] || 'failover:ethereum';
|
|
52
|
+
const address = await withTimeout(provider.resolveName(name), 4000, 'ens');
|
|
53
|
+
if (!address) return null;
|
|
54
|
+
let reverseName = null;
|
|
55
|
+
try {
|
|
56
|
+
reverseName = await withTimeout(provider.lookupAddress(address), 3000, 'ens-reverse');
|
|
57
|
+
} catch {
|
|
58
|
+
// reverse lookup is best-effort
|
|
59
|
+
}
|
|
60
|
+
return { network: 'ethereum', name, address, reverseName, rpc: rpcUrl || 'ethers-default' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function resolveSns(name) {
|
|
64
|
+
const bare = name.toLowerCase().replace(/\.sol$/, '');
|
|
65
|
+
if (!/^[a-z0-9-]{1,63}$/.test(bare)) return null;
|
|
66
|
+
const lookup = await resilientFetch(
|
|
67
|
+
`${SNS_API}/v2/domain/lookup/${bare}.sol`,
|
|
68
|
+
{},
|
|
69
|
+
{ timeoutMs: 6000, retries: 2, label: 'sns-lookup' },
|
|
70
|
+
).catch(() => null);
|
|
71
|
+
if (!lookup || !lookup.ok) return null;
|
|
72
|
+
const data = await lookup.json().catch(() => null);
|
|
73
|
+
const owner = data?.owner || data?.[bare + '.sol']?.owner || data?.data?.owner || null;
|
|
74
|
+
if (!owner) return null;
|
|
75
|
+
|
|
76
|
+
// Reverse fetch: other domains the owner holds.
|
|
77
|
+
let allDomains = [];
|
|
78
|
+
try {
|
|
79
|
+
const r = await resilientFetch(
|
|
80
|
+
`${SNS_API}/v2/user/domains/${owner}`,
|
|
81
|
+
{},
|
|
82
|
+
{ timeoutMs: 6000, retries: 1, label: 'sns-domains' },
|
|
83
|
+
);
|
|
84
|
+
if (r.ok) {
|
|
85
|
+
const body = await r.json();
|
|
86
|
+
const list = body?.[owner] || body?.data?.[owner] || [];
|
|
87
|
+
if (Array.isArray(list)) {
|
|
88
|
+
allDomains = list
|
|
89
|
+
.map((d) => (typeof d === 'string' ? d : d?.domain || d?.name))
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// best effort
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let favoriteDomain = null;
|
|
98
|
+
try {
|
|
99
|
+
const r = await resilientFetch(
|
|
100
|
+
`${SNS_API}/v2/user/fav-domains/${owner}`,
|
|
101
|
+
{},
|
|
102
|
+
{ timeoutMs: 6000, retries: 1, label: 'sns-fav' },
|
|
103
|
+
);
|
|
104
|
+
if (r.ok) {
|
|
105
|
+
const body = await r.json();
|
|
106
|
+
favoriteDomain = body?.[owner] || body?.data?.[owner] || null;
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// best effort
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
network: 'solana',
|
|
114
|
+
name: `${bare}.sol`,
|
|
115
|
+
address: owner,
|
|
116
|
+
favoriteDomain,
|
|
117
|
+
allDomains,
|
|
118
|
+
source: `${SNS_API}/v2/domain/lookup/${bare}.sol`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Single source of truth: Zod shape with description + bounds; JSON Schema derived.
|
|
123
|
+
const inputZodShape = {
|
|
124
|
+
name: z
|
|
125
|
+
.string()
|
|
126
|
+
.min(1)
|
|
127
|
+
.max(253)
|
|
128
|
+
.describe(
|
|
129
|
+
'Name to resolve (e.g. "vitalik.eth", "bonfida.sol", or bare "vitalik" which is tried in both).',
|
|
130
|
+
),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const inputJsonSchema = jsonSchemaFromZod(inputZodShape);
|
|
134
|
+
|
|
135
|
+
export async function buildEnsSnsResolveTool() {
|
|
136
|
+
const handler = await paid(
|
|
137
|
+
{
|
|
138
|
+
toolName: TOOL_NAME,
|
|
139
|
+
description: TOOL_DESCRIPTION,
|
|
140
|
+
scheme: 'exact',
|
|
141
|
+
priceUsd: '$0.0005',
|
|
142
|
+
inputSchema: inputJsonSchema,
|
|
143
|
+
example: { name: 'vitalik.eth' },
|
|
144
|
+
outputExample: {
|
|
145
|
+
ok: true,
|
|
146
|
+
input: 'vitalik.eth',
|
|
147
|
+
ens: {
|
|
148
|
+
network: 'ethereum',
|
|
149
|
+
name: 'vitalik.eth',
|
|
150
|
+
address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
|
|
151
|
+
reverseName: 'vitalik.eth',
|
|
152
|
+
},
|
|
153
|
+
sns: null,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
async ({ name }) => {
|
|
157
|
+
const trimmed = String(name || '')
|
|
158
|
+
.trim()
|
|
159
|
+
.toLowerCase();
|
|
160
|
+
const isEns = ENS_RE.test(trimmed);
|
|
161
|
+
const isSol = /\.sol$/.test(trimmed) || (!isEns && SOL_RE.test(trimmed));
|
|
162
|
+
|
|
163
|
+
const tasks = [];
|
|
164
|
+
if (isEns)
|
|
165
|
+
tasks.push([
|
|
166
|
+
'ens',
|
|
167
|
+
resolveEns(trimmed).catch((e) => ({ error: e?.message || 'ens failed' })),
|
|
168
|
+
]);
|
|
169
|
+
if (isSol)
|
|
170
|
+
tasks.push([
|
|
171
|
+
'sns',
|
|
172
|
+
resolveSns(trimmed).catch((e) => ({ error: e?.message || 'sns failed' })),
|
|
173
|
+
]);
|
|
174
|
+
if (!isEns && !isSol) {
|
|
175
|
+
return toolError(
|
|
176
|
+
'invalid_name',
|
|
177
|
+
'name does not look like a .eth, .sol, or bare label',
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const results = await Promise.all(tasks.map((t) => t[1]));
|
|
181
|
+
const out = { ok: false, input: trimmed, ens: null, sns: null };
|
|
182
|
+
tasks.forEach(([key], i) => {
|
|
183
|
+
out[key] = results[i] || null;
|
|
184
|
+
});
|
|
185
|
+
if (out.ens && !out.ens.error) out.ok = true;
|
|
186
|
+
if (out.sns && !out.sns.error) out.ok = true;
|
|
187
|
+
if (!out.ok) {
|
|
188
|
+
out.error = 'not_found';
|
|
189
|
+
out.message = 'name did not resolve in either ENS or SNS';
|
|
190
|
+
}
|
|
191
|
+
out.fetchedAt = new Date().toISOString();
|
|
192
|
+
return out;
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
return {
|
|
196
|
+
name: TOOL_NAME,
|
|
197
|
+
title: 'ENS + SNS resolve ($0.0005)',
|
|
198
|
+
description: TOOL_DESCRIPTION,
|
|
199
|
+
inputSchema: inputZodShape,
|
|
200
|
+
// Read-only external lookup. Not idempotent: name → address records can
|
|
201
|
+
// be re-pointed between calls.
|
|
202
|
+
annotations: {
|
|
203
|
+
readOnlyHint: true,
|
|
204
|
+
idempotentHint: false,
|
|
205
|
+
openWorldHint: true,
|
|
206
|
+
},
|
|
207
|
+
handler,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// `mesh_forge` — paid MCP tool: text → textured 3D GLB, as a model chain.
|
|
2
|
+
//
|
|
3
|
+
// Pricing: $0.25 USDC, settled `exact` on Solana.
|
|
4
|
+
//
|
|
5
|
+
// This is a thin, x402-gated client over the three.ws production pipeline — it
|
|
6
|
+
// does NOT hold any generation credentials itself. The npx-distributed MCP
|
|
7
|
+
// server can run anywhere; all GPU/LLM work happens on three.ws prod (which
|
|
8
|
+
// holds the Replicate / watsonx keys). The x402 USDC payment is what gates the
|
|
9
|
+
// call.
|
|
10
|
+
//
|
|
11
|
+
// Two modes:
|
|
12
|
+
// • text→3D — a chain of specialist models, each doing one job:
|
|
13
|
+
// 1. Prompt director (IBM Granite via /api/chat, provider=watsonx) —
|
|
14
|
+
// rewrites the caller's rough idea into an optimized 3D-generation spec
|
|
15
|
+
// (subject, style, materials, single-subject framing). Fail-soft: if
|
|
16
|
+
// the director is unreachable or disabled, the original prompt is
|
|
17
|
+
// forwarded unchanged and `directed:false` is reported — never faked.
|
|
18
|
+
// 2. Reference synthesis + reconstruction (/api/forge) — FLUX renders a
|
|
19
|
+
// clean reference image, then TRELLIS / Hunyuan3D reconstruct a
|
|
20
|
+
// textured GLB.
|
|
21
|
+
// • image→3D — a caller-supplied image_url is reconstructed directly
|
|
22
|
+
// (/api/forge with image_url); the prompt-director stage is skipped.
|
|
23
|
+
//
|
|
24
|
+
// Either mode returns a textured GLB URL and a three.ws viewer link. Rigging is
|
|
25
|
+
// a separate composable step — feed the returned glbUrl to the `rig_mesh` tool
|
|
26
|
+
// for a skeleton + skin weights (animation-ready) GLB.
|
|
27
|
+
//
|
|
28
|
+
// Environment (all optional — sensible prod defaults):
|
|
29
|
+
// MESH_FORGE_API_BASE — three.ws origin. Default https://three.ws
|
|
30
|
+
// MESH_FORGE_DIRECTOR — "0" to skip the Granite director stage. Default on.
|
|
31
|
+
// MESH_FORGE_DIRECTOR_MODEL — watsonx model id for direction. Default server default.
|
|
32
|
+
// MESH_FORGE_TIMEOUT_MS — overall reconstruct poll budget. Default 180000.
|
|
33
|
+
// MESH_FORGE_POLL_MS — poll interval. Default 3000.
|
|
34
|
+
|
|
35
|
+
import { z } from 'zod';
|
|
36
|
+
|
|
37
|
+
import { paid, toolError } from '../payments.js';
|
|
38
|
+
import { jsonSchemaFromZod } from './_shared.js';
|
|
39
|
+
|
|
40
|
+
const TOOL_NAME = 'mesh_forge';
|
|
41
|
+
const TOOL_DESCRIPTION =
|
|
42
|
+
'Generate a textured 3D GLB model from a text prompt, a single reference image, OR 2–4 reference views of the same object. In text mode, a chain of specialist models runs: an IBM Granite "prompt director" rewrites the prompt into an optimized 3D spec, FLUX renders a reference image, then Microsoft TRELLIS / Tencent Hunyuan3D reconstruct the mesh. In image mode, a supplied image_url is reconstructed directly. In multi-view mode, pass image_urls (1–4 angles such as front/back/left/right) and the backend fuses them for a higher-fidelity mesh with no hallucinated back. Returns the GLB URL, a three.ws viewer link, how many views were fused, which backend handled it, the directed prompt (text mode), and timing. Feed the GLB to rig_mesh for a rigged, animation-ready model. Paid: $0.25 USDC.';
|
|
43
|
+
|
|
44
|
+
const VALID_ASPECT = new Set(['1:1', '4:3', '3:4', '16:9', '9:16']);
|
|
45
|
+
|
|
46
|
+
function env(k, def) {
|
|
47
|
+
const v = process.env[k];
|
|
48
|
+
return v && String(v).trim() ? String(v).trim() : def;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function apiBase() {
|
|
52
|
+
return env('MESH_FORGE_API_BASE', 'https://three.ws').replace(/\/$/, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The director system instruction. Granite turns a loose idea into a tight,
|
|
56
|
+
// single-subject generation spec — the single biggest lever on mesh quality.
|
|
57
|
+
const DIRECTOR_INSTRUCTION =
|
|
58
|
+
'You are a 3D asset art director. Rewrite the user\'s idea into ONE concise prompt for a ' +
|
|
59
|
+
'text-to-3D generator. Describe a SINGLE isolated subject on a plain background, naming form, ' +
|
|
60
|
+
'materials, color, and surface detail. No scenes, no multiple objects, no text or logos, no ' +
|
|
61
|
+
'background environment. Output ONLY the rewritten prompt as a single line — no preamble, no quotes.';
|
|
62
|
+
|
|
63
|
+
// Drive the deployed /api/chat SSE endpoint with IBM Granite (provider=watsonx)
|
|
64
|
+
// to refine the prompt. Returns the refined prompt string, or null on any
|
|
65
|
+
// failure so the caller can fall back to the original prompt (fail-soft, never
|
|
66
|
+
// fabricated). watsonx is a server-side key on prod; an anonymous caller may
|
|
67
|
+
// request provider:"watsonx" explicitly per api/chat.js routing.
|
|
68
|
+
async function directPrompt(rawPrompt) {
|
|
69
|
+
const base = apiBase();
|
|
70
|
+
const model = env('MESH_FORGE_DIRECTOR_MODEL');
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetch(`${base}/api/chat`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
provider: 'watsonx',
|
|
78
|
+
...(model ? { model } : {}),
|
|
79
|
+
message: `${DIRECTOR_INSTRUCTION}\n\nIdea: ${rawPrompt}`,
|
|
80
|
+
}),
|
|
81
|
+
signal: AbortSignal.timeout(30_000),
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (!res.ok || !res.body) return null;
|
|
87
|
+
|
|
88
|
+
// Collect SSE `data: {type:'chunk',text}` events until `done`/stream end.
|
|
89
|
+
let acc = '';
|
|
90
|
+
try {
|
|
91
|
+
const reader = res.body.getReader();
|
|
92
|
+
const decoder = new TextDecoder();
|
|
93
|
+
let buf = '';
|
|
94
|
+
for (;;) {
|
|
95
|
+
const { value, done } = await reader.read();
|
|
96
|
+
if (done) break;
|
|
97
|
+
buf += decoder.decode(value, { stream: true });
|
|
98
|
+
const lines = buf.split('\n');
|
|
99
|
+
buf = lines.pop() ?? '';
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
if (!line.startsWith('data: ')) continue;
|
|
102
|
+
let evt;
|
|
103
|
+
try {
|
|
104
|
+
evt = JSON.parse(line.slice(6));
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (evt.type === 'chunk' && typeof evt.text === 'string') acc += evt.text;
|
|
109
|
+
else if (evt.type === 'error') return null;
|
|
110
|
+
else if (evt.type === 'done') {
|
|
111
|
+
if (typeof evt.text === 'string' && !acc) acc = evt.text;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const refined = acc.trim().replace(/^["']|["']$/g, '').split('\n')[0].trim();
|
|
120
|
+
return refined.length >= 3 && refined.length <= 1000 ? refined : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function startForge({ prompt, aspect, imageUrls }) {
|
|
124
|
+
const base = apiBase();
|
|
125
|
+
// Multi-view: send all supplied views as image_urls[]; the three.ws pipeline
|
|
126
|
+
// routes >1 view to a multi-view-capable backend (and reports a downgrade if
|
|
127
|
+
// the configured backend can't fuse them). Text mode sends just the prompt.
|
|
128
|
+
const payload =
|
|
129
|
+
Array.isArray(imageUrls) && imageUrls.length
|
|
130
|
+
? { image_urls: imageUrls, prompt: prompt || undefined, aspect_ratio: aspect }
|
|
131
|
+
: { prompt, aspect_ratio: aspect };
|
|
132
|
+
const res = await fetch(`${base}/api/forge`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'content-type': 'application/json' },
|
|
135
|
+
body: JSON.stringify(payload),
|
|
136
|
+
signal: AbortSignal.timeout(30_000),
|
|
137
|
+
});
|
|
138
|
+
const data = await res.json().catch(() => ({}));
|
|
139
|
+
if (res.status === 503) {
|
|
140
|
+
const e = new Error(data?.message || 'text-to-3D is not configured on the three.ws deployment');
|
|
141
|
+
e.code = 'not_configured';
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
if (res.status === 429) {
|
|
145
|
+
const e = new Error(data?.message || 'the 3D generator is busy; try again shortly');
|
|
146
|
+
e.code = 'rate_limited';
|
|
147
|
+
e.retryAfter = data?.retry_after;
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
if (!res.ok || !data?.job_id) {
|
|
151
|
+
const e = new Error(data?.message || `forge returned ${res.status}`);
|
|
152
|
+
e.code = 'provider_error';
|
|
153
|
+
throw e;
|
|
154
|
+
}
|
|
155
|
+
return data; // { job_id, creation_id, status, preview_image_url, ... }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function pollForge(jobId, { timeoutMs, intervalMs }) {
|
|
159
|
+
const base = apiBase();
|
|
160
|
+
const deadline = Date.now() + timeoutMs;
|
|
161
|
+
let last = null;
|
|
162
|
+
while (Date.now() < deadline) {
|
|
163
|
+
let res;
|
|
164
|
+
try {
|
|
165
|
+
res = await fetch(`${base}/api/forge?job=${encodeURIComponent(jobId)}`, {
|
|
166
|
+
headers: { accept: 'application/json' },
|
|
167
|
+
signal: AbortSignal.timeout(Math.max(intervalMs * 3, 15_000)),
|
|
168
|
+
});
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (err?.name === 'AbortError' || err?.name === 'TimeoutError') {
|
|
171
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const e = new Error(`forge poll failed: ${err?.message || err}`);
|
|
175
|
+
e.code = 'provider_error';
|
|
176
|
+
throw e;
|
|
177
|
+
}
|
|
178
|
+
const data = await res.json().catch(() => ({}));
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
const e = new Error(data?.message || `forge poll returned ${res.status}`);
|
|
181
|
+
e.code = 'provider_error';
|
|
182
|
+
throw e;
|
|
183
|
+
}
|
|
184
|
+
last = data;
|
|
185
|
+
if (data.status === 'done' && data.glb_url) return data;
|
|
186
|
+
if (data.status === 'failed') {
|
|
187
|
+
const e = new Error(data.error || 'generation failed');
|
|
188
|
+
e.code = 'generation_failed';
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
192
|
+
}
|
|
193
|
+
return { ...(last || {}), _timedOut: true };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const inputZodShape = {
|
|
197
|
+
prompt: z
|
|
198
|
+
.string()
|
|
199
|
+
.min(3)
|
|
200
|
+
.max(1000)
|
|
201
|
+
.describe('Text→3D: natural-language description of the single object to model, e.g. "a worn leather armchair". Optional when image_url is provided (then used only as guidance).')
|
|
202
|
+
.optional(),
|
|
203
|
+
image_url: z
|
|
204
|
+
.string()
|
|
205
|
+
.url()
|
|
206
|
+
.describe('Image→3D: an http(s) URL to a reference image to reconstruct directly. When set, the prompt-director and text-to-image stages are skipped.')
|
|
207
|
+
.optional(),
|
|
208
|
+
image_urls: z
|
|
209
|
+
.array(z.string().url())
|
|
210
|
+
.min(1)
|
|
211
|
+
.max(4)
|
|
212
|
+
.describe('Multi-view → 3D: 1–4 http(s) URLs of the SAME object from different angles (e.g. front, back, left, right). More than one view enables multi-view reconstruction, which removes the back-of-object guesswork of single-image reconstruction. Takes precedence over image_url.')
|
|
213
|
+
.optional(),
|
|
214
|
+
aspect_ratio: z
|
|
215
|
+
.enum(['1:1', '4:3', '3:4', '16:9', '9:16'])
|
|
216
|
+
.describe('Reference image aspect ratio (text mode). Default 1:1 (best for isolated objects).')
|
|
217
|
+
.optional(),
|
|
218
|
+
direct: z
|
|
219
|
+
.boolean()
|
|
220
|
+
.describe('Run the IBM Granite prompt-director stage to optimize the prompt before generation (text mode only). Default true.')
|
|
221
|
+
.optional(),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const inputJsonSchema = jsonSchemaFromZod(inputZodShape);
|
|
225
|
+
|
|
226
|
+
export async function buildMeshForgeTool() {
|
|
227
|
+
const handler = await paid(
|
|
228
|
+
{
|
|
229
|
+
toolName: TOOL_NAME,
|
|
230
|
+
description: TOOL_DESCRIPTION,
|
|
231
|
+
scheme: 'exact',
|
|
232
|
+
priceUsd: '$0.25',
|
|
233
|
+
inputSchema: inputJsonSchema,
|
|
234
|
+
example: { prompt: 'a worn leather armchair, brass studs', aspect_ratio: '1:1' },
|
|
235
|
+
outputExample: {
|
|
236
|
+
ok: true,
|
|
237
|
+
glbUrl: 'https://three.ws/cdn/creations/abc123/mesh.glb',
|
|
238
|
+
preview: 'https://three.ws/viewer?src=https%3A%2F%2Fthree.ws%2Fcdn%2F...',
|
|
239
|
+
prompt: 'a worn leather armchair, brass studs',
|
|
240
|
+
imageUrls: null,
|
|
241
|
+
viewsRequested: 0,
|
|
242
|
+
viewsUsed: 1,
|
|
243
|
+
multiview: false,
|
|
244
|
+
backend: 'replicate',
|
|
245
|
+
directedPrompt: 'A single worn brown leather wingback armchair with brass stud trim...',
|
|
246
|
+
directed: true,
|
|
247
|
+
jobId: 'k7m2q9x4',
|
|
248
|
+
creationId: 'abc123',
|
|
249
|
+
referenceImageUrl: 'https://replicate.delivery/.../ref.png',
|
|
250
|
+
durationMs: 96000,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
async ({ prompt, image_url, image_urls, aspect_ratio, direct }) => {
|
|
254
|
+
const trimmedPrompt = typeof prompt === 'string' ? prompt.trim() : '';
|
|
255
|
+
|
|
256
|
+
// Merge the multi-view array form with the legacy single image_url,
|
|
257
|
+
// de-duplicating while preserving order. image_urls wins when present.
|
|
258
|
+
const rawViews = Array.isArray(image_urls)
|
|
259
|
+
? image_urls
|
|
260
|
+
: typeof image_url === 'string'
|
|
261
|
+
? [image_url]
|
|
262
|
+
: [];
|
|
263
|
+
const seenViews = new Set();
|
|
264
|
+
const views = [];
|
|
265
|
+
for (const v of rawViews) {
|
|
266
|
+
if (typeof v !== 'string') continue;
|
|
267
|
+
const t = v.trim();
|
|
268
|
+
if (!t || seenViews.has(t)) continue;
|
|
269
|
+
seenViews.add(t);
|
|
270
|
+
views.push(t);
|
|
271
|
+
}
|
|
272
|
+
if (views.length > 4) {
|
|
273
|
+
return toolError('invalid_input', 'Provide between 1 and 4 reference images.');
|
|
274
|
+
}
|
|
275
|
+
const imageMode = views.length > 0;
|
|
276
|
+
if (!imageMode && trimmedPrompt.length < 3) {
|
|
277
|
+
return toolError(
|
|
278
|
+
'invalid_input',
|
|
279
|
+
'Provide a prompt (3+ chars) for text→3D, or 1–4 image_urls for image/multi-view→3D.',
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const aspect = VALID_ASPECT.has(aspect_ratio) ? aspect_ratio : '1:1';
|
|
283
|
+
const started = Date.now();
|
|
284
|
+
|
|
285
|
+
// Stage 1 — Granite prompt director (text mode only; fail-soft, opt-out).
|
|
286
|
+
const runDirector =
|
|
287
|
+
!imageMode && trimmedPrompt && direct !== false && env('MESH_FORGE_DIRECTOR', '1') !== '0';
|
|
288
|
+
let directedPrompt = null;
|
|
289
|
+
if (runDirector) directedPrompt = await directPrompt(trimmedPrompt);
|
|
290
|
+
const effectivePrompt = directedPrompt || trimmedPrompt;
|
|
291
|
+
|
|
292
|
+
// Stage 2 — submit reference synthesis + reconstruction (or direct
|
|
293
|
+
// image reconstruction when an image_url was supplied).
|
|
294
|
+
let job;
|
|
295
|
+
try {
|
|
296
|
+
job = await startForge({
|
|
297
|
+
prompt: effectivePrompt || undefined,
|
|
298
|
+
aspect,
|
|
299
|
+
imageUrls: imageMode ? views : undefined,
|
|
300
|
+
});
|
|
301
|
+
} catch (err) {
|
|
302
|
+
return toolError(err.code || 'provider_error', err.message, {
|
|
303
|
+
...(err.retryAfter ? { retryAfter: err.retryAfter } : {}),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Stage 3 — poll to a terminal state. 3D reconstruction is slow; budget
|
|
308
|
+
// generously but bound it so the caller never hangs forever.
|
|
309
|
+
const timeoutMs = Number(env('MESH_FORGE_TIMEOUT_MS', '180000'));
|
|
310
|
+
const intervalMs = Number(env('MESH_FORGE_POLL_MS', '3000'));
|
|
311
|
+
let final;
|
|
312
|
+
try {
|
|
313
|
+
final = await pollForge(job.job_id, { timeoutMs, intervalMs });
|
|
314
|
+
} catch (err) {
|
|
315
|
+
return toolError(err.code || 'provider_error', err.message, {
|
|
316
|
+
jobId: job.job_id,
|
|
317
|
+
creationId: job.creation_id ?? null,
|
|
318
|
+
durationMs: Date.now() - started,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const durationMs = Date.now() - started;
|
|
323
|
+
|
|
324
|
+
if (final._timedOut) {
|
|
325
|
+
return toolError('timeout', `reconstruction did not finish within ${timeoutMs}ms`, {
|
|
326
|
+
jobId: job.job_id,
|
|
327
|
+
creationId: job.creation_id ?? null,
|
|
328
|
+
status: final.status || 'running',
|
|
329
|
+
resumeUrl: `${apiBase()}/api/forge?job=${job.job_id}`,
|
|
330
|
+
durationMs,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const glbUrl = final.glb_url;
|
|
335
|
+
const preview = `${apiBase()}/viewer?src=${encodeURIComponent(glbUrl)}`;
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
ok: true,
|
|
339
|
+
mode: imageMode ? 'image_to_3d' : 'text_to_3d',
|
|
340
|
+
glbUrl,
|
|
341
|
+
preview,
|
|
342
|
+
prompt: trimmedPrompt || null,
|
|
343
|
+
imageUrl: imageMode ? views[0] : null,
|
|
344
|
+
imageUrls: imageMode ? views : null,
|
|
345
|
+
viewsRequested: imageMode ? views.length : 0,
|
|
346
|
+
// How the backend actually conditioned the mesh — surfaced from the
|
|
347
|
+
// submit + poll responses so a multi-view downgrade is never silent.
|
|
348
|
+
viewsUsed:
|
|
349
|
+
(typeof final.views_used === 'number' ? final.views_used : job.views_used) ?? null,
|
|
350
|
+
multiview: (final.multiview ?? job.multiview) ?? null,
|
|
351
|
+
backend: (final.backend ?? job.backend) ?? null,
|
|
352
|
+
directedPrompt: directedPrompt || null,
|
|
353
|
+
directed: Boolean(directedPrompt),
|
|
354
|
+
jobId: job.job_id,
|
|
355
|
+
creationId: final.creation_id ?? job.creation_id ?? null,
|
|
356
|
+
referenceImageUrl: job.preview_image_url ?? null,
|
|
357
|
+
durable: Boolean(final.durable),
|
|
358
|
+
durationMs,
|
|
359
|
+
fetchedAt: new Date().toISOString(),
|
|
360
|
+
};
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
name: TOOL_NAME,
|
|
366
|
+
title: 'Text → 3D mesh ($0.25)',
|
|
367
|
+
description: TOOL_DESCRIPTION,
|
|
368
|
+
inputSchema: inputZodShape,
|
|
369
|
+
// Creates a hosted mesh artifact via external generation APIs; destroys
|
|
370
|
+
// nothing, and every call mints a fresh asset.
|
|
371
|
+
annotations: {
|
|
372
|
+
readOnlyHint: false,
|
|
373
|
+
destructiveHint: false,
|
|
374
|
+
idempotentHint: false,
|
|
375
|
+
openWorldHint: true,
|
|
376
|
+
},
|
|
377
|
+
handler,
|
|
378
|
+
};
|
|
379
|
+
}
|