mj41-mcp 1.1.0 → 1.2.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.
Files changed (2) hide show
  1. package/dist/index.js +90 -24
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -23,6 +23,43 @@
23
23
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
24
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
25
  import { z } from "zod";
26
+ // ── Cache + Rate Limiting ──────────────────────────────────
27
+ // Protects upstream APIs from runaway agents. No dependencies.
28
+ const cache = new Map();
29
+ async function cachedFetch(key, fetcher, ttlMs) {
30
+ const hit = cache.get(key);
31
+ if (hit && Date.now() - hit.at < ttlMs)
32
+ return hit.value;
33
+ const value = await fetcher();
34
+ cache.set(key, { value, at: Date.now() });
35
+ return value;
36
+ }
37
+ // Simple sliding-window rate limiter per upstream group
38
+ const rateLimits = new Map();
39
+ function checkRate(group, maxPerMinute) {
40
+ const now = Date.now();
41
+ const window = 60_000;
42
+ let timestamps = rateLimits.get(group) || [];
43
+ timestamps = timestamps.filter((t) => now - t < window);
44
+ if (timestamps.length >= maxPerMinute) {
45
+ throw new Error(`Rate limit exceeded for ${group}. Max ${maxPerMinute} requests/minute. Try again shortly.`);
46
+ }
47
+ timestamps.push(now);
48
+ rateLimits.set(group, timestamps);
49
+ }
50
+ // TTLs (ms)
51
+ const TTL = {
52
+ COPPER_STATUS: 60_000, // 60s — prices update every 15min at fastest
53
+ ETH_PRICE: 30_000, // 30s
54
+ NEWS_LIST: 120_000, // 2min — stories update 2x daily
55
+ NEWS_STORY: 300_000, // 5min — published stories don't change
56
+ };
57
+ // Rate limits (requests per minute per upstream group)
58
+ const RATE = {
59
+ COPPER: 20,
60
+ RICHARD: 10, // investigate_wallet calls AI, expensive
61
+ FIRST_SIGNAL: 30,
62
+ };
26
63
  const COPPERTRACE_URL = "https://coppertrace.vercel.app";
27
64
  const FIRST_SIGNAL_URL = "https://aiwire.mj41.me";
