@nooticr/mcp 1.26.19 → 1.26.20

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.
@@ -36,6 +36,68 @@
36
36
  */
37
37
  import { BACKEND_CALL_CREDITS, costOf } from "./spend.js";
38
38
  const url = (a) => String(a.url ?? "");
39
+ /**
40
+ * Platforms whose accounts can be found by NAME. This is the backend's
41
+ * search_creators enum, not a preference — no other network has a keyword
42
+ * search behind it, so on the rest a handle has to arrive from outside.
43
+ */
44
+ export const NAME_SEARCHABLE = ["tiktok", "instagram", "xiaohongshu"];
45
+ /**
46
+ * The `platform` argument description every tool sharing the silent tiktok
47
+ * default should use.
48
+ *
49
+ * Saying "default tiktok" was technically true and operationally useless: it
50
+ * reads as a harmless convenience rather than as the thing that will answer a
51
+ * question about X with TikTok data. The default cannot simply be removed —
52
+ * hosts already call these without it — so the description has to carry the
53
+ * warning instead.
54
+ */
55
+ export const PLATFORM_ARG = "Platform. Defaults to tiktok, so SET IT whenever the user named a network — otherwise a " +
56
+ "question about X, LinkedIn or Reddit is silently answered with TikTok data, and an empty " +
57
+ "result looks like the account does not exist. Accepts what get_user_posts does: tiktok, " +
58
+ "instagram, youtube, douyin, xiaohongshu, twitter, bilibili, linkedin, reddit, weibo.";
59
+ /**
60
+ * What to say when a handle lookup comes back empty.
61
+ *
62
+ * Written because of a real session: someone asked what a competitor was doing
63
+ * on X, the tool searched TikTok (the silent default), found nothing, offered
64
+ * to guess another TikTok handle, and gave up. Every part of that was the
65
+ * surface's fault. It never said which network it had searched, so the wrong
66
+ * one looked like an absent account; and an empty result carried no next step,
67
+ * so the only move left was to apologise.
68
+ *
69
+ * The fix is to be specific about what happened and to hand back the one step
70
+ * that actually works. We cannot search X, LinkedIn or Reddit by name — but the
71
+ * model calling us can search the open web, and `get_user_posts` will happily
72
+ * fetch any of them once it has the handle. So ask for that rather than
73
+ * substituting a network the user did not ask about.
74
+ */
75
+ export function handleMissGuidance(a) {
76
+ const searchable = NAME_SEARCHABLE.includes(a.platform);
77
+ const lines = [
78
+ `No posts for @${a.handle} on ${a.platform}.`,
79
+ "",
80
+ a.defaulted
81
+ ? `Note that ${a.platform} was the default here — it was not asked for. If the user named a ` +
82
+ "different network, call this again with `platform` set to it before concluding anything: " +
83
+ "an empty result on the wrong network says nothing about the account they meant."
84
+ : `That is the network that was searched, so the account either does not exist there, is ` +
85
+ "private, or uses a different handle.",
86
+ "",
87
+ searchable
88
+ ? `${a.platform} can be searched by name: call search_creators with the brand or person as ` +
89
+ "the keyword to find the right handle, then come back."
90
+ : `${a.platform} cannot be searched by name here — there is no keyword index behind it, so ` +
91
+ "guessing handles will not converge. Find the account yourself with a web search, or ask " +
92
+ "the user for the handle or profile URL, then call this again with that. Do NOT quietly " +
93
+ "switch to a network you can search instead: answering about TikTok when the question " +
94
+ "was about X is worse than saying you need the handle.",
95
+ "",
96
+ "Say plainly that nothing was found and which network was checked. Do not present an empty " +
97
+ "result as evidence the competitor is inactive.",
98
+ ];
99
+ return lines.filter((l, i, all) => !(l === "" && all[i - 1] === "")).join("\n");
100
+ }
39
101
  /** Closing line every guidance block shares. */
