@three-ws/avatar-agent 1.0.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,60 @@
1
+ // `spawn_avatar` — create an in-process avatar session, either from a
2
+ // curated default (default / cz) or from a custom GLB URL. Returns a
3
+ // session id that other tools reference (dress_avatar, viewer_url, etc.).
4
+
5
+ import { z } from 'zod';
6
+
7
+ import { createSession, findAvatar, viewerUrlFor } from '../lib/avatars.js';
8
+
9
+ export const def = {
10
+ name: 'spawn_avatar',
11
+ title: 'Spawn a 3D avatar session',
12
+ description:
13
+ 'Spawn a 3D avatar from a curated default (preset="default" or "cz") or from a custom GLB URL. Returns a sessionId other tools (dress_avatar, viewer_url, speak) reference, plus the avatar GLB URL and a ready-to-open three.ws viewer link.',
14
+ inputSchema: {
15
+ preset: z.enum(['default', 'cz']).optional()
16
+ .describe('Pick a curated default avatar.'),
17
+ glbUrl: z.string().url().optional()
18
+ .describe('Custom GLB URL — overrides preset.'),
19
+ name: z.string().max(80).optional()
20
+ .describe('Display name for the avatar persona.'),
21
+ voice: z
22
+ .enum(['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer', 'verse'])
23
+ .optional()
24
+ .describe('OpenAI TTS voice the speak tool should use for this session.'),
25
+ persona: z.string().max(500).optional()
26
+ .describe('Short persona/personality blurb (used by callers; not enforced server-side).'),
27
+ },
28
+ async handler(args) {
29
+ const { preset, glbUrl, name, voice, persona } = args || {};
30
+ if (!preset && !glbUrl) {
31
+ return {
32
+ ok: false,
33
+ error: 'invalid_input',
34
+ message: 'Provide either preset ("default" / "cz") or glbUrl.',
35
+ };
36
+ }
37
+ let glb;
38
+ if (glbUrl) {
39
+ glb = glbUrl;
40
+ } else {
41
+ const a = findAvatar(preset);
42
+ if (!a) {
43
+ return { ok: false, error: 'unknown_preset', message: `No default avatar named "${preset}". Call list_avatars.` };
44
+ }
45
+ glb = a.glb;
46
+ }
47
+ const session = createSession({ glb, name, voice, persona });
48
+ return {
49
+ ok: true,
50
+ sessionId: session.id,
51
+ avatar: session.avatar,
52
+ name: session.name,
53
+ voice: session.voice,
54
+ persona: session.persona,
55
+ pose: session.pose,
56
+ viewerUrl: viewerUrlFor(session),
57
+ createdAt: session.createdAt,
58
+ };
59
+ },
60
+ };
@@ -0,0 +1,92 @@
1
+ // `speak` — synthesize audio for the avatar via OpenAI's text-to-speech
2
+ // API. Returns the audio as a base64 data URL by default so clients can
3
+ // embed it directly. The avatar's voice (set when spawn_avatar runs) is
4
+ // used unless explicitly overridden.
5
+ //
6
+ // Requires OPENAI_API_KEY in the MCP server's environment.
7
+
8
+ import { z } from 'zod';
9
+
10
+ import { OPENAI_API_KEY } from '../config.js';
11
+ import { getSession, updateSession } from '../lib/avatars.js';
12
+
13
+ const VOICES = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer', 'verse'];
14
+ const MODELS = ['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts'];
15
+ const FORMATS = {
16
+ mp3: 'audio/mpeg',
17
+ opus: 'audio/ogg',
18
+ aac: 'audio/aac',
19
+ flac: 'audio/flac',
20
+ wav: 'audio/wav',
21
+ pcm: 'audio/pcm',
22
+ };
23
+
24
+ export const def = {
25
+ name: 'speak',
26
+ title: 'Avatar speaks (OpenAI TTS)',
27
+ description:
28
+ 'Synthesize speech for an avatar session via OpenAI TTS and return a base64 audio data URL the client can play. Picks the session\'s configured voice unless overridden. Requires OPENAI_API_KEY on the MCP server.',
29
+ inputSchema: {
30
+ sessionId: z.string().optional()
31
+ .describe('Avatar session id (optional — when omitted, voice falls back to the override or "nova").'),
32
+ text: z.string().min(1).max(4096).describe('Text the avatar should say.'),
33
+ voice: z.enum(VOICES).optional().describe('Override the session voice for this call.'),
34
+ model: z.enum(MODELS).optional().describe('TTS model (default gpt-4o-mini-tts).'),
35
+ format: z.enum(Object.keys(FORMATS)).optional().describe('Audio format (default mp3).'),
36
+ speed: z.number().min(0.5).max(2.0).optional().describe('Playback speed multiplier.'),
37
+ },
38
+ async handler(args) {
39
+ if (!OPENAI_API_KEY) {
40
+ return {
41
+ ok: false,
42
+ error: 'not_configured',
43
+ message: 'OPENAI_API_KEY is not set on the MCP server. Set it to enable TTS.',
44
+ };
45
+ }
46
+ const { sessionId, text } = args || {};
47
+ const session = sessionId ? getSession(sessionId) : null;
48
+ if (sessionId && !session) {
49
+ return { ok: false, error: 'unknown_session', message: `No session ${sessionId}.` };
50
+ }
51
+ const voice = args.voice || session?.voice || 'nova';
52
+ const model = args.model || 'gpt-4o-mini-tts';
53
+ const format = args.format || 'mp3';
54
+ const speed = typeof args.speed === 'number' ? args.speed : 1.0;
55
+ const mime = FORMATS[format];
56
+
57
+ const t0 = Date.now();
58
+ const r = await fetch('https://api.openai.com/v1/audio/speech', {
59
+ method: 'POST',
60
+ headers: {
61
+ authorization: `Bearer ${OPENAI_API_KEY}`,
62
+ 'content-type': 'application/json',
63
+ },
64
+ body: JSON.stringify({ input: text, voice, model, response_format: format, speed }),
65
+ });
66
+ if (!r.ok) {
67
+ const errText = await r.text().catch(() => '');
68
+ return {
69
+ ok: false,
70
+ error: 'tts_failed',
71
+ status: r.status,
72
+ message: errText.slice(0, 500),
73
+ };
74
+ }
75
+ const buf = Buffer.from(await r.arrayBuffer());
76
+ const base64 = buf.toString('base64');
77
+ const dataUrl = `data:${mime};base64,${base64}`;
78
+ if (session) updateSession(session.id, { lastSpoken: text.slice(0, 200) });
79
+ return {
80
+ ok: true,
81
+ sessionId: session?.id || null,
82
+ voice,
83
+ model,
84
+ format,
85
+ mime,
86
+ sizeBytes: buf.length,
87
+ durationMs: Date.now() - t0,
88
+ audio: dataUrl,
89
+ text,
90
+ };
91
+ },
92
+ };
@@ -0,0 +1,35 @@
1
+ // `thumbnail_glb` — render any public GLB URL to a PNG via three.ws's
2
+ // hosted headless-chromium pipeline (the same three-light rig and
3
+ // bounding-box framing that powers three.ws OG cards).
4
+ //
5
+ // Returns a base64 PNG data URL the client can display inline, plus
6
+ // width/height and the upstream endpoint that produced it.
7
+ //
8
+ // This is the single most-requested capability for any 3D MCP — without
9
+ // it the agent is "blind" to how a model actually looks.
10
+
11
+ import { z } from 'zod';
12
+
13
+ import { renderGlbThumbnail } from '../lib/render.js';
14
+
15
+ export const def = {
16
+ name: 'thumbnail_glb',
17
+ title: 'Render a GLB to a PNG thumbnail',
18
+ description:
19
+ 'Render any public GLB URL to a PNG via three.ws\'s hosted three-light rig + auto-framing camera. Returns the PNG inline as a base64 data URL (≤ ~4 MB) plus dimensions. Background defaults to #0a0a0a — pass "transparent" for compositing.',
20
+ inputSchema: {
21
+ glbUrl: z.string().url().describe('Public http(s) URL of a .glb file.'),
22
+ width: z.number().int().min(64).max(2048).optional().describe('Output width in pixels (default 1024).'),
23
+ height: z.number().int().min(64).max(2048).optional().describe('Output height in pixels (default 1024).'),
24
+ background: z.string().optional().describe('CSS color (e.g. "#0a0a0a") or "transparent". Default "#0a0a0a".'),
25
+ },
26
+ async handler(args) {
27
+ const { glbUrl, width, height, background } = args || {};
28
+ if (!glbUrl) return { ok: false, error: 'invalid_input', message: 'glbUrl is required' };
29
+ const out = await renderGlbThumbnail({ glbUrl, width, height, background });
30
+ return {
31
+ ...out,
32
+ glbUrl,
33
+ };
34
+ },
35
+ };
@@ -0,0 +1,90 @@
1
+ // `validate_glb` — run Khronos's official `gltf-validator` against any
2
+ // GLB URL and surface its findings (errors, warnings, infos, hints) with
3
+ // JSON-pointer locations and severity codes.
4
+ //
5
+ // This is the same validator the glTF Viewer (gltf.report) uses. It
6
+ // checks the entire spec compliance surface: accessor bounds, animation
7
+ // channel targets, image data integrity, extension usage, etc.
8
+
9
+ import { z } from 'zod';
10
+ import validator from 'gltf-validator';
11
+
12
+ import { fetchGlbBytes } from '../lib/glb-io.js';
13
+
14
+ const SEVERITIES = ['error', 'warning', 'info', 'hint'];
15
+
16
+ function classify(messages = []) {
17
+ const buckets = { error: [], warning: [], info: [], hint: [] };
18
+ for (const m of messages) {
19
+ const sev = SEVERITIES[m.severity] || 'info';
20
+ buckets[sev].push({
21
+ code: m.code,
22
+ message: m.message,
23
+ pointer: m.pointer || null,
24
+ offset: m.offset ?? null,
25
+ });
26
+ }
27
+ return buckets;
28
+ }
29
+
30
+ export const def = {
31
+ name: 'validate_glb',
32
+ title: 'Validate a GLB / glTF against the Khronos spec',
33
+ description:
34
+ 'Run the official Khronos gltf-validator against a GLB URL. Returns errors, warnings, infos, and hints with codes + JSON pointers, plus structural counts (animations, materials, etc.) per the validator\'s info report. The same engine that powers gltf.report.',
35
+ inputSchema: {
36
+ url: z.string().describe('Public URL or data: URL of a .glb / .gltf file to validate.'),
37
+ maxIssues: z.number().int().min(1).max(2000).optional()
38
+ .describe('Cap on issues returned in each bucket (default 100). Validator may still scan all of them.'),
39
+ },
40
+ async handler(args) {
41
+ const { url } = args || {};
42
+ if (!url) return { ok: false, error: 'invalid_input', message: 'url is required' };
43
+ const cap = args?.maxIssues || 100;
44
+ let bytes;
45
+ try {
46
+ bytes = await fetchGlbBytes(url);
47
+ } catch (err) {
48
+ return { ok: false, error: 'fetch_failed', message: err.message };
49
+ }
50
+
51
+ let report;
52
+ try {
53
+ report = await validator.validateBytes(new Uint8Array(bytes), {
54
+ uri: url,
55
+ maxIssues: 2000,
56
+ externalResourceFunction: async (resourceUri) => {
57
+ // Fetch buffer/image references that live outside the GLB (rare
58
+ // for .glb because everything's embedded, but supported here
59
+ // for .gltf inputs).
60
+ const r = await fetch(resourceUri);
61
+ if (!r.ok) throw new Error(`external resource HTTP ${r.status}: ${resourceUri}`);
62
+ return new Uint8Array(await r.arrayBuffer());
63
+ },
64
+ });
65
+ } catch (err) {
66
+ return { ok: false, error: 'validator_failed', message: err.message };
67
+ }
68
+
69
+ const buckets = classify(report.issues?.messages || []);
70
+ for (const k of SEVERITIES) buckets[k] = buckets[k].slice(0, cap);
71
+
72
+ return {
73
+ ok: true,
74
+ url,
75
+ sizeBytes: bytes.byteLength,
76
+ validatorVersion: report.validatorVersion || null,
77
+ mimeType: report.mimeType || null,
78
+ validated: report.validatedAt || new Date().toISOString(),
79
+ summary: {
80
+ numErrors: report.issues?.numErrors ?? 0,
81
+ numWarnings: report.issues?.numWarnings ?? 0,
82
+ numInfos: report.issues?.numInfos ?? 0,
83
+ numHints: report.issues?.numHints ?? 0,
84
+ truncated: !!report.issues?.truncated,
85
+ },
86
+ info: report.info || null,
87
+ issues: buckets,
88
+ };
89
+ },
90
+ };
@@ -0,0 +1,124 @@
1
+ // `viewer_url` — build a shareable three.ws viewer URL for any GLB plus
2
+ // optional embed snippet. Exposes the full set of viewer query params
3
+ // the three.ws web viewer respects: background, auto-rotate, camera
4
+ // preset OR explicit orbit string, AR mode, dimensions, pose, and
5
+ // accessory overlays.
6
+ //
7
+ // Supports an avatar sessionId (uses the session's GLB + accessories +
8
+ // pose as defaults) or a raw GLB URL. No fetch — pure URL composition
9
+ // plus an iframe snippet for sites that want to embed.
10
+
11
+ import { z } from 'zod';
12
+
13
+ import { VIEWER_BASE, THREE_WS_BASE } from '../config.js';
14
+ import { findAccessory, getSession } from '../lib/avatars.js';
15
+
16
+ function escAttr(s) {
17
+ return String(s).replace(/&/g, '&').replace(/"/g, '"');
18
+ }
19
+
20
+ export const def = {
21
+ name: 'viewer_url',
22
+ title: 'Build a three.ws viewer URL + embed snippet',
23
+ description:
24
+ 'Build a shareable https://three.ws/viewer?... URL that opens any GLB in three.ws\'s WebGL viewer, plus a ready-to-paste iframe snippet. Supports background, auto-rotate, camera preset OR explicit camera orbit, AR mode (model-viewer), dimensions, pose, and accessory overlay. Accepts a raw glbUrl or a sessionId from spawn_avatar.',
25
+ inputSchema: {
26
+ glbUrl: z.string().url().optional().describe('Direct GLB URL. Required if sessionId is omitted.'),
27
+ sessionId: z.string().optional().describe('Avatar sessionId returned by spawn_avatar. Overrides glbUrl.'),
28
+ pose: z.string().optional().describe('Pose preset id (e.g. "wave", "tpose"). Call list_animations for the catalog.'),
29
+ accessoryIds: z.array(z.string()).optional().describe('Accessory ids to attach in the viewer.'),
30
+ background: z.string().optional()
31
+ .describe('Background color or gradient (CSS), e.g. "#0a0a0a", "linear-gradient(...)", or "transparent".'),
32
+ autoRotate: z.boolean().optional().describe('Auto-rotate the model. Default true on the web viewer.'),
33
+ ar: z.boolean().optional().describe('Enable model-viewer AR buttons on iOS/Android.'),
34
+ cameraPreset: z.enum(['front', 'three-quarter', 'side', 'back', 'top', 'closeup']).optional()
35
+ .describe('Named camera framing preset.'),
36
+ cameraOrbit: z.string().optional()
37
+ .describe('Explicit camera orbit in model-viewer syntax, e.g. "0deg 80deg 2m". Overrides cameraPreset.'),
38
+ cameraDistance: z.number().positive().optional().describe('Distance multiplier shorthand (1.0 = default). Used when cameraOrbit is absent.'),
39
+ cameraAngleDeg: z.number().optional().describe('Yaw in degrees shorthand. Used when cameraOrbit is absent.'),
40
+ width: z.number().int().min(64).max(4096).optional().describe('Viewer width in pixels (for the iframe snippet).'),
41
+ height: z.number().int().min(64).max(4096).optional().describe('Viewer height in pixels.'),
42
+ thumbnailUrl: z.string().url().optional().describe('Optional thumbnail to show in the iframe before the GLB loads.'),
43
+ },
44
+ async handler(args) {
45
+ const {
46
+ sessionId, glbUrl, pose, accessoryIds, background, autoRotate, ar,
47
+ cameraPreset, cameraOrbit, cameraDistance, cameraAngleDeg,
48
+ width, height, thumbnailUrl,
49
+ } = args || {};
50
+
51
+ let resolvedGlb = glbUrl;
52
+ let resolvedAccessories = accessoryIds;
53
+ let resolvedPose = pose;
54
+ let session = null;
55
+ if (sessionId) {
56
+ session = getSession(sessionId);
57
+ if (!session) return { ok: false, error: 'unknown_session', message: `No session ${sessionId}.` };
58
+ resolvedGlb = session.avatar.glb;
59
+ if (!resolvedAccessories) resolvedAccessories = session.accessories.map((a) => a.id);
60
+ if (!resolvedPose) resolvedPose = session.pose;
61
+ }
62
+ if (!resolvedGlb) return { ok: false, error: 'invalid_input', message: 'Pass glbUrl or sessionId.' };
63
+
64
+ const params = new URLSearchParams({ src: resolvedGlb });
65
+
66
+ // Accessories — dedup + verify against the catalog.
67
+ if (Array.isArray(resolvedAccessories) && resolvedAccessories.length) {
68
+ const seen = new Set();
69
+ const verified = [];
70
+ for (const id of resolvedAccessories) {
71
+ if (seen.has(id)) continue;
72
+ seen.add(id);
73
+ const acc = findAccessory(id);
74
+ verified.push(acc ? acc.id : id);
75
+ }
76
+ params.set('accessories', verified.join(','));
77
+ }
78
+ if (resolvedPose && resolvedPose !== 'idle') params.set('pose', resolvedPose);
79
+
80
+ // Camera — explicit orbit wins; fallback to preset; fallback to
81
+ // the dist/yaw shorthand for backward compatibility.
82
+ if (cameraOrbit) {
83
+ params.set('camera', cameraOrbit);
84
+ } else if (cameraPreset) {
85
+ params.set('camera', cameraPreset);
86
+ } else {
87
+ if (typeof cameraDistance === 'number') params.set('camDist', String(cameraDistance));
88
+ if (typeof cameraAngleDeg === 'number') params.set('camYaw', String(cameraAngleDeg));
89
+ }
90
+
91
+ if (background) params.set('background', background);
92
+ if (autoRotate === false) params.set('auto_rotate', '0');
93
+ if (autoRotate === true) params.set('auto_rotate', '1');
94
+ if (ar) params.set('ar', '1');
95
+ if (width) params.set('width', String(width));
96
+ if (height) params.set('height', String(height));
97
+
98
+ const viewerUrl = `${VIEWER_BASE}?${params.toString()}`;
99
+ const iframeWidth = width || 800;
100
+ const iframeHeight = height || 800;
101
+ const iframeSnippet =
102
+ `<iframe src="${escAttr(viewerUrl)}" width="${iframeWidth}" height="${iframeHeight}" ` +
103
+ `style="border:0;border-radius:12px;background:#0a0a0a" allowfullscreen ` +
104
+ `allow="autoplay;fullscreen;xr-spatial-tracking" loading="lazy"></iframe>`;
105
+
106
+ return {
107
+ ok: true,
108
+ viewerUrl,
109
+ iframeSnippet,
110
+ glb: resolvedGlb,
111
+ sessionId: session?.id || null,
112
+ pose: resolvedPose || null,
113
+ accessories: Array.isArray(resolvedAccessories) ? resolvedAccessories : null,
114
+ cameraOrbit: cameraOrbit || (cameraPreset ? `preset:${cameraPreset}` : null),
115
+ background: background || null,
116
+ ar: !!ar,
117
+ thumbnailUrl: thumbnailUrl || null,
118
+ openGraph: {
119
+ image: `${THREE_WS_BASE}/api/avatar-og?src=${encodeURIComponent(resolvedGlb)}`,
120
+ url: viewerUrl,
121
+ },
122
+ };
123
+ },
124
+ };
@@ -0,0 +1,36 @@
1
+ // `wallet_balance` — read SOL balance and all SPL token balances (both
2
+ // classic SPL + Token-2022) for any Solana pubkey. Read-only, no signer.
3
+
4
+ import { z } from 'zod';
5
+
6
+ import { getBalanceSol, getTokenBalances, isValidPubkey } from '../lib/solana.js';
7
+ import { SOLANA_RPC_URL } from '../config.js';
8
+
9
+ export const def = {
10
+ name: 'wallet_balance',
11
+ title: 'Read Solana wallet balances (SOL + SPL tokens)',
12
+ description:
13
+ 'Return SOL balance and all SPL token balances (including Token-2022) for a Solana pubkey. Uses the configured SOLANA_RPC_URL. Read-only — no signer required.',
14
+ inputSchema: {
15
+ pubkey: z.string().min(32).max(64).describe('Base58 Solana pubkey to read.'),
16
+ includeTokens: z.boolean().optional().describe('Include SPL token accounts (default true).'),
17
+ },
18
+ async handler(args) {
19
+ const { pubkey, includeTokens = true } = args || {};
20
+ if (!isValidPubkey(pubkey)) {
21
+ return { ok: false, error: 'invalid_pubkey' };
22
+ }
23
+ const sol = await getBalanceSol(pubkey);
24
+ const tokens = includeTokens ? await getTokenBalances(pubkey) : null;
25
+ return {
26
+ ok: true,
27
+ pubkey,
28
+ sol: sol.sol,
29
+ lamports: sol.lamports,
30
+ tokens,
31
+ rpc: SOLANA_RPC_URL,
32
+ explorer: `https://solscan.io/account/${pubkey}`,
33
+ fetchedAt: new Date().toISOString(),
34
+ };
35
+ },
36
+ };
@@ -0,0 +1,67 @@
1
+ // `wallet_create` — generate a fresh Solana keypair locally and (optionally)
2
+ // attach it to an avatar session. With `vanityPrefix` / `vanitySuffix` the
3
+ // tool grinds keys until the base58 pubkey matches — useful for spawning a
4
+ // "three…" or "…wsai" themed wallet on the fly.
5
+ //
6
+ // The secret is returned base58-encoded ONCE. The MCP server does not
7
+ // persist it. The caller is responsible for storing it safely.
8
+
9
+ import { Keypair } from '@solana/web3.js';
10
+ import { z } from 'zod';
11
+
12
+ import { bs58encode, grindVanity } from '../lib/solana.js';
13
+ import { getSession, updateSession } from '../lib/avatars.js';
14
+
15
+ export const def = {
16
+ name: 'wallet_create',
17
+ title: 'Create a Solana wallet (optionally vanity-grinded)',
18
+ description:
19
+ 'Generate a Solana keypair locally. Optionally grind for a base58 prefix/suffix (e.g. "three") and/or attach it to an avatar session. Returns the base58 pubkey and secret ONCE — store the secret yourself; the MCP does not persist it.',
20
+ inputSchema: {
21
+ sessionId: z.string().optional().describe('If set, the wallet is attached to this avatar session.'),
22
+ vanityPrefix: z.string().max(8).optional().describe('Base58 prefix to grind for (e.g. "three"). Up to 8 chars.'),
23
+ vanitySuffix: z.string().max(8).optional().describe('Base58 suffix to grind for. Up to 8 chars.'),
24
+ caseSensitive: z.boolean().optional().describe('Match case-sensitively (default true). Base58 has no 0OIl so set false to be permissive.'),
25
+ maxAttempts: z.number().int().min(1).max(2_000_000).optional()
26
+ .describe('Cap on grind attempts (default 500_000). Bump for longer prefixes.'),
27
+ },
28
+ async handler(args) {
29
+ const { sessionId, vanityPrefix, vanitySuffix, caseSensitive = true, maxAttempts } = args || {};
30
+
31
+ let pubkey;
32
+ let secret;
33
+ let grind = null;
34
+ if (vanityPrefix || vanitySuffix) {
35
+ grind = grindVanity({ prefix: vanityPrefix, suffix: vanitySuffix, caseSensitive, maxAttempts: maxAttempts || 500_000 });
36
+ if (!grind.found) {
37
+ return { ok: false, error: 'vanity_not_found', ...grind };
38
+ }
39
+ pubkey = grind.pubkey;
40
+ secret = grind.secret;
41
+ } else {
42
+ const kp = Keypair.generate();
43
+ pubkey = kp.publicKey.toBase58();
44
+ secret = bs58encode(kp.secretKey);
45
+ }
46
+
47
+ let session = null;
48
+ if (sessionId) {
49
+ session = getSession(sessionId);
50
+ if (!session) {
51
+ return { ok: false, error: 'unknown_session', message: `No session ${sessionId}.` };
52
+ }
53
+ updateSession(sessionId, { wallet: { pubkey, hasSecret: true } });
54
+ }
55
+
56
+ return {
57
+ ok: true,
58
+ pubkey,
59
+ secret,
60
+ warning:
61
+ 'This secret is shown ONCE. The MCP server does not persist it. Store it in a password manager or hardware wallet; treat it like cash.',
62
+ sessionId: session?.id || null,
63
+ vanity: grind ? { attempts: grind.attempts, durationMs: grind.durationMs } : null,
64
+ explorer: `https://solscan.io/account/${pubkey}`,
65
+ };
66
+ },
67
+ };
@@ -0,0 +1,35 @@
1
+ // `wallet_send` — send SOL from a signer to a destination pubkey. The
2
+ // signer comes from the `secret` arg (preferred) or from SOLANA_SECRET_KEY
3
+ // in the MCP server environment. Returns the on-chain signature and a
4
+ // Solscan link once confirmed.
5
+
6
+ import { z } from 'zod';
7
+
8
+ import { sendSol } from '../lib/solana.js';
9
+
10
+ export const def = {
11
+ name: 'wallet_send',
12
+ title: 'Send SOL on Solana mainnet',
13
+ description:
14
+ 'Send SOL from the configured signer to a destination pubkey. The signer is supplied via the `secret` arg (base58) or via SOLANA_SECRET_KEY env on the MCP server. Returns the confirmed signature and a Solscan link. EXECUTION ACTION — funds move on mainnet.',
15
+ inputSchema: {
16
+ to: z.string().min(32).max(64).describe('Destination Solana pubkey.'),
17
+ sol: z.number().positive().describe('Amount of SOL to send.'),
18
+ secret: z.string().optional().describe('Base58 secret of the sender. Falls back to SOLANA_SECRET_KEY env.'),
19
+ priorityMicroLamports: z.number().int().min(0).max(10_000_000).optional()
20
+ .describe('Compute-unit price (default 100000).'),
21
+ },
22
+ async handler(args) {
23
+ try {
24
+ const out = await sendSol({
25
+ secret: args.secret,
26
+ to: args.to,
27
+ sol: args.sol,
28
+ priorityMicroLamports: args.priorityMicroLamports,
29
+ });
30
+ return { ok: true, ...out };
31
+ } catch (err) {
32
+ return { ok: false, error: err.code || 'send_failed', message: err.message, signature: err.signature || null };
33
+ }
34
+ },
35
+ };