moshcode 0.55.0 → 0.57.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
@@ -440,6 +440,19 @@ moshcode railway up
440
440
  moshcode doctl compute droplet list
441
441
  ```
442
442
 
443
+ ### MCP server testing
444
+
445
+ ```sh
446
+ moshcode install mcpjam # npm i -g @mcpjam/cli
447
+
448
+ moshcode mcpjam --help # straight through to the native CLI
449
+ ```
450
+
451
+ MCPJam is the companion to `moshcode mcp`: `mcp` registers a server across your
452
+ engines, `mcpjam` tells you whether that server is healthy first — health
453
+ checks, OAuth conformance, tool-surface diffing, and structured triage from the
454
+ terminal or CI. Re-running `moshcode install mcpjam` is also its upgrade path.
455
+
443
456
  `gh`, `supabase`, and `doctl` publish no cross-platform install script, so
444
457
  MoshCode resolves the latest GitHub release and drops the binary in
445
458
  `$MOSHCODE_BIN` (default `~/.local/bin`) — no sudo, no package manager. Set
@@ -1069,6 +1082,7 @@ chmod +x deploy.mosh
1069
1082
  | `turso(args…)` | drive the Turso CLI |
1070
1083
  | `tailscale(args…)` | drive the Tailscale CLI |
1071
1084
  | `coral(args…)` | drive the Coral CLI (SQL over APIs, databases, internal systems) |
1085
+ | `mcpjam(args…)` | drive the MCPJam CLI (test, debug, and validate MCP servers) |
1072
1086
  | `pwd()` | print the current repo/location |
1073
1087
  | `run(file)` | run another .mosh file (include/compose) |
1074
1088
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moshcode",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "type": "module",
5
5
  "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",
6
6
  "repository": {
package/src/commands.mjs CHANGED
@@ -354,6 +354,7 @@ const COMMANDS = [
354
354
  cliVerb("tailscale", "drive the Tailscale CLI (mesh VPN: up, status, ssh, serve)"),
355
355
  cliVerb("coral", "drive the Coral CLI (SQL over APIs, databases, and internal systems)"),
356
356
  cliVerb("alpaca", "drive the native Alpaca trading CLI"),
357
+ cliVerb("mcpjam", "drive the MCPJam CLI (test, debug, and validate MCP servers)"),
357
358
  cliVerb("trade", "look up tickers, inspect markets, and preview/place Alpaca orders"),
358
359
  cliVerb("pwd", "print the current repo/location"),
359
360
  ];
@@ -272,16 +272,24 @@ export const DEFAULT_FEEDS = [
272
272
  * one. They are somebody else's lists, and that is the point: the feeds inside
273
273
  * them stay current without moshcode shipping a release.
274
274
  *
275
- * Two shapes, because the lists worth reading come in two:
275
+ * Three shapes, because the lists worth reading come in three:
276
276
  *
277
277
  * `format: "opml"` — an OPML document, parsed by parseOpml.
278
- * `format: "text"` — one feed URL per line, `#` comments ignored. Kagi's
279
- * smallweb list is published this way and there is no OPML of it.
278
+ * `format: "text"` — one feed URL per line, `#` comments ignored.
279
+ * `format: "feed"` — the URL is not a catalogue at all but a single feed that
280
+ * already aggregates one. Nothing is fetched to list it; `add` subscribes
281
+ * to the one URL. This is how smallweb is carried — see below.
280
282
  *
281
- * `searchOnly` marks a list too large to subscribe to wholesale. smallweb is
282
- * 32,000+ feeds: importing it would write a 32,000-entry news.opml and then try
283
- * to fetch every one of them on the next `/news`. It is a catalogue to search,
284
- * not a subscription`/rss search <keyword>` is how you get feeds out of it.
283
+ * Kagi's Small Web used to be here as its catalogue: a 5MB OPML of 33,000
284
+ * personal blogs, plus a 1.1MB plain-text fork of the same thing. Both were
285
+ * marked search-only, because subscribing to 33,000 feeds is not a thing a
286
+ * reader can dobut `find` reads every list on every search, so *searching*
287
+ * loaded and parsed 33,000 outlines each time, which is what took `/rss` and
288
+ * `/news find` down. Lazily paging a catalogue that size is a feature nobody
289
+ * asked for, so the catalogue is gone and the firehose stands in for it:
290
+ * kagi.com/smallweb/feed is those same blogs already merged into one Atom
291
+ * document, newest first, with titles and summaries. One feed, one fetch, and
292
+ * `/news --feed smallweb` reads it on its own.
285
293
  */
