@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,158 @@
1
+ /** Mirrors `mcp_tool_cost_for` in crates/server/src/mcp_tools.rs. */
2
+ export const SEARCH_PLATFORMS = [
3
+ "youtube",
4
+ "tiktok",
5
+ "instagram",
6
+ "douyin",
7
+ "xiaohongshu",
8
+ "twitter",
9
+ "bilibili",
10
+ "reddit",
11
+ "weibo",
12
+ ];
13
+ /** Two per network, except the one that costs an order of magnitude more upstream. */
14
+ export const CREDITS_PER_NETWORK = 2;
15
+ export const XIAOHONGSHU_CREDITS = 5;
16
+ /** Per creator checked, so the price is the length of the watchlist. */
17
+ export const CREDITS_PER_CREATOR = 2;
18
+ /**
19
+ * The hard ceilings `search_spoken_mentions` cannot cross, whatever it is
20
+ * asked for.
21
+ *
22
+ * Every other fan-out in this file prices something whose size is already
23
+ * known once a cheap first call returns — the watchlist length, the post
24
+ * count of a feed already fetched. A transcript search cannot do that: there
25
+ * is no way to know how many of a niche's candidates actually say the term
26
+ * without transcribing them, so the candidate count can never be the number
27
+ * that sets the price. Left uncapped, a wide niche sweep could spend a hundred
28
+ * credits transcribing to find three hits. These are the caps that make that
29
+ * impossible regardless of what `maxTranscripts` or the handle list ask for —
30
+ * clamp server-side, never trust the argument alone.
31
+ */
32
+ export const MAX_SPOKEN_TRANSCRIPTS = 20;
33
+ /** get_user_posts calls a spoken-mention search will make, across explicit handles and the watchlist combined. */
34
+ export const MAX_SPOKEN_HANDLE_CALLS = 8;
35
+ /**
36
+ * What each backend call costs, so a tool that fans out can price itself.
37
+ *
38
+ * The job tools in jobs.ts are compositions: `answer_my_audience` is one
39
+ * `get_user_posts` plus a `get_post_comments` per post it opens, and nothing
40
+ * else. Billing is therefore the sum of what it actually called, which is only
41
+ * knowable if the per-call prices are written down somewhere. Mirrors
42
+ * `mcp_tool_cost_for` in crates/server/src/mcp_tools.rs, same as the constants
43
+ * above — a call missing from here prices as zero, which would understate a
44
+ * confirmation rather than overstate it, so add to it when a tool grows a
45
+ * new fetch.
46
+ */
47
+ export const BACKEND_CALL_CREDITS = {
48
+ get_social_media: 1,
49
+ get_post_transcript: 1,
50
+ get_user_posts: 2,
51
+ get_post_comments: 2,
52
+ discover_social_posts: 2,
53
+ search_creators: 2,
54
+ get_similar_creators: 2,
55
+ discover_sounds: 2,
56
+ discover_hashtags: 2,
57
+ get_post_frames: 2,
58
+ };
59
+ /** What a run of backend calls costs, named one per call actually made. */
60
+ export function costOf(calls) {
61
+ return calls.reduce((total, name) => total + (BACKEND_CALL_CREDITS[name] ?? 0), 0);
62
+ }
63
+ /**
64
+ * What a `search_mentions` call will cost.
65
+ *
66
+ * Omitting `platforms` means every network — which is the expensive default,
67
+ * and the reason this function exists.
68
+ */
69
+ export function searchMentionsCost(platforms) {
70
+ const chosen = platforms && platforms.length > 0
71
+ ? platforms
72
+ .map((p) => String(p).toLowerCase())
73
+ .filter((p) => SEARCH_PLATFORMS.includes(p))
74
+ : [...SEARCH_PLATFORMS];
75
+ if (chosen.length === 0)
76
+ return CREDITS_PER_NETWORK;
77
+ return chosen.reduce((total, p) => total + (p === "xiaohongshu" ? XIAOHONGSHU_CREDITS : CREDITS_PER_NETWORK), 0);
78
+ }
79
+ /**
80
+ * Above this, ask. Below it, the number is small enough that a prompt costs
81
+ * the user more attention than the credits are worth.
82
+ *
83
+ * Six is deliberate: a three-network sweep (6) passes silently, a four-network
84
+ * one (8) does not, and the all-networks default (21) never does.
85
+ */
86
+ export const CONFIRM_ABOVE_CREDITS = 6;
87
+ /** A client that never declared elicitation must not be sent one. */
88
+ function canAsk(server) {
89
+ const caps = server.getClientCapabilities();
90
+ return Boolean(caps && "elicitation" in caps && caps.elicitation);
91
+ }
92
+ /**
93
+ * Ask before spending. Returns whether to go ahead.
94
+ *
95
+ * Anything unexpected — a client that declared elicitation and then threw, a
96
+ * transport that dropped — proceeds. The failure mode of a broken confirmation
97
+ * should be the behaviour we had before it existed, not a tool that stopped
98
+ * working.
99
+ */
100
+ export async function confirmSpend(server, opts) {
101
+ if (!opts.always && opts.credits <= CONFIRM_ABOVE_CREDITS)
102
+ return { proceed: true };
103
+ if (!canAsk(server))
104
+ return { proceed: true };
105
+ const cost = opts.prompt?.cost ?? `This costs ${opts.credits} nooticr credits.`;
106
+ const lines = [`${opts.summary} ${cost}`];
107
+ if (opts.cheaper)
108
+ lines.push(opts.cheaper);
109
+ let result;
110
+ try {
111
+ result = (await server.elicitInput({
112
+ mode: "form",
113
+ message: lines.join(" "),
114
+ requestedSchema: {
115
+ type: "object",
116
+ properties: {
117
+ proceed: {
118
+ type: "boolean",
119
+ title: opts.prompt?.title ?? `Spend ${opts.credits} credits`,
120
+ description: opts.prompt?.action ?? "Run the search now.",
121
+ },
122
+ },
123
+ required: ["proceed"],
124
+ },
125
+ }));
126
+ // "accept" with proceed:false is a user who read the form and said no; it
127
+ // is not the same as dismissing the dialog, but it is still a no.
128
+ const accepted = result.action === "accept" &&
129
+ result.content?.proceed !== false;
130
+ if (accepted)
131
+ return { proceed: true, approved: true };
132
+ return { proceed: false, declined: result.action === "decline" ? "decline" : "cancel" };
133
+ }
134
+ catch {
135
+ return { proceed: true };
136
+ }
137
+ }
138
+ /**
139
+ * What the caller returns when the user said no. Not an error — a choice.
140
+ *
141
+ * `cheaper` is the way out. It defaults to the platforms argument because
142
+ * `search_mentions` was the first caller and that is its lever, but a tool
143
+ * that fans out over posts rather than networks has a different one, and
144
+ * telling a user to narrow a "platforms" argument the tool does not have is
145
+ * worse than saying nothing.
146
+ */
147
+ export function declinedResult(credits, what, cheaper) {
148
+ return {
149
+ content: [
150
+ {
151
+ type: "text",
152
+ text: `Cancelled — no credits were spent. ${what} would have cost ${credits} credits. ` +
153
+ (cheaper ?? `Narrow it with the "platforms" argument to spend less.`),
154
+ },
155
+ ],
156
+ structuredContent: { cancelled: true, wouldHaveCost: credits },
157
+ };
158
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Long calls, made honest.
3
+ *
4
+ * Every tool here declared `taskSupport: "forbidden"` — the SDK default, never
5
+ * chosen — including the ones that watch a video. POLL_TIMEOUT_MS is five
6
+ * minutes, so analyze_post could hold a synchronous tools/call open for that
7
+ * long and a host had no way to put it in the background. Measured against the
8
+ * live server, discover_social_posts takes 10.3s and compare_posts 10.4s; the
9
+ * multimodal tools wrapped here are the slow ones above them.
10
+ *
11
+ * The three tools that run longest now accept a task. What they do is
12
+ * unchanged: the existing handler is moved onto a background run and its
13
+ * result stored, so a client that asks for a task gets a task id it can poll,
14
+ * and a client that does not is auto-polled by the SDK and sees exactly the
15
+ * same result it saw before. That compatibility is the reason for `optional`
16
+ * rather than `required`.
17
+ *
18
+ * The store is in memory, per session. A task does not survive a restart —
19
+ * neither did the synchronous call it replaces, so nothing regresses, and the
20
+ * result outliving the request by fifteen minutes is new ground either way.
21
+ * When these need to survive a deploy, the store is the piece to replace: the
22
+ * worker already has a SQLite Durable Object under the session.
23
+ *
24
+ * The task APIs are experimental in SDK 1.30 and may change under us. They are
25
+ * contained here for that reason — one file to fix rather than three handlers.
26
+ */
27
+ import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks/stores/in-memory.js";
28
+ /** How long a finished result stays fetchable. */
29
+ export const TASK_TTL_MS = 900_000;
30
+ /**
31
+ * How often a client is told to poll — and, because the SDK auto-polls at this
32
+ * interval for a client that asked for no task, a floor on how fast those
33
+ * calls can now return. Measured: a plain call that would have answered
34
+ * instantly takes one interval. The SDK's default is 5s; these tools import
35
+ * media and run multimodal AI over it, so 2s is a rounding error against the
36
+ * work and cheap enough to poll over HTTP for the five minutes it may run.
37
+ * Only the three slowest tools are wrapped, so nothing quick pays this.
38
+ */
39
+ export const TASK_POLL_MS = 2_000;
40
+ /**
41
+ * The default store: in memory, which is correct for stdio because the process
42
+ * *is* the session — when it exits there is nothing left to poll for.
43
+ *
44
+ * It is wrong for the Worker, where the Durable Object holding the server is
45
+ * restarted by eviction, hibernation and every deploy, taking every in-flight
46
+ * task with it. That transport passes its own durable store; see
47
+ * `cloudflare/src/taskStore.ts`.
48
+ */
49
+ export function createTaskStore() {
50
+ return new InMemoryTaskStore();
51
+ }
52
+ /**
53
+ * Registers a tool whose work is slow enough to deserve a task, keeping the
54
+ * handler exactly as it was written for the synchronous path.
55
+ */
56
+ export function registerSlowTool(server, name, config, run) {
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ const tasks = server.experimental.tasks;
59
+ tasks.registerToolTask(name, { ...config, execution: { taskSupport: "optional" } }, {
60
+ createTask: async (args, extra) => {
61
+ const task = await extra.taskStore.createTask({ ttl: TASK_TTL_MS, pollInterval: TASK_POLL_MS });
62
+ // Deliberately not awaited: createTask must return the id now, and the
63
+ // work carries on past the end of this request. Nothing in these
64
+ // handlers reads extra.signal, and makeClient takes only plain values
65
+ // off extra (authInfo, requestId, arguments), so none of it goes stale
66
+ // when the request that started it completes.
67
+ void (async () => {
68
+ try {
69
+ const result = await run(args, extra);
70
+ await extra.taskStore.storeTaskResult(task.taskId, result?.isError ? "failed" : "completed", result);
71
+ }
72
+ catch (err) {
73
+ // A throw here has nowhere to go — the request is long gone — so
74
+ // it has to land in the task or the client polls a task that never
75
+ // finishes.
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ await extra.taskStore.storeTaskResult(task.taskId, "failed", {
78
+ content: [{ type: "text", text: `${name} failed: ${message}` }],
79
+ isError: true,
80
+ });
81
+ }
82
+ })();
83
+ return { task };
84
+ },
85
+ getTask: async (_args, extra) => extra.taskStore.getTask(extra.taskId),
86
+ getTaskResult: async (_args, extra) => extra.taskStore.getTaskResult(extra.taskId),
87
+ });
88
+ }