@nooticr/mcp 1.26.44 → 1.26.45

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.
@@ -0,0 +1,154 @@
1
+ const LIMIT = 2000;
2
+ const POST_KEYS = ["posts", "window", "demand", "supply", "results"];
3
+ /** One spelling per post: no trailing slash, no tracking query, YouTube's id kept. */
4
+ export function postKey(url) {
5
+ if (typeof url !== "string" || !url.trim())
6
+ return "";
7
+ const raw = url.trim();
8
+ try {
9
+ const u = new URL(raw);
10
+ const host = u.hostname.toLowerCase().replace(/^(www|m|mobile)\./, "");
11
+ const keep = host.endsWith("youtube.com") && u.searchParams.get("v") ? `?v=${u.searchParams.get("v")}` : "";
12
+ return `${host}${u.pathname.replace(/\/+$/, "")}${keep}`;
13
+ }
14
+ catch {
15
+ return raw.replace(/[?#].*$/, "").replace(/\/+$/, "").toLowerCase();
16
+ }
17
+ }
18
+ const urlsOf = (row) => [row.externalUrl, row.url, row.permalink, row.postUrl].map(postKey).filter(Boolean);
19
+ const handleOf = (row) => String(row.handle ?? row.username ?? row.creatorHandle ?? "")
20
+ .trim()
21
+ .replace(/^@/, "")
22
+ .toLowerCase();
23
+ export const creatorKey = (row) => {
24
+ const h = handleOf(row);
25
+ return h ? `${String(row.platform ?? "").toLowerCase()}:${h}` : "";
26
+ };
27
+ const runKey = (term, ranAt) => `${String(term ?? "").trim().toLowerCase()}|${String(ranAt ?? "")}`;
28
+ class Bounded {
29
+ map = new Map();
30
+ set(k, v) {
31
+ if (!k)
32
+ return;
33
+ this.map.delete(k);
34
+ this.map.set(k, v);
35
+ if (this.map.size > LIMIT)
36
+ this.map.delete(this.map.keys().next().value);
37
+ }
38
+ get(k) {
39
+ return k ? this.map.get(k) : undefined;
40
+ }
41
+ }
42
+ export class FetchLedger {
43
+ posts = new Bounded();
44
+ creators = new Bounded();
45
+ runs = new Bounded();
46
+ /** Keep what a tool returned. The views' own results are never kept: they echo the model. */
47
+ record(tool, structured) {
48
+ if (tool.startsWith("show_") || !structured || typeof structured !== "object")
49
+ return;
50
+ const sc = structured;
51
+ const keepPost = (p) => {
52
+ if (!p || typeof p !== "object")
53
+ return;
54
+ for (const k of urlsOf(p))
55
+ this.posts.set(k, p);
56
+ };
57
+ for (const key of POST_KEYS) {
58
+ if (Array.isArray(sc[key]))
59
+ for (const p of sc[key])
60
+ keepPost(p);
61
+ }
62
+ keepPost(sc.post);
63
+ // analyze_post and its siblings carry the post's fields at the top level.
64
+ if (urlsOf(sc).length && (sc.views != null || sc.caption != null || sc.title != null))
65
+ keepPost(sc);
66
+ if (Array.isArray(sc.creators)) {
67
+ for (const c of sc.creators) {
68
+ if (c && typeof c === "object")
69
+ this.creators.set(creatorKey(c), c);
70
+ }
71
+ }
72
+ if (Array.isArray(sc.runs)) {
73
+ for (const r of sc.runs) {
74
+ if (!r || typeof r !== "object" || r.ranAt == null)
75
+ continue;
76
+ this.runs.set(runKey(sc.term, r.ranAt), r);
77
+ this.runs.set(runKey("", r.ranAt), r);
78
+ }
79
+ }
80
+ }
81
+ post(url) {
82
+ return this.posts.get(postKey(url));
83
+ }
84
+ /**
85
+ * Posts as the view should draw them: nooticr's fields over the model's,
86
+ * so a copied stat can never win, and the model's extra fields survive.
87
+ */
88
+ checkPosts(sent) {
89
+ const unmatched = [];
90
+ const rows = sent.map((p) => {
91
+ const ours = urlsOf(p).map((k) => this.posts.get(k)).find(Boolean);
92
+ if (!ours) {
93
+ unmatched.push(String(p.externalUrl ?? p.url ?? "a post with no URL"));
94
+ return p;
95
+ }
96
+ return { ...p, ...ours, verified: true };
97
+ });
98
+ return { rows, verification: verdict(rows.length, unmatched, "post") };
99
+ }
100
+ checkCreators(sent) {
101
+ const unmatched = [];
102
+ const rows = sent.map((c) => {
103
+ const ours = this.creators.get(creatorKey(c));
104
+ if (!ours) {
105
+ unmatched.push(`@${handleOf(c) || "?"}${c.platform ? ` on ${String(c.platform)}` : ""}`);
106
+ return c;
107
+ }
108
+ // The order and any ranking note are the model's; every figure is ours.
109
+ return { ...c, ...ours, verified: true };
110
+ });
111
+ return { rows, verification: verdict(rows.length, unmatched, "creator") };
112
+ }
113
+ checkRuns(sent, term) {
114
+ const unmatched = [];
115
+ const rows = sent.map((r) => {
116
+ const ours = this.runs.get(runKey(term, r.ranAt)) ?? this.runs.get(runKey("", r.ranAt));
117
+ if (!ours) {
118
+ unmatched.push(String(r.ranAt ?? "an undated point"));
119
+ return r;
120
+ }
121
+ return { ...r, ...ours, verified: true };
122
+ });
123
+ return { rows, verification: verdict(rows.length, unmatched, "point") };
124
+ }
125
+ }
126
+ function verdict(total, unmatched, noun) {
127
+ if (!unmatched.length) {
128
+ return {
129
+ status: "verified",
130
+ note: `All ${total} ${noun}${total === 1 ? "" : "s"} drawn with the figures nooticr returned in this session.`,
131
+ unmatched,
132
+ };
133
+ }
134
+ const n = unmatched.length;
135
+ return {
136
+ status: "unverified",
137
+ note: `${n} of ${total} ${noun}${total === 1 ? "" : "s"} ${n === 1 ? "is" : "are"} not among what nooticr ` +
138
+ `returned in this session, so ${n === 1 ? "its" : "their"} figures are as re-sent and were not ` +
139
+ `checked: ${unmatched.slice(0, 5).join(", ")}${n > 5 ? ", …" : ""}.`,
140
+ unmatched,
141
+ };
142
+ }
143
+ /**
144
+ * One ledger per server. Keyed off the server object so the task path
145
+ * (registerSlowTool) and the plain path record into the same one without
146
+ * threading it through every registration.
147
+ */
148
+ const LEDGERS = new WeakMap();
149
+ export function ledgerFor(server) {
150
+ let l = LEDGERS.get(server);
151
+ if (!l)
152
+ LEDGERS.set(server, (l = new FetchLedger()));
153
+ return l;
154
+ }
@@ -311,6 +311,15 @@ const marketplaceScanShape = {
311
311
  };
312
312
  const marketplaceScan = open(marketplaceScanShape);
313
313
  const amazonScan = open(amazonScanShape);
314
+ /**
315
+ * Whether a show_* view drew nooticr's own figures (#107): the session's
316
+ * fetch ledger overlays what was actually returned on what the model sent.
317
+ */
318
+ const checkedAgainstSession = open({
319
+ status: scalar().describe("verified: every row matched what nooticr returned this session. unverified: some are as re-sent."),
320
+ note: scalar(),
321
+ unmatched: listOf(z.string()).describe("Rows not among what nooticr returned in this session; drawn as sent, unchecked."),
322
+ }).nullish();
314
323
  export const OUTPUT_SCHEMAS = {
315
324
  analyze_post: open({ ...analyzed, ...evidence }),
316
325
  analyze_post_fast: open({ ...evidence, ...analyzed }),
@@ -539,6 +548,7 @@ export const OUTPUT_SCHEMAS = {
539
548
  // to land except chat text. Free, and make no requests, same as
540
549
  // show_comment_review: they only draw what they're handed.
541
550
  show_compared_posts: open({
551
+ verification: checkedAgainstSession,
542
552
  posts: listOf(post).describe("The 2-5 posts being compared, same shape as compare_posts returned."),
543
553
  comparison: open({
544
554
  winner: scalar().describe("1-indexed position of the post that won, matching the posts array."),
@@ -550,6 +560,7 @@ export const OUTPUT_SCHEMAS = {
550
560
  mcpCredits,
551
561
  }),
552
562
  show_post_analysis: open({
563
+ verification: checkedAgainstSession,
553
564
  url: scalar(),
554
565
  post: post.nullish().describe("The post analyze_post/analyze_post_fast/understand_social_post handed back."),
555
566
  analysis: open({}).passthrough().nullish().describe("Your own analysis, any of the fields analyze_post's guidance asked for — summary, " +
@@ -569,6 +580,7 @@ export const OUTPUT_SCHEMAS = {
569
580
  mcpCredits,
570
581
  }),
571
582
  show_variants: open({
583
+ verification: checkedAgainstSession,
572
584
  sourceUrl: scalar(),
573
585
  post: post.nullish(),
574
586
  variants: listOf(open({
@@ -945,6 +957,7 @@ export const OUTPUT_SCHEMAS = {
945
957
  mcpCredits,
946
958
  }),
947
959
  show_standings: open({
960
+ verification: checkedAgainstSession,
948
961
  creators: listOf(anyObject()),
949
962
  metric: scalar(),
950
963
  ranking: scalar().describe("Which axis the order is on — 'how often' and 'how big' disagree."),
@@ -1148,6 +1161,7 @@ export const OUTPUT_SCHEMAS = {
1148
1161
  * might want is left underived on purpose — the model reads the series.
1149
1162
  */
1150
1163
  show_trend: open({
1164
+ verification: checkedAgainstSession,
1151
1165
  points: listOf(anyObject()),
1152
1166
  term: scalar(),
1153
1167
  metric: scalar(),
@@ -24,6 +24,7 @@
24
24
  * The task APIs are experimental in SDK 1.30 and may change under us. They are
25
25
  * contained here for that reason — one file to fix rather than three handlers.
26
26
  */
27
+ import { ledgerFor } from "./fetch-ledger.js";
27
28
  import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
28
29
  /** How long a finished result stays fetchable. */
29
30
  export const TASK_TTL_MS = 900_000;
@@ -67,6 +68,7 @@ export function registerSlowTool(server, name, config, run) {
67
68
  void (async () => {
68
69
  try {
69
70
  const result = await run(args, extra);
71
+ ledgerFor(server).record(name, result?.structuredContent);
70
72
  await extra.taskStore.storeTaskResult(task.taskId, result?.isError ? "failed" : "completed", result);
71
73
  }
72
74
  catch (err) {
@@ -5,6 +5,7 @@
5
5
  * file for the CLI, KV session for the worker) — the tool bodies, schemas and
6
6
  * result formatting live here and nowhere else.
7
7
  */
8
+ import { ledgerFor } from "./fetch-ledger.js";
8
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
9
10
  import { z } from "zod";
10
11
  import { NooticrError, safeCreditsHint } from "./nooticr.js";
@@ -29,7 +30,7 @@ import { registerCollabTools } from "./collab.js";
29
30
  import { registerAmazonTools } from "./amazon.js";
30
31
  import { registerMarketplaceTools } from "./marketplace.js";
31
32
  /** Current MCP server version — bumped on every deploy for traceability. */
32
- export const MCP_SERVER_VERSION = "1.26.44";
33
+ export const MCP_SERVER_VERSION = "1.26.45";
33
34
  /** MCP Apps extension identifier */
34
35
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
35
36
  /** MIME type for MCP Apps HTML resources */
@@ -556,6 +557,18 @@ export function createMcpServer(rawMakeClient, opts) {
556
557
  },
557
558
  },
558
559
  });
560
+ // Every tool result passes through the session's fetch ledger on its way
561
+ // out, so the show_* views can draw what nooticr returned rather than the
562
+ // model's copy of it (#107). Wrapped once here rather than in eighty
563
+ // handlers; registerSlowTool records the task path the same way.
564
+ const ledger = ledgerFor(server);
565
+ const registerTool = server.registerTool.bind(server);
566
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
567
+ server.registerTool = (name, config, handler) => registerTool(name, config, async (...a) => {
568
+ const result = (await handler(...a));
569
+ ledger.record(name, result?.structuredContent);
570
+ return result;
571
+ });
559
572
  // Register one UI app resource per tool/view. Claude/ChatGPT render a
560
573
  // separate sandboxed app per resourceUri and key app state by it, so a
561
574
  // distinct URI per tool avoids a shared app instance/session colliding
@@ -1685,7 +1698,9 @@ export function createMcpServer(rawMakeClient, opts) {
1685
1698
  // it (show_hooks, show_variants, show_repurposed_post).
1686
1699
  server.registerTool("show_trend", {
1687
1700
  title: "Show Trend",
1688
- description: "Draw the trend you read out of mention_trend. Free, and makes no requests — it renders what " +
1701
+ description: "Figures are checked against what nooticr returned in this session: its own numbers are drawn, " +
1702
+ "and a row it never returned is drawn marked as unchecked, so there is no need to re-type them exactly. " +
1703
+ "Draw the trend you read out of mention_trend. Free, and makes no requests — it renders what " +
1689
1704
  "you pass it: the series as a chart, per-network lines where you supply them, and your own " +
1690
1705
  "read of what changed. Pass `tooShort` when the series has too few points to call a " +
1691
1706
  "direction, and `edgeIsRecordStart` when the left edge is where the record begins rather " +
@@ -1726,16 +1741,19 @@ export function createMcpServer(rawMakeClient, opts) {
1726
1741
  })
1727
1742
  .strict(),
1728
1743
  }, async (args) => {
1744
+ const checked = ledger.checkRuns(args.points, args.term);
1729
1745
  return {
1730
1746
  content: [
1731
1747
  {
1732
1748
  type: "text",
1733
1749
  text: `Showing a ${args.points.length}-point trend${args.term ? ` for "${args.term}"` : ""}.` +
1734
- (args.verdict ? ` ${args.verdict}` : ""),
1750
+ (args.verdict ? ` ${args.verdict}` : "") +
1751
+ ` ${checked.verification.note}`,
1735
1752
  },
1736
1753
  ],
1737
1754
  structuredContent: {
1738
- points: args.points,
1755
+ points: checked.rows,
1756
+ verification: checked.verification,
1739
1757
  term: args.term ?? null,
1740
1758
  metric: args.metric ?? "found",
1741
1759
  verdict: args.verdict ?? null,
@@ -1747,7 +1765,9 @@ export function createMcpServer(rawMakeClient, opts) {
1747
1765
  });
1748
1766
  server.registerTool("show_standings", {
1749
1767
  title: "Show Standings",
1750
- description: "Draw the standings you read out of compare_creators or watchlist_standings. Free, and makes " +
1768
+ description: "Figures are checked against what nooticr returned in this session: its own numbers are drawn, " +
1769
+ "and a row it never returned is drawn marked as unchecked, so there is no need to re-type them exactly. " +
1770
+ "Draw the standings you read out of compare_creators or watchlist_standings. Free, and makes " +
1751
1771
  "no requests — it only renders what you pass it: one row per creator with their window, their " +
1752
1772
  "own median, how often they beat it and how hard, and the post that did best. Pass `ranking` " +
1753
1773
  "to say which axis you ordered on, and `tooThin` for the creators whose window was too short " +
@@ -1783,16 +1803,19 @@ export function createMcpServer(rawMakeClient, opts) {
1783
1803
  })
1784
1804
  .strict(),
1785
1805
  }, async (args) => {
1806
+ const checked = ledger.checkCreators(args.creators);
1786
1807
  return {
1787
1808
  content: [
1788
1809
  {
1789
1810
  type: "text",
1790
1811
  text: `Showing standings for ${args.creators.length} creator${args.creators.length === 1 ? "" : "s"}.` +
1791
- (args.verdict ? ` ${args.verdict}` : ""),
1812
+ (args.verdict ? ` ${args.verdict}` : "") +
1813
+ ` ${checked.verification.note}`,
1792
1814
  },
1793
1815
  ],
1794
1816
  structuredContent: {
1795
- creators: args.creators,
1817
+ creators: checked.rows,
1818
+ verification: checked.verification,
1796
1819
  metric: args.metric ?? "views",
1797
1820
  ranking: args.ranking ?? null,
1798
1821
  verdict: args.verdict ?? null,
@@ -1803,7 +1826,9 @@ export function createMcpServer(rawMakeClient, opts) {
1803
1826
  });
1804
1827
  server.registerTool("show_compared_posts", {
1805
1828
  title: "Show Comparison",
1806
- description: "Display a comparison you wrote after compare_posts fetched the first post and you fetched " +
1829
+ description: "Figures are checked against what nooticr returned in this session: its own numbers are drawn, " +
1830
+ "and a row it never returned is drawn marked as unchecked, so there is no need to re-type them exactly. " +
1831
+ "Display a comparison you wrote after compare_posts fetched the first post and you fetched " +
1807
1832
  "the rest yourself (get_social_media, 1 credit each). Free, and makes no requests — it only " +
1808
1833
  "draws what you pass it: each post with a BEST badge on the winner, what differed, shared " +
1809
1834
  "strengths and the next experiment worth running. Call this after you have done the " +
@@ -1834,15 +1859,18 @@ export function createMcpServer(rawMakeClient, opts) {
1834
1859
  })
1835
1860
  .strict(),
1836
1861
  }, async (args) => {
1862
+ const checked = ledger.checkPosts(args.posts);
1837
1863
  return {
1838
1864
  content: [
1839
1865
  {
1840
1866
  type: "text",
1841
- text: `Showing a comparison of ${args.posts.length} posts.${args.winnerReason ? ` ${args.winnerReason}` : ""}`,
1867
+ text: `Showing a comparison of ${args.posts.length} posts.${args.winnerReason ? ` ${args.winnerReason}` : ""}` +
1868
+ ` ${checked.verification.note}`,
1842
1869
  },
1843
1870
  ],
1844
1871
  structuredContent: {
1845
- posts: args.posts,
1872
+ posts: checked.rows,
1873
+ verification: checked.verification,
1846
1874
  comparison: {
1847
1875
  winner: args.winner,
1848
1876
  winnerReason: args.winnerReason ?? null,
@@ -1856,7 +1884,9 @@ export function createMcpServer(rawMakeClient, opts) {
1856
1884
  });
1857
1885
  server.registerTool("show_post_analysis", {
1858
1886
  title: "Show Analysis",
1859
- description: "Display an analysis you wrote after analyze_post, analyze_post_fast or understand_social_post " +
1887
+ description: "Figures are checked against what nooticr returned in this session: its own numbers are drawn, " +
1888
+ "and a row it never returned is drawn marked as unchecked, so there is no need to re-type them exactly. " +
1889
+ "Display an analysis you wrote after analyze_post, analyze_post_fast or understand_social_post " +
1860
1890
  "handed you the material. Free, and makes no requests — it only draws what you pass it: hook " +
1861
1891
  "strength, script structure, quotable lines, hashtags, target audience, viral triggers and " +
1862
1892
  "more, whichever of these you actually produced. Call this after you have done the analysing, " +
@@ -1883,11 +1913,15 @@ export function createMcpServer(rawMakeClient, opts) {
1883
1913
  })
1884
1914
  .strict(),
1885
1915
  }, async (args) => {
1916
+ const checked = ledger.checkPosts([
1917
+ { ...(args.post ?? { platform: platformFromUrl(args.url) }), externalUrl: args.post?.externalUrl ?? args.url },
1918
+ ]);
1886
1919
  return {
1887
- content: [{ type: "text", text: `Showing your analysis of ${args.url}.` }],
1920
+ content: [{ type: "text", text: `Showing your analysis of ${args.url}. ${checked.verification.note}` }],
1888
1921
  structuredContent: {
1889
1922
  url: args.url,
1890
- post: args.post ?? { platform: platformFromUrl(args.url), externalUrl: args.url },
1923
+ post: checked.rows[0],
1924
+ verification: checked.verification,
1891
1925
  analysis: args.analysis,
1892
1926
  mcpCredits: { cost: 0 },
1893
1927
  },
@@ -1932,7 +1966,9 @@ export function createMcpServer(rawMakeClient, opts) {
1932
1966
  });
1933
1967
  server.registerTool("show_variants", {
1934
1968
  title: "Show Variants",
1935
- description: "Display the post variants you wrote after create_variants handed you the original post's " +
1969
+ description: "Figures are checked against what nooticr returned in this session: its own numbers are drawn, " +
1970
+ "and a row it never returned is drawn marked as unchecked, so there is no need to re-type them exactly. " +
1971
+ "Display the post variants you wrote after create_variants handed you the original post's " +
1936
1972
  "material. Free, and makes no requests — it only draws what you pass it: each variant's hook, " +
1937
1973
  "the angle that changes, its shot beats and its call to action. Call this after you have " +
1938
1974
  "written the variants, not instead of writing them.",
@@ -1960,11 +1996,23 @@ export function createMcpServer(rawMakeClient, opts) {
1960
1996
  })
1961
1997
  .strict(),
1962
1998
  }, async (args) => {
1999
+ const checked = ledger.checkPosts([
2000
+ {
2001
+ ...(args.post ?? { platform: platformFromUrl(args.sourceUrl) }),
2002
+ externalUrl: args.post?.externalUrl ?? args.sourceUrl,
2003
+ },
2004
+ ]);
1963
2005
  return {
1964
- content: [{ type: "text", text: `Showing ${args.variants.length} variants of ${args.sourceUrl}.` }],
2006
+ content: [
2007
+ {
2008
+ type: "text",
2009
+ text: `Showing ${args.variants.length} variants of ${args.sourceUrl}. ${checked.verification.note}`,
2010
+ },
2011
+ ],
1965
2012
  structuredContent: {
1966
2013
  sourceUrl: args.sourceUrl,
1967
- post: args.post ?? { platform: platformFromUrl(args.sourceUrl), externalUrl: args.sourceUrl },
2014
+ post: checked.rows[0],
2015
+ verification: checked.verification,
1968
2016
  variants: args.variants,
1969
2017
  mcpCredits: { cost: 0 },
1970
2018
  },
@@ -3464,7 +3464,24 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
3464
3464
  // after it — one wrapper instead of a call at each of the many early
3465
3465
  // returns below.
3466
3466
  function render(result){
3467
- try{renderView(result);}finally{initPlayers();setTimeout(reportSize,80);}
3467
+ try{renderView(result);verificationBanner(result);}finally{initPlayers();setTimeout(reportSize,80);}
3468
+ }
3469
+
3470
+ // #107: a show_* view whose rows could not all be matched to what nooticr
3471
+ // returned this session says so above the card. The marketplace card draws
3472
+ // its own. Only the unverified case is drawn: a banner on every honest card
3473
+ // would be noise that trains a reader to skip it.
3474
+ function verificationBanner(result){
3475
+ var d=extractResult(result);
3476
+ if(!d||typeof d!=="object"||d.marketplace)return;
3477
+ var v=d.verification;
3478
+ if(!v||v.status!=="unverified")return;
3479
+ var app=document.getElementById("app");if(!app)return;
3480
+ var b=document.createElement("div");
3481
+ b.setAttribute("data-verification","unverified");
3482
+ b.style.cssText="font-size:12px;padding:8px 10px;margin:0 0 10px;border-radius:8px;border:1px solid var(--border);background:rgba(180,35,24,.06)";
3483
+ b.innerHTML="<b>Not checked against nooticr</b> · "+esc(v.note||"");
3484
+ app.insertBefore(b,app.firstChild);
3468
3485
  }
3469
3486
 
3470
3487
  // ─── Own account ───
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nooticr/mcp",
3
3
  "mcpName": "com.nooticr/mcp",
4
- "version": "1.26.44",
4
+ "version": "1.26.45",
5
5
  "description": "MCP server for nooticr - fetch, discover and understand TikTok/Instagram/YouTube/X/Reddit/LinkedIn/Weibo posts with AI (media metadata, niche discovery, brand monitoring, hook & viral analysis)",
6
6
  "homepage": "https://mcp.nooticr.com",
7
7
  "repository": {