@nooticr/mcp 1.26.55 → 1.26.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,7 +52,7 @@ npx @nooticr/mcp login # one-time sign-in (Google)
52
52
 
53
53
  ## Tools
54
54
 
55
- 88 tools, grouped by what you are trying to do. Prices are in nooticr credits and
55
+ 90 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
  The job tools under **Answer a question you actually have** are not
@@ -113,7 +113,7 @@ them caps that fan-out with an argument.
113
113
 
114
114
  | Tool | Credits | What it is for |
115
115
  |------|---------|----------------|
116
- | `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply: nooticr does not post comments on any network, even where a connection's grant allows managing them. `limit` caps how many posts are opened, which is the price. |
116
+ | `answer_my_audience` | 2 + 2 per post opened (14 by default) | **The mirror of `search_mentions`.** The questions waiting under your *own* posts: recent posts fetched, comments read on each, grouped under the post they were left on, every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. It finds and drafts — it cannot post a reply. This tool never sends anything; a reply is posted only if you then ask for it, on your connected account, after you confirm the exact text. `limit` caps how many posts are opened, which is the price. |
117
117
  | `prepare_handoff` | free | **The hand-off to your tracker.** Turns items you classified — a bug report in a comment, a complaint said out loud in a video — into the exact `title`, `body` and `labels` to file through whichever GitHub, Jira or Linear MCP server the host also has connected. nooticr files nothing and holds no tracker credential. The body carries the quote fenced and framed as a third-party report rather than as instructions, because the issue is read next by a coding agent; contact details are redacted, `@handles` and `#numbers` are defanged so filing notifies nobody by accident, and a `searchFirst` string dedupes a second sweep against the first. Makes no requests. |
118
118
  | `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. |
119
119
  | `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. |
@@ -173,6 +173,8 @@ a `scanId` for the rest.
173
173
  |------|---------|----------------|
174
174
  | `nooticr_getting_started` | free | **Start here.** Where this account stands — balance, connected accounts, watchlist — and the next calls worth making, each with its price and an example. The server's `instructions` point every host at it. |
175
175
  | `check_nooticr_credits` | free | Balance and billing URL. |
176
+ | `nooticr_settings_read` | free | Read your default social platform and result count. |
177
+ | `nooticr_settings_update` | free | Save the default platform and result count used by supported searches when arguments are omitted. |
176
178
  | `list_tool_runs` | free | Your tool-call history: what ran, whether it worked, and the credits each call actually took (0 for a free, refunded, cached or replayed call). `scope: "workspace"` shows every member's runs to owners and admins. |
177
179
  | `get_tool_run` | free | One run from that history by id, with its full error. |
178
180
  | `get_usage_report` | free | The run ledger added up over a window: total calls, failures and credits actually taken, per tool, per day and the most recent failures. `scope: "workspace"` adds one row per seat, for the workspace's owners and admins. |
@@ -636,6 +638,10 @@ and text** posts.
636
638
  See [docs/SUBMISSION.md](docs/SUBMISSION.md) for ready-to-paste configs and the submission
637
639
  package for **Claude Desktop**, **Cursor**, and **OpenAI Agents SDK**.
638
640
 
641
+ The official MCP registry entry `com.nooticr/mcp` must equal `server.json`'s version after each
642
+ release; see [docs/registry-publishing.md](docs/registry-publishing.md) for the drift check
643
+ (`npm run registry:check`) and the publish script (`npm run registry:publish`).
644
+
639
645
  ## Security notes
640
646
 
641
647
  - The credentials file is written with mode `0600` and the directory with
package/dist/index.js CHANGED
@@ -414,6 +414,23 @@ export async function runApiKey(opts) {
414
414
  "the key it returns is what every run afterwards uses.");
415
415
  }
416
416
  const client = new NooticrClient(baseUrl, createManagementTokenProvider(auth));
