@nooticr/mcp 1.26.33 → 1.26.35
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 +24 -2
- package/dist/shared/amazon.js +480 -0
- package/dist/shared/collab.js +13 -1
- package/dist/shared/comment-review.js +50 -3
- package/dist/shared/jobs.js +40 -7
- package/dist/shared/loading-plans.js +52 -0
- package/dist/shared/output-schemas.js +109 -0
- package/dist/shared/tools-def.js +26 -2
- package/dist/shared/tools.js +42 -6
- package/dist/shared/ui-template.js +520 -2
- package/package.json +1 -1
package/dist/shared/jobs.js
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
*/
|
|
56
56
|
import { z } from "zod";
|
|
57
57
|
import { OUTPUT_SCHEMAS } from "./output-schemas.js";
|
|
58
|
-
import { platformFromUrl, postSlug } from "./comment-review.js";
|
|
58
|
+
import { classifyGuidance, platformFromUrl, postSlug } from "./comment-review.js";
|
|
59
59
|
import { clamp, complaintCore, handleMissGuidance, ownIt, PLATFORM_ARG } from "./evidence.js";
|
|
60
60
|
import { withEvidence } from "./evidence-digest.js";
|
|
61
61
|
import { confirmSpend, costOf, declinedResult, MAX_SPOKEN_HANDLE_CALLS, MAX_SPOKEN_TRANSCRIPTS, SPOKEN_PLATFORMS, CREDITS_PER_CREATOR, } from "./spend.js";
|
|
@@ -1220,16 +1220,31 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1220
1220
|
existing.foundBy = "both";
|
|
1221
1221
|
return;
|
|
1222
1222
|
}
|
|
1223
|
+
// null only when neither spelling arrived; "" when one did and was
|
|
1224
|
+
// blank. `?? null` rather than `?? ""` is the whole distinction.
|
|
1225
|
+
const rawBio = raw.signature ?? raw.bio;
|
|
1226
|
+
const bio = typeof rawBio === "string" ? rawBio : null;
|
|
1223
1227
|
bySource.set(key, {
|
|
1224
1228
|
...raw,
|
|
1225
1229
|
id: `creator:${platform}:${key}`,
|
|
1226
1230
|
username,
|
|
1227
1231
|
followers: numberOf(raw.followers ?? raw.followerCount),
|
|
1228
1232
|
foundBy: source,
|
|
1233
|
+
// Three states, not two. The server distinguishes "this creator
|
|
1234
|
+
// wrote no bio" (empty string) from "the endpoint did not send one"
|
|
1235
|
+
// (null) — Nooticr/nooticr-server#82, where `signature` came back
|
|
1236
|
+
// empty for 55 of 55 results across two platforms. Collapsing the
|
|
1237
|
+
// null back to "" here would undo that fix at its most important
|
|
1238
|
+
// consumer: `links` is this tool's headline differentiator, and an
|
|
1239
|
+
// empty array reads as "this creator published nothing" whichever
|
|
1240
|
+
// reason produced it.
|
|
1241
|
+
bioRead: bio !== null,
|
|
1229
1242
|
// What the host should go and read. Pulled out of the bio here
|
|
1230
1243
|
// rather than left for the model to spot inside prose — see
|
|
1231
1244
|
// collab.ts for why they are typed and why we do not open them.
|
|
1232
|
-
links:
|
|
1245
|
+
links: bio === null
|
|
1246
|
+
? []
|
|
1247
|
+
: extractLinks(bio, typeof raw.externalUrl === "string" ? raw.externalUrl : undefined),
|
|
1233
1248
|
});
|
|
1234
1249
|
};
|
|
1235
1250
|
try {
|
|
@@ -1264,15 +1279,20 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
1264
1279
|
const creators = [...bySource.values()].sort((a, b) => (a.foundBy === "both" ? 0 : 1) - (b.foundBy === "both" ? 0 : 1) ||
|
|
1265
1280
|
numberOf(b.followers) - numberOf(a.followers));
|
|
1266
1281
|
const withLinks = creators.filter((c) => Array.isArray(c.links) && c.links.length > 0).length;
|
|
1282
|
+
// Candidates whose bio never arrived. Counted rather than inferred from
|
|
1283
|
+
// an empty `links`, because the two are different claims and this tool
|
|
1284
|
+
// is built entirely on the one it cannot make when the bio is missing.
|
|
1285
|
+
const bioUnread = creators.filter((c) => c.bioRead === false).length;
|
|
1267
1286
|
return evidence([
|
|
1268
1287
|
collabGuidance({ niche: args.niche, found: creators.length, seed: args.seed, platform }),
|
|
1269
1288
|
"",
|
|
1270
|
-
vettingGuidance(creators.length, withLinks),
|
|
1289
|
+
vettingGuidance(creators.length, withLinks, bioUnread),
|
|
1271
1290
|
].join("\n"), {
|
|
1272
1291
|
mode: "evidence",
|
|
1273
1292
|
tool: "who_should_i_work_with",
|
|
1274
1293
|
rubric: COLLAB_RUBRIC,
|
|
1275
1294
|
withLinks,
|
|
1295
|
+
bioUnread,
|
|
1276
1296
|
evidenceFrom: args.seed ? ["search_creators", "get_similar_creators"] : ["search_creators"],
|
|
1277
1297
|
niche: args.niche,
|
|
1278
1298
|
platform,
|
|
@@ -2096,8 +2116,8 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
2096
2116
|
"Widens the query into the forms a complaint takes — the plain phrasing, the " +
|
|
2097
2117
|
"does-anyone-else question, the is-there-a-tool ask — and merges what each returns. " +
|
|
2098
2118
|
"It is a wide cheap net and NOT a filter: the posts come back for you to judge, and " +
|
|
2099
|
-
"many will be off-target. Consumes 2 nooticr credits per platform searched. Defaults to " +
|
|
2100
|
-
"reddit, where people describe workflow pain in sentences; add twitter for volume.",
|
|
2119
|
+
"many will be off-target. Consumes 2 nooticr credits per search, and each platform is searched once per query shape — three by default, so 6 credits per platform. Defaults to " +
|
|
2120
|
+
"reddit, where people describe workflow pain in sentences; add twitter for volume. Not searchable here: linkedin.",
|
|
2101
2121
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
2102
2122
|
outputSchema: OUTPUT_SCHEMAS.find_people_with_problem,
|
|
2103
2123
|
inputSchema: z
|
|
@@ -2108,9 +2128,9 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
2108
2128
|
"checking what competitors posted\" finds people; \"competitor analysis\" finds " +
|
|
2109
2129
|
"marketing about competitor analysis."),
|
|
2110
2130
|
platforms: z
|
|
2111
|
-
.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram"
|
|
2131
|
+
.array(z.enum(["reddit", "twitter", "youtube", "tiktok", "instagram"]))
|
|
2112
2132
|
.optional()
|
|
2113
|
-
.describe("Where to look (default reddit). Each one is a separate paid search."),
|
|
2133
|
+
.describe("Where to look (default reddit). Each one is a separate paid search per query shape. Not searchable here: linkedin — no keyword post search exists for it upstream, so asking for it could only ever return nothing, which is why it is absent from this list rather than merely untested."),
|
|
2114
2134
|
limit: z.number().int().optional().describe("Posts per query shape (default 6, max 15)."),
|
|
2115
2135
|
readComments: z
|
|
2116
2136
|
.boolean()
|
|
@@ -2297,6 +2317,19 @@ export function registerJobTools(server, makeClient, store) {
|
|
|
2297
2317
|
? `Note that ${unavailable.length} searches errored — tell the user which networks ` +
|
|
2298
2318
|
"could not be searched rather than presenting this as the whole picture.\n"
|
|
2299
2319
|
: "",
|
|
2320
|
+
// A prospect list is a judgement with nowhere to land unless the
|
|
2321
|
+
// labels come back in a shape the view can draw. `bug_report` and
|
|
2322
|
+
// `complaint` are exactly what makes this list actionable — one goes
|
|
2323
|
+
// to engineering, one to positioning — and the chips, counts and
|
|
2324
|
+
// filter that would show them have always been built and never been
|
|
2325
|
+
// asked for on this path (#79).
|
|
2326
|
+
...(args.readComments && posts.some((p) => Array.isArray(p.commentSample))
|
|
2327
|
+
? [
|
|
2328
|
+
"",
|
|
2329
|
+
classifyGuidance("The replies you were handed under these posts are unclassified, and labelling " +
|
|
2330
|
+
"them is what turns this list into something sortable.", posts.reduce((n, p) => n + (Array.isArray(p.commentSample) ? p.commentSample.length : 0), 0)),
|
|
2331
|
+
]
|
|
2332
|
+
: []),
|
|
2300
2333
|
ownIt,
|
|
2301
2334
|
]
|
|
2302
2335
|
.filter((l, i, all) => !(l === "" && all[i - 1] === ""))
|
|
@@ -332,6 +332,58 @@ export const LOADING_PLANS = {
|
|
|
332
332
|
// upstream step to wait on — the wait is a database read.
|
|
333
333
|
mention_trend: { label: "Reading the run history", kind: "text", n: 1, steps: [], free: true },
|
|
334
334
|
prepare_handoff: { label: "Packaging the handoff", kind: "list", n: 4, steps: [], free: true },
|
|
335
|
+
show_amazon_category_insights: {
|
|
336
|
+
label: "Drawing the category read",
|
|
337
|
+
kind: "list",
|
|
338
|
+
n: 5,
|
|
339
|
+
steps: [],
|
|
340
|
+
free: true,
|
|
341
|
+
},
|
|
342
|
+
// ─── Marketplace ───
|
|
343
|
+
//
|
|
344
|
+
// The wait here is genuinely long and the reason is worth showing rather
|
|
345
|
+
// than hiding behind a spinner: each listing is a browser render behind
|
|
346
|
+
// Amazon's bot defences, and the politeness delay between them is set by
|
|
347
|
+
// what Amazon tolerates rather than by what the machine can do. A plan that
|
|
348
|
+
// said "Working" for four minutes would read as a hang.
|
|
349
|
+
scan_amazon_category: {
|
|
350
|
+
label: "Collecting the category",
|
|
351
|
+
kind: "list",
|
|
352
|
+
n: 6,
|
|
353
|
+
steps: [],
|
|
354
|
+
perUnit: [
|
|
355
|
+
{
|
|
356
|
+
via: "scan_amazon_category",
|
|
357
|
+
label: "Collecting a listing",
|
|
358
|
+
detail: "price, stars, aspects and its reviews",
|
|
359
|
+
credits: 3,
|
|
360
|
+
arg: "limit",
|
|
361
|
+
defaultCount: 10,
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
note: "Amazon is collected politely, so this is minutes rather than seconds. Partial results come back either way.",
|
|
365
|
+
},
|
|
366
|
+
// Free: the poll of a collection already paid for.
|
|
367
|
+
amazon_scan_status: {
|
|
368
|
+
label: "Checking the collection",
|
|
369
|
+
kind: "list",
|
|
370
|
+
n: 6,
|
|
371
|
+
steps: [],
|
|
372
|
+
free: true,
|
|
373
|
+
},
|
|
374
|
+
get_amazon_product: {
|
|
375
|
+
label: "Reading the listing",
|
|
376
|
+
kind: "text",
|
|
377
|
+
n: 1,
|
|
378
|
+
steps: [
|
|
379
|
+
{
|
|
380
|
+
via: "get_amazon_product",
|
|
381
|
+
label: "Fetching the listing",
|
|
382
|
+
detail: "price, stars, aspects and its reviews",
|
|
383
|
+
credits: 3,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
},
|
|
335
387
|
// ─── Own-account reads: nooticr's own stored rows ───
|
|
336
388
|
get_scheduled_posts: { label: "Reading your pipeline", kind: "list", n: 5, steps: [], free: true },
|
|
337
389
|
get_post_performance: { label: "Reading your numbers", kind: "list", n: 5, steps: [], free: true },
|
|
@@ -142,6 +142,16 @@ const post = open({
|
|
|
142
142
|
comments: scalar(),
|
|
143
143
|
shares: scalar(),
|
|
144
144
|
engagementRate: scalar(),
|
|
145
|
+
// The replies under the post, when a tool opened it — find_people_with_problem's
|
|
146
|
+
// `readComments`. Declared rather than left to `open()` to pass through: a host
|
|
147
|
+
// reading the schema had no way to know these could be here, so the field that
|
|
148
|
+
// costs the most in that call was the one field it could not plan for (#78).
|
|
149
|
+
//
|
|
150
|
+
// `commentsRead: 0` means the thread would not open, NOT that nobody replied.
|
|
151
|
+
// The two read as opposite findings about a prospect, so the distinction is
|
|
152
|
+
// stated here as well as in the digest and the view.
|
|
153
|
+
commentsRead: scalar().describe("How many replies were read under this post. 0 means the thread would not open — not that nobody replied. Absent when nothing tried."),
|
|
154
|
+
commentSample: anyList().describe("The replies themselves, when they were read. Someone answering \"same here, this is exactly my problem\" under another person's thread is a better find than the thread — the permalink to quote is still this post's."),
|
|
145
155
|
hashtags: listOf(z.string()),
|
|
146
156
|
// Media comes in threes: the platform URL, an nooticr proxy, and a resolver
|
|
147
157
|
// that re-fetches when a signed link has expired.
|
|
@@ -225,6 +235,65 @@ const analyzed = { ...singlePost, analysis, analyzed: scalar() };
|
|
|
225
235
|
* One entry per tool. A tool missing from here declares nothing, which is the
|
|
226
236
|
* honest option when the shape has not been checked against a real response.
|
|
227
237
|
*/
|
|
238
|
+
/**
|
|
239
|
+
* One Amazon listing, as the scan hands it over.
|
|
240
|
+
*
|
|
241
|
+
* `aspects` and `reviewSummary` are Amazon's own, not ours, and they are the
|
|
242
|
+
* half of a listing that survives the sign-in gate which withholds review
|
|
243
|
+
* bodies — so they are declared beside `reviews` rather than folded into it.
|
|
244
|
+
*/
|
|
245
|
+
const amazonProduct = open({
|
|
246
|
+
asin: scalar(),
|
|
247
|
+
url: scalar(),
|
|
248
|
+
title: scalar(),
|
|
249
|
+
brand: scalar(),
|
|
250
|
+
price: scalar(),
|
|
251
|
+
priceValue: scalar(),
|
|
252
|
+
listPrice: scalar(),
|
|
253
|
+
availability: scalar(),
|
|
254
|
+
rating: scalar(),
|
|
255
|
+
ratingCount: scalar(),
|
|
256
|
+
image: scalar(),
|
|
257
|
+
images: listOf(z.string()),
|
|
258
|
+
features: listOf(z.string()),
|
|
259
|
+
categories: listOf(z.string()),
|
|
260
|
+
specs: open({}).nullish(),
|
|
261
|
+
ratingHistogram: open({}).nullish().describe("Star bucket to share, e.g. {\"5\": \"73%\"}."),
|
|
262
|
+
reviewSummary: scalar().describe("Amazon's own digest of the reviews."),
|
|
263
|
+
aspects: listOf(open({ name: scalar(), mentions: scalar(), sentiment: scalar() })),
|
|
264
|
+
reviewsGated: scalar().describe("True when Amazon withheld the review bodies behind sign-in."),
|
|
265
|
+
reviews: anyList(),
|
|
266
|
+
});
|
|
267
|
+
/** One thing you concluded, with what it rests on. */
|
|
268
|
+
const amazonFinding = open({
|
|
269
|
+
label: scalar(),
|
|
270
|
+
detail: scalar(),
|
|
271
|
+
evidence: listOf(z.string()).describe("Review ids or short quotes."),
|
|
272
|
+
strength: scalar().describe("strong / moderate / thin — how well the collected text supports it."),
|
|
273
|
+
});
|
|
274
|
+
const amazonScanShape = {
|
|
275
|
+
guidance: scalar(),
|
|
276
|
+
mode: scalar(),
|
|
277
|
+
marketplace: scalar(),
|
|
278
|
+
scanId: scalar().describe("Pass to amazon_scan_status to pick a running collection back up."),
|
|
279
|
+
status: scalar(),
|
|
280
|
+
complete: scalar().describe("False means more listings are still coming."),
|
|
281
|
+
query: scalar(),
|
|
282
|
+
domain: scalar(),
|
|
283
|
+
progress: open({
|
|
284
|
+
done: scalar(),
|
|
285
|
+
total: scalar(),
|
|
286
|
+
message: scalar(),
|
|
287
|
+
elapsedMs: scalar(),
|
|
288
|
+
fromCache: scalar(),
|
|
289
|
+
}).nullish(),
|
|
290
|
+
rollup: open({}).nullish().describe("The arithmetic over the set: price and rating spread, aspects by brand, 1-2 star share."),
|
|
291
|
+
products: listOf(amazonProduct),
|
|
292
|
+
reviews: anyList().describe("Every review across the scan, worst-rated first — the barriers are the half a summary drops."),
|
|
293
|
+
errors: anyList(),
|
|
294
|
+
mcpCredits,
|
|
295
|
+
};
|
|
296
|
+
const amazonScan = open(amazonScanShape);
|
|
228
297
|
export const OUTPUT_SCHEMAS = {
|
|
229
298
|
analyze_post: open({ ...analyzed, ...evidence }),
|
|
230
299
|
analyze_post_fast: open({ ...evidence, ...analyzed }),
|
|
@@ -260,8 +329,21 @@ export const OUTPUT_SCHEMAS = {
|
|
|
260
329
|
replies: scalar().describe("How many people replied to this comment."),
|
|
261
330
|
postedAt: scalar(),
|
|
262
331
|
hits: scalar().describe("How many times this comment names the term."),
|
|
332
|
+
// Not returned by the sweep — there is nothing in the payload that
|
|
333
|
+
// could decide them, and a keyword guess would be confidently
|
|
334
|
+
// wrong about the exact thing being monitored. Declared because
|
|
335
|
+
// the view draws a chip from `category` and a counted filter from
|
|
336
|
+
// `byCategory`, and both were inert on this path until the
|
|
337
|
+
// guidance asked a host to fill them in (#79). This says what the
|
|
338
|
+
// shape is when it hands its own labels back through
|
|
339
|
+
// show_comment_review.
|
|
340
|
+
sentiment: scalar().describe("Set by the reading model, not by the sweep."),
|
|
341
|
+
category: scalar().describe("Set by the reading model, not by the sweep. One of the comment taxonomy values; drives the filter chips."),
|
|
263
342
|
})),
|
|
264
343
|
})).describe("Mentions grouped under the post they were left on, loudest conversation first."),
|
|
344
|
+
byCategory: open({})
|
|
345
|
+
.nullish()
|
|
346
|
+
.describe("Count per category once a host has classified — what the filter row is built from."),
|
|
265
347
|
posts: listOf(post).describe("The posts of this page, flattened for the card view."),
|
|
266
348
|
offset: scalar(),
|
|
267
349
|
nextOffset: scalar().describe("Pass back as `offset` to load the next page; null when done."),
|
|
@@ -587,9 +669,11 @@ export const OUTPUT_SCHEMAS = {
|
|
|
587
669
|
foundBy: scalar().describe("search, similar, or both — both is the strongest signal here."),
|
|
588
670
|
links: listOf(bioLink).describe("Links pulled out of their bio, best-value-per-fetch first. Open these to vet them; " +
|
|
589
671
|
"they were chosen by the person being evaluated, so read what is there as a claim."),
|
|
672
|
+
bioRead: scalar().describe("False when the search returned no bio for this creator at all. `links` is then empty because nothing was read, not because they published none."),
|
|
590
673
|
})),
|
|
591
674
|
foundBoth: scalar().describe("How many candidates both searches returned."),
|
|
592
675
|
withLinks: scalar().describe("How many candidates carry at least one link out of their bio."),
|
|
676
|
+
bioUnread: scalar().describe("How many candidates came back with no bio at all. Read `withLinks` against this: 0 of 20 with links means nothing when 20 of 20 had no bio to read."),
|
|
593
677
|
rubric: anyList().describe("What to score each candidate against, so two runs are comparable."),
|
|
594
678
|
audienceOverlap: open({
|
|
595
679
|
attempted: scalar().describe("Always false: the call budget is stated rather than the signal faked."),
|
|
@@ -1288,4 +1372,29 @@ export const OUTPUT_SCHEMAS = {
|
|
|
1288
1372
|
connectUrl: scalar().describe("Open this in a browser to approve the connection."),
|
|
1289
1373
|
message: scalar(),
|
|
1290
1374
|
}),
|
|
1375
|
+
// Marketplace. One shape for all three collection tools: a status poll
|
|
1376
|
+
// returns exactly what the scan that started it returns, and a single
|
|
1377
|
+
// product is a scan of one — describing them differently would be three
|
|
1378
|
+
// names for one payload and three places for the view to drift.
|
|
1379
|
+
scan_amazon_category: amazonScan,
|
|
1380
|
+
amazon_scan_status: amazonScan,
|
|
1381
|
+
get_amazon_product: open({
|
|
1382
|
+
...amazonScanShape,
|
|
1383
|
+
product: amazonProduct.nullish().describe("The single listing, also present as products[0]."),
|
|
1384
|
+
}),
|
|
1385
|
+
show_amazon_category_insights: open({
|
|
1386
|
+
marketplace: scalar(),
|
|
1387
|
+
view: scalar(),
|
|
1388
|
+
category: scalar(),
|
|
1389
|
+
summary: scalar(),
|
|
1390
|
+
drivers: listOf(amazonFinding).describe("What makes someone buy, as you read it."),
|
|
1391
|
+
barriers: listOf(amazonFinding).describe("What stops them, or makes them return it."),
|
|
1392
|
+
strengths: listOf(open({ brand: scalar(), detail: scalar(), asin: scalar() })),
|
|
1393
|
+
gaps: listOf(amazonFinding),
|
|
1394
|
+
positioning: listOf(open({ angle: scalar(), who: scalar(), why: scalar(), risk: scalar() })),
|
|
1395
|
+
products: listOf(amazonProduct).describe("The listings, passed through so the view can draw them."),
|
|
1396
|
+
rollup: open({}).nullish(),
|
|
1397
|
+
scanId: scalar(),
|
|
1398
|
+
mcpCredits,
|
|
1399
|
+
}),
|
|
1291
1400
|
};
|
package/dist/shared/tools-def.js
CHANGED
|
@@ -393,8 +393,8 @@ export const TOOL_DEFINITIONS = [
|
|
|
393
393
|
{
|
|
394
394
|
name: "find_people_with_problem",
|
|
395
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"
|
|
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 search, and each platform is searched once per query shape — three by default, so 6 credits per platform. Defaults to reddit, where people describe workflow pain in sentences; add twitter for volume. Not searchable here: linkedin.",
|
|
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"])).optional().describe("Where to look (default reddit). Each one is a separate paid search per query shape. Not searchable here: linkedin — no keyword post search exists for it upstream, so asking for it could only ever return nothing, which is why it is absent from this list rather than merely untested."), 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",
|
|
@@ -414,4 +414,28 @@ export const TOOL_DEFINITIONS = [
|
|
|
414
414
|
description: "Patch your own product's fields \u2014 omitted arguments leave their column unchanged, so this cannot blank a field by not mentioning it. Takes appId, optional when your workspace has exactly one product; every other field is snake_case, the same names create_product takes and read the same way. The result lists which fields were actually written, so a name you spelled wrong shows up as a field that did not change rather than as a silent no-op. Free \u2014 no AI call, just a row.",
|
|
415
415
|
inputSchema: z.object({ appId: z.number().int().optional().describe("Your product's id. Omit only with a single-product workspace."), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), website_url: z.string().optional(), niche: z.string().optional(), product_type: z.string().optional(), icon_url: z.string().optional(), primary_cta_label: z.string().optional(), primary_cta_url: z.string().optional(), external_listing_id: z.string().optional(), ios_bundle_id: z.string().optional(), android_package: z.string().optional() }).strict(),
|
|
416
416
|
},
|
|
417
|
+
{
|
|
418
|
+
name: "scan_amazon_category",
|
|
419
|
+
title: "Scan Amazon Category",
|
|
420
|
+
description: "Collect an Amazon category and read it: listings with prices, star histograms, Amazon's own review-aspect counts and the review text itself, plus the arithmetic over them (price spread, rating spread, which aspects recur across how many brands, and the share of each listing's ratings sitting at 1-2 stars). Pass query to search the category, asins to pin named competitors, or both \u2014 named competitors keep their place at the front. You write the read: purchase drivers, barriers, what the incumbents do well, the gaps, and how a new entrant could position; show_amazon_category_insights draws it for free afterwards. Collection runs in the background, so this returns everything ready within waitSeconds plus a scanId to continue with amazon_scan_status. Consumes 3 nooticr credits per listing collected \u2014 a listing with reviews is a real browser render behind Amazon's bot defences.",
|
|
421
|
+
inputSchema: z.object({ query: z.string().optional().describe("Category or keyword to search on Amazon, e.g. 'ashwagandha gummies'."), asins: z.array(z.string()).optional().describe("Named competitors: ASINs or any Amazon product URL. Kept at the front of the set."), limit: z.number().int().optional().describe("Listings to collect (default 10, max 50)."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), domain: z.string().optional().describe("Marketplace host, e.g. www.amazon.co.uk (default www.amazon.com)."), waitSeconds: z.number().int().optional().describe("How long to wait before returning partial results (default 90, max 240)."), focus: z.string().optional().describe("An extra question to answer from the reviews.") }).strict(),
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
name: "amazon_scan_status",
|
|
425
|
+
title: "Amazon Scan Status",
|
|
426
|
+
description: "Pick up a scan_amazon_category collection that was still running, by scanId \u2014 the listings and reviews collected since, with the same rollup and the same instructions for reading them. Free: it is the poll scan_amazon_category asked for, and charging for the second half of one answer would bill a wait this server chose. Call it when a scan came back incomplete, then write your read from the full set.",
|
|
427
|
+
inputSchema: z.object({ scanId: z.string().describe("The scanId scan_amazon_category returned."), waitSeconds: z.number().int().optional().describe("How long to wait for more listings before answering (default 30, max 240)."), focus: z.string().optional().describe("Same optional steer as scan_amazon_category.") }).strict(),
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
name: "get_amazon_product",
|
|
431
|
+
title: "Get Amazon Product",
|
|
432
|
+
description: "Fetch one Amazon listing by ASIN or URL: price, rating, the full star histogram, features, specs, Amazon's review digest and aspect breakdown, and the review text. Use for a single product; for a category or a competitive set, scan_amazon_category collects many in one job and rolls them up. Consumes 3 nooticr credits.",
|
|
433
|
+
inputSchema: z.object({ asin: z.string().optional().describe("ASIN, or a full Amazon product URL."), url: z.string().optional().describe("Full Amazon product URL, if you have that instead."), reviews: z.boolean().optional().describe("Collect review text too (default true)."), domain: z.string().optional().describe("Marketplace host (default www.amazon.com).") }).strict(),
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
name: "show_amazon_category_insights",
|
|
437
|
+
title: "Show Amazon Category Insights",
|
|
438
|
+
description: "Display the category read you produced from scan_amazon_category: purchase drivers, barriers, what each brand does well, the gaps, and the positioning angles \u2014 drawn beside the listings and their reviews, so a person can click a product and check any claim against the text it came from. Free, and makes no requests: it only draws what you pass it, attributed to you rather than presented as a nooticr rating of anyone's product. Call this after you have read the reviews, not instead of reading them.",
|
|
439
|
+
inputSchema: z.object({ category: z.string().describe("The category this is a read of."), summary: z.string().optional(), drivers: z.array(z.any()).optional().describe("What makes someone buy, each with the review evidence behind it."), barriers: z.array(z.any()).optional().describe("What stops them buying, or makes them return it."), strengths: z.array(z.any()).optional().describe("What each incumbent does well."), gaps: z.array(z.any()).optional().describe("What buyers keep asking for that nobody serves."), positioning: z.array(z.any()).optional().describe("Angles a new entrant could take."), products: z.array(z.any()).optional().describe("The listings from the scan, passed straight through."), rollup: z.record(z.unknown()).optional().describe("The rollup from the scan, passed straight through."), scanId: z.string().optional() }).passthrough(),
|
|
440
|
+
},
|
|
417
441
|
];
|
package/dist/shared/tools.js
CHANGED
|
@@ -13,7 +13,7 @@ import { registerPrompts } from "./prompts.js";
|
|
|
13
13
|
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
|
-
import { COMMENT_CATEGORIES, COMMENT_SENTIMENTS, platformFromUrl, reviewGuidance, toEvidence, } from "./comment-review.js";
|
|
16
|
+
import { COMMENT_CATEGORIES, COMMENT_SENTIMENTS, classifyGuidance, platformFromUrl, reviewGuidance, toEvidence, } from "./comment-review.js";
|
|
17
17
|
import { costSentence, EVIDENCE_PLANS, fetchBillingNote, frameIndex, framesToBlocks, planCalls, scoreDraftGuidance, KNOWN_PLATFORMS, platformFailureGuidance, } from "./evidence.js";
|
|
18
18
|
import { evidenceDigest } from "./evidence-digest.js";
|
|
19
19
|
import { confirmSpend, declinedResult, searchMentionsCost, SEARCH_PLATFORMS, DISCOVERABLE_PLATFORMS, } from "./spend.js";
|
|
@@ -25,8 +25,9 @@ import { registerConnectionTools } from "./connections.js";
|
|
|
25
25
|
import { loadingPlansJson } from "./loading-plans.js";
|
|
26
26
|
import { registerHandoff } from "./handoff.js";
|
|
27
27
|
import { registerCollabTools } from "./collab.js";
|
|
28
|
+
import { registerAmazonTools } from "./amazon.js";
|
|
28
29
|
/** Current MCP server version — bumped on every deploy for traceability. */
|
|
29
|
-
export const MCP_SERVER_VERSION = "1.26.
|
|
30
|
+
export const MCP_SERVER_VERSION = "1.26.35";
|
|
30
31
|
/** MCP Apps extension identifier */
|
|
31
32
|
const UI_EXTENSION = "io.modelcontextprotocol/ui";
|
|
32
33
|
/** MIME type for MCP Apps HTML resources */
|
|
@@ -278,7 +279,17 @@ function withSource(result, source, platform) {
|
|
|
278
279
|
return result;
|
|
279
280
|
return { ...result, structuredContent: { ...result.structuredContent, source, platform } };
|
|
280
281
|
}
|
|
281
|
-
|
|
282
|
+
/**
|
|
283
|
+
* `guidance`, when given, is steering this repo adds to a backend passthrough
|
|
284
|
+
* — and it goes into BOTH channels deliberately.
|
|
285
|
+
*
|
|
286
|
+
* Claude Code drops every text block when a result also carries
|
|
287
|
+
* structuredContent, and a host rendering the UI view does the reverse: it
|
|
288
|
+
* hands the model the text and gives structuredContent to the widget. So a
|
|
289
|
+
* guidance string in one place only reaches roughly half the hosts, which is
|
|
290
|
+
* the failure #44/#51 were and the reason chain:map gates on it.
|
|
291
|
+
*/
|
|
292
|
+
async function toToolResult(proxy, guidance) {
|
|
282
293
|
// MCP Apps views (ChatGPT & Claude) render the interactive HTML card, which
|
|
283
294
|
// already embeds all thumbnails/videos. Claude's Apps bridge rejects a tool
|
|
284
295
|
// result that mixes raw `image` blocks with an app view ("could not be
|
|
@@ -304,10 +315,11 @@ async function toToolResult(proxy) {
|
|
|
304
315
|
const textJson = JSON.stringify(forHosts, null, 2);
|
|
305
316
|
// Replace image URLs in HTML with proxied versions
|
|
306
317
|
const proxiedHtml = htmlPrefix ? proxyImageUrlsInHtml(htmlPrefix) : "";
|
|
307
|
-
const
|
|
318
|
+
const body = proxiedHtml ? `${proxiedHtml}\n\n${textJson}` : textJson;
|
|
319
|
+
const text = guidance ? `${guidance}\n\n${body}` : body;
|
|
308
320
|
return {
|
|
309
321
|
content: [{ type: "text", text }],
|
|
310
|
-
structuredContent: forHosts,
|
|
322
|
+
structuredContent: guidance ? { ...forHosts, guidance } : forHosts,
|
|
311
323
|
};
|
|
312
324
|
}
|
|
313
325
|
/** Replace external image/video src, poster and href URLs in HTML with proxied versions. */
|
|
@@ -627,6 +639,12 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
627
639
|
// grant each connection carries is a table, which is what a view is for.
|
|
628
640
|
"list_social_connections",
|
|
629
641
|
"connect_social_account",
|
|
642
|
+
// Marketplace. All four draw: the three collection tools render the
|
|
643
|
+
// listings and their reviews, and the view draws the read on top of them.
|
|
644
|
+
"scan_amazon_category",
|
|
645
|
+
"amazon_scan_status",
|
|
646
|
+
"get_amazon_product",
|
|
647
|
+
"show_amazon_category_insights",
|
|
630
648
|
];
|
|
631
649
|
// Human-readable resource name per tool (used in resources/list + tools/list).
|
|
632
650
|
function resourceName(tool) {
|
|
@@ -706,6 +724,12 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
706
724
|
// LinkedIn
|
|
707
725
|
"https://*.licdn.com",
|
|
708
726
|
"https://*.linkedin.com",
|
|
727
|
+
// Amazon product images. Listings serve from m.media-amazon.com and, on
|
|
728
|
+
// older listings, images-na.ssl-images-amazon.com — a listing whose picture
|
|
729
|
+
// is blocked draws as a brand initial, which is a worse comparison screen
|
|
730
|
+
// than the one the scan paid for.
|
|
731
|
+
"https://*.media-amazon.com",
|
|
732
|
+
"https://*.ssl-images-amazon.com",
|
|
709
733
|
];
|
|
710
734
|
const apiUrl = process.env.NOOTICR_API_URL || process.env.NOOTICR_BASE_URL;
|
|
711
735
|
if (apiUrl && apiUrl.trim()) {
|
|
@@ -2172,7 +2196,15 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
2172
2196
|
return declinedResult(credits, "That sweep");
|
|
2173
2197
|
const client = await makeClient({ ...extra, arguments: args });
|
|
2174
2198
|
try {
|
|
2175
|
-
|
|
2199
|
+
const proxy = await client.callTool("search_mentions", { ...args });
|
|
2200
|
+
// Ask for the labels the view is already built to draw. Everything on the
|
|
2201
|
+
// receiving end has existed for as long as the chips have — the per-row
|
|
2202
|
+
// chip, the counted filter, the click-to-filter — and this sweep is the
|
|
2203
|
+
// most expensive call in the product, so it drew all three empty every
|
|
2204
|
+
// time for want of one instruction (#79).
|
|
2205
|
+
const structured = (proxy.structured ?? {});
|
|
2206
|
+
const groups = Array.isArray(structured.threads) ? structured.threads.length : 0;
|
|
2207
|
+
return await toToolResult(proxy, classifyGuidance(`They are comments naming "${args.term}", grouped under the ${groups} posts they were left on.`, groups));
|
|
2176
2208
|
}
|
|
2177
2209
|
catch (err) {
|
|
2178
2210
|
return toolError("search_mentions failed", err);
|
|
@@ -2351,5 +2383,9 @@ export function createMcpServer(rawMakeClient, opts) {
|
|
|
2351
2383
|
// classified for a tracker on another server, the other draws what it scored.
|
|
2352
2384
|
registerHandoff(server);
|
|
2353
2385
|
registerCollabTools(server);
|
|
2386
|
+
// Marketplace intelligence (amazon.ts): the category collection, the poll
|
|
2387
|
+
// that picks it back up, a single listing, and the free view for the read
|
|
2388
|
+
// the model writes out of them.
|
|
2389
|
+
registerAmazonTools(server, makeClient);
|
|
2354
2390
|
return server;
|
|
2355
2391
|
}
|