28
65
  const ORACLE_URLS = {
@@ -46,11 +83,14 @@ server.tool("get_copper_price", "Get the current copper price from a ZJ Industri
46
83
  .describe("Copper class: 'a' for COMEX spot, 'b' for scrap, 'c' for industrial"),
47
84
  }, async ({ class: copperClass }) => {
48
85
  try {
86
+ checkRate("copper", RATE.COPPER);
49
87
  const url = ORACLE_URLS[copperClass];
50
- const res = await fetch(`${url}/api/v1/status`);
51
- if (!res.ok)
52
- throw new Error(`HTTP ${res.status}`);
53
- const status = await res.json();
88
+ const status = await cachedFetch(`copper-status-${copperClass}`, async () => {
89
+ const res = await fetch(`${url}/api/v1/status`);
90
+ if (!res.ok)
91
+ throw new Error(`HTTP ${res.status}`);
92
+ return res.json();
93
+ }, TTL.COPPER_STATUS);
54
94
  const label = CLASS_LABELS[copperClass];
55
95
  const fresh = status.is_fresh ? "FRESH" : "STALE";
56
96
  const lastUpdate = new Date(status.last_updated).toLocaleString("en-US", {
@@ -97,10 +137,13 @@ server.tool("get_all_copper_prices", "Get status of all three ZJ Industries copp
97
137
  "═══════════════════════════════════════════════",
98
138
  "",
99
139
  ];
140
+ checkRate("copper", RATE.COPPER);
100
141
  for (const [cls, url] of Object.entries(ORACLE_URLS)) {
101
142
  try {
102
- const res = await fetch(`${url}/api/v1/status`);
103
- const status = await res.json();
143
+ const status = await cachedFetch(`copper-status-${cls}`, async () => {
144
+ const res = await fetch(`${url}/api/v1/status`);
145
+ return res.json();
146
+ }, TTL.COPPER_STATUS);
104
147
  const fresh = status.is_fresh ? "FRESH" : "STALE";
105
148
  const lastUpdate = new Date(status.last_updated).toLocaleString("en-US", {
106
149
  timeZone: "America/New_York",
@@ -138,11 +181,14 @@ server.tool("get_oracle_status", "Check if a ZJ Industries copper oracle is oper
138
181
  .describe("Copper class: 'a' for COMEX spot, 'b' for scrap, 'c' for industrial"),
139
182
  }, async ({ class: copperClass }) => {
140
183
  try {
184
+ checkRate("copper", RATE.COPPER);
141
185
  const url = ORACLE_URLS[copperClass];
142
- const res = await fetch(`${url}/api/v1/status`);
143
- if (!res.ok)
144
- throw new Error(`HTTP ${res.status}`);
145
- const data = await res.json();
186
+ const data = await cachedFetch(`copper-status-${copperClass}`, async () => {
187
+ const res = await fetch(`${url}/api/v1/status`);
188
+ if (!res.ok)
189
+ throw new Error(`HTTP ${res.status}`);
190
+ return res.json();
191
+ }, TTL.COPPER_STATUS);
146
192
  return {
147
193
  content: [
148
194
  {
@@ -169,6 +215,8 @@ server.tool("investigate_wallet", "Investigate a blockchain wallet address or tr
169
215
  .describe("The investigation query. Can be a wallet address, transaction hash, or incident description (e.g. 'I clicked a link and my wallet was drained, address: 0x...')"),
170
216
  }, async ({ query }) => {
171
217
  try {
218
+ checkRate("richard", RATE.RICHARD);
219
+ // No cache — investigations are unique per query
172
220
  const res = await fetch(`${COPPERTRACE_URL}/api/investigate`, {
173
221
  method: "POST",
174
222
  headers: { "Content-Type": "application/json" },
@@ -205,8 +253,11 @@ server.tool("investigate_wallet", "Investigate a blockchain wallet address or tr
205
253
  // ── get_eth_price ───────────────────────────────────────────
206
254
  server.tool("get_eth_price", "Get the current price of Ethereum in USD. Powered by mj41, LLC.", {}, async () => {
207
255
  try {
208
- const res = await fetch(`${COPPERTRACE_URL}/api/eth-price`);
209
- const data = await res.json();
256
+ checkRate("richard", RATE.RICHARD);
257
+ const data = await cachedFetch("eth-price", async () => {
258
+ const res = await fetch(`${COPPERTRACE_URL}/api/eth-price`);
259
+ return res.json();
260
+ }, TTL.ETH_PRICE);
210
261
  return {
211
262
  content: [
212
263
  {
@@ -235,11 +286,14 @@ server.tool("get_latest_news", "Get the latest stories from The First Signal, an
235
286
  .describe("Number of stories to return (default 5, max 20)"),
236
287
  }, async ({ limit }) => {
237
288
  try {
289
+ checkRate("first-signal", RATE.FIRST_SIGNAL);
238
290
  const cap = Math.min(limit, 20);
239
- const res = await fetch(`${FIRST_SIGNAL_URL}/api/stories?limit=${cap}`);
240
- if (!res.ok)
241
- throw new Error(`HTTP ${res.status}`);
242
- const data = await res.json();
291
+ const data = await cachedFetch(`news-latest-${cap}`, async () => {
292
+ const res = await fetch(`${FIRST_SIGNAL_URL}/api/stories?limit=${cap}`);
293
+ if (!res.ok)
294
+ throw new Error(`HTTP ${res.status}`);
295
+ return res.json();
296
+ }, TTL.NEWS_LIST);
243
297
  const stories = data.stories || data;
244
298
  if (!stories.length) {
245
299
  return {
@@ -259,6 +313,8 @@ server.tool("get_latest_news", "Get the latest stories from The First Signal, an
259
313
  });
260
314
  lines.push(`[${s.beat?.toUpperCase() || "WIRE"}] ${s.headline}`);
261
315
  lines.push(` ${date} | ${s.byline || "Staff"} | Confidence: ${s.confidence || "N/A"}`);
316
+ if (s.id)
317
+ lines.push(` ID: ${s.id}`);
262
318
  if (s.summary)
263
319
  lines.push(` ${s.summary.slice(0, 200)}${s.summary.length > 200 ? "..." : ""}`);
264
320
  lines.push("");
@@ -287,11 +343,14 @@ server.tool("get_news_by_beat", "Get stories from a specific beat on The First S
287
343
  .describe("Number of stories (default 5)"),
288
344
  }, async ({ beat, limit }) => {
289
345
  try {
346
+ checkRate("first-signal", RATE.FIRST_SIGNAL);
290
347
  const cap = Math.min(limit, 20);
291
- const res = await fetch(`${FIRST_SIGNAL_URL}/api/stories?beat=${beat}&limit=${cap}`);
292
- if (!res.ok)
293
- throw new Error(`HTTP ${res.status}`);
294
- const data = await res.json();
348
+ const data = await cachedFetch(`news-beat-${beat}-${cap}`, async () => {
349
+ const res = await fetch(`${FIRST_SIGNAL_URL}/api/stories?beat=${beat}&limit=${cap}`);
350
+ if (!res.ok)
351
+ throw new Error(`HTTP ${res.status}`);
352
+ return res.json();
353
+ }, TTL.NEWS_LIST);
295
354
  const stories = data.stories || data;
296
355
  if (!stories.length) {
297
356
  return {
@@ -310,6 +369,8 @@ server.tool("get_news_by_beat", "Get stories from a specific beat on The First S
310
369
  });
311
370
  lines.push(`${s.headline}`);
312
371
  lines.push(` ${date} | ${s.byline || "Staff"} | Confidence: ${s.confidence || "N/A"}`);
372
+ if (s.id)
373
+ lines.push(` ID: ${s.id}`);
313
374
  if (s.summary)
314
375
  lines.push(` ${s.summary.slice(0, 200)}${s.summary.length > 200 ? "..." : ""}`);
315
376
  lines.push("");
@@ -333,10 +394,13 @@ server.tool("get_story", "Read the full text of a specific story from The First
333
394
  .describe("The story ID (UUID)"),
334
395
  }, async ({ id }) => {
335
396
  try {
336
- const res = await fetch(`${FIRST_SIGNAL_URL}/api/stories/${id}`);
337
- if (!res.ok)
338
- throw new Error(`HTTP ${res.status}`);
339
- const story = await res.json();
397
+ checkRate("first-signal", RATE.FIRST_SIGNAL);
398
+ const story = await cachedFetch(`news-story-${id}`, async () => {
399
+ const res = await fetch(`${FIRST_SIGNAL_URL}/api/stories/${id}`);
400
+ if (!res.ok)
401
+ throw new Error(`HTTP ${res.status}`);
402
+ return res.json();
403
+ }, TTL.NEWS_STORY);
340
404
  const lines = [
341
405
  `THE FIRST SIGNAL`,
342
406
  "════════════════════════════════",
@@ -371,6 +435,8 @@ server.tool("submit_story_idea", "Submit a story idea or investigation request t
371
435
  description: z.string().describe("Details about what should be investigated"),
372
436
  }, async ({ topic, description }) => {
373
437
  try {
438
+ checkRate("first-signal", RATE.FIRST_SIGNAL);
439
+ // No cache — write operation
374
440
  const res = await fetch(`${FIRST_SIGNAL_URL}/api/requests`, {
375
441
  method: "POST",
376
442
  headers: { "Content-Type": "application/json" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mj41-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "MJ41 MCP Server — Agent gateway for mj41, LLC. Live copper price oracles (COMEX/scrap/industrial on Base L2), blockchain investigation, and AI news wire with 16 autonomous reporters.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",