@nooticr/mcp 1.26.29 → 1.26.30

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.
@@ -13,6 +13,7 @@
13
13
  * rather than a build.
14
14
  */
15
15
  import { z } from "zod";
16
+ import { withEvidence } from "./evidence-digest.js";
16
17
  import { OUTPUT_SCHEMAS } from "./output-schemas.js";
17
18
  import { viewMeta } from "./view-meta.js";
18
19
  import { BACKEND_CALL_CREDITS, confirmSpend, declinedResult, searchMentionsCost } from "./spend.js";
@@ -268,9 +269,17 @@ function trendGuidance(sc, runs) {
268
269
  }
269
270
  function toResult(proxy) {
270
271
  const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
272
+ const structured = proxy.structured;
273
+ // The backend's text block is a one-line summary — "1 active brand watch."
274
+ // — and the watches themselves are in `structuredContent`, which a host
275
+ // rendering the view keeps for the widget. A model asked which watch to stop
276
+ // could see the count and not the terms (#59).
277
+ const text = String(textBlock?.text ?? "");
271
278
  return {
272
- content: textBlock ? [{ type: "text", text: String(textBlock.text ?? "") }] : [],
273
- structuredContent: proxy.structured,
279
+ content: [
280
+ { type: "text", text: structured ? withEvidence(text, structured) : text },
281
+ ],
282
+ structuredContent: structured,
274
283
  };
275
284
  }
