@mgcrea/mcp-x-api 0.1.0
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 +290 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +114 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +836 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/server-BeRiqZxj.js +4008 -0
- package/dist/server-BeRiqZxj.js.map +1 -0
- package/package.json +78 -0
|
@@ -0,0 +1,4008 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { gunzipSync } from "node:zlib";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
//#region src/build-info.ts
|
|
11
|
+
const readPackageJson = () => {
|
|
12
|
+
try {
|
|
13
|
+
const pkgUrl = new URL("../package.json", import.meta.url);
|
|
14
|
+
return JSON.parse(readFileSync(pkgUrl, "utf8"));
|
|
15
|
+
} catch {
|
|
16
|
+
return {
|
|
17
|
+
name: "@mgcrea/mcp-x-api",
|
|
18
|
+
version: "0.0.0"
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const pkg = readPackageJson();
|
|
23
|
+
const BUILD_INFO = {
|
|
24
|
+
name: pkg.name,
|
|
25
|
+
version: pkg.version,
|
|
26
|
+
gitCommit: "0e3b506",
|
|
27
|
+
gitCommitDate: "2026-08-30T20:26:25+02:00"
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/client/errors.ts
|
|
31
|
+
var XApiRequestError = class extends Error {
|
|
32
|
+
name = "XApiRequestError";
|
|
33
|
+
status;
|
|
34
|
+
errors;
|
|
35
|
+
constructor(message, opts) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.status = opts.status;
|
|
38
|
+
this.errors = opts.errors;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Thrown when a tool needs a logged-in user and only an app-only Bearer token
|
|
43
|
+
* is available. The message carries the fix, because "401 Unauthorized" tells
|
|
44
|
+
* you nothing about which of two credentials was missing.
|
|
45
|
+
*/
|
|
46
|
+
var UserContextRequiredError = class extends Error {
|
|
47
|
+
name = "UserContextRequiredError";
|
|
48
|
+
constructor(what, reason) {
|
|
49
|
+
super(`${what} needs an OAuth2 user context — an app-only Bearer token cannot reach it. Set X_API_CLIENT_ID and run \`x-api-mcp login\` once (it opens a browser and stores a refresh token in your config directory with mode 600), then retry.` + (reason ? ` (${reason})` : ""));
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/** Thrown when a write tool is reached while X_API_ALLOW_WRITES is off. */
|
|
53
|
+
var WritesDisabledError = class extends Error {
|
|
54
|
+
name = "WritesDisabledError";
|
|
55
|
+
constructor(what) {
|
|
56
|
+
super(`${what} is a write operation, but writes are disabled. Set X_API_ALLOW_WRITES=1 to enable mutating tools. Note that x_compose_post posts for free via a web intent and needs no flag at all.`);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Thrown when the Ads API is reachable but this account cannot use it — no Ads
|
|
61
|
+
* entitlement on the app, or no ads account behind the logged-in user. Separate
|
|
62
|
+
* from `UserContextRequiredError` because logging in again does not fix it: the
|
|
63
|
+
* missing piece is an approval, not a token.
|
|
64
|
+
*/
|
|
65
|
+
var AdsAccessError = class extends Error {
|
|
66
|
+
name = "AdsAccessError";
|
|
67
|
+
details;
|
|
68
|
+
constructor(message, details = {}) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.details = details;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* A local guard that fires *before* the request goes out, so an agent in a loop
|
|
75
|
+
* cannot spend past the ceiling. Carries the arithmetic so the number is
|
|
76
|
+
* auditable rather than mysterious.
|
|
77
|
+
*/
|
|
78
|
+
var BudgetExceededError = class extends Error {
|
|
79
|
+
name = "BudgetExceededError";
|
|
80
|
+
details;
|
|
81
|
+
constructor(opts) {
|
|
82
|
+
super(`${opts.what} would cost about $${opts.estimateUsd.toFixed(3)}, which takes this session past the $${opts.limitUsd.toFixed(2)} budget (about $${opts.spentUsd.toFixed(3)} spent so far). Raise or unset X_API_MONTHLY_BUDGET_USD, or ask for fewer results.`);
|
|
83
|
+
this.details = { ...opts };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* A local check that failed before we sent anything to X. Carries the state it
|
|
88
|
+
* read, so the caller sees why rather than just that something was wrong.
|
|
89
|
+
*/
|
|
90
|
+
var PreconditionError = class extends Error {
|
|
91
|
+
name = "PreconditionError";
|
|
92
|
+
details;
|
|
93
|
+
constructor(message, details = {}) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.details = details;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/client/http.ts
|
|
100
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
101
|
+
const backoffMs = (attempt) => Math.min(1e3 * 2 ** attempt, 8e3);
|
|
102
|
+
const retryAfterMs = (res) => {
|
|
103
|
+
const header = res.headers.get("Retry-After");
|
|
104
|
+
if (header === null) return void 0;
|
|
105
|
+
const seconds = Number(header);
|
|
106
|
+
return Number.isFinite(seconds) ? Math.max(seconds, 0) * 1e3 : void 0;
|
|
107
|
+
};
|
|
108
|
+
const safeJsonParse = (text) => {
|
|
109
|
+
try {
|
|
110
|
+
return text ? JSON.parse(text) : void 0;
|
|
111
|
+
} catch {
|
|
112
|
+
return text;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const numberOrUndefined = (value) => {
|
|
116
|
+
if (value === null) return void 0;
|
|
117
|
+
const n = Number(value);
|
|
118
|
+
return Number.isFinite(n) ? n : void 0;
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* X takes comma-separated lists for both field selection
|
|
122
|
+
* (`tweet.fields=id,text,created_at`) and batch lookups (`ids=1,2,3`) — not
|
|
123
|
+
* repeated keys. Same join as JSON:API happens to need, different reason.
|
|
124
|
+
*/
|
|
125
|
+
const buildQuery = (query) => {
|
|
126
|
+
if (!query) return "";
|
|
127
|
+
const params = new URLSearchParams();
|
|
128
|
+
for (const [key, value] of Object.entries(query)) {
|
|
129
|
+
if (value === void 0) continue;
|
|
130
|
+
if (Array.isArray(value)) {
|
|
131
|
+
if (value.length === 0) continue;
|
|
132
|
+
params.append(key, value.join(","));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
params.append(key, String(value));
|
|
136
|
+
}
|
|
137
|
+
const qs = params.toString();
|
|
138
|
+
return qs ? `?${qs}` : "";
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Collapse a concrete path to the shape X documents its rate limits against, so
|
|
142
|
+
* `/2/tweets/1799…` and `/2/tweets/1798…` share one bucket instead of leaking a
|
|
143
|
+
* new entry per post.
|
|
144
|
+
*/
|
|
145
|
+
const endpointKey = (method, path) => `${method} ${path.replace(/\/\d{5,}/g, "/:id").replace(/\?.*$/, "")}`;
|
|
146
|
+
/** Run `perform` until it yields a non-retryable response or the budget runs out. */
|
|
147
|
+
const withRetry = async (perform, policy) => {
|
|
148
|
+
let attempt = 0;
|
|
149
|
+
for (;;) {
|
|
150
|
+
policy.logger?.debug?.(`[x-api] ${policy.label} (attempt ${attempt + 1})`);
|
|
151
|
+
const res = await perform();
|
|
152
|
+
if (res.status === 401 && policy.onUnauthorized && attempt < policy.maxRetries) {
|
|
153
|
+
policy.logger?.warn?.(`[x-api] HTTP 401 — refreshing token and retrying`);
|
|
154
|
+
policy.onUnauthorized();
|
|
155
|
+
attempt += 1;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if ((res.status === 429 || res.status >= 500) && attempt < policy.maxRetries) {
|
|
159
|
+
const delay = retryAfterMs(res) ?? backoffMs(attempt);
|
|
160
|
+
policy.logger?.warn?.(`[x-api] HTTP ${res.status} — retrying in ${delay}ms`);
|
|
161
|
+
await sleep(delay);
|
|
162
|
+
attempt += 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
return res;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
//#endregion
|
|
169
|
+
//#region src/config.ts
|
|
170
|
+
const DEFAULT_BASE_URL = "https://api.x.com";
|
|
171
|
+
const DEFAULT_ADS_BASE_URL = "https://ads-api.x.com";
|
|
172
|
+
/**
|
|
173
|
+
* The Ads API sandbox: free, isolated, and the only sane place to exercise the
|
|
174
|
+
* write tools. Note the host — X's own docs say `ads-api-sandbox.x.com`, which
|
|
175
|
+
* has no DNS record at all. `ads-api-sandbox.twitter.com` is the one that
|
|
176
|
+
* resolves, so this is not a typo waiting to be "fixed".
|
|
177
|
+
*/
|
|
178
|
+
const SANDBOX_ADS_BASE_URL = "https://ads-api-sandbox.twitter.com";
|
|
179
|
+
/**
|
|
180
|
+
* A fixed loopback port, deliberately not an ephemeral one. Unlike most OAuth
|
|
181
|
+
* providers, X matches the callback URL against the value registered in the
|
|
182
|
+
* developer portal byte-for-byte, so a random port can never be authorized.
|
|
183
|
+
*/
|
|
184
|
+
const DEFAULT_REDIRECT_URI = "http://127.0.0.1:8723/callback";
|
|
185
|
+
/**
|
|
186
|
+
* `offline.access` is what makes a refresh token come back at all — without it
|
|
187
|
+
* the user has to re-login every two hours. `tweet.write` is appended at
|
|
188
|
+
* startup when the paid write backend is enabled, so a read-only install never
|
|
189
|
+
* asks for a permission it cannot use.
|
|
190
|
+
*/
|
|
191
|
+
const DEFAULT_SCOPES = [
|
|
192
|
+
"tweet.read",
|
|
193
|
+
"users.read",
|
|
194
|
+
"bookmark.read",
|
|
195
|
+
"offline.access"
|
|
196
|
+
];
|
|
197
|
+
/**
|
|
198
|
+
* X moved to pay-per-use on 2026-02-06; there is no free tier for new
|
|
199
|
+
* developers. These are list prices in USD, overridable via the config file's
|
|
200
|
+
* `pricing` key because a table baked into a schema with no escape hatch is
|
|
201
|
+
* wrong the day X changes it.
|
|
202
|
+
*
|
|
203
|
+
* The 24h dedup window is the load-bearing rule: within one UTC day, re-reading
|
|
204
|
+
* a resource id you already paid for is free. That is why the ledger keys on
|
|
205
|
+
* (kind, id, utcDay) rather than counting requests.
|
|
206
|
+
*/
|
|
207
|
+
const DEFAULT_PRICING = {
|
|
208
|
+
postRead: .005,
|
|
209
|
+
userRead: .01,
|
|
210
|
+
/** Your own posts and profile — five times cheaper than reading someone else's. */
|
|
211
|
+
ownedRead: .001,
|
|
212
|
+
postCreate: .015,
|
|
213
|
+
/** A post containing a URL costs 40x a post read. Not a typo. */
|
|
214
|
+
postCreateWithUrl: .2,
|
|
215
|
+
monthlyReadCap: 2e6,
|
|
216
|
+
effectiveFrom: "2026-02-06"
|
|
217
|
+
};
|
|
218
|
+
const PricingSchema = z.object({
|
|
219
|
+
postRead: z.number().nonnegative().default(DEFAULT_PRICING.postRead),
|
|
220
|
+
userRead: z.number().nonnegative().default(DEFAULT_PRICING.userRead),
|
|
221
|
+
ownedRead: z.number().nonnegative().default(DEFAULT_PRICING.ownedRead),
|
|
222
|
+
postCreate: z.number().nonnegative().default(DEFAULT_PRICING.postCreate),
|
|
223
|
+
postCreateWithUrl: z.number().nonnegative().default(DEFAULT_PRICING.postCreateWithUrl),
|
|
224
|
+
monthlyReadCap: z.number().int().positive().default(DEFAULT_PRICING.monthlyReadCap),
|
|
225
|
+
effectiveFrom: z.string().default(DEFAULT_PRICING.effectiveFrom)
|
|
226
|
+
}).strict();
|
|
227
|
+
const ConfigSchema = z.object({
|
|
228
|
+
bearerToken: z.string().min(1).optional(),
|
|
229
|
+
clientId: z.string().min(1).optional(),
|
|
230
|
+
clientSecret: z.string().min(1).optional(),
|
|
231
|
+
redirectUri: z.string().min(1).default(DEFAULT_REDIRECT_URI),
|
|
232
|
+
scopes: z.array(z.string().min(1)).min(1).default(DEFAULT_SCOPES),
|
|
233
|
+
tokenFile: z.string().min(1),
|
|
234
|
+
allowWrites: z.boolean().default(false),
|
|
235
|
+
writeBackend: z.enum(["intent", "api"]).default("intent"),
|
|
236
|
+
autoOpenBrowser: z.boolean().default(true),
|
|
237
|
+
enableFullArchive: z.boolean().default(false),
|
|
238
|
+
defaultMaxResults: z.number().int().min(1).max(100).default(10),
|
|
239
|
+
monthlyBudgetUsd: z.number().nonnegative().optional(),
|
|
240
|
+
cacheEnabled: z.boolean().default(true),
|
|
241
|
+
cacheMaxEntries: z.number().int().min(0).max(1e5).default(5e3),
|
|
242
|
+
maxRetries: z.number().int().nonnegative().max(10).default(3),
|
|
243
|
+
baseUrl: z.string().min(1).default(DEFAULT_BASE_URL),
|
|
244
|
+
pricing: PricingSchema.default(DEFAULT_PRICING),
|
|
245
|
+
adsEnabled: z.boolean().default(false),
|
|
246
|
+
adsAllowWrites: z.boolean().default(false),
|
|
247
|
+
adsBaseUrl: z.string().min(1).default(DEFAULT_ADS_BASE_URL),
|
|
248
|
+
adsAccountId: z.string().min(1).optional(),
|
|
249
|
+
adsMaxDownloadBytes: z.number().int().positive().default(25e6)
|
|
250
|
+
}).strict().superRefine((cfg, ctx) => {
|
|
251
|
+
if (cfg.writeBackend === "api" && !cfg.clientId) ctx.addIssue({
|
|
252
|
+
code: "custom",
|
|
253
|
+
message: "X_API_WRITE_BACKEND=api needs a user context: set X_API_CLIENT_ID and run `x-api-mcp login`. The default backend (intent) needs no credentials at all — it returns an x.com/intent/tweet URL you click, which costs nothing."
|
|
254
|
+
});
|
|
255
|
+
if (cfg.adsEnabled && !cfg.clientId) ctx.addIssue({
|
|
256
|
+
code: "custom",
|
|
257
|
+
message: "X_ADS_ENABLED=1 needs an OAuth 2.0 user context: set X_API_CLIENT_ID and run `x-api-mcp login`. The Ads API does not accept an app-only Bearer token."
|
|
258
|
+
});
|
|
259
|
+
if (cfg.adsAllowWrites && !cfg.adsEnabled) ctx.addIssue({
|
|
260
|
+
code: "custom",
|
|
261
|
+
message: "X_ADS_ALLOW_WRITES=1 has no effect without X_ADS_ENABLED=1 — the ads tools are not registered at all. Set both, or neither."
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
/**
|
|
265
|
+
* The on-disk config document. Keys are camelCase to mirror `Config` rather than
|
|
266
|
+
* the env var names: this is a typed JSON file, not a shell.
|
|
267
|
+
*
|
|
268
|
+
* `.strict()` on purpose — a typo'd `clientID` must be an error. Silently
|
|
269
|
+
* ignoring an unknown key looks exactly like "that setting had no effect",
|
|
270
|
+
* which is the worst way to learn your credentials came from somewhere else.
|
|
271
|
+
*/
|
|
272
|
+
const FileConfigSchema = z.object({
|
|
273
|
+
bearerToken: z.string().min(1).optional(),
|
|
274
|
+
clientId: z.string().min(1).optional(),
|
|
275
|
+
clientSecret: z.string().min(1).optional(),
|
|
276
|
+
redirectUri: z.string().min(1).optional(),
|
|
277
|
+
scopes: z.array(z.string().min(1)).min(1).optional(),
|
|
278
|
+
tokenFile: z.string().min(1).optional(),
|
|
279
|
+
allowWrites: z.boolean().optional(),
|
|
280
|
+
writeBackend: z.enum(["intent", "api"]).optional(),
|
|
281
|
+
autoOpenBrowser: z.boolean().optional(),
|
|
282
|
+
enableFullArchive: z.boolean().optional(),
|
|
283
|
+
defaultMaxResults: z.number().int().min(1).max(100).optional(),
|
|
284
|
+
monthlyBudgetUsd: z.number().nonnegative().optional(),
|
|
285
|
+
cacheEnabled: z.boolean().optional(),
|
|
286
|
+
cacheMaxEntries: z.number().int().min(0).max(1e5).optional(),
|
|
287
|
+
maxRetries: z.number().int().nonnegative().max(10).optional(),
|
|
288
|
+
baseUrl: z.string().min(1).optional(),
|
|
289
|
+
pricing: PricingSchema.optional(),
|
|
290
|
+
adsEnabled: z.boolean().optional(),
|
|
291
|
+
adsAllowWrites: z.boolean().optional(),
|
|
292
|
+
adsBaseUrl: z.string().min(1).optional(),
|
|
293
|
+
adsAccountId: z.string().min(1).optional(),
|
|
294
|
+
adsMaxDownloadBytes: z.number().int().positive().optional()
|
|
295
|
+
}).strict();
|
|
296
|
+
const parseBool = (value) => {
|
|
297
|
+
const t = trimmed(value);
|
|
298
|
+
if (t === void 0) return void 0;
|
|
299
|
+
return [
|
|
300
|
+
"1",
|
|
301
|
+
"true",
|
|
302
|
+
"yes",
|
|
303
|
+
"on"
|
|
304
|
+
].includes(t.toLowerCase());
|
|
305
|
+
};
|
|
306
|
+
const parseIntOpt = (value) => {
|
|
307
|
+
if (value === void 0 || value.trim() === "") return void 0;
|
|
308
|
+
const n = Number(value);
|
|
309
|
+
return Number.isInteger(n) ? n : void 0;
|
|
310
|
+
};
|
|
311
|
+
const parseFloatOpt = (value) => {
|
|
312
|
+
if (value === void 0 || value.trim() === "") return void 0;
|
|
313
|
+
const n = Number(value);
|
|
314
|
+
return Number.isFinite(n) ? n : void 0;
|
|
315
|
+
};
|
|
316
|
+
/** Scopes are space-separated in OAuth but commas are what people actually type. */
|
|
317
|
+
const parseList = (value) => {
|
|
318
|
+
const t = trimmed(value);
|
|
319
|
+
if (t === void 0) return void 0;
|
|
320
|
+
const items = t.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
321
|
+
return items.length > 0 ? items : void 0;
|
|
322
|
+
};
|
|
323
|
+
const trimmed = (value) => {
|
|
324
|
+
const t = value?.trim();
|
|
325
|
+
return t ? t : void 0;
|
|
326
|
+
};
|
|
327
|
+
const message$1 = (err) => err instanceof Error ? err.message : String(err);
|
|
328
|
+
/** `readFileSync` does not expand `~`, but it is the natural thing to write in a config file. */
|
|
329
|
+
const expandTilde = (path) => path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
330
|
+
/**
|
|
331
|
+
* Where the config file lives, most specific first: an explicit override, then
|
|
332
|
+
* the XDG location, then the conventional `~/.config`.
|
|
333
|
+
*/
|
|
334
|
+
const resolveConfigPath = (env = process.env) => {
|
|
335
|
+
const explicit = trimmed(env.X_API_CONFIG);
|
|
336
|
+
if (explicit) return expandTilde(explicit);
|
|
337
|
+
const base = trimmed(env.XDG_CONFIG_HOME) ?? join(homedir(), ".config");
|
|
338
|
+
return join(expandTilde(base), "x-api", "config.json");
|
|
339
|
+
};
|
|
340
|
+
/** The OAuth token file sits beside the config file unless told otherwise. */
|
|
341
|
+
const resolveTokenPath = (env = process.env) => join(dirname(resolveConfigPath(env)), "tokens.json");
|
|
342
|
+
/**
|
|
343
|
+
* These files hold a bearer token or a refresh token, so being readable by
|
|
344
|
+
* other users is worth saying out loud. It is a warning and not an error:
|
|
345
|
+
* refusing to start would be a worse trade for someone on a single-user machine.
|
|
346
|
+
*/
|
|
347
|
+
const warnIfGroupReadable = (path) => {
|
|
348
|
+
if (process.platform === "win32") return;
|
|
349
|
+
try {
|
|
350
|
+
if (statSync(path).mode & 63) process.stderr.write(`[x-api] ${path} is readable by other users. Run: chmod 600 ${path}\n`);
|
|
351
|
+
} catch {}
|
|
352
|
+
};
|
|
353
|
+
/**
|
|
354
|
+
* Read the config file, treating "absent" as "contributes nothing". Every other
|
|
355
|
+
* failure throws and names the path, so a malformed file is never mistaken for
|
|
356
|
+
* a missing one — that confusion would send you hunting for credentials that
|
|
357
|
+
* were sitting right there.
|
|
358
|
+
*/
|
|
359
|
+
const readConfigFile = (path) => {
|
|
360
|
+
let raw;
|
|
361
|
+
try {
|
|
362
|
+
raw = readFileSync(path, "utf8");
|
|
363
|
+
} catch (err) {
|
|
364
|
+
if (err.code === "ENOENT") return {};
|
|
365
|
+
throw new Error(`Could not read the config file (${path}): ${message$1(err)}`, { cause: err });
|
|
366
|
+
}
|
|
367
|
+
warnIfGroupReadable(path);
|
|
368
|
+
let parsed;
|
|
369
|
+
try {
|
|
370
|
+
parsed = JSON.parse(raw);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
throw new Error(`The config file (${path}) is not valid JSON: ${message$1(err)}`, { cause: err });
|
|
373
|
+
}
|
|
374
|
+
const result = FileConfigSchema.safeParse(parsed);
|
|
375
|
+
if (!result.success) {
|
|
376
|
+
const issues = result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
377
|
+
throw new Error(`The config file (${path}) is not valid: ${issues}`);
|
|
378
|
+
}
|
|
379
|
+
return result.data;
|
|
380
|
+
};
|
|
381
|
+
/**
|
|
382
|
+
* Environment first, config file second, **per field** — not whole-source.
|
|
383
|
+
* Docker and CI inject the environment and must keep working untouched, while a
|
|
384
|
+
* one-off `X_API_ALLOW_WRITES=0` still has to override a file that says `true`.
|
|
385
|
+
* Merging field by field is the only rule that gives both.
|
|
386
|
+
*/
|
|
387
|
+
const loadConfig = (env = process.env, configPath = resolveConfigPath(env)) => {
|
|
388
|
+
const file = readConfigFile(configPath);
|
|
389
|
+
const tokenFile = trimmed(env.X_API_TOKEN_FILE) ?? file.tokenFile ?? resolveTokenPath(env);
|
|
390
|
+
return ConfigSchema.parse({
|
|
391
|
+
bearerToken: trimmed(env.X_API_BEARER_TOKEN) ?? file.bearerToken,
|
|
392
|
+
clientId: trimmed(env.X_API_CLIENT_ID) ?? file.clientId,
|
|
393
|
+
clientSecret: trimmed(env.X_API_CLIENT_SECRET) ?? file.clientSecret,
|
|
394
|
+
redirectUri: trimmed(env.X_API_REDIRECT_URI) ?? file.redirectUri,
|
|
395
|
+
scopes: parseList(env.X_API_SCOPES) ?? file.scopes,
|
|
396
|
+
tokenFile: expandTilde(tokenFile),
|
|
397
|
+
allowWrites: parseBool(env.X_API_ALLOW_WRITES) ?? file.allowWrites,
|
|
398
|
+
writeBackend: trimmed(env.X_API_WRITE_BACKEND) ?? file.writeBackend,
|
|
399
|
+
autoOpenBrowser: parseBool(env.X_API_AUTO_OPEN_BROWSER) ?? file.autoOpenBrowser,
|
|
400
|
+
enableFullArchive: parseBool(env.X_API_ENABLE_FULL_ARCHIVE) ?? file.enableFullArchive,
|
|
401
|
+
defaultMaxResults: parseIntOpt(env.X_API_DEFAULT_MAX_RESULTS) ?? file.defaultMaxResults,
|
|
402
|
+
monthlyBudgetUsd: parseFloatOpt(env.X_API_MONTHLY_BUDGET_USD) ?? file.monthlyBudgetUsd,
|
|
403
|
+
cacheEnabled: parseBool(env.X_API_CACHE_ENABLED) ?? file.cacheEnabled,
|
|
404
|
+
cacheMaxEntries: parseIntOpt(env.X_API_CACHE_MAX_ENTRIES) ?? file.cacheMaxEntries,
|
|
405
|
+
maxRetries: parseIntOpt(env.X_API_MAX_RETRIES) ?? file.maxRetries,
|
|
406
|
+
baseUrl: trimmed(env.X_API_BASE_URL) ?? file.baseUrl,
|
|
407
|
+
pricing: file.pricing,
|
|
408
|
+
adsEnabled: parseBool(env.X_ADS_ENABLED) ?? file.adsEnabled,
|
|
409
|
+
adsAllowWrites: parseBool(env.X_ADS_ALLOW_WRITES) ?? file.adsAllowWrites,
|
|
410
|
+
adsBaseUrl: trimmed(env.X_ADS_BASE_URL) ?? file.adsBaseUrl,
|
|
411
|
+
adsAccountId: trimmed(env.X_ADS_ACCOUNT_ID) ?? file.adsAccountId,
|
|
412
|
+
adsMaxDownloadBytes: parseIntOpt(env.X_ADS_MAX_DOWNLOAD_BYTES) ?? file.adsMaxDownloadBytes
|
|
413
|
+
});
|
|
414
|
+
};
|
|
415
|
+
/**
|
|
416
|
+
* Whether the ads tools should be registered. The Ads API rides the same OAuth
|
|
417
|
+
* 2.0 user token as bookmarks and the home timeline — the `ads.read` /
|
|
418
|
+
* `ads.write` scopes are what separate them — so a client id is the hard
|
|
419
|
+
* requirement, not a second set of credentials.
|
|
420
|
+
*/
|
|
421
|
+
const hasAdsAccess = (config) => config.adsEnabled && Boolean(config.clientId);
|
|
422
|
+
/** Whether anything at all is configured that can reach the X API. */
|
|
423
|
+
const hasApiCredentials = (config) => Boolean(config.bearerToken ?? config.clientId);
|
|
424
|
+
/**
|
|
425
|
+
* What to do when nothing is configured. Returned by `x_auth_status` and
|
|
426
|
+
* printed at startup, because this is the state a first-time user lands in and
|
|
427
|
+
* the server can no longer signal it by refusing to start.
|
|
428
|
+
*/
|
|
429
|
+
const setupInstructions = (config) => [
|
|
430
|
+
"No X credentials are configured, so the tools that call the X API are not registered.",
|
|
431
|
+
"The free local tools still work: x_compose_post (posts via a browser click, no credentials, no cost), x_validate_post, and x_build_search_query.",
|
|
432
|
+
"Create an app at https://console.x.com (this replaced the old developer.x.com portal). Both credentials below are on the app's Keys and Tokens screen.",
|
|
433
|
+
"To enable reading and search, set X_API_BEARER_TOKEN to the app's Bearer Token. That alone covers post lookup, search, profiles and timelines — OAuth is not needed for any of it.",
|
|
434
|
+
`To enable bookmarks, your home timeline and API writes, also set X_API_CLIENT_ID. When creating the app choose Type of App = Native App: that makes it a public PKCE client with no client secret, which is what this server expects. Register the callback URL ${config.redirectUri} byte for byte (X's docs say to use 127.0.0.1 rather than localhost), then run \`x-api-mcp login\` or call x_auth_login.`,
|
|
435
|
+
"Enroll the app in the Pay-per-use package and the Production environment. An app left in the legacy Free/Development state logs in successfully and then fails every call with 403 client-not-enrolled.",
|
|
436
|
+
"Note that X removed its free tier on 2026-02-06: creating an app is free, but reads are pay-per-use and need prepurchased credits in the console."
|
|
437
|
+
];
|
|
438
|
+
/**
|
|
439
|
+
* What to do when ads is enabled but the account cannot reach the Ads API.
|
|
440
|
+
* Surfaced by `x_auth_status`, because the two steps people miss are invisible
|
|
441
|
+
* from the error alone: the app needs the Ads Project attached, and any token
|
|
442
|
+
* minted *before* approval does not carry the entitlement.
|
|
443
|
+
*/
|
|
444
|
+
const adsSetupInstructions = (config) => [
|
|
445
|
+
"The Ads API is separate from the X API v2: it needs its own approval, even though it uses the same OAuth 2.0 login.",
|
|
446
|
+
"At https://console.x.com open your app, then Project Access → MANAGE → Ads Project. That attaches Ads API access to the app id.",
|
|
447
|
+
"Request Ads API access for the app using X's Ads API Access Form. Standard Access covers campaigns, creatives, audiences and analytics.",
|
|
448
|
+
"After approval is granted, run `x-api-mcp login` again. A token minted before approval does not carry the entitlement, and re-using it fails every call.",
|
|
449
|
+
"Ads calls are billed separately from X's pay-per-use reads, so they do not appear in x_usage_report — but the campaigns they manage spend your advertising budget.",
|
|
450
|
+
`Point X_ADS_BASE_URL at ${SANDBOX_ADS_BASE_URL} for a free sandbox before touching a live account. Set X_ADS_ALLOW_WRITES=1 to register the campaign-mutating tools; without it they do not exist.`,
|
|
451
|
+
...config.adsAccountId ? [] : ["X_ADS_ACCOUNT_ID is unset. That is fine when you have exactly one ads account — it is resolved automatically — but with several you must pass accountId per call or set it."]
|
|
452
|
+
];
|
|
453
|
+
/**
|
|
454
|
+
* The scopes actually requested at login. `tweet.write` is only asked for when
|
|
455
|
+
* the paid write backend is on, so a reader never holds a permission it cannot
|
|
456
|
+
* use — and the consent screen stays honest about what the server will do.
|
|
457
|
+
*/
|
|
458
|
+
const effectiveScopes = (config) => {
|
|
459
|
+
const scopes = [...config.scopes];
|
|
460
|
+
if (config.allowWrites && config.writeBackend === "api" && !scopes.includes("tweet.write")) scopes.push("tweet.write");
|
|
461
|
+
if (config.adsEnabled && !scopes.includes("ads.read")) scopes.push("ads.read");
|
|
462
|
+
if (config.adsEnabled && config.adsAllowWrites && !scopes.includes("ads.write")) scopes.push("ads.write");
|
|
463
|
+
return scopes;
|
|
464
|
+
};
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/client/ads.ts
|
|
467
|
+
/** The default page size X uses. Its maximum is 1000. */
|
|
468
|
+
const DEFAULT_COUNT = 200;
|
|
469
|
+
/**
|
|
470
|
+
* Hosts the async-analytics download is allowed to reach. The URL comes out of
|
|
471
|
+
* an X response rather than from us, and following a server-supplied URL
|
|
472
|
+
* unchecked is an SSRF primitive — not something to leave open in a project
|
|
473
|
+
* whose pitch is a small attack surface.
|
|
474
|
+
*/
|
|
475
|
+
const DOWNLOAD_HOSTS = [
|
|
476
|
+
".x.com",
|
|
477
|
+
".twimg.com",
|
|
478
|
+
".twitter.com",
|
|
479
|
+
".amazonaws.com"
|
|
480
|
+
];
|
|
481
|
+
/**
|
|
482
|
+
* `endpointKey` collapses long digit runs, which is right for v2 post ids and
|
|
483
|
+
* useless here: ads ids are alphanumeric (`18ce54d4x5t`). Collapse the resource
|
|
484
|
+
* segments by name instead, so one bucket per endpoint rather than per entity.
|
|
485
|
+
*/
|
|
486
|
+
const adsEndpointKey = (method, path) => endpointKey(method, path.replace(/\/(accounts|campaigns|line_items|promoted_tweets|targeting_criteria|custom_audiences|funding_instruments)\/[A-Za-z0-9_-]+/g, "/$1/:id"));
|
|
487
|
+
const isRec = (value) => typeof value === "object" && value !== null;
|
|
488
|
+
/**
|
|
489
|
+
* Fetch-based client for the X Ads API v12. Deliberately not an `XApiClient`
|
|
490
|
+
* subclass: the two share transport concerns and nothing else. Ads paginates by
|
|
491
|
+
* cursor rather than `next_token`, answers errors in two envelopes neither of
|
|
492
|
+
* which is v2's problem-details, reports three families of rate-limit headers,
|
|
493
|
+
* and needs its own diagnostics — the v2 prose about the Pay-per-use package is
|
|
494
|
+
* actively misleading here. What genuinely is shared lives in `./http.js`.
|
|
495
|
+
*
|
|
496
|
+
* Writes send their parameters in the query string, never a JSON body: that is
|
|
497
|
+
* what the Ads API takes on POST and PUT, and what X's own SDKs send.
|
|
498
|
+
*/
|
|
499
|
+
var AdsApiClient = class {
|
|
500
|
+
sandbox;
|
|
501
|
+
baseUrl;
|
|
502
|
+
tokenProvider;
|
|
503
|
+
maxRetries;
|
|
504
|
+
maxDownloadBytes;
|
|
505
|
+
fetchImpl;
|
|
506
|
+
logger;
|
|
507
|
+
userAgent;
|
|
508
|
+
rateLimits = /* @__PURE__ */ new Map();
|
|
509
|
+
constructor(opts) {
|
|
510
|
+
this.baseUrl = (opts.baseUrl ?? "https://ads-api.x.com").replace(/\/+$/, "");
|
|
511
|
+
this.sandbox = /ads-api-sandbox\./.test(this.baseUrl);
|
|
512
|
+
this.tokenProvider = opts.tokenProvider;
|
|
513
|
+
this.maxRetries = opts.maxRetries ?? 3;
|
|
514
|
+
this.maxDownloadBytes = opts.maxDownloadBytes ?? 25e6;
|
|
515
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
516
|
+
this.logger = opts.logger;
|
|
517
|
+
this.userAgent = opts.userAgent ?? "mcp-x-api-js";
|
|
518
|
+
}
|
|
519
|
+
rateLimitStatus() {
|
|
520
|
+
return [...this.rateLimits.values()];
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Ads reports three independent budgets, and a 429 can come from any of them.
|
|
524
|
+
* Recording only the endpoint family would leave the account-level limit —
|
|
525
|
+
* the one that actually bites during a bulk read — invisible.
|
|
526
|
+
*/
|
|
527
|
+
recordRateLimit(method, path, res) {
|
|
528
|
+
for (const { scope, prefix } of [
|
|
529
|
+
{
|
|
530
|
+
scope: "endpoint",
|
|
531
|
+
prefix: "x-rate-limit"
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
scope: "account",
|
|
535
|
+
prefix: "x-account-rate-limit"
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
scope: "cost",
|
|
539
|
+
prefix: "x-cost-rate-limit"
|
|
540
|
+
}
|
|
541
|
+
]) {
|
|
542
|
+
const limit = numberOrUndefined(res.headers.get(`${prefix}-limit`));
|
|
543
|
+
const remaining = numberOrUndefined(res.headers.get(`${prefix}-remaining`));
|
|
544
|
+
const reset = numberOrUndefined(res.headers.get(`${prefix}-reset`));
|
|
545
|
+
if (limit === void 0 && remaining === void 0 && reset === void 0) continue;
|
|
546
|
+
const endpoint = adsEndpointKey(method, path);
|
|
547
|
+
this.rateLimits.set(`${scope} ${endpoint}`, {
|
|
548
|
+
endpoint,
|
|
549
|
+
api: "ads",
|
|
550
|
+
scope,
|
|
551
|
+
...limit !== void 0 ? { limit } : {},
|
|
552
|
+
...remaining !== void 0 ? { remaining } : {},
|
|
553
|
+
...reset !== void 0 ? {
|
|
554
|
+
reset,
|
|
555
|
+
resetAt: (/* @__PURE__ */ new Date(reset * 1e3)).toISOString()
|
|
556
|
+
} : {}
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
async request(method, path, query) {
|
|
561
|
+
const url = `${this.baseUrl}${path}${buildQuery(query)}`;
|
|
562
|
+
const res = await withRetry(async () => {
|
|
563
|
+
const token = await this.tokenProvider.getToken("user");
|
|
564
|
+
return this.fetchImpl(url, {
|
|
565
|
+
method,
|
|
566
|
+
headers: {
|
|
567
|
+
Accept: "application/json",
|
|
568
|
+
Authorization: `Bearer ${token}`,
|
|
569
|
+
"User-Agent": this.userAgent
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
}, {
|
|
573
|
+
maxRetries: this.maxRetries,
|
|
574
|
+
label: `${method} ${url}`,
|
|
575
|
+
logger: this.logger,
|
|
576
|
+
onUnauthorized: () => this.tokenProvider.invalidate("user")
|
|
577
|
+
});
|
|
578
|
+
this.recordRateLimit(method, path, res);
|
|
579
|
+
const text = await res.text();
|
|
580
|
+
if (!res.ok) throw new XApiRequestError(this.errorMessage(res, method, path, text), {
|
|
581
|
+
status: res.status,
|
|
582
|
+
errors: this.parseErrors(text)
|
|
583
|
+
});
|
|
584
|
+
if (res.status === 204 || text.trim() === "") return null;
|
|
585
|
+
return safeJsonParse(text);
|
|
586
|
+
}
|
|
587
|
+
get(path, query) {
|
|
588
|
+
return this.request("GET", path, query);
|
|
589
|
+
}
|
|
590
|
+
post(path, query) {
|
|
591
|
+
return this.request("POST", path, query);
|
|
592
|
+
}
|
|
593
|
+
put(path, query) {
|
|
594
|
+
return this.request("PUT", path, query);
|
|
595
|
+
}
|
|
596
|
+
del(path, query) {
|
|
597
|
+
return this.request("DELETE", path, query);
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* GET a collection, following `next_cursor` until the pages run out or a
|
|
601
|
+
* bound is hit.
|
|
602
|
+
*
|
|
603
|
+
* Unlike v2, the cursor is a top-level field rather than nested under `meta`,
|
|
604
|
+
* it is `null` (not absent) on the last page, and it goes back out as
|
|
605
|
+
* `cursor`. Everything else about the loop matches `XApiClient.paginate`,
|
|
606
|
+
* which is why this is a separate method rather than a shared one — the
|
|
607
|
+
* differences are exactly the parts that matter.
|
|
608
|
+
*/
|
|
609
|
+
async paginateCursor(path, query, opts) {
|
|
610
|
+
const maxPages = opts.maxPages ?? 5;
|
|
611
|
+
const collected = [];
|
|
612
|
+
let cursor;
|
|
613
|
+
let pages = 0;
|
|
614
|
+
let nextCursor;
|
|
615
|
+
let totalCount;
|
|
616
|
+
for (;;) {
|
|
617
|
+
const res = await this.request("GET", path, {
|
|
618
|
+
count: DEFAULT_COUNT,
|
|
619
|
+
...query,
|
|
620
|
+
...cursor ? { cursor } : {}
|
|
621
|
+
});
|
|
622
|
+
pages += 1;
|
|
623
|
+
if (Array.isArray(res?.data)) collected.push(...res.data);
|
|
624
|
+
if (typeof res?.total_count === "number") totalCount = res.total_count;
|
|
625
|
+
const next = res?.next_cursor;
|
|
626
|
+
cursor = typeof next === "string" && next ? next : void 0;
|
|
627
|
+
nextCursor = cursor;
|
|
628
|
+
if (!cursor || collected.length >= opts.maxItems || pages >= maxPages) break;
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
data: collected.slice(0, opts.maxItems),
|
|
632
|
+
pages,
|
|
633
|
+
...nextCursor ? { nextCursor } : {},
|
|
634
|
+
...totalCount !== void 0 ? { totalCount } : {}
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Fetch a finished analytics job's result file and decompress it.
|
|
639
|
+
*
|
|
640
|
+
* Three things here are load-bearing. The URL is a presigned object-store
|
|
641
|
+
* link on a different host, so it must go out with **no** Authorization
|
|
642
|
+
* header — signing it makes the store reject it. The host is checked first,
|
|
643
|
+
* because the URL came from a remote response. And both the compressed and
|
|
644
|
+
* decompressed sizes are capped: a 25 MB gzip of repetitive JSON expands to
|
|
645
|
+
* hundreds of megabytes, so an uncapped gunzip here is an OOM waiting for a
|
|
646
|
+
* big enough report.
|
|
647
|
+
*/
|
|
648
|
+
async downloadGzipped(url) {
|
|
649
|
+
let parsed;
|
|
650
|
+
try {
|
|
651
|
+
parsed = new URL(url);
|
|
652
|
+
} catch {
|
|
653
|
+
throw new PreconditionError(`The analytics result URL is not a valid URL: ${url}`, { url });
|
|
654
|
+
}
|
|
655
|
+
const host = parsed.hostname.toLowerCase();
|
|
656
|
+
if (parsed.protocol !== "https:" || !DOWNLOAD_HOSTS.some((d) => host.endsWith(d))) throw new PreconditionError(`Refusing to download the analytics result from ${host}: it is not an X-owned host. This URL came from an API response, so an unexpected host is worth stopping on.`, {
|
|
657
|
+
host,
|
|
658
|
+
allowed: DOWNLOAD_HOSTS
|
|
659
|
+
});
|
|
660
|
+
const res = await this.fetchImpl(url, { headers: {
|
|
661
|
+
Accept: "application/json",
|
|
662
|
+
"User-Agent": this.userAgent
|
|
663
|
+
} });
|
|
664
|
+
if (!res.ok) throw new XApiRequestError(`Downloading the analytics result failed: HTTP ${res.status} ${res.statusText}. These URLs expire — re-read the job with x_ads_get_stats_jobs for a fresh one.`, { status: res.status });
|
|
665
|
+
const declared = numberOrUndefined(res.headers.get("content-length"));
|
|
666
|
+
if (declared !== void 0 && declared > this.maxDownloadBytes) throw new PreconditionError(`The analytics result is ${declared} bytes, over the ${this.maxDownloadBytes}-byte limit. Re-run the job over fewer entity_ids, a shorter date range, or a coarser granularity, or raise X_ADS_MAX_DOWNLOAD_BYTES.`, {
|
|
667
|
+
bytes: declared,
|
|
668
|
+
limit: this.maxDownloadBytes
|
|
669
|
+
});
|
|
670
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
671
|
+
if (buf.byteLength > this.maxDownloadBytes) throw new PreconditionError(`The analytics result is ${buf.byteLength} bytes, over the ${this.maxDownloadBytes}-byte limit. Narrow the job, or raise X_ADS_MAX_DOWNLOAD_BYTES.`, {
|
|
672
|
+
bytes: buf.byteLength,
|
|
673
|
+
limit: this.maxDownloadBytes
|
|
674
|
+
});
|
|
675
|
+
try {
|
|
676
|
+
const out = gunzipSync(buf, { maxOutputLength: this.maxDownloadBytes * 20 });
|
|
677
|
+
return {
|
|
678
|
+
text: out.toString("utf8"),
|
|
679
|
+
bytes: out.byteLength
|
|
680
|
+
};
|
|
681
|
+
} catch (err) {
|
|
682
|
+
if (err.code === "ERR_BUFFER_TOO_LARGE") throw new PreconditionError(`The analytics result decompressed past ${this.maxDownloadBytes * 20} bytes and was discarded. Re-run the job over a narrower range.`, { limit: this.maxDownloadBytes * 20 });
|
|
683
|
+
throw err;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
parseErrors(text) {
|
|
687
|
+
const parsed = safeJsonParse(text);
|
|
688
|
+
if (isRec(parsed) && Array.isArray(parsed.errors)) return parsed.errors;
|
|
689
|
+
return parsed;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Ads answers in two envelopes. The gateway rejects bad auth with the legacy
|
|
693
|
+
* v1.1 shape and a *numeric* code — and with HTTP 400, not 401. Past that,
|
|
694
|
+
* application errors use CAPS_CASE string codes. Both are quoted here, and
|
|
695
|
+
* each status gets the sentence that actually fixes it: a bare
|
|
696
|
+
* "UNAUTHORIZED_ACCESS" sends people to re-check a token that is usually fine.
|
|
697
|
+
*/
|
|
698
|
+
errorMessage(res, method, path, text) {
|
|
699
|
+
const base = `X Ads API ${method} ${path} failed: HTTP ${res.status} ${res.statusText}`.trim();
|
|
700
|
+
const parsed = this.parseErrors(text);
|
|
701
|
+
const detail = Array.isArray(parsed) ? parsed.map((e) => [
|
|
702
|
+
e.code,
|
|
703
|
+
e.message ?? e.detail,
|
|
704
|
+
e.parameter
|
|
705
|
+
].filter(Boolean).join(" — ")).filter(Boolean).join("; ") : "";
|
|
706
|
+
const suffix = detail ? ` (${detail})` : "";
|
|
707
|
+
if (res.status === 400 && /"code":\s*2\d\d/.test(text)) return `${base} — X's gateway rejected the credentials outright. The access token is missing or malformed; run \`x-api-mcp login\` again${suffix}`;
|
|
708
|
+
if (res.status === 401) return `${base} — authenticated request refused. Most often the stored token predates your Ads API approval, or was minted before ads.read was in scope: run \`x-api-mcp login\` again so the new token carries the ads scopes${suffix}`;
|
|
709
|
+
if (res.status === 403) return `${base} — the token is valid, but this app is not approved for the Ads REST API. Attaching the Ads Project at console.x.com is only half of it: that switch enables X's hosted Ads MCP, while these /12/ endpoints additionally need X's Ads API Access Form to be approved, which is a human review rather than a toggle. Once it is granted, run \`x-api-mcp login\` again — a token minted before approval does not carry it` + suffix;
|
|
710
|
+
if (res.status === 404) return `${base} — no such entity at ${this.baseUrl}. Check the account id, and check you are pointed at the right environment: X_ADS_BASE_URL is currently ${this.sandbox ? "the SANDBOX" : "PRODUCTION"} (${this.baseUrl}), and ids do not carry across${suffix}`;
|
|
711
|
+
if (res.status === 429) {
|
|
712
|
+
const snapshot = [...this.rateLimits.values()].find((s) => s.endpoint === adsEndpointKey(method, path));
|
|
713
|
+
return `${base} — rate limited${snapshot ? ` (${snapshot.remaining ?? 0}/${snapshot.limit ?? "?"} remaining on the ${snapshot.scope} budget${snapshot.resetAt ? `, resets ${snapshot.resetAt}` : ""})` : ""}. Wait for the window to reset, or ask for less.`;
|
|
714
|
+
}
|
|
715
|
+
return base + suffix;
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
//#endregion
|
|
719
|
+
//#region src/client/ads-shape.ts
|
|
720
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
721
|
+
/** X states every money field in millionths of a currency unit. */
|
|
722
|
+
const MICRO = 1e6;
|
|
723
|
+
const toMicro = (major) => Math.round(major * MICRO);
|
|
724
|
+
const fromMicro = (micro) => Math.round(micro / MICRO * 100) / 100;
|
|
725
|
+
const MICRO_SUFFIX = "_amount_local_micro";
|
|
726
|
+
/**
|
|
727
|
+
* Pair every `*_amount_local_micro` field with the value a human would say.
|
|
728
|
+
*
|
|
729
|
+
* This is not cosmetic. A model that reads `daily_budget_amount_local_micro:
|
|
730
|
+
* 50000000` and reasons about it concludes the budget is fifty million, and the
|
|
731
|
+
* next thing it proposes is scaled by a factor of a million. The write path
|
|
732
|
+
* guards against the same mistake by refusing micro inputs; this is the other
|
|
733
|
+
* half, and without it the guard only covers one direction.
|
|
734
|
+
*
|
|
735
|
+
* The micro field is kept rather than replaced, so the raw value X returned
|
|
736
|
+
* stays auditable.
|
|
737
|
+
*/
|
|
738
|
+
const shapeMoney = (value, currency) => {
|
|
739
|
+
if (Array.isArray(value)) return value.map((item) => shapeMoney(item, currency));
|
|
740
|
+
if (!isRecord$1(value)) return value;
|
|
741
|
+
const out = {};
|
|
742
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
743
|
+
out[key] = shapeMoney(raw, currency);
|
|
744
|
+
if (!key.endsWith(MICRO_SUFFIX) || typeof raw !== "number") continue;
|
|
745
|
+
const base = key.slice(0, -19);
|
|
746
|
+
out[base] = fromMicro(raw);
|
|
747
|
+
if (currency) out[`${base}_currency`] = currency;
|
|
748
|
+
}
|
|
749
|
+
return out;
|
|
750
|
+
};
|
|
751
|
+
/**
|
|
752
|
+
* Strip the Ads API's envelope down to what a caller wants.
|
|
753
|
+
*
|
|
754
|
+
* Every ads response echoes the request back under `request`, which is pure
|
|
755
|
+
* noise in a tool result — the caller just sent it. `next_cursor` and
|
|
756
|
+
* `total_count` are lifted out by the client's pagination instead.
|
|
757
|
+
*/
|
|
758
|
+
const adsData = (raw) => isRecord$1(raw) ? raw.data : void 0;
|
|
759
|
+
const message = (err) => err instanceof Error ? err.message : String(err);
|
|
760
|
+
const createTokenStore = (path) => ({
|
|
761
|
+
path,
|
|
762
|
+
read() {
|
|
763
|
+
let raw;
|
|
764
|
+
try {
|
|
765
|
+
raw = readFileSync(path, "utf8");
|
|
766
|
+
} catch (err) {
|
|
767
|
+
if (err.code === "ENOENT") return void 0;
|
|
768
|
+
throw new Error(`Could not read the token file (${path}): ${message(err)}`, { cause: err });
|
|
769
|
+
}
|
|
770
|
+
warnIfGroupReadable(path);
|
|
771
|
+
let parsed;
|
|
772
|
+
try {
|
|
773
|
+
parsed = JSON.parse(raw);
|
|
774
|
+
} catch {
|
|
775
|
+
process.stderr.write(`[x-api] ${path} is not valid JSON — run \`x-api-mcp login\` again.\n`);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) return;
|
|
779
|
+
return parsed;
|
|
780
|
+
},
|
|
781
|
+
write(tokens) {
|
|
782
|
+
mkdirSync(dirname(path), {
|
|
783
|
+
recursive: true,
|
|
784
|
+
mode: 448
|
|
785
|
+
});
|
|
786
|
+
const tmp = join(dirname(path), `.tokens.${process.pid}.tmp`);
|
|
787
|
+
writeFileSync(tmp, `${JSON.stringify(tokens, null, 2)}\n`, { mode: 384 });
|
|
788
|
+
renameSync(tmp, path);
|
|
789
|
+
},
|
|
790
|
+
clear() {
|
|
791
|
+
try {
|
|
792
|
+
unlinkSync(path);
|
|
793
|
+
} catch (err) {
|
|
794
|
+
if (err.code !== "ENOENT") throw err;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
/** True when the stored tokens cannot serve the app or scopes we now need. */
|
|
799
|
+
const tokensAreStale = (tokens, clientId, requiredScopes) => {
|
|
800
|
+
if (!tokens) return {
|
|
801
|
+
stale: true,
|
|
802
|
+
reason: "no stored tokens"
|
|
803
|
+
};
|
|
804
|
+
if (tokens.clientId !== clientId) return {
|
|
805
|
+
stale: true,
|
|
806
|
+
reason: "the stored tokens belong to a different X_API_CLIENT_ID"
|
|
807
|
+
};
|
|
808
|
+
const missing = requiredScopes.filter((scope) => !tokens.scopes.includes(scope));
|
|
809
|
+
if (missing.length > 0) return {
|
|
810
|
+
stale: true,
|
|
811
|
+
reason: `the stored tokens lack the scope(s): ${missing.join(", ")}`
|
|
812
|
+
};
|
|
813
|
+
return { stale: false };
|
|
814
|
+
};
|
|
815
|
+
/** Mode bits of the token file, for tests and `x_auth_status`. */
|
|
816
|
+
const fileMode = (path) => {
|
|
817
|
+
try {
|
|
818
|
+
return statSync(path).mode & 511;
|
|
819
|
+
} catch {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
//#endregion
|
|
824
|
+
//#region src/client/oauth.ts
|
|
825
|
+
const AUTHORIZE_URL = "https://x.com/i/oauth2/authorize";
|
|
826
|
+
const TOKEN_PATH = "/2/oauth2/token";
|
|
827
|
+
const CALLBACK_TIMEOUT_MS = 12e4;
|
|
828
|
+
const base64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
829
|
+
/**
|
|
830
|
+
* PKCE S256. 32 random bytes base64url-encode to exactly 43 characters, the
|
|
831
|
+
* minimum the spec allows for a verifier.
|
|
832
|
+
*/
|
|
833
|
+
const createPkcePair = (random = randomBytes) => {
|
|
834
|
+
const verifier = base64url(random(32));
|
|
835
|
+
return {
|
|
836
|
+
verifier,
|
|
837
|
+
challenge: base64url(createHash("sha256").update(verifier).digest())
|
|
838
|
+
};
|
|
839
|
+
};
|
|
840
|
+
const buildAuthorizeUrl = (opts) => {
|
|
841
|
+
const params = new URLSearchParams({
|
|
842
|
+
response_type: "code",
|
|
843
|
+
client_id: opts.clientId,
|
|
844
|
+
redirect_uri: opts.redirectUri,
|
|
845
|
+
scope: opts.scopes.join(" "),
|
|
846
|
+
state: opts.state,
|
|
847
|
+
code_challenge: opts.challenge,
|
|
848
|
+
code_challenge_method: "S256"
|
|
849
|
+
});
|
|
850
|
+
return `${AUTHORIZE_URL}?${params.toString()}`;
|
|
851
|
+
};
|
|
852
|
+
/** Constant-time compare, so a mismatched state cannot be probed byte by byte. */
|
|
853
|
+
const statesMatch = (a, b) => {
|
|
854
|
+
const left = Buffer.from(a);
|
|
855
|
+
const right = Buffer.from(b);
|
|
856
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
857
|
+
};
|
|
858
|
+
const formPost = async (fetchImpl, config, body) => {
|
|
859
|
+
const headers = {
|
|
860
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
861
|
+
Accept: "application/json"
|
|
862
|
+
};
|
|
863
|
+
if (config.clientSecret) headers.Authorization = `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64")}`;
|
|
864
|
+
const res = await fetchImpl(`${config.baseUrl.replace(/\/+$/, "")}${TOKEN_PATH}`, {
|
|
865
|
+
method: "POST",
|
|
866
|
+
headers,
|
|
867
|
+
body: body.toString()
|
|
868
|
+
});
|
|
869
|
+
const text = await res.text();
|
|
870
|
+
if (!res.ok) throw new Error(`X rejected the OAuth token request: HTTP ${res.status} — ${text.slice(0, 400)}`);
|
|
871
|
+
return JSON.parse(text);
|
|
872
|
+
};
|
|
873
|
+
const createOAuthClient = (config, fetchImpl = fetch) => {
|
|
874
|
+
if (!config.clientId) throw new PreconditionError("X_API_CLIENT_ID is required for the OAuth2 flow.");
|
|
875
|
+
const clientId = config.clientId;
|
|
876
|
+
return {
|
|
877
|
+
exchangeCode: (code, verifier) => formPost(fetchImpl, config, new URLSearchParams({
|
|
878
|
+
grant_type: "authorization_code",
|
|
879
|
+
code,
|
|
880
|
+
redirect_uri: config.redirectUri,
|
|
881
|
+
code_verifier: verifier,
|
|
882
|
+
client_id: clientId
|
|
883
|
+
})),
|
|
884
|
+
refresh: (refreshToken) => formPost(fetchImpl, config, new URLSearchParams({
|
|
885
|
+
grant_type: "refresh_token",
|
|
886
|
+
refresh_token: refreshToken,
|
|
887
|
+
client_id: clientId
|
|
888
|
+
}))
|
|
889
|
+
};
|
|
890
|
+
};
|
|
891
|
+
const toStoredTokens = (res, opts) => ({
|
|
892
|
+
version: 1,
|
|
893
|
+
clientId: opts.clientId,
|
|
894
|
+
scopes: res.scope ? res.scope.split(/\s+/).filter(Boolean) : opts.requestedScopes,
|
|
895
|
+
accessToken: res.access_token,
|
|
896
|
+
...res.refresh_token ? { refreshToken: res.refresh_token } : {},
|
|
897
|
+
...opts.previousRefreshToken ? { previousRefreshToken: opts.previousRefreshToken } : {},
|
|
898
|
+
expiresAt: opts.now + (res.expires_in ?? 7200) * 1e3,
|
|
899
|
+
obtainedAt: opts.now,
|
|
900
|
+
...opts.userId ? { userId: opts.userId } : {},
|
|
901
|
+
...opts.username ? { username: opts.username } : {}
|
|
902
|
+
});
|
|
903
|
+
const SUCCESS_PAGE = `<!doctype html><meta charset="utf-8"><title>x-api-mcp</title>
|
|
904
|
+
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;line-height:1.5">
|
|
905
|
+
<h1>Signed in</h1><p>x-api-mcp stored your token. You can close this tab and return to your terminal.</p>
|
|
906
|
+
</body>`;
|
|
907
|
+
const FAILURE_PAGE = `<!doctype html><meta charset="utf-8"><title>x-api-mcp</title>
|
|
908
|
+
<body style="font-family:system-ui;max-width:32rem;margin:4rem auto;line-height:1.5">
|
|
909
|
+
<h1>Sign-in failed</h1><p>Check the terminal running x-api-mcp for the reason.</p>
|
|
910
|
+
</body>`;
|
|
911
|
+
/**
|
|
912
|
+
* Run the browser half of the flow: open the authorize URL, listen on the
|
|
913
|
+
* loopback port for exactly one callback, and hand back the code.
|
|
914
|
+
*
|
|
915
|
+
* The port is fixed rather than ephemeral because X matches the redirect URI
|
|
916
|
+
* against the value registered in the developer portal byte for byte — an
|
|
917
|
+
* ephemeral port could never be authorized.
|
|
918
|
+
*/
|
|
919
|
+
const awaitCallback = (opts) => {
|
|
920
|
+
const url = new URL(opts.redirectUri);
|
|
921
|
+
const port = Number(url.port);
|
|
922
|
+
const expectedPath = url.pathname;
|
|
923
|
+
let settle;
|
|
924
|
+
const code = new Promise((resolve, reject) => {
|
|
925
|
+
settle = {
|
|
926
|
+
resolve,
|
|
927
|
+
reject
|
|
928
|
+
};
|
|
929
|
+
});
|
|
930
|
+
const server = createServer((req, res) => {
|
|
931
|
+
const requestUrl = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
932
|
+
if (requestUrl.pathname !== expectedPath) {
|
|
933
|
+
res.writeHead(404).end("Not found");
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
const returnedState = requestUrl.searchParams.get("state") ?? "";
|
|
937
|
+
const returnedCode = requestUrl.searchParams.get("code");
|
|
938
|
+
const error = requestUrl.searchParams.get("error");
|
|
939
|
+
if (error) {
|
|
940
|
+
res.writeHead(400, { "content-type": "text/html" }).end(FAILURE_PAGE);
|
|
941
|
+
settle.reject(/* @__PURE__ */ new Error(`X denied the authorization: ${error}`));
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
if (!statesMatch(returnedState, opts.state)) {
|
|
945
|
+
res.writeHead(400, { "content-type": "text/html" }).end(FAILURE_PAGE);
|
|
946
|
+
settle.reject(/* @__PURE__ */ new Error("The callback did not come from the login that was started (state mismatch)."));
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (!returnedCode) {
|
|
950
|
+
res.writeHead(400, { "content-type": "text/html" }).end(FAILURE_PAGE);
|
|
951
|
+
settle.reject(/* @__PURE__ */ new Error("The callback carried no authorization code."));
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
res.writeHead(200, { "content-type": "text/html" }).end(SUCCESS_PAGE);
|
|
955
|
+
settle.resolve(returnedCode);
|
|
956
|
+
});
|
|
957
|
+
const timer = setTimeout(() => {
|
|
958
|
+
settle.reject(/* @__PURE__ */ new Error(`No callback arrived within ${(opts.timeoutMs ?? CALLBACK_TIMEOUT_MS) / 1e3}s. Re-run the login and complete it in the browser.`));
|
|
959
|
+
}, opts.timeoutMs ?? CALLBACK_TIMEOUT_MS);
|
|
960
|
+
timer.unref?.();
|
|
961
|
+
server.on("error", (err) => {
|
|
962
|
+
settle.reject(err.code === "EADDRINUSE" ? /* @__PURE__ */ new Error(`Port ${port} is already in use, so the OAuth callback cannot be received. Free it, or set X_API_REDIRECT_URI to another loopback URL — and register that exact URL in the X developer console at console.x.com, which must match byte for byte.`) : err);
|
|
963
|
+
});
|
|
964
|
+
server.listen(port, "127.0.0.1");
|
|
965
|
+
const close = () => {
|
|
966
|
+
clearTimeout(timer);
|
|
967
|
+
server.close();
|
|
968
|
+
};
|
|
969
|
+
code.finally(close).catch(() => {});
|
|
970
|
+
return {
|
|
971
|
+
url: void 0,
|
|
972
|
+
code,
|
|
973
|
+
close
|
|
974
|
+
};
|
|
975
|
+
};
|
|
976
|
+
/** The whole login: PKCE, browser, callback, exchange, identify, persist. */
|
|
977
|
+
const startLoginFlow = async (opts) => {
|
|
978
|
+
const { config, store } = opts;
|
|
979
|
+
if (!config.clientId) throw new PreconditionError("X_API_CLIENT_ID is required to log in. Create an OAuth 2.0 app in the X developer portal, enable PKCE, and add this exact callback URL: " + config.redirectUri);
|
|
980
|
+
const fetchImpl = opts.fetch ?? fetch;
|
|
981
|
+
const now = opts.now ?? Date.now;
|
|
982
|
+
const scopes = effectiveScopes(config);
|
|
983
|
+
const { verifier, challenge } = createPkcePair();
|
|
984
|
+
const state = base64url(randomBytes(16));
|
|
985
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
986
|
+
clientId: config.clientId,
|
|
987
|
+
redirectUri: config.redirectUri,
|
|
988
|
+
scopes,
|
|
989
|
+
state,
|
|
990
|
+
challenge
|
|
991
|
+
});
|
|
992
|
+
const listener = awaitCallback({
|
|
993
|
+
redirectUri: config.redirectUri,
|
|
994
|
+
state,
|
|
995
|
+
...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
|
|
996
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
997
|
+
});
|
|
998
|
+
opts.logger?.warn?.(`Open this URL to authorize x-api-mcp:\n${authorizeUrl}`);
|
|
999
|
+
if (opts.openBrowser) await opts.openBrowser(authorizeUrl);
|
|
1000
|
+
const code = await listener.code;
|
|
1001
|
+
const res = await createOAuthClient(config, fetchImpl).exchangeCode(code, verifier);
|
|
1002
|
+
let userId;
|
|
1003
|
+
let username;
|
|
1004
|
+
try {
|
|
1005
|
+
const body = await (await fetchImpl(`${config.baseUrl.replace(/\/+$/, "")}/2/users/me`, { headers: {
|
|
1006
|
+
Authorization: `Bearer ${res.access_token}`,
|
|
1007
|
+
Accept: "application/json"
|
|
1008
|
+
} })).json();
|
|
1009
|
+
userId = body.data?.id;
|
|
1010
|
+
username = body.data?.username;
|
|
1011
|
+
} catch {}
|
|
1012
|
+
const tokens = toStoredTokens(res, {
|
|
1013
|
+
clientId: config.clientId,
|
|
1014
|
+
requestedScopes: scopes,
|
|
1015
|
+
now: now(),
|
|
1016
|
+
...userId ? { userId } : {},
|
|
1017
|
+
...username ? { username } : {}
|
|
1018
|
+
});
|
|
1019
|
+
store.write(tokens);
|
|
1020
|
+
return {
|
|
1021
|
+
tokens,
|
|
1022
|
+
authorizeUrl
|
|
1023
|
+
};
|
|
1024
|
+
};
|
|
1025
|
+
//#endregion
|
|
1026
|
+
//#region src/client/auth.ts
|
|
1027
|
+
/**
|
|
1028
|
+
* The app-only Bearer token, copied from the developer portal. It never
|
|
1029
|
+
* expires and cannot be reminted, so `invalidate` is a no-op: a 401 here means
|
|
1030
|
+
* the token is wrong, and retrying with the same string would only burn the
|
|
1031
|
+
* retry budget.
|
|
1032
|
+
*/
|
|
1033
|
+
const bearerTokenProvider = (token) => ({
|
|
1034
|
+
getToken: async (context) => {
|
|
1035
|
+
if (context === "user") throw new UserContextRequiredError("This tool", "only an app-only Bearer token is configured");
|
|
1036
|
+
return token;
|
|
1037
|
+
},
|
|
1038
|
+
invalidate: () => {},
|
|
1039
|
+
describe: () => ({
|
|
1040
|
+
app: true,
|
|
1041
|
+
user: {
|
|
1042
|
+
authenticated: false,
|
|
1043
|
+
reason: "no OAuth2 client id configured"
|
|
1044
|
+
}
|
|
1045
|
+
})
|
|
1046
|
+
});
|
|
1047
|
+
/**
|
|
1048
|
+
* An OAuth2 user token, refreshed on demand.
|
|
1049
|
+
*
|
|
1050
|
+
* Two rules make this safe against X's rotating refresh tokens, which are
|
|
1051
|
+
* invalidated the instant a refresh succeeds:
|
|
1052
|
+
*
|
|
1053
|
+
* 1. **Persist before use.** The new pair is written to disk before the access
|
|
1054
|
+
* token is handed to the caller. Handing it out first and then crashing
|
|
1055
|
+
* leaves the on-disk refresh token already dead, forcing a re-login.
|
|
1056
|
+
* 2. **Keep one generation.** A refresh that fails on the current token is
|
|
1057
|
+
* retried once with `previousRefreshToken`, which recovers exactly the
|
|
1058
|
+
* crash-between-response-and-write window rather than dumping the user back
|
|
1059
|
+
* into a browser.
|
|
1060
|
+
*
|
|
1061
|
+
* A single in-flight promise coordinates concurrent callers: unlike a locally
|
|
1062
|
+
* signed JWT, an OAuth refresh is a network call that must not be issued twice
|
|
1063
|
+
* — the second would present a token the first had just invalidated.
|
|
1064
|
+
*/
|
|
1065
|
+
const userTokenProvider = (opts) => {
|
|
1066
|
+
const now = opts.now ?? Date.now;
|
|
1067
|
+
/** Refresh a minute early, so a token cannot expire mid-flight. */
|
|
1068
|
+
const SKEW_MS = 6e4;
|
|
1069
|
+
let inFlight;
|
|
1070
|
+
const refresh = async (tokens) => {
|
|
1071
|
+
const candidates = [tokens.refreshToken, tokens.previousRefreshToken].filter((t) => typeof t === "string" && t.length > 0);
|
|
1072
|
+
if (candidates.length === 0) throw new UserContextRequiredError("This tool", "the stored token has expired and carries no refresh token — the `offline.access` scope was probably not granted");
|
|
1073
|
+
let lastError;
|
|
1074
|
+
for (const [index, candidate] of candidates.entries()) try {
|
|
1075
|
+
const next = toStoredTokens(await opts.oauth.refresh(candidate), {
|
|
1076
|
+
clientId: opts.clientId,
|
|
1077
|
+
requestedScopes: tokens.scopes,
|
|
1078
|
+
now: now(),
|
|
1079
|
+
previousRefreshToken: candidate,
|
|
1080
|
+
...tokens.userId ? { userId: tokens.userId } : {},
|
|
1081
|
+
...tokens.username ? { username: tokens.username } : {}
|
|
1082
|
+
});
|
|
1083
|
+
opts.store.write(next);
|
|
1084
|
+
return next.accessToken;
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
lastError = err;
|
|
1087
|
+
if (index === 0 && candidates.length > 1) opts.logger?.warn?.("[x-api] refresh failed on the current token; retrying with the previous generation");
|
|
1088
|
+
}
|
|
1089
|
+
throw new UserContextRequiredError("This tool", `refreshing the stored token failed (${lastError instanceof Error ? lastError.message : String(lastError)})`);
|
|
1090
|
+
};
|
|
1091
|
+
const resolve = async () => {
|
|
1092
|
+
const tokens = opts.store.read();
|
|
1093
|
+
const staleness = tokensAreStale(tokens, opts.clientId, opts.requiredScopes);
|
|
1094
|
+
if (staleness.stale) throw new UserContextRequiredError("This tool", staleness.reason);
|
|
1095
|
+
const current = tokens;
|
|
1096
|
+
if (current.expiresAt - SKEW_MS > now()) return current.accessToken;
|
|
1097
|
+
return refresh(current);
|
|
1098
|
+
};
|
|
1099
|
+
return {
|
|
1100
|
+
getToken: async (context) => {
|
|
1101
|
+
if (context === "app") return resolve();
|
|
1102
|
+
if (!inFlight) inFlight = resolve().finally(() => {
|
|
1103
|
+
inFlight = void 0;
|
|
1104
|
+
});
|
|
1105
|
+
return inFlight;
|
|
1106
|
+
},
|
|
1107
|
+
invalidate: () => {
|
|
1108
|
+
const tokens = opts.store.read();
|
|
1109
|
+
if (tokens) opts.store.write({
|
|
1110
|
+
...tokens,
|
|
1111
|
+
expiresAt: 0
|
|
1112
|
+
});
|
|
1113
|
+
inFlight = void 0;
|
|
1114
|
+
},
|
|
1115
|
+
describe: () => {
|
|
1116
|
+
const tokens = opts.store.read();
|
|
1117
|
+
const staleness = tokensAreStale(tokens, opts.clientId, opts.requiredScopes);
|
|
1118
|
+
if (staleness.stale) return {
|
|
1119
|
+
app: false,
|
|
1120
|
+
user: {
|
|
1121
|
+
authenticated: false,
|
|
1122
|
+
reason: staleness.reason
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
const current = tokens;
|
|
1126
|
+
return {
|
|
1127
|
+
app: false,
|
|
1128
|
+
user: {
|
|
1129
|
+
authenticated: true,
|
|
1130
|
+
...current.username ? { username: current.username } : {},
|
|
1131
|
+
...current.userId ? { userId: current.userId } : {},
|
|
1132
|
+
scopes: current.scopes,
|
|
1133
|
+
expiresAt: current.expiresAt
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
};
|
|
1139
|
+
/**
|
|
1140
|
+
* Combine whichever providers are configured, dispatching by context. Either
|
|
1141
|
+
* side may be absent — a Bearer-only install is the common case, and a
|
|
1142
|
+
* user-only install is legitimate too (an OAuth2 access token can read
|
|
1143
|
+
* everything an app-only token can).
|
|
1144
|
+
*/
|
|
1145
|
+
const compositeTokenProvider = (parts) => ({
|
|
1146
|
+
getToken: async (context) => {
|
|
1147
|
+
if (context === "user") {
|
|
1148
|
+
if (!parts.user) throw new UserContextRequiredError("This tool", "no OAuth2 client id is configured");
|
|
1149
|
+
return parts.user.getToken("user");
|
|
1150
|
+
}
|
|
1151
|
+
if (parts.app) return parts.app.getToken("app");
|
|
1152
|
+
if (parts.user) return parts.user.getToken("user");
|
|
1153
|
+
throw new Error("No credentials configured. Set X_API_BEARER_TOKEN, or X_API_CLIENT_ID and run `x-api-mcp login`.");
|
|
1154
|
+
},
|
|
1155
|
+
invalidate: (context) => {
|
|
1156
|
+
if (context === "user") parts.user?.invalidate("user");
|
|
1157
|
+
else parts.app?.invalidate("app");
|
|
1158
|
+
},
|
|
1159
|
+
describe: () => ({
|
|
1160
|
+
app: parts.app?.describe().app ?? false,
|
|
1161
|
+
user: parts.user?.describe().user ?? {
|
|
1162
|
+
authenticated: false,
|
|
1163
|
+
reason: "no OAuth2 client id configured"
|
|
1164
|
+
}
|
|
1165
|
+
})
|
|
1166
|
+
});
|
|
1167
|
+
/** The test double: one token, both contexts, no network. */
|
|
1168
|
+
const staticTokenProvider = (token) => ({
|
|
1169
|
+
getToken: async () => token,
|
|
1170
|
+
invalidate: () => {},
|
|
1171
|
+
describe: () => ({
|
|
1172
|
+
app: true,
|
|
1173
|
+
user: {
|
|
1174
|
+
authenticated: true,
|
|
1175
|
+
username: "test",
|
|
1176
|
+
userId: "1",
|
|
1177
|
+
scopes: [],
|
|
1178
|
+
expiresAt: 0
|
|
1179
|
+
}
|
|
1180
|
+
})
|
|
1181
|
+
});
|
|
1182
|
+
//#endregion
|
|
1183
|
+
//#region src/client/cache.ts
|
|
1184
|
+
/**
|
|
1185
|
+
* The dedup window is the UTC calendar day, not a rolling 24 hours — so the
|
|
1186
|
+
* whole cache turns over at once at UTC midnight rather than expiring entry by
|
|
1187
|
+
* entry. Comparing a stored day string is both cheaper and more faithful than
|
|
1188
|
+
* per-entry timestamps.
|
|
1189
|
+
*/
|
|
1190
|
+
const utcDay = (now) => new Date(now).toISOString().slice(0, 10);
|
|
1191
|
+
const keyOf = (kind, id) => `${kind}:${id}`;
|
|
1192
|
+
const createDayCache = (opts) => {
|
|
1193
|
+
const now = opts.now ?? Date.now;
|
|
1194
|
+
const enabled = opts.enabled ?? true;
|
|
1195
|
+
let day = utcDay(now());
|
|
1196
|
+
let hits = 0;
|
|
1197
|
+
let misses = 0;
|
|
1198
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1199
|
+
const rollover = () => {
|
|
1200
|
+
const today = utcDay(now());
|
|
1201
|
+
if (today !== day) {
|
|
1202
|
+
entries.clear();
|
|
1203
|
+
day = today;
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
return {
|
|
1207
|
+
get(kind, id) {
|
|
1208
|
+
if (!enabled) return void 0;
|
|
1209
|
+
rollover();
|
|
1210
|
+
const key = keyOf(kind, id);
|
|
1211
|
+
if (!entries.has(key)) {
|
|
1212
|
+
misses += 1;
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
hits += 1;
|
|
1216
|
+
const value = entries.get(key);
|
|
1217
|
+
entries.delete(key);
|
|
1218
|
+
entries.set(key, value);
|
|
1219
|
+
return value;
|
|
1220
|
+
},
|
|
1221
|
+
set(kind, id, value) {
|
|
1222
|
+
if (!enabled || opts.maxEntries === 0) return;
|
|
1223
|
+
rollover();
|
|
1224
|
+
const key = keyOf(kind, id);
|
|
1225
|
+
entries.delete(key);
|
|
1226
|
+
entries.set(key, value);
|
|
1227
|
+
while (entries.size > opts.maxEntries) {
|
|
1228
|
+
const oldest = entries.keys().next();
|
|
1229
|
+
if (oldest.done) break;
|
|
1230
|
+
entries.delete(oldest.value);
|
|
1231
|
+
}
|
|
1232
|
+
},
|
|
1233
|
+
stats() {
|
|
1234
|
+
rollover();
|
|
1235
|
+
const total = hits + misses;
|
|
1236
|
+
return {
|
|
1237
|
+
day,
|
|
1238
|
+
entries: entries.size,
|
|
1239
|
+
hits,
|
|
1240
|
+
misses,
|
|
1241
|
+
hit_rate: total === 0 ? 0 : Math.round(hits / total * 100) / 100
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
};
|
|
1246
|
+
//#endregion
|
|
1247
|
+
//#region src/client/cost.ts
|
|
1248
|
+
const rate = (pricing, kind) => kind === "post" ? pricing.postRead : kind === "user" ? pricing.userRead : pricing.ownedRead;
|
|
1249
|
+
const round = (n) => Math.round(n * 1e3) / 1e3;
|
|
1250
|
+
const createLedger = (opts) => {
|
|
1251
|
+
const now = opts.now ?? Date.now;
|
|
1252
|
+
let day = utcDay(now());
|
|
1253
|
+
let paid = /* @__PURE__ */ new Set();
|
|
1254
|
+
const counts = {
|
|
1255
|
+
post: 0,
|
|
1256
|
+
user: 0,
|
|
1257
|
+
owned: 0,
|
|
1258
|
+
freeFromDedup: 0,
|
|
1259
|
+
created: 0
|
|
1260
|
+
};
|
|
1261
|
+
let spent = 0;
|
|
1262
|
+
const rollover = () => {
|
|
1263
|
+
const today = utcDay(now());
|
|
1264
|
+
if (today !== day) {
|
|
1265
|
+
paid = /* @__PURE__ */ new Set();
|
|
1266
|
+
day = today;
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
return {
|
|
1270
|
+
record(kind, ids) {
|
|
1271
|
+
rollover();
|
|
1272
|
+
const billable = [];
|
|
1273
|
+
const free = [];
|
|
1274
|
+
for (const id of ids) {
|
|
1275
|
+
const key = `${kind}:${id}`;
|
|
1276
|
+
if (paid.has(key)) {
|
|
1277
|
+
free.push(id);
|
|
1278
|
+
counts.freeFromDedup += 1;
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
paid.add(key);
|
|
1282
|
+
billable.push(id);
|
|
1283
|
+
counts[kind] += 1;
|
|
1284
|
+
spent += rate(opts.pricing, kind);
|
|
1285
|
+
}
|
|
1286
|
+
return {
|
|
1287
|
+
billable,
|
|
1288
|
+
free
|
|
1289
|
+
};
|
|
1290
|
+
},
|
|
1291
|
+
estimate(kind, ids) {
|
|
1292
|
+
rollover();
|
|
1293
|
+
return ids.filter((id) => !paid.has(`${kind}:${id}`)).length * rate(opts.pricing, kind);
|
|
1294
|
+
},
|
|
1295
|
+
estimateCount(kind, count) {
|
|
1296
|
+
return count * rate(opts.pricing, kind);
|
|
1297
|
+
},
|
|
1298
|
+
recordCreate(hasUrl) {
|
|
1299
|
+
counts.created += 1;
|
|
1300
|
+
spent += hasUrl ? opts.pricing.postCreateWithUrl : opts.pricing.postCreate;
|
|
1301
|
+
},
|
|
1302
|
+
spentUsd: () => round(spent),
|
|
1303
|
+
report(cache) {
|
|
1304
|
+
rollover();
|
|
1305
|
+
const reads = counts.post + counts.user + counts.owned;
|
|
1306
|
+
return {
|
|
1307
|
+
day,
|
|
1308
|
+
since_process_start: {
|
|
1309
|
+
billable_post_reads: counts.post,
|
|
1310
|
+
billable_user_reads: counts.user,
|
|
1311
|
+
owned_reads: counts.owned,
|
|
1312
|
+
free_from_dedup: counts.freeFromDedup,
|
|
1313
|
+
posts_created: counts.created,
|
|
1314
|
+
estimated_usd: round(spent)
|
|
1315
|
+
},
|
|
1316
|
+
read_cap: {
|
|
1317
|
+
monthly_cap: opts.pricing.monthlyReadCap,
|
|
1318
|
+
reads_this_session: reads,
|
|
1319
|
+
cap_used_pct: Math.round(reads / opts.pricing.monthlyReadCap * 1e4) / 100
|
|
1320
|
+
},
|
|
1321
|
+
...opts.budgetUsd !== void 0 ? { budget: {
|
|
1322
|
+
limit_usd: opts.budgetUsd,
|
|
1323
|
+
remaining_usd: round(Math.max(0, opts.budgetUsd - spent))
|
|
1324
|
+
} } : {},
|
|
1325
|
+
cache,
|
|
1326
|
+
pricing: opts.pricing,
|
|
1327
|
+
disclaimer: "Estimated locally from X's published pay-per-use rates and counted only since this process started — it is not persisted across restarts, and does not know about spend from other clients. The X developer console (console.x.com) is the authoritative record."
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
};
|
|
1331
|
+
};
|
|
1332
|
+
//#endregion
|
|
1333
|
+
//#region src/client/shape.ts
|
|
1334
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1335
|
+
const str = (value) => typeof value === "string" && value ? value : void 0;
|
|
1336
|
+
const num = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1337
|
+
const indexBy = (items, key) => {
|
|
1338
|
+
const map = /* @__PURE__ */ new Map();
|
|
1339
|
+
if (!Array.isArray(items)) return map;
|
|
1340
|
+
for (const item of items) {
|
|
1341
|
+
if (!isRecord(item)) continue;
|
|
1342
|
+
const id = str(item[key]);
|
|
1343
|
+
if (id) map.set(id, item);
|
|
1344
|
+
}
|
|
1345
|
+
return map;
|
|
1346
|
+
};
|
|
1347
|
+
/**
|
|
1348
|
+
* Build the lookup tables from one `includes` block or several (paginated reads
|
|
1349
|
+
* return one per page).
|
|
1350
|
+
*
|
|
1351
|
+
* These are `Map`s rather than an `Array.find` per lookup on purpose: a 100-post
|
|
1352
|
+
* page with 100 distinct authors would otherwise be quadratic, and the whole
|
|
1353
|
+
* point of this module is that it stays cheap on the largest responses.
|
|
1354
|
+
*/
|
|
1355
|
+
const buildIncludesIndex = (includes) => {
|
|
1356
|
+
const blocks = Array.isArray(includes) ? includes : includes ? [includes] : [];
|
|
1357
|
+
const index = {
|
|
1358
|
+
users: /* @__PURE__ */ new Map(),
|
|
1359
|
+
tweets: /* @__PURE__ */ new Map(),
|
|
1360
|
+
media: /* @__PURE__ */ new Map()
|
|
1361
|
+
};
|
|
1362
|
+
for (const block of blocks) {
|
|
1363
|
+
for (const [id, user] of indexBy(block.users, "id")) index.users.set(id, user);
|
|
1364
|
+
for (const [id, tweet] of indexBy(block.tweets, "id")) index.tweets.set(id, tweet);
|
|
1365
|
+
for (const [key, media] of indexBy(block.media, "media_key")) index.media.set(key, media);
|
|
1366
|
+
}
|
|
1367
|
+
return index;
|
|
1368
|
+
};
|
|
1369
|
+
/** Pull the `includes` block out of a raw response envelope. */
|
|
1370
|
+
const includesOf = (response) => isRecord(response) && isRecord(response.includes) ? response.includes : void 0;
|
|
1371
|
+
/**
|
|
1372
|
+
* "@handle (Display Name)" in one field, because the model reads it as a name
|
|
1373
|
+
* and never has to look one up. An unresolved author degrades rather than
|
|
1374
|
+
* throwing — a deleted or suspended account is routine, not exceptional.
|
|
1375
|
+
*/
|
|
1376
|
+
const formatAuthor = (authorId, index) => {
|
|
1377
|
+
if (!authorId) return "@unknown";
|
|
1378
|
+
const user = index.users.get(authorId);
|
|
1379
|
+
const username = user ? str(user.username) : void 0;
|
|
1380
|
+
if (!username) return `@unknown (id ${authorId})`;
|
|
1381
|
+
const name = user ? str(user.name) : void 0;
|
|
1382
|
+
return name ? `@${username} (${name})` : `@${username}`;
|
|
1383
|
+
};
|
|
1384
|
+
const postUrl = (authorId, id, index) => {
|
|
1385
|
+
return `https://x.com/${(authorId ? str(index.users.get(authorId)?.username) : void 0) ?? "i/web"}/status/${id}`;
|
|
1386
|
+
};
|
|
1387
|
+
/**
|
|
1388
|
+
* Replace each t.co link with where it actually points.
|
|
1389
|
+
*
|
|
1390
|
+
* Splices run right-to-left by `start` so earlier offsets stay valid as the
|
|
1391
|
+
* string changes length. The offsets are UTF-16 code units, which is exactly
|
|
1392
|
+
* what `String.prototype.slice` counts — no conversion needed. That is worth
|
|
1393
|
+
* saying out loud, because "fixing" this to use code points is a natural-looking
|
|
1394
|
+
* change that would corrupt every post containing an emoji.
|
|
1395
|
+
*/
|
|
1396
|
+
const expandUrls = (text, raw) => {
|
|
1397
|
+
const entities = isRecord(raw.entities) ? raw.entities : void 0;
|
|
1398
|
+
const spans = (entities && Array.isArray(entities.urls) ? entities.urls : []).filter(isRecord).map((u) => ({
|
|
1399
|
+
start: num(u.start),
|
|
1400
|
+
end: num(u.end),
|
|
1401
|
+
expanded: str(u.expanded_url) ?? str(u.url)
|
|
1402
|
+
})).filter((u) => u.start !== void 0 && u.end !== void 0 && u.expanded !== void 0).toSorted((a, b) => b.start - a.start);
|
|
1403
|
+
let out = text;
|
|
1404
|
+
for (const span of spans) {
|
|
1405
|
+
if (span.start < 0 || span.end > out.length || span.start > span.end) continue;
|
|
1406
|
+
out = out.slice(0, span.start) + span.expanded + out.slice(span.end);
|
|
1407
|
+
}
|
|
1408
|
+
return out;
|
|
1409
|
+
};
|
|
1410
|
+
const shapeMedia = (raw, index) => {
|
|
1411
|
+
const attachments = isRecord(raw.attachments) ? raw.attachments : void 0;
|
|
1412
|
+
const keys = attachments && Array.isArray(attachments.media_keys) ? attachments.media_keys : [];
|
|
1413
|
+
const items = [];
|
|
1414
|
+
for (const key of keys) {
|
|
1415
|
+
const k = str(key);
|
|
1416
|
+
if (!k) continue;
|
|
1417
|
+
const media = index.media.get(k);
|
|
1418
|
+
if (!media) {
|
|
1419
|
+
items.push(`media (not expanded): ${k}`);
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
const type = str(media.type) ?? "media";
|
|
1423
|
+
const url = str(media.url) ?? str(media.preview_image_url);
|
|
1424
|
+
const alt = str(media.alt_text);
|
|
1425
|
+
items.push(`${type}${url ? `: ${url}` : ""}${alt ? ` (alt: ${alt})` : ""}`);
|
|
1426
|
+
}
|
|
1427
|
+
return items.length > 0 ? items : void 0;
|
|
1428
|
+
};
|
|
1429
|
+
const shapeMetrics = (raw) => {
|
|
1430
|
+
const m = isRecord(raw.public_metrics) ? raw.public_metrics : void 0;
|
|
1431
|
+
if (!m) return void 0;
|
|
1432
|
+
const metrics = {
|
|
1433
|
+
likes: num(m.like_count),
|
|
1434
|
+
reposts: num(m.retweet_count),
|
|
1435
|
+
replies: num(m.reply_count),
|
|
1436
|
+
quotes: num(m.quote_count),
|
|
1437
|
+
views: num(m.impression_count)
|
|
1438
|
+
};
|
|
1439
|
+
const entries = Object.entries(metrics).filter(([, v]) => v !== void 0);
|
|
1440
|
+
return entries.length > 0 ? Object.fromEntries(entries) : void 0;
|
|
1441
|
+
};
|
|
1442
|
+
/**
|
|
1443
|
+
* Resolve one referenced post from `includes.tweets`. Deliberately one hop and
|
|
1444
|
+
* no recursion: X only sideloads a single level anyway, and a
|
|
1445
|
+
* quote-of-a-quote-of-a-quote expanded in place is a context-window bomb for no
|
|
1446
|
+
* added meaning.
|
|
1447
|
+
*/
|
|
1448
|
+
const shapeRef = (id, index) => {
|
|
1449
|
+
const raw = index.tweets.get(id);
|
|
1450
|
+
if (!raw) return { id };
|
|
1451
|
+
const text = str(raw.text);
|
|
1452
|
+
const authorId = str(raw.author_id);
|
|
1453
|
+
return {
|
|
1454
|
+
id,
|
|
1455
|
+
...authorId ? { author: formatAuthor(authorId, index) } : {},
|
|
1456
|
+
...text ? { text: expandUrls(text, raw) } : {},
|
|
1457
|
+
...str(raw.created_at) ? { created_at: str(raw.created_at) } : {}
|
|
1458
|
+
};
|
|
1459
|
+
};
|
|
1460
|
+
const shapePost = (raw, index) => {
|
|
1461
|
+
const id = str(raw.id) ?? "";
|
|
1462
|
+
const authorId = str(raw.author_id);
|
|
1463
|
+
const refs = Array.isArray(raw.referenced_tweets) ? raw.referenced_tweets.filter(isRecord) : [];
|
|
1464
|
+
const refOf = (type) => {
|
|
1465
|
+
const ref = refs.find((r) => str(r.type) === type);
|
|
1466
|
+
const refId = ref ? str(ref.id) : void 0;
|
|
1467
|
+
return refId ? shapeRef(refId, index) : void 0;
|
|
1468
|
+
};
|
|
1469
|
+
const reposts = refOf("retweeted");
|
|
1470
|
+
const rawText = str(raw.text) ?? "";
|
|
1471
|
+
const text = reposts?.text ? reposts.text : expandUrls(rawText, raw);
|
|
1472
|
+
const metrics = shapeMetrics(raw);
|
|
1473
|
+
const media = shapeMedia(raw, index);
|
|
1474
|
+
const quotes = refOf("quoted");
|
|
1475
|
+
const repliesTo = refOf("replied_to");
|
|
1476
|
+
return {
|
|
1477
|
+
id,
|
|
1478
|
+
url: postUrl(authorId, id, index),
|
|
1479
|
+
author: formatAuthor(authorId, index),
|
|
1480
|
+
...str(raw.created_at) ? { created_at: str(raw.created_at) } : {},
|
|
1481
|
+
text,
|
|
1482
|
+
...str(raw.lang) ? { lang: str(raw.lang) } : {},
|
|
1483
|
+
...metrics ? { metrics } : {},
|
|
1484
|
+
...quotes ? { quotes } : {},
|
|
1485
|
+
...repliesTo ? { replies_to: repliesTo } : {},
|
|
1486
|
+
...reposts ? { reposts } : {},
|
|
1487
|
+
...media ? { media } : {},
|
|
1488
|
+
...str(raw.conversation_id) ? { conversation_id: str(raw.conversation_id) } : {}
|
|
1489
|
+
};
|
|
1490
|
+
};
|
|
1491
|
+
const shapeUser = (raw) => {
|
|
1492
|
+
const username = str(raw.username) ?? "";
|
|
1493
|
+
const m = isRecord(raw.public_metrics) ? raw.public_metrics : void 0;
|
|
1494
|
+
const metrics = m ? {
|
|
1495
|
+
followers: num(m.followers_count),
|
|
1496
|
+
following: num(m.following_count),
|
|
1497
|
+
posts: num(m.tweet_count),
|
|
1498
|
+
listed: num(m.listed_count)
|
|
1499
|
+
} : void 0;
|
|
1500
|
+
const hasMetrics = metrics && Object.values(metrics).some((v) => v !== void 0);
|
|
1501
|
+
return {
|
|
1502
|
+
id: str(raw.id) ?? "",
|
|
1503
|
+
username,
|
|
1504
|
+
...str(raw.name) ? { name: str(raw.name) } : {},
|
|
1505
|
+
url: `https://x.com/${username}`,
|
|
1506
|
+
...str(raw.description) ? { description: str(raw.description) } : {},
|
|
1507
|
+
...typeof raw.verified === "boolean" ? { verified: raw.verified } : {},
|
|
1508
|
+
...typeof raw.protected === "boolean" ? { protected: raw.protected } : {},
|
|
1509
|
+
...str(raw.location) ? { location: str(raw.location) } : {},
|
|
1510
|
+
...str(raw.created_at) ? { created_at: str(raw.created_at) } : {},
|
|
1511
|
+
...hasMetrics ? { metrics } : {}
|
|
1512
|
+
};
|
|
1513
|
+
};
|
|
1514
|
+
/**
|
|
1515
|
+
* X reports per-id failures in a top-level `errors` array *alongside* a 200, so
|
|
1516
|
+
* asking for five posts and getting three back is a success with a footnote.
|
|
1517
|
+
* Surfacing the missing ids beats letting the model wonder where they went.
|
|
1518
|
+
*/
|
|
1519
|
+
const notFoundIds = (response) => {
|
|
1520
|
+
if (!isRecord(response) || !Array.isArray(response.errors)) return void 0;
|
|
1521
|
+
const ids = response.errors.filter(isRecord).map((e) => str(e.value) ?? str(e.resource_id)).filter((v) => v !== void 0);
|
|
1522
|
+
return ids.length > 0 ? ids : void 0;
|
|
1523
|
+
};
|
|
1524
|
+
/** Flatten a list-of-posts response. Accepts a single- or multi-page envelope. */
|
|
1525
|
+
const shapePostsResponse = (response) => {
|
|
1526
|
+
const index = buildIncludesIndex(includesOf(response));
|
|
1527
|
+
const data = isRecord(response) && Array.isArray(response.data) ? response.data : [];
|
|
1528
|
+
const meta = isRecord(response) && isRecord(response.meta) ? response.meta : void 0;
|
|
1529
|
+
const notFound = notFoundIds(response);
|
|
1530
|
+
return {
|
|
1531
|
+
posts: data.filter(isRecord).map((raw) => shapePost(raw, index)),
|
|
1532
|
+
...meta && num(meta.result_count) !== void 0 ? { result_count: num(meta.result_count) } : {},
|
|
1533
|
+
...meta && str(meta.next_token) ? { next_token: str(meta.next_token) } : {},
|
|
1534
|
+
...notFound ? { not_found: notFound } : {}
|
|
1535
|
+
};
|
|
1536
|
+
};
|
|
1537
|
+
/** Flatten a single-post response. */
|
|
1538
|
+
const shapePostResponse = (response) => {
|
|
1539
|
+
const index = buildIncludesIndex(includesOf(response));
|
|
1540
|
+
const data = isRecord(response) && isRecord(response.data) ? response.data : void 0;
|
|
1541
|
+
if (!data) {
|
|
1542
|
+
const notFound = notFoundIds(response);
|
|
1543
|
+
return { error: notFound ? `X returned no post for id ${notFound.join(", ")} — it is deleted, protected, or from a suspended account.` : "X returned no post for that id." };
|
|
1544
|
+
}
|
|
1545
|
+
return shapePost(data, index);
|
|
1546
|
+
};
|
|
1547
|
+
const shapeUsersResponse = (response) => {
|
|
1548
|
+
const raw = isRecord(response) ? response.data : void 0;
|
|
1549
|
+
const list = Array.isArray(raw) ? raw : isRecord(raw) ? [raw] : [];
|
|
1550
|
+
const notFound = notFoundIds(response);
|
|
1551
|
+
return {
|
|
1552
|
+
users: list.filter(isRecord).map(shapeUser),
|
|
1553
|
+
...notFound ? { not_found: notFound } : {}
|
|
1554
|
+
};
|
|
1555
|
+
};
|
|
1556
|
+
//#endregion
|
|
1557
|
+
//#region src/client/x.ts
|
|
1558
|
+
/**
|
|
1559
|
+
* Minimal fetch-based client for the X API v2. Paths are absolute (`/2/tweets`).
|
|
1560
|
+
* Retries a 401 (invalidating the token first) and 429/5xx with exponential
|
|
1561
|
+
* backoff honoring `Retry-After`, and records every response's rate-limit
|
|
1562
|
+
* headers so a 429 can say what it is waiting for.
|
|
1563
|
+
*/
|
|
1564
|
+
var XApiClient = class {
|
|
1565
|
+
baseUrl;
|
|
1566
|
+
tokenProvider;
|
|
1567
|
+
maxRetries;
|
|
1568
|
+
fetchImpl;
|
|
1569
|
+
logger;
|
|
1570
|
+
userAgent;
|
|
1571
|
+
rateLimits = /* @__PURE__ */ new Map();
|
|
1572
|
+
constructor(opts) {
|
|
1573
|
+
this.baseUrl = (opts.baseUrl ?? "https://api.x.com").replace(/\/+$/, "");
|
|
1574
|
+
this.tokenProvider = opts.tokenProvider;
|
|
1575
|
+
this.maxRetries = opts.maxRetries ?? 3;
|
|
1576
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
1577
|
+
this.logger = opts.logger;
|
|
1578
|
+
this.userAgent = opts.userAgent ?? "mcp-x-api-js";
|
|
1579
|
+
}
|
|
1580
|
+
/** Everything the last response said about each endpoint's remaining budget. */
|
|
1581
|
+
rateLimitStatus() {
|
|
1582
|
+
return [...this.rateLimits.values()];
|
|
1583
|
+
}
|
|
1584
|
+
recordRateLimit(method, path, res) {
|
|
1585
|
+
const limit = numberOrUndefined(res.headers.get("x-rate-limit-limit"));
|
|
1586
|
+
const remaining = numberOrUndefined(res.headers.get("x-rate-limit-remaining"));
|
|
1587
|
+
const reset = numberOrUndefined(res.headers.get("x-rate-limit-reset"));
|
|
1588
|
+
if (limit === void 0 && remaining === void 0 && reset === void 0) return;
|
|
1589
|
+
const endpoint = endpointKey(method, path);
|
|
1590
|
+
this.rateLimits.set(endpoint, {
|
|
1591
|
+
endpoint,
|
|
1592
|
+
...limit !== void 0 ? { limit } : {},
|
|
1593
|
+
...remaining !== void 0 ? { remaining } : {},
|
|
1594
|
+
...reset !== void 0 ? {
|
|
1595
|
+
reset,
|
|
1596
|
+
resetAt: (/* @__PURE__ */ new Date(reset * 1e3)).toISOString()
|
|
1597
|
+
} : {}
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
async request(method, path, opts = {}) {
|
|
1601
|
+
const url = `${this.baseUrl}${path}${buildQuery(opts.query)}`;
|
|
1602
|
+
const hasBody = opts.body !== void 0;
|
|
1603
|
+
const auth = opts.auth ?? "app";
|
|
1604
|
+
const res = await withRetry(async () => {
|
|
1605
|
+
const token = await this.tokenProvider.getToken(auth);
|
|
1606
|
+
return this.fetchImpl(url, {
|
|
1607
|
+
method,
|
|
1608
|
+
headers: {
|
|
1609
|
+
Accept: "application/json",
|
|
1610
|
+
Authorization: `Bearer ${token}`,
|
|
1611
|
+
"User-Agent": this.userAgent,
|
|
1612
|
+
...hasBody ? { "Content-Type": "application/json" } : {}
|
|
1613
|
+
},
|
|
1614
|
+
...hasBody ? { body: JSON.stringify(opts.body) } : {}
|
|
1615
|
+
});
|
|
1616
|
+
}, {
|
|
1617
|
+
maxRetries: this.maxRetries,
|
|
1618
|
+
label: `${method} ${url}`,
|
|
1619
|
+
logger: this.logger,
|
|
1620
|
+
onUnauthorized: () => this.tokenProvider.invalidate(auth)
|
|
1621
|
+
});
|
|
1622
|
+
this.recordRateLimit(method, path, res);
|
|
1623
|
+
const text = await res.text();
|
|
1624
|
+
if (!res.ok) throw new XApiRequestError(this.errorMessage(res, method, path, text), {
|
|
1625
|
+
status: res.status,
|
|
1626
|
+
errors: this.parseErrors(text)
|
|
1627
|
+
});
|
|
1628
|
+
if (res.status === 204 || text.trim() === "") return null;
|
|
1629
|
+
return safeJsonParse(text);
|
|
1630
|
+
}
|
|
1631
|
+
get(path, query, auth = "app") {
|
|
1632
|
+
return this.request("GET", path, {
|
|
1633
|
+
query,
|
|
1634
|
+
auth
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
post(path, body, auth = "user") {
|
|
1638
|
+
return this.request("POST", path, {
|
|
1639
|
+
body,
|
|
1640
|
+
auth
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
del(path, auth = "user") {
|
|
1644
|
+
return this.request("DELETE", path, { auth });
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* GET a collection, following `meta.next_token` until the pages run out or a
|
|
1648
|
+
* bound is hit.
|
|
1649
|
+
*
|
|
1650
|
+
* Both bounds exist because unbounded pagination here spends real money: at
|
|
1651
|
+
* $0.005 a post, walking a busy hashtag to the end is a three-figure mistake
|
|
1652
|
+
* an agent can make in one call. `maxItems` is the one callers actually set.
|
|
1653
|
+
*
|
|
1654
|
+
* X carries the cursor in `meta.next_token` and expects it back as
|
|
1655
|
+
* `pagination_token`, so — unlike a `links.next` API — the original query has
|
|
1656
|
+
* to be re-sent on every page rather than replaced.
|
|
1657
|
+
*/
|
|
1658
|
+
async paginate(path, query, opts) {
|
|
1659
|
+
const maxPages = opts.maxPages ?? 10;
|
|
1660
|
+
const collected = [];
|
|
1661
|
+
const includes = [];
|
|
1662
|
+
let token;
|
|
1663
|
+
let pages = 0;
|
|
1664
|
+
let nextToken;
|
|
1665
|
+
for (;;) {
|
|
1666
|
+
const res = await this.request("GET", path, {
|
|
1667
|
+
query: {
|
|
1668
|
+
...query,
|
|
1669
|
+
...token ? { pagination_token: token } : {}
|
|
1670
|
+
},
|
|
1671
|
+
...opts.auth ? { auth: opts.auth } : {}
|
|
1672
|
+
});
|
|
1673
|
+
pages += 1;
|
|
1674
|
+
if (Array.isArray(res?.data)) collected.push(...res.data);
|
|
1675
|
+
if (res?.includes) includes.push(res.includes);
|
|
1676
|
+
const next = res?.meta?.next_token;
|
|
1677
|
+
token = typeof next === "string" && next ? next : void 0;
|
|
1678
|
+
nextToken = token;
|
|
1679
|
+
if (!token || collected.length >= opts.maxItems || pages >= maxPages) break;
|
|
1680
|
+
}
|
|
1681
|
+
return {
|
|
1682
|
+
data: collected.slice(0, opts.maxItems),
|
|
1683
|
+
pages,
|
|
1684
|
+
...nextToken ? { nextToken } : {},
|
|
1685
|
+
includes
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
parseErrors(text) {
|
|
1689
|
+
const parsed = safeJsonParse(text);
|
|
1690
|
+
if (parsed && typeof parsed === "object" && "errors" in parsed) return parsed.errors;
|
|
1691
|
+
return parsed;
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* The three statuses that actually happen get a sentence naming the fix. A
|
|
1695
|
+
* bare "HTTP 403" sends people to the wrong place — usually to re-check a
|
|
1696
|
+
* token that was fine, when the real answer is that their access tier does
|
|
1697
|
+
* not include the endpoint.
|
|
1698
|
+
*/
|
|
1699
|
+
errorMessage(res, method, path, text) {
|
|
1700
|
+
const base = `X API ${method} ${path} failed: HTTP ${res.status} ${res.statusText}`.trim();
|
|
1701
|
+
const parsed = this.parseErrors(text);
|
|
1702
|
+
const detail = Array.isArray(parsed) ? parsed.map((e) => [e.title, e.detail ?? e.message].filter(Boolean).join(" — ")).filter(Boolean).join("; ") : this.problemDetail(parsed);
|
|
1703
|
+
if (res.status === 401) return `${base} — the token was rejected. Check X_API_BEARER_TOKEN, or re-run \`x-api-mcp login\` if this call needed a user context` + (detail ? ` (${detail})` : "");
|
|
1704
|
+
if (res.status === 403) return `${base} — authenticated, but the request was refused. Most often this means the app is not enrolled: at console.x.com open the app, and make sure it is in the Pay-per-use package and the Production environment (a "client-not-enrolled" or "client-forbidden" detail below confirms this). Otherwise, your access tier or this token's scopes do not cover the endpoint — full-archive search needs a paid tier, bookmarks need bookmark.read` + (detail ? ` (${detail})` : "");
|
|
1705
|
+
if (res.status === 429) {
|
|
1706
|
+
const snapshot = this.rateLimits.get(endpointKey(method, path));
|
|
1707
|
+
return `${base} — rate limited${snapshot ? ` (${snapshot.remaining ?? 0}/${snapshot.limit ?? "?"} remaining` + (snapshot.resetAt ? `, resets ${snapshot.resetAt}` : "") + `)` : ""}. Wait for the window to reset, or lower maxResults.`;
|
|
1708
|
+
}
|
|
1709
|
+
return base + (detail ? ` — ${detail}` : "");
|
|
1710
|
+
}
|
|
1711
|
+
problemDetail(parsed) {
|
|
1712
|
+
if (!parsed || typeof parsed !== "object") return "";
|
|
1713
|
+
const p = parsed;
|
|
1714
|
+
return [p.title, p.detail].filter(Boolean).join(" — ");
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
//#endregion
|
|
1718
|
+
//#region src/compose/weighted.ts
|
|
1719
|
+
/** Code point ranges that weigh 100 (i.e. one character). Everything else is 200. */
|
|
1720
|
+
const LIGHT_RANGES = [
|
|
1721
|
+
[0, 4351],
|
|
1722
|
+
[8192, 8205],
|
|
1723
|
+
[8208, 8223],
|
|
1724
|
+
[8242, 8247]
|
|
1725
|
+
];
|
|
1726
|
+
const DEFAULT_WEIGHT = 200;
|
|
1727
|
+
const SCALE = 100;
|
|
1728
|
+
const MAX_WEIGHTED_LENGTH = 280;
|
|
1729
|
+
/**
|
|
1730
|
+
* Every URL costs the same whatever its real length: X rewrites it to t.co.
|
|
1731
|
+
* There is a single value now — the old http/https split was deprecated once
|
|
1732
|
+
* every t.co link became https.
|
|
1733
|
+
*/
|
|
1734
|
+
const TCO_URL_LENGTH = 23;
|
|
1735
|
+
/**
|
|
1736
|
+
* Conservative URL detection. Scheme-ful URLs plus bare `domain.tld/path` for
|
|
1737
|
+
* the TLDs people actually paste. Deliberately narrow: over-matching would
|
|
1738
|
+
* silently under-count a draft (charging 23 for something X treats as plain
|
|
1739
|
+
* text), and a draft rejected at the composer is worse than one that looks a
|
|
1740
|
+
* few characters longer than it is.
|
|
1741
|
+
*/
|
|
1742
|
+
const URL_PATTERN = /\bhttps?:\/\/[^\s<>"']+|\b(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:com|org|net|io|dev|co|ai|app|xyz|me|gg|so|sh|to|tv|fr|uk|de|jp)\b(?:\/[^\s<>"']*)?/gi;
|
|
1743
|
+
const isLight = (codePoint) => LIGHT_RANGES.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
1744
|
+
const EMOJI = /\p{Extended_Pictographic}/u;
|
|
1745
|
+
const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
1746
|
+
/** Weight one grapheme cluster in twitter-text's internal units. */
|
|
1747
|
+
const clusterWeight = (cluster) => {
|
|
1748
|
+
const first = cluster.codePointAt(0);
|
|
1749
|
+
if (first === void 0) return 0;
|
|
1750
|
+
if (EMOJI.test(cluster) || [...cluster].length > 1) return DEFAULT_WEIGHT;
|
|
1751
|
+
return isLight(first) ? SCALE : DEFAULT_WEIGHT;
|
|
1752
|
+
};
|
|
1753
|
+
/**
|
|
1754
|
+
* Count a draft the way X will.
|
|
1755
|
+
*
|
|
1756
|
+
* NFC normalization comes first because twitter-text normalizes first: without
|
|
1757
|
+
* it a decomposed "café" counts 5 instead of 4, and the user is told they have
|
|
1758
|
+
* one character less than they do.
|
|
1759
|
+
*/
|
|
1760
|
+
const weightedLength = (text) => {
|
|
1761
|
+
const normalized = text.normalize("NFC");
|
|
1762
|
+
const urls = [];
|
|
1763
|
+
const spans = [];
|
|
1764
|
+
for (const match of normalized.matchAll(URL_PATTERN)) {
|
|
1765
|
+
if (match.index === void 0) continue;
|
|
1766
|
+
urls.push({
|
|
1767
|
+
url: match[0],
|
|
1768
|
+
countedAs: 23
|
|
1769
|
+
});
|
|
1770
|
+
spans.push([match.index, match.index + match[0].length]);
|
|
1771
|
+
}
|
|
1772
|
+
let total = urls.length * 23 * SCALE;
|
|
1773
|
+
let index = 0;
|
|
1774
|
+
let spanIndex = 0;
|
|
1775
|
+
while (index < normalized.length) {
|
|
1776
|
+
const span = spans[spanIndex];
|
|
1777
|
+
if (span && index === span[0]) {
|
|
1778
|
+
index = span[1];
|
|
1779
|
+
spanIndex += 1;
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
const nextStart = span ? span[0] : normalized.length;
|
|
1783
|
+
const chunk = normalized.slice(index, nextStart);
|
|
1784
|
+
for (const { segment } of segmenter.segment(chunk)) total += clusterWeight(segment);
|
|
1785
|
+
index = nextStart;
|
|
1786
|
+
}
|
|
1787
|
+
const weighted = Math.ceil(total / SCALE);
|
|
1788
|
+
return {
|
|
1789
|
+
weighted,
|
|
1790
|
+
remaining: 280 - weighted,
|
|
1791
|
+
valid: weighted > 0 && weighted <= 280,
|
|
1792
|
+
urls
|
|
1793
|
+
};
|
|
1794
|
+
};
|
|
1795
|
+
//#endregion
|
|
1796
|
+
//#region src/compose/intent.ts
|
|
1797
|
+
/**
|
|
1798
|
+
* X's web intent: a documented, credential-free URL that opens the composer
|
|
1799
|
+
* pre-filled. Nothing is posted until a human clicks Post, which is why it
|
|
1800
|
+
* needs no auth, consumes no API quota and costs nothing.
|
|
1801
|
+
*
|
|
1802
|
+
* The path is `/intent/tweet`, not `/intent/post`. X renamed Tweet to Post
|
|
1803
|
+
* throughout its docs prose but never changed the URL, and `/intent/post` is
|
|
1804
|
+
* undocumented with known edge-case bugs. `twitter.com` 301s to `x.com`, so
|
|
1805
|
+
* there is no reason to emit the legacy domain.
|
|
1806
|
+
*/
|
|
1807
|
+
const INTENT_BASE_URL = "https://x.com/intent/tweet";
|
|
1808
|
+
const stripLeading = (value, char) => value.startsWith(char) ? value.slice(1) : value;
|
|
1809
|
+
/**
|
|
1810
|
+
* What the composer will actually contain, in X's documented assembly order:
|
|
1811
|
+
* text, then url, then hashtags, then "via @handle".
|
|
1812
|
+
*
|
|
1813
|
+
* Validating `text` alone and then handing back a URL the composer rejects is
|
|
1814
|
+
* exactly the bug this module exists to prevent, so counting happens on this
|
|
1815
|
+
* string rather than on the input.
|
|
1816
|
+
*/
|
|
1817
|
+
const assembleComposerText = (input) => {
|
|
1818
|
+
const parts = [input.text.trim()];
|
|
1819
|
+
if (input.url) parts.push(input.url);
|
|
1820
|
+
for (const tag of input.hashtags ?? []) {
|
|
1821
|
+
const clean = stripLeading(tag.trim(), "#");
|
|
1822
|
+
if (clean) parts.push(`#${clean}`);
|
|
1823
|
+
}
|
|
1824
|
+
if (input.via) parts.push(`via @${stripLeading(input.via.trim(), "@")}`);
|
|
1825
|
+
return parts.filter(Boolean).join(" ");
|
|
1826
|
+
};
|
|
1827
|
+
const buildIntentUrl = (input) => {
|
|
1828
|
+
const params = new URLSearchParams();
|
|
1829
|
+
if (input.text) params.set("text", input.text);
|
|
1830
|
+
if (input.url) params.set("url", input.url);
|
|
1831
|
+
const hashtags = (input.hashtags ?? []).map((t) => stripLeading(t.trim(), "#")).filter(Boolean);
|
|
1832
|
+
if (hashtags.length > 0) params.set("hashtags", hashtags.join(","));
|
|
1833
|
+
if (input.via) params.set("via", stripLeading(input.via.trim(), "@"));
|
|
1834
|
+
if (input.inReplyTo) params.set("in_reply_to", input.inReplyTo);
|
|
1835
|
+
if (input.lang) params.set("lang", input.lang);
|
|
1836
|
+
return `${INTENT_BASE_URL}?${params.toString()}`;
|
|
1837
|
+
};
|
|
1838
|
+
const validateIntent = (input) => {
|
|
1839
|
+
const composed = assembleComposerText(input);
|
|
1840
|
+
const { weighted, remaining, valid, urls } = weightedLength(composed);
|
|
1841
|
+
const warnings = [];
|
|
1842
|
+
if (urls.length > 0) warnings.push(`${urls.length} URL${urls.length > 1 ? "s" : ""} counted as ${urls.length * 23} characters (X rewrites every link to a fixed-length t.co URL, whatever its real length).`);
|
|
1843
|
+
if ((input.hashtags?.length ?? 0) > 4) warnings.push("More than four hashtags reads as spam and tends to suppress reach.");
|
|
1844
|
+
if (input.via?.trim().startsWith("@")) warnings.push("Stripped the leading '@' from `via` — X expects a bare handle.");
|
|
1845
|
+
if (input.inReplyTo && !/^\d+$/.test(input.inReplyTo)) warnings.push(`inReplyTo "${input.inReplyTo}" is not a post id. Ids are digits only — the trailing number in a post's URL.`);
|
|
1846
|
+
return {
|
|
1847
|
+
valid,
|
|
1848
|
+
weighted,
|
|
1849
|
+
remaining,
|
|
1850
|
+
composed,
|
|
1851
|
+
intent_url: buildIntentUrl(input),
|
|
1852
|
+
warnings,
|
|
1853
|
+
...valid ? {} : { error: weighted === 0 ? "The post is empty." : `The assembled post is ${weighted} weighted characters, ${-remaining} over X's 280 limit. Note that the url, hashtags and via parts all count.` }
|
|
1854
|
+
};
|
|
1855
|
+
};
|
|
1856
|
+
//#endregion
|
|
1857
|
+
//#region src/compose/open.ts
|
|
1858
|
+
/** Only ever X. This must not become a generic "open whatever the model asked for". */
|
|
1859
|
+
const ALLOWED_ORIGINS = /* @__PURE__ */ new Set(["https://x.com", "https://twitter.com"]);
|
|
1860
|
+
const isHeadless = () => {
|
|
1861
|
+
if (existsSync("/.dockerenv")) return true;
|
|
1862
|
+
if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return true;
|
|
1863
|
+
return false;
|
|
1864
|
+
};
|
|
1865
|
+
const command = (url) => {
|
|
1866
|
+
switch (process.platform) {
|
|
1867
|
+
case "darwin": return {
|
|
1868
|
+
file: "open",
|
|
1869
|
+
args: [url]
|
|
1870
|
+
};
|
|
1871
|
+
case "win32": return {
|
|
1872
|
+
file: "cmd",
|
|
1873
|
+
args: [
|
|
1874
|
+
"/c",
|
|
1875
|
+
"start",
|
|
1876
|
+
"",
|
|
1877
|
+
url
|
|
1878
|
+
]
|
|
1879
|
+
};
|
|
1880
|
+
default: return {
|
|
1881
|
+
file: "xdg-open",
|
|
1882
|
+
args: [url]
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
};
|
|
1886
|
+
/**
|
|
1887
|
+
* Best effort, and deliberately non-throwing: the intent URL is the deliverable,
|
|
1888
|
+
* and Docker, SSH and headless CI are all normal places to run this server.
|
|
1889
|
+
* Callers always return the URL regardless of what this says.
|
|
1890
|
+
*/
|
|
1891
|
+
const openInBrowser = async (url) => {
|
|
1892
|
+
let origin;
|
|
1893
|
+
try {
|
|
1894
|
+
origin = new URL(url).origin;
|
|
1895
|
+
} catch {
|
|
1896
|
+
return {
|
|
1897
|
+
opened: false,
|
|
1898
|
+
reason: "not a valid URL"
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
if (!ALLOWED_ORIGINS.has(origin)) return {
|
|
1902
|
+
opened: false,
|
|
1903
|
+
reason: `refusing to open a non-X origin (${origin})`
|
|
1904
|
+
};
|
|
1905
|
+
if (isHeadless()) return {
|
|
1906
|
+
opened: false,
|
|
1907
|
+
reason: "headless environment — open the URL yourself"
|
|
1908
|
+
};
|
|
1909
|
+
const { file, args } = command(url);
|
|
1910
|
+
return new Promise((resolve) => {
|
|
1911
|
+
execFile(file, args, { timeout: 5e3 }, (err) => {
|
|
1912
|
+
resolve(err ? {
|
|
1913
|
+
opened: false,
|
|
1914
|
+
reason: `${file} failed: ${err.message}`
|
|
1915
|
+
} : { opened: true });
|
|
1916
|
+
});
|
|
1917
|
+
});
|
|
1918
|
+
};
|
|
1919
|
+
//#endregion
|
|
1920
|
+
//#region src/tools/ads/util.ts
|
|
1921
|
+
/**
|
|
1922
|
+
* Ads calls are not metered by X's pay-per-use rates, so they carry a fixed
|
|
1923
|
+
* note rather than a ledger entry. Saying it on every result is deliberate: the
|
|
1924
|
+
* absence of a cost line would otherwise read as "not measured", and the real
|
|
1925
|
+
* point is that the tool is free while the campaigns it manages are not.
|
|
1926
|
+
*/
|
|
1927
|
+
const adsCostNote = () => ({
|
|
1928
|
+
estimated_usd: 0,
|
|
1929
|
+
note: "Ads API calls are not billed under X's pay-per-use read pricing, so this costs nothing and does not appear in x_usage_report. The campaigns it manages spend your advertising budget."
|
|
1930
|
+
});
|
|
1931
|
+
const accountIdArg = z.string().min(1).regex(/^[A-Za-z0-9]+$/, "An ads account id is letters and digits, e.g. \"18ce54d4x5t\".").optional().describe("The ads account to act on, e.g. \"18ce54d4x5t\". Omit it when you have exactly one account or X_ADS_ACCOUNT_ID is set — it is resolved automatically. List them with x_ads_get_accounts.");
|
|
1932
|
+
const entityIdArg = z.string().min(1).regex(/^[A-Za-z0-9]+$/).describe("An ads entity id, e.g. a campaign or line item id like \"8v7jo\".");
|
|
1933
|
+
const adsCountArg = z.number().int().min(1).max(1e3).default(200).describe("How many records to return (1-1000). X's own default is 200.");
|
|
1934
|
+
const adsConfirmArg = z.literal(true).describe("Must be true. Explicit acknowledgement that this changes a live advertising campaign and can spend your advertising budget.");
|
|
1935
|
+
const activateArg = z.boolean().default(false).describe("Create this ACTIVE instead of PAUSED. Defaults to false, which is the safe path: a PAUSED entity spends nothing until you activate it with x_ads_set_entity_status. Set true only when you intend spending to start the moment this call returns.");
|
|
1936
|
+
/**
|
|
1937
|
+
* Budgets are taken in major units and converted here, so a caller never sees a
|
|
1938
|
+
* `*_micro` field on the way in. A factor of a million is not a mistake anyone
|
|
1939
|
+
* catches by reading a number back, so the safest design is one where it cannot
|
|
1940
|
+
* be expressed.
|
|
1941
|
+
*/
|
|
1942
|
+
const budgetArg = z.number().positive().max(1e4).describe("Budget in MAJOR units of the funding instrument's currency — 50 means 50.00, not 50 million. Do NOT multiply by 1,000,000; this server converts to X's *_amount_local_micro field for you. Capped at 10,000 per call.");
|
|
1943
|
+
/** ISO-8601, which the Ads API requires at whole-hour boundaries. */
|
|
1944
|
+
const adsTimeArg = z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:00:00Z$/, "X requires whole hours in ISO-8601 UTC, e.g. \"2026-08-01T00:00:00Z\".").describe("An ISO-8601 UTC time on a whole hour, e.g. \"2026-08-01T00:00:00Z\".");
|
|
1945
|
+
/** Shape one ads entity or list for a tool result. */
|
|
1946
|
+
const shapeAds = (raw, currency) => shapeMoney(adsData(raw) ?? raw, currency);
|
|
1947
|
+
/**
|
|
1948
|
+
* Resolve which ads account a call is about.
|
|
1949
|
+
*
|
|
1950
|
+
* Mirrors `resolveOwnUserId` for timelines: prefer what the caller said, fall
|
|
1951
|
+
* back to configuration, and only then ask X — caching the answer so it happens
|
|
1952
|
+
* at most once per process. Unlike that case there is no token file to persist
|
|
1953
|
+
* into, so the memo lives in the closure and dies with the process, which is
|
|
1954
|
+
* fine for something that costs one request.
|
|
1955
|
+
*
|
|
1956
|
+
* The multi-account branch deliberately refuses rather than guessing. Agency
|
|
1957
|
+
* users hit it immediately, and silently picking the first account would create
|
|
1958
|
+
* campaigns in the wrong client's account — a mistake that spends real money
|
|
1959
|
+
* and is not obvious from the response.
|
|
1960
|
+
*/
|
|
1961
|
+
const createAccountResolver = (client, ads) => {
|
|
1962
|
+
let memo;
|
|
1963
|
+
return async (explicit) => {
|
|
1964
|
+
if (explicit) return explicit;
|
|
1965
|
+
if (ads.accountId) return ads.accountId;
|
|
1966
|
+
if (memo) return memo;
|
|
1967
|
+
const raw = await client.get("/12/accounts", { count: 50 });
|
|
1968
|
+
const list = isRecord$1(raw) && Array.isArray(raw.data) ? raw.data : [];
|
|
1969
|
+
if (list.length === 0) throw new AdsAccessError("The logged-in account has access to no ads accounts, so there is nothing to act on. Either this X user has not been granted a role on an ads account (that is done in ads.x.com, not the developer console), or the app is not approved for the Ads API yet. Check x_auth_status for the setup steps." + (ads.sandbox ? " In the sandbox, call x_ads_create_sandbox_account to make one." : ""), {
|
|
1970
|
+
baseUrl: ads.baseUrl,
|
|
1971
|
+
sandbox: ads.sandbox
|
|
1972
|
+
});
|
|
1973
|
+
if (list.length > 1) {
|
|
1974
|
+
const accounts = list.filter(isRecord$1).map((a) => ({
|
|
1975
|
+
id: a.id,
|
|
1976
|
+
name: a.name
|
|
1977
|
+
}));
|
|
1978
|
+
throw new PreconditionError(`This login can reach ${list.length} ads accounts, so there is no safe default. Pass accountId explicitly, or set X_ADS_ACCOUNT_ID.`, { accounts });
|
|
1979
|
+
}
|
|
1980
|
+
const only = list[0];
|
|
1981
|
+
const id = isRecord$1(only) && typeof only.id === "string" ? only.id : void 0;
|
|
1982
|
+
if (!id) throw new AdsAccessError("X returned an ads account with no id, so it cannot be addressed.", { received: only });
|
|
1983
|
+
memo = id;
|
|
1984
|
+
return id;
|
|
1985
|
+
};
|
|
1986
|
+
};
|
|
1987
|
+
//#endregion
|
|
1988
|
+
//#region src/tools/util.ts
|
|
1989
|
+
const ok = (data) => ({ content: [{
|
|
1990
|
+
type: "text",
|
|
1991
|
+
text: JSON.stringify(data ?? { ok: true }, null, 2)
|
|
1992
|
+
}] });
|
|
1993
|
+
const fail = (message, extra) => ({
|
|
1994
|
+
content: [{
|
|
1995
|
+
type: "text",
|
|
1996
|
+
text: JSON.stringify({
|
|
1997
|
+
error: message,
|
|
1998
|
+
...extra ? { details: extra } : {}
|
|
1999
|
+
}, null, 2)
|
|
2000
|
+
}],
|
|
2001
|
+
isError: true
|
|
2002
|
+
});
|
|
2003
|
+
/** Render a thrown value as a tool error, preserving X's own detail. */
|
|
2004
|
+
const toFailure = (err) => {
|
|
2005
|
+
if (err instanceof XApiRequestError) return fail(err.message, {
|
|
2006
|
+
status: err.status,
|
|
2007
|
+
errors: err.errors
|
|
2008
|
+
});
|
|
2009
|
+
if (err instanceof BudgetExceededError || err instanceof PreconditionError) return fail(err.message, err.details);
|
|
2010
|
+
if (err instanceof UserContextRequiredError || err instanceof WritesDisabledError) return fail(err.message);
|
|
2011
|
+
if (err instanceof Error) {
|
|
2012
|
+
const details = err.details;
|
|
2013
|
+
return fail(err.message, details);
|
|
2014
|
+
}
|
|
2015
|
+
return fail("Unknown error", err);
|
|
2016
|
+
};
|
|
2017
|
+
/** Run a tool body, JSON-formatting the result and turning errors into a tool error. */
|
|
2018
|
+
const wrap = async (fn) => {
|
|
2019
|
+
try {
|
|
2020
|
+
return ok(await fn());
|
|
2021
|
+
} catch (err) {
|
|
2022
|
+
return toFailure(err);
|
|
2023
|
+
}
|
|
2024
|
+
};
|
|
2025
|
+
/**
|
|
2026
|
+
* Every read tool takes this. `maxResults` defaults low and says why in its
|
|
2027
|
+
* own description — an agent that reads the schema learns the cost model
|
|
2028
|
+
* without anyone having to document it elsewhere.
|
|
2029
|
+
*/
|
|
2030
|
+
const maxResultsArg = z.number().int().min(1).max(100).default(10).describe("How many results to return (1-100). Defaults to 10 because X bills about $0.005 per post read, so 100 results costs roughly $0.50. Raise it deliberately.");
|
|
2031
|
+
const postIdArg = z.string().regex(/^\d+$/, "A post id is digits only — the number at the end of a post's URL.").describe("A post (tweet) id: the digits ending its URL, e.g. \"1799000000000000001\".");
|
|
2032
|
+
const usernameArg = z.string().regex(/^@?[A-Za-z0-9_]{1,15}$/, "An X handle is 1-15 characters of letters, digits or _.").describe("An X handle, with or without the leading @, e.g. \"mgcrea\".");
|
|
2033
|
+
const userIdArg = z.string().regex(/^\d+$/).describe("A numeric X user id, e.g. \"44196397\". Prefer `username` unless you already have one.");
|
|
2034
|
+
const paginationTokenArg = z.string().min(1).optional().describe("The `next_token` from a previous call, to fetch the following page.");
|
|
2035
|
+
const confirmArg = z.literal(true).describe("Must be true. Explicit acknowledgement that this posts to X and costs money.");
|
|
2036
|
+
/** Drop undefined values so we never send `{"tweet.fields": undefined}` upstream. */
|
|
2037
|
+
const compact = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
2038
|
+
const stripAt = (handle) => handle.startsWith("@") ? handle.slice(1) : handle;
|
|
2039
|
+
/**
|
|
2040
|
+
* The expansions and field sets every post read asks for. Kept in one place
|
|
2041
|
+
* because the shaping layer's output is only as good as what was requested —
|
|
2042
|
+
* omitting `author_id` here silently degrades every post to "@unknown".
|
|
2043
|
+
*/
|
|
2044
|
+
const POST_QUERY = {
|
|
2045
|
+
expansions: [
|
|
2046
|
+
"author_id",
|
|
2047
|
+
"referenced_tweets.id",
|
|
2048
|
+
"referenced_tweets.id.author_id",
|
|
2049
|
+
"attachments.media_keys"
|
|
2050
|
+
],
|
|
2051
|
+
"tweet.fields": [
|
|
2052
|
+
"created_at",
|
|
2053
|
+
"public_metrics",
|
|
2054
|
+
"entities",
|
|
2055
|
+
"conversation_id",
|
|
2056
|
+
"lang",
|
|
2057
|
+
"referenced_tweets"
|
|
2058
|
+
],
|
|
2059
|
+
"user.fields": [
|
|
2060
|
+
"username",
|
|
2061
|
+
"name",
|
|
2062
|
+
"verified"
|
|
2063
|
+
],
|
|
2064
|
+
"media.fields": [
|
|
2065
|
+
"type",
|
|
2066
|
+
"url",
|
|
2067
|
+
"preview_image_url",
|
|
2068
|
+
"alt_text"
|
|
2069
|
+
]
|
|
2070
|
+
};
|
|
2071
|
+
const USER_QUERY = { "user.fields": [
|
|
2072
|
+
"description",
|
|
2073
|
+
"public_metrics",
|
|
2074
|
+
"verified",
|
|
2075
|
+
"created_at",
|
|
2076
|
+
"location",
|
|
2077
|
+
"protected"
|
|
2078
|
+
] };
|
|
2079
|
+
/**
|
|
2080
|
+
* Guard a read against the configured budget *before* issuing it, so a runaway
|
|
2081
|
+
* agent cannot discover the ceiling by crossing it.
|
|
2082
|
+
*/
|
|
2083
|
+
const assertWithinBudget = (deps, what, estimateUsd) => {
|
|
2084
|
+
if (deps.budgetUsd === void 0) return;
|
|
2085
|
+
const spent = deps.ledger.spentUsd();
|
|
2086
|
+
if (spent + estimateUsd > deps.budgetUsd) throw new BudgetExceededError({
|
|
2087
|
+
estimateUsd,
|
|
2088
|
+
spentUsd: spent,
|
|
2089
|
+
limitUsd: deps.budgetUsd,
|
|
2090
|
+
what
|
|
2091
|
+
});
|
|
2092
|
+
};
|
|
2093
|
+
/**
|
|
2094
|
+
* Serve whatever today's cache already holds and fetch only the rest.
|
|
2095
|
+
*
|
|
2096
|
+
* This mirrors X's own billing rule rather than merely optimizing: within one
|
|
2097
|
+
* UTC day the cached ids would not have been billed again anyway, so a hit is
|
|
2098
|
+
* genuinely free rather than just fast.
|
|
2099
|
+
*/
|
|
2100
|
+
const cachedByIds = async (deps, kind, ids, fetchMissing, label) => {
|
|
2101
|
+
const cached = /* @__PURE__ */ new Map();
|
|
2102
|
+
const missing = [];
|
|
2103
|
+
for (const id of ids) {
|
|
2104
|
+
const hit = deps.cache.get(kind, id);
|
|
2105
|
+
if (hit !== void 0) cached.set(id, hit);
|
|
2106
|
+
else missing.push(id);
|
|
2107
|
+
}
|
|
2108
|
+
if (missing.length > 0) {
|
|
2109
|
+
assertWithinBudget(deps, label, deps.ledger.estimate(kind, missing));
|
|
2110
|
+
const fetched = await fetchMissing(missing);
|
|
2111
|
+
for (const [id, item] of fetched) {
|
|
2112
|
+
deps.cache.set(kind, id, item);
|
|
2113
|
+
cached.set(id, item);
|
|
2114
|
+
}
|
|
2115
|
+
deps.ledger.record(kind, [...fetched.keys()]);
|
|
2116
|
+
}
|
|
2117
|
+
const items = [];
|
|
2118
|
+
const notFound = [];
|
|
2119
|
+
for (const id of ids) {
|
|
2120
|
+
const item = cached.get(id);
|
|
2121
|
+
if (item !== void 0) items.push(item);
|
|
2122
|
+
else notFound.push(id);
|
|
2123
|
+
}
|
|
2124
|
+
const billable = missing.filter((id) => cached.has(id)).length;
|
|
2125
|
+
const free = ids.length - missing.length;
|
|
2126
|
+
return {
|
|
2127
|
+
items,
|
|
2128
|
+
cost: buildCostNote(kind, billable, free, deps),
|
|
2129
|
+
notFound
|
|
2130
|
+
};
|
|
2131
|
+
};
|
|
2132
|
+
const buildCostNote = (kind, billable, free, deps) => {
|
|
2133
|
+
const usd = deps.ledger.estimateCount(kind, billable);
|
|
2134
|
+
return {
|
|
2135
|
+
[kind === "post" ? "billable_post_reads" : kind === "user" ? "billable_user_reads" : "owned_reads"]: billable,
|
|
2136
|
+
free_from_cache: free,
|
|
2137
|
+
estimated_usd: Math.round(usd * 1e3) / 1e3,
|
|
2138
|
+
...free > 0 ? { note: `${free} already read today — X does not bill those again until UTC midnight.` } : {}
|
|
2139
|
+
};
|
|
2140
|
+
};
|
|
2141
|
+
/** Record the cost of a read whose ids were only known after the fact (searches). */
|
|
2142
|
+
const recordResultCost = (deps, kind, ids) => {
|
|
2143
|
+
const { billable, free } = deps.ledger.record(kind, ids);
|
|
2144
|
+
return buildCostNote(kind, billable.length, free.length, deps);
|
|
2145
|
+
};
|
|
2146
|
+
//#endregion
|
|
2147
|
+
//#region src/tools/ads/accounts.ts
|
|
2148
|
+
const registerAdsAccountTools = (server, client, ads, resolveAccount) => {
|
|
2149
|
+
server.registerTool("x_ads_get_accounts", {
|
|
2150
|
+
title: "X: Ads Get Accounts",
|
|
2151
|
+
description: "List the advertising accounts this login can reach, with their name, currency, timezone and approval status. Start here: every other ads tool needs an account id, and the currency and timezone returned here decide how budgets and analytics dates are read.",
|
|
2152
|
+
inputSchema: z.object({
|
|
2153
|
+
count: adsCountArg,
|
|
2154
|
+
withDeleted: z.boolean().default(false).describe("Include deleted accounts. Off by default.")
|
|
2155
|
+
}),
|
|
2156
|
+
annotations: { readOnlyHint: true }
|
|
2157
|
+
}, async ({ count, withDeleted }) => wrap(async () => {
|
|
2158
|
+
const page = await client.paginateCursor("/12/accounts", {
|
|
2159
|
+
count,
|
|
2160
|
+
...withDeleted ? { with_deleted: true } : {}
|
|
2161
|
+
}, { maxItems: count });
|
|
2162
|
+
return {
|
|
2163
|
+
accounts: page.data,
|
|
2164
|
+
environment: ads.sandbox ? "sandbox" : "production",
|
|
2165
|
+
...page.nextCursor ? { next_cursor: page.nextCursor } : {},
|
|
2166
|
+
cost: adsCostNote()
|
|
2167
|
+
};
|
|
2168
|
+
}));
|
|
2169
|
+
server.registerTool("x_ads_get_funding_instruments", {
|
|
2170
|
+
title: "X: Ads Get Funding Instruments",
|
|
2171
|
+
description: "List an account's funding instruments — the payment sources campaigns draw from. A campaign cannot be created without one, and the instrument's `currency` is the currency every budget on that campaign is stated in. Check `able_to_fund` before using one.",
|
|
2172
|
+
inputSchema: z.object({
|
|
2173
|
+
accountId: accountIdArg,
|
|
2174
|
+
count: adsCountArg
|
|
2175
|
+
}),
|
|
2176
|
+
annotations: { readOnlyHint: true }
|
|
2177
|
+
}, async ({ accountId, count }) => wrap(async () => {
|
|
2178
|
+
const id = await resolveAccount(accountId);
|
|
2179
|
+
const page = await client.paginateCursor(`/12/accounts/${id}/funding_instruments`, { count }, { maxItems: count });
|
|
2180
|
+
return {
|
|
2181
|
+
account_id: id,
|
|
2182
|
+
funding_instruments: shapeAds({ data: page.data }),
|
|
2183
|
+
...page.nextCursor ? { next_cursor: page.nextCursor } : {},
|
|
2184
|
+
cost: adsCostNote()
|
|
2185
|
+
};
|
|
2186
|
+
}));
|
|
2187
|
+
if (!ads.sandbox) return;
|
|
2188
|
+
server.registerTool("x_ads_create_sandbox_account", {
|
|
2189
|
+
title: "X: Ads Create Sandbox Account",
|
|
2190
|
+
description: "Create a throwaway ads account in the sandbox, complete with a funding instrument, so the campaign tools can be exercised without spending anything. Sandbox only — this tool is not registered against production.",
|
|
2191
|
+
inputSchema: z.object({}),
|
|
2192
|
+
annotations: {
|
|
2193
|
+
readOnlyHint: false,
|
|
2194
|
+
destructiveHint: false,
|
|
2195
|
+
idempotentHint: false
|
|
2196
|
+
}
|
|
2197
|
+
}, async () => wrap(async () => {
|
|
2198
|
+
const created = await client.post("/12/accounts");
|
|
2199
|
+
const data = isRecord$1(created) ? created.data : void 0;
|
|
2200
|
+
const id = isRecord$1(data) && typeof data.id === "string" ? data.id : void 0;
|
|
2201
|
+
return {
|
|
2202
|
+
account: data,
|
|
2203
|
+
...id ? { next_step: `Pass accountId: "${id}" to the other ads tools, or set X_ADS_ACCOUNT_ID to it. Call x_ads_get_funding_instruments to find the funding instrument id you will need to create a campaign.` } : {},
|
|
2204
|
+
cost: adsCostNote()
|
|
2205
|
+
};
|
|
2206
|
+
}));
|
|
2207
|
+
};
|
|
2208
|
+
//#endregion
|
|
2209
|
+
//#region src/tools/ads/analytics.ts
|
|
2210
|
+
const ENTITIES = [
|
|
2211
|
+
"ACCOUNT",
|
|
2212
|
+
"CAMPAIGN",
|
|
2213
|
+
"FUNDING_INSTRUMENT",
|
|
2214
|
+
"LINE_ITEM",
|
|
2215
|
+
"PROMOTED_ACCOUNT",
|
|
2216
|
+
"PROMOTED_TWEET"
|
|
2217
|
+
];
|
|
2218
|
+
const METRIC_GROUPS = [
|
|
2219
|
+
"ENGAGEMENT",
|
|
2220
|
+
"BILLING",
|
|
2221
|
+
"VIDEO",
|
|
2222
|
+
"MEDIA",
|
|
2223
|
+
"WEB_CONVERSION",
|
|
2224
|
+
"MOBILE_CONVERSION",
|
|
2225
|
+
"LIFE_TIME_VALUE_MOBILE_CONVERSION"
|
|
2226
|
+
];
|
|
2227
|
+
/**
|
|
2228
|
+
* The synchronous endpoint accepts only these three. `PUBLISHER_NETWORK` is a
|
|
2229
|
+
* valid line-item placement but not a valid analytics placement, and passing it
|
|
2230
|
+
* here is rejected — worth pinning in the schema rather than discovering at
|
|
2231
|
+
* runtime.
|
|
2232
|
+
*/
|
|
2233
|
+
const PLACEMENTS$1 = [
|
|
2234
|
+
"ALL_ON_TWITTER",
|
|
2235
|
+
"SPOTLIGHT",
|
|
2236
|
+
"TREND"
|
|
2237
|
+
];
|
|
2238
|
+
const SEGMENTATIONS = [
|
|
2239
|
+
"AGE",
|
|
2240
|
+
"GENDER",
|
|
2241
|
+
"METROS",
|
|
2242
|
+
"PLATFORMS",
|
|
2243
|
+
"CONVERSION_TAGS"
|
|
2244
|
+
];
|
|
2245
|
+
const HOUR = 3600 * 1e3;
|
|
2246
|
+
const SYNC_MAX_DAYS = 7;
|
|
2247
|
+
const SYNC_MAX_ENTITIES = 20;
|
|
2248
|
+
/** X answers job ids as JSON numbers too large for a JS number to hold exactly. */
|
|
2249
|
+
const jobIdOf = (job) => {
|
|
2250
|
+
if (typeof job.id_str === "string" && job.id_str) return job.id_str;
|
|
2251
|
+
if (typeof job.id === "string" && job.id) return job.id;
|
|
2252
|
+
};
|
|
2253
|
+
const registerAdsAnalyticsTools = (server, client, resolveAccount) => {
|
|
2254
|
+
server.registerTool("x_ads_get_stats", {
|
|
2255
|
+
title: "X: Ads Get Stats",
|
|
2256
|
+
description: "Performance metrics for up to 20 entities over at most 7 days, returned immediately. This is the fast path — use it for \"how did this campaign do last week\". For longer ranges, segmentation, or more than 20 entities, use the async job tools instead. Times must be whole hours, and endTime is exclusive.",
|
|
2257
|
+
inputSchema: z.object({
|
|
2258
|
+
accountId: accountIdArg,
|
|
2259
|
+
entity: z.enum(ENTITIES).describe("What kind of thing the ids refer to."),
|
|
2260
|
+
entityIds: z.array(entityIdArg).min(1).max(SYNC_MAX_ENTITIES).describe(`The entities to report on. X allows at most ${SYNC_MAX_ENTITIES} per call.`),
|
|
2261
|
+
startTime: adsTimeArg.describe("Start of the window, ISO-8601 UTC on a whole hour."),
|
|
2262
|
+
endTime: adsTimeArg.describe("End of the window, exclusive. At most 7 days after start."),
|
|
2263
|
+
granularity: z.enum([
|
|
2264
|
+
"DAY",
|
|
2265
|
+
"HOUR",
|
|
2266
|
+
"TOTAL"
|
|
2267
|
+
]).default("DAY").describe("How finely to bucket. DAY and TOTAL expect startTime at midnight in the ACCOUNT's timezone, which is not necessarily UTC — check it with x_ads_get_accounts."),
|
|
2268
|
+
placement: z.enum(PLACEMENTS$1).default("ALL_ON_TWITTER").describe("One placement per call. PUBLISHER_NETWORK is not valid here."),
|
|
2269
|
+
metricGroups: z.array(z.enum(METRIC_GROUPS)).min(1).default(["ENGAGEMENT"]).describe("Which metric families to return. BILLING carries spend.")
|
|
2270
|
+
}),
|
|
2271
|
+
annotations: { readOnlyHint: true }
|
|
2272
|
+
}, async ({ accountId, entity, entityIds, startTime, endTime, granularity, placement, metricGroups }) => wrap(async () => {
|
|
2273
|
+
const span = Date.parse(endTime) - Date.parse(startTime);
|
|
2274
|
+
if (span <= 0) throw new PreconditionError("endTime must be after startTime.", {
|
|
2275
|
+
startTime,
|
|
2276
|
+
endTime
|
|
2277
|
+
});
|
|
2278
|
+
if (span > SYNC_MAX_DAYS * 24 * HOUR) throw new PreconditionError(`The synchronous stats endpoint covers at most ${SYNC_MAX_DAYS} days, and this asks for ${Math.round(span / (24 * HOUR))}. Narrow the window, or use x_ads_create_stats_job, which reaches 90 days.`, {
|
|
2279
|
+
startTime,
|
|
2280
|
+
endTime,
|
|
2281
|
+
maxDays: SYNC_MAX_DAYS
|
|
2282
|
+
});
|
|
2283
|
+
const id = await resolveAccount(accountId);
|
|
2284
|
+
const raw = await client.get(`/12/stats/accounts/${id}`, {
|
|
2285
|
+
entity,
|
|
2286
|
+
entity_ids: entityIds,
|
|
2287
|
+
start_time: startTime,
|
|
2288
|
+
end_time: endTime,
|
|
2289
|
+
granularity,
|
|
2290
|
+
placement,
|
|
2291
|
+
metric_groups: metricGroups
|
|
2292
|
+
});
|
|
2293
|
+
return {
|
|
2294
|
+
account_id: id,
|
|
2295
|
+
entity,
|
|
2296
|
+
granularity,
|
|
2297
|
+
placement,
|
|
2298
|
+
start_time: startTime,
|
|
2299
|
+
end_time: endTime,
|
|
2300
|
+
stats: isRecord$1(raw) ? raw.data : raw,
|
|
2301
|
+
cost: adsCostNote()
|
|
2302
|
+
};
|
|
2303
|
+
}));
|
|
2304
|
+
server.registerTool("x_ads_create_stats_job", {
|
|
2305
|
+
title: "X: Ads Create Stats Job",
|
|
2306
|
+
description: "Queue an asynchronous analytics job, for what the synchronous endpoint cannot do: up to 90 days (45 when segmented), segmentation by age, gender, platform or metro, and more than 20 entities. Returns a job id — poll it with x_ads_get_stats_jobs until its status is SUCCESS, then fetch the numbers with x_ads_download_stats_job. Queuing a job spends nothing and changes nothing.",
|
|
2307
|
+
inputSchema: z.object({
|
|
2308
|
+
accountId: accountIdArg,
|
|
2309
|
+
entity: z.enum(ENTITIES).describe("What kind of thing the ids refer to."),
|
|
2310
|
+
entityIds: z.array(entityIdArg).min(1).max(200).describe("The entities to report on."),
|
|
2311
|
+
startTime: adsTimeArg.describe("Start of the window, ISO-8601 UTC on a whole hour."),
|
|
2312
|
+
endTime: adsTimeArg.describe("End of the window, exclusive."),
|
|
2313
|
+
granularity: z.enum([
|
|
2314
|
+
"DAY",
|
|
2315
|
+
"HOUR",
|
|
2316
|
+
"TOTAL"
|
|
2317
|
+
]).default("DAY").describe("Bucket size."),
|
|
2318
|
+
placement: z.enum(PLACEMENTS$1).default("ALL_ON_TWITTER").describe("One placement per job."),
|
|
2319
|
+
metricGroups: z.array(z.enum(METRIC_GROUPS)).min(1).default(["ENGAGEMENT"]).describe("Which metric families to return."),
|
|
2320
|
+
segmentation: z.enum(SEGMENTATIONS).optional().describe("Break the numbers down by this dimension. Segmented jobs are capped at 45 days. METROS additionally needs `country`."),
|
|
2321
|
+
country: z.string().optional().describe("Targeting-value id of a country. Required when segmentation is METROS.")
|
|
2322
|
+
}),
|
|
2323
|
+
annotations: {
|
|
2324
|
+
readOnlyHint: false,
|
|
2325
|
+
destructiveHint: false,
|
|
2326
|
+
idempotentHint: false
|
|
2327
|
+
}
|
|
2328
|
+
}, async (args) => wrap(async () => {
|
|
2329
|
+
if (args.segmentation === "METROS" && !args.country) throw new PreconditionError("Segmenting by METROS needs a `country`. Find its id with x_ads_search_targeting_options type=locations, locationType=COUNTRIES.");
|
|
2330
|
+
const id = await resolveAccount(args.accountId);
|
|
2331
|
+
const raw = await client.post(`/12/stats/jobs/accounts/${id}`, compact({
|
|
2332
|
+
entity: args.entity,
|
|
2333
|
+
entity_ids: args.entityIds,
|
|
2334
|
+
start_time: args.startTime,
|
|
2335
|
+
end_time: args.endTime,
|
|
2336
|
+
granularity: args.granularity,
|
|
2337
|
+
placement: args.placement,
|
|
2338
|
+
metric_groups: args.metricGroups,
|
|
2339
|
+
segmentation_type: args.segmentation,
|
|
2340
|
+
country: args.country
|
|
2341
|
+
}));
|
|
2342
|
+
const job = isRecord$1(raw) && isRecord$1(raw.data) ? raw.data : {};
|
|
2343
|
+
return {
|
|
2344
|
+
account_id: id,
|
|
2345
|
+
job,
|
|
2346
|
+
job_id: jobIdOf(job),
|
|
2347
|
+
next_step: "Poll x_ads_get_stats_jobs until this job's status is SUCCESS, then call x_ads_download_stats_job with its url. Jobs typically take seconds to minutes.",
|
|
2348
|
+
cost: adsCostNote()
|
|
2349
|
+
};
|
|
2350
|
+
}));
|
|
2351
|
+
server.registerTool("x_ads_get_stats_jobs", {
|
|
2352
|
+
title: "X: Ads Get Stats Jobs",
|
|
2353
|
+
description: "List this account's analytics jobs and their status. A job is ready when its status is SUCCESS, at which point it carries a `url` to pass to x_ads_download_stats_job. Those URLs expire, so re-read the job rather than reusing an old one.",
|
|
2354
|
+
inputSchema: z.object({
|
|
2355
|
+
accountId: accountIdArg,
|
|
2356
|
+
jobIds: z.array(z.string().min(1)).max(200).optional().describe("Only these job ids. Omit to list recent jobs."),
|
|
2357
|
+
count: z.number().int().min(1).max(200).default(50).describe("How many jobs to return.")
|
|
2358
|
+
}),
|
|
2359
|
+
annotations: { readOnlyHint: true }
|
|
2360
|
+
}, async ({ accountId, jobIds, count }) => wrap(async () => {
|
|
2361
|
+
const id = await resolveAccount(accountId);
|
|
2362
|
+
const raw = await client.get(`/12/stats/jobs/accounts/${id}`, compact({
|
|
2363
|
+
count,
|
|
2364
|
+
...jobIds?.length ? { job_ids: jobIds } : {}
|
|
2365
|
+
}));
|
|
2366
|
+
const jobs = isRecord$1(raw) && Array.isArray(raw.data) ? raw.data : [];
|
|
2367
|
+
const ready = jobs.filter(isRecord$1).filter((j) => j.status === "SUCCESS").length;
|
|
2368
|
+
return {
|
|
2369
|
+
account_id: id,
|
|
2370
|
+
jobs,
|
|
2371
|
+
ready_count: ready,
|
|
2372
|
+
...jobs.length > 0 && ready === 0 ? { note: "No job has finished yet. Poll again in a few seconds." } : {},
|
|
2373
|
+
cost: adsCostNote()
|
|
2374
|
+
};
|
|
2375
|
+
}));
|
|
2376
|
+
server.registerTool("x_ads_download_stats_job", {
|
|
2377
|
+
title: "X: Ads Download Stats Job",
|
|
2378
|
+
description: "Fetch and decompress a finished analytics job's results. By default it returns a per-entity summary rather than every row, because a segmented 90-day job is far larger than a useful answer. Set raw to true for the underlying rows, bounded by maxRows. If the download is refused as too large, re-run the job over fewer entities, a shorter range, or a coarser granularity.",
|
|
2379
|
+
inputSchema: z.object({
|
|
2380
|
+
url: z.string().url().describe("The `url` from a SUCCESS job in x_ads_get_stats_jobs. These expire."),
|
|
2381
|
+
raw: z.boolean().default(false).describe("Return the underlying rows instead of the summary."),
|
|
2382
|
+
maxRows: z.number().int().min(1).max(2e3).default(200).describe("Cap on rows returned when raw is true.")
|
|
2383
|
+
}),
|
|
2384
|
+
annotations: { readOnlyHint: true }
|
|
2385
|
+
}, async ({ url, raw, maxRows }) => wrap(async () => {
|
|
2386
|
+
const { text, bytes } = await client.downloadGzipped(url);
|
|
2387
|
+
const parsed = JSON.parse(text);
|
|
2388
|
+
const rows = isRecord$1(parsed) && Array.isArray(parsed.data) ? parsed.data : [];
|
|
2389
|
+
if (raw) return {
|
|
2390
|
+
rows: rows.slice(0, maxRows),
|
|
2391
|
+
row_count: rows.length,
|
|
2392
|
+
truncated: rows.length > maxRows,
|
|
2393
|
+
decompressed_bytes: bytes,
|
|
2394
|
+
cost: adsCostNote()
|
|
2395
|
+
};
|
|
2396
|
+
return {
|
|
2397
|
+
entities: rows.filter(isRecord$1).map((row) => {
|
|
2398
|
+
const series = Array.isArray(row.id_data) ? row.id_data : [];
|
|
2399
|
+
const totals = {};
|
|
2400
|
+
for (const segment of series) {
|
|
2401
|
+
if (!isRecord$1(segment) || !isRecord$1(segment.metrics)) continue;
|
|
2402
|
+
for (const [metric, values] of Object.entries(segment.metrics)) {
|
|
2403
|
+
if (!Array.isArray(values)) continue;
|
|
2404
|
+
const sum = values.reduce((acc, v) => acc + (typeof v === "number" ? v : 0), 0);
|
|
2405
|
+
totals[metric] = (totals[metric] ?? 0) + sum;
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
return {
|
|
2409
|
+
id: row.id,
|
|
2410
|
+
segments: series.length,
|
|
2411
|
+
totals
|
|
2412
|
+
};
|
|
2413
|
+
}),
|
|
2414
|
+
row_count: rows.length,
|
|
2415
|
+
decompressed_bytes: bytes,
|
|
2416
|
+
note: "Totals are summed across every bucket and segment in the job. Pass raw: true for the per-bucket rows.",
|
|
2417
|
+
cost: adsCostNote()
|
|
2418
|
+
};
|
|
2419
|
+
}));
|
|
2420
|
+
};
|
|
2421
|
+
//#endregion
|
|
2422
|
+
//#region src/tools/ads/audiences.ts
|
|
2423
|
+
const registerAdsAudienceTools = (server, client, resolveAccount) => {
|
|
2424
|
+
server.registerTool("x_ads_get_audiences", {
|
|
2425
|
+
title: "X: Ads Get Audiences",
|
|
2426
|
+
description: "List an account's custom audiences with their size and targetability. Use an audience's id as the targetingValue of a CUSTOM_AUDIENCE criterion. Read-only: uploading audience members means handling personal data and is deliberately not exposed here. Note that \"tailored audiences\" is the old name for these and its endpoints are long gone.",
|
|
2427
|
+
inputSchema: z.object({
|
|
2428
|
+
accountId: accountIdArg,
|
|
2429
|
+
count: adsCountArg
|
|
2430
|
+
}),
|
|
2431
|
+
annotations: { readOnlyHint: true }
|
|
2432
|
+
}, async ({ accountId, count }) => wrap(async () => {
|
|
2433
|
+
const id = await resolveAccount(accountId);
|
|
2434
|
+
const page = await client.paginateCursor(`/12/accounts/${id}/custom_audiences`, { count }, { maxItems: count });
|
|
2435
|
+
return {
|
|
2436
|
+
account_id: id,
|
|
2437
|
+
audiences: shapeAds({ data: page.data }),
|
|
2438
|
+
...page.nextCursor ? { next_cursor: page.nextCursor } : {},
|
|
2439
|
+
cost: adsCostNote()
|
|
2440
|
+
};
|
|
2441
|
+
}));
|
|
2442
|
+
};
|
|
2443
|
+
//#endregion
|
|
2444
|
+
//#region src/tools/ads/campaigns.ts
|
|
2445
|
+
/** Line-item enums, verbatim from the v12 reference. */
|
|
2446
|
+
const OBJECTIVES = [
|
|
2447
|
+
"APP_ENGAGEMENTS",
|
|
2448
|
+
"APP_INSTALLS",
|
|
2449
|
+
"REACH",
|
|
2450
|
+
"FOLLOWERS",
|
|
2451
|
+
"ENGAGEMENTS",
|
|
2452
|
+
"VIDEO_VIEWS",
|
|
2453
|
+
"PREROLL_VIEWS",
|
|
2454
|
+
"WEBSITE_CLICKS"
|
|
2455
|
+
];
|
|
2456
|
+
const PRODUCT_TYPES = [
|
|
2457
|
+
"MEDIA",
|
|
2458
|
+
"PROMOTED_ACCOUNT",
|
|
2459
|
+
"PROMOTED_TWEETS"
|
|
2460
|
+
];
|
|
2461
|
+
const PLACEMENTS = [
|
|
2462
|
+
"ALL_ON_TWITTER",
|
|
2463
|
+
"PUBLISHER_NETWORK",
|
|
2464
|
+
"TAP_BANNER",
|
|
2465
|
+
"TAP_FULL",
|
|
2466
|
+
"TAP_FULL_LANDSCAPE",
|
|
2467
|
+
"TAP_NATIVE",
|
|
2468
|
+
"TAP_MRECT",
|
|
2469
|
+
"TWITTER_PROFILE",
|
|
2470
|
+
"TWITTER_REPLIES",
|
|
2471
|
+
"TWITTER_SEARCH",
|
|
2472
|
+
"TWITTER_TIMELINE"
|
|
2473
|
+
];
|
|
2474
|
+
const PATH_FOR = {
|
|
2475
|
+
campaign: "campaigns",
|
|
2476
|
+
line_item: "line_items",
|
|
2477
|
+
promoted_tweet: "promoted_tweets"
|
|
2478
|
+
};
|
|
2479
|
+
/**
|
|
2480
|
+
* What a create tool sends for `entity_status`, and the sentence explaining it.
|
|
2481
|
+
* PAUSED is the default because an ACTIVE campaign begins spending the moment
|
|
2482
|
+
* it exists, and an agent that mis-set a budget should not discover that from
|
|
2483
|
+
* the invoice. `activateImmediately` is the deliberate way out.
|
|
2484
|
+
*/
|
|
2485
|
+
const statusFor = (activate) => activate ? {
|
|
2486
|
+
status: "ACTIVE",
|
|
2487
|
+
note: "Created ACTIVE, as requested — delivery and spending start now."
|
|
2488
|
+
} : {
|
|
2489
|
+
status: "PAUSED",
|
|
2490
|
+
note: "Created PAUSED, so it is spending nothing. Activate it with x_ads_set_entity_status when you are ready."
|
|
2491
|
+
};
|
|
2492
|
+
const registerAdsCampaignTools = (server, client, ads, resolveAccount) => {
|
|
2493
|
+
/** The currency every budget on a campaign is denominated in. */
|
|
2494
|
+
const currencyOf = async (accountId, fundingInstrumentId) => {
|
|
2495
|
+
try {
|
|
2496
|
+
const raw = await client.get(`/12/accounts/${accountId}/funding_instruments/${fundingInstrumentId}`);
|
|
2497
|
+
const data = isRecord$1(raw) ? raw.data : void 0;
|
|
2498
|
+
return isRecord$1(data) && typeof data.currency === "string" ? data.currency : void 0;
|
|
2499
|
+
} catch {
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
};
|
|
2503
|
+
server.registerTool("x_ads_get_campaigns", {
|
|
2504
|
+
title: "X: Ads Get Campaigns",
|
|
2505
|
+
description: "List an account's campaigns with their budgets, funding instrument and status. Budgets come back in both major units (`daily_budget`) and X's raw millionths (`daily_budget_amount_local_micro`) — read the former. Note that in v12 campaigns carry no start or end date; flight dates live on their line items.",
|
|
2506
|
+
inputSchema: z.object({
|
|
2507
|
+
accountId: accountIdArg,
|
|
2508
|
+
campaignIds: z.array(entityIdArg).max(200).optional().describe("Only these campaign ids. Omit to list them all."),
|
|
2509
|
+
count: adsCountArg,
|
|
2510
|
+
withDeleted: z.boolean().default(false).describe("Include deleted campaigns.")
|
|
2511
|
+
}),
|
|
2512
|
+
annotations: { readOnlyHint: true }
|
|
2513
|
+
}, async ({ accountId, campaignIds, count, withDeleted }) => wrap(async () => {
|
|
2514
|
+
const id = await resolveAccount(accountId);
|
|
2515
|
+
const page = await client.paginateCursor(`/12/accounts/${id}/campaigns`, compact({
|
|
2516
|
+
count,
|
|
2517
|
+
...campaignIds?.length ? { campaign_ids: campaignIds } : {},
|
|
2518
|
+
...withDeleted ? { with_deleted: true } : {}
|
|
2519
|
+
}), { maxItems: count });
|
|
2520
|
+
return {
|
|
2521
|
+
account_id: id,
|
|
2522
|
+
campaigns: shapeAds({ data: page.data }),
|
|
2523
|
+
...page.nextCursor ? { next_cursor: page.nextCursor } : {},
|
|
2524
|
+
cost: adsCostNote()
|
|
2525
|
+
};
|
|
2526
|
+
}));
|
|
2527
|
+
server.registerTool("x_ads_get_line_items", {
|
|
2528
|
+
title: "X: Ads Get Line Items",
|
|
2529
|
+
description: "List an account's line items — the ad groups that carry the objective, bid, placements and flight dates under a campaign. Targeting and creatives attach to a line item, not to its campaign, so this is the id you need for x_ads_get_targeting_criteria and x_ads_create_promoted_tweet.",
|
|
2530
|
+
inputSchema: z.object({
|
|
2531
|
+
accountId: accountIdArg,
|
|
2532
|
+
campaignIds: z.array(entityIdArg).max(200).optional().describe("Only line items under these campaigns."),
|
|
2533
|
+
lineItemIds: z.array(entityIdArg).max(200).optional().describe("Only these line item ids."),
|
|
2534
|
+
count: adsCountArg,
|
|
2535
|
+
withDeleted: z.boolean().default(false).describe("Include deleted line items.")
|
|
2536
|
+
}),
|
|
2537
|
+
annotations: { readOnlyHint: true }
|
|
2538
|
+
}, async ({ accountId, campaignIds, lineItemIds, count, withDeleted }) => wrap(async () => {
|
|
2539
|
+
const id = await resolveAccount(accountId);
|
|
2540
|
+
const page = await client.paginateCursor(`/12/accounts/${id}/line_items`, compact({
|
|
2541
|
+
count,
|
|
2542
|
+
...campaignIds?.length ? { campaign_ids: campaignIds } : {},
|
|
2543
|
+
...lineItemIds?.length ? { line_item_ids: lineItemIds } : {},
|
|
2544
|
+
...withDeleted ? { with_deleted: true } : {}
|
|
2545
|
+
}), { maxItems: count });
|
|
2546
|
+
return {
|
|
2547
|
+
account_id: id,
|
|
2548
|
+
line_items: shapeAds({ data: page.data }),
|
|
2549
|
+
...page.nextCursor ? { next_cursor: page.nextCursor } : {},
|
|
2550
|
+
cost: adsCostNote()
|
|
2551
|
+
};
|
|
2552
|
+
}));
|
|
2553
|
+
server.registerTool("x_ads_get_promoted_tweets", {
|
|
2554
|
+
title: "X: Ads Get Promoted Tweets",
|
|
2555
|
+
description: "List the posts promoted under an account's line items. Each entry pairs a line item with the post id it is promoting; look the post itself up with x_get_post if you need its text.",
|
|
2556
|
+
inputSchema: z.object({
|
|
2557
|
+
accountId: accountIdArg,
|
|
2558
|
+
lineItemIds: z.array(entityIdArg).max(200).optional().describe("Only promoted posts under these line items."),
|
|
2559
|
+
count: adsCountArg
|
|
2560
|
+
}),
|
|
2561
|
+
annotations: { readOnlyHint: true }
|
|
2562
|
+
}, async ({ accountId, lineItemIds, count }) => wrap(async () => {
|
|
2563
|
+
const id = await resolveAccount(accountId);
|
|
2564
|
+
const page = await client.paginateCursor(`/12/accounts/${id}/promoted_tweets`, compact({
|
|
2565
|
+
count,
|
|
2566
|
+
...lineItemIds?.length ? { line_item_ids: lineItemIds } : {}
|
|
2567
|
+
}), { maxItems: count });
|
|
2568
|
+
return {
|
|
2569
|
+
account_id: id,
|
|
2570
|
+
promoted_tweets: page.data,
|
|
2571
|
+
...page.nextCursor ? { next_cursor: page.nextCursor } : {},
|
|
2572
|
+
cost: adsCostNote()
|
|
2573
|
+
};
|
|
2574
|
+
}));
|
|
2575
|
+
if (!ads.allowWrites) return;
|
|
2576
|
+
server.registerTool("x_ads_create_campaign", {
|
|
2577
|
+
title: "X: Ads Create Campaign",
|
|
2578
|
+
description: "Create a campaign. SPENDS MONEY once activated. Budgets are given in MAJOR currency units — 50 means 50.00 — and converted to X's millionths for you; never pass a pre-multiplied figure. The campaign is created PAUSED unless you set activateImmediately, so the normal flow is: create, add a line item, add targeting, then activate. In v12 a campaign has no dates of its own; set them on the line item.",
|
|
2579
|
+
inputSchema: z.object({
|
|
2580
|
+
accountId: accountIdArg,
|
|
2581
|
+
fundingInstrumentId: entityIdArg.describe("Which funding instrument pays for this. List them with x_ads_get_funding_instruments."),
|
|
2582
|
+
name: z.string().min(1).max(255).describe("Campaign name, up to 255 characters."),
|
|
2583
|
+
dailyBudget: budgetArg.describe("Daily budget in MAJOR currency units of the funding instrument (50 means 50.00). Do NOT multiply by 1,000,000."),
|
|
2584
|
+
totalBudget: budgetArg.optional().describe("Optional lifetime cap, in the same major units as dailyBudget."),
|
|
2585
|
+
activateImmediately: activateArg,
|
|
2586
|
+
confirm: adsConfirmArg
|
|
2587
|
+
}),
|
|
2588
|
+
annotations: {
|
|
2589
|
+
readOnlyHint: false,
|
|
2590
|
+
destructiveHint: false,
|
|
2591
|
+
idempotentHint: false
|
|
2592
|
+
}
|
|
2593
|
+
}, async ({ accountId, fundingInstrumentId, name, dailyBudget, totalBudget, activateImmediately }) => wrap(async () => {
|
|
2594
|
+
const id = await resolveAccount(accountId);
|
|
2595
|
+
const { status, note } = statusFor(activateImmediately);
|
|
2596
|
+
const dailyMicro = toMicro(dailyBudget);
|
|
2597
|
+
const totalMicro = totalBudget === void 0 ? void 0 : toMicro(totalBudget);
|
|
2598
|
+
const created = await client.post(`/12/accounts/${id}/campaigns`, compact({
|
|
2599
|
+
funding_instrument_id: fundingInstrumentId,
|
|
2600
|
+
name,
|
|
2601
|
+
daily_budget_amount_local_micro: dailyMicro,
|
|
2602
|
+
total_budget_amount_local_micro: totalMicro,
|
|
2603
|
+
entity_status: status
|
|
2604
|
+
}));
|
|
2605
|
+
const currency = await currencyOf(id, fundingInstrumentId);
|
|
2606
|
+
return {
|
|
2607
|
+
account_id: id,
|
|
2608
|
+
campaign: shapeAds(created, currency),
|
|
2609
|
+
entity_status: status,
|
|
2610
|
+
budget_sent: {
|
|
2611
|
+
daily_budget: dailyBudget,
|
|
2612
|
+
daily_budget_amount_local_micro: dailyMicro,
|
|
2613
|
+
...totalBudget !== void 0 ? {
|
|
2614
|
+
total_budget: totalBudget,
|
|
2615
|
+
total_budget_amount_local_micro: totalMicro
|
|
2616
|
+
} : {},
|
|
2617
|
+
...currency ? { currency } : {}
|
|
2618
|
+
},
|
|
2619
|
+
next_step: note,
|
|
2620
|
+
environment: ads.sandbox ? "sandbox" : "production",
|
|
2621
|
+
cost: adsCostNote()
|
|
2622
|
+
};
|
|
2623
|
+
}));
|
|
2624
|
+
server.registerTool("x_ads_update_campaign", {
|
|
2625
|
+
title: "X: Ads Update Campaign",
|
|
2626
|
+
description: "Change a campaign's name, budget or status. Budgets are in MAJOR currency units, as on create. Raising a daily budget on an ACTIVE campaign increases spending immediately.",
|
|
2627
|
+
inputSchema: z.object({
|
|
2628
|
+
accountId: accountIdArg,
|
|
2629
|
+
campaignId: entityIdArg.describe("The campaign to change."),
|
|
2630
|
+
name: z.string().min(1).max(255).optional().describe("New name."),
|
|
2631
|
+
dailyBudget: budgetArg.optional().describe("New daily budget, in major currency units."),
|
|
2632
|
+
totalBudget: budgetArg.optional().describe("New lifetime cap, in major currency units."),
|
|
2633
|
+
entityStatus: z.enum(["ACTIVE", "PAUSED"]).optional().describe("ACTIVE resumes delivery and spending; PAUSED stops it."),
|
|
2634
|
+
confirm: adsConfirmArg
|
|
2635
|
+
}),
|
|
2636
|
+
annotations: {
|
|
2637
|
+
readOnlyHint: false,
|
|
2638
|
+
destructiveHint: false,
|
|
2639
|
+
idempotentHint: true
|
|
2640
|
+
}
|
|
2641
|
+
}, async ({ accountId, campaignId, name, dailyBudget, totalBudget, entityStatus }) => wrap(async () => {
|
|
2642
|
+
const id = await resolveAccount(accountId);
|
|
2643
|
+
const dailyMicro = dailyBudget === void 0 ? void 0 : toMicro(dailyBudget);
|
|
2644
|
+
const totalMicro = totalBudget === void 0 ? void 0 : toMicro(totalBudget);
|
|
2645
|
+
return {
|
|
2646
|
+
account_id: id,
|
|
2647
|
+
campaign: shapeAds(await client.put(`/12/accounts/${id}/campaigns/${campaignId}`, compact({
|
|
2648
|
+
name,
|
|
2649
|
+
daily_budget_amount_local_micro: dailyMicro,
|
|
2650
|
+
total_budget_amount_local_micro: totalMicro,
|
|
2651
|
+
entity_status: entityStatus
|
|
2652
|
+
}))),
|
|
2653
|
+
...dailyBudget !== void 0 ? { budget_sent: {
|
|
2654
|
+
daily_budget: dailyBudget,
|
|
2655
|
+
daily_budget_amount_local_micro: dailyMicro
|
|
2656
|
+
} } : {},
|
|
2657
|
+
cost: adsCostNote()
|
|
2658
|
+
};
|
|
2659
|
+
}));
|
|
2660
|
+
server.registerTool("x_ads_delete_campaign", {
|
|
2661
|
+
title: "X: Ads Delete Campaign",
|
|
2662
|
+
description: "Delete a campaign. This also stops its line items. X keeps deleted campaigns visible to `withDeleted` reads but they cannot be revived — pause the campaign instead if you may want it back.",
|
|
2663
|
+
inputSchema: z.object({
|
|
2664
|
+
accountId: accountIdArg,
|
|
2665
|
+
campaignId: entityIdArg.describe("The campaign to delete."),
|
|
2666
|
+
confirm: adsConfirmArg
|
|
2667
|
+
}),
|
|
2668
|
+
annotations: {
|
|
2669
|
+
readOnlyHint: false,
|
|
2670
|
+
destructiveHint: true,
|
|
2671
|
+
idempotentHint: true
|
|
2672
|
+
}
|
|
2673
|
+
}, async ({ accountId, campaignId }) => wrap(async () => {
|
|
2674
|
+
const id = await resolveAccount(accountId);
|
|
2675
|
+
return {
|
|
2676
|
+
account_id: id,
|
|
2677
|
+
deleted: campaignId,
|
|
2678
|
+
campaign: shapeAds(await client.del(`/12/accounts/${id}/campaigns/${campaignId}`)),
|
|
2679
|
+
cost: adsCostNote()
|
|
2680
|
+
};
|
|
2681
|
+
}));
|
|
2682
|
+
server.registerTool("x_ads_create_line_item", {
|
|
2683
|
+
title: "X: Ads Create Line Item",
|
|
2684
|
+
description: "Create a line item under a campaign — the ad group carrying the objective, bid, placements and flight dates. Created PAUSED unless activateImmediately is set. Bids are in MAJOR currency units. A line item with no targeting criteria and no promoted post will not deliver, so this is usually the second of three calls.",
|
|
2685
|
+
inputSchema: z.object({
|
|
2686
|
+
accountId: accountIdArg,
|
|
2687
|
+
campaignId: entityIdArg.describe("The campaign this belongs to."),
|
|
2688
|
+
name: z.string().min(1).max(255).optional().describe("Line item name."),
|
|
2689
|
+
objective: z.enum(OBJECTIVES).describe("What the line item optimises for."),
|
|
2690
|
+
productType: z.enum(PRODUCT_TYPES).describe("The kind of ad. Usually PROMOTED_TWEETS."),
|
|
2691
|
+
placements: z.array(z.enum(PLACEMENTS)).min(1).describe("Where ads may appear. ALL_ON_TWITTER is the usual choice."),
|
|
2692
|
+
startTime: adsTimeArg.describe("When delivery starts, ISO-8601 UTC on a whole hour."),
|
|
2693
|
+
endTime: adsTimeArg.optional().describe("When delivery stops. Omit to run open-ended."),
|
|
2694
|
+
bid: budgetArg.optional().describe("Bid in MAJOR currency units. Omit to let X bid automatically."),
|
|
2695
|
+
totalBudget: budgetArg.optional().describe("Lifetime cap for this line item."),
|
|
2696
|
+
activateImmediately: activateArg,
|
|
2697
|
+
confirm: adsConfirmArg
|
|
2698
|
+
}),
|
|
2699
|
+
annotations: {
|
|
2700
|
+
readOnlyHint: false,
|
|
2701
|
+
destructiveHint: false,
|
|
2702
|
+
idempotentHint: false
|
|
2703
|
+
}
|
|
2704
|
+
}, async (args) => wrap(async () => {
|
|
2705
|
+
const id = await resolveAccount(args.accountId);
|
|
2706
|
+
const { status, note } = statusFor(args.activateImmediately);
|
|
2707
|
+
const bidMicro = args.bid === void 0 ? void 0 : toMicro(args.bid);
|
|
2708
|
+
return {
|
|
2709
|
+
account_id: id,
|
|
2710
|
+
line_item: shapeAds(await client.post(`/12/accounts/${id}/line_items`, compact({
|
|
2711
|
+
campaign_id: args.campaignId,
|
|
2712
|
+
name: args.name,
|
|
2713
|
+
objective: args.objective,
|
|
2714
|
+
product_type: args.productType,
|
|
2715
|
+
placements: args.placements,
|
|
2716
|
+
start_time: args.startTime,
|
|
2717
|
+
end_time: args.endTime,
|
|
2718
|
+
bid_amount_local_micro: bidMicro,
|
|
2719
|
+
total_budget_amount_local_micro: args.totalBudget === void 0 ? void 0 : toMicro(args.totalBudget),
|
|
2720
|
+
entity_status: status
|
|
2721
|
+
}))),
|
|
2722
|
+
entity_status: status,
|
|
2723
|
+
...args.bid !== void 0 ? { bid_sent: {
|
|
2724
|
+
bid: args.bid,
|
|
2725
|
+
bid_amount_local_micro: bidMicro
|
|
2726
|
+
} } : {},
|
|
2727
|
+
next_step: `${note} Attach targeting with x_ads_create_targeting_criterion and a post with x_ads_create_promoted_tweet before activating, or it will not deliver.`,
|
|
2728
|
+
cost: adsCostNote()
|
|
2729
|
+
};
|
|
2730
|
+
}));
|
|
2731
|
+
server.registerTool("x_ads_update_line_item", {
|
|
2732
|
+
title: "X: Ads Update Line Item",
|
|
2733
|
+
description: "Change a line item's name, bid, dates or status. Bids and budgets are in MAJOR currency units, as on create.",
|
|
2734
|
+
inputSchema: z.object({
|
|
2735
|
+
accountId: accountIdArg,
|
|
2736
|
+
lineItemId: entityIdArg.describe("The line item to change."),
|
|
2737
|
+
name: z.string().min(1).max(255).optional().describe("New name."),
|
|
2738
|
+
bid: budgetArg.optional().describe("New bid, in major currency units."),
|
|
2739
|
+
totalBudget: budgetArg.optional().describe("New lifetime cap, in major currency units."),
|
|
2740
|
+
startTime: adsTimeArg.optional().describe("New start time."),
|
|
2741
|
+
endTime: adsTimeArg.optional().describe("New end time."),
|
|
2742
|
+
entityStatus: z.enum(["ACTIVE", "PAUSED"]).optional().describe("Resume or stop delivery."),
|
|
2743
|
+
confirm: adsConfirmArg
|
|
2744
|
+
}),
|
|
2745
|
+
annotations: {
|
|
2746
|
+
readOnlyHint: false,
|
|
2747
|
+
destructiveHint: false,
|
|
2748
|
+
idempotentHint: true
|
|
2749
|
+
}
|
|
2750
|
+
}, async (args) => wrap(async () => {
|
|
2751
|
+
const id = await resolveAccount(args.accountId);
|
|
2752
|
+
return {
|
|
2753
|
+
account_id: id,
|
|
2754
|
+
line_item: shapeAds(await client.put(`/12/accounts/${id}/line_items/${args.lineItemId}`, compact({
|
|
2755
|
+
name: args.name,
|
|
2756
|
+
bid_amount_local_micro: args.bid === void 0 ? void 0 : toMicro(args.bid),
|
|
2757
|
+
total_budget_amount_local_micro: args.totalBudget === void 0 ? void 0 : toMicro(args.totalBudget),
|
|
2758
|
+
start_time: args.startTime,
|
|
2759
|
+
end_time: args.endTime,
|
|
2760
|
+
entity_status: args.entityStatus
|
|
2761
|
+
}))),
|
|
2762
|
+
cost: adsCostNote()
|
|
2763
|
+
};
|
|
2764
|
+
}));
|
|
2765
|
+
server.registerTool("x_ads_delete_line_item", {
|
|
2766
|
+
title: "X: Ads Delete Line Item",
|
|
2767
|
+
description: "Delete a line item. Irreversible — pause it instead if you may want it back. Its targeting criteria and promoted posts stop delivering with it.",
|
|
2768
|
+
inputSchema: z.object({
|
|
2769
|
+
accountId: accountIdArg,
|
|
2770
|
+
lineItemId: entityIdArg.describe("The line item to delete."),
|
|
2771
|
+
confirm: adsConfirmArg
|
|
2772
|
+
}),
|
|
2773
|
+
annotations: {
|
|
2774
|
+
readOnlyHint: false,
|
|
2775
|
+
destructiveHint: true,
|
|
2776
|
+
idempotentHint: true
|
|
2777
|
+
}
|
|
2778
|
+
}, async ({ accountId, lineItemId }) => wrap(async () => {
|
|
2779
|
+
const id = await resolveAccount(accountId);
|
|
2780
|
+
return {
|
|
2781
|
+
account_id: id,
|
|
2782
|
+
deleted: lineItemId,
|
|
2783
|
+
line_item: shapeAds(await client.del(`/12/accounts/${id}/line_items/${lineItemId}`)),
|
|
2784
|
+
cost: adsCostNote()
|
|
2785
|
+
};
|
|
2786
|
+
}));
|
|
2787
|
+
server.registerTool("x_ads_create_promoted_tweet", {
|
|
2788
|
+
title: "X: Ads Create Promoted Tweet",
|
|
2789
|
+
description: "Promote existing posts under a line item. The posts must already exist — compose one first with x_compose_post, or pick an id from x_get_user_posts. Promotion begins when the line item is active.",
|
|
2790
|
+
inputSchema: z.object({
|
|
2791
|
+
accountId: accountIdArg,
|
|
2792
|
+
lineItemId: entityIdArg.describe("The line item that will carry these posts."),
|
|
2793
|
+
postIds: z.array(z.string().regex(/^\d+$/)).min(1).max(50).describe("Post ids to promote, e.g. [\"1799000000000000001\"]."),
|
|
2794
|
+
confirm: adsConfirmArg
|
|
2795
|
+
}),
|
|
2796
|
+
annotations: {
|
|
2797
|
+
readOnlyHint: false,
|
|
2798
|
+
destructiveHint: false,
|
|
2799
|
+
idempotentHint: false
|
|
2800
|
+
}
|
|
2801
|
+
}, async ({ accountId, lineItemId, postIds }) => wrap(async () => {
|
|
2802
|
+
const id = await resolveAccount(accountId);
|
|
2803
|
+
return {
|
|
2804
|
+
account_id: id,
|
|
2805
|
+
line_item_id: lineItemId,
|
|
2806
|
+
promoted_tweets: shapeAds(await client.post(`/12/accounts/${id}/promoted_tweets`, {
|
|
2807
|
+
line_item_id: lineItemId,
|
|
2808
|
+
tweet_ids: postIds
|
|
2809
|
+
})),
|
|
2810
|
+
cost: adsCostNote()
|
|
2811
|
+
};
|
|
2812
|
+
}));
|
|
2813
|
+
server.registerTool("x_ads_delete_promoted_tweet", {
|
|
2814
|
+
title: "X: Ads Delete Promoted Tweet",
|
|
2815
|
+
description: "Stop promoting a post by removing it from its line item. The post itself is untouched and stays on the timeline — use x_delete_post to remove that.",
|
|
2816
|
+
inputSchema: z.object({
|
|
2817
|
+
accountId: accountIdArg,
|
|
2818
|
+
promotedTweetId: entityIdArg.describe("The promoted-tweet id from x_ads_get_promoted_tweets, not the post id."),
|
|
2819
|
+
confirm: adsConfirmArg
|
|
2820
|
+
}),
|
|
2821
|
+
annotations: {
|
|
2822
|
+
readOnlyHint: false,
|
|
2823
|
+
destructiveHint: true,
|
|
2824
|
+
idempotentHint: true
|
|
2825
|
+
}
|
|
2826
|
+
}, async ({ accountId, promotedTweetId }) => wrap(async () => {
|
|
2827
|
+
const id = await resolveAccount(accountId);
|
|
2828
|
+
return {
|
|
2829
|
+
account_id: id,
|
|
2830
|
+
deleted: promotedTweetId,
|
|
2831
|
+
promoted_tweet: shapeAds(await client.del(`/12/accounts/${id}/promoted_tweets/${promotedTweetId}`)),
|
|
2832
|
+
note: "The post itself is unaffected and is still on the timeline.",
|
|
2833
|
+
cost: adsCostNote()
|
|
2834
|
+
};
|
|
2835
|
+
}));
|
|
2836
|
+
server.registerTool("x_ads_set_entity_status", {
|
|
2837
|
+
title: "X: Ads Set Entity Status",
|
|
2838
|
+
description: "Activate or pause a campaign or line item. This is the switch that starts and stops spending: ACTIVE begins delivery immediately at the entity's configured budget. Check the budget with x_ads_get_campaigns before activating something you did not just create.",
|
|
2839
|
+
inputSchema: z.object({
|
|
2840
|
+
accountId: accountIdArg,
|
|
2841
|
+
entityType: z.enum(["campaign", "line_item"]).describe("Which kind of entity the id refers to."),
|
|
2842
|
+
entityId: entityIdArg.describe("The campaign or line item id."),
|
|
2843
|
+
status: z.enum(["ACTIVE", "PAUSED"]).describe("ACTIVE starts delivery and spending. PAUSED stops it."),
|
|
2844
|
+
confirm: adsConfirmArg
|
|
2845
|
+
}),
|
|
2846
|
+
annotations: {
|
|
2847
|
+
readOnlyHint: false,
|
|
2848
|
+
destructiveHint: false,
|
|
2849
|
+
idempotentHint: true
|
|
2850
|
+
}
|
|
2851
|
+
}, async ({ accountId, entityType, entityId, status }) => wrap(async () => {
|
|
2852
|
+
const id = await resolveAccount(accountId);
|
|
2853
|
+
return {
|
|
2854
|
+
account_id: id,
|
|
2855
|
+
entity_type: entityType,
|
|
2856
|
+
entity_id: entityId,
|
|
2857
|
+
entity_status: status,
|
|
2858
|
+
entity: shapeAds(await client.put(`/12/accounts/${id}/${PATH_FOR[entityType]}/${entityId}`, { entity_status: status })),
|
|
2859
|
+
note: status === "ACTIVE" ? `Now ACTIVE${ads.sandbox ? " (sandbox — nothing is really spent)" : " — delivery and spending have started"}.` : "Now PAUSED. It is spending nothing.",
|
|
2860
|
+
cost: adsCostNote()
|
|
2861
|
+
};
|
|
2862
|
+
}));
|
|
2863
|
+
};
|
|
2864
|
+
//#endregion
|
|
2865
|
+
//#region src/tools/ads/targeting.ts
|
|
2866
|
+
/**
|
|
2867
|
+
* The twelve targeting-option lookup endpoints that exist under
|
|
2868
|
+
* `/12/targeting_criteria/`. Deliberately a closed list: guessing a thirteenth
|
|
2869
|
+
* (`keywords` is the one people reach for) 404s, and keyword research lives at
|
|
2870
|
+
* `/12/insights/keywords/search` on a different path entirely.
|
|
2871
|
+
*/
|
|
2872
|
+
const OPTION_TYPES = [
|
|
2873
|
+
"app_store_categories",
|
|
2874
|
+
"conversations",
|
|
2875
|
+
"devices",
|
|
2876
|
+
"events",
|
|
2877
|
+
"interests",
|
|
2878
|
+
"languages",
|
|
2879
|
+
"locations",
|
|
2880
|
+
"network_operators",
|
|
2881
|
+
"platform_versions",
|
|
2882
|
+
"platforms",
|
|
2883
|
+
"tv_markets",
|
|
2884
|
+
"tv_shows"
|
|
2885
|
+
];
|
|
2886
|
+
const LOCATION_TYPES = [
|
|
2887
|
+
"COUNTRIES",
|
|
2888
|
+
"REGIONS",
|
|
2889
|
+
"METROS",
|
|
2890
|
+
"CITIES",
|
|
2891
|
+
"POSTAL_CODES"
|
|
2892
|
+
];
|
|
2893
|
+
const registerAdsTargetingTools = (server, client, ads, resolveAccount) => {
|
|
2894
|
+
server.registerTool("x_ads_get_targeting_criteria", {
|
|
2895
|
+
title: "X: Ads Get Targeting Criteria",
|
|
2896
|
+
description: "Read the targeting attached to one or more line items — the interests, locations, keywords, follower look-alikes and audiences that decide who sees the ads. Targeting hangs off line items, never off campaigns.",
|
|
2897
|
+
inputSchema: z.object({
|
|
2898
|
+
accountId: accountIdArg,
|
|
2899
|
+
lineItemIds: z.array(entityIdArg).min(1).max(200).describe("The line items whose targeting you want. At least one is required."),
|
|
2900
|
+
count: adsCountArg
|
|
2901
|
+
}),
|
|
2902
|
+
annotations: { readOnlyHint: true }
|
|
2903
|
+
}, async ({ accountId, lineItemIds, count }) => wrap(async () => {
|
|
2904
|
+
const id = await resolveAccount(accountId);
|
|
2905
|
+
const page = await client.paginateCursor(`/12/accounts/${id}/targeting_criteria`, {
|
|
2906
|
+
count,
|
|
2907
|
+
line_item_ids: lineItemIds
|
|
2908
|
+
}, { maxItems: count });
|
|
2909
|
+
return {
|
|
2910
|
+
account_id: id,
|
|
2911
|
+
targeting_criteria: page.data,
|
|
2912
|
+
...page.nextCursor ? { next_cursor: page.nextCursor } : {},
|
|
2913
|
+
cost: adsCostNote()
|
|
2914
|
+
};
|
|
2915
|
+
}));
|
|
2916
|
+
server.registerTool("x_ads_search_targeting_options", {
|
|
2917
|
+
title: "X: Ads Search Targeting Options",
|
|
2918
|
+
description: "Look up the valid values for a targeting type before using one. X's targeting takes opaque ids, not names — a location is something like \"96683cc9126741d1\", not \"Paris\" — so this is the step that turns an intention into a `targetingValue` you can pass to x_ads_create_targeting_criterion. There is no keyword option here: keywords are free text and need no lookup.",
|
|
2919
|
+
inputSchema: z.object({
|
|
2920
|
+
type: z.enum(OPTION_TYPES).describe("Which targeting dimension to search. Each maps to one X lookup endpoint."),
|
|
2921
|
+
q: z.string().min(1).optional().describe("Free-text filter, e.g. \"Paris\" for locations or \"cycling\" for interests."),
|
|
2922
|
+
locationType: z.enum(LOCATION_TYPES).optional().describe("For type=locations only: which granularity of place to return."),
|
|
2923
|
+
locale: z.string().min(2).optional().describe("For type=tv_shows, which requires it, e.g. \"en-US\"."),
|
|
2924
|
+
eventTypes: z.array(z.string().min(1)).optional().describe("For type=events, which requires it."),
|
|
2925
|
+
countryCode: z.string().length(2).optional().describe("Two-letter country filter where the endpoint supports one, e.g. \"FR\"."),
|
|
2926
|
+
count: adsCountArg
|
|
2927
|
+
}),
|
|
2928
|
+
annotations: { readOnlyHint: true }
|
|
2929
|
+
}, async ({ type, q, locationType, locale, eventTypes, countryCode, count }) => wrap(async () => {
|
|
2930
|
+
const page = await client.paginateCursor(`/12/targeting_criteria/${type}`, compact({
|
|
2931
|
+
count,
|
|
2932
|
+
q,
|
|
2933
|
+
location_type: locationType,
|
|
2934
|
+
locale,
|
|
2935
|
+
event_types: eventTypes,
|
|
2936
|
+
country_code: countryCode
|
|
2937
|
+
}), { maxItems: count });
|
|
2938
|
+
return {
|
|
2939
|
+
type,
|
|
2940
|
+
options: page.data,
|
|
2941
|
+
...page.nextCursor ? { next_cursor: page.nextCursor } : {},
|
|
2942
|
+
note: "Pass an option's `targeting_value` (or `id`) as targetingValue to x_ads_create_targeting_criterion.",
|
|
2943
|
+
cost: adsCostNote()
|
|
2944
|
+
};
|
|
2945
|
+
}));
|
|
2946
|
+
if (!ads.allowWrites) return;
|
|
2947
|
+
server.registerTool("x_ads_create_targeting_criterion", {
|
|
2948
|
+
title: "X: Ads Create Targeting Criterion",
|
|
2949
|
+
description: "Add one targeting criterion to a line item. Look the value up first with x_ads_search_targeting_options — X takes opaque ids for most types, and an invented one is rejected. Criteria of different types intersect (AND) while criteria of the same type union (OR), so adding two locations widens the audience while adding a location and an interest narrows it. Broadening targeting on an active line item increases spending.",
|
|
2950
|
+
inputSchema: z.object({
|
|
2951
|
+
accountId: accountIdArg,
|
|
2952
|
+
lineItemId: entityIdArg.describe("The line item to target."),
|
|
2953
|
+
targetingType: z.string().min(1).describe("The criterion type, e.g. \"LOCATION\", \"INTEREST\", \"BROAD_KEYWORD\", \"FOLLOWERS_OF_USER\", \"CUSTOM_AUDIENCE\", \"PLATFORM\", \"LANGUAGE\"."),
|
|
2954
|
+
targetingValue: z.string().min(1).describe("The value for that type — an id from x_ads_search_targeting_options, or free text for keyword types."),
|
|
2955
|
+
operatorType: z.enum([
|
|
2956
|
+
"EQ",
|
|
2957
|
+
"NE",
|
|
2958
|
+
"GTE",
|
|
2959
|
+
"LT"
|
|
2960
|
+
]).default("EQ").describe("How to compare. EQ is right for almost everything; NE excludes."),
|
|
2961
|
+
confirm: adsConfirmArg
|
|
2962
|
+
}),
|
|
2963
|
+
annotations: {
|
|
2964
|
+
readOnlyHint: false,
|
|
2965
|
+
destructiveHint: false,
|
|
2966
|
+
idempotentHint: false
|
|
2967
|
+
}
|
|
2968
|
+
}, async ({ accountId, lineItemId, targetingType, targetingValue, operatorType }) => wrap(async () => {
|
|
2969
|
+
const id = await resolveAccount(accountId);
|
|
2970
|
+
return {
|
|
2971
|
+
account_id: id,
|
|
2972
|
+
line_item_id: lineItemId,
|
|
2973
|
+
targeting_criterion: shapeAds(await client.post(`/12/accounts/${id}/targeting_criteria`, {
|
|
2974
|
+
line_item_id: lineItemId,
|
|
2975
|
+
targeting_type: targetingType,
|
|
2976
|
+
targeting_value: targetingValue,
|
|
2977
|
+
operator_type: operatorType
|
|
2978
|
+
})),
|
|
2979
|
+
cost: adsCostNote()
|
|
2980
|
+
};
|
|
2981
|
+
}));
|
|
2982
|
+
server.registerTool("x_ads_delete_targeting_criterion", {
|
|
2983
|
+
title: "X: Ads Delete Targeting Criterion",
|
|
2984
|
+
description: "Remove one targeting criterion from a line item. Narrowing or widening targeting on an active line item changes who sees the ads immediately. Removing the last criterion leaves the line item targeting everyone, which usually spends faster, not slower.",
|
|
2985
|
+
inputSchema: z.object({
|
|
2986
|
+
accountId: accountIdArg,
|
|
2987
|
+
targetingCriterionId: entityIdArg.describe("The criterion id from x_ads_get_targeting_criteria."),
|
|
2988
|
+
confirm: adsConfirmArg
|
|
2989
|
+
}),
|
|
2990
|
+
annotations: {
|
|
2991
|
+
readOnlyHint: false,
|
|
2992
|
+
destructiveHint: true,
|
|
2993
|
+
idempotentHint: true
|
|
2994
|
+
}
|
|
2995
|
+
}, async ({ accountId, targetingCriterionId }) => wrap(async () => {
|
|
2996
|
+
const id = await resolveAccount(accountId);
|
|
2997
|
+
return {
|
|
2998
|
+
account_id: id,
|
|
2999
|
+
deleted: targetingCriterionId,
|
|
3000
|
+
targeting_criterion: shapeAds(await client.del(`/12/accounts/${id}/targeting_criteria/${targetingCriterionId}`)),
|
|
3001
|
+
cost: adsCostNote()
|
|
3002
|
+
};
|
|
3003
|
+
}));
|
|
3004
|
+
};
|
|
3005
|
+
//#endregion
|
|
3006
|
+
//#region src/tools/ads/index.ts
|
|
3007
|
+
/**
|
|
3008
|
+
* Register the X Ads API tools.
|
|
3009
|
+
*
|
|
3010
|
+
* Reads and the analytics-job tools are registered whenever ads is enabled.
|
|
3011
|
+
* The campaign-mutating tools appear only when `X_ADS_ALLOW_WRITES` is on, so
|
|
3012
|
+
* with the defaults they are not merely refused — they do not exist and cannot
|
|
3013
|
+
* be called. Sandbox-only tools appear only when pointed at the sandbox.
|
|
3014
|
+
*
|
|
3015
|
+
* The analytics-job tools sit with the reads rather than behind the write gate
|
|
3016
|
+
* on purpose: queuing a job changes nothing an advertiser can see and spends
|
|
3017
|
+
* nothing, so gating it would make long-range analytics unreachable in exactly
|
|
3018
|
+
* the configuration most people should be running.
|
|
3019
|
+
*/
|
|
3020
|
+
const registerAdsTools = (server, client, ctx) => {
|
|
3021
|
+
const ads = ctx.ads;
|
|
3022
|
+
if (!ads) return;
|
|
3023
|
+
const resolveAccount = createAccountResolver(client, ads);
|
|
3024
|
+
registerAdsAccountTools(server, client, ads, resolveAccount);
|
|
3025
|
+
registerAdsCampaignTools(server, client, ads, resolveAccount);
|
|
3026
|
+
registerAdsTargetingTools(server, client, ads, resolveAccount);
|
|
3027
|
+
registerAdsAudienceTools(server, client, resolveAccount);
|
|
3028
|
+
registerAdsAnalyticsTools(server, client, resolveAccount);
|
|
3029
|
+
};
|
|
3030
|
+
//#endregion
|
|
3031
|
+
//#region src/tools/auth.ts
|
|
3032
|
+
const registerAuthTools = (server, ctx) => {
|
|
3033
|
+
server.registerTool("x_auth_status", {
|
|
3034
|
+
title: "X: Auth Status",
|
|
3035
|
+
description: "Which credentials this server is holding: an app-only Bearer token (enough for public reads and search), an OAuth2 user session (needed for bookmarks and the home timeline), or neither. Shows the logged-in handle, granted scopes and token expiry, and whether the Ads API tools are registered and against which environment. Call this first if the X API tools seem to be missing — it explains exactly what to configure.",
|
|
3036
|
+
inputSchema: z.object({}),
|
|
3037
|
+
annotations: { readOnlyHint: true }
|
|
3038
|
+
}, async () => wrap(async () => {
|
|
3039
|
+
const status = ctx.tokenProvider.describe();
|
|
3040
|
+
const mode = ctx.tokenFile ? fileMode(ctx.tokenFile) : void 0;
|
|
3041
|
+
if (!ctx.hasCredentials) return {
|
|
3042
|
+
configured: false,
|
|
3043
|
+
app_only_bearer: false,
|
|
3044
|
+
user: {
|
|
3045
|
+
authenticated: false,
|
|
3046
|
+
reason: "no credentials configured"
|
|
3047
|
+
},
|
|
3048
|
+
can_read_public: false,
|
|
3049
|
+
can_read_bookmarks: false,
|
|
3050
|
+
available_without_credentials: [
|
|
3051
|
+
"x_compose_post",
|
|
3052
|
+
"x_validate_post",
|
|
3053
|
+
"x_build_search_query",
|
|
3054
|
+
"x_auth_status"
|
|
3055
|
+
],
|
|
3056
|
+
setup: ctx.setup ?? []
|
|
3057
|
+
};
|
|
3058
|
+
return {
|
|
3059
|
+
configured: true,
|
|
3060
|
+
app_only_bearer: status.app,
|
|
3061
|
+
user: status.user.authenticated ? {
|
|
3062
|
+
...status.user,
|
|
3063
|
+
expires_at: new Date(status.user.expiresAt).toISOString(),
|
|
3064
|
+
expires_in_seconds: Math.max(0, Math.round((status.user.expiresAt - Date.now()) / 1e3))
|
|
3065
|
+
} : status.user,
|
|
3066
|
+
...ctx.tokenFile ? { token_file: {
|
|
3067
|
+
path: ctx.tokenFile,
|
|
3068
|
+
mode: mode === void 0 ? "absent" : `0${mode.toString(8)}`,
|
|
3069
|
+
...mode !== void 0 && (mode & 63) !== 0 ? { warning: `Readable by other users. Run: chmod 600 ${ctx.tokenFile}` } : {}
|
|
3070
|
+
} } : {},
|
|
3071
|
+
can_read_public: status.app || status.user.authenticated,
|
|
3072
|
+
can_read_bookmarks: status.user.authenticated,
|
|
3073
|
+
ads: ctx.ads ? {
|
|
3074
|
+
enabled: true,
|
|
3075
|
+
environment: ctx.ads.sandbox ? "sandbox" : "production",
|
|
3076
|
+
base_url: ctx.ads.baseUrl,
|
|
3077
|
+
writes_enabled: ctx.ads.allowWrites,
|
|
3078
|
+
default_account_id: ctx.ads.accountId ?? null,
|
|
3079
|
+
note: ctx.ads.sandbox ? "Sandbox — campaigns here spend nothing." : "PRODUCTION — these tools read and can change campaigns that spend real money."
|
|
3080
|
+
} : {
|
|
3081
|
+
enabled: false,
|
|
3082
|
+
reason: ctx.adsSetup ? "X_ADS_ENABLED is not set." : "no OAuth2 client id configured",
|
|
3083
|
+
...ctx.adsSetup ? { setup: ctx.adsSetup } : {}
|
|
3084
|
+
}
|
|
3085
|
+
};
|
|
3086
|
+
}));
|
|
3087
|
+
if (!ctx.login) return;
|
|
3088
|
+
const login = ctx.login;
|
|
3089
|
+
server.registerTool("x_auth_login", {
|
|
3090
|
+
title: "X: Auth Login",
|
|
3091
|
+
description: "Start the OAuth2 login. Prints a URL (and opens your browser) for you to authorize the app, waits up to two minutes for the callback, then stores a refresh token in the token file with mode 600. Only needed for bookmarks, the home timeline and API writes — public reads and search work with the Bearer token alone.",
|
|
3092
|
+
inputSchema: z.object({ open: z.boolean().default(true).describe("Open the authorize URL in your browser.") }),
|
|
3093
|
+
annotations: {
|
|
3094
|
+
readOnlyHint: false,
|
|
3095
|
+
destructiveHint: false
|
|
3096
|
+
}
|
|
3097
|
+
}, async ({ open }) => wrap(async () => {
|
|
3098
|
+
const result = await login(open);
|
|
3099
|
+
return {
|
|
3100
|
+
authenticated: true,
|
|
3101
|
+
username: result.username,
|
|
3102
|
+
userId: result.userId,
|
|
3103
|
+
scopes: result.scopes,
|
|
3104
|
+
token_file: result.tokenFile,
|
|
3105
|
+
note: "The refresh token is stored with mode 600 and rotates on every refresh."
|
|
3106
|
+
};
|
|
3107
|
+
}));
|
|
3108
|
+
server.registerTool("x_auth_logout", {
|
|
3109
|
+
title: "X: Auth Logout",
|
|
3110
|
+
description: "Delete the stored OAuth2 tokens. The app-only Bearer token is unaffected, so public reads and search keep working.",
|
|
3111
|
+
inputSchema: z.object({ confirm: z.literal(true).describe("Must be true. You will need to run the login flow again to undo this.") }),
|
|
3112
|
+
annotations: {
|
|
3113
|
+
readOnlyHint: false,
|
|
3114
|
+
destructiveHint: true,
|
|
3115
|
+
idempotentHint: true
|
|
3116
|
+
}
|
|
3117
|
+
}, async () => wrap(async () => {
|
|
3118
|
+
ctx.logout?.();
|
|
3119
|
+
return {
|
|
3120
|
+
logged_out: true,
|
|
3121
|
+
note: "Public reads and search continue to work if a Bearer token is configured."
|
|
3122
|
+
};
|
|
3123
|
+
}));
|
|
3124
|
+
};
|
|
3125
|
+
//#endregion
|
|
3126
|
+
//#region src/tools/compose.ts
|
|
3127
|
+
const textArg = z.string().min(1).describe("The body of the post. Counted against X's 280 weighted-character limit.");
|
|
3128
|
+
const urlArg = z.string().url().optional().describe("A link to append. X shortens every link to a fixed 23 characters, so its real length does not matter — but those 23 do count.");
|
|
3129
|
+
const hashtagsArg = z.array(z.string()).optional().describe("Hashtags, with or without \"#\". More than four tends to suppress reach.");
|
|
3130
|
+
const viaArg = z.string().optional().describe("An attribution handle, appended as \"via @handle\".");
|
|
3131
|
+
const registerComposeTools = (server, client, ctx) => {
|
|
3132
|
+
server.registerTool("x_validate_post", {
|
|
3133
|
+
title: "X: Validate Post",
|
|
3134
|
+
description: "Check a draft against X's 280-character limit before doing anything with it. X counts weighted characters, not plain ones: every URL costs 23 whatever its length, and CJK characters and emoji cost 2 each — so 140 Japanese characters is already a full post. Runs locally; no API call, no cost.",
|
|
3135
|
+
inputSchema: z.object({
|
|
3136
|
+
text: textArg,
|
|
3137
|
+
url: urlArg,
|
|
3138
|
+
hashtags: hashtagsArg,
|
|
3139
|
+
via: viaArg
|
|
3140
|
+
}),
|
|
3141
|
+
annotations: { readOnlyHint: true }
|
|
3142
|
+
}, async ({ text, url, hashtags, via }) => wrap(async () => {
|
|
3143
|
+
const { intent_url: _intentUrl, ...validation } = validateIntent({
|
|
3144
|
+
text,
|
|
3145
|
+
url,
|
|
3146
|
+
hashtags,
|
|
3147
|
+
via
|
|
3148
|
+
});
|
|
3149
|
+
return validation;
|
|
3150
|
+
}));
|
|
3151
|
+
server.registerTool("x_compose_post", {
|
|
3152
|
+
title: "X: Compose Post",
|
|
3153
|
+
description: "The default way to post. Validates the draft and returns an x.com/intent/tweet URL that opens X's composer pre-filled — you click Post yourself. This is FREE: no API quota, no write scope, no credentials, and nothing is published without a human click. Prefer it over x_create_post, which costs $0.015 per post ($0.200 with a URL). Web intents cannot attach media, create polls, make native quote posts, or build threads — those need the paid API. Replying to a post does work.",
|
|
3154
|
+
inputSchema: z.object({
|
|
3155
|
+
text: textArg,
|
|
3156
|
+
url: urlArg,
|
|
3157
|
+
hashtags: hashtagsArg,
|
|
3158
|
+
via: viaArg,
|
|
3159
|
+
inReplyTo: postIdArg.optional().describe("Post id to reply to. The composer opens in reply context."),
|
|
3160
|
+
open: z.boolean().optional().describe("Open the URL in your browser. Defaults to the server's X_API_AUTO_OPEN_BROWSER setting. The URL is returned either way.")
|
|
3161
|
+
}),
|
|
3162
|
+
annotations: {
|
|
3163
|
+
readOnlyHint: false,
|
|
3164
|
+
destructiveHint: false
|
|
3165
|
+
}
|
|
3166
|
+
}, async ({ text, url, hashtags, via, inReplyTo, open }) => wrap(async () => {
|
|
3167
|
+
const validation = validateIntent({
|
|
3168
|
+
text,
|
|
3169
|
+
url,
|
|
3170
|
+
hashtags,
|
|
3171
|
+
via,
|
|
3172
|
+
inReplyTo
|
|
3173
|
+
});
|
|
3174
|
+
if (!validation.valid) return {
|
|
3175
|
+
...validation,
|
|
3176
|
+
cost: {
|
|
3177
|
+
estimated_usd: 0,
|
|
3178
|
+
note: "Nothing was sent — the draft is not postable."
|
|
3179
|
+
}
|
|
3180
|
+
};
|
|
3181
|
+
const result = open ?? ctx.autoOpenBrowser ? await openInBrowser(validation.intent_url) : {
|
|
3182
|
+
opened: false,
|
|
3183
|
+
reason: "not requested"
|
|
3184
|
+
};
|
|
3185
|
+
return {
|
|
3186
|
+
intent_url: validation.intent_url,
|
|
3187
|
+
opened: result.opened,
|
|
3188
|
+
...result.reason ? { open_note: result.reason } : {},
|
|
3189
|
+
composed: validation.composed,
|
|
3190
|
+
weighted_length: validation.weighted,
|
|
3191
|
+
remaining: validation.remaining,
|
|
3192
|
+
valid: true,
|
|
3193
|
+
warnings: validation.warnings,
|
|
3194
|
+
next_step: result.opened ? "X's composer is open in your browser — review it and click Post." : "Open the intent_url above to review and post it.",
|
|
3195
|
+
cost: {
|
|
3196
|
+
estimated_usd: 0,
|
|
3197
|
+
note: "Web intent — no API call, no quota consumed, no credentials used."
|
|
3198
|
+
}
|
|
3199
|
+
};
|
|
3200
|
+
}));
|
|
3201
|
+
if (!ctx.allowWrites || ctx.writeBackend !== "api") return;
|
|
3202
|
+
server.registerTool("x_create_post", {
|
|
3203
|
+
title: "X: Create Post",
|
|
3204
|
+
description: "Publish a post directly through the API. COSTS MONEY: about $0.015 per post, or $0.200 if it contains a URL — forty times a post read. x_compose_post does the same thing for free via a browser click; use this only when you specifically need unattended posting, a thread, or a native quote post.",
|
|
3205
|
+
inputSchema: z.object({
|
|
3206
|
+
text: textArg,
|
|
3207
|
+
replyToPostId: postIdArg.optional().describe("Post id to reply to."),
|
|
3208
|
+
quotePostId: postIdArg.optional().describe("Post id to quote."),
|
|
3209
|
+
replySettings: z.enum([
|
|
3210
|
+
"everyone",
|
|
3211
|
+
"mentionedUsers",
|
|
3212
|
+
"following"
|
|
3213
|
+
]).optional().describe("Who may reply. Defaults to everyone."),
|
|
3214
|
+
confirm: confirmArg
|
|
3215
|
+
}),
|
|
3216
|
+
annotations: {
|
|
3217
|
+
readOnlyHint: false,
|
|
3218
|
+
destructiveHint: false,
|
|
3219
|
+
idempotentHint: false
|
|
3220
|
+
}
|
|
3221
|
+
}, async ({ text, replyToPostId, quotePostId, replySettings }) => wrap(async () => {
|
|
3222
|
+
const validation = validateIntent({ text });
|
|
3223
|
+
if (!validation.valid) return {
|
|
3224
|
+
error: validation.error,
|
|
3225
|
+
weighted_length: validation.weighted
|
|
3226
|
+
};
|
|
3227
|
+
const res = await client.post("/2/tweets", compact({
|
|
3228
|
+
text,
|
|
3229
|
+
...replyToPostId ? { reply: { in_reply_to_tweet_id: replyToPostId } } : {},
|
|
3230
|
+
...quotePostId ? { quote_tweet_id: quotePostId } : {},
|
|
3231
|
+
...replySettings ? { reply_settings: replySettings } : {}
|
|
3232
|
+
}));
|
|
3233
|
+
const hasUrl = validation.weighted !== void 0 && /https?:\/\/|\w+\.\w{2,}/.test(text);
|
|
3234
|
+
ctx.ledger.recordCreate(hasUrl);
|
|
3235
|
+
const data = isRecord(res) && isRecord(res.data) ? res.data : {};
|
|
3236
|
+
const id = typeof data.id === "string" ? data.id : void 0;
|
|
3237
|
+
return {
|
|
3238
|
+
posted: true,
|
|
3239
|
+
id,
|
|
3240
|
+
...id ? { url: `https://x.com/i/web/status/${id}` } : {},
|
|
3241
|
+
cost: {
|
|
3242
|
+
estimated_usd: hasUrl ? ctx.pricing.postCreateWithUrl : ctx.pricing.postCreate,
|
|
3243
|
+
note: hasUrl ? "Billed at the with-URL rate. x_compose_post would have cost nothing." : "x_compose_post would have cost nothing."
|
|
3244
|
+
}
|
|
3245
|
+
};
|
|
3246
|
+
}));
|
|
3247
|
+
server.registerTool("x_delete_post", {
|
|
3248
|
+
title: "X: Delete Post",
|
|
3249
|
+
description: "Delete one of your own posts. Irreversible — X keeps no undo, and the id cannot be reused.",
|
|
3250
|
+
inputSchema: z.object({
|
|
3251
|
+
postId: postIdArg,
|
|
3252
|
+
confirm: confirmArg
|
|
3253
|
+
}),
|
|
3254
|
+
annotations: {
|
|
3255
|
+
readOnlyHint: false,
|
|
3256
|
+
destructiveHint: true,
|
|
3257
|
+
idempotentHint: true
|
|
3258
|
+
}
|
|
3259
|
+
}, async ({ postId }) => wrap(async () => {
|
|
3260
|
+
await client.del(`/2/tweets/${postId}`);
|
|
3261
|
+
return { deleted: postId };
|
|
3262
|
+
}));
|
|
3263
|
+
};
|
|
3264
|
+
//#endregion
|
|
3265
|
+
//#region src/tools/posts.ts
|
|
3266
|
+
const registerPostTools = (server, client, ctx) => {
|
|
3267
|
+
/** One batched lookup, shared by the single- and multi-id tools. */
|
|
3268
|
+
const fetchPosts = async (ids) => {
|
|
3269
|
+
const shaped = shapePostsResponse(await client.get("/2/tweets", compact({
|
|
3270
|
+
ids,
|
|
3271
|
+
...POST_QUERY
|
|
3272
|
+
})));
|
|
3273
|
+
return new Map(shaped.posts.map((post) => [post.id, post]));
|
|
3274
|
+
};
|
|
3275
|
+
server.registerTool("x_get_post", {
|
|
3276
|
+
title: "X: Get Post",
|
|
3277
|
+
description: "Get one post by id, with its author, metrics, media and any quoted or replied-to post already inlined. Reading the same post twice in one UTC day is free — X does not bill a repeat read.",
|
|
3278
|
+
inputSchema: z.object({ postId: postIdArg }),
|
|
3279
|
+
annotations: { readOnlyHint: true }
|
|
3280
|
+
}, async ({ postId }) => wrap(async () => {
|
|
3281
|
+
const { items, cost, notFound } = await cachedByIds(ctx, "post", [postId], fetchPosts, "x_get_post");
|
|
3282
|
+
if (items.length === 0) return {
|
|
3283
|
+
error: `X returned no post for id ${notFound[0]}. It is deleted, protected, or from a suspended account.`,
|
|
3284
|
+
cost
|
|
3285
|
+
};
|
|
3286
|
+
return {
|
|
3287
|
+
post: items[0],
|
|
3288
|
+
cost
|
|
3289
|
+
};
|
|
3290
|
+
}));
|
|
3291
|
+
server.registerTool("x_get_posts", {
|
|
3292
|
+
title: "X: Get Posts",
|
|
3293
|
+
description: "Get up to 100 posts by id in a single request. Always prefer this over calling x_get_post repeatedly — X bills per post either way, but one request is far faster and spends only one unit of rate limit. Ids that cannot be served come back under `not_found` rather than failing the call.",
|
|
3294
|
+
inputSchema: z.object({ postIds: z.array(postIdArg).min(1).max(100).describe("Post ids to look up, up to 100 in one call.") }),
|
|
3295
|
+
annotations: { readOnlyHint: true }
|
|
3296
|
+
}, async ({ postIds }) => wrap(async () => {
|
|
3297
|
+
const { items, cost, notFound } = await cachedByIds(ctx, "post", [...new Set(postIds)], fetchPosts, "x_get_posts");
|
|
3298
|
+
return {
|
|
3299
|
+
posts: items,
|
|
3300
|
+
...notFound.length > 0 ? { not_found: notFound } : {},
|
|
3301
|
+
cost
|
|
3302
|
+
};
|
|
3303
|
+
}));
|
|
3304
|
+
server.registerTool("x_get_thread", {
|
|
3305
|
+
title: "X: Get Thread",
|
|
3306
|
+
description: "Reconstruct a conversation: every reply sharing the post's conversation_id, oldest first. Note that this searches the last 7 days only, so an older thread returns just the root post. Costs one post read per reply returned.",
|
|
3307
|
+
inputSchema: z.object({
|
|
3308
|
+
postId: postIdArg,
|
|
3309
|
+
maxResults: maxResultsArg
|
|
3310
|
+
}),
|
|
3311
|
+
annotations: { readOnlyHint: true }
|
|
3312
|
+
}, async ({ postId, maxResults }) => wrap(async () => {
|
|
3313
|
+
const rootRaw = await client.get(`/2/tweets/${postId}`, compact({ ...POST_QUERY }));
|
|
3314
|
+
const root = shapePostsResponse({
|
|
3315
|
+
...isRecord(rootRaw) ? rootRaw : {},
|
|
3316
|
+
data: isRecord(rootRaw) && isRecord(rootRaw.data) ? [rootRaw.data] : []
|
|
3317
|
+
}).posts[0];
|
|
3318
|
+
if (!root) return {
|
|
3319
|
+
error: `X returned no post for id ${postId}.`,
|
|
3320
|
+
cost: recordResultCost(ctx, "post", [])
|
|
3321
|
+
};
|
|
3322
|
+
const conversationId = root.conversation_id ?? root.id;
|
|
3323
|
+
const replies = await client.paginate("/2/tweets/search/recent", compact({
|
|
3324
|
+
query: `conversation_id:${conversationId}`,
|
|
3325
|
+
max_results: Math.min(Math.max(maxResults, 10), 100),
|
|
3326
|
+
sort_order: "recency",
|
|
3327
|
+
...POST_QUERY
|
|
3328
|
+
}), { maxItems: maxResults });
|
|
3329
|
+
const ordered = shapePostsResponse({
|
|
3330
|
+
data: replies.data,
|
|
3331
|
+
includes: replies.includes[0] ?? {}
|
|
3332
|
+
}).posts.toReversed();
|
|
3333
|
+
const ids = [root.id, ...ordered.map((p) => p.id)];
|
|
3334
|
+
return {
|
|
3335
|
+
conversation_id: conversationId,
|
|
3336
|
+
posts: [root, ...ordered.filter((p) => p.id !== root.id)],
|
|
3337
|
+
...replies.nextToken ? { next_token: replies.nextToken } : {},
|
|
3338
|
+
note: "Recent search reaches back 7 days. Replies older than that are not returned even if the thread has more.",
|
|
3339
|
+
cost: recordResultCost(ctx, "post", ids)
|
|
3340
|
+
};
|
|
3341
|
+
}));
|
|
3342
|
+
server.registerTool("x_get_quotes", {
|
|
3343
|
+
title: "X: Get Quotes",
|
|
3344
|
+
description: "List posts quoting a given post, newest first.",
|
|
3345
|
+
inputSchema: z.object({
|
|
3346
|
+
postId: postIdArg,
|
|
3347
|
+
maxResults: maxResultsArg,
|
|
3348
|
+
paginationToken: paginationTokenArg
|
|
3349
|
+
}),
|
|
3350
|
+
annotations: { readOnlyHint: true }
|
|
3351
|
+
}, async ({ postId, maxResults, paginationToken }) => wrap(async () => {
|
|
3352
|
+
const res = await client.paginate(`/2/tweets/${postId}/quote_tweets`, compact({
|
|
3353
|
+
max_results: Math.min(Math.max(maxResults, 10), 100),
|
|
3354
|
+
pagination_token: paginationToken,
|
|
3355
|
+
...POST_QUERY
|
|
3356
|
+
}), { maxItems: maxResults });
|
|
3357
|
+
const shaped = shapePostsResponse({
|
|
3358
|
+
data: res.data,
|
|
3359
|
+
includes: res.includes[0] ?? {}
|
|
3360
|
+
});
|
|
3361
|
+
return {
|
|
3362
|
+
posts: shaped.posts,
|
|
3363
|
+
result_count: shaped.posts.length,
|
|
3364
|
+
...res.nextToken ? { next_token: res.nextToken } : {},
|
|
3365
|
+
cost: recordResultCost(ctx, "post", shaped.posts.map((p) => p.id))
|
|
3366
|
+
};
|
|
3367
|
+
}));
|
|
3368
|
+
};
|
|
3369
|
+
//#endregion
|
|
3370
|
+
//#region src/tools/search.ts
|
|
3371
|
+
const queryArg = z.string().min(1).max(1024).describe("An X search query, e.g. \"rust -is:retweet lang:en\". Build one with x_build_search_query if you are unsure of the operators.");
|
|
3372
|
+
const timeArgs = {
|
|
3373
|
+
startTime: z.string().optional().describe("Only posts at or after this ISO-8601 UTC time, e.g. \"2026-07-01T00:00:00Z\"."),
|
|
3374
|
+
endTime: z.string().optional().describe("Only posts before this ISO-8601 UTC time.")
|
|
3375
|
+
};
|
|
3376
|
+
/**
|
|
3377
|
+
* Full-archive search is capped at one request per second on top of its 15-minute
|
|
3378
|
+
* window. Enforced with a real gate rather than hoped for: a paginated call
|
|
3379
|
+
* issues several requests back to back and would trip the limit on its own.
|
|
3380
|
+
*/
|
|
3381
|
+
const createRateGate = (minIntervalMs) => {
|
|
3382
|
+
let last = 0;
|
|
3383
|
+
return async () => {
|
|
3384
|
+
const wait = last + minIntervalMs - Date.now();
|
|
3385
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
|
3386
|
+
last = Date.now();
|
|
3387
|
+
};
|
|
3388
|
+
};
|
|
3389
|
+
/**
|
|
3390
|
+
* Registered separately from the rest of search because it runs entirely
|
|
3391
|
+
* locally — no API call, no credentials, no cost. It stays available on an
|
|
3392
|
+
* unconfigured server, where getting a query right for free is the most useful
|
|
3393
|
+
* thing left to do.
|
|
3394
|
+
*/
|
|
3395
|
+
const registerQueryBuilderTool = (server) => {
|
|
3396
|
+
server.registerTool("x_build_search_query", {
|
|
3397
|
+
title: "X: Build Search Query",
|
|
3398
|
+
description: "Build an X search query from structured parts and explain each operator it used. Runs entirely locally: no API call, no cost, no credentials. Use this to get the query right for free, then pass the result to x_count_recent and only then to x_search_recent.",
|
|
3399
|
+
inputSchema: z.object({
|
|
3400
|
+
allWords: z.string().optional().describe("Words that must all appear, e.g. \"rust async\"."),
|
|
3401
|
+
exactPhrase: z.string().optional().describe("A phrase that must appear verbatim."),
|
|
3402
|
+
anyWords: z.array(z.string()).optional().describe("At least one of these must appear."),
|
|
3403
|
+
noneWords: z.array(z.string()).optional().describe("None of these may appear."),
|
|
3404
|
+
hashtags: z.array(z.string()).optional().describe("Hashtags, with or without \"#\"."),
|
|
3405
|
+
from: z.array(usernameLike()).optional().describe("Only posts by these handles."),
|
|
3406
|
+
to: z.array(usernameLike()).optional().describe("Only replies to these handles."),
|
|
3407
|
+
mentioning: z.array(usernameLike()).optional().describe("Only posts mentioning these."),
|
|
3408
|
+
lang: z.string().optional().describe("BCP-47 language code, e.g. \"en\", \"fr\", \"ja\"."),
|
|
3409
|
+
hasMedia: z.boolean().optional().describe("Only posts with a photo or video."),
|
|
3410
|
+
hasLinks: z.boolean().optional().describe("Only posts containing a link."),
|
|
3411
|
+
isReply: z.boolean().optional().describe("true to require replies, false to exclude them."),
|
|
3412
|
+
isRetweet: z.boolean().optional().describe("true to require reposts, false to exclude them. False is the usual choice."),
|
|
3413
|
+
isQuote: z.boolean().optional().describe("true to require quote posts, false to exclude.")
|
|
3414
|
+
}),
|
|
3415
|
+
annotations: { readOnlyHint: true }
|
|
3416
|
+
}, async (args) => wrap(async () => buildSearchQuery(args)));
|
|
3417
|
+
};
|
|
3418
|
+
const registerSearchTools = (server, client, ctx) => {
|
|
3419
|
+
const runSearch = async (path, args, label, gate) => {
|
|
3420
|
+
assertWithinBudget(ctx, label, ctx.ledger.estimateCount("post", args.maxResults));
|
|
3421
|
+
if (gate) await gate();
|
|
3422
|
+
const res = await client.paginate(path, compact({
|
|
3423
|
+
query: args.query,
|
|
3424
|
+
max_results: Math.min(Math.max(args.maxResults, 10), 100),
|
|
3425
|
+
sort_order: args.sortOrder,
|
|
3426
|
+
start_time: args.startTime,
|
|
3427
|
+
end_time: args.endTime,
|
|
3428
|
+
since_id: args.sinceId,
|
|
3429
|
+
until_id: args.untilId,
|
|
3430
|
+
pagination_token: args.paginationToken,
|
|
3431
|
+
...POST_QUERY
|
|
3432
|
+
}), {
|
|
3433
|
+
maxItems: args.maxResults,
|
|
3434
|
+
maxPages: 5
|
|
3435
|
+
});
|
|
3436
|
+
const shaped = shapePostsResponse({
|
|
3437
|
+
data: res.data,
|
|
3438
|
+
includes: res.includes[0] ?? {}
|
|
3439
|
+
});
|
|
3440
|
+
return {
|
|
3441
|
+
query: args.query,
|
|
3442
|
+
posts: shaped.posts,
|
|
3443
|
+
result_count: shaped.posts.length,
|
|
3444
|
+
...res.nextToken ? { next_token: res.nextToken } : {},
|
|
3445
|
+
cost: recordResultCost(ctx, "post", shaped.posts.map((p) => p.id))
|
|
3446
|
+
};
|
|
3447
|
+
};
|
|
3448
|
+
server.registerTool("x_search_recent", {
|
|
3449
|
+
title: "X: Search Recent",
|
|
3450
|
+
description: "Search posts from the last 7 days. Supports X's full query syntax: `from:handle`, `to:handle`, `#tag`, `\"exact phrase\"`, `lang:en`, `has:media`, `has:links`, `url:example.com`, `conversation_id:`, and negation with `-is:retweet` or `-is:reply`. Run x_count_recent first to see how big a query is before paying to read it.",
|
|
3451
|
+
inputSchema: z.object({
|
|
3452
|
+
query: queryArg,
|
|
3453
|
+
maxResults: maxResultsArg,
|
|
3454
|
+
sortOrder: z.enum(["recency", "relevancy"]).optional().describe("`recency` (newest first, the default) or `relevancy`."),
|
|
3455
|
+
...timeArgs,
|
|
3456
|
+
sinceId: z.string().regex(/^\d+$/).optional().describe("Only posts newer than this id."),
|
|
3457
|
+
untilId: z.string().regex(/^\d+$/).optional().describe("Only posts older than this id."),
|
|
3458
|
+
paginationToken: paginationTokenArg
|
|
3459
|
+
}),
|
|
3460
|
+
annotations: { readOnlyHint: true }
|
|
3461
|
+
}, async (args) => wrap(() => runSearch("/2/tweets/search/recent", args, "x_search_recent")));
|
|
3462
|
+
server.registerTool("x_count_recent", {
|
|
3463
|
+
title: "X: Count Recent",
|
|
3464
|
+
description: "Count how many posts match a query over the last 7 days WITHOUT reading any of them. This endpoint returns only totals, so it costs nothing per post — always run it before a broad x_search_recent to find out whether you are about to read 10 posts or 10,000.",
|
|
3465
|
+
inputSchema: z.object({
|
|
3466
|
+
query: queryArg,
|
|
3467
|
+
granularity: z.enum([
|
|
3468
|
+
"minute",
|
|
3469
|
+
"hour",
|
|
3470
|
+
"day"
|
|
3471
|
+
]).default("day").describe("Bucket size for the time series. Defaults to day."),
|
|
3472
|
+
...timeArgs
|
|
3473
|
+
}),
|
|
3474
|
+
annotations: { readOnlyHint: true }
|
|
3475
|
+
}, async ({ query, granularity, startTime, endTime }) => wrap(async () => {
|
|
3476
|
+
const raw = await client.get("/2/tweets/counts/recent", compact({
|
|
3477
|
+
query,
|
|
3478
|
+
granularity,
|
|
3479
|
+
start_time: startTime,
|
|
3480
|
+
end_time: endTime
|
|
3481
|
+
}));
|
|
3482
|
+
const meta = isRecord(raw) && isRecord(raw.meta) ? raw.meta : {};
|
|
3483
|
+
const total = typeof meta.total_tweet_count === "number" ? meta.total_tweet_count : 0;
|
|
3484
|
+
const estimated = ctx.ledger.estimateCount("post", total);
|
|
3485
|
+
return {
|
|
3486
|
+
query,
|
|
3487
|
+
total_posts: total,
|
|
3488
|
+
buckets: isRecord(raw) && Array.isArray(raw.data) ? raw.data : [],
|
|
3489
|
+
cost: {
|
|
3490
|
+
estimated_usd: 0,
|
|
3491
|
+
note: "Counts are not billed per post."
|
|
3492
|
+
},
|
|
3493
|
+
reading_all_would_cost_usd: Math.round(estimated * 100) / 100,
|
|
3494
|
+
...total > 100 ? { advice: `Reading all ${total} matches would cost about $${(Math.round(estimated * 100) / 100).toFixed(2)}. Narrow the query with -is:retweet, lang:, or a tighter time window before searching.` } : {}
|
|
3495
|
+
};
|
|
3496
|
+
}));
|
|
3497
|
+
if (!ctx.enableFullArchive) return;
|
|
3498
|
+
const archiveGate = createRateGate(1e3);
|
|
3499
|
+
server.registerTool("x_search_all", {
|
|
3500
|
+
title: "X: Search All",
|
|
3501
|
+
description: "Search the FULL archive, back to X's first post in March 2006 — not just the last 7 days. Requires a paid access tier and is limited to one request per second, so it is slower than x_search_recent. Same query syntax. Costs the same per post read.",
|
|
3502
|
+
inputSchema: z.object({
|
|
3503
|
+
query: queryArg,
|
|
3504
|
+
maxResults: maxResultsArg,
|
|
3505
|
+
sortOrder: z.enum(["recency", "relevancy"]).optional(),
|
|
3506
|
+
...timeArgs,
|
|
3507
|
+
sinceId: z.string().regex(/^\d+$/).optional(),
|
|
3508
|
+
untilId: z.string().regex(/^\d+$/).optional(),
|
|
3509
|
+
paginationToken: paginationTokenArg
|
|
3510
|
+
}),
|
|
3511
|
+
annotations: { readOnlyHint: true }
|
|
3512
|
+
}, async (args) => wrap(() => runSearch("/2/tweets/search/all", args, "x_search_all", archiveGate)));
|
|
3513
|
+
};
|
|
3514
|
+
function usernameLike() {
|
|
3515
|
+
return z.string().regex(/^@?[A-Za-z0-9_]{1,15}$/);
|
|
3516
|
+
}
|
|
3517
|
+
/** Grouped with OR and parenthesised, which is what X's `from:a OR from:b` needs. */
|
|
3518
|
+
const orGroup = (operator, values) => values.length === 1 ? `${operator}:${values[0]}` : `(${values.map((v) => `${operator}:${v}`).join(" OR ")})`;
|
|
3519
|
+
const buildSearchQuery = (parts) => {
|
|
3520
|
+
const clauses = [];
|
|
3521
|
+
const explanation = [];
|
|
3522
|
+
if (parts.allWords?.trim()) {
|
|
3523
|
+
clauses.push(parts.allWords.trim());
|
|
3524
|
+
explanation.push(`\`${parts.allWords.trim()}\` — all of these words must appear.`);
|
|
3525
|
+
}
|
|
3526
|
+
if (parts.exactPhrase?.trim()) {
|
|
3527
|
+
clauses.push(`"${parts.exactPhrase.trim()}"`);
|
|
3528
|
+
explanation.push(`\`"${parts.exactPhrase.trim()}"\` — this exact phrase must appear.`);
|
|
3529
|
+
}
|
|
3530
|
+
if (parts.anyWords?.length) {
|
|
3531
|
+
clauses.push(`(${parts.anyWords.join(" OR ")})`);
|
|
3532
|
+
explanation.push(`\`(${parts.anyWords.join(" OR ")})\` — at least one of these must appear.`);
|
|
3533
|
+
}
|
|
3534
|
+
for (const word of parts.noneWords ?? []) {
|
|
3535
|
+
clauses.push(`-${word}`);
|
|
3536
|
+
explanation.push(`\`-${word}\` — excludes posts containing "${word}".`);
|
|
3537
|
+
}
|
|
3538
|
+
for (const tag of parts.hashtags ?? []) {
|
|
3539
|
+
const clean = tag.startsWith("#") ? tag : `#${tag}`;
|
|
3540
|
+
clauses.push(clean);
|
|
3541
|
+
explanation.push(`\`${clean}\` — must carry this hashtag.`);
|
|
3542
|
+
}
|
|
3543
|
+
if (parts.from?.length) {
|
|
3544
|
+
const handles = parts.from.map(stripAt);
|
|
3545
|
+
clauses.push(orGroup("from", handles));
|
|
3546
|
+
explanation.push(`\`from:\` — only posts authored by ${handles.map((h) => `@${h}`).join(" or ")}.`);
|
|
3547
|
+
}
|
|
3548
|
+
if (parts.to?.length) {
|
|
3549
|
+
const handles = parts.to.map(stripAt);
|
|
3550
|
+
clauses.push(orGroup("to", handles));
|
|
3551
|
+
explanation.push(`\`to:\` — only replies addressed to ${handles.map((h) => `@${h}`).join(" or ")}.`);
|
|
3552
|
+
}
|
|
3553
|
+
for (const handle of (parts.mentioning ?? []).map(stripAt)) {
|
|
3554
|
+
clauses.push(`@${handle}`);
|
|
3555
|
+
explanation.push(`\`@${handle}\` — must mention this account.`);
|
|
3556
|
+
}
|
|
3557
|
+
if (parts.lang) {
|
|
3558
|
+
clauses.push(`lang:${parts.lang}`);
|
|
3559
|
+
explanation.push(`\`lang:${parts.lang}\` — only posts X detected as this language.`);
|
|
3560
|
+
}
|
|
3561
|
+
if (parts.hasMedia !== void 0) {
|
|
3562
|
+
clauses.push(`${parts.hasMedia ? "" : "-"}has:media`);
|
|
3563
|
+
explanation.push(`\`${parts.hasMedia ? "" : "-"}has:media\` — ${parts.hasMedia ? "requires" : "excludes"} posts with a photo or video.`);
|
|
3564
|
+
}
|
|
3565
|
+
if (parts.hasLinks !== void 0) {
|
|
3566
|
+
clauses.push(`${parts.hasLinks ? "" : "-"}has:links`);
|
|
3567
|
+
explanation.push(`\`${parts.hasLinks ? "" : "-"}has:links\` — ${parts.hasLinks ? "requires" : "excludes"} posts containing a link.`);
|
|
3568
|
+
}
|
|
3569
|
+
for (const [flag, name] of [
|
|
3570
|
+
[parts.isReply, "reply"],
|
|
3571
|
+
[parts.isRetweet, "retweet"],
|
|
3572
|
+
[parts.isQuote, "quote"]
|
|
3573
|
+
]) {
|
|
3574
|
+
if (flag === void 0) continue;
|
|
3575
|
+
clauses.push(`${flag ? "" : "-"}is:${name}`);
|
|
3576
|
+
explanation.push(`\`${flag ? "" : "-"}is:${name}\` — ${flag ? "only" : "never"} ${name} posts.` + (name === "retweet" && !flag ? " This is the single most useful filter: it removes the duplicate noise of reposts." : ""));
|
|
3577
|
+
}
|
|
3578
|
+
const query = clauses.join(" ");
|
|
3579
|
+
return {
|
|
3580
|
+
query,
|
|
3581
|
+
explanation,
|
|
3582
|
+
length: query.length,
|
|
3583
|
+
valid: query.length > 0 && query.length <= 1024,
|
|
3584
|
+
...query.length === 0 ? { warning: "No criteria given — the query is empty." } : {},
|
|
3585
|
+
...query.length > 1024 ? { warning: `Query is ${query.length} characters; X's limit is 1024.` } : {}
|
|
3586
|
+
};
|
|
3587
|
+
};
|
|
3588
|
+
//#endregion
|
|
3589
|
+
//#region src/tools/timelines.ts
|
|
3590
|
+
/**
|
|
3591
|
+
* Both endpoints here are self-only: X permits reading *your* home timeline and
|
|
3592
|
+
* *your* bookmarks and nobody else's. So the user id comes from the stored
|
|
3593
|
+
* token rather than from an argument — accepting one would imply a capability
|
|
3594
|
+
* that does not exist.
|
|
3595
|
+
*/
|
|
3596
|
+
const resolveOwnUserId = async (client, ctx) => {
|
|
3597
|
+
const status = ctx.tokenProvider.describe().user;
|
|
3598
|
+
if (!status.authenticated) throw new UserContextRequiredError("This tool", status.reason);
|
|
3599
|
+
if (status.userId) return status.userId;
|
|
3600
|
+
const raw = await client.get("/2/users/me", {}, "user");
|
|
3601
|
+
const id = isRecord(raw) && isRecord(raw.data) ? raw.data.id : void 0;
|
|
3602
|
+
const username = isRecord(raw) && isRecord(raw.data) ? raw.data.username : void 0;
|
|
3603
|
+
if (typeof id !== "string" || !id) throw new UserContextRequiredError("This tool", "X did not return your account id from /2/users/me, so there is no way to identify whose timeline to read. Re-run `x-api-mcp login`, and check the app is enrolled in the Pay-per-use package and Production environment at console.x.com");
|
|
3604
|
+
ctx.ledger.record("owned", [id]);
|
|
3605
|
+
const stored = ctx.tokenStore?.read();
|
|
3606
|
+
if (stored) ctx.tokenStore?.write({
|
|
3607
|
+
...stored,
|
|
3608
|
+
userId: id,
|
|
3609
|
+
...typeof username === "string" && username ? { username } : {}
|
|
3610
|
+
});
|
|
3611
|
+
return id;
|
|
3612
|
+
};
|
|
3613
|
+
const registerTimelineTools = (server, client, ctx) => {
|
|
3614
|
+
server.registerTool("x_get_home_timeline", {
|
|
3615
|
+
title: "X: Get Home Timeline",
|
|
3616
|
+
description: "Your own reverse-chronological home timeline — the posts from accounts you follow. Requires `x-api-mcp login`; X only ever serves this for the authenticated account, so there is no way to read someone else's. Billed at the cheaper owned-read rate.",
|
|
3617
|
+
inputSchema: z.object({
|
|
3618
|
+
maxResults: maxResultsArg,
|
|
3619
|
+
excludeReplies: z.boolean().default(false).describe("Leave out replies."),
|
|
3620
|
+
excludeReposts: z.boolean().default(false).describe("Leave out reposts (retweets)."),
|
|
3621
|
+
sinceId: z.string().regex(/^\d+$/).optional().describe("Only posts newer than this id — useful for polling."),
|
|
3622
|
+
paginationToken: paginationTokenArg
|
|
3623
|
+
}),
|
|
3624
|
+
annotations: { readOnlyHint: true }
|
|
3625
|
+
}, async ({ maxResults, excludeReplies, excludeReposts, sinceId, paginationToken }) => wrap(async () => {
|
|
3626
|
+
const userId = await resolveOwnUserId(client, ctx);
|
|
3627
|
+
const exclude = [...excludeReplies ? ["replies"] : [], ...excludeReposts ? ["retweets"] : []];
|
|
3628
|
+
const res = await client.paginate(`/2/users/${userId}/timelines/reverse_chronological`, compact({
|
|
3629
|
+
max_results: Math.min(Math.max(maxResults, 5), 100),
|
|
3630
|
+
exclude: exclude.length > 0 ? exclude : void 0,
|
|
3631
|
+
since_id: sinceId,
|
|
3632
|
+
pagination_token: paginationToken,
|
|
3633
|
+
...POST_QUERY
|
|
3634
|
+
}), {
|
|
3635
|
+
maxItems: maxResults,
|
|
3636
|
+
auth: "user"
|
|
3637
|
+
});
|
|
3638
|
+
const shaped = shapePostsResponse({
|
|
3639
|
+
data: res.data,
|
|
3640
|
+
includes: res.includes[0] ?? {}
|
|
3641
|
+
});
|
|
3642
|
+
return {
|
|
3643
|
+
posts: shaped.posts,
|
|
3644
|
+
result_count: shaped.posts.length,
|
|
3645
|
+
...res.nextToken ? { next_token: res.nextToken } : {},
|
|
3646
|
+
cost: recordResultCost(ctx, "owned", shaped.posts.map((p) => p.id))
|
|
3647
|
+
};
|
|
3648
|
+
}));
|
|
3649
|
+
server.registerTool("x_get_bookmarks", {
|
|
3650
|
+
title: "X: Get Bookmarks",
|
|
3651
|
+
description: "Your saved bookmarks, newest first. Requires `x-api-mcp login` with the `bookmark.read` scope — an app-only Bearer token cannot reach bookmarks at all.",
|
|
3652
|
+
inputSchema: z.object({
|
|
3653
|
+
maxResults: maxResultsArg,
|
|
3654
|
+
paginationToken: paginationTokenArg
|
|
3655
|
+
}),
|
|
3656
|
+
annotations: { readOnlyHint: true }
|
|
3657
|
+
}, async ({ maxResults, paginationToken }) => wrap(async () => {
|
|
3658
|
+
const userId = await resolveOwnUserId(client, ctx);
|
|
3659
|
+
const res = await client.paginate(`/2/users/${userId}/bookmarks`, compact({
|
|
3660
|
+
max_results: Math.min(Math.max(maxResults, 1), 100),
|
|
3661
|
+
pagination_token: paginationToken,
|
|
3662
|
+
...POST_QUERY
|
|
3663
|
+
}), {
|
|
3664
|
+
maxItems: maxResults,
|
|
3665
|
+
auth: "user"
|
|
3666
|
+
});
|
|
3667
|
+
const shaped = shapePostsResponse({
|
|
3668
|
+
data: res.data,
|
|
3669
|
+
includes: res.includes[0] ?? {}
|
|
3670
|
+
});
|
|
3671
|
+
return {
|
|
3672
|
+
posts: shaped.posts,
|
|
3673
|
+
result_count: shaped.posts.length,
|
|
3674
|
+
...res.nextToken ? { next_token: res.nextToken } : {},
|
|
3675
|
+
cost: recordResultCost(ctx, "owned", shaped.posts.map((p) => p.id))
|
|
3676
|
+
};
|
|
3677
|
+
}));
|
|
3678
|
+
};
|
|
3679
|
+
//#endregion
|
|
3680
|
+
//#region src/tools/usage.ts
|
|
3681
|
+
const registerUsageTools = (server, client, ctx) => {
|
|
3682
|
+
server.registerTool("x_usage_report", {
|
|
3683
|
+
title: "X: Usage Report",
|
|
3684
|
+
description: "What this session has spent against X's pay-per-use rates, how much the dedup cache saved, and the pricing table used to compute it. Estimates only, counted since this process started — the X developer console (console.x.com) is the authoritative record.",
|
|
3685
|
+
inputSchema: z.object({}),
|
|
3686
|
+
annotations: { readOnlyHint: true }
|
|
3687
|
+
}, async () => wrap(async () => ctx.ledger.report(ctx.cache.stats())));
|
|
3688
|
+
server.registerTool("x_rate_limit_status", {
|
|
3689
|
+
title: "X: Rate Limit Status",
|
|
3690
|
+
description: "Rate-limit headroom per endpoint, as of the last response from each. Empty until at least one request has been made. Useful when a call has just been rate-limited and you need to know how long to wait. Covers both the X API v2 and the Ads API, which have separate budgets — the `api` field says which, and `scope` distinguishes the Ads endpoint, account and cost limits.",
|
|
3691
|
+
inputSchema: z.object({}),
|
|
3692
|
+
annotations: { readOnlyHint: true }
|
|
3693
|
+
}, async () => wrap(async () => {
|
|
3694
|
+
const limits = [...client.rateLimitStatus().map((s) => ({
|
|
3695
|
+
api: "v2",
|
|
3696
|
+
...s
|
|
3697
|
+
})), ...ctx.ads ? ctx.ads.client.rateLimitStatus().map((s) => ({
|
|
3698
|
+
api: "ads",
|
|
3699
|
+
...s
|
|
3700
|
+
})) : []];
|
|
3701
|
+
const adsSeen = limits.some((l) => l.api === "ads");
|
|
3702
|
+
return {
|
|
3703
|
+
endpoints: limits,
|
|
3704
|
+
...limits.length === 0 ? { note: "No requests issued yet this session, so X has not reported any limits." } : {},
|
|
3705
|
+
...ctx.ads && !adsSeen ? { ads_note: "Ads is configured but has not been called yet this session, so it reports no limits. That is not a failure." } : {}
|
|
3706
|
+
};
|
|
3707
|
+
}));
|
|
3708
|
+
};
|
|
3709
|
+
//#endregion
|
|
3710
|
+
//#region src/tools/users.ts
|
|
3711
|
+
/**
|
|
3712
|
+
* Resolve a handle to the numeric id X's timeline endpoints require. Cached and
|
|
3713
|
+
* billed as a user read, because that is exactly what it is.
|
|
3714
|
+
*/
|
|
3715
|
+
const resolveUserId = async (client, ctx, opts) => {
|
|
3716
|
+
if (opts.userId) return { id: opts.userId };
|
|
3717
|
+
if (!opts.username) throw new PreconditionError("Provide either `username` or `userId`.", { got: opts });
|
|
3718
|
+
const handle = stripAt(opts.username);
|
|
3719
|
+
const user = shapeUsersResponse(await client.get(`/2/users/by/username/${handle}`, compact({ ...USER_QUERY }))).users[0];
|
|
3720
|
+
if (!user?.id) throw new PreconditionError(`No X account found for @${handle}.`, { username: handle });
|
|
3721
|
+
ctx.ledger.record("user", [user.id]);
|
|
3722
|
+
return {
|
|
3723
|
+
id: user.id,
|
|
3724
|
+
user
|
|
3725
|
+
};
|
|
3726
|
+
};
|
|
3727
|
+
const registerUserTools = (server, client, ctx) => {
|
|
3728
|
+
const fetchUsersByIds = async (ids) => {
|
|
3729
|
+
const raw = await client.get("/2/users", compact({
|
|
3730
|
+
ids,
|
|
3731
|
+
...USER_QUERY
|
|
3732
|
+
}));
|
|
3733
|
+
return new Map(shapeUsersResponse(raw).users.map((u) => [u.id, u]));
|
|
3734
|
+
};
|
|
3735
|
+
server.registerTool("x_get_user", {
|
|
3736
|
+
title: "X: Get User",
|
|
3737
|
+
description: "Look up one profile by handle or numeric id: bio, follower and post counts, join date. A user read costs about $0.010, twice a post read.",
|
|
3738
|
+
inputSchema: z.object({
|
|
3739
|
+
username: usernameArg.optional(),
|
|
3740
|
+
userId: userIdArg.optional()
|
|
3741
|
+
}),
|
|
3742
|
+
annotations: { readOnlyHint: true }
|
|
3743
|
+
}, async ({ username, userId }) => wrap(async () => {
|
|
3744
|
+
if (!username && !userId) throw new PreconditionError("Provide either `username` (a handle like \"mgcrea\") or `userId` (digits).");
|
|
3745
|
+
if (username && userId) throw new PreconditionError("Provide only one of `username` or `userId` — they may disagree.", {
|
|
3746
|
+
username,
|
|
3747
|
+
userId
|
|
3748
|
+
});
|
|
3749
|
+
if (userId) {
|
|
3750
|
+
const { items, cost, notFound } = await cachedByIds(ctx, "user", [userId], fetchUsersByIds, "x_get_user");
|
|
3751
|
+
return items[0] ? {
|
|
3752
|
+
user: items[0],
|
|
3753
|
+
cost
|
|
3754
|
+
} : {
|
|
3755
|
+
error: `No X account found for id ${notFound[0]}.`,
|
|
3756
|
+
cost
|
|
3757
|
+
};
|
|
3758
|
+
}
|
|
3759
|
+
const handle = stripAt(username);
|
|
3760
|
+
const user = shapeUsersResponse(await client.get(`/2/users/by/username/${handle}`, compact({ ...USER_QUERY }))).users[0];
|
|
3761
|
+
if (!user) return { error: `No X account found for @${handle}.` };
|
|
3762
|
+
ctx.cache.set("user", user.id, user);
|
|
3763
|
+
return {
|
|
3764
|
+
user,
|
|
3765
|
+
cost: recordResultCost(ctx, "user", [user.id])
|
|
3766
|
+
};
|
|
3767
|
+
}));
|
|
3768
|
+
server.registerTool("x_get_users", {
|
|
3769
|
+
title: "X: Get Users",
|
|
3770
|
+
description: "Look up up to 100 profiles at once, by handle or by id. One request instead of many, billed per profile returned.",
|
|
3771
|
+
inputSchema: z.object({
|
|
3772
|
+
usernames: z.array(usernameArg).min(1).max(100).optional().describe("Handles to look up, e.g. [\"mgcrea\", \"acme\"]."),
|
|
3773
|
+
userIds: z.array(userIdArg).min(1).max(100).optional()
|
|
3774
|
+
}),
|
|
3775
|
+
annotations: { readOnlyHint: true }
|
|
3776
|
+
}, async ({ usernames, userIds }) => wrap(async () => {
|
|
3777
|
+
if (!usernames && !userIds) throw new PreconditionError("Provide either `usernames` or `userIds`.");
|
|
3778
|
+
if (usernames && userIds) throw new PreconditionError("Provide only one of `usernames` or `userIds`.");
|
|
3779
|
+
if (userIds) {
|
|
3780
|
+
const { items, cost, notFound } = await cachedByIds(ctx, "user", [...new Set(userIds)], fetchUsersByIds, "x_get_users");
|
|
3781
|
+
return {
|
|
3782
|
+
users: items,
|
|
3783
|
+
...notFound.length > 0 ? { not_found: notFound } : {},
|
|
3784
|
+
cost
|
|
3785
|
+
};
|
|
3786
|
+
}
|
|
3787
|
+
const handles = [...new Set(usernames.map(stripAt))];
|
|
3788
|
+
const shaped = shapeUsersResponse(await client.get("/2/users/by", compact({
|
|
3789
|
+
usernames: handles,
|
|
3790
|
+
...USER_QUERY
|
|
3791
|
+
})));
|
|
3792
|
+
for (const user of shaped.users) ctx.cache.set("user", user.id, user);
|
|
3793
|
+
return {
|
|
3794
|
+
users: shaped.users,
|
|
3795
|
+
...shaped.not_found ? { not_found: shaped.not_found } : {},
|
|
3796
|
+
cost: recordResultCost(ctx, "user", shaped.users.map((u) => u.id))
|
|
3797
|
+
};
|
|
3798
|
+
}));
|
|
3799
|
+
server.registerTool("x_get_user_posts", {
|
|
3800
|
+
title: "X: Get User Posts",
|
|
3801
|
+
description: "A user's own recent posts, newest first. Replies and reposts are excluded by default so you get their original writing; set the flags to include them. Reaches back roughly 3200 posts, X's timeline limit.",
|
|
3802
|
+
inputSchema: z.object({
|
|
3803
|
+
username: usernameArg.optional(),
|
|
3804
|
+
userId: userIdArg.optional(),
|
|
3805
|
+
maxResults: maxResultsArg,
|
|
3806
|
+
excludeReplies: z.boolean().default(true).describe("Leave out replies to other people. Defaults to true."),
|
|
3807
|
+
excludeReposts: z.boolean().default(true).describe("Leave out reposts (retweets). Defaults to true."),
|
|
3808
|
+
startTime: z.string().optional().describe("Only posts at or after this ISO-8601 UTC time, e.g. \"2026-07-01T00:00:00Z\"."),
|
|
3809
|
+
endTime: z.string().optional().describe("Only posts before this ISO-8601 UTC time."),
|
|
3810
|
+
paginationToken: paginationTokenArg
|
|
3811
|
+
}),
|
|
3812
|
+
annotations: { readOnlyHint: true }
|
|
3813
|
+
}, async ({ username, userId, maxResults, excludeReplies, excludeReposts, startTime, endTime, paginationToken }) => wrap(async () => {
|
|
3814
|
+
const { id } = await resolveUserId(client, ctx, {
|
|
3815
|
+
username,
|
|
3816
|
+
userId
|
|
3817
|
+
});
|
|
3818
|
+
const exclude = [...excludeReplies ? ["replies"] : [], ...excludeReposts ? ["retweets"] : []];
|
|
3819
|
+
const res = await client.paginate(`/2/users/${id}/tweets`, compact({
|
|
3820
|
+
max_results: Math.min(Math.max(maxResults, 5), 100),
|
|
3821
|
+
exclude: exclude.length > 0 ? exclude : void 0,
|
|
3822
|
+
start_time: startTime,
|
|
3823
|
+
end_time: endTime,
|
|
3824
|
+
pagination_token: paginationToken,
|
|
3825
|
+
...POST_QUERY
|
|
3826
|
+
}), { maxItems: maxResults });
|
|
3827
|
+
const shaped = shapePostsResponse({
|
|
3828
|
+
data: res.data,
|
|
3829
|
+
includes: res.includes[0] ?? {}
|
|
3830
|
+
});
|
|
3831
|
+
return {
|
|
3832
|
+
user_id: id,
|
|
3833
|
+
posts: shaped.posts,
|
|
3834
|
+
result_count: shaped.posts.length,
|
|
3835
|
+
...res.nextToken ? { next_token: res.nextToken } : {},
|
|
3836
|
+
cost: recordResultCost(ctx, "post", shaped.posts.map((p) => p.id))
|
|
3837
|
+
};
|
|
3838
|
+
}));
|
|
3839
|
+
server.registerTool("x_get_user_mentions", {
|
|
3840
|
+
title: "X: Get User Mentions",
|
|
3841
|
+
description: "Posts mentioning a user, newest first — who is talking about them, and what.",
|
|
3842
|
+
inputSchema: z.object({
|
|
3843
|
+
username: usernameArg.optional(),
|
|
3844
|
+
userId: userIdArg.optional(),
|
|
3845
|
+
maxResults: maxResultsArg,
|
|
3846
|
+
startTime: z.string().optional().describe("Only posts at or after this ISO-8601 UTC time."),
|
|
3847
|
+
endTime: z.string().optional().describe("Only posts before this ISO-8601 UTC time."),
|
|
3848
|
+
paginationToken: paginationTokenArg
|
|
3849
|
+
}),
|
|
3850
|
+
annotations: { readOnlyHint: true }
|
|
3851
|
+
}, async ({ username, userId, maxResults, startTime, endTime, paginationToken }) => wrap(async () => {
|
|
3852
|
+
const { id } = await resolveUserId(client, ctx, {
|
|
3853
|
+
username,
|
|
3854
|
+
userId
|
|
3855
|
+
});
|
|
3856
|
+
const res = await client.paginate(`/2/users/${id}/mentions`, compact({
|
|
3857
|
+
max_results: Math.min(Math.max(maxResults, 5), 100),
|
|
3858
|
+
start_time: startTime,
|
|
3859
|
+
end_time: endTime,
|
|
3860
|
+
pagination_token: paginationToken,
|
|
3861
|
+
...POST_QUERY
|
|
3862
|
+
}), { maxItems: maxResults });
|
|
3863
|
+
const shaped = shapePostsResponse({
|
|
3864
|
+
data: res.data,
|
|
3865
|
+
includes: res.includes[0] ?? {}
|
|
3866
|
+
});
|
|
3867
|
+
return {
|
|
3868
|
+
user_id: id,
|
|
3869
|
+
posts: shaped.posts,
|
|
3870
|
+
result_count: shaped.posts.length,
|
|
3871
|
+
...res.nextToken ? { next_token: res.nextToken } : {},
|
|
3872
|
+
cost: recordResultCost(ctx, "post", shaped.posts.map((p) => p.id))
|
|
3873
|
+
};
|
|
3874
|
+
}));
|
|
3875
|
+
};
|
|
3876
|
+
//#endregion
|
|
3877
|
+
//#region src/tools/index.ts
|
|
3878
|
+
/**
|
|
3879
|
+
* Register the X API tools.
|
|
3880
|
+
*
|
|
3881
|
+
* Read tools and the free compose tools are always registered. The paid write
|
|
3882
|
+
* tools appear only when `allowWrites` *and* `writeBackend === "api"`;
|
|
3883
|
+
* `x_search_all` only when full-archive access is enabled; and the login tools
|
|
3884
|
+
* and user-context timelines only when an OAuth client id is configured — so
|
|
3885
|
+
* with the defaults those tools are not merely refused, they are invisible and
|
|
3886
|
+
* cannot be called at all.
|
|
3887
|
+
*/
|
|
3888
|
+
const registerTools = (server, client, ctx) => {
|
|
3889
|
+
registerComposeTools(server, client, ctx);
|
|
3890
|
+
registerAuthTools(server, ctx);
|
|
3891
|
+
registerQueryBuilderTool(server);
|
|
3892
|
+
if (!ctx.hasCredentials) return;
|
|
3893
|
+
registerPostTools(server, client, ctx);
|
|
3894
|
+
registerUserTools(server, client, ctx);
|
|
3895
|
+
registerSearchTools(server, client, ctx);
|
|
3896
|
+
registerUsageTools(server, client, ctx);
|
|
3897
|
+
if (ctx.login) {
|
|
3898
|
+
registerTimelineTools(server, client, ctx);
|
|
3899
|
+
if (ctx.ads) registerAdsTools(server, ctx.ads.client, ctx);
|
|
3900
|
+
}
|
|
3901
|
+
};
|
|
3902
|
+
//#endregion
|
|
3903
|
+
//#region src/server.ts
|
|
3904
|
+
const SERVER_NAME = BUILD_INFO.name;
|
|
3905
|
+
const SERVER_VERSION = BUILD_INFO.version;
|
|
3906
|
+
const USER_AGENT = `mcp-x-api-js/${BUILD_INFO.version}`;
|
|
3907
|
+
const createServer$1 = (opts) => {
|
|
3908
|
+
const { config } = opts;
|
|
3909
|
+
const server = new McpServer({
|
|
3910
|
+
name: SERVER_NAME,
|
|
3911
|
+
version: SERVER_VERSION
|
|
3912
|
+
});
|
|
3913
|
+
const scopes = effectiveScopes(config);
|
|
3914
|
+
const store = createTokenStore(config.tokenFile);
|
|
3915
|
+
const tokenProvider = opts.tokenProvider ?? compositeTokenProvider({
|
|
3916
|
+
...config.bearerToken ? { app: bearerTokenProvider(config.bearerToken) } : {},
|
|
3917
|
+
...config.clientId ? { user: userTokenProvider({
|
|
3918
|
+
store,
|
|
3919
|
+
oauth: createOAuthClient(config, opts.fetch ?? fetch),
|
|
3920
|
+
clientId: config.clientId,
|
|
3921
|
+
requiredScopes: scopes,
|
|
3922
|
+
...opts.logger ? { logger: opts.logger } : {},
|
|
3923
|
+
...opts.now ? { now: opts.now } : {}
|
|
3924
|
+
}) } : {}
|
|
3925
|
+
});
|
|
3926
|
+
const client = new XApiClient({
|
|
3927
|
+
baseUrl: config.baseUrl,
|
|
3928
|
+
tokenProvider,
|
|
3929
|
+
maxRetries: config.maxRetries,
|
|
3930
|
+
userAgent: USER_AGENT,
|
|
3931
|
+
...opts.fetch ? { fetch: opts.fetch } : {},
|
|
3932
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
3933
|
+
});
|
|
3934
|
+
const ads = hasAdsAccess(config) ? new AdsApiClient({
|
|
3935
|
+
baseUrl: config.adsBaseUrl,
|
|
3936
|
+
tokenProvider,
|
|
3937
|
+
maxRetries: config.maxRetries,
|
|
3938
|
+
maxDownloadBytes: config.adsMaxDownloadBytes,
|
|
3939
|
+
userAgent: USER_AGENT,
|
|
3940
|
+
...opts.fetch ? { fetch: opts.fetch } : {},
|
|
3941
|
+
...opts.logger ? { logger: opts.logger } : {}
|
|
3942
|
+
}) : void 0;
|
|
3943
|
+
const cache = createDayCache({
|
|
3944
|
+
maxEntries: config.cacheMaxEntries,
|
|
3945
|
+
enabled: config.cacheEnabled,
|
|
3946
|
+
...opts.now ? { now: opts.now } : {}
|
|
3947
|
+
});
|
|
3948
|
+
const ledger = createLedger({
|
|
3949
|
+
pricing: config.pricing,
|
|
3950
|
+
budgetUsd: config.monthlyBudgetUsd,
|
|
3951
|
+
...opts.now ? { now: opts.now } : {}
|
|
3952
|
+
});
|
|
3953
|
+
registerTools(server, client, {
|
|
3954
|
+
allowWrites: config.allowWrites,
|
|
3955
|
+
writeBackend: config.writeBackend,
|
|
3956
|
+
autoOpenBrowser: config.autoOpenBrowser,
|
|
3957
|
+
enableFullArchive: config.enableFullArchive,
|
|
3958
|
+
defaultMaxResults: config.defaultMaxResults,
|
|
3959
|
+
pricing: config.pricing,
|
|
3960
|
+
budgetUsd: config.monthlyBudgetUsd,
|
|
3961
|
+
cache,
|
|
3962
|
+
ledger,
|
|
3963
|
+
tokenProvider,
|
|
3964
|
+
hasCredentials: hasApiCredentials(config),
|
|
3965
|
+
...hasApiCredentials(config) ? {} : { setup: setupInstructions(config) },
|
|
3966
|
+
...ads ? { ads: {
|
|
3967
|
+
client: ads,
|
|
3968
|
+
allowWrites: config.adsAllowWrites,
|
|
3969
|
+
sandbox: ads.sandbox,
|
|
3970
|
+
baseUrl: config.adsBaseUrl,
|
|
3971
|
+
...config.adsAccountId ? { accountId: config.adsAccountId } : {}
|
|
3972
|
+
} } : { adsSetup: adsSetupInstructions(config) },
|
|
3973
|
+
...config.clientId ? {
|
|
3974
|
+
tokenFile: config.tokenFile,
|
|
3975
|
+
tokenStore: store,
|
|
3976
|
+
login: async (open) => {
|
|
3977
|
+
const { tokens } = await startLoginFlow({
|
|
3978
|
+
config,
|
|
3979
|
+
store,
|
|
3980
|
+
...opts.fetch ? { fetch: opts.fetch } : {},
|
|
3981
|
+
...open ? { openBrowser: openInBrowser } : {},
|
|
3982
|
+
...opts.logger ? { logger: opts.logger } : {},
|
|
3983
|
+
...opts.now ? { now: opts.now } : {}
|
|
3984
|
+
});
|
|
3985
|
+
return {
|
|
3986
|
+
username: tokens.username,
|
|
3987
|
+
userId: tokens.userId,
|
|
3988
|
+
scopes: tokens.scopes,
|
|
3989
|
+
tokenFile: config.tokenFile
|
|
3990
|
+
};
|
|
3991
|
+
},
|
|
3992
|
+
logout: () => store.clear()
|
|
3993
|
+
} : {}
|
|
3994
|
+
});
|
|
3995
|
+
return {
|
|
3996
|
+
server,
|
|
3997
|
+
client,
|
|
3998
|
+
tokenProvider,
|
|
3999
|
+
cache,
|
|
4000
|
+
ledger,
|
|
4001
|
+
store,
|
|
4002
|
+
...ads ? { ads } : {}
|
|
4003
|
+
};
|
|
4004
|
+
};
|
|
4005
|
+
//#endregion
|
|
4006
|
+
export { AdsApiClient as A, setupInstructions as B, compositeTokenProvider as C, fromMicro as D, MICRO as E, effectiveScopes as F, WritesDisabledError as G, BudgetExceededError as H, hasAdsAccess as I, XApiRequestError as K, hasApiCredentials as L, DEFAULT_PRICING as M, SANDBOX_ADS_BASE_URL as N, shapeMoney as O, adsSetupInstructions as P, loadConfig as R, bearerTokenProvider as S, startLoginFlow as T, PreconditionError as U, AdsAccessError as V, UserContextRequiredError as W, shapeUser as _, openInBrowser as a, createDayCache as b, buildIntentUrl as c, TCO_URL_LENGTH as d, weightedLength as f, shapePostsResponse as g, shapePostResponse as h, registerTools as i, DEFAULT_ADS_BASE_URL as j, toMicro as k, validateIntent as l, buildIncludesIndex as m, SERVER_VERSION as n, INTENT_BASE_URL as o, XApiClient as p, BUILD_INFO as q, createServer$1 as r, assembleComposerText as s, SERVER_NAME as t, MAX_WEIGHTED_LENGTH as u, shapeUsersResponse as v, staticTokenProvider as w, utcDay as x, createLedger as y, resolveConfigPath as z };
|
|
4007
|
+
|
|
4008
|
+
//# sourceMappingURL=server-BeRiqZxj.js.map
|