@keelage/mcp 0.1.1 → 0.2.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/README.md CHANGED
@@ -24,6 +24,7 @@ Or from a checkout: `node core/bin/keelage.mjs` starts the stdio server.
24
24
  |---|---|
25
25
  | `keelage_scan` | The structural verdict for one 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, size rule. Four states: `ELIGIBLE`, `NOT_ELIGIBLE`, `FAIL`, `UNVERIFIED`. |
26
26
  | `keelage_holders` | Top wallets with pools, contracts and burn addresses labeled; top-10 share excluding pools and contracts; burn share; share held by EIP-7702 smart accounts. |
27
+ | `keelage_x` | What X is saying about the token in the last 24 hours: posts and accounts, repeated-template share, large profiles and the chain they meant, views, a one-line read of the narrative. Its own call: a scan never reads X. Offered only where the server has a posts source. |
27
28
  | `keelage_template` | Which launcher template the contract came from, with the template verdict, the ABI privileges and the transfer-tax identifier count. |
28
29
  | `keelage_record` | The dated studies, with sample sizes, behind every rule. |
29
30
 
@@ -63,7 +64,7 @@ A stricter preset ships only when every line it moves has a documented source: a
63
64
 
64
65
  ## Hosted
65
66
 
66
- The same server runs at `https://mcp.keelage.ai/mcp` over Streamable HTTP (POST JSON-RPC; GET answers 405). No key and no sign-in. Per caller, 20 counted calls a day across `keelage_scan` and `keelage_holders`; `keelage_record` and `keelage_template` are never counted. The caller is the `X-Wallet` header when you send one (a 0x address), otherwise a salted hash of the network address that changes daily. Past the quota a priced tool answers with its price in USDG on Robinhood Chain and `x402.status: "not open yet"`; settlement opens when the merchant address is published. The hosted code is this package plus one transport file, so the two give the same answers.
67
+ The same server runs at `https://mcp.keelage.ai/mcp` over Streamable HTTP (POST JSON-RPC; GET answers 405). No key and no sign-in. Per caller, 20 counted calls a day across `keelage_scan`, `keelage_holders` and `keelage_x`; `keelage_record` and `keelage_template` are never counted. The caller is the `X-Wallet` header when you send one (a 0x address), otherwise a salted hash of the network address that changes daily. Past the quota a priced tool draws on the caller's prepaid credit, or answers with its price in USDG on Robinhood Chain and the payment terms: send USDG to the merchant address on [keelage.ai/#ledger](https://keelage.ai/#ledger), register the transaction once with the hosted-only tool `keelage_pay`, and the wallet that sent it is credited; later calls that carry `X-Wallet` draw on it. The server holds no wallet key; it reads receipts from the chain. The hosted code is this package plus one transport file, so the two give the same answers.
67
68
 
68
69
  ## Command line
69
70
 
package/bin/keelage.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  // keelage serve same
6
6
  // keelage scan <0x> [--text] [--ruleset <name|json>] verdict as JSON (or the text card)
7
7
  // keelage holders <0x> [--no-smart]
8
+ // keelage x <0x> [--fresh] what X is saying in the last 24h (needs SOCIALDATA_API_KEY)
8
9
  // keelage template <0x>
9
10
  // keelage record [study-key]
10
11
  // keelage tools the MCP tool list
@@ -12,6 +13,7 @@ import { scanOne, renderScanText } from '../lib/gate.mjs';
12
13
  import { renderScanJson } from '../lib/render.mjs';
13
14
  import { holdersFor } from '../lib/holders.mjs';
14
15
  import { templateFor } from '../lib/template.mjs';
16
+ import { xFor } from '../lib/x.mjs';
15
17
  import { trackRecord } from '../lib/record.mjs';
16
18
  import { serve, TOOLS } from '../server/mcp.mjs';
17
19
 
