@keelage/mcp 0.1.1
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 +21 -0
- package/README.md +100 -0
- package/bin/keelage.mjs +54 -0
- package/data/thresholds.json +26 -0
- package/data/track-record.json +61 -0
- package/lib/calc.mjs +338 -0
- package/lib/format.mjs +166 -0
- package/lib/gate.mjs +638 -0
- package/lib/holders.mjs +67 -0
- package/lib/io.mjs +43 -0
- package/lib/jev.mjs +104 -0
- package/lib/record.mjs +19 -0
- package/lib/render.mjs +56 -0
- package/lib/state.mjs +17 -0
- package/lib/template.mjs +22 -0
- package/lib/templates.json +100 -0
- package/lib/x-meta.mjs +214 -0
- package/package.json +45 -0
- package/server/core.mjs +114 -0
- package/server/hosted-io.mjs +9 -0
- package/server/mcp.mjs +25 -0
package/lib/x-meta.mjs
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// x-meta.mjs - what X is saying about a token, as CONTEXT on a verdict.
|
|
2
|
+
//
|
|
3
|
+
// Display only. Never a gate, never a rank key. This module states MEASURED facts: posts and accounts
|
|
4
|
+
// in the last 24h, how much of it is copy-paste, which large profiles posted and whether their post is
|
|
5
|
+
// even about this chain's token, the views, and one model-written line on what the chatter is about.
|
|
6
|
+
//
|
|
7
|
+
// Sources: SocialData (SOCIALDATA_API_KEY) for the posts; xAI grok-4-fast without tools (XAI_API_KEY) for
|
|
8
|
+
// the one-line gist over the posts already fetched. No key = that half is null; the card still renders.
|
|
9
|
+
// Cache: <state>/x-meta-cache.json per token, X_META_TTL_H (6h); failures are never cached.
|
|
10
|
+
// Import-safe: no network at import, no top-level await, no argv reads.
|
|
11
|
+
import { io } from './io.mjs';
|
|
12
|
+
import { statePath } from './state.mjs';
|
|
13
|
+
|
|
14
|
+
export const X_META_TTL_H = Number(process.env.X_META_TTL_H || 6);
|
|
15
|
+
export const BIG_FOLLOWERS = Number(process.env.X_META_BIG_FOLLOWERS || 10_000); // "large profile" bar
|
|
16
|
+
const SD_BASE = 'https://api.socialdata.tools';
|
|
17
|
+
const PAGES_MAX = 3; // 3 x ~20 posts covers a 24h window on a memecoin cashtag
|
|
18
|
+
const WINDOW_H = 24;
|
|
19
|
+
const GIST_POSTS = 25; // posts handed to the model for the gist
|
|
20
|
+
const BIG_SHOWN = 3; // large profiles named on the card
|
|
21
|
+
const CASHTAG_RE = /^[A-Za-z0-9]{2,15}$/; // a symbol X indexes as a cashtag
|
|
22
|
+
const NAME_RE = /^[\p{L}\p{N}]{1,15}$/u; // letters/digits in any script: searchable as a word plus the chain
|
|
23
|
+
const CHAIN_WORD = { robinhood: 'robinhood', solana: 'solana', bsc: 'bnb', ethereum: 'ethereum', base: 'base' };
|
|
24
|
+
const CHAIN_WORDS = {
|
|
25
|
+
robinhood: /\b(robinhood|rh chain|on rh|\$hood|hood chain|robinhood chain)\b/i,
|
|
26
|
+
solana: /\b(solana|sol|pump\.?fun|pumpswap|raydium|jupiter)\b/i,
|
|
27
|
+
bsc: /\b(bsc|bnb|binance smart chain|pancakeswap|four\.meme)\b/i,
|
|
28
|
+
ethereum: /\b(ethereum|eth mainnet|uniswap on eth)\b/i,
|
|
29
|
+
base: /\b(on base|base chain|basechain)\b/i,
|
|
30
|
+
};
|
|
31
|
+
const SOL_MINT_IN_TEXT = /\b[1-9A-HJ-NP-Za-km-z]{32,44}\b/;
|
|
32
|
+
const EVM_ADDR_IN_TEXT = /\b0x[0-9a-fA-F]{40}\b/;
|
|
33
|
+
|
|
34
|
+
// How to search X for a symbol: an ASCII symbol is a cashtag ($NAME); a non-Latin symbol is searched as
|
|
35
|
+
// the word plus the chain; an emoji or empty symbol is unsearchable. Returns {query, mode} or null.
|
|
36
|
+
export function queryFor(symbol, chain) {
|
|
37
|
+
const sym = String(symbol || '').replace(/^\$/, '').trim();
|
|
38
|
+
if (CASHTAG_RE.test(sym)) return { query: '$' + sym, mode: 'cashtag' };
|
|
39
|
+
if (NAME_RE.test(sym)) return { query: `${sym} ${CHAIN_WORD[chain] || chain || ''}`.trim(), mode: 'name+chain' };
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const keyOf = (c) => `${c.chain}:${String(c.address || '').toLowerCase()}`;
|
|
44
|
+
const readCache = () => { try { return JSON.parse(io.read(statePath('x-meta-cache.json'))); } catch { return {}; } };
|
|
45
|
+
const writeCache = (v) => { try { io.write(statePath('x-meta-cache.json'), JSON.stringify(v, null, 1)); } catch {} };
|
|
46
|
+
const normText = (s) => String(s || '').toLowerCase().replace(/https?:\/\/\S+/g, '').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
47
|
+
|
|
48
|
+
// Which chain a post is talking about, from its own words: a mint or contract in the text beats chain
|
|
49
|
+
// words. Returns the chain name, or null when the post names none (most posts). `ours` is the token's chain.
|
|
50
|
+
export function postChain(text, ours) {
|
|
51
|
+
const t = String(text || '');
|
|
52
|
+
if (SOL_MINT_IN_TEXT.test(t.replace(/\$\w+/g, ''))) return 'solana';
|
|
53
|
+
if (EVM_ADDR_IN_TEXT.test(t)) return ours === 'robinhood' || ours === 'base' || ours === 'ethereum' || ours === 'bsc' ? ours : 'evm';
|
|
54
|
+
for (const [chain, re] of Object.entries(CHAIN_WORDS)) if (re.test(t)) return chain;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Pure: turn a SocialData tweet list into the card's facts.
|
|
59
|
+
export function summariseTweets(tweets, { symbol, chain, now = Date.now() } = {}) {
|
|
60
|
+
const since = now - WINDOW_H * 3600_000;
|
|
61
|
+
const rows = (Array.isArray(tweets) ? tweets : []).filter((t) => {
|
|
62
|
+
const ts = Date.parse(t.tweet_created_at || t.created_at || '');
|
|
63
|
+
return Number.isFinite(ts) && ts >= since && ts <= now + 300_000;
|
|
64
|
+
});
|
|
65
|
+
const originals = rows.filter((t) => !t.retweeted_status);
|
|
66
|
+
const byAuthor = new Map();
|
|
67
|
+
const textCount = new Map();
|
|
68
|
+
let views = 0, likes = 0, replies = 0, chainHits = 0, otherChainHits = 0;
|
|
69
|
+
for (const t of originals) {
|
|
70
|
+
const u = t.user || {};
|
|
71
|
+
const a = u.screen_name || '?';
|
|
72
|
+
const text = t.full_text || t.text || '';
|
|
73
|
+
const pc = postChain(text, chain);
|
|
74
|
+
const d = byAuthor.get(a) || { handle: a, followers: Number(u.followers_count) || 0, verified: !!u.verified, posts: 0, views: 0, chains: new Set(), sample: '' };
|
|
75
|
+
d.posts++; d.views += Number(t.views_count) || 0; if (pc) d.chains.add(pc); if (!d.sample) d.sample = text.replace(/\s+/g, ' ').slice(0, 140);
|
|
76
|
+
byAuthor.set(a, d);
|
|
77
|
+
views += Number(t.views_count) || 0;
|
|
78
|
+
likes += Number(t.favorite_count) || 0;
|
|
79
|
+
replies += Number(t.reply_count) || 0;
|
|
80
|
+
if (pc === chain) chainHits++; else if (pc) otherChainHits++;
|
|
81
|
+
const n = normText(text); textCount.set(n, (textCount.get(n) || 0) + 1);
|
|
82
|
+
}
|
|
83
|
+
const posts24 = originals.length;
|
|
84
|
+
const authors = [...byAuthor.values()];
|
|
85
|
+
const top = authors.slice().sort((a, b) => b.posts - a.posts)[0] || null;
|
|
86
|
+
// Copy-paste share: posts whose link-stripped, 80-char text appears more than once, i.e. a template being pushed.
|
|
87
|
+
const copyPaste = [...textCount.values()].filter((c) => c > 1).reduce((s, c) => s + c, 0);
|
|
88
|
+
const copyPasteShare = posts24 ? Math.round((copyPaste / posts24) * 100) : null;
|
|
89
|
+
const followers = authors.map((a) => a.followers).sort((a, b) => a - b);
|
|
90
|
+
const bigAccounts = authors.filter((a) => a.followers >= BIG_FOLLOWERS).sort((a, b) => b.followers - a.followers)
|
|
91
|
+
.map((a) => ({ handle: a.handle, followers: a.followers, posts: a.posts, views: a.views, verified: a.verified,
|
|
92
|
+
otherChain: [...a.chains].find((x) => x !== chain) || null, sample: a.sample }));
|
|
93
|
+
const forGist = originals.slice()
|
|
94
|
+
.sort((a, b) => ((postChain(b.full_text || '', chain) === chain) - (postChain(a.full_text || '', chain) === chain))
|
|
95
|
+
|| ((Number(b.user?.followers_count) || 0) - (Number(a.user?.followers_count) || 0)))
|
|
96
|
+
.slice(0, GIST_POSTS)
|
|
97
|
+
.map((t) => `[@${t.user?.screen_name || '?'} ${Number(t.user?.followers_count) || 0} followers] ${String(t.full_text || t.text || '').replace(/\s+/g, ' ').slice(0, 220)}`);
|
|
98
|
+
return {
|
|
99
|
+
symbol, chain, posts24, rts24: rows.length - originals.length, accounts24: byAuthor.size,
|
|
100
|
+
topAccount: top ? top.handle : null, topShare: top && posts24 ? Math.round((top.posts / posts24) * 100) : null,
|
|
101
|
+
views24: views, likes24: likes, replies24: replies,
|
|
102
|
+
chainMentions: chainHits, // posts that name THIS chain (or carry its contract)
|
|
103
|
+
otherChainMentions: otherChainHits, // posts that name a different chain: the same cashtag elsewhere
|
|
104
|
+
copyPasteShare, // % of posts that are a repeated template
|
|
105
|
+
accountsBig: bigAccounts.length, bigAccounts, medianFollowers: followers.length ? followers[Math.floor(followers.length / 2)] : null,
|
|
106
|
+
sample: forGist,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Measured tone word from the copy-paste share. Not an opinion: 40%+ of posts being one template is a campaign.
|
|
111
|
+
export function toneOf(s) {
|
|
112
|
+
if (!s || s.posts24 == null || s.posts24 < 4 || s.copyPasteShare == null) return null;
|
|
113
|
+
return s.copyPasteShare >= 40 ? 'campaign' : s.copyPasteShare < 20 ? 'organic' : 'mixed';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function fetchTweets(query, key, log) {
|
|
117
|
+
const out = [];
|
|
118
|
+
let cursor = null;
|
|
119
|
+
for (let p = 0; p < PAGES_MAX; p++) {
|
|
120
|
+
const url = `${SD_BASE}/twitter/search?query=${encodeURIComponent(query)}&type=Latest${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
|
|
121
|
+
const r = await fetch(url, { headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' }, signal: AbortSignal.timeout(15_000) });
|
|
122
|
+
if (!r.ok) { log(`[x-meta] socialdata ${r.status} for "${query}"`); return out.length ? out : null; }
|
|
123
|
+
const j = await r.json();
|
|
124
|
+
const tw = Array.isArray(j.tweets) ? j.tweets : [];
|
|
125
|
+
out.push(...tw);
|
|
126
|
+
const oldest = tw.length ? Math.min(...tw.map((t) => Date.parse(t.tweet_created_at || t.created_at || '') || Infinity)) : Infinity;
|
|
127
|
+
cursor = j.next_cursor || null;
|
|
128
|
+
if (!cursor || !tw.length || oldest < Date.now() - WINDOW_H * 3600_000) break;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// One line on what the chatter is about, from the posts already fetched. The measured facts go in so the
|
|
134
|
+
// line explains them (which big account said what, whether the campaign is a listing vote or a raid).
|
|
135
|
+
export async function gistFor(summary, key, log) {
|
|
136
|
+
if (!key || !summary || !summary.sample.length) return null;
|
|
137
|
+
try {
|
|
138
|
+
const facts = `${summary.posts24} posts from ${summary.accounts24} accounts in 24h; ${summary.copyPasteShare ?? '?'}% are a repeated template; ${summary.accountsBig} account(s) with ${BIG_FOLLOWERS.toLocaleString('en-US')}+ followers${summary.bigAccounts.length ? ` (${summary.bigAccounts.slice(0, 5).map((b) => `@${b.handle} ${b.followers.toLocaleString('en-US')}${b.otherChain ? ` - posting about the ${b.otherChain} token` : ''}`).join(', ')})` : ''}; ${summary.chainMentions} name ${summary.chain}, ${summary.otherChainMentions} name another chain.`;
|
|
139
|
+
const r = await fetch('https://api.x.ai/v1/responses', {
|
|
140
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
model: 'grok-4-fast',
|
|
143
|
+
input: `${/^[A-Za-z0-9]+$/.test(summary.symbol) ? `Cashtag $${summary.symbol}` : `Token "${summary.symbol}" (searched by name + chain, not as a cashtag - unrelated uses of the word may appear)`}, a memecoin on the ${summary.chain} chain (the same cashtag may exist on other chains - posts carrying a Solana mint or naming another chain are NOT about this coin).\nMeasured: ${facts}\n\nLast-24h posts (author, followers, text):\n`
|
|
144
|
+
+ summary.sample.map((s, i) => `${i + 1}. ${s}`).join('\n')
|
|
145
|
+
+ `\n\nReply ONLY with JSON: {"meta": "<max 100 chars, plain words: the narrative / catalyst / campaign the ${summary.chain} posts are pushing, or 'no clear meta'>", "chain": "<${summary.chain}|other|mixed|unclear - which chain's token MOST posts are about>", "big": "<max 80 chars: what the largest accounts actually said about THIS coin, or 'none about this coin'>"}`,
|
|
146
|
+
}),
|
|
147
|
+
signal: AbortSignal.timeout(25_000),
|
|
148
|
+
});
|
|
149
|
+
const j = await r.json();
|
|
150
|
+
const txt = (j.output || []).filter((o) => o.type === 'message').flatMap((o) => (o.content || []).map((x) => x.text || '')).join(' ');
|
|
151
|
+
const m = /\{[\s\S]*\}/.exec(txt);
|
|
152
|
+
if (!m) return null;
|
|
153
|
+
const g = JSON.parse(m[0]);
|
|
154
|
+
return { meta: String(g.meta || '').slice(0, 120), chain: String(g.chain || 'unclear').slice(0, 12), big: String(g.big || '').slice(0, 100) };
|
|
155
|
+
} catch (e) { log(`[x-meta] gist ${summary.symbol}: ${e.message}`); return null; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The fresh cache entry for a token, or null.
|
|
159
|
+
export function cachedMeta(c) {
|
|
160
|
+
const hit = readCache()[keyOf(c)];
|
|
161
|
+
return hit && hit.v === 2 && Date.now() - Date.parse(hit.at || 0) < X_META_TTL_H * 3600_000 ? hit : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The X facts for one token, cached X_META_TTL_H. null when there is no key; a note when the symbol is unsearchable.
|
|
165
|
+
export async function xMeta(c, { log = console.error, force = false } = {}) {
|
|
166
|
+
const sdKey = process.env.SOCIALDATA_API_KEY;
|
|
167
|
+
if (!sdKey) return null;
|
|
168
|
+
const symbol = String(c.symbol || '').replace(/^\$/, '').trim();
|
|
169
|
+
const q = queryFor(symbol, c.chain);
|
|
170
|
+
if (!q) return { symbol, posts24: null, note: symbol ? `symbol "${symbol}" is not a searchable cashtag` : 'no symbol to search' };
|
|
171
|
+
const key = keyOf(c);
|
|
172
|
+
const cache = readCache();
|
|
173
|
+
const hit = cache[key];
|
|
174
|
+
if (!force && hit && hit.v === 2 && Date.now() - Date.parse(hit.at || 0) < X_META_TTL_H * 3600_000) return hit;
|
|
175
|
+
let tweets;
|
|
176
|
+
try { tweets = await fetchTweets(q.query, sdKey, log); } catch (e) { log(`[x-meta] "${q.query}": ${e.message}`); return hit || null; }
|
|
177
|
+
if (tweets === null) return hit || null; // provider error: never cached, keep the stale read if any
|
|
178
|
+
const s = summariseTweets(tweets, { symbol, chain: c.chain });
|
|
179
|
+
const gist = await gistFor(s, process.env.XAI_API_KEY, log);
|
|
180
|
+
const out = {
|
|
181
|
+
v: 2, at: new Date().toISOString(), source: 'socialdata', symbol, chain: c.chain, query: q.query, queryMode: q.mode,
|
|
182
|
+
posts24: s.posts24, rts24: s.rts24, accounts24: s.accounts24, topAccount: s.topAccount, topShare: s.topShare,
|
|
183
|
+
views24: s.views24, likes24: s.likes24, replies24: s.replies24, chainMentions: s.chainMentions, otherChainMentions: s.otherChainMentions,
|
|
184
|
+
copyPasteShare: s.copyPasteShare, tone: toneOf(s), accountsBig: s.accountsBig, medianFollowers: s.medianFollowers,
|
|
185
|
+
bigAccounts: s.bigAccounts.slice(0, 8).map(({ sample, ...b }) => b),
|
|
186
|
+
meta: gist ? gist.meta : null, metaChain: gist ? gist.chain : null, big: gist ? gist.big : null,
|
|
187
|
+
};
|
|
188
|
+
const fresh = readCache(); fresh[key] = out; writeCache(fresh);
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const kFollowers = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
|
|
193
|
+
|
|
194
|
+
// The card lines. Pure; `esc` is an optional output escaper (identity for plain text).
|
|
195
|
+
// X 24h: 60 posts | 47 accounts (3 with 10k+) | 8k views | 65% copy-paste (campaign) | 21 name robinhood, 4 another chain
|
|
196
|
+
// big: @handle 32k (solana), @handle 28k
|
|
197
|
+
// meta: listing-vote campaign
|
|
198
|
+
export function xLine(x, esc = (s) => String(s ?? '')) {
|
|
199
|
+
if (!x) return null;
|
|
200
|
+
if (x.posts24 == null) return x.note ? `X: ${esc(x.note)}` : null;
|
|
201
|
+
const k = kFollowers;
|
|
202
|
+
const parts = [`X 24h${x.queryMode === 'name+chain' ? ` (searched "${esc(x.query)}")` : ''}: ${x.posts24} posts`];
|
|
203
|
+
if (x.accounts24 != null) parts.push(`${x.accounts24} accounts${x.accountsBig ? ` (${x.accountsBig} with ${kFollowers(BIG_FOLLOWERS)}+)` : ''}${x.topShare != null && x.posts24 >= 4 && x.topShare >= 40 ? ` (one posts ${x.topShare}%)` : ''}`);
|
|
204
|
+
if (x.views24) parts.push(`${k(x.views24)} views`);
|
|
205
|
+
if (x.copyPasteShare != null && x.posts24 >= 4) parts.push(`${x.copyPasteShare}% copy-paste${x.tone ? ` (${x.tone})` : ''}`);
|
|
206
|
+
if (x.chainMentions != null && x.posts24) parts.push(`${x.chainMentions} name ${x.chain}${x.otherChainMentions ? `, ${x.otherChainMentions} another chain` : ''}`);
|
|
207
|
+
const lines = [parts.join(' | ')];
|
|
208
|
+
if (x.bigAccounts && x.bigAccounts.length) {
|
|
209
|
+
lines.push(`big: ${x.bigAccounts.slice(0, BIG_SHOWN).map((b) => `@${esc(b.handle)} ${kFollowers(b.followers)}${b.otherChain ? ` (${esc(b.otherChain)})` : ''}${b.posts > 1 ? ` x${b.posts}` : ''}`).join(', ')}${x.bigAccounts.length > BIG_SHOWN ? ` +${x.bigAccounts.length - BIG_SHOWN} more` : ''}${x.big && !/^none/i.test(x.big) ? ` - ${esc(x.big)}` : ''}`);
|
|
210
|
+
}
|
|
211
|
+
if (x.meta) lines.push(`meta: ${esc(x.meta)}${x.metaChain && x.metaChain !== x.chain && x.metaChain !== 'unclear' ? ` (most posts read as ${esc(x.metaChain)}-chain)` : ''}`);
|
|
212
|
+
else if (x.posts24 === 0) lines[0] += ' - quiet';
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@keelage/mcp",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Token intelligence for Robinhood Chain, as an MCP server. Structural verdicts, holder maps, launcher templates and a published track record. Research only, not financial advice.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://keelage.ai",
|
|
8
|
+
"bin": {
|
|
9
|
+
"keelage": "bin/keelage.mjs"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./lib/gate.mjs",
|
|
13
|
+
"./render": "./lib/render.mjs",
|
|
14
|
+
"./holders": "./lib/holders.mjs",
|
|
15
|
+
"./template": "./lib/template.mjs",
|
|
16
|
+
"./record": "./lib/record.mjs",
|
|
17
|
+
"./server": "./server/mcp.mjs"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"lib",
|
|
22
|
+
"server",
|
|
23
|
+
"data",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"start": "node bin/keelage.mjs",
|
|
29
|
+
"test": "node test/pure.mjs",
|
|
30
|
+
"test:live": "node test/reference.mjs"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"mcp",
|
|
37
|
+
"robinhood-chain",
|
|
38
|
+
"token",
|
|
39
|
+
"research",
|
|
40
|
+
"model-context-protocol"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/server/core.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// core.mjs - the Model Context Protocol server, transport-free. No dependencies.
|
|
2
|
+
//
|
|
3
|
+
// Transport: newline-delimited JSON-RPC 2.0 on stdin/stdout. Logs go to stderr only; stdout carries protocol
|
|
4
|
+
// frames and nothing else. Read-only: no wallet, no key, nothing here can spend.
|
|
5
|
+
import { scanOne } from '../lib/gate.mjs';
|
|
6
|
+
import { renderScanJson } from '../lib/render.mjs';
|
|
7
|
+
import { holdersFor } from '../lib/holders.mjs';
|
|
8
|
+
import { templateFor } from '../lib/template.mjs';
|
|
9
|
+
import { trackRecord } from '../lib/record.mjs';
|
|
10
|
+
import { knownRulesets, THRESHOLDS } from '../lib/calc.mjs';
|
|
11
|
+
|
|
12
|
+
export const SERVER_INFO = { name: 'keelage', title: 'Keelage', version: '0.1.0' };
|
|
13
|
+
const SUPPORTED = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
|
|
14
|
+
const ADDRESS_SCHEMA = { type: 'string', description: 'Token contract address on Robinhood Chain (0x + 40 hex characters).', pattern: '^0x[0-9a-fA-F]{40}$' };
|
|
15
|
+
// The ruleset the verdict is computed under: a preset by name, or the default with some lines replaced.
|
|
16
|
+
const RULESET_SCHEMA = {
|
|
17
|
+
description: `Which ruleset the verdict is computed under. A preset name (${knownRulesets().join(', ')}; "default" is short for keelage-default), or { lines: [{ key, value }] } to replace lines of keelage-default. Keys are the ones in keelage_record's rules: ${THRESHOLDS.lines.map((l) => l.key).join(', ')}. The facts never change; only the policy over them does. Omitted: keelage-default.`,
|
|
18
|
+
oneOf: [
|
|
19
|
+
{ type: 'string', enum: [...knownRulesets(), 'default'] },
|
|
20
|
+
{
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: { lines: { type: 'array', minItems: 1, items: { type: 'object', properties: { key: { type: 'string' }, value: { description: 'A number, or for a band line an array of numbers of the same length as the default.' } }, required: ['key', 'value'] } } },
|
|
23
|
+
required: ['lines'],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const TOOLS = [
|
|
29
|
+
{
|
|
30
|
+
name: 'keelage_scan',
|
|
31
|
+
title: 'Scan a token',
|
|
32
|
+
description: 'Full structural verdict for one Robinhood Chain token: verified source, launcher template, owner privileges, transfer-tax code, honeypot flags, sells clearing, holder concentration tier, ATH drawdown and 30-day-low band, entry test and size rule, plus Jev probabilities as context when TYPESAFE_API_KEY is set. Four verdict states: ELIGIBLE, NOT_ELIGIBLE, FAIL, UNVERIFIED, computed under a named, dated ruleset (the optional ruleset argument; the answer names it under verdict.ruleset) and marked beta; the facts themselves are measured from the chain and timestamped. Research only, not financial advice.',
|
|
33
|
+
inputSchema: { type: 'object', properties: { address: ADDRESS_SCHEMA, ruleset: RULESET_SCHEMA }, required: ['address'] },
|
|
34
|
+
annotations: { readOnlyHint: true, openWorldHint: true, idempotentHint: true },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'keelage_holders',
|
|
38
|
+
title: 'Holder map',
|
|
39
|
+
description: 'Top wallets for one token with pools, contracts and burn addresses labeled, top-10 share excluding pools and contracts, burn share, and the share held by EIP-7702 smart accounts (how the Robinhood app wallet reads).',
|
|
40
|
+
inputSchema: { type: 'object', properties: { address: ADDRESS_SCHEMA, smartAccounts: { type: 'boolean', description: 'Check each listed wallet for EIP-7702 delegation (one RPC call per wallet). Default true.' } }, required: ['address'] },
|
|
41
|
+
annotations: { readOnlyHint: true, openWorldHint: true, idempotentHint: true },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'keelage_template',
|
|
45
|
+
title: 'Launcher template',
|
|
46
|
+
description: 'Which launcher template a contract came from (Pons, LaunchToken, Doppler, UERC20, tokenized stock, or unregistered), with the template verdict, the ABI privileges and the transfer-tax identifier count read from verified source.',
|
|
47
|
+
inputSchema: { type: 'object', properties: { address: ADDRESS_SCHEMA }, required: ['address'] },
|
|
48
|
+
annotations: { readOnlyHint: true, openWorldHint: true, idempotentHint: true },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'keelage_record',
|
|
52
|
+
title: 'Track record',
|
|
53
|
+
description: 'The published track record behind the verdicts: dated studies with sample sizes (gate policy outcomes, template census, base-band outcomes, probability calibration). Optionally one study by key.',
|
|
54
|
+
inputSchema: { type: 'object', properties: { study: { type: 'string', description: 'Study key, e.g. gate-policy-review, template-census, base-band-outcomes, jev-halve-calibration. Omit for all.' } } },
|
|
55
|
+
annotations: { readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export async function callTool(name, args = {}) {
|
|
60
|
+
switch (name) {
|
|
61
|
+
case 'keelage_scan': return renderScanJson(await scanOne(args.address, { log: (...a) => console.error(...a), ruleset: args.ruleset ?? 'default' }));
|
|
62
|
+
case 'keelage_holders': return holdersFor(args.address, { withSmartAccounts: args.smartAccounts !== false });
|
|
63
|
+
case 'keelage_template': return templateFor(args.address);
|
|
64
|
+
case 'keelage_record': return trackRecord({ study: args.study || null });
|
|
65
|
+
default: throw Object.assign(new Error(`unknown tool ${name}`), { code: -32602 });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Prices per call in USDG on Robinhood Chain, as published on keelage.ai/#pricing. Free tools are never counted.
|
|
70
|
+
export const TOOL_PRICES = { keelage_scan: '0.05', keelage_holders: '0.03' };
|
|
71
|
+
export const FREE_TOOLS = ['keelage_record', 'keelage_template'];
|
|
72
|
+
export const FREE_CALLS_PER_DAY = 20;
|
|
73
|
+
export { SUPPORTED };
|
|
74
|
+
|
|
75
|
+
const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
|
|
76
|
+
const err = (id, code, message, data) => ({ jsonrpc: '2.0', id, error: { code, message, ...(data ? { data } : {}) } });
|
|
77
|
+
|
|
78
|
+
// One JSON-RPC message in, one response object out (undefined for a notification). Transports write it.
|
|
79
|
+
export async function dispatch(msg) {
|
|
80
|
+
const { id, method, params = {} } = msg;
|
|
81
|
+
const isNotification = id === undefined || id === null;
|
|
82
|
+
try {
|
|
83
|
+
switch (method) {
|
|
84
|
+
case 'initialize': {
|
|
85
|
+
const asked = String(params.protocolVersion || '');
|
|
86
|
+
const protocolVersion = SUPPORTED.includes(asked) ? asked : SUPPORTED[0];
|
|
87
|
+
return ok(id, { protocolVersion, capabilities: { tools: { listChanged: false } }, serverInfo: SERVER_INFO,
|
|
88
|
+
instructions: 'Keelage reads Robinhood Chain tokens from the chain and answers with a structural verdict, a holder map, a launcher template and the published track record. Every answer is research only, not financial advice. Unknown facts are null, never zero.' });
|
|
89
|
+
}
|
|
90
|
+
case 'notifications/initialized': return;
|
|
91
|
+
case 'notifications/cancelled': return;
|
|
92
|
+
case 'ping': return ok(id, {});
|
|
93
|
+
case 'tools/list': return ok(id, { tools: TOOLS });
|
|
94
|
+
case 'tools/call': {
|
|
95
|
+
const name = params.name, args = params.arguments || {};
|
|
96
|
+
if (!TOOLS.some((t) => t.name === name)) return err(id, -32602, `unknown tool ${name}`);
|
|
97
|
+
try {
|
|
98
|
+
const result = await callTool(name, args);
|
|
99
|
+
return ok(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], structuredContent: result, isError: result && result.ok === false });
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return ok(id, { content: [{ type: 'text', text: `error: ${e.message}` }], isError: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
case 'resources/list': return ok(id, { resources: [] });
|
|
105
|
+
case 'prompts/list': return ok(id, { prompts: [] });
|
|
106
|
+
default:
|
|
107
|
+
if (isNotification) return;
|
|
108
|
+
return err(id, -32601, `method not found: ${method}`);
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (!isNotification) return err(id, -32603, e.message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// hosted-io.mjs - storage for a bundled, filesystem-free runtime (the hosted server on Cloudflare Pages).
|
|
2
|
+
// Import this module FIRST: it installs the in-memory store before calc.mjs reads thresholds.json at load.
|
|
3
|
+
// The data files are embedded at bundle time; caches live in memory for the life of the isolate.
|
|
4
|
+
import { setIO, memoryIO } from '../lib/io.mjs';
|
|
5
|
+
import thresholds from '../data/thresholds.json' with { type: 'json' };
|
|
6
|
+
import trackRecord from '../data/track-record.json' with { type: 'json' };
|
|
7
|
+
import templates from '../lib/templates.json' with { type: 'json' };
|
|
8
|
+
|
|
9
|
+
setIO(memoryIO({ 'data/thresholds.json': thresholds, 'data/track-record.json': trackRecord, 'lib/templates.json': templates }));
|
package/server/mcp.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// mcp.mjs - the Model Context Protocol server over stdio. No dependencies.
|
|
2
|
+
// Transport: newline-delimited JSON-RPC 2.0 on stdin/stdout. Logs go to stderr only; stdout carries protocol
|
|
3
|
+
// messages and nothing else. The protocol itself lives in core.mjs and is shared with the hosted server.
|
|
4
|
+
import { createInterface } from 'readline';
|
|
5
|
+
import { dispatch } from './core.mjs';
|
|
6
|
+
export { SERVER_INFO, TOOLS, callTool, dispatch, TOOL_PRICES, FREE_TOOLS, FREE_CALLS_PER_DAY } from './core.mjs';
|
|
7
|
+
|
|
8
|
+
export async function handle(msg) {
|
|
9
|
+
const out = await dispatch(msg);
|
|
10
|
+
if (out !== undefined) process.stdout.write(JSON.stringify(out) + '\n');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function serve() {
|
|
14
|
+
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
15
|
+
rl.on('line', (line) => {
|
|
16
|
+
const s = line.trim();
|
|
17
|
+
if (!s) return;
|
|
18
|
+
let msg;
|
|
19
|
+
try { msg = JSON.parse(s); } catch { return fail(null, -32700, 'parse error'); }
|
|
20
|
+
if (Array.isArray(msg)) { for (const m of msg) handle(m); return; }
|
|
21
|
+
handle(msg);
|
|
22
|
+
});
|
|
23
|
+
rl.on('close', () => process.exit(0));
|
|
24
|
+
process.stdin.on('end', () => process.exit(0));
|
|
25
|
+
}
|