@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.
- package/LICENSE +21 -0
- package/README.md +521 -0
- package/dist/auth.js +170 -0
- package/dist/config.js +54 -0
- package/dist/idempotency.js +32 -0
- package/dist/index.js +417 -0
- package/dist/nooticr.js +6 -0
- package/dist/oauth.js +263 -0
- package/dist/shared/brand-watch.js +297 -0
- package/dist/shared/cimd.js +231 -0
- package/dist/shared/collab.js +447 -0
- package/dist/shared/comment-review.js +171 -0
- package/dist/shared/connections.js +80 -0
- package/dist/shared/evidence.js +410 -0
- package/dist/shared/handoff.js +526 -0
- package/dist/shared/jobs.js +1785 -0
- package/dist/shared/nooticr.js +249 -0
- package/dist/shared/oauth.js +132 -0
- package/dist/shared/output-schemas.js +1046 -0
- package/dist/shared/own-account.js +559 -0
- package/dist/shared/performance.js +156 -0
- package/dist/shared/prompts.js +383 -0
- package/dist/shared/spend.js +158 -0
- package/dist/shared/tasks.js +88 -0
- package/dist/shared/tools-def.js +387 -0
- package/dist/shared/tools.js +2027 -0
- package/dist/shared/ui-template.js +2856 -0
- package/dist/shared/video.js +152 -0
- package/dist/shared/view-meta.js +34 -0
- package/dist/shared/watchlist.js +603 -0
- package/dist/video.js +6 -0
- package/package.json +62 -0
|
@@ -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
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
|
+
}
|