417
+ // Who, and where. A key is scoped to one account on one server, and both
418
+ // are easy to get wrong without noticing: the credentials file holds
419
+ // whoever last ran `login` on this machine, and NOOTICR_BASE_URL defaults
420
+ // to production. A key minted against one pair and looked for in another
421
+ // is simply absent, with nothing anywhere saying why — which is exactly how
422
+ // this was first reported.
423
+ let account = "an unknown account";
424
+ try {
425
+ const me = await client.me();
426
+ if (me?.email)
427
+ account = me.email;
428
+ }
429
+ catch {
430
+ // Not worth failing the command over; the operation below will fail with
431
+ // a better message if the credential is genuinely bad.
432
+ }
433
+ const scope = `${account} on ${baseUrl}`;
417
434
  if (opts.action === "create") {
418
435
  const created = await client.createApiKey({
419
436
  ...(opts.name !== undefined ? { name: opts.name } : {}),
@@ -424,7 +441,7 @@ export async function runApiKey(opts) {
424
441
  process.stdout.write(`${JSON.stringify(created, null, 2)}\n`);
425
442
  return;
426
443
  }
427
- process.stdout.write(`Created API key "${created.name}" (${created.id}).\n\n` +
444
+ process.stdout.write(`Created API key "${created.name}" (${created.id}) for ${scope}.\n\n` +
428
445
  ` ${created.key}\n\n` +
429
446
  "This is the only time the key is shown — nooticr stores a hash of it and\n" +
430
447
  "cannot show it again. Store it wherever this deployment keeps its secrets,\n" +
@@ -441,10 +458,11 @@ export async function runApiKey(opts) {
441
458
  return;
442
459
  }
443
460
  if (keys.length === 0) {
444
- process.stdout.write("No API keys. Create one with `npx nooticr-mcp api-key create --name <name>`.\n");
461
+ process.stdout.write(`No API keys for ${scope}.\n` +
462
+ "Create one with `npx nooticr-mcp api-key create --name <name>`.\n");
445
463
  return;
446
464
  }
447
- process.stdout.write(`${keys.map(describeKey).join("\n\n")}\n`);
465
+ process.stdout.write(`Keys for ${scope}:\n\n${keys.map(describeKey).join("\n\n")}\n`);
448
466
  return;
449
467
  }
450
468
  if (!opts.id) {
@@ -455,7 +473,7 @@ export async function runApiKey(opts) {
455
473
  process.stdout.write(`${JSON.stringify({ id: opts.id, revoked: true }, null, 2)}\n`);
456
474
  return;
457
475
  }
458
- process.stdout.write(`Revoked ${opts.id}. Any server still presenting it now gets a 401.\n`);
476
+ process.stdout.write(`Revoked ${opts.id} for ${scope}. Any server still presenting it now gets a 401.\n`);
459
477
  }
460
478
  // ---------------------------------------------------------------------------
461
479
  // CLI
@@ -177,7 +177,15 @@ export function categoryGuidance(opts) {
177
177
  // commonest reason is a query in a language that storefront does not sell
178
178
  // in. Said here rather than only in the tool description because by the time
179
179
  // this is read the description is long out of the model's attention.
180
- if (opts.products === 0 && opts.complete) {
180
+ if (opts.status === "degraded") {
181
+ lines.push(`DEGRADED SCAN: ${site} could not be read properly right now, so this is not a finding ` +
182
+ `about the category and not evidence that nothing sells there. ` +
183
+ (opts.notice
184
+ ? `Tell the user this, verbatim: "${opts.notice}". `
185
+ : `Tell the user the site is currently unavailable. `) +
186
+ `Do not retry with other queries and do not call the category empty.`);
187
+ }
188
+ else if (opts.products === 0 && opts.complete) {
181
189
  lines.push(`No listings came back. This is not a finding about the category — it is a search that ` +
182
190
  `matched nothing. Before reporting it: check the query is in the language that ` +
183
191
  `storefront sells in, try the common shopper's word for the thing rather than a ` +
@@ -190,6 +198,10 @@ export function categoryGuidance(opts) {
190
198
  // caveat changes what it writes.
191
199
  if (opts.freshness)
192
200
  lines.push(opts.freshness);
201
+ if (opts.siteStatus && opts.status !== "degraded") {
202
+ lines.push(`Caveat: the ${site} collector is currently ${opts.siteStatus}, so this data may be ` +
203
+ `partial. Say so when you report it.`);
204
+ }
193
205
  if (polling) {
194
206
  lines.push(`Stop polling only if ${statusTool} reports status "failed", or returns the same number of ` +
195
207
  `listings three calls in a row. Then write the read from what was collected and say plainly ` +
@@ -20,5 +20,9 @@ export const BACKEND_ONLY_TOOLS = {
20
20
  schedule_post: "Rendering and publishing stay in the dashboard (#92).",
21
21
  get_batch_history: "History of the dashboard's compose batches, which only exist once rendering does; stays with #92.",
22
22
  get_latest_analysis: "Returns the dashboard's stored AI fleet-analysis verdict; the public surface sells the evidence, not a judgement of ours.",
23
+ propose_listing_update: "Commerce writes (nooticr-server C1 3/4) stay in the dashboard until exposing store writes to hosts is a product decision. connect_store and list_store_products, the C1 read pair, are undecided and want settling together with these.",
24
+ apply_listing_update: "Commerce writes (nooticr-server C1 3/4) stay in the dashboard until exposing store writes to hosts is a product decision; applying a change edits the merchant's live store.",
25
+ propose_price_change: "Commerce writes (nooticr-server C1 3/4) stay in the dashboard until exposing store writes to hosts is a product decision.",
26
+ apply_price_change: "Commerce writes (nooticr-server C1 3/4) stay in the dashboard until exposing store writes to hosts is a product decision; applying a change edits the merchant's live prices.",
23
27
  brand_watch_history: "Not a deliberate exclusion: a candidate for the public surface once it has a trend view to draw. Listed so it reads as pending, not forgotten.",
24
28
  };
@@ -68,20 +68,27 @@ export function registerConnectionTools(server, makeClient) {
68
68
  "or product — omit both and this picks the only one if there is just one, or lists the " +
69
69
  "choices if there is more than one. The user approves at the provider; nothing is " +
70
70
  "connected until they do, and no credential ever passes through this tool. Each call " +
71
- "mints a fresh link, so do not reuse an old one. No cost to call.",
71
+ "mints a fresh link, so do not reuse an old one. For YouTube, pass capabilities " +
72
+ "[\"manage_comments\"] to also ask for the comment-management grant. No cost to call.",
72
73
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
73
74
  inputSchema: z
74
75
  .object({
75
76
  platform: z.string().describe("Which network to connect, e.g. tiktok, youtube."),
76
77
  influencerId: z.number().int().optional().describe("Which creator profile — call list_own_apps or list_social_connections first if unsure."),
77
78
  appId: z.number().int().optional().describe("Which product this connection is for."),
79
+ capabilities: z
80
+ .array(z.enum(["manage_comments"]))
81
+ .optional()
82
+ .describe("Extra access to ask for. YouTube only: [\"manage_comments\"] asks for the comment-management grant. Omit for the default."),
78
83
  })
79
84
  .strict(),
80
85
  outputSchema: OUTPUT_SCHEMAS.connect_social_account,
81
86
  }, async (args, extra) => {
82
87
  const client = await makeClient({ ...extra, arguments: args });
83
88
  try {
84
- return toResult(await client.callTool("connect_social_account", args));
89
+ // Forward capabilities only when supplied, so a plain call is unchanged.
90
+ const { capabilities, ...rest } = args;
91
+ return toResult(await client.callTool("connect_social_account", capabilities ? { ...rest, capabilities } : rest));
85
92
  }
86
93
  catch (err) {
87
94
  return failed("connect_social_account failed", err);
@@ -320,9 +320,8 @@ function audienceGuidance(a) {
320
320
  "ones asking the same thing: a single pinned reply beats twenty individual ones, and a " +
321
321
  "question that recurs across posts is a video rather than a reply.",
322
322
  "",
323
- "You are drafting, not sending. nooticr does not post comments on any network — even a " +
324
- "connection whose grant allows managing comments is never used to send one — so these are " +
325
- "for the creator to paste in themselves. Say that rather than implying the replies will go out.",
323
+ "You are drafting, not sending. this tool cannot post: nothing is sent from here, and a reply is posted only " +
324
+ "after the creator confirms its exact text. Say that rather than implying the replies will go out.",
326
325
  "",
327
326
  "Do not invent facts about the product, the price or the creator's plans to fill a gap. If a " +
328
327
  "comment cannot be answered from what is here, say what you would need.",
@@ -633,9 +632,8 @@ export function registerJobTools(server, makeClient, store) {
633
632
  "reads the comments on each, and returns them grouped under the post they were left on — " +
634
633
  "every comment with a stable id, and the ones that look like questions or requests " +
635
634
  "flagged and sorted to the top. This FINDS and helps you DRAFT answers; it cannot post " +
636
- "them: nooticr does not post replies on any network, even where list_social_connections " +
637
- "reports that a connection can manage comments, so the replies are for a person to paste " +
638
- "in themselves — never promise the user they will be sent. `since` filters on the POST's date, not the comments'. `limit` caps how many " +
635
+ "them: this tool sends nothing, and a reply is only ever posted after the user confirms its " +
636
+ "exact text — never promise the user a reply will go out on its own. `since` filters on the POST's date, not the comments'. `limit` caps how many " +
639
637
  "posts are opened. Pair it with show_audience_replies to lay the drafts out for triage. " +
640
638
  "Costs 2 nooticr credits for the post list plus 2 per post opened — 14 credits at the " +
641
639
  "default of 6 posts. Use when the job is to answer people; search_mentions is for what " +
@@ -1631,8 +1629,8 @@ export function registerJobTools(server, makeClient, store) {
1631
1629
  _meta: viewMeta("show_audience_replies"),
1632
1630
  description: "Lay out the replies you drafted from answer_my_audience so a person can read them. " +
1633
1631
  "Free, and makes no requests — it only draws what you pass it. It does NOT send " +
1634
- "anything: nooticr does not post comments, whatever a connection allows, so each row is a draft for the " +
1635
- "creator to copy into the app themselves. Each comment shows with what you decided to do " +
1632
+ "anything: each row is a draft, and a reply is posted only after the creator confirms its " +
1633
+ "exact text. Each comment shows with what you decided to do " +
1636
1634
  "about it and your draft underneath, grouped under its post, so they can work through " +
1637
1635
  "them one at a time and skip the rest. Call this after you have drafted the replies, not " +
1638
1636
  "instead of drafting them.",
@@ -1660,7 +1658,7 @@ export function registerJobTools(server, makeClient, store) {
1660
1658
  draft: z
1661
1659
  .string()
1662
1660
  .optional()
1663
- .describe("The reply, in the creator's voice, for them to paste in themselves."),
1661
+ .describe("The reply, in the creator's voice. It is only a draft: nothing is posted until the creator confirms its exact text."),
1664
1662
  kind: z
1665
1663
  .enum(["answer", "pin", "make_a_video", "escalate", "ignore"])
1666
1664
  .optional()
@@ -67,6 +67,8 @@ const CREATOR_UNIT = {
67
67
  * generic grey box that said "Working".
68
68
  */
69
69
  export const LOADING_PLANS = {
70
+ nooticr_settings_read: { label: "Reading your research defaults", kind: "text", n: 0, steps: [], free: true },
71
+ nooticr_settings_update: { label: "Saving your research defaults", kind: "text", n: 0, steps: [], free: true },
70
72
  // ─── Read a post ───
71
73
  get_social_media: { label: "Fetching the post", kind: "post", n: 1, steps: [step("get_social_media")] },
72
74
  get_post_transcript: {
@@ -164,6 +164,17 @@ function toolError(what, err) {
164
164
  return { isError: true, content: [{ type: "text", text: `${what}: ${message}` }] };
165
165
  }
166
166
  const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
167
+ /** `siteStatus` is `{status, since, note}` from the server; a bare string is accepted too. */
168
+ function siteStatusName(v) {
169
+ if (typeof v === "string")
170
+ return v;
171
+ if (v && typeof v === "object") {
172
+ const status = v.status;
173
+ if (typeof status === "string")
174
+ return status;
175
+ }
176
+ return undefined;
177
+ }
167
178
  /**
168
179
  * The scan, in the shape the view and the digest read.
169
180
  *
@@ -172,7 +183,7 @@ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
172
183
  * `market`, because each collector calls it something different and a caller
173
184
  * should not have to know which.
174
185
  */
175
- function marketplaceResult(payload, m, focus) {
186
+ export function marketplaceResult(payload, m, focus) {
176
187
  const products = normaliseProducts(payload.products);
177
188
  const rollup = (payload.rollup ?? {});
178
189
  const reviews = allReviews(products);
@@ -198,6 +209,9 @@ function marketplaceResult(payload, m, focus) {
198
209
  statusTool: "marketplace_scan_status",
199
210
  statusArgs: { marketplace: m.slug },
200
211
  status: typeof payload.status === "string" ? payload.status : undefined,
212
+ notice: typeof payload.notice === "string" ? payload.notice : undefined,
213
+ // The server sends `{status, since, note}`; a bare string is accepted too.
214
+ siteStatus: siteStatusName(payload.siteStatus),
201
215
  total: num(progress.total) ?? undefined,
202
216
  // Its own view, labelled with this site (#95): pointing a model at
203
217
  // show_amazon_category_insights would have it draw a Lazada scan as an
@@ -226,6 +240,11 @@ function marketplaceResult(payload, m, focus) {
226
240
  reviews,
227
241
  errors: payload.errors ?? [],
228
242
  mcpCredits: payload.mcpCredits ?? null,
243
+ // Degraded-scan fields (nooticr-server#239); absent on an older server.
244
+ notice: payload.notice ?? null,
245
+ quality: payload.quality ?? null,
246
+ collectorStatus: payload.collectorStatus ?? null,
247
+ siteStatus: payload.siteStatus ?? null,
229
248
  };
230
249
  return {
231
250
  content: [{ type: "text", text: pollTail(withEvidence(guidance, structuredPayload), structuredPayload.nextCall) }],
@@ -640,8 +640,8 @@ export const OUTPUT_SCHEMAS = {
640
640
  since: scalar().describe("The date window asked for, if any."),
641
641
  sinceApplied: scalar().describe("False when the platform returned no dates, so the window could not be honoured."),
642
642
  postsChecked: scalar().describe("Posts whose comments were actually read."),
643
- repliesCanBeSent: scalar().describe("Always false. nooticr does not post replies, even where list_social_connections reports a " +
644
- "connection can manage comments, so the drafts this produces are for a person to paste in themselves."),
643
+ repliesCanBeSent: scalar().describe("Always false. This tool only drafts and never sends; a reply is posted only by a separate step, " +
644
+ "on the user's connected account, after the user confirms the exact text."),
645
645
  totalMentions: scalar().describe("Comments returned across every group."),
646
646
  totalThreads: scalar(),
647
647
  wantsReplyCount: scalar().describe("How many carry a reply signal. A sort, not a verdict."),
@@ -0,0 +1,69 @@
1
+ export const SETTINGS_READ_TOOL = "nooticr_settings_read";
2
+ export const SETTINGS_UPDATE_TOOL = "nooticr_settings_update";
3
+ export const DEFAULT_SOCIAL_PLATFORMS = [
4
+ "tiktok",
5
+ "instagram",
6
+ "youtube",
7
+ "twitter",
8
+ "reddit",
9
+ "douyin",
10
+ "xiaohongshu",
11
+ "weibo",
12
+ "bilibili",
13
+ ];
14
+ export const DEFAULT_NOOTICR_SETTINGS = {
15
+ defaultSocialPlatform: "tiktok",
16
+ // Keep existing tool behaviour until the user chooses another count.
17
+ defaultResultCount: 12,
18
+ };
19
+ export class MemorySettingsStore {
20
+ values = new Map();
21
+ async get(owner) {
22
+ const value = this.values.get(owner);
23
+ return value ? { ...value } : undefined;
24
+ }
25
+ async put(owner, value) {
26
+ this.values.set(owner, { ...value });
27
+ }
28
+ }
29
+ /** Durable account-scoped preferences for the Cloudflare-hosted MCP server. */
30
+ export class KvSettingsStore {
31
+ kv;
32
+ constructor(kv) {
33
+ this.kv = kv;
34
+ }
35
+ key(owner) {
36
+ return `plugin-settings:${owner}`;
37
+ }
38
+ async get(owner) {
39
+ const raw = await this.kv.get(this.key(owner));
40
+ if (!raw)
41
+ return undefined;
42
+ try {
43
+ const parsed = JSON.parse(raw);
44
+ const platform = DEFAULT_SOCIAL_PLATFORMS.find((candidate) => candidate === parsed.defaultSocialPlatform);
45
+ const count = parsed.defaultResultCount;
46
+ if (!platform || !Number.isInteger(count) || count < 3 || count > 12)
47
+ return undefined;
48
+ return { defaultSocialPlatform: platform, defaultResultCount: count };
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ async put(owner, value) {
55
+ await this.kv.put(this.key(owner), JSON.stringify(value));
56
+ }
57
+ }
58
+ /** Stable owner keys prefer the authenticated Nooticr account ID. */
59
+ export async function settingsOwnerFromAuth(authInfo, resolveAccountId) {
60
+ const token = authInfo?.token ?? "";
61
+ const accountId = token && resolveAccountId ? await resolveAccountId(token) : undefined;
62
+ if (accountId)
63
+ return `account:${accountId}`;
64
+ if (!token)
65
+ return "local";
66
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
67
+ const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
68
+ return `credential:${hex}`;
69
+ }
@@ -399,13 +399,13 @@ export const TOOL_DEFINITIONS = [
399
399
  {
400
400
  name: "connect_social_account",
401
401
  title: "Connect Social Account",
402
- description: "Get a link to open so you can connect one social account. The user approves at the provider; nothing is connected until they do, and no credential ever passes through this tool. No cost to call.",
403
- inputSchema: z.object({ platform: z.string().describe("Which network to connect."), influencerId: z.number().int().optional(), appId: z.number().int().optional() }).strict(),
402
+ description: "Get a link to open so you can connect one social account. The user approves at the provider; nothing is connected until they do, and no credential ever passes through this tool. For YouTube, pass capabilities [\"manage_comments\"] to also ask for the comment-management grant. No cost to call.",
403
+ inputSchema: z.object({ platform: z.string().describe("Which network to connect."), influencerId: z.number().int().optional(), appId: z.number().int().optional(), capabilities: z.array(z.enum(["manage_comments"])).optional().describe("YouTube only: [\"manage_comments\"] asks for the comment-management grant.") }).strict(),
404
404
  },
405
405
  {
406
406
  name: "answer_my_audience",
407
407
  title: "Answer My Audience",
408
- description: "The questions waiting for you under your own posts. Fetches a creator's recent posts, reads the comments on each, and returns them grouped under the post they were left on \u2014 every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. Finds and drafts; it cannot post a reply, because nooticr does not post comments whatever a connection allows \u2014 the drafts are for a person to paste in themselves. Use when the job is to answer your own audience rather than to read about strangers. Consumes 2 nooticr credits for the post list plus 2 per post opened \u2014 14 credits at the default of 6 posts.",
408
+ description: "The questions waiting for you under your own posts. Fetches a creator's recent posts, reads the comments on each, and returns them grouped under the post they were left on \u2014 every comment with a stable id, and the ones that read like questions or requests flagged and sorted to the top. Finds and drafts; this tool cannot post a reply \u2014 a reply is posted only after the user confirms its exact text. Use when the job is to answer your own audience rather than to read about strangers. Consumes 2 nooticr credits for the post list plus 2 per post opened \u2014 14 credits at the default of 6 posts.",
409
409
  inputSchema: z.object({ username: z.string().describe("Your handle, with or without @."), platform: z.string().optional().describe("Platform (default tiktok)."), limit: z.number().int().optional().describe("Posts to open (default 6, max 12). Each one is a comment fetch, so this is the price."), commentsPerPost: z.number().int().optional().describe("Comments to read per post (default 20, max 50)."), since: z.string().optional().describe("Only posts published on or after this date, as YYYY-MM-DD. Windows the posts, not the comments.") }).strict(),
410
410
  },
411
411
  {
@@ -528,4 +528,16 @@ export const TOOL_DEFINITIONS = [
528
528
  description: "Display the category read you produced from scan_marketplace_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. Pass the scanId: the listings are then re-read from the scan itself (free) rather than taken from you, and any ASIN you cite that the scan does not contain is flagged. Without a scanId, listings you pass are drawn marked as not checked against a scan. Free. Your read is 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.",
529
529
  inputSchema: z.object({ marketplace: z.string().describe("The site the scan ran on, e.g. lazada."), 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(),
530
530
  },
531
+ {
532
+ name: "nooticr_settings_read",
533
+ title: "Read Nooticr Settings",
534
+ description: "Read the current default social platform and result count for this Nooticr account. Free; no credits are used.",
535
+ inputSchema: z.object({}).strict(),
536
+ },
537
+ {
538
+ name: "nooticr_settings_update",
539
+ title: "Update Nooticr Settings",
540
+ description: "Save changed default social platform or result count preferences for this Nooticr account. Free; no credits are used.",
541
+ inputSchema: z.object({ set: z.object({ defaultSocialPlatform: z.enum(["tiktok", "instagram", "youtube", "twitter", "reddit", "douyin", "xiaohongshu", "weibo", "bilibili"]).optional(), defaultResultCount: z.number().int().min(3).max(12).optional() }).strict() }).strict(),
542
+ },
531
543
  ];
@@ -29,8 +29,9 @@ import { registerHandoff } from "./handoff.js";
29
29
  import { registerCollabTools } from "./collab.js";
30
30
  import { registerAmazonTools } from "./amazon.js";
31
31
  import { registerMarketplaceTools } from "./marketplace.js";
32
+ import { DEFAULT_NOOTICR_SETTINGS, DEFAULT_SOCIAL_PLATFORMS, MemorySettingsStore, SETTINGS_READ_TOOL, SETTINGS_UPDATE_TOOL, settingsOwnerFromAuth, } from "./settings.js";
32
33
  /** Current MCP server version — bumped on every deploy for traceability. */
33
- export const MCP_SERVER_VERSION = "1.26.55";
34
+ export const MCP_SERVER_VERSION = "1.26.57";
34
35
  /** MCP Apps extension identifier */
35
36
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
36
37
  /** MIME type for MCP Apps HTML resources */
@@ -559,6 +560,10 @@ export function createMcpServer(rawMakeClient, opts) {
559
560
  prompts: {},
560
561
  extensions: {
561
562
  [UI_EXTENSION]: { mimeTypes: [RESOURCE_MIME_TYPE] },
563
+ "openai/settings": {
564
+ readTool: SETTINGS_READ_TOOL,
565
+ updateTool: SETTINGS_UPDATE_TOOL,
566
+ },
562
567
  },
563
568
  },
564
569
  });
@@ -567,13 +572,110 @@ export function createMcpServer(rawMakeClient, opts) {
567
572
  // model's copy of it (#107). Wrapped once here rather than in eighty
568
573
  // handlers; registerSlowTool records the task path the same way.
569
574
  const ledger = ledgerFor(server);
575
+ const settingsStore = opts?.settingsStore ?? new MemorySettingsStore();
576
+ const settingsForCall = async (extra) => {
577
+ const owner = opts?.resolveSettingsOwner
578
+ ? await opts.resolveSettingsOwner(extra?.authInfo)
579
+ : await settingsOwnerFromAuth(extra?.authInfo);
580
+ return { owner, settings: (await settingsStore.get(owner)) ?? DEFAULT_NOOTICR_SETTINGS };
581
+ };
582
+ // Only apply defaults to search/research tools where these settings describe
583
+ // the omitted arguments. Explicit values always win; cost-bearing deeper
584
+ // reads such as comment counts and monitor sweeps are deliberately excluded.
585
+ const platformDefaults = {
586
+ discover_social_posts: DEFAULT_SOCIAL_PLATFORMS,
587
+ get_user_posts: DEFAULT_SOCIAL_PLATFORMS,
588
+ analyze_creator_profile: DEFAULT_SOCIAL_PLATFORMS,
589
+ search_creators: ["tiktok", "instagram", "xiaohongshu"],
590
+ get_similar_creators: ["tiktok", "instagram"],
591
+ discover_sounds: ["tiktok", "instagram"],
592
+ niche_report: DEFAULT_SOCIAL_PLATFORMS,
593
+ find_hook_pattern: DEFAULT_SOCIAL_PLATFORMS,
594
+ };
595
+ const resultCountFields = {
596
+ discover_social_posts: "limit",
597
+ get_user_posts: "limit",
598
+ analyze_creator_profile: "limit",
599
+ search_creators: "count",
600
+ discover_sounds: "count",
601
+ niche_report: "count",
602
+ find_hook_pattern: "limit",
603
+ };
570
604
  const registerTool = server.registerTool.bind(server);
571
605
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
572
606
  server.registerTool = (name, config, handler) => registerTool(name, config, async (...a) => {
573
- const result = (await handler(...a));
607
+ const [rawArgs, rawExtra, ...rest] = a;
608
+ let callArgs = a;
609
+ if (rawArgs &&
610
+ typeof rawArgs === "object" &&
611
+ !Array.isArray(rawArgs) &&
612
+ (platformDefaults[name] || resultCountFields[name])) {
613
+ const args = { ...rawArgs };
614
+ const extra = rawExtra;
615
+ try {
616
+ const { settings } = await settingsForCall(extra);
617
+ const allowed = platformDefaults[name];
618
+ if (allowed && args.platform === undefined && allowed.includes(settings.defaultSocialPlatform)) {
619
+ args.platform = settings.defaultSocialPlatform;
620
+ }
621
+ const resultField = resultCountFields[name];
622
+ if (resultField && args[resultField] === undefined)
623
+ args[resultField] = settings.defaultResultCount;
624
+ callArgs = [args, rawExtra, ...rest];
625
+ }
626
+ catch (err) {
627
+ if (name === SETTINGS_READ_TOOL || name === SETTINGS_UPDATE_TOOL)
628
+ throw err;
629
+ // A preferences-store outage must not take down ordinary MCP tools.
630
+ }
631
+ }
632
+ const result = (await handler(...callArgs));
633
+ // Login recovery promises to retry what the caller originally supplied.
634
+ // The settings layer's effective defaults belong to this attempt only.
635
+ if (result?.isError && pendingAfterLogin?.name === name && rawArgs && typeof rawArgs === "object") {
636
+ pendingAfterLogin = { name, args: rawArgs };
637
+ }
574
638
  ledger.record(name, result?.structuredContent);
575
639
  return result;
576
640
  });
641
+ const settingsSchema = {
642
+ type: "object",
643
+ properties: {
644
+ defaultSocialPlatform: {
645
+ type: "string",
646
+ title: "Default social platform",
647
+ description: "Used when a supported social search omits its platform. Explicit tool arguments take precedence.",
648
+ enum: [...DEFAULT_SOCIAL_PLATFORMS],
649
+ },
650
+ defaultResultCount: {
651
+ type: "integer",
652
+ title: "Default result count",
653
+ description: "Used when supported search tools omit their result limit. Choose 3–12 results.",
654
+ minimum: 3,
655
+ maximum: 12,
656
+ },
657
+ },
658
+ required: ["defaultSocialPlatform", "defaultResultCount"],
659
+ };
660
+ const settingPropertySchema = z.object({
661
+ type: z.enum(["string", "integer"]),
662
+ title: z.string(),
663
+ description: z.string(),
664
+ enum: z.array(z.string()).optional(),
665
+ minimum: z.number().optional(),
666
+ maximum: z.number().optional(),
667
+ }).strict();
668
+ const settingsOutput = z.object({
669
+ schema: z.object({
670
+ type: z.literal("object"),
671
+ properties: z.object({
672
+ defaultSocialPlatform: settingPropertySchema,
673
+ defaultResultCount: settingPropertySchema,
674
+ }).strict(),
675
+ required: z.array(z.string()),
676
+ }).strict(),
677
+ values: z.object({ defaultSocialPlatform: z.string(), defaultResultCount: z.number().int() }),
678
+ });
577
679
  // Register one UI app resource per tool/view. Claude/ChatGPT render a
578
680
  // separate sandboxed app per resourceUri and key app state by it, so a
579
681
  // distinct URI per tool avoids a shared app instance/session colliding
@@ -984,12 +1086,12 @@ export function createMcpServer(rawMakeClient, opts) {
984
1086
  .object({
985
1087
  niche: z.string().describe("Niche/topic, e.g. 'fitness'."),
986
1088
  keywords: z.string().optional().describe("Optional extra keywords."),
987
- limit: z.number().int().optional().describe("Max results (default 6)."),
1089
+ limit: z.number().int().optional().describe("Max results (default 6, or your Settings result count when omitted)."),
988
1090
  offset: z.number().int().optional().describe("Skip first N results — for 'next' pagination."),
989
1091
  platform: z
990
1092
  .enum(["youtube", "tiktok", "instagram", "douyin", "xiaohongshu", "twitter", "bilibili", "reddit", "weibo", "any"])
991
1093
  .optional()
992
- .describe("Platform to search (default youtube)."),
1094
+ .describe("Platform to search (your Settings default when omitted; otherwise youtube)."),
993
1095
  })
994
1096
  .strict(),
995
1097
  }, async (args, extra) => {
@@ -1024,8 +1126,8 @@ export function createMcpServer(rawMakeClient, opts) {
1024
1126
  platform: z
1025
1127
  .enum(["tiktok", "instagram", "youtube", "douyin", "xiaohongshu", "twitter", "bilibili", "linkedin", "reddit", "weibo"])
1026
1128
  .optional()
1027
- .describe("Which platform (default tiktok)."),
1028
- limit: z.number().int().optional().describe("Max posts (default 6)."),
1129
+ .describe("Which platform (your Settings default when supported, otherwise tiktok)."),
1130
+ limit: z.number().int().optional().describe("Max posts (default 6, or your Settings result count when omitted)."),
1029
1131
  })
1030
1132
  .strict(),
1031
1133
  }, async (args, extra) => {
@@ -1064,7 +1166,7 @@ export function createMcpServer(rawMakeClient, opts) {
1064
1166
  platform: z
1065
1167
  .enum(["tiktok", "instagram", "youtube", "douyin", "xiaohongshu", "twitter", "bilibili", "linkedin", "reddit", "weibo"])
1066
1168
  .optional()
1067
- .describe("Which platform (default tiktok)."),
1169
+ .describe("Which platform (your Settings default when supported, otherwise tiktok)."),
1068
1170
  limit: z.number().int().optional().describe("Posts to fetch (default 12, max 30)."),
1069
1171
  focus: z.string().optional().describe("Extra instruction for the profile synthesis."),
1070
1172
  })
@@ -1139,8 +1241,8 @@ export function createMcpServer(rawMakeClient, opts) {
1139
1241
  // fields at all, so advertising them only spends a paid call to fail.
1140
1242
  .enum(["tiktok", "instagram", "xiaohongshu"])
1141
1243
  .optional()
1142
- .describe("Which platform (default tiktok)."),
1143
- count: z.number().int().optional().describe("Max creators (default 8)."),
1244
+ .describe("Which platform (your Settings default when supported, otherwise tiktok)."),
1245
+ count: z.number().int().optional().describe("Max creators (default 8, or your Settings result count when omitted)."),
1144
1246
  })
1145
1247
  .strict(),
1146
1248
  }, async (args, extra) => {
@@ -1244,8 +1346,8 @@ export function createMcpServer(rawMakeClient, opts) {
1244
1346
  inputSchema: z
1245
1347
  .object({
1246
1348
  keyword: z.string().describe("Niche/keyword, e.g. 'gym'."),
1247
- platform: z.enum(["tiktok", "instagram"]).optional().describe("Which platform (default tiktok)."),
1248
- count: z.number().int().optional().describe("Max sounds (default 6)."),
1349
+ platform: z.enum(["tiktok", "instagram"]).optional().describe("Which platform (your Settings default when supported, otherwise tiktok)."),
1350
+ count: z.number().int().optional().describe("Max sounds (default 6, or your Settings result count when omitted)."),
1249
1351
  })
1250
1352
  .strict(),
1251
1353
  }, async (args, extra) => {
@@ -2374,7 +2476,7 @@ export function createMcpServer(rawMakeClient, opts) {
2374
2476
  .object({
2375
2477
  niche: z.string().describe("Niche or topic, e.g. 'home fitness'."),
2376
2478
  platform: z.string().optional().describe("Platform to survey (default tiktok)."),
2377
- count: z.number().int().optional().describe("Posts to survey (default 12, max 40)."),
2479
+ count: z.number().int().optional().describe("Posts to survey (default 12, or your Settings result count when omitted; max 40)."),
2378
2480
  })
2379
2481
  .strict(),
2380
2482
  }, async (args, extra) => {
@@ -2406,7 +2508,7 @@ export function createMcpServer(rawMakeClient, opts) {
2406
2508
  .object({
2407
2509
  username: z.string().describe("Creator handle, with or without @."),
2408
2510
  platform: z.string().optional().describe("Platform (default tiktok)."),
2409
- limit: z.number().int().optional().describe("Posts to read (default 12, max 40)."),
2511
+ limit: z.number().int().optional().describe("Posts to read (default 12, or your Settings result count when omitted; max 40)."),
2410
2512
  })
2411
2513
  .strict(),
2412
2514
  }, async (args, extra) => {
@@ -2798,5 +2900,41 @@ export function createMcpServer(rawMakeClient, opts) {
2798
2900
  // and the generic one cannot, so the two descriptions are not the same
2799
2901
  // promise — not because the question differs.
2800
2902
  registerMarketplaceTools(server, makeClient);
2903
+ // Register the settings controls after the existing tool surface so adding
2904
+ // them does not reorder tools that hosts already know about. These settings
2905
+ // tools have no app view; the host renders them in its native settings UI.
2906
+ server.registerTool(SETTINGS_READ_TOOL, {
2907
+ title: "Read Nooticr Settings",
2908
+ description: "Read the current default social platform and result count for this Nooticr account. Free; no credits are used.",
2909
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2910
+ inputSchema: z.object({}).strict(),
2911
+ outputSchema: settingsOutput,
2912
+ }, async (_args, extra) => {
2913
+ const { settings } = await settingsForCall(extra);
2914
+ return {
2915
+ content: [],
2916
+ structuredContent: {
2917
+ schema: settingsSchema,
2918
+ values: settings,
2919
+ },
2920
+ };
2921
+ });
2922
+ server.registerTool(SETTINGS_UPDATE_TOOL, {
2923
+ title: "Update Nooticr Settings",
2924
+ description: "Save changed Nooticr research defaults for this account. Free; no credits are used.",
2925
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2926
+ inputSchema: z.object({
2927
+ set: z.object({
2928
+ defaultSocialPlatform: z.enum(DEFAULT_SOCIAL_PLATFORMS).optional(),
2929
+ defaultResultCount: z.number().int().min(3).max(12).optional(),
2930
+ }).strict().refine((set) => Object.keys(set).length > 0, "At least one setting must change."),
2931
+ }).strict(),
2932
+ outputSchema: z.object({ values: settingsOutput.shape.values }),
2933
+ }, async ({ set }, extra) => {
2934
+ const { owner, settings } = await settingsForCall(extra);
2935
+ const updated = { ...settings, ...set };
2936
+ await settingsStore.put(owner, updated);
2937
+ return { content: [], structuredContent: { values: updated } };
2938
+ });
2801
2939
  return server;
2802
2940
  }
@@ -4411,7 +4411,7 @@ export const NOOTICR_UI_TEMPLATE = `<!DOCTYPE html>
4411
4411
  +'<div class="nt-commerce-chiprow">'+readOnly.map(function(k){
4412
4412
  return '<span class="nt-tag nt-commerce-nettag tag">'+glyph(k,12)+esc(pTitle(k))+"</span>";}).join("")+"</div>"
4413
4413
  +'<p class="nt-commerce-conn-sub" style="margin-top:8px">So "monitor my brand on Weibo" works and "reply to that Weibo comment" cannot — '
4414
- +"nooticr does not post replies on any network, whatever a connection is allowed to do.</p></div>":""),
4414
+ +"replies are never sent on their own: one is posted only after you confirm its exact text.</p></div>":""),
4415
4415
  footer:'<span class="ai-note">Each press mints a fresh link — an old one does not work twice. Approve in your own browser; nothing is connected until you do.</span>'});
4416
4416
  }
4417
4417
 
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.55",
4
+ "version": "1.26.57",
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": {
@@ -46,7 +46,9 @@
46
46
  "test:mutation": "node scripts/mutation-guard.mjs",
47
47
  "test:invariants": "node scripts/invariant-guard.mjs",
48
48
  "verify:deep": "npm run verify && npm run test:mutation && npm run test:invariants",
49
- "check:submission": "node scripts/check-submission-schema.mjs"
49
+ "check:submission": "node scripts/check-submission-schema.mjs",
50
+ "registry:check": "node scripts/registry-drift.mjs",
51
+ "registry:publish": "bash scripts/publish-registry.sh"
50
52
  },
51
53
  "keywords": [
52
54
  "mcp",