@alfe.ai/social-mcp 0.2.3 → 0.2.5

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.
Files changed (3) hide show
  1. package/README.md +19 -0
  2. package/dist/server.js +517 -159
  3. package/package.json +6 -5
package/README.md CHANGED
@@ -16,6 +16,25 @@ npm install @alfe.ai/social-mcp
16
16
  npx @alfe.ai/social-mcp --provider bluesky
17
17
  ```
18
18
 
19
+ The server starts even when the agent has no connected Bluesky account, so
20
+ `social_list_accounts` can explain the empty state. Every other tool requires
21
+ an explicit `account` selector returned by that discovery call.
22
+
23
+ ## Runtime boundaries
24
+
25
+ - Provider selection accepts exactly `--provider bluesky`,
26
+ `--provider=bluesky`, or the positional `bluesky` form.
27
+ - Bluesky PDS endpoints must be clean HTTPS origins. The driver rejects IP
28
+ literals and reserved hostnames, then resolves and rejects private or
29
+ reserved addresses immediately before each request. XRPC redirects are
30
+ rejected so bearer requests cannot escape the validated origin.
31
+ - Provider bodies and model-facing tool results have byte ceilings. Provider
32
+ exception bodies are never returned to the model.
33
+ - Delete accepts only an exact `app.bsky.feed.post` AT URI owned by the
34
+ selected account; a foreign-author URI is rejected before the PDS call.
35
+ - Importing the package is inert. Signal handlers are installed only while the
36
+ executable is running and close the MCP server before the process exits.
37
+
19
38
  ## Links
20
39
 
21
40
  - 🌐 Website: <https://alfe.ai>
package/dist/server.js CHANGED
@@ -1,9 +1,14 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { resolveConfig } from "@alfe.ai/config";
5
6
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
6
7
  import { assertPatternA } from "@alfe.ai/mcp-bundler";
8
+ import dns from "node:dns/promises";
9
+ import { BlockList, isIP } from "node:net";
10
+ import { resolve } from "node:path";
11
+ import { pathToFileURL } from "node:url";
7
12
  import { z } from "zod";
8
13
  //#region src/args.ts
9
14
  /**
@@ -39,33 +44,37 @@ function isSupportedProvider(value) {
39
44
  * misconfiguration, distinct from the zero-account (graceful) path.
40
45
  */
41
46
  function parseProviderArg(argv) {
47
+ if (argv.length === 0) return missingProvider();
48
+ if (argv.length > 2) return {
49
+ ok: false,
50
+ error: "Invalid social provider arguments. Pass exactly --provider <id>, --provider=<id>, or <id>."
51
+ };
42
52
  let raw;
43
- for (let i = 0; i < argv.length; i++) {
44
- const arg = argv[i];
45
- if (arg === "--provider") {
46
- raw = argv[i + 1];
47
- break;
48
- }
49
- if (arg.startsWith("--provider=")) {
50
- raw = arg.slice(11);
51
- break;
52
- }
53
- if (raw === void 0 && !arg.startsWith("-")) raw = arg;
54
- }
55
- if (!raw) return {
53
+ const first = argv[0];
54
+ if (first === "--provider" && argv.length === 2) raw = argv[1];
55
+ else if (first.startsWith("--provider=") && argv.length === 1) raw = first.slice(11);
56
+ else if (!first.startsWith("-") && argv.length === 1) raw = first;
57
+ else return {
56
58
  ok: false,
57
- error: `No social provider selected. Pass one via --provider (e.g. "--provider bluesky"). Supported: ${SUPPORTED_PROVIDERS.join(", ")}.`
59
+ error: "Invalid social provider arguments. Pass exactly --provider <id>, --provider=<id>, or <id>."
58
60
  };
61
+ if (!raw || raw.length > 64) return missingProvider();
59
62
  const provider = raw.trim().toLowerCase();
60
63
  if (!isSupportedProvider(provider)) return {
61
64
  ok: false,
62
- error: `Unsupported social provider "${raw}". Supported: ${SUPPORTED_PROVIDERS.join(", ")}.`
65
+ error: `Unsupported social provider. Supported: ${SUPPORTED_PROVIDERS.join(", ")}.`
63
66
  };
64
67
  return {
65
68
  ok: true,
66
69
  provider
67
70
  };
68
71
  }
72
+ function missingProvider() {
73
+ return {
74
+ ok: false,
75
+ error: `No social provider selected. Pass one via --provider (e.g. "--provider bluesky"). Supported: ${SUPPORTED_PROVIDERS.join(", ")}.`
76
+ };
77
+ }
69
78
  //#endregion
70
79
  //#region src/drivers/facets.ts
71
80
  /**
@@ -76,7 +85,8 @@ function parseProviderArg(argv) {
76
85
  */
77
86
  const URL_RE = /https?:\/\/[^\s]+/g;
78
87
  /** Trailing chars to strip from a matched URL (sentence punctuation, closers). */
79
- const TRAILING_TRIM = /[.,;:!?)\]}'"]+$/;
88
+ const TRAILING_PUNCTUATION = /[.,;:!?'"]+$/u;
89
+ const MAX_LINK_LENGTH = 2048;
80
90
  const encoder = new TextEncoder();
81
91
  /** UTF-8 byte length of a string — the unit Bluesky facet offsets are in. */
82
92
  function utf8Len(text) {
@@ -96,9 +106,8 @@ function detectLinkFacets(text) {
96
106
  for (const match of text.matchAll(URL_RE)) {
97
107
  const start = match.index;
98
108
  let raw = match[0];
99
- const trimmed = raw.replace(TRAILING_TRIM, "");
100
- if (!trimmed) continue;
101
- raw = trimmed;
109
+ raw = trimTrailingPunctuation(raw);
110
+ if (!isSafeHttpUrl(raw)) continue;
102
111
  const end = start + raw.length;
103
112
  const byteStart = utf8Len(text.slice(0, start));
104
113
  const byteEnd = byteStart + utf8Len(text.slice(start, end));
@@ -115,13 +124,175 @@ function detectLinkFacets(text) {
115
124
  }
116
125
  return facets;
117
126
  }
127
+ function trimTrailingPunctuation(value) {
128
+ let candidate = value.replace(TRAILING_PUNCTUATION, "");
129
+ for (const [open, close] of [
130
+ ["(", ")"],
131
+ ["[", "]"],
132
+ ["{", "}"]
133
+ ]) while (candidate.endsWith(close) && count(candidate, close) > count(candidate, open)) candidate = candidate.slice(0, -1);
134
+ return candidate;
135
+ }
136
+ function count(value, needle) {
137
+ let total = 0;
138
+ let offset = 0;
139
+ while ((offset = value.indexOf(needle, offset)) !== -1) {
140
+ total += 1;
141
+ offset += needle.length;
142
+ }
143
+ return total;
144
+ }
145
+ function isSafeHttpUrl(value) {
146
+ if (value.length === 0 || value.length > MAX_LINK_LENGTH) return false;
147
+ try {
148
+ const url = new URL(value);
149
+ return (url.protocol === "https:" || url.protocol === "http:") && url.hostname.length > 0 && url.username.length === 0 && url.password.length === 0;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+ //#endregion
155
+ //#region src/boundary.ts
156
+ const MAX_PROVIDER_RESPONSE_BYTES = 2 * 1024 * 1024;
157
+ const MAX_TOOL_RESPONSE_BYTES = 512 * 1024;
158
+ function isRecord(value) {
159
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
160
+ const prototype = Object.getPrototypeOf(value);
161
+ return prototype === Object.prototype || prototype === null;
162
+ }
163
+ /** Read one provider response without allowing an untrusted PDS to exhaust memory. */
164
+ async function readProviderJson(response) {
165
+ const declared = response.headers.get("content-length");
166
+ if (declared !== null && (!/^\d+$/u.test(declared) || Number(declared) > MAX_PROVIDER_RESPONSE_BYTES)) {
167
+ await response.body?.cancel().catch(() => void 0);
168
+ throw new Error("social_provider_response_too_large");
169
+ }
170
+ if (response.body === null) throw new Error("social_provider_response_empty");
171
+ const reader = response.body.getReader();
172
+ const decoder = new TextDecoder();
173
+ let bytes = 0;
174
+ let text = "";
175
+ try {
176
+ for (;;) {
177
+ const chunk = await reader.read();
178
+ if (chunk.done) break;
179
+ if (chunk.value === void 0) throw new Error("social_provider_response_invalid");
180
+ bytes += chunk.value.byteLength;
181
+ if (bytes > MAX_PROVIDER_RESPONSE_BYTES) {
182
+ await reader.cancel("response too large").catch(() => void 0);
183
+ throw new Error("social_provider_response_too_large");
184
+ }
185
+ text += decoder.decode(chunk.value, { stream: true });
186
+ }
187
+ text += decoder.decode();
188
+ } finally {
189
+ reader.releaseLock();
190
+ }
191
+ try {
192
+ return JSON.parse(text);
193
+ } catch {
194
+ throw new Error("social_provider_response_invalid");
195
+ }
196
+ }
197
+ /** Serialize a model-facing result with a hard byte ceiling and no raw exception text. */
198
+ function toolResult(data) {
199
+ try {
200
+ const serialized = JSON.stringify(data, null, 2);
201
+ if (typeof serialized !== "string" || Buffer.byteLength(serialized, "utf8") > MAX_TOOL_RESPONSE_BYTES) throw new Error("result too large");
202
+ return { content: [{
203
+ type: "text",
204
+ text: serialized
205
+ }] };
206
+ } catch {
207
+ return toolError();
208
+ }
209
+ }
210
+ /** Keep provider bodies, selectors, routing hosts, and credential details out of tool errors. */
211
+ function toolError() {
212
+ return {
213
+ content: [{
214
+ type: "text",
215
+ text: JSON.stringify({
216
+ error: "social_request_failed",
217
+ message: "The social request could not be completed. Check the account connection and try again."
218
+ })
219
+ }],
220
+ isError: true
221
+ };
222
+ }
223
+ function boundedString(value, maxLength) {
224
+ return typeof value === "string" && value.length > 0 && value.length <= maxLength ? value : void 0;
225
+ }
226
+ function boundedOptionalString(value, maxLength) {
227
+ return typeof value === "string" && value.length <= maxLength ? value : void 0;
228
+ }
229
+ function nonNegativeCount(value) {
230
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
231
+ }
118
232
  //#endregion
119
233
  //#region src/drivers/bluesky.ts
234
+ /**
235
+ * Bluesky `SocialDriver` — AT-protocol XRPC against the account's own PDS.
236
+ *
237
+ * Credentials come from `AgentApiClient.getSocialAccounts("bluesky")`. Each
238
+ * account entry's `accessToken` is the RAW JSON session bundle string
239
+ * (`{ accessJwt, refreshJwt, did }`) that `services/connect`'s Bluesky
240
+ * provider wrote; `providerMetadata` carries the non-secret routing fields
241
+ * `{ handle, pdsHost, did }`. We parse the `accessJwt` out of the bundle and
242
+ * Bearer it on every XRPC call; `pdsHost` (default `https://bsky.social`)
243
+ * targets the account's PDS.
244
+ *
245
+ * Token refresh is DELEGATED TO CONNECT, never done here: on a 401 /
246
+ * ExpiredToken we call `AgentApiClient.refreshSocialAccount("bluesky", did)`
247
+ * (which drives connect's `refreshSession` + rotation persistence) and then
248
+ * re-fetch accounts to pick up the fresh `accessJwt`. We never call
249
+ * `com.atproto.server.refreshSession` ourselves — connect owns the encrypted
250
+ * refreshJwt and its rotation.
251
+ *
252
+ * SSRF defence-in-depth: connect validates `pdsHost` at store time, and this
253
+ * process re-validates the exact origin plus its current DNS answers before
254
+ * every request. This closes stale store-time resolution; callers must still
255
+ * treat DNS validation + fetch as a best-effort boundary because Node fetch
256
+ * does not expose connection-address pinning here.
257
+ *
258
+ * Bluesky posts against the tenant's OWN PDS with the tenant's OWN token, so
259
+ * there is ZERO Alfe-funded cost — this driver is UNMETERED, matching the
260
+ * `@alfe.ai/github-mcp` precedent. See `drivers/types.ts` → metering seam for
261
+ * how a funded platform (X/Reddit) would wire preflight/meter instead.
262
+ */
120
263
  const DEFAULT_PDS_HOST = "https://bsky.social";
121
- /** Bluesky's hard post-text limit is 300 graphemes; we enforce on JS length as a floor. */
122
- const POST_TEXT_MAX = 300;
264
+ const POST_GRAPHEME_MAX = 300;
265
+ const POST_UTF8_MAX = 3e3;
123
266
  /** Bound every XRPC call so a hung PDS can't stall the MCP server. */
124
267
  const XRPC_TIMEOUT_MS = 1e4;
268
+ const DNS_TIMEOUT_MS = 5e3;
269
+ const MAX_ACCOUNTS = 200;
270
+ const MAX_ACCESS_JWT_LENGTH = 32 * 1024;
271
+ const MAX_SESSION_BUNDLE_LENGTH = 96 * 1024;
272
+ const POST_COLLECTION = "app.bsky.feed.post";
273
+ const PRIVATE_ADDRESS_BLOCKLIST = new BlockList();
274
+ for (const [network, prefix] of [
275
+ ["0.0.0.0", 8],
276
+ ["10.0.0.0", 8],
277
+ ["100.64.0.0", 10],
278
+ ["127.0.0.0", 8],
279
+ ["169.254.0.0", 16],
280
+ ["172.16.0.0", 12],
281
+ ["192.0.0.0", 24],
282
+ ["192.0.2.0", 24],
283
+ ["192.168.0.0", 16],
284
+ ["198.18.0.0", 15],
285
+ ["198.51.100.0", 24],
286
+ ["203.0.113.0", 24],
287
+ ["224.0.0.0", 4],
288
+ ["240.0.0.0", 4]
289
+ ]) PRIVATE_ADDRESS_BLOCKLIST.addSubnet(network, prefix, "ipv4");
290
+ PRIVATE_ADDRESS_BLOCKLIST.addAddress("::", "ipv6");
291
+ PRIVATE_ADDRESS_BLOCKLIST.addAddress("::1", "ipv6");
292
+ PRIVATE_ADDRESS_BLOCKLIST.addSubnet("fc00::", 7, "ipv6");
293
+ PRIVATE_ADDRESS_BLOCKLIST.addSubnet("fe80::", 10, "ipv6");
294
+ PRIVATE_ADDRESS_BLOCKLIST.addSubnet("ff00::", 8, "ipv6");
295
+ PRIVATE_ADDRESS_BLOCKLIST.addSubnet("2001:db8::", 32, "ipv6");
125
296
  const CAPABILITIES = {
126
297
  postText: true,
127
298
  reply: true,
@@ -131,30 +302,64 @@ const CAPABILITIES = {
131
302
  search: true,
132
303
  deletePost: true
133
304
  };
134
- /** Reject any non-https PDS host (defence-in-depth SSRF guard). */
305
+ /** Validate and canonicalise one PDS origin without doing network I/O. */
135
306
  function assertHttpsPdsHost(pdsHost) {
136
307
  let url;
137
308
  try {
138
309
  url = new URL(pdsHost);
139
310
  } catch {
140
- throw new Error(`Invalid pdsHost "${pdsHost}"`);
311
+ throw new Error("Invalid pdsHost");
312
+ }
313
+ if (url.protocol !== "https:") throw new Error("Refusing non-https pdsHost (SSRF guard)");
314
+ if (url.username || url.password || url.pathname !== "/" || url.search || url.hash || url.hostname.length === 0) throw new Error("Invalid pdsHost origin");
315
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/gu, "").replace(/\.$/u, "");
316
+ if (host === "localhost" || host === "metadata" || host.endsWith(".local") || host.endsWith(".internal") || isIP(host) !== 0) throw new Error("Refusing reserved pdsHost (SSRF guard)");
317
+ }
318
+ async function assertPublicPdsHost(pdsHost, resolveHost) {
319
+ assertHttpsPdsHost(pdsHost);
320
+ const host = new URL(pdsHost).hostname.toLowerCase().replace(/\.$/u, "");
321
+ let addresses;
322
+ let timeout;
323
+ try {
324
+ addresses = await Promise.race([resolveHost(host), new Promise((_, reject) => {
325
+ timeout = setTimeout(() => {
326
+ reject(/* @__PURE__ */ new Error("dns_timeout"));
327
+ }, DNS_TIMEOUT_MS);
328
+ })]);
329
+ } catch {
330
+ throw new Error("pds_host_dns_failed");
331
+ } finally {
332
+ if (timeout !== void 0) clearTimeout(timeout);
333
+ }
334
+ if (addresses.length === 0) throw new Error("pds_host_dns_failed");
335
+ for (const { address } of addresses) {
336
+ const family = isIP(address);
337
+ if (family === 0 || PRIVATE_ADDRESS_BLOCKLIST.check(address, family === 4 ? "ipv4" : "ipv6")) throw new Error("pds_host_resolved_to_private_address");
141
338
  }
142
- if (url.protocol !== "https:") throw new Error(`Refusing non-https pdsHost "${pdsHost}" (SSRF guard)`);
143
339
  }
144
340
  /**
145
341
  * Parse a `getSocialAccounts` entry into a usable {@link BlueskyAccount} or a
146
342
  * {@link DegradedAccount}. Exported for unit testing the bundle-parse contract.
147
343
  */
148
344
  function parseAccountEntry(entry) {
149
- const meta = entry.providerMetadata;
150
- const handle = typeof meta.handle === "string" ? meta.handle : "";
151
- const did = entry.accountIdentifier || (typeof meta.did === "string" ? meta.did : "");
152
- const pdsHost = typeof meta.pdsHost === "string" && meta.pdsHost ? meta.pdsHost : DEFAULT_PDS_HOST;
345
+ const meta = isRecord(entry.providerMetadata) ? entry.providerMetadata : {};
346
+ const handle = boundedOptionalString(meta.handle, 256) ?? "";
347
+ const metadataDid = boundedOptionalString(meta.did, 256) ?? "";
348
+ const did = boundedString(entry.accountIdentifier, 256) ?? metadataDid;
349
+ const pdsHost = boundedString(meta.pdsHost, 2048) ?? DEFAULT_PDS_HOST;
153
350
  const base = {
154
351
  did,
155
352
  handle,
156
- displayName: entry.displayName,
157
- connectedAt: entry.connectedAt
353
+ displayName: entry.displayName === null ? null : boundedOptionalString(entry.displayName, 512) ?? null,
354
+ connectedAt: boundedOptionalString(entry.connectedAt, 64) ?? ""
355
+ };
356
+ if (!isDid(did) || metadataDid && metadataDid !== did) return {
357
+ ...base,
358
+ reason: "invalid_account_identity"
359
+ };
360
+ if (typeof entry.accessToken !== "string" || entry.accessToken.length === 0 || entry.accessToken.length > MAX_SESSION_BUNDLE_LENGTH) return {
361
+ ...base,
362
+ reason: "invalid_session_bundle"
158
363
  };
159
364
  let bundle;
160
365
  try {
@@ -165,22 +370,32 @@ function parseAccountEntry(entry) {
165
370
  reason: "unparseable_session_bundle"
166
371
  };
167
372
  }
168
- if (!bundle.accessJwt) return {
373
+ if (!isRecord(bundle)) return {
374
+ ...base,
375
+ reason: "invalid_session_bundle"
376
+ };
377
+ const accessJwt = boundedString(bundle.accessJwt, MAX_ACCESS_JWT_LENGTH);
378
+ if (!accessJwt) return {
169
379
  ...base,
170
380
  reason: "missing_access_jwt"
171
381
  };
382
+ const bundleDid = boundedOptionalString(bundle.did, 256);
383
+ if (bundleDid !== void 0 && bundleDid !== did) return {
384
+ ...base,
385
+ reason: "session_identity_mismatch"
386
+ };
172
387
  try {
173
388
  assertHttpsPdsHost(pdsHost);
174
- } catch (err) {
389
+ } catch {
175
390
  return {
176
391
  ...base,
177
- reason: err instanceof Error ? err.message : "bad_pds_host"
392
+ reason: "invalid_pds_host"
178
393
  };
179
394
  }
180
395
  return {
181
396
  ...base,
182
- pdsHost,
183
- accessJwt: bundle.accessJwt
397
+ pdsHost: new URL(pdsHost).origin,
398
+ accessJwt
184
399
  };
185
400
  }
186
401
  /** Thrown when the PDS says the session is expired/invalid (drives one refresh). */
@@ -190,11 +405,13 @@ var BlueskyDriver = class BlueskyDriver {
190
405
  toolPrefix = "bluesky";
191
406
  capabilities = CAPABILITIES;
192
407
  api;
193
- accounts = /* @__PURE__ */ new Map();
194
- byHandle = /* @__PURE__ */ new Map();
408
+ resolveHost;
409
+ accounts = [];
195
410
  degraded = [];
196
- constructor(api) {
411
+ selectors = /* @__PURE__ */ new Map();
412
+ constructor(api, resolveHost) {
197
413
  this.api = api;
414
+ this.resolveHost = resolveHost;
198
415
  }
199
416
  /**
200
417
  * Build a driver from the agent's connected Bluesky accounts. Zero accounts
@@ -202,27 +419,32 @@ var BlueskyDriver = class BlueskyDriver {
202
419
  * accounts` / `bluesky_check_connection` register). Per-account parse
203
420
  * failures are recorded as degraded rather than failing the whole driver.
204
421
  */
205
- static async create(api) {
206
- const driver = new BlueskyDriver(api);
422
+ static async create(api, options = {}) {
423
+ const driver = new BlueskyDriver(api, options.resolveHost ?? ((host) => dns.lookup(host, { all: true })));
207
424
  const { accounts } = await api.getSocialAccounts("bluesky");
208
- for (const entry of accounts) driver.ingest(entry);
425
+ driver.replaceAccounts(accounts);
209
426
  return driver;
210
427
  }
211
- ingest(entry) {
212
- const parsed = parseAccountEntry(entry);
213
- if ("reason" in parsed) {
214
- this.degraded.push(parsed);
215
- return;
428
+ replaceAccounts(entries) {
429
+ this.accounts = [];
430
+ this.degraded = [];
431
+ this.selectors = /* @__PURE__ */ new Map();
432
+ for (const entry of entries.slice(0, MAX_ACCOUNTS)) {
433
+ const parsed = parseAccountEntry(entry);
434
+ if ("reason" in parsed) {
435
+ this.degraded.push(parsed);
436
+ continue;
437
+ }
438
+ this.accounts.push(parsed);
439
+ for (const selector of new Set([parsed.did, parsed.handle].filter(Boolean))) this.selectors.set(selector, this.selectors.has(selector) ? null : parsed);
216
440
  }
217
- this.accounts.set(parsed.did, parsed);
218
- if (parsed.handle) this.byHandle.set(parsed.handle, parsed.did);
219
441
  }
220
442
  /** Resolve the `account` selector (DID or handle) to a usable account. */
221
443
  resolve(account) {
222
444
  if (!account) throw new Error("Missing required `account` argument. Call social_list_accounts to see connected Bluesky accounts and pass the handle or DID you want to target.");
223
- const did = this.accounts.has(account) ? account : this.byHandle.get(account);
224
- const acct = did ? this.accounts.get(did) : void 0;
225
- if (!acct) {
445
+ const acct = this.selectors.get(account);
446
+ if (acct === null) throw new Error("Ambiguous account selector. Use a unique DID from social_list_accounts.");
447
+ if (acct === void 0) {
226
448
  const degraded = this.degraded.find((d) => d.did === account || d.handle === account);
227
449
  if (degraded) throw new Error(`Account "${account}" is connected but not usable (reason: ${degraded.reason}). Ask the user to reconnect this Bluesky account from the dashboard.`);
228
450
  throw new Error(`Unknown account "${account}". Call social_list_accounts to see connected Bluesky accounts on this agent.`);
@@ -230,7 +452,7 @@ var BlueskyDriver = class BlueskyDriver {
230
452
  return acct;
231
453
  }
232
454
  listAccounts() {
233
- const usable = [...this.accounts.values()].map((a) => ({
455
+ const usable = this.accounts.map((a) => ({
234
456
  accountIdentifier: a.did,
235
457
  handle: a.handle,
236
458
  displayName: a.displayName,
@@ -253,7 +475,7 @@ var BlueskyDriver = class BlueskyDriver {
253
475
  * Honors a single `Retry-After` on 429. Never logs tokens.
254
476
  */
255
477
  async xrpc(acct, method, nsid, opts = {}) {
256
- assertHttpsPdsHost(acct.pdsHost);
478
+ await assertPublicPdsHost(acct.pdsHost, this.resolveHost);
257
479
  const url = new URL(`${acct.pdsHost}/xrpc/${nsid}`);
258
480
  for (const [k, v] of Object.entries(opts.query ?? {})) if (Array.isArray(v)) for (const item of v) url.searchParams.append(k, item);
259
481
  else url.searchParams.set(k, v);
@@ -265,12 +487,14 @@ var BlueskyDriver = class BlueskyDriver {
265
487
  const res = await fetch(url, {
266
488
  method,
267
489
  headers,
490
+ redirect: "error",
268
491
  body: method === "POST" ? JSON.stringify(opts.body ?? {}) : void 0,
269
492
  signal: AbortSignal.timeout(XRPC_TIMEOUT_MS)
270
493
  });
271
494
  if (res.status === 429 && !opts.retried429) {
272
495
  const retryAfter = Number(res.headers.get("retry-after"));
273
496
  const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : 1e3;
497
+ await res.body?.cancel().catch(() => void 0);
274
498
  await new Promise((r) => setTimeout(r, Math.min(delayMs, 3e4)));
275
499
  return this.xrpc(acct, method, nsid, {
276
500
  ...opts,
@@ -278,7 +502,8 @@ var BlueskyDriver = class BlueskyDriver {
278
502
  });
279
503
  }
280
504
  if (res.status === 401) {
281
- if (opts.retriedAuth) throw new ExpiredTokenError(`Bluesky session invalid for ${acct.handle || acct.did} after refresh. Ask the user to reconnect.`);
505
+ await res.body?.cancel().catch(() => void 0);
506
+ if (opts.retriedAuth) throw new ExpiredTokenError("Bluesky session invalid after refresh. Ask the user to reconnect.");
282
507
  const refreshed = await this.refreshAccount(acct.did);
283
508
  return this.xrpc(refreshed, method, nsid, {
284
509
  ...opts,
@@ -287,10 +512,10 @@ var BlueskyDriver = class BlueskyDriver {
287
512
  });
288
513
  }
289
514
  if (!res.ok) {
290
- let detail = "";
515
+ let detail;
291
516
  try {
292
- const b = await res.json();
293
- detail = b.error ?? b.message ?? "";
517
+ const body = await readProviderJson(res);
518
+ if (isRecord(body)) detail = boundedOptionalString(body.error, 128);
294
519
  } catch {}
295
520
  if (detail === "ExpiredToken" && !opts.retriedAuth) {
296
521
  const refreshed = await this.refreshAccount(acct.did);
@@ -300,68 +525,67 @@ var BlueskyDriver = class BlueskyDriver {
300
525
  retried429: false
301
526
  });
302
527
  }
303
- throw new Error(`Bluesky ${nsid} failed: HTTP ${String(res.status)}${detail ? ` (${detail})` : ""}`);
528
+ throw new Error(`Bluesky request failed: HTTP ${String(res.status)}`);
304
529
  }
305
- return res.json();
530
+ return readProviderJson(res);
306
531
  }
307
532
  /** Delegate refresh to connect, then re-fetch accounts for the fresh jwt. */
308
533
  async refreshAccount(did) {
309
534
  await this.api.refreshSocialAccount("bluesky", did);
310
535
  const { accounts } = await this.api.getSocialAccounts("bluesky");
311
- const entry = accounts.find((a) => a.accountIdentifier === did);
312
- if (entry) {
313
- const parsed = parseAccountEntry(entry);
314
- if (!("reason" in parsed)) {
315
- this.accounts.set(parsed.did, parsed);
316
- if (parsed.handle) this.byHandle.set(parsed.handle, parsed.did);
317
- return parsed;
318
- }
319
- }
320
- throw new ExpiredTokenError(`Could not resolve a usable Bluesky session for ${did} after refresh. Ask the user to reconnect.`);
536
+ this.replaceAccounts(accounts);
537
+ const refreshed = this.selectors.get(did);
538
+ if (refreshed) return refreshed;
539
+ throw new ExpiredTokenError("Could not resolve a usable Bluesky session after refresh. Ask the user to reconnect.");
321
540
  }
322
541
  async checkConnection(account) {
323
542
  const acct = this.resolve(account);
324
543
  const session = await this.xrpc(acct, "GET", "com.atproto.server.getSession");
544
+ if (!isRecord(session)) throw new Error("Invalid Bluesky session response");
545
+ const did = boundedString(session.did, 256);
546
+ const handle = boundedString(session.handle, 256);
547
+ if (!did || !handle || did !== acct.did) throw new Error("Invalid Bluesky session response");
325
548
  return { data: {
326
549
  account: acct.handle || acct.did,
327
550
  connected: true,
328
- did: session.did,
329
- handle: session.handle
551
+ did,
552
+ handle
330
553
  } };
331
554
  }
332
555
  async postText(args) {
333
556
  const acct = this.resolve(args.account);
334
- const len = graphemeLen(args.text);
335
- if (len > POST_TEXT_MAX) throw new Error(`Post text is ${String(len)} characters; Bluesky's limit is ${String(POST_TEXT_MAX)}. Shorten it.`);
557
+ validatePostText(args.text, "Post");
336
558
  const record = {
337
559
  $type: "app.bsky.feed.post",
338
560
  text: args.text,
339
561
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
340
562
  facets: detectLinkFacets(args.text)
341
563
  };
342
- const out = await this.xrpc(acct, "POST", "com.atproto.repo.createRecord", { body: {
343
- repo: acct.did,
344
- collection: "app.bsky.feed.post",
345
- record
346
- } });
347
564
  return { data: {
348
- uri: out.uri,
349
- cid: out.cid,
565
+ ...parseCreatedPost(await this.xrpc(acct, "POST", "com.atproto.repo.createRecord", { body: {
566
+ repo: acct.did,
567
+ collection: POST_COLLECTION,
568
+ record
569
+ } }), acct.did),
350
570
  account: acct.handle || acct.did
351
571
  } };
352
572
  }
353
573
  async reply(args) {
354
574
  const acct = this.resolve(args.account);
355
- const len = graphemeLen(args.text);
356
- if (len > POST_TEXT_MAX) throw new Error(`Reply text is ${String(len)} characters; Bluesky's limit is ${String(POST_TEXT_MAX)}. Shorten it.`);
575
+ validatePostText(args.text, "Reply");
576
+ parsePostUri(args.parent);
357
577
  const parent = await this.xrpc(acct, "GET", "app.bsky.feed.getPostThread", { query: { uri: args.parent } });
358
- const parentPost = parent.thread?.post;
359
- if (!parentPost?.uri || !parentPost.cid) throw new Error(`Could not resolve parent post "${args.parent}" for reply.`);
578
+ const thread = isRecord(parent) && isRecord(parent.thread) ? parent.thread : void 0;
579
+ const parentPost = thread && isRecord(thread.post) ? thread.post : void 0;
580
+ const parentUri = parentPost && boundedString(parentPost.uri, 2048);
581
+ const parentCid = parentPost && boundedString(parentPost.cid, 256);
582
+ if (!parentUri || !parentCid || parentUri !== args.parent) throw new Error("Could not resolve parent post for reply.");
583
+ parsePostUri(parentUri);
360
584
  const parentRef = {
361
- uri: parentPost.uri,
362
- cid: parentPost.cid
585
+ uri: parentUri,
586
+ cid: parentCid
363
587
  };
364
- const rootRef = deriveRoot(parent.thread) ?? parentRef;
588
+ const rootRef = deriveRoot(thread) ?? parentRef;
365
589
  const record = {
366
590
  $type: "app.bsky.feed.post",
367
591
  text: args.text,
@@ -372,53 +596,55 @@ var BlueskyDriver = class BlueskyDriver {
372
596
  parent: parentRef
373
597
  }
374
598
  };
375
- const out = await this.xrpc(acct, "POST", "com.atproto.repo.createRecord", { body: {
376
- repo: acct.did,
377
- collection: "app.bsky.feed.post",
378
- record
379
- } });
380
599
  return { data: {
381
- uri: out.uri,
382
- cid: out.cid,
600
+ ...parseCreatedPost(await this.xrpc(acct, "POST", "com.atproto.repo.createRecord", { body: {
601
+ repo: acct.did,
602
+ collection: POST_COLLECTION,
603
+ record
604
+ } }), acct.did),
383
605
  account: acct.handle || acct.did
384
606
  } };
385
607
  }
386
608
  async getPost(args) {
387
609
  const acct = this.resolve(args.account);
388
- return { data: { posts: (await this.xrpc(acct, "GET", "app.bsky.feed.getPosts", { query: { uris: [args.post] } })).posts ?? [] } };
610
+ parsePostUri(args.post);
611
+ return { data: { posts: projectPosts(await this.xrpc(acct, "GET", "app.bsky.feed.getPosts", { query: { uris: [args.post] } }), 1) } };
389
612
  }
390
613
  async getMetrics(args) {
391
614
  const acct = this.resolve(args.account);
392
- const post = (await this.xrpc(acct, "GET", "app.bsky.feed.getPosts", { query: { uris: [args.post] } })).posts?.[0];
393
- if (!post) throw new Error(`No post found for "${args.post}".`);
615
+ parsePostUri(args.post);
616
+ const post = projectPosts(await this.xrpc(acct, "GET", "app.bsky.feed.getPosts", { query: { uris: [args.post] } }), 1).at(0);
617
+ if (post?.uri !== args.post) throw new Error("No post found.");
394
618
  return { data: {
395
619
  uri: post.uri,
396
- likes: post.likeCount ?? 0,
397
- reposts: post.repostCount ?? 0,
398
- replies: post.replyCount ?? 0,
399
- quotes: post.quoteCount ?? 0
620
+ likes: post.likeCount,
621
+ reposts: post.repostCount,
622
+ replies: post.replyCount,
623
+ quotes: post.quoteCount
400
624
  } };
401
625
  }
402
626
  async listNotifications(args) {
403
627
  const acct = this.resolve(args.account);
404
628
  const query = {};
405
629
  if (typeof args.limit === "number") query.limit = String(Math.min(Math.max(args.limit, 1), 100));
406
- return { data: { notifications: (await this.xrpc(acct, "GET", "app.bsky.notification.listNotifications", { query })).notifications ?? [] } };
630
+ return { data: { notifications: projectNotifications(await this.xrpc(acct, "GET", "app.bsky.notification.listNotifications", { query }), args.limit ?? 50) } };
407
631
  }
408
632
  async search(args) {
409
633
  const acct = this.resolve(args.account);
410
- const query = { q: args.query };
634
+ const searchQuery = args.query.trim();
635
+ if (searchQuery.length === 0 || searchQuery.length > 512) throw new Error("Search query must contain 1-512 characters.");
636
+ const query = { q: searchQuery };
411
637
  if (typeof args.limit === "number") query.limit = String(Math.min(Math.max(args.limit, 1), 100));
412
- return { data: { posts: (await this.xrpc(acct, "GET", "app.bsky.feed.searchPosts", { query })).posts ?? [] } };
638
+ return { data: { posts: projectPosts(await this.xrpc(acct, "GET", "app.bsky.feed.searchPosts", { query }), args.limit ?? 25) } };
413
639
  }
414
640
  async deletePost(args) {
415
641
  const acct = this.resolve(args.account);
416
- const rkey = rkeyFromUri(args.post);
417
- if (!rkey) throw new Error(`Could not parse a record key from "${args.post}".`);
642
+ const parsed = parsePostUri(args.post);
643
+ if (parsed.repo !== acct.did) throw new Error("The post URI does not belong to the selected account.");
418
644
  await this.xrpc(acct, "POST", "com.atproto.repo.deleteRecord", { body: {
419
645
  repo: acct.did,
420
- collection: "app.bsky.feed.post",
421
- rkey
646
+ collection: POST_COLLECTION,
647
+ rkey: parsed.rkey
422
648
  } });
423
649
  return { data: {
424
650
  deleted: true,
@@ -436,11 +662,112 @@ const graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" }
436
662
  function graphemeLen(text) {
437
663
  return [...graphemeSegmenter.segment(text)].length;
438
664
  }
439
- /** Extract the record key (last path segment) from an at:// URI. */
440
- function rkeyFromUri(uri) {
441
- const parts = uri.split("/");
442
- const last = parts[parts.length - 1];
443
- return last && parts.length >= 3 ? last : null;
665
+ function validatePostText(text, label) {
666
+ const graphemes = graphemeLen(text);
667
+ if (graphemes === 0) throw new Error(`${label} text must not be empty.`);
668
+ if (graphemes > POST_GRAPHEME_MAX) throw new Error(`${label} text is ${String(graphemes)} characters; Bluesky's limit is ${String(POST_GRAPHEME_MAX)}. Shorten it.`);
669
+ if (new TextEncoder().encode(text).byteLength > POST_UTF8_MAX) throw new Error(`${label} text exceeds Bluesky's UTF-8 byte limit. Shorten it.`);
670
+ }
671
+ function isDid(value) {
672
+ return /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/u.test(value);
673
+ }
674
+ /** Parse an exact AT post URI, rejecting a foreign collection or extra URL parts. */
675
+ function parsePostUri(uri) {
676
+ if (uri.length === 0 || uri.length > 2048 || uri.includes("?") || uri.includes("#")) throw new Error("Invalid Bluesky post URI.");
677
+ const match = /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/u.exec(uri);
678
+ if (!match) throw new Error("Invalid Bluesky post URI.");
679
+ const [, repo, rkey] = match;
680
+ if (!isDid(repo) && !/^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/u.test(repo) || !/^[A-Za-z0-9._~:@!$&'()*+,;=-]{1,512}$/u.test(rkey)) throw new Error("Invalid Bluesky post URI.");
681
+ return {
682
+ repo,
683
+ rkey
684
+ };
685
+ }
686
+ function projectPosts(value, maxItems) {
687
+ if (!isRecord(value) || !Array.isArray(value.posts)) return [];
688
+ const posts = [];
689
+ for (const raw of value.posts.slice(0, Math.min(Math.max(maxItems, 0), 100))) {
690
+ const projected = projectPost(raw);
691
+ if (projected) posts.push(projected);
692
+ }
693
+ return posts;
694
+ }
695
+ function projectPost(value) {
696
+ if (!isRecord(value)) return void 0;
697
+ const uri = boundedString(value.uri, 2048);
698
+ if (!uri) return void 0;
699
+ try {
700
+ parsePostUri(uri);
701
+ } catch {
702
+ return;
703
+ }
704
+ const record = isRecord(value.record) ? value.record : void 0;
705
+ const author = projectAuthor(value.author);
706
+ return {
707
+ uri,
708
+ ...boundedOptionalString(value.cid, 256) ? { cid: String(value.cid) } : {},
709
+ ...author ? { author } : {},
710
+ ...record && boundedOptionalString(record.text, POST_UTF8_MAX) ? { text: String(record.text) } : {},
711
+ ...record && boundedOptionalString(record.createdAt, 64) ? { createdAt: String(record.createdAt) } : {},
712
+ ...boundedOptionalString(value.indexedAt, 64) ? { indexedAt: String(value.indexedAt) } : {},
713
+ likeCount: nonNegativeCount(value.likeCount),
714
+ repostCount: nonNegativeCount(value.repostCount),
715
+ replyCount: nonNegativeCount(value.replyCount),
716
+ quoteCount: nonNegativeCount(value.quoteCount)
717
+ };
718
+ }
719
+ function projectAuthor(value) {
720
+ if (!isRecord(value)) return void 0;
721
+ const did = boundedString(value.did, 256);
722
+ if (!did || !isDid(did)) return void 0;
723
+ const handle = boundedOptionalString(value.handle, 256);
724
+ const displayName = boundedOptionalString(value.displayName, 512);
725
+ return {
726
+ did,
727
+ ...handle ? { handle } : {},
728
+ ...displayName ? { displayName } : {}
729
+ };
730
+ }
731
+ function projectNotifications(value, maxItems) {
732
+ if (!isRecord(value) || !Array.isArray(value.notifications)) return [];
733
+ const notifications = [];
734
+ for (const raw of value.notifications.slice(0, Math.min(Math.max(maxItems, 0), 100))) {
735
+ if (!isRecord(raw)) continue;
736
+ const reason = boundedString(raw.reason, 64);
737
+ const indexedAt = boundedString(raw.indexedAt, 64);
738
+ const author = projectAuthor(raw.author);
739
+ if (!reason || !indexedAt || !author) continue;
740
+ const uri = boundedOptionalString(raw.uri, 2048);
741
+ if (uri && !isAtRecordUri(uri)) continue;
742
+ const reasonSubject = boundedOptionalString(raw.reasonSubject, 2048);
743
+ if (reasonSubject && !isAtRecordUri(reasonSubject)) continue;
744
+ notifications.push({
745
+ reason,
746
+ indexedAt,
747
+ author,
748
+ isRead: raw.isRead === true,
749
+ ...uri ? { uri } : {},
750
+ ...reasonSubject ? { reasonSubject } : {}
751
+ });
752
+ }
753
+ return notifications;
754
+ }
755
+ function isAtRecordUri(value) {
756
+ if (value.includes("?") || value.includes("#")) return false;
757
+ const match = /^at:\/\/([^/]+)\/([a-z][a-z0-9.-]{0,253})\/([^/]+)$/u.exec(value);
758
+ if (!match) return false;
759
+ const [, repo, , rkey] = match;
760
+ return (isDid(repo) || /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/u.test(repo)) && /^[A-Za-z0-9._~:@!$&'()*+,;=-]{1,512}$/u.test(rkey);
761
+ }
762
+ function parseCreatedPost(value, expectedDid) {
763
+ if (!isRecord(value)) throw new Error("Invalid Bluesky create-record response");
764
+ const uri = boundedString(value.uri, 2048);
765
+ const cid = boundedString(value.cid, 256);
766
+ if (!uri || !cid || parsePostUri(uri).repo !== expectedDid) throw new Error("Invalid Bluesky create-record response");
767
+ return {
768
+ uri,
769
+ cid
770
+ };
444
771
  }
445
772
  /**
446
773
  * Best-effort thread-root ref extraction from a getPostThread response.
@@ -454,11 +781,25 @@ function rkeyFromUri(uri) {
454
781
  */
455
782
  function deriveRoot(thread) {
456
783
  if (!thread) return void 0;
457
- const root = thread.post?.record?.reply?.root;
458
- if (root?.uri && root.cid) return {
459
- uri: root.uri,
460
- cid: root.cid
461
- };
784
+ const post = isRecord(thread.post) ? thread.post : void 0;
785
+ const record = post && isRecord(post.record) ? post.record : void 0;
786
+ const reply = record && isRecord(record.reply) ? record.reply : void 0;
787
+ const root = reply && isRecord(reply.root) ? reply.root : void 0;
788
+ const uri = root && boundedString(root.uri, 2048);
789
+ const cid = root && boundedString(root.cid, 256);
790
+ if (uri && cid) {
791
+ parsePostUri(uri);
792
+ return {
793
+ uri,
794
+ cid
795
+ };
796
+ }
797
+ }
798
+ //#endregion
799
+ //#region src/execution.ts
800
+ /** Pure direct-execution check kept separate so importing the CLI stays inert. */
801
+ function isDirectExecution(moduleUrl, argvEntry = process.argv.at(1)) {
802
+ return typeof argvEntry === "string" && pathToFileURL(resolve(argvEntry)).href === moduleUrl;
462
803
  }
463
804
  //#endregion
464
805
  //#region src/tools.ts
@@ -492,21 +833,12 @@ function deriveRoot(thread) {
492
833
  * names `github_list_accounts` alongside its GitHub-specific tools.
493
834
  */
494
835
  function ok(data) {
495
- return { content: [{
496
- type: "text",
497
- text: JSON.stringify(data, null, 2)
498
- }] };
836
+ return toolResult(data);
499
837
  }
500
- function fail(err) {
501
- return {
502
- content: [{
503
- type: "text",
504
- text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2)
505
- }],
506
- isError: true
507
- };
838
+ function fail() {
839
+ return toolError();
508
840
  }
509
- const accountField = z.string().describe("The connected account to act as — the handle or DID from social_list_accounts. Selects which OAuth identity makes the call (Pattern A).");
841
+ const accountField = z.string().trim().min(1).max(256).describe("The connected account to act as — the handle or DID from social_list_accounts. Selects which OAuth identity makes the call (Pattern A).");
510
842
  /**
511
843
  * The one table both registration and descriptor-building derive from. The
512
844
  * `capability` field is BOTH the `SocialCapabilities` flag and the same-named
@@ -520,7 +852,7 @@ const ACTION_TOOL_SPECS = [
520
852
  description: "Publish a text post as the selected account. Links in the text are auto-detected and made clickable. Enforces the platform's character limit.",
521
853
  inputSchema: {
522
854
  account: accountField,
523
- text: z.string().describe("The post body.")
855
+ text: z.string().min(1).max(3e3).describe("The non-empty post body.")
524
856
  }
525
857
  },
526
858
  {
@@ -529,8 +861,8 @@ const ACTION_TOOL_SPECS = [
529
861
  description: "Reply to a post as the selected account. Pass the parent post's URI/id; the thread root is resolved automatically.",
530
862
  inputSchema: {
531
863
  account: accountField,
532
- parent: z.string().describe("URI/id of the post being replied to."),
533
- text: z.string().describe("The reply body.")
864
+ parent: z.string().min(1).max(2048).describe("AT URI of the post being replied to."),
865
+ text: z.string().min(1).max(3e3).describe("The non-empty reply body.")
534
866
  }
535
867
  },
536
868
  {
@@ -539,7 +871,7 @@ const ACTION_TOOL_SPECS = [
539
871
  description: "Fetch a post by its URI/id as the selected account.",
540
872
  inputSchema: {
541
873
  account: accountField,
542
- post: z.string().describe("URI/id of the post to fetch.")
874
+ post: z.string().min(1).max(2048).describe("AT URI of the post to fetch.")
543
875
  }
544
876
  },
545
877
  {
@@ -548,7 +880,7 @@ const ACTION_TOOL_SPECS = [
548
880
  description: "Get like / repost / reply / quote counts for a post.",
549
881
  inputSchema: {
550
882
  account: accountField,
551
- post: z.string().describe("URI/id of the post to measure.")
883
+ post: z.string().min(1).max(2048).describe("AT URI of the post to measure.")
552
884
  }
553
885
  },
554
886
  {
@@ -566,7 +898,7 @@ const ACTION_TOOL_SPECS = [
566
898
  description: "Search posts as the selected account.",
567
899
  inputSchema: {
568
900
  account: accountField,
569
- query: z.string().describe("Search query."),
901
+ query: z.string().trim().min(1).max(512).describe("Search query."),
570
902
  limit: z.coerce.number().int().min(1).max(100).optional().describe("Max results (1-100).")
571
903
  }
572
904
  },
@@ -576,7 +908,7 @@ const ACTION_TOOL_SPECS = [
576
908
  description: "Delete a post the selected account authored, by its URI/id.",
577
909
  inputSchema: {
578
910
  account: accountField,
579
- post: z.string().describe("URI/id of the post to delete.")
911
+ post: z.string().min(1).max(2048).describe("AT URI of the selected account's post to delete.")
580
912
  }
581
913
  }
582
914
  ];
@@ -611,8 +943,8 @@ function registerTools(server, driver) {
611
943
  }, async (args) => {
612
944
  try {
613
945
  return ok((await driver.checkConnection(args.account)).data);
614
- } catch (err) {
615
- return fail(err);
946
+ } catch {
947
+ return fail();
616
948
  }
617
949
  });
618
950
  for (const spec of ACTION_TOOL_SPECS) {
@@ -621,7 +953,7 @@ function registerTools(server, driver) {
621
953
  register(`${p}_${spec.suffix}`, {
622
954
  description: spec.description,
623
955
  inputSchema: spec.inputSchema
624
- }, (args) => method(args).then((r) => ok(r.data), (err) => fail(err)));
956
+ }, (args) => method(args).then((r) => ok(r.data), () => fail()));
625
957
  }
626
958
  }
627
959
  /**
@@ -686,9 +1018,12 @@ const DISCOVERY_TOOL_NAME = "social_list_accounts";
686
1018
  * Pattern A: every credential-touching tool requires an `account` selector;
687
1019
  * `assertPatternA()` runs at startup so a forgotten selector fails closed.
688
1020
  */
1021
+ const packageJson = createRequire(import.meta.url)("../package.json");
1022
+ const SERVER_VERSION = typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
689
1023
  function log(msg) {
690
1024
  process.stderr.write(`[social-mcp] ${msg}\n`);
691
1025
  }
1026
+ var SafeStartupError = class extends Error {};
692
1027
  /**
693
1028
  * Per-provider driver factories. Adding a platform = add a `SocialDriver`
694
1029
  * implementation and register its factory here (plus `SUPPORTED_PROVIDERS` in
@@ -700,39 +1035,62 @@ const DRIVER_FACTORIES = { bluesky: (api) => BlueskyDriver.create(api) };
700
1035
  function createDriver(provider, api) {
701
1036
  return DRIVER_FACTORIES[provider](api);
702
1037
  }
703
- async function main() {
704
- const parsed = parseProviderArg(process.argv.slice(2));
705
- if (!parsed.ok) {
706
- log(`Fatal: ${parsed.error}`);
707
- process.exit(1);
708
- }
1038
+ async function runSocialServer(argv = process.argv.slice(2), dependencies = {}) {
1039
+ const parsed = parseProviderArg(argv);
1040
+ if (!parsed.ok) throw new SafeStartupError(parsed.error);
709
1041
  const provider = parsed.provider;
710
- const { apiKey, apiUrl } = resolveConfig();
711
- const driver = await createDriver(provider, new AgentApiClient({
1042
+ const { apiKey, apiUrl } = (dependencies.resolveRuntimeConfig ?? resolveConfig)();
1043
+ const api = dependencies.createApiClient ? dependencies.createApiClient({
1044
+ apiKey,
1045
+ apiUrl
1046
+ }) : new AgentApiClient({
712
1047
  apiKey,
713
1048
  apiUrl
714
- }));
1049
+ });
1050
+ const driver = await (dependencies.createProviderDriver ?? createDriver)(provider, api);
715
1051
  const usable = driver.listAccounts().filter((a) => a.connected).length;
716
1052
  if (usable === 0) log(`No ${provider} accounts connected — server will start with ${DISCOVERY_TOOL_NAME} and ${driver.toolPrefix}_check_connection only`);
717
1053
  const server = new McpServer({
718
1054
  name: "social-mcp-server",
719
- version: "0.1.0"
1055
+ version: SERVER_VERSION
720
1056
  });
721
1057
  registerTools(server, driver);
722
1058
  assertPatternA(buildPatternADescriptors(driver), {
723
1059
  selector: "account",
724
1060
  exempt: [DISCOVERY_TOOL_NAME]
725
1061
  });
726
- for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {
727
- process.exit(0);
728
- });
729
- const transport = new StdioServerTransport();
1062
+ const transport = dependencies.createTransport?.() ?? new StdioServerTransport();
730
1063
  await server.connect(transport);
1064
+ let stopping = false;
1065
+ const shutdown = async () => {
1066
+ if (stopping) return;
1067
+ stopping = true;
1068
+ for (const signal of ["SIGTERM", "SIGINT"]) process.off(signal, signalHandlers[signal]);
1069
+ await server.close();
1070
+ };
1071
+ const signalHandlers = {
1072
+ SIGTERM: () => void shutdown().catch(() => {
1073
+ process.exitCode = 1;
1074
+ }),
1075
+ SIGINT: () => void shutdown().catch(() => {
1076
+ process.exitCode = 1;
1077
+ })
1078
+ };
1079
+ for (const signal of ["SIGTERM", "SIGINT"]) process.once(signal, signalHandlers[signal]);
731
1080
  log(`${provider} MCP server running with ${String(usable)} connected account(s) and Pattern A selector enforcement`);
1081
+ return {
1082
+ server,
1083
+ shutdown
1084
+ };
1085
+ }
1086
+ async function main() {
1087
+ try {
1088
+ await runSocialServer();
1089
+ } catch (error) {
1090
+ log(error instanceof SafeStartupError ? `Fatal: ${error.message}` : "Fatal: social MCP server failed to start");
1091
+ process.exitCode = 1;
1092
+ }
732
1093
  }
733
- main().catch((err) => {
734
- log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
735
- process.exit(1);
736
- });
1094
+ if (isDirectExecution(import.meta.url)) main();
737
1095
  //#endregion
738
1096
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/social-mcp",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Social MCP server — post, engage, and read on connected social accounts (Bluesky today; X / Meta / Threads / LinkedIn / Pinterest / TikTok / Reddit / YouTube on the approval-gated roster) via Alfe Connect credentials (Pattern A multi-account, provider selected via --provider)",
5
5
  "type": "module",
6
6
  "main": "./dist/server.js",
@@ -14,14 +14,15 @@
14
14
  }
15
15
  },
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "README.md"
18
19
  ],
19
20
  "dependencies": {
20
21
  "@modelcontextprotocol/sdk": "^1.29.0",
21
22
  "zod": "^4.0.5",
22
- "@alfe.ai/agent-api-client": "0.13.0",
23
- "@alfe.ai/config": "0.3.0",
24
- "@alfe.ai/mcp-bundler": "0.4.0"
23
+ "@alfe.ai/agent-api-client": "0.15.0",
24
+ "@alfe.ai/config": "0.4.1",
25
+ "@alfe.ai/mcp-bundler": "0.4.1"
25
26
  },
26
27
  "license": "UNLICENSED",
27
28
  "homepage": "https://alfe.ai",