@nooticr/mcp 1.26.19

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.
@@ -0,0 +1,603 @@
1
+ /**
2
+ * A watchlist — the first thing here that remembers anything.
3
+ *
4
+ * Every other tool is one-shot. You can read a creator today and read them
5
+ * again next week and nothing in between knows you did, so the question people
6
+ * actually have — "what changed since I last looked" — could not be asked at
7
+ * all. Answering it needs two things kept: who you are watching, and what was
8
+ * true when you last checked.
9
+ *
10
+ * There is no scheduler on either transport, so nothing runs in the
11
+ * background. The diff is computed when you ask for it, against a baseline
12
+ * stored the last time you asked. That is enough for the question, and it
13
+ * keeps the cost where the user can see it.
14
+ *
15
+ * Which is the reason for the split below: reading the watchlist resource is
16
+ * free and touches only stored state, because a host may read a resource
17
+ * whenever it likes and a resource read that quietly spends credits would be
18
+ * indefensible. Catching up is a tool, because it fetches, and it says what it
19
+ * costs.
20
+ *
21
+ * WHERE IT LIVES: in the nooticr account. It did not always — the two
22
+ * transports each kept their own copy (a file beside the credentials for
23
+ * stdio, KV for the worker), so the same person on ChatGPT and on Claude
24
+ * Desktop had two lists and a server-side scheduler could read neither.
25
+ * `BackendWatchStore` below is the swap: the same `WatchStore` interface, over
26
+ * nooticr-server's workspace-scoped `list_watchlist` / `watch_creator` /
27
+ * `unwatch_creator` / `get_watchlist_baseline` / `advance_watchlist_baseline`.
28
+ *
29
+ * Two things about that are worth knowing before changing anything here.
30
+ *
31
+ * The swap is at the store, not at the tool layer, and that was the point.
32
+ * The backend registers tools with the same names this file already
33
+ * registers, and only one registration under a name can win — but renaming
34
+ * either side would break whichever hosts had already learned the old one,
35
+ * for a tool whose whole promise is "what you called before still works". So
36
+ * nothing about the surface moved. `watch_creator`, `unwatch_creator`,
37
+ * `catch_up_watchlist` and `track_competitor` keep their names, arguments and
38
+ * behaviour; only the bytes moved, and the backend's own twins stay
39
+ * unregistered here because they would be a second, disconnected list.
40
+ *
41
+ * The stores above are still live and still matter. They are the fallback for
42
+ * an account with no workspace or a backend too old to have the tools, and
43
+ * they are what the one-time migration reads from. Deleting one because "the
44
+ * watchlist is in the account now" would strand exactly those users.
45
+ */
46
+ import { z } from "zod";
47
+ import { confirmSpend, declinedResult, CREDITS_PER_CREATOR } from "./spend.js";
48
+ export const WATCHLIST_URI = "nooticr://watchlist";
49
+ /** Default, and what the tests use. Fine for a single process. */
50
+ export class MemoryWatchStore {
51
+ byOwner = new Map();
52
+ own(owner) {
53
+ let m = this.byOwner.get(owner);
54
+ if (!m)
55
+ this.byOwner.set(owner, (m = new Map()));
56
+ return m;
57
+ }
58
+ async list(owner) {
59
+ return [...this.own(owner).values()].sort((a, b) => a.id.localeCompare(b.id));
60
+ }
61
+ async put(owner, entry) {
62
+ this.own(owner).set(entry.id, entry);
63
+ }
64
+ async remove(owner, id) {
65
+ return this.own(owner).delete(id);
66
+ }
67
+ }
68
+ export class KvWatchStore {
69
+ kv;
70
+ constructor(kv) {
71
+ this.kv = kv;
72
+ }
73
+ key(owner) {
74
+ return `watchlist:${owner}`;
75
+ }
76
+ async list(owner) {
77
+ const raw = await this.kv.get(this.key(owner));
78
+ if (!raw)
79
+ return [];
80
+ try {
81
+ const parsed = JSON.parse(raw);
82
+ return Array.isArray(parsed) ? parsed : [];
83
+ }
84
+ catch {
85
+ // A corrupt value must not take the tool down with it.
86
+ return [];
87
+ }
88
+ }
89
+ async put(owner, entry) {
90
+ const all = (await this.list(owner)).filter((e) => e.id !== entry.id);
91
+ all.push(entry);
92
+ all.sort((a, b) => a.id.localeCompare(b.id));
93
+ await this.kv.put(this.key(owner), JSON.stringify(all));
94
+ }
95
+ async remove(owner, id) {
96
+ const all = await this.list(owner);
97
+ const kept = all.filter((e) => e.id !== id);
98
+ if (kept.length === all.length)
99
+ return false;
100
+ await this.kv.put(this.key(owner), JSON.stringify(kept));
101
+ return true;
102
+ }
103
+ }
104
+ /** stdio: one machine, one user, a file beside the credentials. */
105
+ export class FileWatchStore {
106
+ file;
107
+ constructor(file) {
108
+ this.file = file;
109
+ }
110
+ async read() {
111
+ try {
112
+ const { readFile } = await import("node:fs/promises");
113
+ return JSON.parse(await readFile(this.file, "utf8"));
114
+ }
115
+ catch {
116
+ return {};
117
+ }
118
+ }
119
+ async write(all) {
120
+ const { writeFile, mkdir } = await import("node:fs/promises");
121
+ const { dirname } = await import("node:path");
122
+ await mkdir(dirname(this.file), { recursive: true });
123
+ await writeFile(this.file, JSON.stringify(all, null, 2), { mode: 0o600 });
124
+ }
125
+ async list(owner) {
126
+ return (await this.read())[owner] ?? [];
127
+ }
128
+ async put(owner, entry) {
129
+ const all = await this.read();
130
+ const mine = (all[owner] ?? []).filter((e) => e.id !== entry.id);
131
+ mine.push(entry);
132
+ mine.sort((a, b) => a.id.localeCompare(b.id));
133
+ all[owner] = mine;
134
+ await this.write(all);
135
+ }
136
+ async remove(owner, id) {
137
+ const all = await this.read();
138
+ const mine = all[owner] ?? [];
139
+ const kept = mine.filter((e) => e.id !== id);
140
+ if (kept.length === mine.length)
141
+ return false;
142
+ all[owner] = kept;
143
+ await this.write(all);
144
+ return true;
145
+ }
146
+ }
147
+ /**
148
+ * The watchlist, kept in the nooticr account rather than beside the client.
149
+ *
150
+ * This is what the docblock at the top of this file called the real fix. The
151
+ * stores above keep a list per *connection* — a file next to the credentials
152
+ * on stdio, a KV entry on the Worker — so the same person on Claude Desktop
153
+ * and on ChatGPT had two, and a server-side scheduler could read neither.
154
+ *
155
+ * The swap is deliberately at `WatchStore` rather than at the tool layer.
156
+ * `watch_creator`, `unwatch_creator`, `catch_up_watchlist` and
157
+ * `track_competitor` keep their names, their arguments and their behaviour;
158
+ * only where the bytes live changes. That is the whole reason the collision
159
+ * with the backend's identically-named tools was not worth solving by
160
+ * renaming anything: a host that already learned `watch_creator` keeps
161
+ * working, and it now writes somewhere the other host can see.
162
+ *
163
+ * ## Why `put` diffs instead of writing
164
+ *
165
+ * `WatchStore.put` takes a whole entry, because a file store can just
166
+ * overwrite one. The backend splits the same write in two: `watch_creator`
167
+ * upserts the handle and note, `advance_watchlist_baseline` moves one of the
168
+ * two markers. Replaying both on every `put` would work and would also reset
169
+ * `capturedAt` to now each time — so "what is new since you last looked"
170
+ * would answer "since a moment ago" and report nothing. So a baseline is
171
+ * pushed only when its post ids actually changed, measured against the
172
+ * snapshot the caller's own `list()` just returned.
173
+ *
174
+ * ## Why it can still fall back
175
+ *
176
+ * The backend tools need a workspace, and an account that has only ever used
177
+ * the MCP surface may not have one; an older server does not have the tools
178
+ * at all. Both are permanent facts about the session rather than blips, so on
179
+ * either one this falls back to the local store for the rest of the process
180
+ * and says so once. Anything else — a timeout, a 500 — is rethrown rather
181
+ * than quietly redirected: silently writing to a different store than the one
182
+ * that was read is how a watchlist ends up split in half, which is the exact
183
+ * problem this class exists to end.
184
+ */
185
+ export class BackendWatchStore {
186
+ client;
187
+ local;
188
+ /** What the last `list()` returned, so `put` can tell what changed. */
189
+ seen = new Map();
190
+ /** Owners whose local entries are all in the account now. */
191
+ migrated = new Set();
192
+ /** Owners a migration has been tried for, so a retry knows it is one. */
193
+ attempted = new Set();
194
+ /** Set once the backend has told us it cannot serve this session at all. */
195
+ unavailable = null;
196
+ constructor(client, local) {
197
+ this.client = client;
198
+ this.local = local;
199
+ }
200
+ /**
201
+ * True for the two answers that mean "not now, and not later either".
202
+ *
203
+ * Matched on the message because that is all a JSON-RPC error carries back
204
+ * through the proxy. Kept narrow on purpose: a wider match would swallow a
205
+ * transient failure and silently split the list.
206
+ */
207
+ static isPermanent(err) {
208
+ const msg = err instanceof Error ? err.message : String(err);
209
+ return (/has no workspace/i.test(msg) ||
210
+ /method not found/i.test(msg) ||
211
+ /unknown tool/i.test(msg) ||
212
+ /-32601/.test(msg));
213
+ }
214
+ async call(tool, args) {
215
+ const client = await this.client();
216
+ const res = await client.callTool(tool, args);
217
+ return (res.structured ?? {});
218
+ }
219
+ /** Runs `fn` against the backend, or falls back for good if it cannot. */
220
+ async viaBackend(fn, fallback) {
221
+ if (this.unavailable)
222
+ return fallback();
223
+ try {
224
+ return await fn();
225
+ }
226
+ catch (err) {
227
+ if (!BackendWatchStore.isPermanent(err))
228
+ throw err;
229
+ this.unavailable = err instanceof Error ? err.message : String(err);
230
+ process.stderr.write(`[nooticr-mcp] watchlist: staying on local storage for this session (${this.unavailable})\n`);
231
+ return fallback();
232
+ }
233
+ }
234
+ static toEntry(raw) {
235
+ const r = (raw ?? {});
236
+ const platform = String(r.platform ?? "");
237
+ const handle = String(r.handle ?? "");
238
+ if (!platform || !handle)
239
+ return null;
240
+ const baseline = r.baseline;
241
+ const competitor = r.competitorBaseline;
242
+ return {
243
+ id: String(r.id ?? watchEntryId(platform, handle)),
244
+ platform,
245
+ handle,
246
+ note: r.note ? String(r.note) : undefined,
247
+ addedAt: String(r.addedAt ?? new Date().toISOString()),
248
+ ...(baseline ? { baseline } : {}),
249
+ ...(competitor ? { competitorBaseline: competitor } : {}),
250
+ };
251
+ }
252
+ /** Two baselines are the same when they cover the same posts. */
253
+ static sameIds(a, b) {
254
+ const x = a?.postIds ?? null;
255
+ const y = b?.postIds ?? null;
256
+ if (!x || !y)
257
+ return x === y;
258
+ return x.length === y.length && x.every((id, i) => id === y[i]);
259
+ }
260
+ /**
261
+ * Hand the local list over once, and only into an empty backend.
262
+ *
263
+ * A non-empty backend already has the user's real list — overwriting it with
264
+ * a stale per-connection copy would be the migration losing data rather than
265
+ * moving it. Failure to migrate one entry is not failure to read the list,
266
+ * so it is reported and stepped over.
267
+ */
268
+ async migrateOnce(owner, remote) {
269
+ if (this.migrated.has(owner))
270
+ return remote;
271
+ const firstLook = !this.attempted.has(owner);
272
+ this.attempted.add(owner);
273
+ // On the first look a non-empty account wins outright: it already holds
274
+ // the real list, and dropping a stale per-connection copy on top would be
275
+ // the migration losing data rather than moving it.
276
+ if (firstLook && remote.length) {
277
+ this.migrated.add(owner);
278
+ return remote;
279
+ }
280
+ const mine = await this.local.list(owner);
281
+ // Anything already up there is not a candidate — which is what makes a
282
+ // retry safe. After a partial migration the account is non-empty *because
283
+ // of us*, so the rule above would otherwise strand whatever failed: the
284
+ // account would have half the list and the rest would never be looked at
285
+ // again.
286
+ const present = new Set(remote.map((e) => e.id));
287
+ const todo = mine.filter((e) => !present.has(e.id));
288
+ if (!todo.length) {
289
+ this.migrated.add(owner);
290
+ return remote;
291
+ }
292
+ let moved = 0;
293
+ for (const entry of todo) {
294
+ try {
295
+ await this.pushEntry(entry, undefined);
296
+ moved += 1;
297
+ }
298
+ catch (err) {
299
+ process.stderr.write(`[nooticr-mcp] watchlist: could not migrate ${entry.id}: ${err instanceof Error ? err.message : String(err)}\n`);
300
+ }
301
+ }
302
+ // Only done when everything landed. Retrying is cheap — `watch_creator`
303
+ // upserts and the baselines are diffed — so the safe direction is to try
304
+ // the stragglers again on the next read.
305
+ if (moved === todo.length)
306
+ this.migrated.add(owner);
307
+ process.stderr.write(`[nooticr-mcp] watchlist: moved ${moved} of ${todo.length} creator(s) into your nooticr ` +
308
+ `account; they are now shared across every host you connect from\n`);
309
+ return this.readRemote();
310
+ }
311
+ async readRemote() {
312
+ const payload = await this.call("list_watchlist", {});
313
+ const rows = Array.isArray(payload.entries) ? payload.entries : [];
314
+ return rows
315
+ .map((r) => BackendWatchStore.toEntry(r))
316
+ .filter((e) => e !== null)
317
+ .sort((a, b) => a.id.localeCompare(b.id));
318
+ }
319
+ /** The upsert plus whichever baselines actually moved. */
320
+ async pushEntry(entry, before) {
321
+ await this.call("watch_creator", {
322
+ handle: entry.handle,
323
+ platform: entry.platform,
324
+ ...(entry.note ? { note: entry.note } : {}),
325
+ });
326
+ if (entry.baseline && !BackendWatchStore.sameIds(before?.baseline, entry.baseline)) {
327
+ await this.call("advance_watchlist_baseline", {
328
+ handle: entry.handle,
329
+ platform: entry.platform,
330
+ kind: "catch_up",
331
+ postIds: entry.baseline.postIds,
332
+ ...(entry.baseline.topViews === undefined ? {} : { topViews: entry.baseline.topViews }),
333
+ });
334
+ }
335
+ if (entry.competitorBaseline &&
336
+ !BackendWatchStore.sameIds(before?.competitorBaseline, entry.competitorBaseline)) {
337
+ await this.call("advance_watchlist_baseline", {
338
+ handle: entry.handle,
339
+ platform: entry.platform,
340
+ kind: "competitor",
341
+ postIds: entry.competitorBaseline.postIds,
342
+ });
343
+ }
344
+ }
345
+ async list(owner) {
346
+ return this.viaBackend(async () => {
347
+ const entries = await this.migrateOnce(owner, await this.readRemote());
348
+ this.seen.set(owner, new Map(entries.map((e) => [e.id, e])));
349
+ return entries;
350
+ }, () => this.local.list(owner));
351
+ }
352
+ async put(owner, entry) {
353
+ return this.viaBackend(async () => {
354
+ await this.pushEntry(entry, this.seen.get(owner)?.get(entry.id));
355
+ let mine = this.seen.get(owner);
356
+ if (!mine)
357
+ this.seen.set(owner, (mine = new Map()));
358
+ mine.set(entry.id, entry);
359
+ }, () => this.local.put(owner, entry));
360
+ }
361
+ async remove(owner, id) {
362
+ return this.viaBackend(async () => {
363
+ const [platform, ...rest] = id.split(":");
364
+ const handle = rest.join(":");
365
+ if (!platform || !handle)
366
+ return false;
367
+ const payload = await this.call("unwatch_creator", { handle, platform });
368
+ this.seen.get(owner)?.delete(id);
369
+ // `removed` is the backend's own boolean for "a row went away", which
370
+ // is exactly the contract the local stores return.
371
+ return payload.removed === true;
372
+ }, () => this.local.remove(owner, id));
373
+ }
374
+ }
375
+ /** Handles differ only by case and a leading @; the store keys on this. */
376
+ export const normaliseHandle = (h) => h.trim().replace(/^@/, "").toLowerCase();
377
+ /** `platform:handle` — stable, what unwatch takes, and what a lookup needs. */
378
+ export const watchEntryId = (platform, handle) => `${platform}:${normaliseHandle(handle)}`;
379
+ const norm = normaliseHandle;
380
+ const entryId = watchEntryId;
381
+ /**
382
+ * The account a stored list belongs to.
383
+ *
384
+ * The watchlist belongs to the nooticr account, not to the connection: the same
385
+ * person on two hosts should see one list. me() is the only identity both
386
+ * transports already have. Exported because jobs.ts reads the same store to
387
+ * answer "what has this competitor shipped since I last checked", and two
388
+ * different owner keys over one store would be two different watchlists.
389
+ */
390
+ export async function watchlistOwner(client) {
391
+ try {
392
+ const me = await client.me();
393
+ return String(me?.id || me?.email || "anonymous");
394
+ }
395
+ catch {
396
+ return "anonymous";
397
+ }
398
+ }
399
+ /** Posts as get_user_posts returns them, reduced to what a baseline needs. */
400
+ function snapshot(posts) {
401
+ const ids = posts
402
+ .map((p) => String(p.id ?? p.externalUrl ?? ""))
403
+ .filter(Boolean);
404
+ const views = posts.map((p) => Number(p.views) || 0);
405
+ return {
406
+ capturedAt: new Date().toISOString(),
407
+ postIds: ids,
408
+ topViews: views.length ? Math.max(...views) : undefined,
409
+ };
410
+ }
411
+ export function registerWatchlist(server, makeClient, store) {
412
+ const ownerOf = watchlistOwner;
413
+ const text = (value) => ({
414
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
415
+ structuredContent: value,
416
+ });
417
+ server.registerResource("Nooticr Watchlist", WATCHLIST_URI, {
418
+ mimeType: "application/json",
419
+ description: "Creators you are watching, and what was true at the last catch-up. Reading this is free " +
420
+ "and fetches nothing.",
421
+ }, async (uri, extra) => {
422
+ const client = await makeClient({ ...extra });
423
+ const entries = await store.list(await ownerOf(client));
424
+ return {
425
+ contents: [
426
+ {
427
+ uri: uri.href,
428
+ mimeType: "application/json",
429
+ text: JSON.stringify({ watching: entries.length, entries }, null, 2),
430
+ },
431
+ ],
432
+ // Private and short-lived, unlike the view template. This is one
433
+ // account's list, so a shared cache must never hand it to anyone else
434
+ // — that is exactly what `cacheScope` is for. And it changes from
435
+ // inside the same session (watch_creator, unwatch_creator), so the
436
+ // window has to be small enough that a user who just added someone
437
+ // does not look at a list without them.
438
+ ttlMs: 30_000,
439
+ cacheScope: "private",
440
+ };
441
+ });
442
+ server.registerTool("watch_creator", {
443
+ title: "Watch Creator",
444
+ description: "Add a creator to your watchlist so you can ask later what they have posted since. Stores " +
445
+ "the handle only — nothing is fetched here, so there is no cost. Use catch_up_watchlist to " +
446
+ "see what changed. No cost to call.",
447
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
448
+ inputSchema: z
449
+ .object({
450
+ username: z.string().describe("Creator handle, with or without @."),
451
+ platform: z.string().optional().describe("Platform (default tiktok)."),
452
+ note: z.string().optional().describe("Why you are watching them — shown back to you later."),
453
+ })
454
+ .strict(),
455
+ outputSchema: z
456
+ .object({
457
+ watching: z.number().nullish(),
458
+ added: z.string().nullish(),
459
+ entries: z.array(z.union([z.object({}).passthrough(), z.null()])).nullish(),
460
+ })
461
+ .passthrough(),
462
+ }, async (args, extra) => {
463
+ const client = await makeClient({ ...extra, arguments: args });
464
+ const owner = await ownerOf(client);
465
+ const platform = (args.platform || "tiktok").toLowerCase();
466
+ const id = entryId(platform, args.username);
467
+ const existing = (await store.list(owner)).find((e) => e.id === id);
468
+ await store.put(owner, {
469
+ id,
470
+ platform,
471
+ handle: norm(args.username),
472
+ note: args.note ?? existing?.note,
473
+ addedAt: existing?.addedAt ?? new Date().toISOString(),
474
+ // Re-watching keeps the baseline, so "since I last looked" still means
475
+ // the last catch-up rather than the moment of re-adding.
476
+ baseline: existing?.baseline,
477
+ });
478
+ const entries = await store.list(owner);
479
+ return text({ added: id, watching: entries.length, entries });
480
+ });
481
+ server.registerTool("unwatch_creator", {
482
+ title: "Unwatch Creator",
483
+ description: "Remove a creator from your watchlist. Nothing is fetched, so there is no cost. No cost to call.",
484
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
485
+ inputSchema: z
486
+ .object({
487
+ username: z.string().describe("Creator handle, with or without @."),
488
+ platform: z.string().optional().describe("Platform (default tiktok)."),
489
+ })
490
+ .strict(),
491
+ outputSchema: z
492
+ .object({
493
+ removed: z.boolean().nullish(),
494
+ watching: z.number().nullish(),
495
+ entries: z.array(z.union([z.object({}).passthrough(), z.null()])).nullish(),
496
+ })
497
+ .passthrough(),
498
+ }, async (args, extra) => {
499
+ const client = await makeClient({ ...extra, arguments: args });
500
+ const owner = await ownerOf(client);
501
+ const id = entryId((args.platform || "tiktok").toLowerCase(), args.username);
502
+ const removed = await store.remove(owner, id);
503
+ const entries = await store.list(owner);
504
+ return text({ removed, id, watching: entries.length, entries });
505
+ });
506
+ server.registerTool("catch_up_watchlist", {
507
+ title: "Catch Up On Watchlist",
508
+ _meta: {
509
+ ui: { resourceUri: "ui://nooticr/catch_up_watchlist" },
510
+ "ui/resourceUri": "ui://nooticr/catch_up_watchlist",
511
+ "openai/outputTemplate": "ui://nooticr/catch_up_watchlist.html",
512
+ },
513
+ description: "What the creators you watch have posted since you last checked. Fetches each one's recent " +
514
+ "posts and compares them against the snapshot taken at your last catch-up, then moves the " +
515
+ "snapshot forward — so this answers 'what is new' rather than 'what exists'. The first run " +
516
+ "for a creator has nothing to compare against and just records the baseline. " +
517
+ "Consumes 2 nooticr credits per creator checked.",
518
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
519
+ inputSchema: z
520
+ .object({
521
+ limit: z.number().int().optional().describe("Posts to check per creator (default 6)."),
522
+ platform: z.string().optional().describe("Only check creators on this platform."),
523
+ })
524
+ .strict(),
525
+ outputSchema: z
526
+ .object({
527
+ checked: z.number().nullish(),
528
+ creators: z.array(z.union([z.object({}).passthrough(), z.null()])).nullish(),
529
+ posts: z.array(z.union([z.object({}).passthrough(), z.null()])).nullish().describe("Everything new, flattened, for the card view."),
530
+ mcpCredits: z.object({}).passthrough().nullish(),
531
+ })
532
+ .passthrough(),
533
+ }, async (args, extra) => {
534
+ const client = await makeClient({ ...extra, arguments: args });
535
+ const owner = await ownerOf(client);
536
+ const only = args.platform?.toLowerCase();
537
+ let entries = await store.list(owner);
538
+ if (only)
539
+ entries = entries.filter((e) => e.platform === only);
540
+ if (!entries.length) {
541
+ return text({
542
+ checked: 0,
543
+ creators: [],
544
+ hint: "Nothing on the watchlist yet — add someone with watch_creator.",
545
+ });
546
+ }
547
+ // The other call whose price is an argument the caller never sees: the
548
+ // watchlist length. Listing it is a free KV read, so nothing has been
549
+ // spent by the time we ask.
550
+ const credits = entries.length * CREDITS_PER_CREATOR;
551
+ const decision = await confirmSpend(server.server, {
552
+ credits,
553
+ summary: `Check ${entries.length} watched creator${entries.length === 1 ? "" : "s"} for new posts.`,
554
+ cheaper: 'Pass "platform" to check only one network.',
555
+ });
556
+ if (!decision.proceed) {
557
+ return declinedResult(credits, `Checking ${entries.length} creators`);
558
+ }
559
+ const creators = [];
560
+ for (const entry of entries) {
561
+ try {
562
+ const res = await client.callTool("get_user_posts", {
563
+ username: entry.handle,
564
+ platform: entry.platform,
565
+ limit: args.limit ?? 6,
566
+ });
567
+ const structured = (res.structured ?? {});
568
+ const posts = Array.isArray(structured.posts)
569
+ ? structured.posts
570
+ : [];
571
+ const seen = new Set(entry.baseline?.postIds ?? []);
572
+ const fresh = entry.baseline
573
+ ? posts.filter((p) => !seen.has(String(p.id ?? p.externalUrl ?? "")))
574
+ : [];
575
+ creators.push({
576
+ id: entry.id,
577
+ handle: entry.handle,
578
+ platform: entry.platform,
579
+ note: entry.note,
580
+ firstCheck: !entry.baseline,
581
+ lastCheckedAt: entry.baseline?.capturedAt,
582
+ newPosts: fresh.length,
583
+ posts: fresh,
584
+ });
585
+ await store.put(owner, { ...entry, baseline: snapshot(posts) });
586
+ }
587
+ catch (err) {
588
+ // One creator failing must not lose the rest of the catch-up.
589
+ creators.push({
590
+ id: entry.id,
591
+ handle: entry.handle,
592
+ platform: entry.platform,
593
+ error: err instanceof Error ? err.message : String(err),
594
+ });
595
+ }
596
+ }
597
+ // Flattened for the view: the card renderer already knows how to draw a
598
+ // `posts` array, so what is new shows as cards without teaching the
599
+ // template a new shape. `creators` keeps the grouping for the model.
600
+ const fresh = creators.flatMap((c) => Array.isArray(c.posts) ? c.posts : []);
601
+ return text({ checked: creators.length, creators, posts: fresh });
602
+ });
603
+ }
package/dist/video.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * URL validation + analysis workflow helpers now live in `shared/video.ts`
3
+ * (shared with the Cloudflare Worker). This file re-exports them for
4
+ * backward compatibility.
5
+ */
6
+ export * from "./shared/video.js";
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@nooticr/mcp",
3
+ "mcpName": "com.nooticr/mcp",
4
+ "version": "1.26.19",
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
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "bin": {
14
+ "nooticr-mcp": "dist/index.js"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "build:ui": "node scripts/build-ui.mjs",
21
+ "conformance:mcpjam": "sh scripts/mcpjam-apps-conformance.sh",
22
+ "build": "npm run build:ui && tsc",
23
+ "dev": "tsc -w",
24
+ "start": "node dist/index.js",
25
+ "login": "node dist/index.js login",
26
+ "test": "vitest run",
27
+ "prepare": "npm run build",
28
+ "test:e2e": "playwright test",
29
+ "test:e2e:visual": "playwright test tests/e2e/agentic-visual.e2e.ts tests/e2e/agentic-visual-full-app.e2e.ts",
30
+ "test:e2e-smoke": "bash scripts/run-mechanical-e2e-smoke.sh",
31
+ "test:e2e-smoke:fixture": "NOOTICR_E2E_BACKEND=fixture bash scripts/run-mechanical-e2e-smoke.sh",
32
+ "test:agentic-e2e": "bash scripts/run-agentic-evals.sh",
33
+ "contract:host": "sh scripts/host-contract.sh",
34
+ "verify": "npm run build && npx tsc --noEmit && (cd cloudflare && npx tsc --noEmit) && npx vitest run && npm run contract:host && npm run conformance:mcpjam && npx playwright test"
35
+ },
36
+ "keywords": [
37
+ "mcp",
38
+ "nooticr",
39
+ "video",
40
+ "analysis",
41
+ "tiktok",
42
+ "instagram",
43
+ "youtube"
44
+ ],
45
+ "license": "MIT",
46
+ "dependencies": {
47
+ "@modelcontextprotocol/ext-apps": "^1.7.5",
48
+ "@modelcontextprotocol/sdk": "^1.16.0",
49
+ "zod": "^3.24.0"
50
+ },
51
+ "devDependencies": {
52
+ "@playwright/test": "^1.62.1",
53
+ "@tailwindcss/cli": "^4.3.3",
54
+ "@types/node": "^22.0.0",
55
+ "tailwindcss": "^4.3.3",
56
+ "typescript": "^5.5.0",
57
+ "vitest": "^3.1.0"
58
+ },
59
+ "overrides": {
60
+ "hono": "^4.13.5"
61
+ }
62
+ }