@nooticr/mcp 1.26.31 → 1.26.33

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
@@ -80,9 +80,9 @@ them caps that fan-out with an argument.
80
80
  | `understand_social_post` | 3 | The same two fetches, asked for a description of what physically happens on screen rather than why it works. Use when you need the events, not the strategy. |
81
81
  | `analyze_comments` | 2 | The comment section, every comment with a stable id, and the taxonomy to label them with — sentiment, and whether each is praise, a complaint, a bug report, a question, a request, a comparison or spam. The same `get_post_comments` call, at the same price as reading them directly. |
82
82
  | `show_comment_review` | free | Draws the classifications your model produced — every comment with its sentiment and category, filterable and selectable. Makes no requests; it only renders what you pass it. |
83
- | `show_analysis` | free | Draws the analysis your model wrote from `analyze_post`/`analyze_post_fast`/`understand_social_post` — hook strength, script structure, quotable lines, suggested hashtags and more, whichever fields you produced. Makes no requests; it only renders what you pass it. |
83
+ | `show_post_analysis` | free | Draws the analysis your model wrote from `analyze_post`/`analyze_post_fast`/`understand_social_post` — hook strength, script structure, quotable lines, suggested hashtags and more, whichever fields you produced. Makes no requests; it only renders what you pass it. |
84
84
  | `compare_posts` | 1 | The first of two to five posts, fetched with its stats, and the comparison left to you. Fetch the rest with `get_social_media` at 1 credit each. Use when performance differs and you need to know why. |
85
- | `show_comparison` | free | Draws the comparison your model wrote from `compare_posts` — each post scored, the winner marked, what differed and the next test worth running. Makes no requests; it only renders what you pass it. |
85
+ | `show_compared_posts` | free | Draws the comparison your model wrote from `compare_posts` — each post scored, the winner marked, what differed and the next test worth running. Makes no requests; it only renders what you pass it. |
86
86
 
87
87
  ### Research a niche or a creator
88
88
 
@@ -94,7 +94,20 @@ function postLine(row) {
94
94
  .join(" · ");
95
95
  const text = clamp(str(row.caption) || str(row.title) || str(row.text));
96
96
  const url = str(row.externalUrl) || str(row.url);
97
- return [head && `[${head}]`, text, url].filter(Boolean).join("\n ");
97
+ // Replies under the post, when something opened it. Nested one level down,
98
+ // which is exactly where #59 could come back without the chain-map gate
99
+ // noticing: `posts` is rendered, so the check is satisfied while the thing
100
+ // that was paid for — the comment that says "same here, this is my problem"
101
+ // — stays in the payload only.
102
+ const replies = Array.isArray(row.commentSample) ? row.commentSample : [];
103
+ const shown = replies.slice(0, 4).map((c) => ` - ${clamp(commentLine(c), 240)}`);
104
+ if (replies.length > shown.length) {
105
+ shown.push(` - (${replies.length - shown.length} more under this post)`);
106
+ }
107
+ const readNothing = row.commentsRead === 0 ? " - (this thread would not open — not that nobody replied)" : "";
108
+ return [head && `[${head}]`, text, url, ...shown, readNothing]
109
+ .filter(Boolean)
110
+ .join("\n ");
98
111
  }