40
102
  export const ownIt = "Reason over this yourself rather than asking for an interpretation of it — " +
41
103
  "you can see everything the analysis would have been built from. Everything fetched here — " +
@@ -56,7 +56,7 @@
56
56
  import { z } from "zod";
57
57
  import { OUTPUT_SCHEMAS } from "./output-schemas.js";
58
58
  import { platformFromUrl, postSlug } from "./comment-review.js";
59
- import { ownIt } from "./evidence.js";
59
+ import { handleMissGuidance, ownIt, PLATFORM_ARG } from "./evidence.js";
60
60
  import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, } from "./spend.js";
61
61
  import { distributionOf, excluding, numberOf, standing, } from "./performance.js";
62
62
  import { normaliseHandle, watchEntryId, watchlistOwner } from "./watchlist.js";
@@ -353,8 +353,12 @@ function audienceGuidance(a) {
353
353
  }
354
354
  function competitorGuidance(a) {
355
355
  const lines = [
356
- `${a.shipped} recent post${a.shipped === 1 ? "" : "s"} by @${a.handle}, each scored against ` +
357
- `that account's own median ${a.metric} rather than against anyone else's numbers.`,
356
+ // The network is named here, not just in the structured payload, because
357
+ // the platform argument defaults silently: without this line an answer
358
+ // about the wrong network is indistinguishable from the right one.
359
+ `${a.shipped} recent post${a.shipped === 1 ? "" : "s"} by @${a.handle} on ${a.platform}, each ` +
360
+ `scored against that account's own median ${a.metric} rather than against anyone else's ` +
361
+ "numbers.",
358
362
  "",
359
363
  a.baseline
360
364
  ? `Their median is ${Math.round(a.baseline.median).toLocaleString("en-US")} ${a.metric}, ` +
@@ -592,7 +596,7 @@ export function registerJobTools(server, makeClient, store) {
592
596
  inputSchema: z
593
597
  .object({
594
598
  username: z.string().describe("Your handle, with or without @."),
595
- platform: z.string().optional().describe("Platform (default tiktok)."),
599
+ platform: z.string().optional().describe(PLATFORM_ARG),
596
600
  limit: z
597
601
  .number()
598
602
  .int()
@@ -790,8 +794,12 @@ export function registerJobTools(server, makeClient, store) {
790
794
  outputSchema: OUTPUT_SCHEMAS.track_competitor,
791
795
  inputSchema: z
792
796
  .object({
793
- username: z.string().describe("Creator handle, with or without @."),
794
- platform: z.string().optional().describe("Platform (default tiktok)."),
797
+ username: z
798
+ .string()
799
+ .describe("Creator handle, with or without @. This is a handle, not a brand name — if you " +
800
+ "only have a company name, find the handle first (search_creators on TikTok, " +
801
+ "Instagram or Xiaohongshu; a web search anywhere else)."),
802
+ platform: z.string().optional().describe(PLATFORM_ARG),
795
803
  limit: z.number().int().optional().describe("Posts in the window (default 12, max 30). One fetch either way."),
796
804
  metric: metricArg,
797
805
  since: z
@@ -804,6 +812,10 @@ export function registerJobTools(server, makeClient, store) {
804
812
  }, async (args, extra) => {
805
813
  const client = await makeClient({ ...extra, arguments: args });
806
814
  const handle = normaliseHandle(args.username);
815
+ // Whether the network was chosen or merely fallen back to. An empty
816
+ // result means something different in each case, and the caller cannot
817
+ // tell them apart unless we say which happened.
818
+ const platformDefaulted = !args.platform;
807
819
  const platform = (args.platform || "tiktok").toLowerCase();
808
820
  const cap = clamp(args.limit, 12, 1, 30);
809
821
  const metric = args.metric ?? "views";
@@ -818,6 +830,24 @@ export function registerJobTools(server, makeClient, store) {
818
830
  catch (err) {
819
831
  return failed("track_competitor could not list the posts", err);
820
832
  }
833
+ // Nothing came back. Previously this fell through to the scoring path and
834
+ // produced a baseline-less report about zero posts, which reads as "this
835
+ // competitor has been quiet" — a claim the tool has no evidence for.
836
+ if (feed.length === 0) {
837
+ return evidence(handleMissGuidance({ handle, platform, defaulted: platformDefaulted }), {
838
+ mode: "evidence",
839
+ tool: "track_competitor",
840
+ evidenceFrom: ["get_user_posts"],
841
+ username: handle,
842
+ platform,
843
+ platformDefaulted,
844
+ found: false,
845
+ posts: [],
846
+ unavailable: [],
847
+ creditsCharged: spend.credits,
848
+ mcpCredits: spend.payload,
849
+ });
850
+ }
821
851
  const windowed = applySince(feed, args.since);
822
852
  const posts = windowed.posts;
823
853
  const values = posts.map((p) => metricOf(p, metric));
@@ -867,6 +897,7 @@ export function registerJobTools(server, makeClient, store) {
867
897
  const newSince = previous ? scoredPosts.filter((p) => p.isNew === true).length : null;
868
898
  return evidence(competitorGuidance({
869
899
  handle,
900
+ platform,
870
901
  metric,
871
902
  shipped: scoredPosts.length,
872
903
  baseline,
@@ -1161,7 +1192,7 @@ export function registerJobTools(server, makeClient, store) {
1161
1192
  inputSchema: z
1162
1193
  .object({
1163
1194
  username: z.string().describe("Your handle, with or without @."),
1164
- platform: z.string().optional().describe("Platform (default tiktok)."),
1195
+ platform: z.string().optional().describe(PLATFORM_ARG),
1165
1196
  niche: z
1166
1197
  .string()
1167
1198
  .optional()
@@ -23,7 +23,7 @@ import { registerConnectionTools } from "./connections.js";
23
23
  import { registerHandoff } from "./handoff.js";
24
24
  import { registerCollabTools } from "./collab.js";
25
25
  /** Current MCP server version — bumped on every deploy for traceability. */
26
- export const MCP_SERVER_VERSION = "1.26.19";
26
+ export const MCP_SERVER_VERSION = "1.26.20";
27
27
  /** MCP Apps extension identifier */
28
28
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
29
29
  /** MIME type for MCP Apps HTML resources */
@@ -45,6 +45,7 @@
45
45
  */
46
46
  import { z } from "zod";
47
47
  import { confirmSpend, declinedResult, CREDITS_PER_CREATOR } from "./spend.js";
48
+ import { PLATFORM_ARG } from "./evidence.js";
48
49
  export const WATCHLIST_URI = "nooticr://watchlist";
49
50
  /** Default, and what the tests use. Fine for a single process. */
50
51
  export class MemoryWatchStore {
@@ -448,7 +449,7 @@ export function registerWatchlist(server, makeClient, store) {
448
449
  inputSchema: z
449
450
  .object({
450
451
  username: z.string().describe("Creator handle, with or without @."),
451
- platform: z.string().optional().describe("Platform (default tiktok)."),
452
+ platform: z.string().optional().describe(PLATFORM_ARG),
452
453
  note: z.string().optional().describe("Why you are watching them — shown back to you later."),
453
454
  })
454
455
  .strict(),
@@ -485,7 +486,7 @@ export function registerWatchlist(server, makeClient, store) {
485
486
  inputSchema: z
486
487
  .object({
487
488
  username: z.string().describe("Creator handle, with or without @."),
488
- platform: z.string().optional().describe("Platform (default tiktok)."),
489
+ platform: z.string().optional().describe(PLATFORM_ARG),
489
490
  })
490
491
  .strict(),
491
492
  outputSchema: z
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.19",
4
+ "version": "1.26.20",
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
  "type": "module",
7
7
  "main": "dist/index.js",