286
294
  export const FEED_LISTS = [
287
295
  {
@@ -310,17 +318,11 @@ export const FEED_LISTS = [
310
318
  },
311
319
  {
312
320
  name: "smallweb",
313
- description: "Kagi Small Web — 33k personal blogs, with titles",
314
- url: "https://kagi.com/smallweb/opml",
315
- format: "opml",
316
- searchOnly: true,
317
- },
318
- {
319
- name: "smallweb-txt",
320
- description: "Kagi Small Web, profullstack's fork — the plain-text list",
321
- url: "https://raw.githubusercontent.com/ralyodio/smallweb/refs/heads/main/smallweb.txt",
322
- format: "text",
323
- searchOnly: true,
321
+ description: "Kagi Small Web — 33k personal blogs, merged into one feed",
322
+ url: "https://kagi.com/smallweb/feed",
323
+ format: "feed",
324
+ title: "Kagi Small Web",
325
+ site: "https://kagi.com/smallweb",
324
326
  },
325
327
  ];
326
328
 
package/src/news.mjs CHANGED
@@ -99,6 +99,40 @@ const FILE_MODE = 0o600;
99
99
  /** Enough for a large feed, small enough that one bad URL cannot eat the pit. */
100
100
  const MAX_BYTES = 8 * 1024 * 1024;
101
101
 
102
+ /**
103
+ * Where a *feed* read stops, and — unlike MAX_BYTES — stopping is not failing.
104
+ *
105
+ * An aggregated feed has no natural size: smallweb's firehose is 10MB of
106
+ * today's posts, which the 8MB cap would refuse outright, leaving the reader
107
+ * with nothing rather than with the newest few hundred entries. Feeds are
108
+ * newest-first, so the first two megabytes are the part worth having; the rest
109
+ * of the transfer is cancelled mid-stream and what arrived is parsed. parseFeed
110
+ * matches whole `<item>`/`<entry>` blocks, so a cut tail simply is not an item.
111
+ *
112
+ * Ordinary feeds never reach this — the largest in the defaults is under 1MB.
113
+ */
114
+ const FEED_MAX_BYTES = 2 * 1024 * 1024;
115
+
116
+ /**
117
+ * The most headlines one feed may put into a merged listing.
118
+ *
119
+ * Without it, subscribing to an aggregator is the same as unsubscribing from
120
+ * everything else: the merge sorts by date, and 4,000 small-web posts from
121
+ * today's firehose sit above every headline the other feeds have. Set at
122
+ * MAX_LIMIT so no listing can be starved by the cap — `--feed smallweb --limit
123
+ * 200` still fills, because a single feed can supply every row.
124
+ */
125
+ const PER_FEED_ITEMS = 200;
126
+
127
+ /**
128
+ * The most feeds one `add` may subscribe to at once.
129
+ *
130
+ * Whatever the source — a named list, a URL, a local file. The largest list
131
+ * published by name holds 611 feeds, so this refuses only the thing that is a
132
+ * catalogue rather than a reading list.
133
+ */
134
+ const MAX_IMPORT_FEEDS = 2000;
135
+
102
136
  /** How many feeds are in flight at once. Politeness, not throughput. */
103
137
  const CONCURRENCY = 6;
104
138
 
@@ -413,11 +447,11 @@ export function findFeed(feeds, needle) {
413
447
  /**
414
448
  * Parse a newline-delimited feed list: one URL per line, `#` comments ignored.
415
449
  *
416
- * The other shape a published list comes in. Kagi's smallweb list is a plain
417
- * text file of 32,000 URLs and there is no OPML of it, so a reader that only
418
- * speaks OPML cannot read the largest list there is. No titles are invented
419
- * beyond the hostname naming 32,000 feeds properly would mean fetching
420
- * 32,000 feeds, and the host is what the URL already tells us for free.
450
+ * The other shape a published list comes in: plenty of them are a text file of
451
+ * URLs and nothing else, so a reader that only speaks OPML cannot read them.
452
+ * No titles are invented beyond the hostname naming a list's feeds properly
453
+ * would mean fetching every one of them, and the host is what the URL already
454
+ * tells us for free.
421
455
  */
422
456
  export function parseFeedList(text) {
423
457
  const feeds = [];
@@ -440,7 +474,7 @@ export function parseListDocument(body, format = "") {
440
474
  return looksLikeOpml(body) ? parseOpml(body) : parseFeedList(body);
441
475
  }
442
476
 
443
- /** Where a fetched list is cached, so searching does not refetch 32k feeds. */
477
+ /** Where a fetched list is cached, so searching does not refetch every list. */
444
478
  export function listCacheFile(name, env = process.env) {
445
479
  return path.join(path.dirname(opmlFile(env)), "lists", `${slugify(name) || "list"}.json`);
446
480
  }
@@ -462,6 +496,24 @@ const LIST_CACHE_MS = 24 * 60 * 60 * 1000;
462
496
  export async function loadListFeeds(list, {
463
497
  fetchImpl, env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS, now = Date.now(), refresh = false,
464
498
  } = {}) {
499
+ // A `format: "feed"` list is a single feed that has already done the
500
+ // aggregating — smallweb's firehose. There is no catalogue to fetch or cache:
501
+ // the entry itself is the one feed, so a search costs nothing and can never
502
+ // be the thing that makes `find` slow.
503
+ if (list.format === "feed") {
504
+ return {
505
+ ok: true,
506
+ cached: false,
507
+ feeds: [{
508
+ name: list.name,
509
+ title: list.title || list.name,
510
+ url: list.url,
511
+ site: list.site || "",
512
+ category: list.name,
513
+ }],
514
+ };
515
+ }
516
+
465
517
  const file = listCacheFile(list.name, env);
466
518
  const cached = () => {
467
519
  try {
@@ -512,7 +564,7 @@ export function parseKeywords(raw) {
512
564
  *
513
565
  * Ranked rather than filtered, because filtering cannot win both halves of this:
514
566
  *
515
- * · Plain substring matching searches 32,000 feeds for `rust` and returns
567
+ * · Plain substring matching searches every list for `rust` and returns
516
568
  * Trust Machines, Trustnodes, frustrat.com and popthruster.com.
517
569
  * · Requiring the keyword to start a word fixes that and then finds nothing
518
570
  * at all for `homelab`, because the list's only metadata is the hostname
@@ -689,8 +741,15 @@ export function tidyTitle(title) {
689
741
  *
690
742
  * Size-capped while streaming rather than after: a feed that turns out to be a
691
743
  * disk image should cost a few megabytes of transfer, not all of it.
744
+ *
745
+ * What happens at the cap is the caller's to choose. A document read whole —
746
+ * an OPML catalogue — fails, because half a catalogue is a wrong answer rather
747
+ * than a partial one. A feed read for its newest entries truncates instead:
748
+ * see FEED_MAX_BYTES.
692
749
  */
693
- export async function fetchDocument(url, { fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
750
+ export async function fetchDocument(url, {
751
+ fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS, maxBytes = MAX_BYTES, truncate = false,
752
+ } = {}) {
694
753
  const impl = fetchImpl || globalThis.fetch;
695
754
  if (typeof impl !== "function") return { ok: false, error: "no fetch available in this runtime" };
696
755
  const safe = safeUrl(url);
@@ -713,7 +772,10 @@ export async function fetchDocument(url, { fetchImpl, timeoutMs = DEFAULT_TIMEOU
713
772
  // text() for any fetch implementation (tests included) that has no body.
714
773
  if (!res.body || typeof res.body.getReader !== "function") {
715
774
  const body = await res.text();
716
- if (body.length > MAX_BYTES) return { ok: false, error: `feed is larger than ${MAX_BYTES} bytes` };
775
+ if (body.length > maxBytes) {
776
+ if (!truncate) return { ok: false, error: `feed is larger than ${maxBytes} bytes` };
777
+ return { ok: true, body: body.slice(0, maxBytes), truncated: true };
778
+ }
717
779
  return { ok: true, body };
718
780
  }
719
781
  const reader = res.body.getReader();
@@ -724,9 +786,11 @@ export async function fetchDocument(url, { fetchImpl, timeoutMs = DEFAULT_TIMEOU
724
786
  const { done, value } = await reader.read();
725
787
  if (done) break;
726
788
  bytes += value.byteLength;
727
- if (bytes > MAX_BYTES) {
789
+ if (bytes > maxBytes) {
728
790
  try { await reader.cancel(); } catch { /* already gone */ }
729
- return { ok: false, error: `feed is larger than ${MAX_BYTES} bytes` };
791
+ if (!truncate) return { ok: false, error: `feed is larger than ${maxBytes} bytes` };
792
+ body += decoder.decode(value, { stream: true });
793
+ return { ok: true, body: body + decoder.decode(), truncated: true };
730
794
  }
731
795
  body += decoder.decode(value, { stream: true });
732
796
  }
@@ -772,7 +836,7 @@ async function mapLimit(items, limit, worker) {
772
836
  */
773
837
  export async function collectNews(feeds, { fetchImpl, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
774
838
  const results = await mapLimit(feeds, CONCURRENCY, async (feed) => {
775
- const res = await fetchDocument(feed.url, { fetchImpl, timeoutMs });
839
+ const res = await fetchDocument(feed.url, { fetchImpl, timeoutMs, maxBytes: FEED_MAX_BYTES, truncate: true });
776
840
  if (!res.ok) return { feed, error: res.error };
777
841
  let parsed;
778
842
  try { parsed = parseFeed(res.body, { url: feed.url }); }
@@ -787,7 +851,8 @@ export async function collectNews(feeds, { fetchImpl, timeoutMs = DEFAULT_TIMEOU
787
851
  let skipped = 0;
788
852
  for (const result of results) {
789
853
  if (result.error) { failures.push({ name: result.feed.name, url: result.feed.url, error: result.error }); continue; }
790
- for (const item of result.parsed.items) {
854
+ // Newest first inside a feed, so the cap keeps the entries worth keeping.
855
+ for (const item of result.parsed.items.slice(0, PER_FEED_ITEMS)) {
791
856
  // Aggregator feeds wrap the publisher's URL in one of their own. Unwrap
792
857
  // before deduping, so the same story arriving via Google News and via the
793
858
  // publisher's own feed is recognised as one story rather than two.
@@ -1398,9 +1463,13 @@ const FIND_LIMIT = 25;
1398
1463
  /**
1399
1464
  * `news find <keyword,…>` — search every published list for feeds to subscribe to.
1400
1465
  *
1401
- * This is the way into the big lists. smallweb alone is 32,000 feeds, which is
1402
- * a fine thing to search and an impossible thing to subscribe to, so the
1403
- * catalogue is read here and only the matches become candidates for `add`.
1466
+ * This is the way into the lists: they hold a few thousand feeds between them,
1467
+ * which is a fine thing to search and a poor thing to read end to end, so the
1468
+ * catalogues are read here and only the matches become candidates for `add`.
1469
+ *
1470
+ * Every list is loaded on every search, which is why none of them may be
1471
+ * enormous — a 33,000-feed catalogue in here made this the slowest thing in the
1472
+ * pit and took `/rss` down with it.
1404
1473
  */
1405
1474
  async function findCommand(request, { out, fail, fetchImpl, env }) {
1406
1475
  const hits = [];
@@ -1418,7 +1487,7 @@ async function findCommand(request, { out, fail, fetchImpl, env }) {
1418
1487
  }
1419
1488
 
1420
1489
  // Ranked across every list, not within each one. Sorting per list and then
1421
- // concatenating puts all of web3's weak matches above smallweb's exact ones
1490
+ // concatenating puts all of web3's weak matches above journalists' exact ones
1422
1491
  // purely because web3 is fetched first.
1423
1492
  hits.sort((a, b) => scoreFeed(b, request.keywords) - scoreFeed(a, request.keywords));
1424
1493
 
@@ -1472,6 +1541,32 @@ async function addCommand(request, { out, fail, fetchImpl, env }) {
1472
1541
  fail(` ${ash("too large to subscribe to wholesale — search it instead:")} ${bone(`/rss search <keyword>`)}`);
1473
1542
  return 1;
1474
1543
  }
1544
+ // A `format: "feed"` list is one feed wearing a list's name. Subscribed
1545
+ // without fetching it first: smallweb's firehose is ten megabytes of
1546
+ // headlines and not one of them is needed to decide the feed exists. Tagged
1547
+ // with the list name like any other list, so `rm smallweb` still takes it
1548
+ // back out.
1549
+ if (bundle?.format === "feed") {
1550
+ const { feeds, added, existed } = withFeed(loadFeeds(env), {
1551
+ name: bundle.name,
1552
+ title: bundle.title || bundle.name,
1553
+ url: bundle.url,
1554
+ site: bundle.site || "",
1555
+ category: bundle.name,
1556
+ });
1557
+ if (existed) { out(`${ash("· ")}already subscribed to ${bone(added.name)} ${ash(added.url)}`); return 0; }
1558
+ try { saveFeeds(feeds, env); }
1559
+ catch (e) { fail(danger(`✗ can't write ${opmlFile(env)}: ${e.message}`)); return 1; }
1560
+ // Named from the OPML on the way back in, like every other feed, so the
1561
+ // name printed here is the one `--feed` will answer to rather than the one
1562
+ // that went in.
1563
+ const saved = findFeed(loadFeeds(env), bundle.url) ?? added;
1564
+ if (request.json) { out(JSON.stringify({ added: saved, skipped: 0 }, null, 2)); return 0; }
1565
+ out(`${acid("✓ ")}subscribed to ${bone(saved.name)} ${ash(`— ${bundle.description}`)}`);
1566
+ out(` ${ash("read it on its own with")} ${bone(`/news --feed ${saved.name}`)}`);
1567
+ return 0;
1568
+ }
1569
+
1475
1570
  const target = bundle ? bundle.url : request.target;
1476
1571
  if (bundle) out(`${ash("· ")}fetching ${bone(bundle.name)} ${ash(`— ${bundle.description}`)}`);
1477
1572
 
@@ -1491,6 +1586,17 @@ async function addCommand(request, { out, fail, fetchImpl, env }) {
1491
1586
  // Tagged with the list it came from, so `rm <list>` can take it back out.
1492
1587
  const incoming = bundle ? tagWithList(parsedList, bundle.name) : parsedList;
1493
1588
  if (!incoming.length) { fail(danger(`✗ that ${bundle?.format === "text" ? "list" : "OPML file"} lists no feeds`)); return 1; }
1589
+ // A catalogue is not a subscription. `searchOnly` guarded the *name* but
1590
+ // never the URL, so `add https://kagi.com/smallweb/opml` wrote 32,969
1591
+ // outlines into news.opml and every `/rss` afterwards tried to fetch all of
1592
+ // them — the crash this was supposed to prevent, through the one door left
1593
+ // open. The limit sits well above every published list (the largest is 611)
1594
+ // and well below a catalogue.
1595
+ if (incoming.length > MAX_IMPORT_FEEDS) {
1596
+ fail(danger(`✗ that list holds ${incoming.length.toLocaleString()} feeds — more than ${MAX_IMPORT_FEEDS.toLocaleString()}, which is more than a reader can fetch`));
1597
+ fail(` ${ash("it is a catalogue, not a subscription — search it instead:")} ${bone("/rss search <keyword>")}`);
1598
+ return 1;
1599
+ }
1494
1600
  let feeds = existing;
1495
1601
  const added = [];
1496
1602
  let skipped = 0;
package/src/tools.mjs CHANGED
@@ -160,6 +160,15 @@ export const TOOLS = {
160
160
  },
161
161
  installHelp: "Go is required to install Alpaca; install Go, then retry `moshcode install alpaca`.",
162
162
  },
163
+ mcpjam: {
164
+ desc: "MCPJam — test, debug, and validate MCP servers (health, OAuth, tool-surface diffs)",
165
+ bin: "mcpjam",
166
+ // The companion to `moshcode mcp`: that registers a server across engines,
167
+ // this one tells you whether the server is actually worth registering.
168
+ // Published as an ordinary global npm package, and `npm install -g` is
169
+ // idempotent, so it doubles as the upgrade path — no `upgrade` key needed.
170
+ install: { cmd: "npm", args: ["install", "-g", "@mcpjam/cli"] },
171
+ },
163
172
  };
164
173
 
165
174
  /** Resolve a name to `[key, tool]`, or null. */