@@ -44,11 +46,14 @@ else if (cmd === 'scan') {
44
46
  } else if (cmd === 'holders') {
45
47
  if (!arg) { log('usage: keelage holders <0x address> [--no-smart]'); process.exit(2); }
46
48
  out(await holdersFor(arg, { withSmartAccounts: !flags.has('--no-smart') }));
49
+ } else if (cmd === 'x') {
50
+ if (!arg) { log('usage: keelage x <0x address> [--fresh]'); process.exit(2); }
51
+ out(await xFor(arg, { log, force: flags.has('--fresh') }));
47
52
  } else if (cmd === 'template') {
48
53
  if (!arg) { log('usage: keelage template <0x address>'); process.exit(2); }
49
54
  out(await templateFor(arg));
50
55
  } else if (cmd === 'record') { out(trackRecord({ study: arg || null })); }
51
56
  else if (cmd === 'tools') { out(TOOLS); }
52
57
  else if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
53
- process.stdout.write('keelage [serve] | scan <0x> [--text] [--ruleset <name|json>] | holders <0x> [--no-smart] | template <0x> | record [study] | tools\n');
58
+ process.stdout.write('keelage [serve] | scan <0x> [--text] [--ruleset <name|json>] | holders <0x> [--no-smart] | x <0x> [--fresh] | template <0x> | record [study] | tools\n');
54
59
  } else { log(`unknown command ${cmd}`); process.exit(2); }