276
285
  function failed(prefix, err) {
@@ -468,10 +477,17 @@ export function registerBrandWatch(server, makeClient) {
468
477
  // model reading two points as a trend, and reading the start of the
469
478
  // retention window as the moment a conversation began.
470
479
  const guidance = trendGuidance(sc, runs);
480
+ const payload = { guidance, ...sc };
471
481
  return {
472
482
  ...result,
473
- content: [{ type: "text", text: guidance }],
474
- structuredContent: { guidance, ...sc },
483
+ // "8 runs of X, newest first" with the runs in the other channel is
484
+ // the same deictic failure as everywhere else (#59): a host that
485
+ // renders the view keeps `structuredContent` for the widget, and the
486
+ // model is left counting runs it cannot read.
487
+ content: [
488
+ { type: "text", text: withEvidence(guidance, payload) },
489
+ ],
490
+ structuredContent: payload,
475
491
  };
476
492
  }
477
493
  catch (err) {
@@ -0,0 +1,269 @@
1
+ /**
2
+ * A compact rendering of a payload's evidence, for the channel that gets the
3
+ * text blocks.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * There are two channels out of a tool call and no host delivers both to the
8
+ * model:
9
+ *
10
+ * | | `content` text blocks | `structuredContent` |
11
+ * |---|---|---|
12
+ * | Claude Code | dropped when `structuredContent` is present | delivered, serialised |
13
+ * | Claude.ai, tool with a UI view | delivered | routed to the widget |
14
+ *
15
+ * #44 found the first column and #51 fixed it, by writing the guidance into
16
+ * both channels. The evidence never got the same treatment, so it survived
17
+ * Claude Code and vanished on Claude.ai — where a real session received
18
+ * "3 posts that might be someone describing ...", instructions to quote lines
19
+ * from them and report their permalinks, and not one post (#59).
20
+ *
21
+ * The tools most affected were the composed ones, which replace the backend's
22
+ * text block with their own guidance. That guidance is deictic — "Here are 4
23
+ * comments", "2 creators", "8 runs" — so it counts and describes material that
24
+ * is somewhere else. "Here are 4 comments" is a false sentence in the channel
25
+ * it is written in.
26
+ *
27
+ * So: each channel has to be self-sufficient. This renders the evidence for
28
+ * the text one.
29
+ *
30
+ * ## What it is not
31
+ *
32
+ * Not the serialised payload. A model asked to judge posts needs the caption,
33
+ * the handle and the link; it does not need `engagementRate` to four decimal
34
+ * places, and 14 KB of JSON in a text block crowds out the guidance that says
35
+ * what to do with it. Each shape below renders the fields its guidance
36
+ * actually names.
37
+ */
38
+ /** Per-item text budget. Long enough for a real Reddit complaint, short enough that twenty fit. */
39
+ const ITEM_CHARS = 600;
40
+ /**
41
+ * Whole-digest budget.
42
+ *
43
+ * `answer_my_audience` can carry 14 KB of payload. The cap is what keeps a
44
+ * text block readable rather than a second serialisation of it — and when it
45
+ * bites, the digest says so, because a model reasoning over 20 of 60 comments
46
+ * has to know it has 20.
47
+ */
48
+ const DIGEST_CHARS = 6000;
49
+ /** How many items of one kind are worth rendering before the count matters more than the detail. */
50
+ const MAX_ITEMS = 25;
51
+ const str = (v) => (typeof v === "string" ? v : "");
52
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
53
+ function clamp(text, max = ITEM_CHARS) {
54
+ const flat = text.replace(/\s+/g, " ").trim();
55
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
56
+ }
57
+ /** `12.3k`, because a raw 12345 beside four other numbers is harder to read than it looks. */
58
+ function compactCount(n) {
59
+ if (n >= 1_000_000)
60
+ return `${(n / 1_000_000).toFixed(1)}M`;
61
+ if (n >= 1_000)
62
+ return `${(n / 1_000).toFixed(1)}k`;
63
+ return String(n);
64
+ }
65
+ function stats(row) {
66
+ const parts = [];
67
+ const views = num(row.views);
68
+ const likes = num(row.likes);
69
+ const comments = num(row.comments);
70
+ if (views)
71
+ parts.push(`${compactCount(views)} views`);
72
+ if (likes)
73
+ parts.push(`${compactCount(likes)} likes`);
74
+ if (comments)
75
+ parts.push(`${compactCount(comments)} comments`);
76
+ return parts.join(", ");
77
+ }
78
+ /**
79
+ * One post, as a line a model can cite.
80
+ *
81
+ * `externalUrl` is not decoration: `find_people_with_problem`'s guidance asks
82
+ * for qualifying posts to be reported "with their permalink, so they can
83
+ * actually be replied to". A digest without links cannot answer that.
84
+ */
85
+ function postLine(row) {
86
+ const head = [
87
+ str(row.platform) || null,
88
+ str(row.creatorHandle) ? `@${str(row.creatorHandle)}` : null,
89
+ str(row.subreddit) || null,
90
+ str(row.foundBy) ? `found by: ${str(row.foundBy)}` : null,
91
+ stats(row) || null,
92
+ ]
93
+ .filter(Boolean)
94
+ .join(" · ");
95
+ const text = clamp(str(row.caption) || str(row.title) || str(row.text));
96
+ const url = str(row.externalUrl) || str(row.url);
97
+ return [head && `[${head}]`, text, url].filter(Boolean).join("\n ");
98
+ }
99
+ /**
100
+ * One comment, with its id.
101
+ *
102
+ * The id is the load-bearing field: `analyze_comments` asks for a
103
+ * classification per comment and `show_comment_review` takes those ids back.
104
+ * A digest that renders the text and drops the id breaks the next call.
105
+ */
106
+ function commentLine(row) {
107
+ const id = str(row.id);
108
+ const who = str(row.author) || str(row.username);
109
+ const likes = num(row.likes);
110
+ const head = [id || null, who ? `@${who}` : null, likes ? `${compactCount(likes)} likes` : null]
111
+ .filter(Boolean)
112
+ .join(" · ");
113
+ return [head && `[${head}]`, clamp(str(row.text) || str(row.comment))].filter(Boolean).join("\n ");
114
+ }
115
+ function creatorLine(row) {
116
+ const head = [
117
+ str(row.username) ? `@${str(row.username)}` : str(row.handle) ? `@${str(row.handle)}` : null,
118
+ str(row.platform) || null,
119
+ num(row.followers) ? `${compactCount(num(row.followers))} followers` : null,
120
+ str(row.foundBy) ? `found by: ${str(row.foundBy)}` : null,
121
+ ]
122
+ .filter(Boolean)
123
+ .join(" · ");
124
+ const why = clamp(str(row.why) || str(row.bio) || str(row.verdict));
125
+ const links = Array.isArray(row.links)
126
+ ? row.links
127
+ .map((l) => str(l.url))
128
+ .filter(Boolean)
129
+ .slice(0, 3)
130
+ .join(" ")
131
+ : "";
132
+ return [head && `[${head}]`, why, links].filter(Boolean).join("\n ");
133
+ }
134
+ function runLine(row) {
135
+ const when = str(row.ranAt).slice(0, 10);
136
+ const found = num(row.found);
137
+ const reported = num(row.reported);
138
+ return `${when || "(undated)"} · found ${found ?? "?"} · reported ${reported ?? "?"}`;
139
+ }
140
+ /** Everything with no shape of its own: the short scalar fields, in order. */
141
+ function genericLine(row) {
142
+ const pairs = Object.entries(row)
143
+ .filter(([, v]) => typeof v === "string" || typeof v === "number" || typeof v === "boolean")
144
+ .map(([k, v]) => `${k}: ${clamp(String(v), 160)}`)
145
+ .slice(0, 8);
146
+ return pairs.join(" · ");
147
+ }
148
+ /**
149
+ * The evidence keys worth rendering, and how.
150
+ *
151
+ * Ordered: when a payload carries several, the first is the one the guidance
152
+ * is most likely to be about.
153
+ */
154
+ const SHAPES = [
155
+ { key: "posts", label: "post", line: postLine },
156
+ // The baseline a post is scored against, and the asks a niche sweep found.
157
+ // Both are posts in all but name, and both are the material their tool's
158
+ // guidance reasons over — `why_did_this_underperform` cannot say "this is an
159
+ // ordinary result" without the distribution it is ordinary against.
160
+ { key: "window", label: "post in the baseline window", line: postLine },
161
+ { key: "demand", label: "thing the audience asked for", line: postLine },
162
+ { key: "supply", label: "post already serving it", line: postLine },
163
+ { key: "comments", label: "comment", line: commentLine },
164
+ { key: "replies", label: "reply", line: commentLine },
165
+ { key: "mentions", label: "mention", line: commentLine },
166
+ { key: "creators", label: "creator", line: creatorLine },
167
+ { key: "candidates", label: "candidate", line: creatorLine },
168
+ { key: "runs", label: "run", line: runLine },
169
+ { key: "cues", label: "line", line: genericLine },
170
+ { key: "segments", label: "segment", line: genericLine },
171
+ { key: "hashtags", label: "hashtag", line: genericLine },
172
+ { key: "sounds", label: "sound", line: genericLine },
173
+ { key: "watches", label: "watch", line: genericLine },
174
+ { key: "apps", label: "app", line: genericLine },
175
+ { key: "connections", label: "connection", line: genericLine },
176
+ { key: "unavailable", label: "network that could not be searched", line: genericLine },
177
+ ];
178
+ /**
179
+ * Render whatever evidence a payload carries, or "" when it carries none.
180
+ *
181
+ * Returning "" rather than a placeholder matters: a tool whose result is a
182
+ * verdict rather than material (`show_*`, a state mutation) should get its
183
+ * guidance and nothing appended.
184
+ */
185
+ /** Fields that identify a row — the ones a caller would cite it by. */
186
+ const IDENTIFYING = [
187
+ "id",
188
+ "externalUrl",
189
+ "url",
190
+ "permalink",
191
+ "caption",
192
+ "text",
193
+ "title",
194
+ "username",
195
+ "handle",
196
+ "creatorHandle",
197
+ "name",
198
+ "term",
199
+ "appId",
200
+ "ranAt",
201
+ "tag",
202
+ ];
203
+ /**
204
+ * Any array of objects the named shapes did not claim.
205
+ *
206
+ * A hand-maintained list of keys is exactly the artefact that drifts — a tool
207
+ * adds `window` or `demand`, nobody updates the list, and the material goes
208
+ * missing again with every test still green. So anything that looks like a row
209
+ * with an identity gets rendered, well or plainly, rather than dropped.
210
+ */
211
+ function unclaimedShapes(payload) {
212
+ const named = new Set(SHAPES.map((s) => s.key));
213
+ return Object.entries(payload)
214
+ .filter(([key, rows]) => {
215
+ if (named.has(key) || !Array.isArray(rows) || rows.length === 0)
216
+ return false;
217
+ const first = rows.find((r) => r && typeof r === "object");
218
+ if (!first)
219
+ return false;
220
+ return IDENTIFYING.some((f) => {
221
+ const v = first[f];
222
+ return typeof v === "string" || typeof v === "number";
223
+ });
224
+ })
225
+ .map(([key]) => ({ key, label: key.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase(), line: genericLine }));
226
+ }
227
+ export function evidenceDigest(payload) {
228
+ const blocks = [];
229
+ let spent = 0;
230
+ for (const shape of [...SHAPES, ...unclaimedShapes(payload)]) {
231
+ const rows = payload[shape.key];
232
+ if (!Array.isArray(rows) || rows.length === 0)
233
+ continue;
234
+ if (spent >= DIGEST_CHARS)
235
+ break;
236
+ const shown = [];
237
+ for (const row of rows.slice(0, MAX_ITEMS)) {
238
+ if (spent >= DIGEST_CHARS)
239
+ break;
240
+ const line = shape.line((row ?? {}));
241
+ if (!line.trim())
242
+ continue;
243
+ shown.push(line);
244
+ spent += line.length;
245
+ }
246
+ if (!shown.length)
247
+ continue;
248
+ // Say what was left out. A model reasoning over 20 of 60 has to know it
249
+ // has 20 — a digest that silently truncates turns "here is everything" into
250
+ // a claim the caller cannot check.
251
+ const total = rows.length;
252
+ const plural = total === 1 || shape.label.endsWith("s") ? shape.label : `${shape.label}s`;
253
+ const header = shown.length < total
254
+ ? `${shown.length} of ${total} ${plural} (the rest are in the structured payload and in the view):`
255
+ : `${total} ${plural}:`;
256
+ blocks.push([header, ...shown.map((l, i) => `${i + 1}. ${l}`)].join("\n"));
257
+ }
258
+ return blocks.join("\n\n");
259
+ }
260
+ /**
261
+ * Guidance and evidence in one text block, in the order a reader needs them.
262
+ *
263
+ * Guidance first: it is the instruction, and a model that reads the material
264
+ * before knowing what it is for reads it twice.
265
+ */
266
+ export function withEvidence(guidance, payload) {
267
+ const digest = evidenceDigest(payload);
268
+ return digest ? `${guidance}\n\n---\n\n${digest}` : guidance;
269
+ }
@@ -57,6 +57,7 @@ import { z } from "zod";
57
57
  import { OUTPUT_SCHEMAS } from "./output-schemas.js";
58
58
  import { platformFromUrl, postSlug } from "./comment-review.js";
59
59
  import { clamp, handleMissGuidance, ownIt, PLATFORM_ARG } from "./evidence.js";
60
+ import { withEvidence } from "./evidence-digest.js";
60
61
  import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SPOKEN_PLATFORMS, CREDITS_PER_CREATOR, } from "./spend.js";
61
62
  import { distributionOf, excluding, numberOf, standing, } from "./performance.js";
62
63
  import { normaliseHandle, watchEntryId, watchlistOwner } from "./watchlist.js";
@@ -596,14 +597,21 @@ const metricArg = z
596
597
  .optional()
597
598
  .describe("Which stat to rank on (default views).");
598
599
  export function registerJobTools(server, makeClient, store) {
599
- /** Guidance in the text block, evidence in the structured one — as every tool here does. */
600
- // Both channels, because only one of them arrives. A host that renders
601
- // `structuredContent` replaces the content text blocks with the serialised
602
- // JSON, so guidance kept only in a text block reaches no model — measured at
603
- // 0 phrases across 175 tool results. See tools.ts's runEvidence for the full
604
- // note, and docs/testing/tool-chaining-quests.md for the measurement.
600
+ /** Guidance AND evidence in both channels, because no host delivers both. */
601
+ // Neither channel is safe on its own, and which one is dropped depends on
602
+ // the host: Claude Code discards every `content` text block when a result
603
+ // carries `structuredContent` (#44, measured at 0 guidance phrases across
604
+ // 175 tool results), while Claude.ai routes `structuredContent` to the
605
+ // widget and hands the model only the text blocks (#59).
606
+ //
607
+ // #51 made the guidance survive both. The evidence did not, so a real
608
+ // session got "3 posts that might be someone describing ...", instructions
609
+ // to quote lines from them, and no posts. The guidance these tools write is
610
+ // deictic — it counts and describes material that used to be somewhere
611
+ // else — so `withEvidence` puts a rendering of that material in the same
612
+ // block as the sentence describing it.
605
613
  const evidence = (guidance, payload) => ({
606
- content: [{ type: "text", text: guidance }],
614
+ content: [{ type: "text", text: withEvidence(guidance, payload) }],
607
615
  structuredContent: { guidance, ...payload },
608
616
  });
609
617
  const failed = (prefix, err) => ({
@@ -51,13 +51,23 @@
51
51
  * `get_content_plan` above); this file is what makes them reachable here.
52
52
  */
53
53
  import { z } from "zod";
54
+ import { withEvidence } from "./evidence-digest.js";
54
55
  import { OUTPUT_SCHEMAS } from "./output-schemas.js";
55
56
  import { viewMeta } from "./view-meta.js";
56
57
  function toResult(proxy) {
57
58
  const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
59
+ const structured = proxy.structured;
60
+ // The backend's summary line, plus the rows it summarises. `list_own_apps`
61
+ // said "You have 2 app(s) in this workspace" and kept the appIds in
62
+ // `structuredContent` — which a host rendering the view gives to the widget,
63
+ // leaving a model that needs an appId for draft_post with the count alone
64
+ // (#59).
65
+ const text = String(textBlock?.text ?? "");
58
66
  return {
59
- content: textBlock ? [{ type: "text", text: String(textBlock.text ?? "") }] : [],
60
- structuredContent: proxy.structured,
67
+ content: [
68
+ { type: "text", text: structured ? withEvidence(text, structured) : text },
69
+ ],
70
+ structuredContent: structured,
61
71
  };
62
72
  }
63
73
  function failed(prefix, err) {
@@ -15,6 +15,7 @@ import { deriveHashtags, derivedNote } from "./hashtags.js";
15
15
  import { createTaskStore, registerSlowTool } from "./tasks.js";
16
16
  import { COMMENT_CATEGORIES, COMMENT_SENTIMENTS, platformFromUrl, reviewGuidance, toEvidence, } from "./comment-review.js";
17
17
  import { costSentence, EVIDENCE_PLANS, fetchBillingNote, frameIndex, framesToBlocks, planCalls, scoreDraftGuidance, KNOWN_PLATFORMS, platformFailureGuidance, } from "./evidence.js";
18
+ import { evidenceDigest } from "./evidence-digest.js";
18
19
  import { confirmSpend, declinedResult, searchMentionsCost, SEARCH_PLATFORMS, DISCOVERABLE_PLATFORMS, } from "./spend.js";
19
20
  import { BackendWatchStore, MemoryWatchStore, registerWatchlist, } from "./watchlist.js";
20
21
  import { registerJobTools } from "./jobs.js";
@@ -25,7 +26,7 @@ import { loadingPlansJson } from "./loading-plans.js";
25
26
  import { registerHandoff } from "./handoff.js";
26
27
  import { registerCollabTools } from "./collab.js";
27
28
  /** Current MCP server version — bumped on every deploy for traceability. */
28
- export const MCP_SERVER_VERSION = "1.26.29";
29
+ export const MCP_SERVER_VERSION = "1.26.30";
29
30
  /** MCP Apps extension identifier */
30
31
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
31
32
  /** MIME type for MCP Apps HTML resources */
@@ -444,6 +445,19 @@ async function runEvidence(tool, args, client) {
444
445
  // rather than "no fetch was made".
445
446
  if (plan.also && !nothingToFetch)
446
447
  out[plan.also.via] = extra;
448
+ // The evidence has to ride in the text block too, and it is written last
449
+ // because `out` is what it renders — the fetched payload, plus whatever the
450
+ // `also` call added, minus the frames that became image blocks.
451
+ //
452
+ // Same reason the guidance is in both channels (#44/#51), pointed the other
453
+ // way: Claude Code drops these text blocks and reads `structuredContent`,
454
+ // while a host rendering a UI view hands the model the text blocks and gives
455
+ // `structuredContent` to the widget. A transcript that exists only in the
456
+ // second one is a transcript the model was told to read and never saw (#59).
457
+ const digest = evidenceDigest(out);
458
+ if (digest) {
459
+ content[0] = { type: "text", text: `${guidance}\n\n---\n\n${digest}` };
460
+ }
447
461
  return { content, structuredContent: out };
448
462
  }
449
463
  export function createMcpServer(rawMakeClient, opts) {
@@ -1202,23 +1216,31 @@ export function createMcpServer(rawMakeClient, opts) {
1202
1216
  const structured = (res.structured ?? {});
1203
1217
  const comments = toEvidence(args.url, structured.comments);
1204
1218
  const guidance = reviewGuidance(args.url, comments.length);
1219
+ // "Here are 4 comments ... Read them and classify each one" was a false
1220
+ // sentence in the channel it was written in: the comments were only ever
1221
+ // in `structuredContent`, which a host rendering the view keeps for the
1222
+ // widget. The model was asked to classify comments it could not see, and
1223
+ // to return the `id` of each — so the digest renders ids too (#59).
1224
+ const payload = {
1225
+ // See runEvidence: a text block alone reaches no host that renders
1226
+ // structuredContent, and this is the tool show_comment_review's whole
1227
+ // chain hangs off.
1228
+ guidance,
1229
+ mode: "evidence",
1230
+ url: args.url,
1231
+ platform: structured.platform ?? null,
1232
+ commentCount: comments.length,
1233
+ comments,
1234
+ // Passed through rather than re-derived: the platform's own clustering
1235
+ // is evidence too, and it is already paid for.
1236
+ themes: structured.themes ?? [],
1237
+ mcpCredits: structured.mcpCredits ?? null,
1238
+ };
1205
1239
  return {
1206
- content: [{ type: "text", text: guidance }],
1207
- structuredContent: {
1208
- // See runEvidence: a text block alone reaches no host that renders
1209
- // structuredContent, and this is the tool show_comment_review's whole
1210
- // chain hangs off.
1211
- guidance,
1212
- mode: "evidence",
1213
- url: args.url,
1214
- platform: structured.platform ?? null,
1215
- commentCount: comments.length,
1216
- comments,
1217
- // Passed through rather than re-derived: the platform's own clustering
1218
- // is evidence too, and it is already paid for.
1219
- themes: structured.themes ?? [],
1220
- mcpCredits: structured.mcpCredits ?? null,
1221
- },
1240
+ content: [
1241
+ { type: "text", text: `${guidance}\n\n---\n\n${evidenceDigest(payload)}` },
1242
+ ],
1243
+ structuredContent: payload,
1222
1244
  };
1223
1245
  }
1224
1246
  catch (err) {
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.29",
4
+ "version": "1.26.30",
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": {