@noelclaw/mcp 2.3.1 → 3.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.
@@ -5,6 +5,66 @@ exports.handleHumanizerTool = handleHumanizerTool;
5
5
  const zod_1 = require("zod");
6
6
  const llm_js_1 = require("../llm.js");
7
7
  exports.HUMANIZER_TOOLS = [
8
+ {
9
+ name: "write_thread",
10
+ description: "Write a viral Twitter/X thread on any crypto or tech topic. " +
11
+ "Returns a numbered thread (1/, 2/, ...) with a hook tweet, " +
12
+ "3-7 content tweets, and a strong closer with CTA. " +
13
+ "Written in a direct, punchy voice — no fluff, no AI tells. " +
14
+ "Optionally provide your own voice sample to match your style.",
15
+ inputSchema: {
16
+ type: "object",
17
+ properties: {
18
+ topic: {
19
+ type: "string",
20
+ description: "The topic or angle for the thread, e.g. 'why Base is winning', 'how I use MCP agents for DeFi research'",
21
+ },
22
+ tone: {
23
+ type: "string",
24
+ enum: ["alpha", "educational", "opinion", "story"],
25
+ description: "Thread tone: 'alpha' (edge/insight), 'educational' (explainer), 'opinion' (hot take), 'story' (personal narrative). Default: opinion.",
26
+ },
27
+ tweets: {
28
+ type: "number",
29
+ description: "Number of tweets in the thread, 4–12 (default: 7)",
30
+ },
31
+ voice_sample: {
32
+ type: "string",
33
+ description: "Optional: paste 1-3 of your existing tweets to match your voice",
34
+ },
35
+ },
36
+ required: ["topic"],
37
+ },
38
+ },
39
+ {
40
+ name: "write_post",
41
+ description: "Write a single viral-style crypto/tech post for Twitter/X. " +
42
+ "Returns one punchy post under 280 characters (or up to 500 with long-form enabled). " +
43
+ "Hooks in the first line, delivers the insight, ends with impact. No AI fluff.",
44
+ inputSchema: {
45
+ type: "object",
46
+ properties: {
47
+ topic: {
48
+ type: "string",
49
+ description: "What to post about — a thought, observation, alpha, or hot take",
50
+ },
51
+ style: {
52
+ type: "string",
53
+ enum: ["hook", "hot-take", "alpha", "question", "observation"],
54
+ description: "Post style. Default: hook.",
55
+ },
56
+ long: {
57
+ type: "boolean",
58
+ description: "Allow up to 500 chars (long-form post). Default: false (280 chars max)",
59
+ },
60
+ voice_sample: {
61
+ type: "string",
62
+ description: "Optional: a sample of your writing to match your voice",
63
+ },
64
+ },
65
+ required: ["topic"],
66
+ },
67
+ },
8
68
  {
9
69
  name: "humanize_text",
10
70
  description: "Remove AI writing patterns from text — makes it sound natural, direct, and human. " +
@@ -28,8 +88,20 @@ exports.HUMANIZER_TOOLS = [
28
88
  },
29
89
  ];
30
90
  const HumanizerSchema = zod_1.z.object({
31
- text: zod_1.z.string().min(1),
32
- voice_sample: zod_1.z.string().optional(),
91
+ text: zod_1.z.string().min(1).max(20000),
92
+ voice_sample: zod_1.z.string().max(5000).optional(),
93
+ });
94
+ const WriteThreadSchema = zod_1.z.object({
95
+ topic: zod_1.z.string().min(3).max(500),
96
+ tone: zod_1.z.enum(["alpha", "educational", "opinion", "story"]).optional(),
97
+ tweets: zod_1.z.number().int().min(4).max(12).optional(),
98
+ voice_sample: zod_1.z.string().max(5000).optional(),
99
+ });
100
+ const WritePostSchema = zod_1.z.object({
101
+ topic: zod_1.z.string().min(3).max(500),
102
+ style: zod_1.z.enum(["hook", "hot-take", "alpha", "question", "observation"]).optional(),
103
+ long: zod_1.z.boolean().optional(),
104
+ voice_sample: zod_1.z.string().max(5000).optional(),
33
105
  });
34
106
  const HUMANIZER_SYSTEM = `You are a text editor that removes signs of AI-generated writing.
35
107
 
@@ -78,7 +150,76 @@ PROCESS:
78
150
  6. Output ONLY the final humanized text — no commentary, no explanation, no "Here is your text:"
79
151
 
80
152
  If a voice sample is provided, match its tone, rhythm, and vocabulary. Otherwise use direct, opinionated, natural prose.`;
153
+ const THREAD_SYSTEM = `You are a crypto Twitter ghostwriter who writes threads that go viral. Your style: direct, no fluff, confident without being cringe. You understand DeFi, on-chain data, narratives, and market structure. You write like a smart practitioner, not a content creator.
154
+
155
+ Rules:
156
+ - First tweet is the hook — bold claim or surprising insight. Must make people stop scrolling.
157
+ - Middle tweets: each one standalone insight. No "in this thread I'll explain" filler.
158
+ - Last tweet: the payoff. Strong closer, optional CTA (follow, RT, reply) — one CTA max.
159
+ - Number format: 1/ 2/ 3/ etc. Each tweet on its own line, separated by blank line.
160
+ - Under 280 chars per tweet unless it genuinely needs more (max 500).
161
+ - No em dashes, no "delve", no "landscape", no "it's worth noting".
162
+ - No hashtags unless they're actually used. No emojis unless they add meaning.
163
+ - Write in the user's voice if a sample is provided.`;
164
+ const POST_SYSTEM = `You are a crypto Twitter ghostwriter. Write one punchy, high-impact post. Direct. No fluff. Hook in the first line. No em dashes, no AI vocabulary. Write like a smart practitioner with an edge.`;
81
165
  async function handleHumanizerTool(name, args) {
166
+ if (name === "write_thread") {
167
+ const parsed = WriteThreadSchema.safeParse(args);
168
+ if (!parsed.success)
169
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
170
+ const { topic, tone = "opinion", tweets = 7, voice_sample } = parsed.data;
171
+ const toneGuides = {
172
+ alpha: "Share non-obvious insights or edge. Act like you have information most people don't.",
173
+ educational: "Explain a concept clearly. Assume smart but non-expert reader.",
174
+ opinion: "Take a clear position. Defend it with reasoning. Don't hedge.",
175
+ story: "Tell a real story with a beginning, conflict, and lesson. Make it personal and specific.",
176
+ };
177
+ const prompt = [
178
+ `Write a ${tweets}-tweet Twitter/X thread on: ${topic}`,
179
+ ``,
180
+ `Tone: ${tone} — ${toneGuides[tone]}`,
181
+ voice_sample ? `Voice sample (match this style):\n${voice_sample}` : "",
182
+ ``,
183
+ `Format: number each tweet as 1/ 2/ 3/ etc., separated by blank lines.`,
184
+ `First tweet = hook. Last tweet = strong closer.`,
185
+ `Output only the tweets — no intro, no explanation.`,
186
+ ].filter(Boolean).join("\n");
187
+ try {
188
+ const output = await (0, llm_js_1.callLLM)(THREAD_SYSTEM, prompt, 2000);
189
+ return { content: [{ type: "text", text: output.trim() }] };
190
+ }
191
+ catch (err) {
192
+ return { content: [{ type: "text", text: `write_thread error: ${err.message}` }], isError: true };
193
+ }
194
+ }
195
+ if (name === "write_post") {
196
+ const parsed = WritePostSchema.safeParse(args);
197
+ if (!parsed.success)
198
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
199
+ const { topic, style = "hook", long = false, voice_sample } = parsed.data;
200
+ const styleGuides = {
201
+ hook: "Strong first line that stops the scroll. Deliver the insight after.",
202
+ "hot-take": "Controversial opinion stated plainly. Don't soften it.",
203
+ alpha: "Non-obvious market insight written like you're sharing it with one smart friend.",
204
+ question: "Ask a sharp, thought-provoking question. Don't answer it.",
205
+ observation: "One specific thing you noticed that most people missed.",
206
+ };
207
+ const charLimit = long ? 500 : 280;
208
+ const prompt = [
209
+ `Write one ${style} post about: ${topic}`,
210
+ `Style: ${styleGuides[style]}`,
211
+ `Max length: ${charLimit} characters.`,
212
+ voice_sample ? `Voice sample:\n${voice_sample}` : "",
213
+ `Output only the post text — nothing else.`,
214
+ ].filter(Boolean).join("\n");
215
+ try {
216
+ const output = await (0, llm_js_1.callLLM)(POST_SYSTEM, prompt, 300);
217
+ return { content: [{ type: "text", text: output.trim() }] };
218
+ }
219
+ catch (err) {
220
+ return { content: [{ type: "text", text: `write_post error: ${err.message}` }], isError: true };
221
+ }
222
+ }
82
223
  if (name !== "humanize_text")
83
224
  return null;
84
225
  const parsed = HumanizerSchema.safeParse(args);
@@ -5,6 +5,7 @@ exports.handleInsightTool = handleInsightTool;
5
5
  const zod_1 = require("zod");
6
6
  const convex_js_1 = require("../convex.js");
7
7
  const llm_js_1 = require("../llm.js");
8
+ const memory_js_1 = require("./memory.js");
8
9
  exports.INSIGHT_TOOLS = [
9
10
  {
10
11
  name: "ask_noel",
@@ -26,35 +27,212 @@ exports.INSIGHT_TOOLS = [
26
27
  required: ["question"],
27
28
  },
28
29
  },
30
+ {
31
+ name: "market_thesis",
32
+ description: "Generate a structured bull vs bear thesis for any token or market topic. " +
33
+ "Noel fetches live price/market data then writes a sharp, two-sided analysis: " +
34
+ "bull case (catalysts, narratives, technicals), bear case (risks, headwinds, red flags), " +
35
+ "and a net verdict with conviction score 0–10. " +
36
+ "Use before opening a position or for research on a token you're watching.",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ token: { type: "string", description: "Token symbol or CoinGecko ID, e.g. 'ETH', 'bitcoin', 'AERO'" },
41
+ context: { type: "string", description: "Optional: extra context — your time horizon, thesis seed, or specific concerns" },
42
+ },
43
+ required: ["token"],
44
+ },
45
+ },
46
+ {
47
+ name: "trade_plan",
48
+ description: "Build a structured trade plan for any token: entry zone, stop loss, take profit levels, " +
49
+ "position size recommendation (as % of portfolio), and risk/reward ratio. " +
50
+ "Based on live price data + Noel's market reading. Returns a ready-to-act plan, not vague advice.",
51
+ inputSchema: {
52
+ type: "object",
53
+ properties: {
54
+ token: { type: "string", description: "Token symbol or CoinGecko ID" },
55
+ side: { type: "string", enum: ["long", "short"], description: "Trade direction (default: long)" },
56
+ portfolioSize: { type: "number", description: "Optional: your portfolio size in USD (for position sizing)" },
57
+ riskTolerance: { type: "string", enum: ["conservative", "moderate", "aggressive"], description: "Risk profile (default: moderate)" },
58
+ timeframe: { type: "string", description: "Optional: trade timeframe, e.g. 'intraday', 'swing', 'weeks'" },
59
+ },
60
+ required: ["token"],
61
+ },
62
+ },
29
63
  ];