99
112
  /**
100
113
  * One comment, with its id.
@@ -204,7 +204,7 @@ export const EVIDENCE_PLANS = {
204
204
  "what moved while it was there, so do not describe motion you have not",
205
205
  "seen across two frames.",
206
206
  "",
207
- "When you are done, call show_analysis with the url and your analysis —",
207
+ "When you are done, call show_post_analysis with the url and your analysis —",
208
208
  "it draws what you found (hook strength, script structure, quotable",
209
209
  "lines, suggested hashtags, target audience and whichever other fields",
210
210
  "you produced) so it is visible, not only said in chat.",
@@ -239,7 +239,7 @@ export const EVIDENCE_PLANS = {
239
239
  "not what moved during it, so describe stills unless two frames actually",
240
240
  "show the change.",
241
241
  "",
242
- "When you are done, call show_analysis with the url and your analysis —",
242
+ "When you are done, call show_post_analysis with the url and your analysis —",
243
243
  "it draws what you found so it is visible, not only said in chat.",
244
244
  ownIt,
245
245
  ].join("\n");
@@ -259,7 +259,7 @@ export const EVIDENCE_PLANS = {
259
259
  "yourself and look at the frames — do not ask anyone else to fetch",
260
260
  "them for you.",
261
261
  "",
262
- "When you are done, call show_analysis with the url and your analysis —",
262
+ "When you are done, call show_post_analysis with the url and your analysis —",
263
263
  "it draws what you found so it is visible, not only said in chat.",
264
264
  ownIt,
265
265
  ].join("\n"),
@@ -274,7 +274,7 @@ export const EVIDENCE_PLANS = {
274
274
  "which performed better, what actually differed, and the single test",
275
275
  "worth running next. Ground every difference in a number or a quote.",
276
276
  "",
277
- "When you are done, call show_comparison with all the posts (same shape",
277
+ "When you are done, call show_compared_posts with all the posts (same shape",
278
278
  "get_social_media returned for each) and your comparison — it draws a",
279
279
  "scoreboard with the winner marked, so the comparison is visible, not",
280
280
  "only said in chat.",
@@ -2112,6 +2112,15 @@ export function registerJobTools(server, makeClient, store) {
2112
2112
  .optional()
2113
2113
  .describe("Where to look (default reddit). Each one is a separate paid search."),
2114
2114
  limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."),
2115
+ readComments: z
2116
+ .boolean()
2117
+ .optional()
2118
+ .describe("Open the most promising results and read their comments too (default false). " +
2119
+ "On Reddit especially, the person with the problem is at least as often replying " +
2120
+ "under someone else's thread as posting their own, and a post-only search cannot " +
2121
+ "see any of them. Costs 2 nooticr credits per post opened, up to 5 — and they are " +
2122
+ "opened on the MERGED, deduped results, so it is five extra calls in total rather " +
2123
+ "than five per query shape per platform."),
2115
2124
  queries: z
2116
2125
  .array(z.string())
2117
2126
  .optional()
@@ -2181,6 +2190,41 @@ export function registerJobTools(server, makeClient, store) {
2181
2190
  }
2182
2191
  }
2183
2192
  }
2193
+ // Read the comments under the most promising results, on the MERGED,
2194
+ // deduped list rather than per search.
2195
+ //
2196
+ // That distinction is the whole reason this lives here and not in the
2197
+ // backend's own `includeComments`: three query shapes across two
2198
+ // platforms is six searches, and opening five posts under each would be
2199
+ // thirty paid calls for a tool whose description promises a wide CHEAP
2200
+ // net. On the merged list it is five, once.
2201
+ //
2202
+ // Why at all: on Reddit the person with the problem is at least as often
2203
+ // replying under someone else's thread as posting their own, and a
2204
+ // post-only search cannot see any of them (Nooticr/nooticr-server#63).
2205
+ if (args.readComments && posts.length) {
2206
+ const OPEN = 5;
2207
+ for (const post of posts.slice(0, OPEN)) {
2208
+ const url = String(post.externalUrl ?? post.url ?? "");
2209
+ if (!url)
2210
+ continue;
2211
+ try {
2212
+ const res = await client.callTool("get_post_comments", { url, limit: 8 });
2213
+ const structured = (res.structured ?? {});
2214
+ spend.record("get_post_comments", structured);
2215
+ const comments = Array.isArray(structured.comments) ? structured.comments : [];
2216
+ post.commentsRead = comments.length;
2217
+ post.commentSample = comments;
2218
+ }
2219
+ catch {
2220
+ // A thread that will not open is not a failed sweep — the post's
2221
+ // own text may carry the complaint, and this is the cheap net.
2222
+ // Stated rather than left absent, so a reader can tell "no
2223
+ // comments" from "not looked at".
2224
+ post.commentsRead = 0;
2225
+ }
2226
+ }
2227
+ }
2184
2228
  if (!posts.length) {
2185
2229
  return evidence([
2186
2230
  `Nothing came back for "${problem}" on ${platforms.join(", ")}.`,
@@ -2223,6 +2267,17 @@ export function registerJobTools(server, makeClient, store) {
2223
2267
  "and say how many you rejected and why — a shortlist with no reject count reads as if " +
2224
2268
  "the search was precise.",
2225
2269
  "",
2270
+ args.readComments
2271
+ ? "The first few posts carry `commentSample`: the replies underneath them. Judge those " +
2272
+ "the same way — a person saying \"same here, this is exactly my problem\" under " +
2273
+ "someone else's thread is a better find than the thread itself, and the permalink " +
2274
+ "to quote is the post's. `commentsRead: 0` means the thread would not open, not " +
2275
+ "that nobody replied."
2276
+ : "Only the posts themselves were searched. On Reddit the person with the problem is " +
2277
+ "at least as often replying under someone else's thread as posting their own — if " +
2278
+ "these results look thin, call again with `readComments: true` to open the top few " +
2279
+ "and read what is underneath.",
2280
+ "",
2226
2281
  args.queries?.length
2227
2282
  ? "`foundBy` says which of your own phrasings surfaced each post, in the order you " +
2228
2283
  "gave them."
@@ -320,8 +320,8 @@ export const LOADING_PLANS = {
320
320
  // The show_* family renders the model's own output. A wait here is brief
321
321
  // and free, but it still says which, because "nothing is happening" and
322
322
  // "this is free and instant" look identical on a blank screen.
323
- show_analysis: { label: "Drawing the reading", kind: "text", n: 1, steps: [], free: true },
324
- show_comparison: { label: "Drawing the comparison", kind: "strip", n: 2, steps: [], free: true },
323
+ show_post_analysis: { label: "Drawing the reading", kind: "text", n: 1, steps: [], free: true },
324
+ show_compared_posts: { label: "Drawing the comparison", kind: "strip", n: 2, steps: [], free: true },
325
325
  show_hooks: { label: "Laying out the hooks", kind: "list", n: 5, steps: [], free: true },
326
326
  show_variants: { label: "Laying out the variants", kind: "list", n: 4, steps: [], free: true },
327
327
  show_repurposed_post: { label: "Laying out the rewrites", kind: "text", n: 1, steps: [], free: true },
@@ -418,7 +418,7 @@ export const OUTPUT_SCHEMAS = {
418
418
  // thinking" (README) — but until these existed, the thinking had nowhere
419
419
  // to land except chat text. Free, and make no requests, same as
420
420
  // show_comment_review: they only draw what they're handed.
421
- show_comparison: open({
421
+ show_compared_posts: open({
422
422
  posts: listOf(post).describe("The 2-5 posts being compared, same shape as compare_posts returned."),
423
423
  comparison: open({
424
424
  winner: scalar().describe("1-indexed position of the post that won, matching the posts array."),
@@ -429,7 +429,7 @@ export const OUTPUT_SCHEMAS = {
429
429
  }).nullish(),
430
430
  mcpCredits,
431
431
  }),
432
- show_analysis: open({
432
+ show_post_analysis: open({
433
433
  url: scalar(),
434
434
  post: post.nullish().describe("The post analyze_post/analyze_post_fast/understand_social_post handed back."),
435
435
  analysis: open({}).passthrough().nullish().describe("Your own analysis, any of the fields analyze_post's guidance asked for — summary, " +
@@ -157,13 +157,13 @@ export const TOOL_DEFINITIONS = [
157
157
  inputSchema: z.object({ url: z.string().describe("The post the comments came from."), summary: z.string().optional(), title: z.string().optional(), comments: z.array(z.object({ id: z.string(), text: z.string(), author: z.string().optional(), likes: z.number().optional(), sentiment: z.string().optional(), category: z.string().optional(), note: z.string().optional() })), themes: z.array(z.string()).optional(), nextSteps: z.array(z.string()).optional() }).strict(),
158
158
  },
159
159
  {
160
- name: "show_comparison",
160
+ name: "show_compared_posts",
161
161
  title: "Show Comparison",
162
162
  description: "Display a comparison you wrote after compare_posts fetched the first post and you fetched the rest yourself. Free, and makes no requests — it only draws what you pass it: each post with a BEST badge on the winner, what differed, shared strengths and the next experiment worth running. Call this after you have done the comparing, not instead of it.",
163
163
  inputSchema: z.object({ posts: z.array(z.object({}).passthrough()).min(2).max(5), winner: z.number().int(), winnerReason: z.string().optional(), differences: z.array(z.object({ factor: z.string(), detail: z.string() })).optional(), lessons: z.array(z.string()).optional(), nextTest: z.string().optional() }).strict(),
164
164
  },
165
165
  {
166
- name: "show_analysis",
166
+ name: "show_post_analysis",
167
167
  title: "Show Analysis",
168
168
  description: "Display an analysis you wrote after analyze_post, analyze_post_fast or understand_social_post handed you the material. Free, and makes no requests — it only draws what you pass it: hook strength, script structure, quotable lines, hashtags, target audience, viral triggers and more, whichever of these you actually produced. Call this after you have done the analysing, not instead of it.",
169
169
  inputSchema: z.object({ url: z.string(), post: z.object({}).passthrough().optional(), analysis: z.object({}).passthrough() }).strict(),
@@ -394,7 +394,7 @@ export const TOOL_DEFINITIONS = [
394
394
  name: "find_people_with_problem",
395
395
  title: "Find People With A Problem",
396
396
  description: "Find people describing a problem in their own words — prospects, early users, anyone whose complaint your product answers. Say the problem the way a person would say it (\"I spend hours checking competitors by hand\") and this searches for posts shaped like that complaint, not for the topic it is about. That difference is the point: searching the TOPIC finds marketing about it, which is what discover_social_posts is for. Widens the query into the forms a complaint takes — the plain phrasing, the does-anyone-else question, the is-there-a-tool ask — and merges what each returns. It is a wide cheap net and NOT a filter: the posts come back for you to judge, and many will be off-target. Consumes 2 nooticr credits per platform searched. Defaults to reddit, where people describe workflow pain in sentences; add twitter for volume.",
397
- inputSchema: z.object({ problem: z.string().describe("The problem in a person's own words, not a topic. \"I waste an hour a day checking what competitors posted\" finds people; \"competitor analysis\" finds marketing about competitor analysis."), platforms: z.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram", "linkedin"])).optional().describe("Where to look (default reddit). Each one is a separate paid search."), limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."), queries: z.array(z.string()).optional().describe("Search these exact phrasings instead of the ones derived from `problem` (max 4). You are better at this than a string template is: write how a person would actually word the complaint on the network being searched \u2014 a Reddit title, an X post \u2014 and keep each one short, because this is a keyword search and a long sentence matches on its commonest words.") }).strict(),
397
+ inputSchema: z.object({ problem: z.string().describe("The problem in a person's own words, not a topic. \"I waste an hour a day checking what competitors posted\" finds people; \"competitor analysis\" finds marketing about competitor analysis."), platforms: z.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram", "linkedin"])).optional().describe("Where to look (default reddit). Each one is a separate paid search."), limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."), readComments: z.boolean().optional().describe("Open the most promising results and read their comments too (default false). On Reddit especially, the person with the problem is at least as often replying under someone else's thread as posting their own, and a post-only search cannot see any of them. Costs 2 nooticr credits per post opened, up to 5 \u2014 and they are opened on the MERGED, deduped results, so it is five extra calls in total rather than five per query shape per platform."), queries: z.array(z.string()).optional().describe("Search these exact phrasings instead of the ones derived from `problem` (max 4). You are better at this than a string template is: write how a person would actually word the complaint on the network being searched \u2014 a Reddit title, an X post \u2014 and keep each one short, because this is a keyword search and a long sentence matches on its commonest words.") }).strict(),
398
398
  },
399
399
  {
400
400
  name: "search_spoken_mentions",
@@ -26,7 +26,7 @@ import { loadingPlansJson } from "./loading-plans.js";
26
26
  import { registerHandoff } from "./handoff.js";
27
27
  import { registerCollabTools } from "./collab.js";
28
28
  /** Current MCP server version — bumped on every deploy for traceability. */
