@nooticr/mcp 1.26.28 → 1.26.29
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 +8 -5
- package/dist/shared/brand-watch.js +19 -7
- package/dist/shared/evidence.js +67 -0
- package/dist/shared/jobs.js +189 -20
- package/dist/shared/loading-plans.js +10 -1
- package/dist/shared/output-schemas.js +32 -2
- package/dist/shared/prompts.js +1 -1
- package/dist/shared/standings.js +5 -2
- package/dist/shared/tools-def.js +9 -3
- package/dist/shared/tools.js +77 -23
- package/dist/shared/ui-template.js +13 -3
- package/dist/shared/watchlist.js +13 -5
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ npx @nooticr/mcp login # one-time sign-in (Google)
|
|
|
52
52
|
|
|
53
53
|
## Tools
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
68 tools, grouped by what you are trying to do. Prices are in nooticr credits and
|
|
56
56
|
match what the server actually charges.
|
|
57
57
|
|
|
58
58
|
Seven of them — the ones under **Answer a question you actually have** — are not
|
|
@@ -115,10 +115,11 @@ them caps that fan-out with an argument.
|
|
|
115
115
|
| `show_audience_replies` | free | Lays your drafts out for a person to work through, grouped under the post, each with what you decided to do about it. Sends nothing; fetches nothing. |
|
|
116
116
|
| `show_standings` | free | Draws the standings your model read out of `compare_creators` or `watchlist_standings` — one row per creator, with the window and the median it was scored against. `ranking` records which axis you ordered on, and `tooThin` draws a creator with too short a window as unranked rather than last: missing from a comparison is not the same as losing it. Makes no requests. |
|
|
117
117
|
| `show_trend` | free | Draws the trend your model read out of `mention_trend`. `tooShort` marks a series with too few points to call a direction, and `edgeIsRecordStart` marks a left edge that is where the record begins rather than where the conversation did — a chart hiding either is the mistake this view could otherwise make on your behalf. Makes no requests. |
|
|
118
|
-
| `
|
|
118
|
+
| `track_creator` | 2 | What a creator shipped, and which of it beat **their own** median rather than a raw view count that mostly measures follower count. One post list, whatever the window. If they are on your watchlist it also marks what is new since your last check and moves that marker forward — its own marker, not the one `catch_up_watchlist` keeps. |
|
|
119
119
|
| `compare_creators` | 2 per creator (4-10) | Two to five creators side by side on the one axis that compares: each post's ratio to **that creator's own** median. Per creator you get the window that was scored, their median, the share of it that beat that median, how hard they beat it when they did, every ratio, and their best and worst post. It does not rank them — "how often they land one" and "how big it is when they do" usually disagree, and a hit rate over a short window is one post either way, so the window travels with every number and the read is yours. |
|
|
120
120
|
| `watchlist_standings` | 2 per watched creator | The same, across everyone on your watchlist: "who moved" in one call instead of one per creator. Reading the watchlist is free; the fetches are not, so it confirms the total before spending. `catch_up_watchlist` answers what is **new**; this answers how it **did**. |
|
|
121
121
|
| `who_should_i_work_with` | 2, or 4 with a seed | A collaboration shortlist: a keyword search merged with the lookalikes of a creator who already fits, marked by which search found each one. Every candidate also carries the **links pulled out of their bio**, typed and sorted by how much opening one will tell you — a repository to read the code in, their own site, a link hub that holds the real links — so vetting is reading the work rather than re-reading the follower count. Those links are never fetched here: they came out of a field the person being evaluated controls, so the host opens them, and the result says so. It does **not** measure audience overlap — that costs about nine credits a candidate, so the result says so and shows how to check a finalist rather than faking the signal. |
|
|
122
|
+
| `find_people_with_problem` | 2 per platform | 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 and it searches for posts shaped like that complaint rather than for the topic it is about: 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 marks which one surfaced each post. A wide cheap net and **not** a filter: the posts come back for you to judge, and plenty will be off-target. Defaults to reddit, where people describe workflow pain in sentences. |
|
|
122
123
|
| `show_collab_shortlist` | free | Draws the candidates you scored, ranked, and asks the user which to approach. The scores are attributed to your model, not presented as a nooticr rating, and a candidate scored without anything having been opened is marked unverified. Makes no requests. |
|
|
123
124
|
| `why_did_this_underperform` | 3 | One post against the creator's own recent distribution, with the post taken back out of its own baseline. Returns median, quartiles, ratio and percentile, so the answer can be "this is an ordinary result, not a failure". Different question from `compare_posts`, which weighs two URLs you already picked. |
|
|
124
125
|
| `what_should_i_make_next` | 2 + 2 per post read + 2 (12 by default) | Demand against supply: what your commenters explicitly ask for, set beside what a niche sweep shows is already being made. A gap nobody asked for is noise; a request nobody serves is the opportunity. Falls back to your most-used hashtag when you name no niche. |
|
|
@@ -529,9 +530,11 @@ Those are unit tests against a stub backend. For real MCP-protocol
|
|
|
529
530
|
end-to-end tests — no Rust, no Postgres, no API key —
|
|
530
531
|
`npm run test:e2e-smoke:fixture` spawns this repo's real built CLI and
|
|
531
532
|
drives it over stdio against a pure-Node stand-in for nooticr-server. See
|
|
532
|
-
`docs/testing/agentic-e2e-testing.md` for that plus the
|
|
533
|
-
(`npm run test:e2e-smoke` against a real nooticr-server
|
|
534
|
-
|
|
533
|
+
`docs/testing/agentic-e2e-testing.md` for that plus the tier above it
|
|
534
|
+
(`npm run test:e2e-smoke` against a real nooticr-server). For a real model
|
|
535
|
+
driving the server through whole journeys — asserting on the chain of tools
|
|
536
|
+
it walks, not on one call — see `npm run test:quests` and
|
|
537
|
+
`docs/testing/tool-chaining-quests.md`. And for
|
|
535
538
|
`tests/e2e/agentic-visual*.e2e.ts` — every reachable widget view, driven by
|
|
536
539
|
a real tool-call result, rendered in a real browser with every host-facing
|
|
537
540
|
button actually clicked (`npx playwright test tests/e2e/agentic-visual.e2e.ts
|
|
@@ -259,9 +259,11 @@ function trendGuidance(sc, runs) {
|
|
|
259
259
|
"high `timesSeen` is a post that keeps resurfacing — worth naming as one thing rather " +
|
|
260
260
|
"than as repeated new interest.");
|
|
261
261
|
}
|
|
262
|
-
lines.push("", "This is free because the sweeps were already billed.
|
|
263
|
-
"
|
|
264
|
-
"
|
|
262
|
+
lines.push("", "This is free because the sweeps were already billed. Read the numbers and say what changed, " +
|
|
263
|
+
"including that nothing did, if that is what they show. Then call show_trend with the " +
|
|
264
|
+
"series and your read of it — free, and it draws the chart with the two things a chart " +
|
|
265
|
+
"here can lie about marked: a series too short to call a direction, and a left edge that " +
|
|
266
|
+
"is where the record starts rather than where the conversation did.");
|
|
265
267
|
return lines.join("\n");
|
|
266
268
|
}
|
|
267
269
|
function toResult(proxy) {
|
|
@@ -445,7 +447,13 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
445
447
|
"`term` a watch was created for. Nothing was fetched and nothing was charged.",
|
|
446
448
|
},
|
|
447
449
|
],
|
|
448
|
-
structuredContent: {
|
|
450
|
+
structuredContent: {
|
|
451
|
+
guidance: "This needs a watch to read: pass `watchId` (from list_brand_watches) or the " +
|
|
452
|
+
"`term` a watch was created for. Nothing was fetched and nothing was charged.",
|
|
453
|
+
available: false,
|
|
454
|
+
billable: false,
|
|
455
|
+
mcpCredits: { cost: 0 },
|
|
456
|
+
},
|
|
449
457
|
};
|
|
450
458
|
}
|
|
451
459
|
try {
|
|
@@ -453,13 +461,17 @@ export function registerBrandWatch(server, makeClient) {
|
|
|
453
461
|
const result = toResult(proxy);
|
|
454
462
|
const sc = (result.structuredContent ?? {});
|
|
455
463
|
const runs = Array.isArray(sc.runs) ? sc.runs : [];
|
|
456
|
-
// Guidance in
|
|
457
|
-
//
|
|
464
|
+
// Guidance in BOTH places, not just the text block. A host that
|
|
465
|
+
// renders structuredContent drops every content text block and shows
|
|
466
|
+
// the model the serialised JSON instead, so a text-only copy reaches
|
|
467
|
+
// nobody — measured across 59 real runs. What it has to prevent is a
|
|
458
468
|
// model reading two points as a trend, and reading the start of the
|
|
459
469
|
// retention window as the moment a conversation began.
|
|
470
|
+
const guidance = trendGuidance(sc, runs);
|
|
460
471
|
return {
|
|
461
472
|
...result,
|
|
462
|
-
content: [{ type: "text", text:
|
|
473
|
+
content: [{ type: "text", text: guidance }],
|
|
474
|
+
structuredContent: { guidance, ...sc },
|
|
463
475
|
};
|
|
464
476
|
}
|
|
465
477
|
catch (err) {
|
package/dist/shared/evidence.js
CHANGED
|
@@ -115,6 +115,58 @@ export function handleMissGuidance(a) {
|
|
|
115
115
|
];
|
|
116
116
|
return lines.filter((l, i, all) => !(l === "" && all[i - 1] === "")).join("\n");
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Every network the surface can be asked for, for recognising one in an error.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately a flat list rather than a per-capability one: this is used to
|
|
122
|
+
* spot a platform NAME in a failure message, and a platform that failed is a
|
|
123
|
+
* platform whatever it was capable of.
|
|
124
|
+
*/
|
|
125
|
+
export const KNOWN_PLATFORMS = [
|
|
126
|
+
"tiktok",
|
|
127
|
+
"instagram",
|
|
128
|
+
"youtube",
|
|
129
|
+
"douyin",
|
|
130
|
+
"xiaohongshu",
|
|
131
|
+
"twitter",
|
|
132
|
+
"bilibili",
|
|
133
|
+
"linkedin",
|
|
134
|
+
"reddit",
|
|
135
|
+
"weibo",
|
|
136
|
+
];
|
|
137
|
+
/**
|
|
138
|
+
* What to say when a network ERRORED, as opposed to came back empty.
|
|
139
|
+
*
|
|
140
|
+
* Those want opposite responses and nothing distinguished them. An empty
|
|
141
|
+
* result means try a different query; an error means try a different network,
|
|
142
|
+
* or tell the user. Without that split a host does the one thing that cannot
|
|
143
|
+
* work: re-runs the same call with the words changed.
|
|
144
|
+
*
|
|
145
|
+
* From a real session. Reddit's discovery endpoint started failing, and the
|
|
146
|
+
* model read "The social data service could not complete this request" as a
|
|
147
|
+
* bad query — three more discover_social_posts calls with reworded niches,
|
|
148
|
+
* then a search_mentions, every one a paid upstream call against a network
|
|
149
|
+
* that was never going to answer. The user had asked for "X and Reddit"; they
|
|
150
|
+
* were told about X and never told Reddit had failed at all.
|
|
151
|
+
*
|
|
152
|
+
* So: name the network, say the failure is not about the query, forbid the
|
|
153
|
+
* reword loop explicitly, and say what to do instead. `handleMissGuidance`'s
|
|
154
|
+
* shape, for the other half of the same problem.
|
|
155
|
+
*/
|
|
156
|
+
export function platformFailureGuidance(a) {
|
|
157
|
+
return [
|
|
158
|
+
`${a.platform} could not be searched: ${a.message}`,
|
|
159
|
+
"",
|
|
160
|
+
"This is the network failing, not your query. Do NOT call this again with different " +
|
|
161
|
+
"wording, a different niche phrasing, or a different tool against the same network — " +
|
|
162
|
+
"each attempt is another paid upstream call that fails the same way.",
|
|
163
|
+
"",
|
|
164
|
+
`Carry on with any other networks the user asked for, and tell them plainly that ` +
|
|
165
|
+
`${a.platform} could not be searched this time. An answer that silently covers half of ` +
|
|
166
|
+
"what was asked for reads as a complete answer, which is worse than a short one that " +
|
|
167
|
+
"says what is missing.",
|
|
168
|
+
].join("\n");
|
|
169
|
+
}
|
|
118
170
|
/** Closing line every guidance block shares. */
|
|
119
171
|
export const ownIt = "Reason over this yourself rather than asking for an interpretation of it — " +
|
|
120
172
|
"you can see everything the analysis would have been built from. Everything fetched here — " +
|
|
@@ -251,6 +303,21 @@ export const EVIDENCE_PLANS = {
|
|
|
251
303
|
`Focus for this pass, as asked: ${focus}. Still cover the profile as a whole, but weight what you cover toward this.`,
|
|
252
304
|
]
|
|
253
305
|
: []),
|
|
306
|
+
"",
|
|
307
|
+
// The redirect has to be here rather than only in the description,
|
|
308
|
+
// because by the time a description could help this tool has already
|
|
309
|
+
// been chosen. Measured: the phrase "track what X has been doing"
|
|
310
|
+
// retrieves analyze_creator_profile and never track_creator —
|
|
311
|
+
// track_creator's name only surfaces when the user says the word
|
|
312
|
+
// "competitor", which is not how most people phrase it. A result
|
|
313
|
+
// naming the other tool is the one channel that reaches a model which
|
|
314
|
+
// has already committed to this one.
|
|
315
|
+
"If what was actually asked for was to TRACK this creator — how they are",
|
|
316
|
+
"doing lately, what they have shipped since last time, keeping an eye on",
|
|
317
|
+
"them — this is the wrong tool and you should call track_creator",
|
|
318
|
+
"instead. It scores each post against this creator's OWN median rather",
|
|
319
|
+
"than handing you raw counts, and it remembers where you left off, so",
|
|
320
|
+
"the next call can say what is new. This one does neither.",
|
|
254
321
|
ownIt,
|
|
255
322
|
].join("\n");
|
|
256
323
|
},
|
package/dist/shared/jobs.js
CHANGED
|
@@ -597,9 +597,14 @@ const metricArg = z
|
|
|
597
597
|
.describe("Which stat to rank on (default views).");
|
|
598
598
|
export function registerJobTools(server, makeClient, store) {
|
|
599
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
605
|
const evidence = (guidance, payload) => ({
|
|
601
606
|
content: [{ type: "text", text: guidance }],
|
|
602
|
-
structuredContent: payload,
|
|
607
|
+
structuredContent: { guidance, ...payload },
|
|
603
608
|
});
|
|
604
609
|
const failed = (prefix, err) => ({
|
|
605
610
|
content: [{ type: "text", text: `${prefix}: ${reason(err)}` }],
|
|
@@ -802,23 +807,25 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
802
807
|
});
|
|
803
808
|
});
|
|
804
809
|
// ───────────────────────────────────────────────────────────────────────────
|
|
805
|
-
// 2.
|
|
810
|
+
// 2. track_creator
|
|
806
811
|
//
|
|
807
812
|
// Writes, when the creator is on the watchlist: it moves a baseline forward
|
|
808
813
|
// so the next call can say what is new. That baseline is deliberately not
|
|
809
814
|
// the one catch_up_watchlist keeps — two tools sharing one "since I last
|
|
810
815
|
// looked" means each silently consumes the other's answer.
|
|
811
816
|
// ───────────────────────────────────────────────────────────────────────────
|
|
812
|
-
server.registerTool("
|
|
817
|
+
server.registerTool("track_creator", {
|
|
813
818
|
title: "Track Competitor",
|
|
814
|
-
_meta: viewMeta("
|
|
815
|
-
description: "
|
|
816
|
-
"
|
|
817
|
-
"
|
|
818
|
-
"
|
|
819
|
-
"
|
|
820
|
-
"
|
|
821
|
-
"
|
|
819
|
+
_meta: viewMeta("track_creator"),
|
|
820
|
+
description: "Track a competitor, keep an eye on a rival, see how someone is doing lately, catch what " +
|
|
821
|
+
"they shipped since you last looked — this is the tool for all of those. What a creator " +
|
|
822
|
+
"shipped recently and which of it beat THEIR OWN baseline: fetches their recent posts " +
|
|
823
|
+
"once and scores each against the median of that same window, because a raw view count " +
|
|
824
|
+
"mostly measures follower count — outperformance against themselves is the signal. If " +
|
|
825
|
+
"they are on your watchlist it also marks what is new since your last track_creator " +
|
|
826
|
+
"call and moves that marker forward. " +
|
|
827
|
+
"Consumes 2 nooticr credits — one post list, whatever the window size. Use for a rival " +
|
|
828
|
+
"you follow; analyze_creator_profile is the full teardown of one you do not.",
|
|
822
829
|
annotations: {
|
|
823
830
|
readOnlyHint: false,
|
|
824
831
|
destructiveHint: false,
|
|
@@ -827,7 +834,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
827
834
|
idempotentHint: false,
|
|
828
835
|
openWorldHint: true,
|
|
829
836
|
},
|
|
830
|
-
outputSchema: OUTPUT_SCHEMAS.
|
|
837
|
+
outputSchema: OUTPUT_SCHEMAS.track_creator,
|
|
831
838
|
inputSchema: z
|
|
832
839
|
.object({
|
|
833
840
|
username: z
|
|
@@ -864,7 +871,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
864
871
|
feed = rowsOf(structured.posts);
|
|
865
872
|
}
|
|
866
873
|
catch (err) {
|
|
867
|
-
return failed("
|
|
874
|
+
return failed("track_creator could not list the posts", err);
|
|
868
875
|
}
|
|
869
876
|
// Nothing came back. Previously this fell through to the scoring path and
|
|
870
877
|
// produced a baseline-less report about zero posts, which reads as "this
|
|
@@ -872,7 +879,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
872
879
|
if (feed.length === 0) {
|
|
873
880
|
return evidence(handleMissGuidance({ handle, platform, defaulted: platformDefaulted }), {
|
|
874
881
|
mode: "evidence",
|
|
875
|
-
tool: "
|
|
882
|
+
tool: "track_creator",
|
|
876
883
|
evidenceFrom: ["get_user_posts"],
|
|
877
884
|
username: handle,
|
|
878
885
|
platform,
|
|
@@ -942,7 +949,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
942
949
|
lastChecked: previous?.capturedAt,
|
|
943
950
|
}), {
|
|
944
951
|
mode: "evidence",
|
|
945
|
-
tool: "
|
|
952
|
+
tool: "track_creator",
|
|
946
953
|
evidenceFrom: ["get_user_posts"],
|
|
947
954
|
username: handle,
|
|
948
955
|
platform,
|
|
@@ -967,7 +974,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
967
974
|
// ───────────────────────────────────────────────────────────────────────────
|
|
968
975
|
// 2b. compare_creators / watchlist_standings
|
|
969
976
|
//
|
|
970
|
-
// The comparison
|
|
977
|
+
// The comparison track_creator computes and discards. Both are pure
|
|
971
978
|
// compositions of get_user_posts — one fetch per creator, priced as the sum
|
|
972
979
|
// — and both leave the reading to the caller, because a ranking hides the
|
|
973
980
|
// window size that decides whether the ranking means anything.
|
|
@@ -1015,7 +1022,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1015
1022
|
"ranking is yours to make: 'how often' and 'how big' usually disagree, and a hit rate " +
|
|
1016
1023
|
"over a short window is one post either way, so the window size travels with every " +
|
|
1017
1024
|
"number. Fetches one post list per creator: 2 nooticr credits each, 4-10 in total. " +
|
|
1018
|
-
"Use for 'is their hit rate better than mine';
|
|
1025
|
+
"Use for 'is their hit rate better than mine'; track_creator is one creator alone.",
|
|
1019
1026
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
1020
1027
|
outputSchema: OUTPUT_SCHEMAS.compare_creators,
|
|
1021
1028
|
inputSchema: z
|
|
@@ -1120,10 +1127,10 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1120
1127
|
credits,
|
|
1121
1128
|
summary: `Read the recent posts of all ${entries.length} creator${entries.length === 1 ? "" : "s"} ` +
|
|
1122
1129
|
"on your watchlist and score each against their own median.",
|
|
1123
|
-
cheaper: "Unwatch creators you no longer follow, or use
|
|
1130
|
+
cheaper: "Unwatch creators you no longer follow, or use track_creator for just one.",
|
|
1124
1131
|
});
|
|
1125
1132
|
if (!decision.proceed) {
|
|
1126
|
-
return declinedResult(credits, "Those standings", "Use
|
|
1133
|
+
return declinedResult(credits, "Those standings", "Use track_creator for a single creator.");
|
|
1127
1134
|
}
|
|
1128
1135
|
const spend = new Spend();
|
|
1129
1136
|
const rows = [];
|
|
@@ -1817,7 +1824,7 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1817
1824
|
const explicitHandles = [...new Set((args.usernames ?? []).map(normaliseHandle).filter(Boolean))];
|
|
1818
1825
|
// A watchlist handle already knows its own platform, so it is checked
|
|
1819
1826
|
// once there rather than once per requested platform — the same
|
|
1820
|
-
// precision
|
|
1827
|
+
// precision track_creator already applies when it reads this store.
|
|
1821
1828
|
let watchlistUnits = [];
|
|
1822
1829
|
let watchlistTotal = 0;
|
|
1823
1830
|
if (args.useWatchlist) {
|
|
@@ -2048,4 +2055,166 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
2048
2055
|
mcpCredits: spend.payload,
|
|
2049
2056
|
});
|
|
2050
2057
|
});
|
|
2058
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
2059
|
+
// find_people_with_problem — the prospect search this surface did not have.
|
|
2060
|
+
//
|
|
2061
|
+
// Every other discovery tool here is built around a TOPIC or a TERM:
|
|
2062
|
+
// discover_social_posts takes a niche, search_mentions takes a brand,
|
|
2063
|
+
// search_creators takes a keyword. None is built around a PROBLEM SOMEONE
|
|
2064
|
+
// DESCRIBED, which is the shape of "find people who would want this".
|
|
2065
|
+
//
|
|
2066
|
+
// A user asked exactly that — "search X and Reddit for people that might see
|
|
2067
|
+
// our product as a great use for their daily struggle" — and the model did
|
|
2068
|
+
// the only thing available: fed the pain point into discover_social_posts as
|
|
2069
|
+
// a `niche`. That argument is documented as "Niche/topic, e.g. 'fitness'",
|
|
2070
|
+
// the backend runs it as a keyword search, and a seven-word sentence matched
|
|
2071
|
+
// on its commonest words. Seven of eight results were marketing content; the
|
|
2072
|
+
// one on-target result was a competitor's ad.
|
|
2073
|
+
//
|
|
2074
|
+
// What this does differently is not smarter matching — we run the same
|
|
2075
|
+
// keyword search, because that is what the backend has. It is that the query
|
|
2076
|
+
// is EXPANDED into the shapes a complaint actually takes, the platforms
|
|
2077
|
+
// default to where people complain rather than where people market, and the
|
|
2078
|
+
// result says plainly that it is a wide net the caller still has to sort.
|
|
2079
|
+
// The judging is the host's, per this repo's whole design.
|
|
2080
|
+
server.registerTool("find_people_with_problem", {
|
|
2081
|
+
title: "Find People With A Problem",
|
|
2082
|
+
_meta: viewMeta("find_people_with_problem"),
|
|
2083
|
+
description: "Find people describing a problem in their own words — prospects, early users, anyone " +
|
|
2084
|
+
"whose complaint your product answers. Say the problem the way a person would say it " +
|
|
2085
|
+
"(\"I spend hours checking competitors by hand\") and this searches for posts shaped like " +
|
|
2086
|
+
"that complaint, not for the topic it is about. That difference is the point: searching " +
|
|
2087
|
+
"the TOPIC finds marketing about it, which is what discover_social_posts is for. " +
|
|
2088
|
+
"Widens the query into the forms a complaint takes — the plain phrasing, the " +
|
|
2089
|
+
"does-anyone-else question, the is-there-a-tool ask — and merges what each returns. " +
|
|
2090
|
+
"It is a wide cheap net and NOT a filter: the posts come back for you to judge, and " +
|
|
2091
|
+
"many will be off-target. Consumes 2 nooticr credits per platform searched. Defaults to " +
|
|
2092
|
+
"reddit, where people describe workflow pain in sentences; add twitter for volume.",
|
|
2093
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2094
|
+
outputSchema: OUTPUT_SCHEMAS.find_people_with_problem,
|
|
2095
|
+
inputSchema: z
|
|
2096
|
+
.object({
|
|
2097
|
+
problem: z
|
|
2098
|
+
.string()
|
|
2099
|
+
.describe("The problem in a person's own words, not a topic. \"I waste an hour a day " +
|
|
2100
|
+
"checking what competitors posted\" finds people; \"competitor analysis\" finds " +
|
|
2101
|
+
"marketing about competitor analysis."),
|
|
2102
|
+
platforms: z
|
|
2103
|
+
.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram", "linkedin"]))
|
|
2104
|
+
.optional()
|
|
2105
|
+
.describe("Where to look (default reddit). Each one is a separate paid search."),
|
|
2106
|
+
limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."),
|
|
2107
|
+
})
|
|
2108
|
+
.strict(),
|
|
2109
|
+
}, async (args, extra) => {
|
|
2110
|
+
const client = await makeClient({ ...extra, arguments: args });
|
|
2111
|
+
const spend = new Spend();
|
|
2112
|
+
const problem = args.problem.trim();
|
|
2113
|
+
const platforms = args.platforms?.length ? args.platforms : ["reddit"];
|
|
2114
|
+
const cap = clamp(args.limit, 6, 1, 15);
|
|
2115
|
+
// The shapes a complaint takes, rather than the topic it is about. Kept
|
|
2116
|
+
// to three: each is a paid call per platform, and past three they start
|
|
2117
|
+
// returning the same posts as each other.
|
|
2118
|
+
const shapes = [
|
|
2119
|
+
{ shape: "plain", query: problem },
|
|
2120
|
+
{ shape: "shared", query: `anyone else ${problem}` },
|
|
2121
|
+
{ shape: "asking", query: `is there a way to ${problem}` },
|
|
2122
|
+
];
|
|
2123
|
+
const seen = new Set();
|
|
2124
|
+
const posts = [];
|
|
2125
|
+
const unavailable = [];
|
|
2126
|
+
for (const platform of platforms) {
|
|
2127
|
+
for (const { shape, query } of shapes) {
|
|
2128
|
+
try {
|
|
2129
|
+
const res = await client.callTool("discover_social_posts", { niche: query, platform, limit: cap });
|
|
2130
|
+
const structured = (res.structured ?? {});
|
|
2131
|
+
spend.record("discover_social_posts", structured);
|
|
2132
|
+
for (const raw of structured.posts ?? []) {
|
|
2133
|
+
const key = String(raw.externalUrl ?? raw.url ?? JSON.stringify(raw));
|
|
2134
|
+
if (seen.has(key))
|
|
2135
|
+
continue;
|
|
2136
|
+
seen.add(key);
|
|
2137
|
+
// `foundBy` is what makes the net inspectable: a post only the
|
|
2138
|
+
// does-anyone-else phrasing found is a different kind of hit
|
|
2139
|
+
// from one the plain phrasing found, and the caller judging
|
|
2140
|
+
// these should be able to see which.
|
|
2141
|
+
posts.push({ ...raw, foundBy: shape, platform: raw.platform ?? platform });
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
catch (err) {
|
|
2145
|
+
spend.attempted("discover_social_posts");
|
|
2146
|
+
// A platform that failed is reported, not retried with different
|
|
2147
|
+
// words — the failure is the network, not the phrasing. See #49.
|
|
2148
|
+
unavailable.push({ platform, shape, reason: reason(err) });
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
if (!posts.length) {
|
|
2153
|
+
return evidence([
|
|
2154
|
+
`Nothing came back for "${problem}" on ${platforms.join(", ")}.`,
|
|
2155
|
+
"",
|
|
2156
|
+
unavailable.length
|
|
2157
|
+
? `${unavailable.length} of ${platforms.length * shapes.length} searches errored, so ` +
|
|
2158
|
+
"this may be the network rather than the problem statement — say which networks " +
|
|
2159
|
+
"could not be searched rather than reporting an empty result as a finding."
|
|
2160
|
+
: "The searches worked and found nothing shaped like this complaint. Try the " +
|
|
2161
|
+
"problem in fewer, plainer words — this is a keyword search underneath, so a " +
|
|
2162
|
+
"long sentence matches on its commonest words.",
|
|
2163
|
+
"",
|
|
2164
|
+
ownIt,
|
|
2165
|
+
].join("\n"), {
|
|
2166
|
+
mode: "evidence",
|
|
2167
|
+
tool: "find_people_with_problem",
|
|
2168
|
+
problem,
|
|
2169
|
+
platforms,
|
|
2170
|
+
posts: [],
|
|
2171
|
+
unavailable,
|
|
2172
|
+
creditsCharged: spend.credits,
|
|
2173
|
+
mcpCredits: spend.payload,
|
|
2174
|
+
});
|
|
2175
|
+
}
|
|
2176
|
+
return evidence([
|
|
2177
|
+
`${posts.length} posts that might be someone describing "${problem}", across ` +
|
|
2178
|
+
`${platforms.join(", ")}.`,
|
|
2179
|
+
"",
|
|
2180
|
+
"This is a wide net, not a filter. It is a keyword search underneath, so a good number " +
|
|
2181
|
+
"of these will be marketing ABOUT the problem rather than someone having it — that " +
|
|
2182
|
+
"distinction is the whole job here and it is yours to make, not something the search " +
|
|
2183
|
+
"could do.",
|
|
2184
|
+
"",
|
|
2185
|
+
"For each post decide: is this person describing this problem as THEIRS, right now? A " +
|
|
2186
|
+
"complaint, a question, a workaround they are tired of, all count. An ad for a tool " +
|
|
2187
|
+
"that solves it does not, and neither does a listicle. Quote the line that made you " +
|
|
2188
|
+
"decide, and name the post you are reasoning from.",
|
|
2189
|
+
"",
|
|
2190
|
+
"Report the ones that qualify with their permalink, so they can actually be replied to, " +
|
|
2191
|
+
"and say how many you rejected and why — a shortlist with no reject count reads as if " +
|
|
2192
|
+
"the search was precise.",
|
|
2193
|
+
"",
|
|
2194
|
+
"`foundBy` says which phrasing surfaced each post: `plain` the problem itself, `shared` " +
|
|
2195
|
+
"the does-anyone-else form, `asking` the is-there-a-tool form. The last two skew " +
|
|
2196
|
+
"towards genuine complaints and the first towards content about the topic.",
|
|
2197
|
+
"",
|
|
2198
|
+
unavailable.length
|
|
2199
|
+
? `Note that ${unavailable.length} searches errored — tell the user which networks ` +
|
|
2200
|
+
"could not be searched rather than presenting this as the whole picture.\n"
|
|
2201
|
+
: "",
|
|
2202
|
+
ownIt,
|
|
2203
|
+
]
|
|
2204
|
+
.filter((l, i, all) => !(l === "" && all[i - 1] === ""))
|
|
2205
|
+
.join("\n"), {
|
|
2206
|
+
mode: "evidence",
|
|
2207
|
+
tool: "find_people_with_problem",
|
|
2208
|
+
evidenceFrom: ["discover_social_posts"],
|
|
2209
|
+
problem,
|
|
2210
|
+
platforms,
|
|
2211
|
+
// `posts` is the key the gallery view already draws, so this needs no
|
|
2212
|
+
// view of its own.
|
|
2213
|
+
posts,
|
|
2214
|
+
searchedShapes: shapes.map((s) => s.shape),
|
|
2215
|
+
unavailable,
|
|
2216
|
+
creditsCharged: spend.credits,
|
|
2217
|
+
mcpCredits: spend.payload,
|
|
2218
|
+
});
|
|
2219
|
+
});
|
|
2051
2220
|
}
|
|
@@ -243,12 +243,21 @@ export const LOADING_PLANS = {
|
|
|
243
243
|
},
|
|
244
244
|
],
|
|
245
245
|
},
|
|
246
|
-
|
|
246
|
+
track_creator: {
|
|
247
247
|
label: "Reading what they shipped",
|
|
248
248
|
kind: "strip",
|
|
249
249
|
n: 3,
|
|
250
250
|
steps: [step("get_user_posts")],
|
|
251
251
|
},
|
|
252
|
+
find_people_with_problem: {
|
|
253
|
+
label: "Looking for people with this problem",
|
|
254
|
+
kind: "list",
|
|
255
|
+
n: 6,
|
|
256
|
+
// Three query shapes per platform, so the step repeats rather than
|
|
257
|
+
// representing one search — the wait is proportional to platforms asked
|
|
258
|
+
// for, and a single-step plan would under-report it badly.
|
|
259
|
+
steps: [step("discover_social_posts")],
|
|
260
|
+
},
|
|
252
261
|
// Both fan out one post-list fetch per creator, so the price is the size of
|
|
253
262
|
// the list and the wait is drawn per unit — same shape as catch_up_watchlist.
|
|
254
263
|
compare_creators: {
|
|
@@ -198,7 +198,19 @@ const analysis = open({
|
|
|
198
198
|
* marker, what it fetched, and where it came from. Optional throughout,
|
|
199
199
|
* because the shape of the material itself differs per tool.
|
|
200
200
|
*/
|
|
201
|
+
/**
|
|
202
|
+
* What to do with the material, carried where a host will actually deliver it.
|
|
203
|
+
*
|
|
204
|
+
* Every tool that returns guidance puts the same string in a `content` text
|
|
205
|
+
* block too. That block is the one the MCP spec points at and the one hosts
|
|
206
|
+
* drop: a host rendering `structuredContent` replaces the text blocks with the
|
|
207
|
+
* serialised JSON, so the text copy reaches no model. Declared here rather than
|
|
208
|
+
* left to `passthrough()` because a host reading the schema should be able to
|
|
209
|
+
* see that the field exists.
|
|
210
|
+
*/
|
|
211
|
+
const guidance = scalar().describe("What to do with this payload, in prose. Read it: it is the instruction, not a summary.");
|
|
201
212
|
const evidence = {
|
|
213
|
+
guidance,
|
|
202
214
|
mode: scalar().describe('Always "evidence": this payload is material you have still to read.'),
|
|
203
215
|
tool: scalar(),
|
|
204
216
|
evidenceFrom: listOf(z.string()).describe("The cheap calls this was assembled from."),
|
|
@@ -311,6 +323,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
311
323
|
* one should not find the key gone.
|
|
312
324
|
*/
|
|
313
325
|
analyze_comments: open({
|
|
326
|
+
guidance,
|
|
314
327
|
summary: scalar(),
|
|
315
328
|
themes: anyList(),
|
|
316
329
|
commentsAnalyzed: scalar(),
|
|
@@ -516,7 +529,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
516
529
|
threads: anyList().describe("Shaped like search_mentions so one view renders both."),
|
|
517
530
|
mcpCredits,
|
|
518
531
|
}),
|
|
519
|
-
|
|
532
|
+
track_creator: open({
|
|
520
533
|
...evidence,
|
|
521
534
|
username: scalar(),
|
|
522
535
|
platform: scalar(),
|
|
@@ -525,7 +538,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
525
538
|
since: scalar(),
|
|
526
539
|
sinceApplied: scalar(),
|
|
527
540
|
tracked: scalar().describe("True when this creator is on the watchlist, which is what makes a diff possible."),
|
|
528
|
-
lastCheckedAt: scalar().describe("When
|
|
541
|
+
lastCheckedAt: scalar().describe("When track_creator last looked, or null on a first look."),
|
|
529
542
|
newSincePreviousCheck: scalar().describe("Posts not seen at the last check; null when there was none."),
|
|
530
543
|
baseline: open({
|
|
531
544
|
count: scalar(),
|
|
@@ -549,6 +562,21 @@ export const OUTPUT_SCHEMAS = {
|
|
|
549
562
|
creditsCharged: scalar(),
|
|
550
563
|
mcpCredits,
|
|
551
564
|
}),
|
|
565
|
+
/**
|
|
566
|
+
* Prospect discovery. The unit is a POST that might be someone describing a
|
|
567
|
+
* problem — `posts` rather than a bespoke key, because the gallery view
|
|
568
|
+
* already draws that shape and a wide net does not need a view of its own.
|
|
569
|
+
*/
|
|
570
|
+
find_people_with_problem: open({
|
|
571
|
+
...evidence,
|
|
572
|
+
problem: scalar(),
|
|
573
|
+
platforms: listOf(z.string()),
|
|
574
|
+
posts: listOf(post),
|
|
575
|
+
searchedShapes: listOf(z.string()).describe("Which complaint phrasings were searched: plain, shared, asking."),
|
|
576
|
+
unavailable: anyList().describe("Searches that errored, by platform and shape."),
|
|
577
|
+
creditsCharged: scalar(),
|
|
578
|
+
mcpCredits,
|
|
579
|
+
}),
|
|
552
580
|
who_should_i_work_with: open({
|
|
553
581
|
...evidence,
|
|
554
582
|
niche: scalar(),
|
|
@@ -844,6 +872,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
844
872
|
* documenting a field that never arrives is worse than omitting it.
|
|
845
873
|
*/
|
|
846
874
|
score_draft: open({
|
|
875
|
+
guidance,
|
|
847
876
|
draft: scalar(),
|
|
848
877
|
platform: scalar(),
|
|
849
878
|
mcpCredits,
|
|
@@ -936,6 +965,7 @@ export const OUTPUT_SCHEMAS = {
|
|
|
936
965
|
trend: scalar().describe("The discriminator the view keys on."),
|
|
937
966
|
}),
|
|
938
967
|
mention_trend: open({
|
|
968
|
+
guidance,
|
|
939
969
|
runs: listOf(open({
|
|
940
970
|
ranAt: scalar(),
|
|
941
971
|
found: scalar().describe("Everything that sweep saw."),
|
package/dist/shared/prompts.js
CHANGED
|
@@ -298,7 +298,7 @@ export function registerPrompts(server) {
|
|
|
298
298
|
`over- and under-performs, who the audience is. 2 credits.\n` +
|
|
299
299
|
`4. watch_creator to add them to your watchlist — free, stores the handle only, fetches ` +
|
|
300
300
|
`nothing.\n` +
|
|
301
|
-
`5.
|
|
301
|
+
`5. track_creator on the same handle — what they have shipped recently and which of it ` +
|
|
302
302
|
`beat THEIR OWN median, not a raw view count that mostly measures follower count. 2 credits, ` +
|
|
303
303
|
`whatever the window. Because they are now on your watchlist, this also sets its own "since I ` +
|
|
304
304
|
`last checked" marker — a different one from catch_up_watchlist's.\n` +
|
package/dist/shared/standings.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Putting two creators side by side (issue #30).
|
|
3
3
|
*
|
|
4
4
|
* The surface had a strong and correct stance on baselines, applied in one
|
|
5
|
-
* direction only. `
|
|
5
|
+
* direction only. `track_creator` scores each post against the median of
|
|
6
6
|
* that creator's own window, "because a raw view count mostly measures
|
|
7
7
|
* follower count — outperformance against themselves is the signal".
|
|
8
8
|
* `why_did_this_underperform` does the same for a single post.
|
|
@@ -136,7 +136,10 @@ export function standingsGuidance(a) {
|
|
|
136
136
|
lines.push("", `Every ratio here is against a window measured now. It cannot tell you whether anyone's ` +
|
|
137
137
|
"baseline is itself moving — that needs a second point in time, which nothing stores yet " +
|
|
138
138
|
"(issue #28). So do not read a high hit rate as 'improving'; it is 'consistent within this " +
|
|
139
|
-
"window' and nothing more."
|
|
139
|
+
"window' and nothing more.", "", "When you have decided what the numbers say, call show_standings with the rows and the axis " +
|
|
140
|
+
"you ranked on. Free, and it is the one place the ranking is attributed to you rather than " +
|
|
141
|
+
"presented as a nooticr score — it also draws a creator with too thin a window as unranked " +
|
|
142
|
+
"rather than last, which is the mistake a plain table makes on your behalf.");
|
|
140
143
|
return lines.join("\n");
|
|
141
144
|
}
|
|
142
145
|
/** Threshold re-exported so the tools and the view agree on the badge. */
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -349,15 +349,15 @@ export const TOOL_DEFINITIONS = [
|
|
|
349
349
|
inputSchema: z.object({ username: z.string().describe("Whose audience this is, with or without @."), summary: z.string().optional(), replies: z.array(z.object({ id: z.string(), comment: z.string(), author: z.string().optional(), likes: z.number().optional(), postUrl: z.string().optional(), postTitle: z.string().optional(), postedAt: z.string().optional(), draft: z.string().optional(), kind: z.string().optional(), why: z.string().optional() })), themes: z.array(z.string()).optional(), nextSteps: z.array(z.string()).optional() }).strict(),
|
|
350
350
|
},
|
|
351
351
|
{
|
|
352
|
-
name: "
|
|
352
|
+
name: "track_creator",
|
|
353
353
|
title: "Track Competitor",
|
|
354
|
-
description: "What a creator shipped recently, and which of it beat their own baseline. Scores every post in the window against the median of that same window, because a raw view count mostly measures follower count \u2014 outperformance against themselves is the signal worth reporting. If they are on your watchlist it also marks which posts are new since your last
|
|
354
|
+
description: "What a creator shipped recently, and which of it beat their own baseline. Scores every post in the window against the median of that same window, because a raw view count mostly measures follower count \u2014 outperformance against themselves is the signal worth reporting. If they are on your watchlist it also marks which posts are new since your last track_creator call and moves that marker forward. Use for a rival you follow over time. Consumes 2 nooticr credits, whatever the window size.",
|
|
355
355
|
inputSchema: z.object({ username: z.string().describe("Creator handle, with or without @."), platform: z.string().optional().describe("Platform (default tiktok)."), limit: z.number().int().optional().describe("Posts in the window (default 12, max 30)."), metric: z.enum(["views", "likes", "comments", "shares", "engagementRate"]).optional().describe("Which stat to rank on (default views)."), since: z.string().optional().describe("Only posts published on or after this date, as YYYY-MM-DD.") }).strict(),
|
|
356
356
|
},
|
|
357
357
|
{
|
|
358
358
|
name: "compare_creators",
|
|
359
359
|
title: "Compare Creators",
|
|
360
|
-
description: "Two to five creators side by side, each scored against THEIR OWN recent median rather than against each other's raw numbers — which is the only way the comparison means anything, because a raw view count mostly measures follower count. Returns per creator: how many posts were scored, their own median, the share of the window that beat it, how hard they beat it when they did, every post's ratio, and their best and worst post. The ranking is yours to make: 'how often' and 'how big' usually disagree, and a hit rate over a short window is one post either way, so the window size travels with every number. Fetches one post list per creator: 2 nooticr credits each, 4-10 in total. Use for 'is their hit rate better than mine';
|
|
360
|
+
description: "Two to five creators side by side, each scored against THEIR OWN recent median rather than against each other's raw numbers — which is the only way the comparison means anything, because a raw view count mostly measures follower count. Returns per creator: how many posts were scored, their own median, the share of the window that beat it, how hard they beat it when they did, every post's ratio, and their best and worst post. The ranking is yours to make: 'how often' and 'how big' usually disagree, and a hit rate over a short window is one post either way, so the window size travels with every number. Fetches one post list per creator: 2 nooticr credits each, 4-10 in total. Use for 'is their hit rate better than mine'; track_creator is one creator alone.",
|
|
361
361
|
inputSchema: z.object({ usernames: z.array(z.string()).min(2).max(5), platform: z.string().optional(), limit: z.number().int().optional(), metric: z.string().optional() }).strict(),
|
|
362
362
|
},
|
|
363
363
|
{
|
|
@@ -390,6 +390,12 @@ export const TOOL_DEFINITIONS = [
|
|
|
390
390
|
description: "Demand and supply in one result. Reads the comments on a creator's own recent posts for what their audience explicitly asks for, sweeps the niche for what is already being made, and returns both with ids so an idea can be traced back to the comment that asked for it \u2014 a gap nobody asked for is noise, a request nobody serves is the opportunity. Names the niche from the creator's most-used hashtag when you do not give one. Use to decide what to film; niche_report covers the supply half alone. Consumes 2 nooticr credits for the post list, 2 per post read, and 2 for the sweep \u2014 12 at the default of 4 posts.",
|
|
391
391
|
inputSchema: z.object({ username: z.string().describe("Your handle, with or without @."), platform: z.string().optional().describe("Platform (default tiktok)."), niche: z.string().optional().describe("Niche for the supply sweep. Defaults to your most frequent hashtag."), limit: z.number().int().optional().describe("Your posts to read comments on (default 4, max 8). This is the price."), commentsPerPost: z.number().int().optional().describe("Comments per post (default 25, max 50)."), supplyLimit: z.number().int().optional().describe("Posts in the niche sweep (default 12, max 30).") }).strict(),
|
|
392
392
|
},
|
|
393
|
+
{
|
|
394
|
+
name: "find_people_with_problem",
|
|
395
|
+
title: "Find People With A Problem",
|
|
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).") }).strict(),
|
|
398
|
+
},
|
|
393
399
|
{
|
|
394
400
|
name: "search_spoken_mentions",
|
|
395
401
|
title: "Search Spoken Mentions",
|
package/dist/shared/tools.js
CHANGED
|
@@ -14,7 +14,7 @@ import { OUTPUT_SCHEMAS, anyObject } from "./output-schemas.js";
|
|
|
14
14
|
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
|
-
import { costSentence, EVIDENCE_PLANS, fetchBillingNote, frameIndex, framesToBlocks, planCalls, scoreDraftGuidance, } from "./evidence.js";
|
|
17
|
+
import { costSentence, EVIDENCE_PLANS, fetchBillingNote, frameIndex, framesToBlocks, planCalls, scoreDraftGuidance, KNOWN_PLATFORMS, platformFailureGuidance, } from "./evidence.js";
|
|
18
18
|
import { confirmSpend, declinedResult, searchMentionsCost, SEARCH_PLATFORMS, DISCOVERABLE_PLATFORMS, } from "./spend.js";
|
|
19
19
|
import { BackendWatchStore, MemoryWatchStore, registerWatchlist, } from "./watchlist.js";
|
|
20
20
|
import { registerJobTools } from "./jobs.js";
|
|
@@ -25,7 +25,7 @@ import { loadingPlansJson } from "./loading-plans.js";
|
|
|
25
25
|
import { registerHandoff } from "./handoff.js";
|
|
26
26
|
import { registerCollabTools } from "./collab.js";
|
|
27
27
|
/** Current MCP server version — bumped on every deploy for traceability. */
|
|
28
|
-
export const MCP_SERVER_VERSION = "1.26.
|
|
28
|
+
export const MCP_SERVER_VERSION = "1.26.29";
|
|
29
29
|
/** MCP Apps extension identifier */
|
|
30
30
|
const UI_EXTENSION = "io.modelcontextprotocol/ui";
|
|
31
31
|
/** MIME type for MCP Apps HTML resources */
|
|
@@ -342,6 +342,21 @@ function toolError(prefix, err) {
|
|
|
342
342
|
isError: true,
|
|
343
343
|
};
|
|
344
344
|
}
|
|
345
|
+
// A network that ERRORED is not a network that came back empty, and the two
|
|
346
|
+
// want opposite responses: an empty result means try a different query, an
|
|
347
|
+
// error means try a different network or tell the user. Nothing said which,
|
|
348
|
+
// so a host did the one thing that cannot work — re-ran the same call with
|
|
349
|
+
// the words changed, three times, each a paid upstream call. The backend
|
|
350
|
+
// names the platform in these messages ("... failed: reddit: ..."), so the
|
|
351
|
+
// split is recoverable here without every one of the 26 call sites having to
|
|
352
|
+
// pass its arguments down.
|
|
353
|
+
const platform = KNOWN_PLATFORMS.find((p) => new RegExp(`(^|[^a-z])${p}([^a-z]|$)`, "i").test(msg));
|
|
354
|
+
if (platform) {
|
|
355
|
+
return {
|
|
356
|
+
content: [{ type: "text", text: `${prefix}. ${platformFailureGuidance({ platform, message: msg })}` }],
|
|
357
|
+
isError: true,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
345
360
|
return { content: [{ type: "text", text: `${prefix}: ${msg}` }], isError: true };
|
|
346
361
|
}
|
|
347
362
|
/**
|
|
@@ -377,16 +392,34 @@ async function runEvidence(tool, args, client) {
|
|
|
377
392
|
extra = { unavailable: plan.also.via };
|
|
378
393
|
}
|
|
379
394
|
}
|
|
380
|
-
// The guidance
|
|
381
|
-
// that
|
|
382
|
-
//
|
|
395
|
+
// The guidance rides in BOTH channels, and the structured one is the one
|
|
396
|
+
// that arrives.
|
|
397
|
+
//
|
|
398
|
+
// This used to say "the guidance leads: a model reads the first text block in
|
|
399
|
+
// a result, and that is where the account of what to produce has to be". That
|
|
400
|
+
// is what the MCP spec allows, and it is not what hosts do. Claude Code
|
|
401
|
+
// discards every `content` text block whenever a result carries
|
|
402
|
+
// `structuredContent`, keeps the non-text blocks, and shows the model the
|
|
403
|
+
// serialised JSON instead — unconditionally, with no setting, and without
|
|
404
|
+
// consulting `outputSchema`. Every tool here declares one, so every guidance
|
|
405
|
+
// string this file writes was landing nowhere.
|
|
406
|
+
//
|
|
407
|
+
// Measured before changing it, over 59 real runs of the quest suite: 0
|
|
408
|
+
// guidance phrases across 175 tool results. `analyze_post` handed back frames,
|
|
409
|
+
// a transcript and 2,000 characters ending in "call show_analysis when you are
|
|
410
|
+
// done"; the model wrote the analysis and delivered it in chat, and
|
|
411
|
+
// show_analysis was called in 1 run of 12.
|
|
412
|
+
//
|
|
413
|
+
// The text block stays for hosts that honour it (and for the images, which
|
|
414
|
+
// must sit in `content`). `guidance` on the payload is what reaches the rest.
|
|
415
|
+
// See docs/testing/tool-chaining-quests.md.
|
|
383
416
|
const billing = nothingToFetch
|
|
384
417
|
? "Nothing was fetched for this call, so nothing was charged."
|
|
385
418
|
: fetchBillingNote(tool);
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
];
|
|
419
|
+
const guidance = `${plan.guidance(args)}\n\n${billing}`;
|
|
420
|
+
const content = [{ type: "text", text: guidance }];
|
|
389
421
|
const out = {
|
|
422
|
+
guidance,
|
|
390
423
|
// Kept, and constant. It is not an echo of an argument — the job tools in
|
|
391
424
|
// jobs.ts set the same key with no argument to echo — it marks a payload as
|
|
392
425
|
// material the caller still has to read. Dropping it would break anything
|
|
@@ -525,8 +558,8 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
525
558
|
// a search_mentions result render in the monitoring view.
|
|
526
559
|
"answer_my_audience",
|
|
527
560
|
"show_audience_replies",
|
|
528
|
-
"
|
|
529
|
-
// The comparison
|
|
561
|
+
"track_creator",
|
|
562
|
+
// The comparison track_creator computes per creator and never puts side
|
|
530
563
|
// by side, plus the free view that draws the table once a read is written.
|
|
531
564
|
"compare_creators",
|
|
532
565
|
"watchlist_standings",
|
|
@@ -538,6 +571,10 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
538
571
|
// hits rather than comments, so it gets its own view rather than the
|
|
539
572
|
// monitoring one.
|
|
540
573
|
"search_spoken_mentions",
|
|
574
|
+
// Prospect discovery: the same keyword search as discover_social_posts, but
|
|
575
|
+
// widened into the shapes a complaint takes rather than the topic it is
|
|
576
|
+
// about. Returns `posts`, so the gallery view draws it.
|
|
577
|
+
"find_people_with_problem",
|
|
541
578
|
// Own-account intelligence (own-account.ts). list_own_apps stays
|
|
542
579
|
// view-less like watch_creator — it lists metadata, nothing to draw.
|
|
543
580
|
// get_scheduled_posts and get_post_performance return a `posts` array
|
|
@@ -907,7 +944,11 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
907
944
|
"Work out their niche, recurring themes, hook formula, what over- and under-performs and who " +
|
|
908
945
|
"their audience is, reading the spread of the numbers rather than only the best post, and " +
|
|
909
946
|
`name the posts you reason from. ${costSentence("analyze_creator_profile")} ` +
|
|
910
|
-
"Use for the teardown itself; find_hook_pattern fetches the same posts and asks only for the
|
|
947
|
+
"Use for the teardown itself; find_hook_pattern fetches the same posts and asks only for the " +
|
|
948
|
+
"formula. NOT for keeping an eye on a rival over time: \"track X\", \"how are they doing " +
|
|
949
|
+
"lately\", \"what have they shipped since I last looked\" are track_creator, which scores " +
|
|
950
|
+
"each post against that creator's own median and remembers where you left off. This tool " +
|
|
951
|
+
"returns the posts and no verdict, and remembers nothing between calls.",
|
|
911
952
|
_meta: {
|
|
912
953
|
ui: { resourceUri: uiResource("analyze_creator_profile") },
|
|
913
954
|
"ui/resourceUri": uiResource("analyze_creator_profile"),
|
|
@@ -1160,9 +1201,14 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1160
1201
|
const res = await client.callTool("get_post_comments", { ...args });
|
|
1161
1202
|
const structured = (res.structured ?? {});
|
|
1162
1203
|
const comments = toEvidence(args.url, structured.comments);
|
|
1204
|
+
const guidance = reviewGuidance(args.url, comments.length);
|
|
1163
1205
|
return {
|
|
1164
|
-
content: [{ type: "text", text:
|
|
1206
|
+
content: [{ type: "text", text: guidance }],
|
|
1165
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,
|
|
1166
1212
|
mode: "evidence",
|
|
1167
1213
|
url: args.url,
|
|
1168
1214
|
platform: structured.platform ?? null,
|
|
@@ -1921,16 +1967,24 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
1921
1967
|
// argument: the draft is the caller's own text, so the backend was only ever
|
|
1922
1968
|
// being paid to hold it up against a standard, and the standard is what
|
|
1923
1969
|
// comes back instead. Nothing here can fail, which is why nothing is caught.
|
|
1924
|
-
async (args) =>
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1970
|
+
async (args) => {
|
|
1971
|
+
const guidance = scoreDraftGuidance(args.draft, String(args.platform ?? ""));
|
|
1972
|
+
return {
|
|
1973
|
+
content: [{ type: "text", text: guidance }],
|
|
1974
|
+
structuredContent: {
|
|
1975
|
+
// The rubric IS this tool's answer — it fetches nothing and scores
|
|
1976
|
+
// nothing itself. In a text block it reached no host that renders
|
|
1977
|
+
// structuredContent, which made the tool return its own input and a
|
|
1978
|
+
// credit note. See runEvidence.
|
|
1979
|
+
guidance,
|
|
1980
|
+
draft: args.draft,
|
|
1981
|
+
platform: args.platform ?? "tiktok",
|
|
1982
|
+
// Same shape the free tools use, so a caller totting up a session's spend
|
|
1983
|
+
// does not have to special-case this one.
|
|
1984
|
+
mcpCredits: { cost: 0 },
|
|
1985
|
+
},
|
|
1986
|
+
};
|
|
1987
|
+
});
|
|
1934
1988
|
server.registerTool("repurpose_post", {
|
|
1935
1989
|
title: "Repurpose Post",
|
|
1936
1990
|
description: "The source post, its transcript and its stats, for you to rewrite for other surfaces — X " +
|
|
@@ -2247,7 +2301,7 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
2247
2301
|
}
|
|
2248
2302
|
});
|
|
2249
2303
|
registerPrompts(server);
|
|
2250
|
-
// One store for both.
|
|
2304
|
+
// One store for both. track_creator keeps its "since I last looked" marker
|
|
2251
2305
|
// on the same watchlist entries, in its own field — two stores would mean a
|
|
2252
2306
|
// creator you watch and a creator you track were different people.
|
|
2253
2307
|
//
|
|
@@ -2782,7 +2782,7 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
|
|
|
2782
2782
|
/**
|
|
2783
2783
|
* The one line a competitor sweep or an underperformance check actually
|
|
2784
2784
|
* exists to produce — ratio to the creator's own median, and the verdict
|
|
2785
|
-
* that ratio earns.
|
|
2785
|
+
* that ratio earns. track_creator and why_did_this_underperform attach
|
|
2786
2786
|
* "standing" to every post they score (see performance.ts), and until this
|
|
2787
2787
|
* existed postCard rendered the same view/like/comment pills any gallery
|
|
2788
2788
|
* gets, so the computed comparison a person paid for never reached the
|
|
@@ -3982,8 +3982,18 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
|
|
|
3982
3982
|
|
|
3983
3983
|
// Single post
|
|
3984
3984
|
if(d.post||d.platform){app.innerHTML=postCard(d.post||d,true);return;}
|
|
3985
|
-
// Fallback
|
|
3986
|
-
|
|
3985
|
+
// Fallback. The guidance field is prose written for the model, not data —
|
|
3986
|
+
// dumped into the JSON block it reads as a wall of escaped text, and it is
|
|
3987
|
+
// the longest string in most payloads. Drawn above the block and dropped
|
|
3988
|
+
// from it. This is also what finally gives score_draft a view worth
|
|
3989
|
+
// looking at: its rubric IS its answer, and the card used to show the
|
|
3990
|
+
// echoed draft and a credit cost with the rubric nowhere on screen.
|
|
3991
|
+
var rest=d,lead="";
|
|
3992
|
+
if(d&&typeof d==="object"&&typeof d.guidance==="string"){
|
|
3993
|
+
lead='<div class="fade-in" style="white-space:pre-wrap;line-height:1.5;margin-bottom:12px">'+esc(d.guidance)+"</div>";
|
|
3994
|
+
rest={};for(var gk in d){if(gk!=="guidance")rest[gk]=d[gk];}
|
|
3995
|
+
}
|
|
3996
|
+
app.innerHTML=lead+'<div class="json-block fade-in">'+esc(JSON.stringify(rest,null,2))+"</div>";
|
|
3987
3997
|
setTimeout(reportSize,50);
|
|
3988
3998
|
}
|
|
3989
3999
|
|
package/dist/shared/watchlist.js
CHANGED
|
@@ -33,11 +33,18 @@
|
|
|
33
33
|
* registers, and only one registration under a name can win — but renaming
|
|
34
34
|
* either side would break whichever hosts had already learned the old one,
|
|
35
35
|
* for a tool whose whole promise is "what you called before still works". So
|
|
36
|
-
* nothing about the surface moved. `watch_creator`,
|
|
37
|
-
* `
|
|
36
|
+
* nothing about the surface moved for THAT change. `watch_creator`,
|
|
37
|
+
* `unwatch_creator` and `catch_up_watchlist` keep their names, arguments and
|
|
38
38
|
* behaviour; only the bytes moved, and the backend's own twins stay
|
|
39
39
|
* unregistered here because they would be a second, disconnected list.
|
|
40
40
|
*
|
|
41
|
+
* `track_creator` is the one exception, and for an unrelated reason: it was
|
|
42
|
+
* `track_competitor`, and that name was measured to be unreachable for the
|
|
43
|
+
* way most people ask for it — see its registration in jobs.ts. The backend
|
|
44
|
+
* still registers `track_competitor` for its own dashboard copilot, which is
|
|
45
|
+
* the ordinary state of two repos with two release cycles rather than a
|
|
46
|
+
* collision: the names no longer overlap, so nothing has to win.
|
|
47
|
+
*
|
|
41
48
|
* The stores above are still live and still matter. They are the fallback for
|
|
42
49
|
* an account with no workspace or a backend too old to have the tools, and
|
|
43
50
|
* they are what the one-time migration reads from. Deleting one because "the
|
|
@@ -155,9 +162,10 @@ export class FileWatchStore {
|
|
|
155
162
|
* and on ChatGPT had two, and a server-side scheduler could read neither.
|
|
156
163
|
*
|
|
157
164
|
* The swap is deliberately at `WatchStore` rather than at the tool layer.
|
|
158
|
-
* `watch_creator`, `unwatch_creator
|
|
159
|
-
*
|
|
160
|
-
*
|
|
165
|
+
* `watch_creator`, `unwatch_creator` and `catch_up_watchlist` keep their
|
|
166
|
+
* names, their arguments and their behaviour; only where the bytes live
|
|
167
|
+
* changes. (`track_creator` was later renamed from `track_competitor` for a
|
|
168
|
+
* reason that has nothing to do with storage — see above.) That is the whole reason the collision
|
|
161
169
|
* with the backend's identically-named tools was not worth solving by
|
|
162
170
|
* renaming anything: a host that already learned `watch_creator` keeps
|
|
163
171
|
* working, and it now writes somewhere the other host can see.
|
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.
|
|
4
|
+
"version": "1.26.29",
|
|
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": {
|
|
@@ -38,9 +38,10 @@
|
|
|
38
38
|
"test:e2e:visual": "playwright test tests/e2e/agentic-visual.e2e.ts tests/e2e/agentic-visual-full-app.e2e.ts",
|
|
39
39
|
"test:e2e-smoke": "bash scripts/run-mechanical-e2e-smoke.sh",
|
|
40
40
|
"test:e2e-smoke:fixture": "NOOTICR_E2E_BACKEND=fixture bash scripts/run-mechanical-e2e-smoke.sh",
|
|
41
|
-
"test:
|
|
41
|
+
"test:quests": "bash scripts/run-quests.sh",
|
|
42
42
|
"contract:host": "sh scripts/host-contract.sh",
|
|
43
|
-
"
|
|
43
|
+
"chain:map": "bash scripts/run-chain-map.sh",
|
|
44
|
+
"verify": "npm run build && npx tsc --noEmit && (cd cloudflare && npx tsc --noEmit) && npx vitest run && npm run contract:host && npm run chain:map && npm run conformance:mcpjam && npx playwright test",
|
|
44
45
|
"contract:manifest": "node scripts/contract-check.mjs --from=../nooticr-server/platform-capabilities.json",
|
|
45
46
|
"test:mutation": "node scripts/mutation-guard.mjs",
|
|
46
47
|
"test:invariants": "node scripts/invariant-guard.mjs",
|