package/lib/pay.mjs ADDED
@@ -0,0 +1,75 @@
1
+ // pay.mjs - how a paid call is paid: the terms a priced tool quotes, and the receipt check that turns a USDG
2
+ // transfer on Robinhood Chain into prepaid credit. Pure: no network here. The hosted transport fetches the
3
+ // receipt and the ledger; this module only reads them.
4
+ //
5
+ // The scheme is a receipt, not a signature: the caller sends USDG to the merchant address from their own wallet,
6
+ // then registers the transaction hash once (keelage_pay). The sending wallet is credited and spends by sending
7
+ // the X-Wallet header on later calls. No key sits on any server; the server only reads the chain.
8
+ export const PAY = Object.freeze({
9
+ chainId: 4663,
10
+ network: 'eip155:4663',
11
+ rpc: 'https://rpc.mainnet.chain.robinhood.com',
12
+ asset: '0x5fc5360d0400a0fd4f2af552add042d716f1d168', // USDG, 6 decimals, EIP-1967 proxy
13
+ assetSymbol: 'USDG',
14
+ decimals: 6,
15
+ payTo: '0xaE313a57B2CE1cbd31bB82C90ced3CFaC0862bA1', // Keelage merchant, receive-only, published on keelage.ai/#ledger
16
+ treasury: '0x000936C5EE6A180C26F3Edf0aF16c6bF0DFB7171',
17
+ transferTopic: '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
18
+ minDepositUsdg: '0.10', // below this a deposit is still credited, but the tx fee alone outweighs it
19
+ });
20
+
21
+ const ONE = 10n ** BigInt(PAY.decimals);
22
+
23
+ // Units (bigint, 6 decimals) to a plain decimal string: 1234567n -> "1.234567", 50000n -> "0.05".
24
+ export function unitsToUsdg(units) {
25
+ const u = BigInt(units); const neg = u < 0n; const a = neg ? -u : u;
26
+ const whole = a / ONE, frac = (a % ONE).toString().padStart(PAY.decimals, '0').replace(/0+$/, '');
27
+ return (neg ? '-' : '') + whole.toString() + (frac ? '.' + frac : '');
28
+ }
29
+
30
+ // A decimal string ("0.05") to units. Throws on anything that is not a plain non-negative decimal.
31
+ export function usdgToUnits(s) {
32
+ const m = /^(\d+)(?:\.(\d{1,6}))?$/.exec(String(s).trim());
33
+ if (!m) throw new Error(`not a USDG amount: ${s}`);
34
+ return BigInt(m[1]) * ONE + BigInt((m[2] || '').padEnd(PAY.decimals, '0'));
35
+ }
36
+
37
+ const addrOfTopic = (t) => '0x' + String(t || '').slice(-40).toLowerCase();
38
+
39
+ // Read a transaction receipt (eth_getTransactionReceipt result) for USDG transfers to the merchant address.
40
+ // Returns { ok, txHash, block, from, units, amount } summing every qualifying Transfer log, or { ok:false, err }.
41
+ // A receipt with status 0x0, no qualifying log, or several senders is refused; nothing is credited from it.
42
+ export function depositFromReceipt(receipt, { payTo = PAY.payTo, asset = PAY.asset } = {}) {
43
+ if (!receipt || typeof receipt !== 'object') return { ok: false, err: 'no receipt: the transaction is not mined yet, or the hash is wrong' };
44
+ if (receipt.status !== '0x1' && receipt.status !== 1) return { ok: false, err: 'the transaction reverted; nothing moved' };
45
+ const to = payTo.toLowerCase(), token = asset.toLowerCase();
46
+ const senders = new Map();
47
+ for (const log of receipt.logs || []) {
48
+ if (String(log.address || '').toLowerCase() !== token) continue;
49
+ const [sig, fromT, toT] = log.topics || [];
50
+ if (String(sig || '').toLowerCase() !== PAY.transferTopic || addrOfTopic(toT) !== to) continue;
51
+ const from = addrOfTopic(fromT);
52
+ let units; try { units = BigInt(log.data); } catch { continue; }
53
+ if (units <= 0n) continue;
54
+ senders.set(from, (senders.get(from) || 0n) + units);
55
+ }
56
+ if (!senders.size) return { ok: false, err: `no ${PAY.assetSymbol} transfer to ${PAY.payTo} in this transaction` };
57
+ if (senders.size > 1) return { ok: false, err: 'the transaction carries transfers from more than one wallet; send from one wallet' };
58
+ const [[from, units]] = senders;
59
+ return { ok: true, txHash: String(receipt.transactionHash || '').toLowerCase(), block: receipt.blockNumber ? Number(BigInt(receipt.blockNumber)) : null, from, units, amount: unitsToUsdg(units) };
60
+ }
61
+
62
+ // The terms a priced tool quotes when the free calls are used: what to pay, where, and how to be recognised.
63
+ export function paymentTerms(tool, price) {
64
+ return {
65
+ version: 1, status: 'open', scheme: 'receipt',
66
+ network: PAY.network, chainId: PAY.chainId, asset: PAY.asset, assetSymbol: PAY.assetSymbol, decimals: PAY.decimals,
67
+ payTo: PAY.payTo, amount: price, per: 'call', tool,
68
+ steps: [
69
+ `Send ${PAY.assetSymbol} on Robinhood Chain (chain id ${PAY.chainId}) from your wallet to ${PAY.payTo}. Any amount; ${PAY.minDepositUsdg} or more makes sense against the gas.`,
70
+ 'Call keelage_pay with { tx: "<transaction hash>" } once. The sending wallet is credited with the amount.',
71
+ 'Send the header X-Wallet: <your address> on every call. Priced calls past the free 20 a day draw on the credit at the price quoted on keelage.ai/#pricing.',
72
+ ],
73
+ ledger: 'https://keelage.ai/#ledger',
74
+ };
75
+ }
package/lib/x.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // x.mjs - the keelage_x tool: what X is saying about one token in the last 24 hours, as its own paid call.
2
+ //
3
+ // A scan does not read X (the verdict is structural); this call does, and is priced separately. The facts come
4
+ // from x-meta.mjs: posts and accounts in 24h, copy-paste share, large profiles and the chain they meant, views,
5
+ // and a one-line model-written gist. Display only: never a gate, never a rank key.
6
+ //
7
+ // Sources: the posts need SOCIALDATA_API_KEY; the gist needs XAI_API_KEY (no gist key = facts without the line).
8
+ // Without the posts key the tool is not offered (tools/list leaves it out) and a direct call answers an error
9
+ // before any network read, so a caller is never charged for an empty answer.
10
+ import { isAddress, getJson, candidateFromPairs, CHAIN } from './gate.mjs';
11
+ import { xMeta, xLine, X_META_TTL_H } from './x-meta.mjs';
12
+
13
+ export function xConfigured() { return !!process.env.SOCIALDATA_API_KEY; }
14
+
15
+ export async function xFor(address, { log = console.error, force = false } = {}) {
16
+ const raw = String(address || '').trim();
17
+ if (!isAddress(raw)) return { ok: false, address: raw, err: 'not a contract address (0x + 40 hex characters)' };
18
+ const a = raw.toLowerCase();
19
+ if (!xConfigured()) return { ok: false, address: a, err: 'X read not available on this server: no posts source configured' };
20
+ const d = await getJson(`https://api.dexscreener.com/tokens/v1/${CHAIN}/${a}`);
21
+ if (d?.__err) return { ok: false, address: a, err: `dexscreener: ${d.__err}` };
22
+ const c = candidateFromPairs(CHAIN, a, d);
23
+ if (!c) return { ok: false, address: a, err: `no DexScreener pair on ${CHAIN} for this address (not traded here, or a different chain)` };
24
+ let x = null;
25
+ try { x = await xMeta(c, { log, force }); } catch (e) { return { ok: false, address: a, symbol: c.symbol, err: `x: ${e.message}` }; }
26
+ if (!x) return { ok: false, address: a, symbol: c.symbol, err: 'X posts source answered with an error; nothing cached for this token' };
27
+ const { v, ...facts } = x;
28
+ return {
29
+ ok: true, address: a, chain: CHAIN, symbol: c.symbol, name: c.name || null,
30
+ windowHours: 24, cacheHours: X_META_TTL_H,
31
+ sources: { posts: facts.source || 'socialdata', gist: facts.meta != null ? 'xai' : null },
32
+ ...facts,
33
+ line: xLine(x),
34
+ note: 'Context only: X chatter is never a gate and never a rank key in a Keelage verdict.',
35
+ };
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keelage/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
package/server/core.mjs CHANGED
@@ -7,9 +7,16 @@ import { renderScanJson } from '../lib/render.mjs';
7
7
  import { holdersFor } from '../lib/holders.mjs';
8
8
  import { templateFor } from '../lib/template.mjs';
9
9
  import { trackRecord } from '../lib/record.mjs';
10
+ import { xFor, xConfigured } from '../lib/x.mjs';
10
11
  import { knownRulesets, THRESHOLDS } from '../lib/calc.mjs';
11
12
 
12
- export const SERVER_INFO = { name: 'keelage', title: 'Keelage', version: '0.1.0' };
13
+ // The version the server reports is the package's, read through io so the hosted bundle (package.json embedded) agrees.
14
+ import { io } from '../lib/io.mjs';
15
+ import { dirname, join } from 'path';
16
+ import { fileURLToPath } from 'url';
17
+ const PKG_DIR = (() => { try { return join(dirname(fileURLToPath(import.meta.url)), '..'); } catch { return '/keelage'; } })();
18
+ const PKG_VERSION = (() => { try { return JSON.parse(io.read(join(PKG_DIR, 'package.json'))).version; } catch { return '0.0.0'; } })();
19
+ export const SERVER_INFO = { name: 'keelage', title: 'Keelage', version: PKG_VERSION };
13
20
  const SUPPORTED = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05'];
14
21
  const ADDRESS_SCHEMA = { type: 'string', description: 'Token contract address on Robinhood Chain (0x + 40 hex characters).', pattern: '^0x[0-9a-fA-F]{40}$' };
15
22
  // The ruleset the verdict is computed under: a preset by name, or the default with some lines replaced.
@@ -40,6 +47,13 @@ export const TOOLS = [
40
47
  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
48
  annotations: { readOnlyHint: true, openWorldHint: true, idempotentHint: true },
42
49
  },
50
+ {
51
+ name: 'keelage_x',
52
+ title: 'What X is saying',
53
+ description: 'What X is saying about one Robinhood Chain token in the last 24 hours: posts and accounts, how much is a repeated template, which large profiles posted and whether they meant this chain\'s token, views, and a one-line read of the narrative. Context only, never part of the verdict; a scan does not read X. Cached 6 hours per token.',
54
+ inputSchema: { type: 'object', properties: { address: ADDRESS_SCHEMA, fresh: { type: 'boolean', description: 'Skip the 6-hour cache and read X again. Default false.' } }, required: ['address'] },
55
+ annotations: { readOnlyHint: true, openWorldHint: true, idempotentHint: true },
56
+ },
43
57
  {
44
58
  name: 'keelage_template',
45
59
  title: 'Launcher template',
@@ -58,8 +72,9 @@ export const TOOLS = [
58
72
 
59
73
  export async function callTool(name, args = {}) {
60
74
  switch (name) {
61
- case 'keelage_scan': return renderScanJson(await scanOne(args.address, { log: (...a) => console.error(...a), ruleset: args.ruleset ?? 'default' }));
75
+ case 'keelage_scan': return renderScanJson(await scanOne(args.address, { log: (...a) => console.error(...a), ruleset: args.ruleset ?? 'default', withX: false }));
62
76
  case 'keelage_holders': return holdersFor(args.address, { withSmartAccounts: args.smartAccounts !== false });
77
+ case 'keelage_x': return xFor(args.address, { log: (...a) => console.error(...a), force: args.fresh === true });
63
78
  case 'keelage_template': return templateFor(args.address);
64
79
  case 'keelage_record': return trackRecord({ study: args.study || null });
65
80
  default: throw Object.assign(new Error(`unknown tool ${name}`), { code: -32602 });
@@ -67,7 +82,9 @@ export async function callTool(name, args = {}) {
67
82
  }
68
83
 
69
84
  // 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' };
85
+ export const TOOL_PRICES = { keelage_scan: '0.05', keelage_holders: '0.03', keelage_x: '0.02' };
86
+ // keelage_x is offered only where a posts source is configured; a scan never reads X, so the X call is its own price.
87
+ export const offeredTools = () => TOOLS.filter((t) => t.name !== 'keelage_x' || xConfigured());
71
88
  export const FREE_TOOLS = ['keelage_record', 'keelage_template'];
72
89
  export const FREE_CALLS_PER_DAY = 20;
73
90
  export { SUPPORTED };
@@ -85,12 +102,12 @@ export async function dispatch(msg) {
85
102
  const asked = String(params.protocolVersion || '');
86
103
  const protocolVersion = SUPPORTED.includes(asked) ? asked : SUPPORTED[0];
87
104
  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.' });
105
+ instructions: 'Keelage reads Robinhood Chain tokens from the chain and answers with a structural verdict, a holder map, a launcher template, the published track record and, as its own call, what X is saying. Every answer is research only, not financial advice. Unknown facts are null, never zero.' });
89
106
  }
90
107
  case 'notifications/initialized': return;
91
108
  case 'notifications/cancelled': return;
92
109
  case 'ping': return ok(id, {});
93
- case 'tools/list': return ok(id, { tools: TOOLS });
110
+ case 'tools/list': return ok(id, { tools: offeredTools() });
94
111
  case 'tools/call': {
95
112
  const name = params.name, args = params.arguments || {};
96
113
  if (!TOOLS.some((t) => t.name === name)) return err(id, -32602, `unknown tool ${name}`);
@@ -5,5 +5,6 @@ import { setIO, memoryIO } from '../lib/io.mjs';
5
5
  import thresholds from '../data/thresholds.json' with { type: 'json' };
6
6
  import trackRecord from '../data/track-record.json' with { type: 'json' };
7
7
  import templates from '../lib/templates.json' with { type: 'json' };
8
+ import pkg from '../package.json' with { type: 'json' };
8
9
 
9
- setIO(memoryIO({ 'data/thresholds.json': thresholds, 'data/track-record.json': trackRecord, 'lib/templates.json': templates }));
10
+ setIO(memoryIO({ 'data/thresholds.json': thresholds, 'data/track-record.json': trackRecord, 'lib/templates.json': templates, 'package.json': pkg }));