29
- export const MCP_SERVER_VERSION = "1.26.31";
29
+ export const MCP_SERVER_VERSION = "1.26.33";
30
30
  /** MCP Apps extension identifier */
31
31
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
32
32
  /** MIME type for MCP Apps HTML resources */
@@ -407,9 +407,9 @@ async function runEvidence(tool, args, client) {
407
407
  //
408
408
  // Measured before changing it, over 59 real runs of the quest suite: 0
409
409
  // guidance phrases across 175 tool results. `analyze_post` handed back frames,
410
- // a transcript and 2,000 characters ending in "call show_analysis when you are
410
+ // a transcript and 2,000 characters ending in "call show_post_analysis when you are
411
411
  // done"; the model wrote the analysis and delivered it in chat, and
412
- // show_analysis was called in 1 run of 12.
412
+ // show_post_analysis was called in 1 run of 12.
413
413
  //
414
414
  // The text block stays for hosts that honour it (and for the images, which
415
415
  // must sit in `content`). `guidance` on the payload is what reaches the rest.
@@ -561,8 +561,8 @@ export function createMcpServer(rawMakeClient, opts) {
561
561
  // Close the loop the evidence-only tools open: your own analysis/hooks/
562
562
  // variants/repurposing/comparison, drawn — same shape as
563
563
  // show_comment_review, free and no requests.
564
- "show_comparison",
565
- "show_analysis",
564
+ "show_compared_posts",
565
+ "show_post_analysis",
566
566
  "show_hooks",
567
567
  "show_variants",
568
568
  "show_repurposed_post",
@@ -1426,8 +1426,8 @@ export function createMcpServer(rawMakeClient, opts) {
1426
1426
  // the plain post card it started on. Same shape as show_comment_review in
1427
1427
  // every way that matters: free, no requests, draws only what it is
1428
1428
  // handed. Each one's structuredContent is built to match an existing view
1429
- // ui-template.ts already renders (show_comparison → the comparison
1430
- // scoreboard, show_analysis → analysisCard) or a new one added alongside
1429
+ // ui-template.ts already renders (show_compared_posts → the comparison
1430
+ // scoreboard, show_post_analysis → analysisCard) or a new one added alongside
1431
1431
  // it (show_hooks, show_variants, show_repurposed_post).
1432
1432
  server.registerTool("show_trend", {
1433
1433
  title: "Show Trend",
@@ -1547,7 +1547,7 @@ export function createMcpServer(rawMakeClient, opts) {
1547
1547
  },
1548
1548
  };
1549
1549
  });
1550
- server.registerTool("show_comparison", {
1550
+ server.registerTool("show_compared_posts", {
1551
1551
  title: "Show Comparison",
1552
1552
  description: "Display a comparison you wrote after compare_posts fetched the first post and you fetched " +
1553
1553
  "the rest yourself (get_social_media, 1 credit each). Free, and makes no requests — it only " +
@@ -1555,12 +1555,12 @@ export function createMcpServer(rawMakeClient, opts) {
1555
1555
  "strengths and the next experiment worth running. Call this after you have done the " +
1556
1556
  "comparing, not instead of it.",
1557
1557
  _meta: {
1558
- ui: { resourceUri: uiResource("show_comparison") },
1559
- "ui/resourceUri": uiResource("show_comparison"),
1560
- "openai/outputTemplate": appsSdkResource("show_comparison"),
1558
+ ui: { resourceUri: uiResource("show_compared_posts") },
1559
+ "ui/resourceUri": uiResource("show_compared_posts"),
1560
+ "openai/outputTemplate": appsSdkResource("show_compared_posts"),
1561
1561
  },
1562
1562
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1563
- outputSchema: OUTPUT_SCHEMAS.show_comparison,
1563
+ outputSchema: OUTPUT_SCHEMAS.show_compared_posts,
1564
1564
  inputSchema: z
1565
1565
  .object({
1566
1566
  posts: z
@@ -1600,7 +1600,7 @@ export function createMcpServer(rawMakeClient, opts) {
1600
1600
  },
1601
1601
  };
1602
1602
  });
1603
- server.registerTool("show_analysis", {
1603
+ server.registerTool("show_post_analysis", {
1604
1604
  title: "Show Analysis",
1605
1605
  description: "Display an analysis you wrote after analyze_post, analyze_post_fast or understand_social_post " +
1606
1606
  "handed you the material. Free, and makes no requests — it only draws what you pass it: hook " +
@@ -1608,12 +1608,12 @@ export function createMcpServer(rawMakeClient, opts) {
1608
1608
  "more, whichever of these you actually produced. Call this after you have done the analysing, " +
1609
1609
  "not instead of it.",
1610
1610
  _meta: {
1611
- ui: { resourceUri: uiResource("show_analysis") },
1612
- "ui/resourceUri": uiResource("show_analysis"),
1613
- "openai/outputTemplate": appsSdkResource("show_analysis"),
1611
+ ui: { resourceUri: uiResource("show_post_analysis") },
1612
+ "ui/resourceUri": uiResource("show_post_analysis"),
1613
+ "openai/outputTemplate": appsSdkResource("show_post_analysis"),
1614
1614
  },
1615
1615
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
1616
- outputSchema: OUTPUT_SCHEMAS.show_analysis,
1616
+ outputSchema: OUTPUT_SCHEMAS.show_post_analysis,
1617
1617
  inputSchema: z
1618
1618
  .object({
1619
1619
  url: z.string().describe("The post you analyzed."),
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.31",
4
+ "version": "1.26.33",
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": {