helius-mcp 1.2.0 → 1.3.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,67 @@
1
+ /**
2
+ * Quick test: fetch a real blog post and check htmlToText output quality.
3
+ * Run: node dist/scripts/test-htmltotext.js
4
+ */
5
+ function htmlToText(html) {
6
+ let content = html;
7
+ const articleMatch = html.match(/<article[^>]*>([\s\S]*)<\/article>/i);
8
+ if (articleMatch) {
9
+ content = articleMatch[1];
10
+ }
11
+ else {
12
+ const mainMatch = html.match(/<main[^>]*>([\s\S]*)<\/main>/i);
13
+ if (mainMatch) {
14
+ content = mainMatch[1];
15
+ }
16
+ }
17
+ return (content
18
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
19
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
20
+ .replace(/<noscript[^>]*>[\s\S]*?<\/noscript>/gi, '')
21
+ .replace(/<svg[^>]*>[\s\S]*?<\/svg>/gi, '')
22
+ .replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
23
+ .replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
24
+ .replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
25
+ .replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n')
26
+ .replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n')
27
+ .replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n')
28
+ .replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, '\n#### $1\n')
29
+ .replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, '\n```\n$1\n```\n')
30
+ .replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`')
31
+ .replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n')
32
+ .replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, '\n$1\n')
33
+ .replace(/<br\s*\/?>/gi, '\n')
34
+ .replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)')
35
+ .replace(/<(?:strong|b)[^>]*>([\s\S]*?)<\/(?:strong|b)>/gi, '**$1**')
36
+ .replace(/<(?:em|i)[^>]*>([\s\S]*?)<\/(?:em|i)>/gi, '*$1*')
37
+ .replace(/<[^>]+>/g, '')
38
+ .replace(/&amp;/g, '&')
39
+ .replace(/&lt;/g, '<')
40
+ .replace(/&gt;/g, '>')
41
+ .replace(/&quot;/g, '"')
42
+ .replace(/&#39;/g, "'")
43
+ .replace(/&nbsp;/g, ' ')
44
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
45
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
46
+ .replace(/\n{3,}/g, '\n\n')
47
+ .trim());
48
+ }
49
+ async function main() {
50
+ const res = await fetch('https://www.helius.dev/blog/alpenglow');
51
+ const html = await res.text();
52
+ const text = htmlToText(html);
53
+ console.log('=== htmlToText Output (first 3000 chars) ===\n');
54
+ console.log(text.slice(0, 3000));
55
+ console.log('\n=== Stats ===');
56
+ console.log(`HTML length: ${html.length}`);
57
+ console.log(`Text length: ${text.length}`);
58
+ console.log(`Compression ratio: ${((1 - text.length / html.length) * 100).toFixed(1)}%`);
59
+ console.log(`Has ## headers: ${/^## /m.test(text)}`);
60
+ console.log(`Has ### headers: ${/^### /m.test(text)}`);
61
+ console.log(`Has **bold**: ${/\*\*[^*]+\*\*/.test(text)}`);
62
+ console.log(`Has [links](url): ${/\[[^\]]+\]\([^)]+\)/.test(text)}`);
63
+ console.log(`Has bullet lists: ${/^- /m.test(text)}`);
64
+ console.log(`Residual HTML tags: ${(text.match(/<[^>]+>/g) || []).length}`);
65
+ }
66
+ main();
67
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Integration test for solana-knowledge tools.
3
+ *
4
+ * Run after build:
5
+ * node dist/scripts/test-solana-knowledge.js
6
+ *
7
+ * Tests each tool against live endpoints and validates the response shape.
8
+ */
9
+ export {};
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Integration test for solana-knowledge tools.
3
+ *
4
+ * Run after build:
5
+ * node dist/scripts/test-solana-knowledge.js
6
+ *
7
+ * Tests each tool against live endpoints and validates the response shape.
8
+ */
9
+ const PASS = '\x1b[32m✅\x1b[0m';
10
+ const FAIL = '\x1b[31m❌\x1b[0m';
11
+ const WARN = '\x1b[33m⚠️\x1b[0m';
12
+ let passed = 0;
13
+ let failed = 0;
14
+ function assert(condition, label, detail) {
15
+ if (condition) {
16
+ console.log(`${PASS} ${label}`);
17
+ passed++;
18
+ }
19
+ else {
20
+ console.log(`${FAIL} ${label}${detail ? ` — ${detail}` : ''}`);
21
+ failed++;
22
+ }
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // Helpers — minimal reimplementation to test without starting an MCP server
26
+ // ---------------------------------------------------------------------------
27
+ const SIMD_REPO = 'solana-foundation/solana-improvement-documents';
28
+ const SIMD_API_URL = `https://api.github.com/repos/${SIMD_REPO}/contents/proposals`;
29
+ const SIMD_RAW_BASE = `https://raw.githubusercontent.com/${SIMD_REPO}/main/proposals`;
30
+ const UA = 'helius-mcp-test';
31
+ function ghHeaders() {
32
+ const h = { Accept: 'application/vnd.github.v3+json' };
33
+ if (process.env.GITHUB_TOKEN)
34
+ h['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
35
+ return h;
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Test: getSIMD
39
+ // ---------------------------------------------------------------------------
40
+ async function testGetSIMD() {
41
+ console.log('\n--- getSIMD ---');
42
+ // 1. Fetch SIMD index
43
+ const indexRes = await fetch(SIMD_API_URL, { headers: { 'User-Agent': UA, ...ghHeaders() } });
44
+ assert(indexRes.ok, 'SIMD index fetch succeeds', `status=${indexRes.status}`);
45
+ const files = (await indexRes.json());
46
+ const mdFiles = files.filter((f) => f.name.endsWith('.md'));
47
+ assert(mdFiles.length > 50, `SIMD index has >50 proposals (got ${mdFiles.length})`);
48
+ // 2. Parse filenames
49
+ const parsed = mdFiles
50
+ .map((f) => {
51
+ const match = f.name.match(/^(\d+)-(.+)\.md$/);
52
+ return match ? { number: match[1], slug: match[2], filename: f.name } : null;
53
+ })
54
+ .filter((x) => x !== null);
55
+ assert(parsed.length === mdFiles.length, `All ${mdFiles.length} filenames parsed correctly`);
56
+ // 3. Fetch a known SIMD (SIMD-0096)
57
+ const entry96 = parsed.find((e) => e.number === '0096');
58
+ assert(!!entry96, 'SIMD-0096 exists in index');
59
+ if (entry96) {
60
+ const rawRes = await fetch(`${SIMD_RAW_BASE}/${entry96.filename}`, { headers: { 'User-Agent': UA } });
61
+ assert(rawRes.ok, 'SIMD-0096 raw fetch succeeds');
62
+ const content = await rawRes.text();
63
+ assert(content.length > 500, `SIMD-0096 has substantial content (${content.length} chars)`);
64
+ assert(content.includes('---'), 'SIMD-0096 has YAML frontmatter delimiter');
65
+ }
66
+ // 4. Test number padding logic
67
+ const padTest = (input, expected) => {
68
+ const result = input.replace(/^0+/, '').padStart(4, '0');
69
+ assert(result === expected, `Padding "${input}" → "${result}" (expected "${expected}")`);
70
+ };
71
+ padTest('228', '0228');
72
+ padTest('0096', '0096');
73
+ padTest('1', '0001');
74
+ padTest('0001', '0001');
75
+ // 5. Test not-found with nearby suggestions
76
+ const entry9999 = parsed.find((e) => e.number === '9999');
77
+ assert(!entry9999, 'SIMD-9999 does not exist (good — tests not-found path)');
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // Test: listSIMDs
81
+ // ---------------------------------------------------------------------------
82
+ async function testListSIMDs() {
83
+ console.log('\n--- listSIMDs ---');
84
+ // Already tested via getSIMD index fetch — just verify table format
85
+ const indexRes = await fetch(SIMD_API_URL, { headers: { 'User-Agent': UA, ...ghHeaders() } });
86
+ const files = (await indexRes.json());
87
+ const parsed = files
88
+ .filter((f) => f.name.endsWith('.md'))
89
+ .map((f) => {
90
+ const match = f.name.match(/^(\d+)-(.+)\.md$/);
91
+ return match ? { number: match[1], slug: match[2] } : null;
92
+ })
93
+ .filter(Boolean);
94
+ const tableRow = `| ${parsed[0].number} | ${parsed[0].slug.replace(/-/g, ' ')} |`;
95
+ assert(tableRow.includes('|'), 'Table row format is valid markdown');
96
+ assert(parsed.length > 0, `listSIMDs would return ${parsed.length} entries`);
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Test: readSolanaSourceFile
100
+ // ---------------------------------------------------------------------------
101
+ async function testReadSolanaSourceFile() {
102
+ console.log('\n--- readSolanaSourceFile ---');
103
+ // 1. Agave — known file
104
+ const agaveUrl = 'https://raw.githubusercontent.com/anza-xyz/agave/master/svm/src/lib.rs';
105
+ const agaveRes = await fetch(agaveUrl, { headers: { 'User-Agent': UA } });
106
+ assert(agaveRes.ok, `Agave svm/src/lib.rs exists (status=${agaveRes.status})`);
107
+ const agaveContent = await agaveRes.text();
108
+ assert(agaveContent.length > 0, `Agave svm/src/lib.rs has content (${agaveContent.length} chars)`);
109
+ // 2. Firedancer — test with correct default branch ("main")
110
+ const fdUrl = 'https://raw.githubusercontent.com/firedancer-io/firedancer/main/README.md';
111
+ const fdRes = await fetch(fdUrl, { headers: { 'User-Agent': UA } });
112
+ assert(fdRes.ok, `Firedancer README.md on "main" branch exists (status=${fdRes.status})`);
113
+ // 3. Firedancer — verify default branch is "main" via GitHub API
114
+ const fdApiRes = await fetch('https://api.github.com/repos/firedancer-io/firedancer', {
115
+ headers: { 'User-Agent': UA, ...ghHeaders() },
116
+ });
117
+ if (fdApiRes.ok) {
118
+ const fdRepo = (await fdApiRes.json());
119
+ assert(fdRepo.default_branch === 'main', `Firedancer default branch is "${fdRepo.default_branch}" (expected "main")`);
120
+ }
121
+ else {
122
+ console.log(`${WARN} Could not check Firedancer default branch (status=${fdApiRes.status})`);
123
+ }
124
+ // 4. Non-existent file returns error
125
+ const badUrl = 'https://raw.githubusercontent.com/anza-xyz/agave/master/this/does/not/exist.rs';
126
+ const badRes = await fetch(badUrl, { headers: { 'User-Agent': UA } });
127
+ assert(!badRes.ok, `Non-existent path returns error (status=${badRes.status})`);
128
+ // 5. Truncation logic
129
+ const longContent = 'x'.repeat(60_000);
130
+ const MAX_CHARS = 50_000;
131
+ const truncated = longContent.length > MAX_CHARS;
132
+ assert(truncated, 'Content >50k chars is detected as needing truncation');
133
+ const display = longContent.slice(0, MAX_CHARS);
134
+ assert(display.length === MAX_CHARS, `Truncated to exactly ${MAX_CHARS} chars`);
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Test: searchSolanaDocs
138
+ // ---------------------------------------------------------------------------
139
+ async function testSearchSolanaDocs() {
140
+ console.log('\n--- searchSolanaDocs ---');
141
+ // 1. Fetch the docs
142
+ const docsRes = await fetch('https://solana.com/llms-full.txt', { headers: { 'User-Agent': UA } });
143
+ assert(docsRes.ok, `Solana llms-full.txt fetch succeeds (status=${docsRes.status})`);
144
+ const docsContent = await docsRes.text();
145
+ assert(docsContent.length > 10_000, `Solana docs has substantial content (${docsContent.length} chars)`);
146
+ // 2. Test section extraction for "Accounts"
147
+ const lines = docsContent.split('\n');
148
+ const accountSections = [];
149
+ let inSection = false;
150
+ let sectionDepth = 0;
151
+ let sectionLines = [];
152
+ for (const line of lines) {
153
+ const headerMatch = line.match(/^(#{1,4})\s+(.+)/);
154
+ if (headerMatch) {
155
+ const depth = headerMatch[1].length;
156
+ const title = headerMatch[2].toLowerCase();
157
+ if (inSection && depth <= sectionDepth) {
158
+ accountSections.push(sectionLines.join('\n'));
159
+ sectionLines = [];
160
+ inSection = false;
161
+ }
162
+ if (title.includes('accounts')) {
163
+ inSection = true;
164
+ sectionDepth = depth;
165
+ sectionLines.push(line);
166
+ }
167
+ else if (inSection) {
168
+ sectionLines.push(line);
169
+ }
170
+ }
171
+ else if (inSection) {
172
+ sectionLines.push(line);
173
+ }
174
+ }
175
+ if (inSection)
176
+ accountSections.push(sectionLines.join('\n'));
177
+ assert(accountSections.length > 0, `Found ${accountSections.length} sections matching "accounts"`);
178
+ // 3. Test empty queryWords edge case (the bug we fixed)
179
+ const shortQuery = 'a b';
180
+ const shortQueryLower = shortQuery.toLowerCase();
181
+ const shortQueryWords = shortQueryLower.split(/\s+/).filter((w) => w.length > 2);
182
+ assert(shortQueryWords.length === 0, 'Short query "a b" produces empty queryWords');
183
+ // With our fix, the condition `queryWords.length > 0 && queryWords.every(...)` prevents vacuous truth
184
+ const wouldMatchAll = shortQueryWords.length > 0 && shortQueryWords.every(() => true);
185
+ assert(!wouldMatchAll, 'Empty queryWords does NOT vacuously match all headers (bug fixed)');
186
+ // 4. Test "PDAs" query
187
+ const pdaLines = lines.filter((l) => l.toLowerCase().includes('pda'));
188
+ assert(pdaLines.length > 0, `"PDAs" finds ${pdaLines.length} matching lines in Solana docs`);
189
+ }
190
+ // ---------------------------------------------------------------------------
191
+ // Test: fetchHeliusBlog
192
+ // ---------------------------------------------------------------------------
193
+ async function testFetchHeliusBlog() {
194
+ console.log('\n--- fetchHeliusBlog ---');
195
+ // 1. Test blog index size
196
+ const BLOG_INDEX_SIZE = 55; // approximate — we have ~55 curated posts
197
+ // We can't import the actual index, so just validate the fetch works
198
+ // 2. Fetch a known blog post
199
+ const blogUrl = 'https://www.helius.dev/blog/solana-virtual-machine';
200
+ const blogRes = await fetch(blogUrl, { headers: { 'User-Agent': UA } });
201
+ assert(blogRes.ok, `Blog post "solana-virtual-machine" fetch succeeds (status=${blogRes.status})`);
202
+ const html = await blogRes.text();
203
+ assert(html.length > 1000, `Blog HTML has content (${html.length} chars)`);
204
+ // 3. Test htmlToText conversion
205
+ const hasArticle = /<article/i.test(html);
206
+ const hasMain = /<main/i.test(html);
207
+ assert(hasArticle || hasMain, `Blog HTML has <article> or <main> tag for content extraction`);
208
+ // Basic HTML-to-text test
209
+ const simpleHtml = '<h2>Test Header</h2><p>Some <strong>bold</strong> text.</p>';
210
+ const converted = simpleHtml
211
+ .replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n')
212
+ .replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, '\n$1\n')
213
+ .replace(/<(?:strong|b)[^>]*>([\s\S]*?)<\/(?:strong|b)>/gi, '**$1**')
214
+ .replace(/<[^>]+>/g, '')
215
+ .trim();
216
+ assert(converted.includes('## Test Header'), 'h2 converted to ## markdown');
217
+ assert(converted.includes('**bold**'), 'strong converted to **bold**');
218
+ // 4. Test a non-existent slug returns error
219
+ const badBlogUrl = 'https://www.helius.dev/blog/this-post-does-not-exist-12345';
220
+ const badBlogRes = await fetch(badBlogUrl, { headers: { 'User-Agent': UA } });
221
+ // Helius might return 200 with a "not found" page or 404 — either way test completes
222
+ console.log(`${WARN} Non-existent blog slug returns status=${badBlogRes.status} (expected 404 or redirect)`);
223
+ // 5. Fetch another post to verify consistency
224
+ const blog2Url = 'https://www.helius.dev/blog/alpenglow';
225
+ const blog2Res = await fetch(blog2Url, { headers: { 'User-Agent': UA } });
226
+ assert(blog2Res.ok, `Blog post "alpenglow" fetch succeeds (status=${blog2Res.status})`);
227
+ }
228
+ // ---------------------------------------------------------------------------
229
+ // Run all tests
230
+ // ---------------------------------------------------------------------------
231
+ async function main() {
232
+ console.log('=== Solana Knowledge Tools — Integration Tests ===\n');
233
+ try {
234
+ await testGetSIMD();
235
+ }
236
+ catch (e) {
237
+ console.log(`${FAIL} getSIMD test threw: ${e}`);
238
+ failed++;
239
+ }
240
+ try {
241
+ await testListSIMDs();
242
+ }
243
+ catch (e) {
244
+ console.log(`${FAIL} listSIMDs test threw: ${e}`);
245
+ failed++;
246
+ }
247
+ try {
248
+ await testReadSolanaSourceFile();
249
+ }
250
+ catch (e) {
251
+ console.log(`${FAIL} readSolanaSourceFile test threw: ${e}`);
252
+ failed++;
253
+ }
254
+ try {
255
+ await testSearchSolanaDocs();
256
+ }
257
+ catch (e) {
258
+ console.log(`${FAIL} searchSolanaDocs test threw: ${e}`);
259
+ failed++;
260
+ }
261
+ try {
262
+ await testFetchHeliusBlog();
263
+ }
264
+ catch (e) {
265
+ console.log(`${FAIL} fetchHeliusBlog test threw: ${e}`);
266
+ failed++;
267
+ }
268
+ console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
269
+ process.exit(failed > 0 ? 1 : 0);
270
+ }
271
+ main();
272
+ export {};
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validates recommendStack template data for correctness.
4
+ *
5
+ * Checks:
6
+ * 1. MCP tool names exist in KNOWN_TOOLS
7
+ * 2. Reference paths exist on disk
8
+ * 3. Plan-feature compatibility (e.g., Laserstream mainnet requires professional)
9
+ * 4. Tier ordering (budget <= standard <= production plan rank)
10
+ * 5. No empty arrays (products, limitations, references)
11
+ */
12
+ export {};
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validates recommendStack template data for correctness.
4
+ *
5
+ * Checks:
6
+ * 1. MCP tool names exist in KNOWN_TOOLS
7
+ * 2. Reference paths exist on disk
8
+ * 3. Plan-feature compatibility (e.g., Laserstream mainnet requires professional)
9
+ * 4. Tier ordering (budget <= standard <= production plan rank)
10
+ * 5. No empty arrays (products, limitations, references)
11
+ */
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { PROJECT_TEMPLATES, KNOWN_TOOLS } from '../tools/recommend.js';
15
+ import { HELIUS_PLANS } from '../tools/plans.js';
16
+ const PLAN_RANK = { free: 0, developer: 1, business: 2, professional: 3 };
17
+ const TIER_RANK = { budget: 0, standard: 1, production: 2 };
18
+ // Resolve skill references relative to repo root
19
+ const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
20
+ const SKILL_DIR = path.join(REPO_ROOT, 'helius-skills', 'helius');
21
+ let errors = [];
22
+ function error(templateName, msg) {
23
+ errors.push(`[${templateName}] ${msg}`);
24
+ }
25
+ for (const [key, template] of Object.entries(PROJECT_TEMPLATES)) {
26
+ // ── Check tier ordering ──
27
+ const sorted = [...template.tiers].sort((a, b) => TIER_RANK[a.tier] - TIER_RANK[b.tier]);
28
+ for (let i = 1; i < sorted.length; i++) {
29
+ const prev = sorted[i - 1];
30
+ const curr = sorted[i];
31
+ if ((PLAN_RANK[curr.minimumPlan] ?? 0) < (PLAN_RANK[prev.minimumPlan] ?? 0)) {
32
+ error(key, `Tier ordering violation: ${curr.tier} (${curr.minimumPlan}) has lower plan rank than ${prev.tier} (${prev.minimumPlan})`);
33
+ }
34
+ }
35
+ for (const tier of template.tiers) {
36
+ const tierLabel = `${key}/${tier.tier}`;
37
+ // ── No empty arrays ──
38
+ if (tier.products.length === 0) {
39
+ error(tierLabel, 'products array is empty');
40
+ }
41
+ if (tier.limitations.length === 0) {
42
+ error(tierLabel, 'limitations array is empty');
43
+ }
44
+ if (tier.references.length === 0) {
45
+ error(tierLabel, 'references array is empty');
46
+ }
47
+ // ── Validate each product ──
48
+ for (const product of tier.products) {
49
+ // MCP tool name validation
50
+ for (const tool of product.mcpTools) {
51
+ if (!KNOWN_TOOLS.has(tool)) {
52
+ error(tierLabel, `Unknown MCP tool "${tool}" in product "${product.product}"`);
53
+ }
54
+ }
55
+ // Plan-feature compatibility
56
+ if (!(product.minimumPlan in PLAN_RANK)) {
57
+ error(tierLabel, `Unknown plan "${product.minimumPlan}" in product "${product.product}"`);
58
+ }
59
+ if (!(product.minimumPlan in HELIUS_PLANS)) {
60
+ error(tierLabel, `Plan "${product.minimumPlan}" not found in HELIUS_PLANS for product "${product.product}"`);
61
+ }
62
+ // Laserstream mainnet must require professional
63
+ const nameLower = product.product.toLowerCase();
64
+ if (nameLower.includes('laserstream') && nameLower.includes('mainnet') && product.minimumPlan !== 'professional') {
65
+ error(tierLabel, `Laserstream mainnet in product "${product.product}" requires professional plan, but has "${product.minimumPlan}"`);
66
+ }
67
+ // Enhanced WebSockets must require business+
68
+ if (nameLower.includes('enhanced websocket') && (PLAN_RANK[product.minimumPlan] ?? 0) < PLAN_RANK['business']) {
69
+ error(tierLabel, `Enhanced WebSockets in product "${product.product}" requires business+ plan, but has "${product.minimumPlan}"`);
70
+ }
71
+ }
72
+ // ── Reference path validation ──
73
+ for (const ref of tier.references) {
74
+ const refPath = path.join(SKILL_DIR, ref);
75
+ if (!fs.existsSync(refPath)) {
76
+ error(tierLabel, `Reference file not found: ${ref} (looked at ${refPath})`);
77
+ }
78
+ }
79
+ }
80
+ }
81
+ // ── Report ──
82
+ if (errors.length > 0) {
83
+ console.error(`\n❌ Template validation failed with ${errors.length} error(s):\n`);
84
+ for (const err of errors) {
85
+ console.error(` • ${err}`);
86
+ }
87
+ console.error('');
88
+ process.exit(1);
89
+ }
90
+ else {
91
+ const templateCount = Object.keys(PROJECT_TEMPLATES).length;
92
+ const tierCount = Object.values(PROJECT_TEMPLATES).reduce((sum, t) => sum + t.tiers.length, 0);
93
+ console.log(`✅ All templates valid (${templateCount} templates, ${tierCount} tiers)`);
94
+ }
@@ -16,6 +16,9 @@ import { sendFeedbackEvent, captureWalletAddress } from '../utils/feedback.js';
16
16
  import { setSharedApiKey, setJwt, getJwt, SHARED_CONFIG_PATH, KEYPAIR_PATH, loadKeypairFromDisk, saveKeypairToDisk, keypairExistsOnDisk } from '../utils/config.js';
17
17
  import { HELIUS_PLANS } from './plans.js';
18
18
  const PAID_PLAN_ORDER = ['developer', 'business', 'professional'];
19
+ /** Tracks consecutive insufficient-balance checks to prevent agent polling loops. */
20
+ let insufficientBalanceChecks = 0;
21
+ const MAX_BALANCE_CHECKS_BEFORE_STOP = 3;
19
22
  export function registerAuthTools(server) {
20
23
  // ── Getting Started Guide ──
21
24
  server.tool('getStarted', 'Get setup instructions for Helius. Checks whether an API key is configured (not validated), whether a keypair exists on disk, and whether a JWT session is present, then tells you exactly what to do next. Call this when a user asks "how do I get started?" or needs onboarding help.', {}, async () => {
@@ -48,6 +51,8 @@ export function registerAuthTools(server) {
48
51
  // ── Keypair Generation ──
49
52
  server.tool('generateKeypair', 'Generate a new Solana keypair for Helius account signup. Returns the wallet address. The user must fund this wallet with ~0.001 SOL + 1 USDC (basic plan) or more USDC (for paid plans) before calling agenticSignup.', {}, async () => {
50
53
  try {
54
+ // Reset balance-check counter for fresh signup flow
55
+ insufficientBalanceChecks = 0;
51
56
  // Check disk first — reuse existing keypair if available
52
57
  const existingKey = loadKeypairFromDisk();
53
58
  if (existingKey) {
@@ -105,22 +110,48 @@ export function registerAuthTools(server) {
105
110
  const usdcAmount = Number(usdcBalance) / 1_000_000;
106
111
  const solOk = solBalance >= 1000000n;
107
112
  const usdcOk = usdcBalance >= 1000000n; // 1 USDC for basic plan
108
- let status;
109
- if (solOk && usdcOk) {
110
- status = 'Ready for signup (basic plan). For paid plans, ensure sufficient USDC for the plan price.';
111
- }
112
- else {
113
- const missing = [];
114
- if (!solOk)
115
- missing.push(`~0.001 SOL (have ${solAmount.toFixed(6)})`);
116
- if (!usdcOk)
117
- missing.push(`1 USDC (have ${usdcAmount.toFixed(2)})`);
118
- status = `Need more funds: ${missing.join(', ')}`;
119
- }
120
- return mcpText(`**Signup Wallet Balance** (\`${address}\`)\n\n` +
113
+ const funded = solOk && usdcOk;
114
+ // Reset counter when balance is sufficient
115
+ if (funded) {
116
+ insufficientBalanceChecks = 0;
117
+ return mcpText(`**Signup Wallet Balance** (\`${address}\`)\n\n` +
118
+ `- **SOL:** ${solAmount.toFixed(6)} (sufficient)\n` +
119
+ `- **USDC:** ${usdcAmount.toFixed(2)} (sufficient for basic)\n\n` +
120
+ `**Status:** Ready for signup (basic plan). For paid plans, ensure sufficient USDC for the plan price.\n\n` +
121
+ `Call \`agenticSignup\` to proceed.`);
122
+ }
123
+ // Insufficient increment counter and escalate guidance
124
+ insufficientBalanceChecks++;
125
+ const missing = [];
126
+ if (!solOk)
127
+ missing.push(`~0.001 SOL (have ${solAmount.toFixed(6)})`);
128
+ if (!usdcOk)
129
+ missing.push(`1 USDC (have ${usdcAmount.toFixed(2)})`);
130
+ let balanceBlock = `**Signup Wallet Balance** (\`${address}\`)\n\n` +
121
131
  `- **SOL:** ${solAmount.toFixed(6)} ${solOk ? '(sufficient)' : '(insufficient)'}\n` +
122
132
  `- **USDC:** ${usdcAmount.toFixed(2)} ${usdcOk ? '(sufficient for basic)' : '(insufficient)'}\n\n` +
123
- `**Status:** ${status}`);
133
+ `**Status:** Need more funds: ${missing.join(', ')}`;
134
+ if (insufficientBalanceChecks === 1) {
135
+ // First check — normal guidance
136
+ balanceBlock +=
137
+ `\n\n**Action required:** Ask the user to send the missing funds to \`${address}\`. ` +
138
+ `Do **not** call \`checkSignupBalance\` again until the user confirms they have sent the funds.`;
139
+ }
140
+ else if (insufficientBalanceChecks < MAX_BALANCE_CHECKS_BEFORE_STOP) {
141
+ // Second check — firmer nudge
142
+ balanceBlock +=
143
+ `\n\n**⚠ Balance still insufficient (check ${insufficientBalanceChecks}/${MAX_BALANCE_CHECKS_BEFORE_STOP}).** ` +
144
+ `The wallet has not been funded yet. Ask the user to confirm they have sent funds to \`${address}\` before calling this tool again.`;
145
+ }
146
+ else {
147
+ // Third+ check — hard stop
148
+ balanceBlock +=
149
+ `\n\n**🛑 Balance checked ${insufficientBalanceChecks} times — still insufficient. Stop polling.** ` +
150
+ `The wallet \`${address}\` has not received funds. ` +
151
+ `Tell the user the exact amounts needed and the wallet address, then **wait for the user to explicitly confirm** they have sent funds before calling \`checkSignupBalance\` again. ` +
152
+ `Do not retry automatically.`;
153
+ }
154
+ return mcpText(balanceBlock);
124
155
  }
125
156
  catch (err) {
126
157
  return handleToolError(err, 'Error checking balances');
@@ -3,17 +3,18 @@ import { formatSolCompact } from '../utils/formatters.js';
3
3
  import { noApiKeyResponse } from './shared.js';
4
4
  import { mcpText, handleToolError } from '../utils/errors.js';
5
5
  export function registerNetworkTools(server) {
6
- server.tool('getNetworkStatus', 'BEST FOR: quick Solana network health check — epoch, slot, supply, version. Get current Solana network status including epoch info (current epoch, slot, progress), total SOL supply, cluster version, and current block height. No parameters needed — gives a quick overview of blockchain health and state. Credit cost: 3 credits (3 standard RPC calls).', {}, async () => {
6
+ server.tool('getNetworkStatus', 'BEST FOR: quick Solana network health check — epoch, slot, TPS, supply, version. Get current Solana network status including epoch info (current epoch, slot, progress), current TPS (transactions per second), total SOL supply, cluster version, and current block height. No parameters needed — gives a quick overview of blockchain health and state. Credit cost: 4 credits (4 standard RPC calls).', {}, async () => {
7
7
  if (!hasApiKey())
8
8
  return noApiKeyResponse();
9
9
  try {
10
10
  const helius = getHeliusClient();
11
11
  // Fire all requests in parallel — Kit returns bigint for numeric fields
12
12
  // wrapAutoSend in the SDK already calls .send() on pending RPC requests
13
- const [epochInfo, supplyResult, version] = await Promise.all([
13
+ const [epochInfo, supplyResult, version, perfSamples] = await Promise.all([
14
14
  helius.getEpochInfo(),
15
15
  helius.getSupply(),
16
16
  helius.getVersion(),
17
+ helius.getRecentPerformanceSamples(4),
17
18
  ]);
18
19
  const lines = ['**Solana Network Status**', ''];
19
20
  // Epoch info — all fields are bigint from Kit
@@ -35,6 +36,38 @@ export function registerNetworkTools(server) {
35
36
  }
36
37
  lines.push('');
37
38
  }
39
+ // TPS — averaged from 4 recent performance samples (~60s each, ~4 min window)
40
+ // numTransactions includes vote + non-vote; numNonVoteTransactions is real user TPS
41
+ if (Array.isArray(perfSamples) && perfSamples.length > 0) {
42
+ let totalTx = 0;
43
+ let totalNonVoteTx = 0;
44
+ let totalSeconds = 0;
45
+ let hasNonVoteData = false;
46
+ for (const sample of perfSamples) {
47
+ const samplePeriod = Number(sample.samplePeriodSecs);
48
+ if (samplePeriod > 0) {
49
+ totalTx += Number(sample.numTransactions);
50
+ totalSeconds += samplePeriod;
51
+ if (sample.numNonVoteTransactions != null) {
52
+ totalNonVoteTx += Number(sample.numNonVoteTransactions);
53
+ hasNonVoteData = true;
54
+ }
55
+ }
56
+ }
57
+ if (totalSeconds > 0) {
58
+ const totalTps = Math.round(totalTx / totalSeconds);
59
+ lines.push('**TPS (avg. last ~4 min):**');
60
+ if (hasNonVoteData) {
61
+ const nonVoteTps = Math.round(totalNonVoteTx / totalSeconds);
62
+ lines.push(`- Real (non-vote): ~${nonVoteTps.toLocaleString()} tx/sec`);
63
+ lines.push(`- Total (incl. vote): ~${totalTps.toLocaleString()} tx/sec`);
64
+ }
65
+ else {
66
+ lines.push(`- Total: ~${totalTps.toLocaleString()} tx/sec (includes vote transactions)`);
67
+ }
68
+ lines.push('');
69
+ }
70
+ }
38
71
  // Supply — value fields are bigint from Kit
39
72
  if (supplyResult?.value) {
40
73
  const supply = supplyResult.value;
@@ -1,15 +1,15 @@
1
- const NO_API_KEY_MESSAGE = `**Helius API Key Required**
2
-
3
- I need a Helius API key to query the Solana blockchain.
4
-
5
- **Option 1 — Sign up here (recommended):**
6
- 1. Call \`generateKeypair\` to create a signup wallet
7
- 2. Fund the wallet with ~0.001 SOL + 1 USDC
8
- 3. Call \`agenticSignup\` to create your account — the API key will be configured automatically
9
-
10
- **Option 2 — Use an existing key:**
11
- 1. Go to https://dashboard.helius.dev/api-keys
12
- 2. Copy your API key
1
+ const NO_API_KEY_MESSAGE = `**Helius API Key Required**
2
+
3
+ I need a Helius API key to query the Solana blockchain.
4
+
5
+ **Option 1 — Sign up here (recommended):**
6
+ 1. Call \`generateKeypair\` to create a signup wallet
7
+ 2. Fund the wallet with ~0.001 SOL + 1 USDC
8
+ 3. Call \`agenticSignup\` to create your account — the API key will be configured automatically
9
+
10
+ **Option 2 — Use an existing key:**
11
+ 1. Go to https://dashboard.helius.dev/api-keys
12
+ 2. Copy your API key
13
13
  3. Call \`setHeliusApiKey\` with your key`;
14
14
  export function noApiKeyResponse() {
15
15
  return {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "1.2.0";
1
+ export declare const version = "1.3.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = '1.2.0';
1
+ export const version = '1.3.0';