30
64
  const AskNoelSchema = zod_1.z.object({
31
65
  question: zod_1.z.string().min(1),
32
66
  messages: zod_1.z.array(zod_1.z.object({ role: zod_1.z.enum(["user", "assistant"]), content: zod_1.z.string() })).optional(),
33
67
  });
34
- const NOEL_SYSTEM_PROMPT = `You are Noel, a crypto AI analyst with deep expertise in DeFi, on-chain data, market structure, and trading psychology. You provide sharp, direct analysis — no fluff, no disclaimers. You understand narratives, liquidity flows, whale behavior, and how sentiment drives price. When asked about a token or market, give your honest read with supporting reasoning.`;
35
- async function handleInsightTool(name, args) {
36
- if (name !== "ask_noel")
68
+ const MarketThesisSchema = zod_1.z.object({
69
+ token: zod_1.z.string().min(1),
70
+ context: zod_1.z.string().optional(),
71
+ });
72
+ const TradePlanSchema = zod_1.z.object({
73
+ token: zod_1.z.string().min(1),
74
+ side: zod_1.z.enum(["long", "short"]).optional(),
75
+ portfolioSize: zod_1.z.number().positive().optional(),
76
+ riskTolerance: zod_1.z.enum(["conservative", "moderate", "aggressive"]).optional(),
77
+ timeframe: zod_1.z.string().optional(),
78
+ });
79
+ const NOEL_BASE_PROMPT = `You are Noel, a crypto AI analyst and the core intelligence of the Noelclaw AI Operating System. You have deep expertise in DeFi, on-chain data, market structure, and trading psychology. You provide sharp, direct analysis — no fluff, no disclaimers. You understand narratives, liquidity flows, whale behavior, and how sentiment drives price. When asked about a token or market, give your honest read with supporting reasoning.`;
80
+ async function buildSystemPrompt(question) {
81
+ const memories = await (0, memory_js_1.searchSupermemory)(question, 5);
82
+ if (!memories.length)
83
+ return NOEL_BASE_PROMPT;
84
+ const memBlock = memories
85
+ .map(r => {
86
+ const title = r.metadata?.title ? `[${r.metadata.title}] ` : "";
87
+ return `- ${title}${r.content.slice(0, 250).replace(/\n/g, " ")}`;
88
+ })
89
+ .join("\n");
90
+ return `${NOEL_BASE_PROMPT}\n\n<user_memory>\nThe following is stored knowledge about this user — use it to personalize your response:\n${memBlock}\n</user_memory>`;
91
+ }
92
+ async function fetchCgPrice(token) {
93
+ try {
94
+ const id = token.toLowerCase().replace(/ /g, "-");
95
+ const res = await fetch(`https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&ids=${id}&order=market_cap_desc&per_page=1&page=1`, { signal: AbortSignal.timeout(8000) });
96
+ if (!res.ok)
97
+ return null;
98
+ const data = await res.json();
99
+ const coin = data[0];
100
+ if (!coin)
101
+ return null;
102
+ return {
103
+ price: coin.current_price ?? 0,
104
+ change24h: coin.price_change_percentage_24h ?? 0,
105
+ mcap: coin.market_cap ?? 0,
106
+ symbol: coin.symbol?.toUpperCase() ?? token.toUpperCase(),
107
+ };
108
+ }
109
+ catch {
37
110
  return null;
38
- const parsed = AskNoelSchema.safeParse(args);
39
- if (!parsed.success)
40
- return { content: [{ type: "text", text: `Invalid input: question ${parsed.error.issues[0].message}` }], isError: true };
41
- const { question, messages = [] } = parsed.data;
42
- // Try local LLM first (Anthropic from Claude Desktop, or Bankr if configured)
43
- if (process.env.ANTHROPIC_API_KEY || process.env.BANKR_API_KEY) {
111
+ }
112
+ }
113
+ async function handleInsightTool(name, args) {
114
+ if (name === "ask_noel") {
115
+ const parsed = AskNoelSchema.safeParse(args);
116
+ if (!parsed.success)
117
+ return { content: [{ type: "text", text: `Invalid input: question ${parsed.error.issues[0].message}` }], isError: true };
118
+ const { question, messages = [] } = parsed.data;
119
+ const systemPrompt = await buildSystemPrompt(question);
120
+ if (process.env.ANTHROPIC_API_KEY || process.env.BANKR_API_KEY) {
121
+ try {
122
+ const history = messages.map(m => ({ role: m.role, content: m.content }));
123
+ const answer = await (0, llm_js_1.callLLM)(systemPrompt, question, 1024, history);
124
+ return { content: [{ type: "text", text: answer }] };
125
+ }
126
+ catch (err) {
127
+ // fall through to Convex
128
+ }
129
+ }
130
+ const data = await (0, convex_js_1.callConvex)("/mcp/chat", "POST", {
131
+ question,
132
+ agentId: "noel-default",
133
+ messages,
134
+ systemPrompt,
135
+ }, "ask_noel");
136
+ return { content: [{ type: "text", text: data.answer ?? JSON.stringify(data) }] };
137
+ }
138
+ if (name === "market_thesis") {
139
+ const parsed = MarketThesisSchema.safeParse(args);
140
+ if (!parsed.success)
141
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
142
+ const { token, context } = parsed.data;
143
+ const priceData = await fetchCgPrice(token);
144
+ const dataCtx = priceData
145
+ ? `Current price: $${priceData.price.toLocaleString()} | 24h: ${priceData.change24h.toFixed(1)}% | Mcap: $${(priceData.mcap / 1000000).toFixed(0)}M`
146
+ : `(live price data unavailable — use general knowledge)`;
147
+ const prompt = [
148
+ `Write a structured bull vs bear thesis for ${token.toUpperCase()}.`,
149
+ ``,
150
+ `Live market data: ${dataCtx}`,
151
+ context ? `User context: ${context}` : "",
152
+ ``,
153
+ `Output format (use exactly these headers):`,
154
+ `## ${token.toUpperCase()} Thesis`,
155
+ `**Current price:** [price] | **24h:** [change]`,
156
+ ``,
157
+ `### Bull Case`,
158
+ `(3-5 specific catalysts, narratives, or technical factors that support upside. Be concrete — no vague "adoption" claims.)`,
159
+ ``,
160
+ `### Bear Case`,
161
+ `(3-5 specific risks, headwinds, or red flags. Include on-chain, macro, and competitive risks if applicable.)`,
162
+ ``,
163
+ `### Net Verdict`,
164
+ `**Conviction:** X/10`,
165
+ `(2-3 sentences: the deciding factor, the key risk to watch, and your net lean.)`,
166
+ ].filter(Boolean).join("\n");
167
+ try {
168
+ const systemPrompt = await buildSystemPrompt(`${token} ${context ?? ""}`);
169
+ const answer = await (0, llm_js_1.callLLM)(systemPrompt, prompt, 1200);
170
+ return { content: [{ type: "text", text: answer }] };
171
+ }
172
+ catch (err) {
173
+ return { content: [{ type: "text", text: `market_thesis error: ${err.message}` }], isError: true };
174
+ }
175
+ }
176
+ if (name === "trade_plan") {
177
+ const parsed = TradePlanSchema.safeParse(args);
178
+ if (!parsed.success)
179
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
180
+ const { token, side = "long", portfolioSize, riskTolerance = "moderate", timeframe } = parsed.data;
181
+ const priceData = await fetchCgPrice(token);
182
+ const dataCtx = priceData
183
+ ? `Current price: $${priceData.price.toLocaleString()} | 24h: ${priceData.change24h.toFixed(1)}% | Mcap: $${(priceData.mcap / 1000000).toFixed(0)}M`
184
+ : `(live price data unavailable)`;
185
+ const riskPcts = {
186
+ conservative: "1-2% of portfolio",
187
+ moderate: "2-5% of portfolio",
188
+ aggressive: "5-10% of portfolio",
189
+ };
190
+ const prompt = [
191
+ `Build a structured ${side.toUpperCase()} trade plan for ${token.toUpperCase()}.`,
192
+ ``,
193
+ `Live data: ${dataCtx}`,
194
+ `Risk tolerance: ${riskTolerance} (max position size: ${riskPcts[riskTolerance]})`,
195
+ portfolioSize ? `Portfolio size: $${portfolioSize.toLocaleString()}` : "",
196
+ timeframe ? `Timeframe: ${timeframe}` : "",
197
+ ``,
198
+ `Output exactly this format:`,
199
+ `## Trade Plan: ${token.toUpperCase()} ${side.toUpperCase()}`,
200
+ ``,
201
+ `**Direction:** ${side.toUpperCase()}`,
202
+ `**Timeframe:** [fill]`,
203
+ ``,
204
+ `### Entry`,
205
+ `- Ideal entry: $[price or range]`,
206
+ `- Entry condition: [what must happen / trigger]`,
207
+ ``,
208
+ `### Risk Management`,
209
+ `- Stop loss: $[price] ([%] below/above entry)`,
210
+ `- Position size: [% of portfolio]${portfolioSize ? ` = $[USD amount]` : ""}`,
211
+ `- Max loss: [% of portfolio]`,
212
+ ``,
213
+ `### Targets`,
214
+ `- TP1: $[price] ([%] gain) — partial exit [%]`,
215
+ `- TP2: $[price] ([%] gain) — partial exit [%]`,
216
+ `- TP3: $[price] ([%] gain) — final exit`,
217
+ ``,
218
+ `### Risk/Reward`,
219
+ `- RR ratio: [X:1]`,
220
+ `- Expected value: [+/- %]`,
221
+ ``,
222
+ `### Thesis in One Sentence`,
223
+ `[Why this trade makes sense right now]`,
224
+ ``,
225
+ `### Invalidation`,
226
+ `[Exactly what would make you exit early / kill the thesis]`,
227
+ ].filter(Boolean).join("\n");
44
228
  try {
45
- const history = messages.map(m => ({ role: m.role, content: m.content }));
46
- const answer = await (0, llm_js_1.callLLM)(NOEL_SYSTEM_PROMPT, question, 1024, history);
229
+ const systemPrompt = await buildSystemPrompt(`${token} trade ${riskTolerance} ${timeframe ?? ""}`);
230
+ const answer = await (0, llm_js_1.callLLM)(systemPrompt, prompt, 1200);
47
231
  return { content: [{ type: "text", text: answer }] };
48
232
  }
49
233
  catch (err) {
50
- console.error(`Local LLM failed, falling back to Convex: ${err.message}`);
234
+ return { content: [{ type: "text", text: `trade_plan error: ${err.message}` }], isError: true };
51
235
  }
52
236
  }
53
- // Fallback: route through Convex backend (uses server's configured key)
54
- const data = await (0, convex_js_1.callConvex)("/mcp/chat", "POST", {
55
- question,
56
- agentId: "noel-default",
57
- messages,
58
- }, "ask_noel");
59
- return { content: [{ type: "text", text: data.answer ?? JSON.stringify(data) }] };
237
+ return null;
60
238
  }
@@ -23,6 +23,20 @@ async function cgFetch(path) {
23
23
  throw new Error(`CoinGecko ${res.status}`);
24
24
  return res.json();
25
25
  }
26
+ async function resolveTokenId(query) {
27
+ const upper = query.trim().toUpperCase();
28
+ if (SYMBOL_TO_ID[upper])
29
+ return { id: SYMBOL_TO_ID[upper], symbol: upper };
30
+ // Fallback: search CoinGecko — handles any token not in the static map
31
+ try {
32
+ const res = await cgFetch(`/search?query=${encodeURIComponent(query)}`);
33
+ const coin = res.coins?.[0];
34
+ if (coin?.id)
35
+ return { id: coin.id, symbol: coin.symbol?.toUpperCase() ?? upper };
36
+ }
37
+ catch { }
38
+ return null;
39
+ }
26
40
  function fmt(n, decimals = 2) {
27
41
  if (n == null)
28
42
  return "—";
@@ -63,9 +77,55 @@ exports.MARKET_TOOLS = [
63
77
  required: ["question"],
64
78
  },
65
79
  },
80
+ {
81
+ name: "compare_tokens",
82
+ description: "Compare 2–5 tokens side by side — price, 24h/7d change, market cap, volume, and ATH drawdown. " +
83
+ "Ideal for deciding between assets or tracking a portfolio watchlist.",
84
+ inputSchema: {
85
+ type: "object",
86
+ properties: {
87
+ tokens: {
88
+ type: "array",
89
+ items: { type: "string" },
90
+ description: "2–5 token symbols to compare, e.g. ['BTC', 'ETH', 'SOL']",
91
+ minItems: 2,
92
+ maxItems: 5,
93
+ },
94
+ },
95
+ required: ["tokens"],
96
+ },
97
+ },
98
+ {
99
+ name: "market_overview",
100
+ description: "Global crypto market snapshot: Fear & Greed Index, BTC dominance, total market cap, DeFi TVL, " +
101
+ "ETH gas, trending tokens, and top sector leaders. Use for a full market briefing.",
102
+ inputSchema: {
103
+ type: "object",
104
+ properties: {},
105
+ required: [],
106
+ },
107
+ },
108
+ {
109
+ name: "token_history",
110
+ description: "Get historical price data for a token. Returns OHLC candles for the requested timeframe. " +
111
+ "Use to understand price trends, identify support/resistance levels, or calculate % changes over time.",
112
+ inputSchema: {
113
+ type: "object",
114
+ properties: {
115
+ token: { type: "string", description: "Token symbol, e.g. 'BTC', 'ETH', 'SOL'" },
116
+ days: {
117
+ type: "number",
118
+ description: "Number of days of history (1=24h, 7=7d, 30=30d, 90=90d, 365=1y). Default: 7",
119
+ },
120
+ },
121
+ required: ["token"],
122
+ },
123
+ },
66
124
  ];
67
125
  const GetMarketDataSchema = zod_1.z.object({ token: zod_1.z.string().optional() });
68
126
  const GetTokenDataSchema = zod_1.z.object({ question: zod_1.z.string().min(1) });
127
+ const CompareTokensSchema = zod_1.z.object({ tokens: zod_1.z.array(zod_1.z.string()).min(2).max(5) });
128
+ const TokenHistorySchema = zod_1.z.object({ token: zod_1.z.string().min(1), days: zod_1.z.number().positive().optional() });
69
129
  async function fetchMarketSnapshot() {
70
130
  try {
71
131
  const data = await cgFetch("/coins/markets?vs_currency=usd&ids=bitcoin,ethereum,solana&sparkline=false&price_change_percentage=24h");
@@ -91,10 +151,10 @@ async function handleMarketTool(name, args) {
91
151
  return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
92
152
  const { token } = parsed.data;
93
153
  if (token) {
94
- const sym = token.toUpperCase();
95
- const id = SYMBOL_TO_ID[sym];
96
- if (!id)
97
- return { content: [{ type: "text", text: `Unknown token: ${sym}. Try get_token_data for specific lookup.` }], isError: true };
154
+ const resolved = await resolveTokenId(token);
155
+ if (!resolved)
156
+ return { content: [{ type: "text", text: `Token not found: "${token}". Try a full name like "pepe" or a known symbol.` }], isError: true };
157
+ const { id, symbol: sym } = resolved;
98
158
  const [data, trending] = await Promise.all([
99
159
  cgFetch(`/coins/markets?vs_currency=usd&ids=${id}&sparkline=false&price_change_percentage=24h`),
100
160
  cgFetch("/search/trending"),
@@ -148,9 +208,14 @@ async function handleMarketTool(name, args) {
148
208
  const parsed = GetTokenDataSchema.safeParse(args);
149
209
  if (!parsed.success)
150
210
  return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
151
- const q = parsed.data.question.toUpperCase();
152
- const sym = Object.keys(SYMBOL_TO_ID).find((s) => new RegExp(`\\b${s}\\b`).test(q)) ?? "BTC";
153
- const id = SYMBOL_TO_ID[sym];
211
+ const q = parsed.data.question;
212
+ // Try to extract a known symbol first, then fall back to search
213
+ const upperQ = q.toUpperCase();
214
+ const knownSym = Object.keys(SYMBOL_TO_ID).find((s) => new RegExp(`\\b${s}\\b`).test(upperQ));
215
+ const resolved = await resolveTokenId(knownSym ?? q);
216
+ if (!resolved)
217
+ return { content: [{ type: "text", text: `Token not found: "${q}". Try a symbol like "ETH" or a full name.` }], isError: true };
218
+ const { id, symbol: sym } = resolved;
154
219
  const data = await cgFetch(`/coins/markets?vs_currency=usd&ids=${id}&sparkline=false&price_change_percentage=24h`);
155
220
  const c = data[0];
156
221
  if (!c)
@@ -168,6 +233,116 @@ async function handleMarketTool(name, args) {
168
233
  ];
169
234
  return { content: [{ type: "text", text: lines.join("\n") }] };
170
235
  }
236
+ case "compare_tokens": {
237
+ const parsed = CompareTokensSchema.safeParse(args);
238
+ if (!parsed.success)
239
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
240
+ const syms = parsed.data.tokens.map(t => t.toUpperCase());
241
+ const ids = syms.map(s => SYMBOL_TO_ID[s]).filter(Boolean);
242
+ const unknown = syms.filter(s => !SYMBOL_TO_ID[s]);
243
+ if (!ids.length)
244
+ return { content: [{ type: "text", text: `Unknown tokens: ${unknown.join(", ")}` }], isError: true };
245
+ const data = await cgFetch(`/coins/markets?vs_currency=usd&ids=${ids.join(",")}&sparkline=false&price_change_percentage=24h,7d`);
246
+ const header = [
247
+ `**Token Comparison** — ${new Date().toUTCString()}`,
248
+ unknown.length ? `\n⚠️ Unknown: ${unknown.join(", ")}` : "",
249
+ ``,
250
+ `| Token | Price | 24h | 7d | Mcap | Vol 24h | ATH% |`,
251
+ `|-------|-------|-----|----|------|---------|------|`,
252
+ ].filter(Boolean);
253
+ const rows = data.map((c) => {
254
+ const sym = c.symbol?.toUpperCase();
255
+ const ch24 = c.price_change_percentage_24h ?? 0;
256
+ const ch7d = c.price_change_percentage_7d_in_currency ?? 0;
257
+ const athPct = c.ath_change_percentage ?? 0;
258
+ const s = (n) => `${n >= 0 ? "+" : ""}${fmt(n)}%`;
259
+ return `| **${sym}** | ${fmtPrice(c.current_price)} | ${s(ch24)} | ${s(ch7d)} | ${fmtB(c.market_cap)} | ${fmtB(c.total_volume)} | ${fmt(athPct)}% |`;
260
+ });
261
+ return { content: [{ type: "text", text: [...header, ...rows].join("\n") }] };
262
+ }
263
+ case "market_overview": {
264
+ const [globalData, fearGreed, trending] = await Promise.allSettled([
265
+ cgFetch("/global"),
266
+ fetch("https://api.alternative.me/fng/", { signal: AbortSignal.timeout(8000) }).then(r => r.json()),
267
+ cgFetch("/search/trending"),
268
+ ]);
269
+ const global = globalData.status === "fulfilled" ? globalData.value.data : null;
270
+ const fg = fearGreed.status === "fulfilled" ? fearGreed.value?.data?.[0] : null;
271
+ const trendCoins = trending.status === "fulfilled" ? (trending.value?.coins ?? []) : [];
272
+ const totalMcap = global?.total_market_cap?.usd;
273
+ const defiTvl = global?.total_value_locked?.usd;
274
+ const btcDom = global?.market_cap_percentage?.btc;
275
+ const ethDom = global?.market_cap_percentage?.eth;
276
+ const mcap24hChange = global?.market_cap_change_percentage_24h_usd;
277
+ const fgLabel = fg ? `${fg.value}/100 — ${fg.value_classification}` : "unavailable";
278
+ const fgEmoji = fg ? (Number(fg.value) >= 75 ? "🟢 Extreme Greed" : Number(fg.value) >= 55 ? "🟢 Greed" : Number(fg.value) >= 45 ? "🟡 Neutral" : Number(fg.value) >= 25 ? "🔴 Fear" : "🔴 Extreme Fear") : "";
279
+ const lines = [
280
+ `## 🌍 Global Crypto Market`,
281
+ `_${new Date().toUTCString()}_`,
282
+ ``,
283
+ `**Fear & Greed:** ${fgEmoji} ${fg?.value ?? "—"}/100 (${fg?.value_classification ?? "—"})`,
284
+ totalMcap ? `**Total Market Cap:** ${fmtB(totalMcap)} (${mcap24hChange != null ? `${mcap24hChange >= 0 ? "+" : ""}${fmt(mcap24hChange)}% 24h` : ""})` : "",
285
+ btcDom != null ? `**BTC Dominance:** ${fmt(btcDom)}% | **ETH:** ${fmt(ethDom ?? 0)}%` : "",
286
+ defiTvl ? `**DeFi TVL:** ${fmtB(defiTvl)}` : "",
287
+ global?.active_cryptocurrencies ? `**Active Coins:** ${global.active_cryptocurrencies.toLocaleString()}` : "",
288
+ ``,
289
+ ].filter(l => l !== "");
290
+ if (trendCoins.length > 0) {
291
+ lines.push(`**🔥 Trending Now**`);
292
+ for (const t of trendCoins.slice(0, 7)) {
293
+ const item = t.item;
294
+ const rank = item.market_cap_rank ? `#${item.market_cap_rank}` : "unranked";
295
+ lines.push(`• **${item.symbol}** (${rank}) — ${item.name}`);
296
+ }
297
+ }
298
+ return { content: [{ type: "text", text: lines.join("\n") }] };
299
+ }
300
+ case "token_history": {
301
+ const parsed = TokenHistorySchema.safeParse(args);
302
+ if (!parsed.success)
303
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
304
+ const days = parsed.data.days ?? 7;
305
+ const resolved = await resolveTokenId(parsed.data.token);
306
+ if (!resolved)
307
+ return { content: [{ type: "text", text: `Token not found: "${parsed.data.token}". Try a symbol like "ETH" or a full name.` }], isError: true };
308
+ const { id, symbol: sym } = resolved;
309
+ const [ohlc, current] = await Promise.all([
310
+ cgFetch(`/coins/${id}/ohlc?vs_currency=usd&days=${days}`),
311
+ cgFetch(`/coins/markets?vs_currency=usd&ids=${id}&sparkline=false&price_change_percentage=24h`),
312
+ ]);
313
+ const c = current[0];
314
+ const candles = ohlc ?? [];
315
+ if (!candles.length)
316
+ return { content: [{ type: "text", text: `No history data for ${sym}` }], isError: true };
317
+ const first = candles[0];
318
+ const last = candles[candles.length - 1];
319
+ const openPrice = first[1];
320
+ const closePrice = last[4];
321
+ const periodChange = ((closePrice - openPrice) / openPrice) * 100;
322
+ const highs = candles.map(c => c[2]);
323
+ const lows = candles.map(c => c[3]);
324
+ const periodHigh = Math.max(...highs);
325
+ const periodLow = Math.min(...lows);
326
+ const lines = [
327
+ `## ${sym} — ${days}d History`,
328
+ ``,
329
+ `**Current:** ${fmtPrice(c?.current_price)} (${(c?.price_change_percentage_24h ?? 0) >= 0 ? "+" : ""}${fmt(c?.price_change_percentage_24h)}% 24h)`,
330
+ `**Period open:** ${fmtPrice(openPrice)}`,
331
+ `**Period close:** ${fmtPrice(closePrice)} (${periodChange >= 0 ? "+" : ""}${fmt(periodChange)}% over ${days}d)`,
332
+ `**${days}d High:** ${fmtPrice(periodHigh)}`,
333
+ `**${days}d Low:** ${fmtPrice(periodLow)}`,
334
+ `**Range:** ${fmt((periodHigh - periodLow) / periodLow * 100)}% spread`,
335
+ ``,
336
+ `**Last 10 candles (OHLC):**`,
337
+ `| Date | Open | High | Low | Close |`,
338
+ `|------|------|------|-----|-------|`,
339
+ ...candles.slice(-10).map(([ts, o, h, l, cl]) => {
340
+ const d = new Date(ts).toISOString().slice(0, 10);
341
+ return `| ${d} | ${fmtPrice(o)} | ${fmtPrice(h)} | ${fmtPrice(l)} | ${fmtPrice(cl)} |`;
342
+ }),
343
+ ];
344
+ return { content: [{ type: "text", text: lines.join("\n") }] };
345
+ }
171
346
  default:
172
347
  return null;
173
348
  }