@alfe.ai/social-mcp 0.2.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/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @alfe.ai/social-mcp
2
+
3
+ Social MCP server — lets agents post, engage, and read on their connected social accounts (Bluesky today; X / Meta / Threads / LinkedIn / Pinterest / TikTok / Reddit / YouTube on the approval-gated roster) using Alfe Connect credentials. Pattern A multi-account; the platform is selected via `--provider`.
4
+
5
+ Part of [**Alfe**](https://alfe.ai) — the operating system for AI agents: build, deploy, and run agents with persistent memory, identity, integrations, and channels. See the [documentation](https://docs.alfe.ai) to get started.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @alfe.ai/social-mcp
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ npx @alfe.ai/social-mcp --provider bluesky
17
+ ```
18
+
19
+ ## Links
20
+
21
+ - 🌐 Website: <https://alfe.ai>
22
+ - 📚 Docs: <https://docs.alfe.ai>
@@ -0,0 +1 @@
1
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,738 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { resolveConfig } from "@alfe.ai/config";
5
+ import { AgentApiClient } from "@alfe.ai/agent-api-client";
6
+ import { assertPatternA } from "@alfe.ai/mcp-bundler";
7
+ import { z } from "zod";
8
+ //#region src/args.ts
9
+ /**
10
+ * Provider selection for the social MCP server.
11
+ *
12
+ * The provider (`bluesky`, and later `x`, `meta`, `threads`, …) is chosen via
13
+ * a CLI **argument**, NOT an environment variable. This is load-bearing:
14
+ *
15
+ * npx @alfe.ai/social-mcp --provider bluesky (flag form)
16
+ * npx @alfe.ai/social-mcp bluesky (positional form)
17
+ *
18
+ * Why args and not env (from the plan + plugin-engineer audit):
19
+ * `mcp-applier.resolveEnv` short-circuits MCP registration when a required env
20
+ * var is unresolved (e.g. a zero-account 404). If the provider were passed via
21
+ * `env`, that short-circuit would key off the provider var and skip registering
22
+ * the server entirely — killing the zero-account graceful-degradation this
23
+ * package relies on (start with `social_list_accounts` only, never crash the
24
+ * daemon). Keeping the provider in `args` means the manifest env stays EMPTY,
25
+ * exactly like `@alfe.ai/github-mcp`, and the server always registers.
26
+ *
27
+ * Pure + no I/O so it's unit-testable.
28
+ */
29
+ /** Providers this shared package can drive. Bluesky is the only shipped one. */
30
+ const SUPPORTED_PROVIDERS = ["bluesky"];
31
+ function isSupportedProvider(value) {
32
+ return SUPPORTED_PROVIDERS.includes(value);
33
+ }
34
+ /**
35
+ * Extract the provider from an argv slice (typically `process.argv.slice(2)`).
36
+ * Accepts `--provider <id>`, `--provider=<id>`, or a bare positional `<id>`.
37
+ * Returns `{ ok: false, error }` for a missing/unknown provider so the caller
38
+ * can log a clear startup error and exit — this is an operator/manifest
39
+ * misconfiguration, distinct from the zero-account (graceful) path.
40
+ */
41
+ function parseProviderArg(argv) {
42
+ 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 {
56
+ ok: false,
57
+ error: `No social provider selected. Pass one via --provider (e.g. "--provider bluesky"). Supported: ${SUPPORTED_PROVIDERS.join(", ")}.`
58
+ };
59
+ const provider = raw.trim().toLowerCase();
60
+ if (!isSupportedProvider(provider)) return {
61
+ ok: false,
62
+ error: `Unsupported social provider "${raw}". Supported: ${SUPPORTED_PROVIDERS.join(", ")}.`
63
+ };
64
+ return {
65
+ ok: true,
66
+ provider
67
+ };
68
+ }
69
+ //#endregion
70
+ //#region src/drivers/facets.ts
71
+ /**
72
+ * URL detector. Matches http(s) URLs; trailing punctuation that is almost
73
+ * always sentence punctuation rather than part of the URL is trimmed off the
74
+ * match (Bluesky's own reference detector does the same) so "see https://a.co."
75
+ * links `https://a.co` not `https://a.co.`.
76
+ */
77
+ const URL_RE = /https?:\/\/[^\s]+/g;
78
+ /** Trailing chars to strip from a matched URL (sentence punctuation, closers). */
79
+ const TRAILING_TRIM = /[.,;:!?)\]}'"]+$/;
80
+ const encoder = new TextEncoder();
81
+ /** UTF-8 byte length of a string — the unit Bluesky facet offsets are in. */
82
+ function utf8Len(text) {
83
+ return encoder.encode(text).length;
84
+ }
85
+ /**
86
+ * Detect link facets in `text`, returning byte-offset ranges suitable for an
87
+ * `app.bsky.feed.post.facets` array. Empty when there are no URLs.
88
+ *
89
+ * Implementation note: we walk matches in string space (RegExp works on the
90
+ * JS string), but convert each match's [startCharIdx, endCharIdx) to
91
+ * [byteStart, byteEnd) by measuring the UTF-8 length of the substrings. This
92
+ * is O(n) in the number of matches and correct across multi-byte characters.
93
+ */
94
+ function detectLinkFacets(text) {
95
+ const facets = [];
96
+ for (const match of text.matchAll(URL_RE)) {
97
+ const start = match.index;
98
+ let raw = match[0];
99
+ const trimmed = raw.replace(TRAILING_TRIM, "");
100
+ if (!trimmed) continue;
101
+ raw = trimmed;
102
+ const end = start + raw.length;
103
+ const byteStart = utf8Len(text.slice(0, start));
104
+ const byteEnd = byteStart + utf8Len(text.slice(start, end));
105
+ facets.push({
106
+ index: {
107
+ byteStart,
108
+ byteEnd
109
+ },
110
+ features: [{
111
+ $type: "app.bsky.richtext.facet#link",
112
+ uri: raw
113
+ }]
114
+ });
115
+ }
116
+ return facets;
117
+ }
118
+ //#endregion
119
+ //#region src/drivers/bluesky.ts
120
+ 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;
123
+ /** Bound every XRPC call so a hung PDS can't stall the MCP server. */
124
+ const XRPC_TIMEOUT_MS = 1e4;
125
+ const CAPABILITIES = {
126
+ postText: true,
127
+ reply: true,
128
+ getPost: true,
129
+ getMetrics: true,
130
+ listNotifications: true,
131
+ search: true,
132
+ deletePost: true
133
+ };
134
+ /** Reject any non-https PDS host (defence-in-depth SSRF guard). */
135
+ function assertHttpsPdsHost(pdsHost) {
136
+ let url;
137
+ try {
138
+ url = new URL(pdsHost);
139
+ } catch {
140
+ throw new Error(`Invalid pdsHost "${pdsHost}"`);
141
+ }
142
+ if (url.protocol !== "https:") throw new Error(`Refusing non-https pdsHost "${pdsHost}" (SSRF guard)`);
143
+ }
144
+ /**
145
+ * Parse a `getSocialAccounts` entry into a usable {@link BlueskyAccount} or a
146
+ * {@link DegradedAccount}. Exported for unit testing the bundle-parse contract.
147
+ */
148
+ 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;
153
+ const base = {
154
+ did,
155
+ handle,
156
+ displayName: entry.displayName,
157
+ connectedAt: entry.connectedAt
158
+ };
159
+ let bundle;
160
+ try {
161
+ bundle = JSON.parse(entry.accessToken);
162
+ } catch {
163
+ return {
164
+ ...base,
165
+ reason: "unparseable_session_bundle"
166
+ };
167
+ }
168
+ if (!bundle.accessJwt) return {
169
+ ...base,
170
+ reason: "missing_access_jwt"
171
+ };
172
+ try {
173
+ assertHttpsPdsHost(pdsHost);
174
+ } catch (err) {
175
+ return {
176
+ ...base,
177
+ reason: err instanceof Error ? err.message : "bad_pds_host"
178
+ };
179
+ }
180
+ return {
181
+ ...base,
182
+ pdsHost,
183
+ accessJwt: bundle.accessJwt
184
+ };
185
+ }
186
+ /** Thrown when the PDS says the session is expired/invalid (drives one refresh). */
187
+ var ExpiredTokenError = class extends Error {};
188
+ var BlueskyDriver = class BlueskyDriver {
189
+ provider = "bluesky";
190
+ toolPrefix = "bluesky";
191
+ capabilities = CAPABILITIES;
192
+ api;
193
+ accounts = /* @__PURE__ */ new Map();
194
+ byHandle = /* @__PURE__ */ new Map();
195
+ degraded = [];
196
+ constructor(api) {
197
+ this.api = api;
198
+ }
199
+ /**
200
+ * Build a driver from the agent's connected Bluesky accounts. Zero accounts
201
+ * is NOT an error — the server degrades gracefully (only `social_list_
202
+ * accounts` / `bluesky_check_connection` register). Per-account parse
203
+ * failures are recorded as degraded rather than failing the whole driver.
204
+ */
205
+ static async create(api) {
206
+ const driver = new BlueskyDriver(api);
207
+ const { accounts } = await api.getSocialAccounts("bluesky");
208
+ for (const entry of accounts) driver.ingest(entry);
209
+ return driver;
210
+ }
211
+ ingest(entry) {
212
+ const parsed = parseAccountEntry(entry);
213
+ if ("reason" in parsed) {
214
+ this.degraded.push(parsed);
215
+ return;
216
+ }
217
+ this.accounts.set(parsed.did, parsed);
218
+ if (parsed.handle) this.byHandle.set(parsed.handle, parsed.did);
219
+ }
220
+ /** Resolve the `account` selector (DID or handle) to a usable account. */
221
+ resolve(account) {
222
+ 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) {
226
+ const degraded = this.degraded.find((d) => d.did === account || d.handle === account);
227
+ 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
+ throw new Error(`Unknown account "${account}". Call social_list_accounts to see connected Bluesky accounts on this agent.`);
229
+ }
230
+ return acct;
231
+ }
232
+ listAccounts() {
233
+ const usable = [...this.accounts.values()].map((a) => ({
234
+ accountIdentifier: a.did,
235
+ handle: a.handle,
236
+ displayName: a.displayName,
237
+ connectedAt: a.connectedAt,
238
+ connected: true
239
+ }));
240
+ const broken = this.degraded.map((d) => ({
241
+ accountIdentifier: d.did,
242
+ handle: d.handle,
243
+ displayName: d.displayName,
244
+ connectedAt: d.connectedAt,
245
+ connected: false,
246
+ reason: d.reason
247
+ }));
248
+ return [...usable, ...broken];
249
+ }
250
+ /**
251
+ * Authenticated XRPC call with the account's current accessJwt. On a 401 /
252
+ * ExpiredToken, refresh ONCE via connect + re-fetch accounts, then retry.
253
+ * Honors a single `Retry-After` on 429. Never logs tokens.
254
+ */
255
+ async xrpc(acct, method, nsid, opts = {}) {
256
+ assertHttpsPdsHost(acct.pdsHost);
257
+ const url = new URL(`${acct.pdsHost}/xrpc/${nsid}`);
258
+ for (const [k, v] of Object.entries(opts.query ?? {})) if (Array.isArray(v)) for (const item of v) url.searchParams.append(k, item);
259
+ else url.searchParams.set(k, v);
260
+ const headers = {
261
+ Authorization: `Bearer ${acct.accessJwt}`,
262
+ Accept: "application/json"
263
+ };
264
+ if (method === "POST") headers["Content-Type"] = "application/json";
265
+ const res = await fetch(url, {
266
+ method,
267
+ headers,
268
+ body: method === "POST" ? JSON.stringify(opts.body ?? {}) : void 0,
269
+ signal: AbortSignal.timeout(XRPC_TIMEOUT_MS)
270
+ });
271
+ if (res.status === 429 && !opts.retried429) {
272
+ const retryAfter = Number(res.headers.get("retry-after"));
273
+ const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : 1e3;
274
+ await new Promise((r) => setTimeout(r, Math.min(delayMs, 3e4)));
275
+ return this.xrpc(acct, method, nsid, {
276
+ ...opts,
277
+ retried429: true
278
+ });
279
+ }
280
+ 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.`);
282
+ const refreshed = await this.refreshAccount(acct.did);
283
+ return this.xrpc(refreshed, method, nsid, {
284
+ ...opts,
285
+ retriedAuth: true,
286
+ retried429: false
287
+ });
288
+ }
289
+ if (!res.ok) {
290
+ let detail = "";
291
+ try {
292
+ const b = await res.json();
293
+ detail = b.error ?? b.message ?? "";
294
+ } catch {}
295
+ if (detail === "ExpiredToken" && !opts.retriedAuth) {
296
+ const refreshed = await this.refreshAccount(acct.did);
297
+ return this.xrpc(refreshed, method, nsid, {
298
+ ...opts,
299
+ retriedAuth: true,
300
+ retried429: false
301
+ });
302
+ }
303
+ throw new Error(`Bluesky ${nsid} failed: HTTP ${String(res.status)}${detail ? ` (${detail})` : ""}`);
304
+ }
305
+ return res.json();
306
+ }
307
+ /** Delegate refresh to connect, then re-fetch accounts for the fresh jwt. */
308
+ async refreshAccount(did) {
309
+ await this.api.refreshSocialAccount("bluesky", did);
310
+ 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.`);
321
+ }
322
+ async checkConnection(account) {
323
+ const acct = this.resolve(account);
324
+ const session = await this.xrpc(acct, "GET", "com.atproto.server.getSession");
325
+ return { data: {
326
+ account: acct.handle || acct.did,
327
+ connected: true,
328
+ did: session.did,
329
+ handle: session.handle
330
+ } };
331
+ }
332
+ async postText(args) {
333
+ 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.`);
336
+ const record = {
337
+ $type: "app.bsky.feed.post",
338
+ text: args.text,
339
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
340
+ facets: detectLinkFacets(args.text)
341
+ };
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
+ return { data: {
348
+ uri: out.uri,
349
+ cid: out.cid,
350
+ account: acct.handle || acct.did
351
+ } };
352
+ }
353
+ async reply(args) {
354
+ 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.`);
357
+ 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.`);
360
+ const parentRef = {
361
+ uri: parentPost.uri,
362
+ cid: parentPost.cid
363
+ };
364
+ const rootRef = deriveRoot(parent.thread) ?? parentRef;
365
+ const record = {
366
+ $type: "app.bsky.feed.post",
367
+ text: args.text,
368
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
369
+ facets: detectLinkFacets(args.text),
370
+ reply: {
371
+ root: rootRef,
372
+ parent: parentRef
373
+ }
374
+ };
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
+ return { data: {
381
+ uri: out.uri,
382
+ cid: out.cid,
383
+ account: acct.handle || acct.did
384
+ } };
385
+ }
386
+ async getPost(args) {
387
+ const acct = this.resolve(args.account);
388
+ return { data: { posts: (await this.xrpc(acct, "GET", "app.bsky.feed.getPosts", { query: { uris: [args.post] } })).posts ?? [] } };
389
+ }
390
+ async getMetrics(args) {
391
+ 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}".`);
394
+ return { data: {
395
+ uri: post.uri,
396
+ likes: post.likeCount ?? 0,
397
+ reposts: post.repostCount ?? 0,
398
+ replies: post.replyCount ?? 0,
399
+ quotes: post.quoteCount ?? 0
400
+ } };
401
+ }
402
+ async listNotifications(args) {
403
+ const acct = this.resolve(args.account);
404
+ const query = {};
405
+ 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 ?? [] } };
407
+ }
408
+ async search(args) {
409
+ const acct = this.resolve(args.account);
410
+ const query = { q: args.query };
411
+ 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 ?? [] } };
413
+ }
414
+ async deletePost(args) {
415
+ 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}".`);
418
+ await this.xrpc(acct, "POST", "com.atproto.repo.deleteRecord", { body: {
419
+ repo: acct.did,
420
+ collection: "app.bsky.feed.post",
421
+ rkey
422
+ } });
423
+ return { data: {
424
+ deleted: true,
425
+ uri: args.post
426
+ } };
427
+ }
428
+ };
429
+ /**
430
+ * Grapheme count. Bluesky's 300-character post limit is measured in graphemes
431
+ * (user-perceived characters), so a family emoji or a combining-mark sequence
432
+ * counts as one — using JS `.length` (UTF-16 code units) would over-count and
433
+ * reject valid posts. `Intl.Segmenter` gives the correct grapheme count.
434
+ */
435
+ const graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
436
+ function graphemeLen(text) {
437
+ return [...graphemeSegmenter.segment(text)].length;
438
+ }
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;
444
+ }
445
+ /**
446
+ * Best-effort thread-root ref extraction from a getPostThread response.
447
+ *
448
+ * Reads the parent post's OWN `record.reply.root` — for a nested reply that is
449
+ * the true thread root. LIMITATION: when the parent record carries no
450
+ * `reply.root` (e.g. the thread lookup was shallow, or the parent record shape
451
+ * was unexpected), the caller falls back to using the parent itself as the
452
+ * root. On a deep reply that fallback yields root === parent, which is only
453
+ * correct when replying to a top-level post; it can mis-root a deep reply.
454
+ */
455
+ function deriveRoot(thread) {
456
+ 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
+ };
462
+ }
463
+ //#endregion
464
+ //#region src/tools.ts
465
+ /**
466
+ * Tool registration for the social MCP server.
467
+ *
468
+ * Tools are built from a {@link SocialDriver}'s capability flags — the server
469
+ * registers a credential-touching tool ONLY when the driver declares the
470
+ * matching capability, so each platform gets exactly its own subset (per-
471
+ * platform subsetting lives in the driver, never the manifest).
472
+ *
473
+ * Pattern A: every credential-touching tool requires an `account` selector
474
+ * (the value from `social_list_accounts`). `social_list_accounts` is the sole
475
+ * selector-exempt discovery tool. `buildPatternADescriptors()` returns the
476
+ * JSON-Schema-shaped view of the registered tools so `server.ts` can run
477
+ * `assertPatternA()` at startup — if a new tool ever forgets the `account`
478
+ * field, the server refuses to start rather than shipping a credential-leak-
479
+ * shaped contract regression.
480
+ *
481
+ * SINGLE SOURCE OF TRUTH: both `registerTools()` (what the LLM sees) and
482
+ * `buildPatternADescriptors()` (what the Pattern A guard validates) are derived
483
+ * from the one {@link ACTION_TOOL_SPECS} table below. A tool can't be added to
484
+ * registration without also appearing in the descriptors the guard checks —
485
+ * the two can never drift apart, so the guard can't be fooled by a hand-built
486
+ * mirror going stale. `assertToolsMatchDescriptors()` additionally asserts the
487
+ * two derived name sets are identical as a belt-and-braces regression fence.
488
+ *
489
+ * Tool naming mirrors the connect-credential MCP convention: the discovery /
490
+ * health tools are `social_*` (platform-agnostic), while the action tools are
491
+ * `<prefix>_*` (e.g. `bluesky_post_text`) — matching how `@alfe.ai/github-mcp`
492
+ * names `github_list_accounts` alongside its GitHub-specific tools.
493
+ */
494
+ function ok(data) {
495
+ return { content: [{
496
+ type: "text",
497
+ text: JSON.stringify(data, null, 2)
498
+ }] };
499
+ }
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
+ };
508
+ }
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).");
510
+ /**
511
+ * The one table both registration and descriptor-building derive from. The
512
+ * `capability` field is BOTH the `SocialCapabilities` flag and the same-named
513
+ * optional method on `SocialDriver`, so a spec is live IFF
514
+ * `driver.capabilities[cap] && typeof driver[cap] === "function"`.
515
+ */
516
+ const ACTION_TOOL_SPECS = [
517
+ {
518
+ capability: "postText",
519
+ suffix: "post_text",
520
+ 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
+ inputSchema: {
522
+ account: accountField,
523
+ text: z.string().describe("The post body.")
524
+ }
525
+ },
526
+ {
527
+ capability: "reply",
528
+ suffix: "reply",
529
+ description: "Reply to a post as the selected account. Pass the parent post's URI/id; the thread root is resolved automatically.",
530
+ inputSchema: {
531
+ account: accountField,
532
+ parent: z.string().describe("URI/id of the post being replied to."),
533
+ text: z.string().describe("The reply body.")
534
+ }
535
+ },
536
+ {
537
+ capability: "getPost",
538
+ suffix: "get_post",
539
+ description: "Fetch a post by its URI/id as the selected account.",
540
+ inputSchema: {
541
+ account: accountField,
542
+ post: z.string().describe("URI/id of the post to fetch.")
543
+ }
544
+ },
545
+ {
546
+ capability: "getMetrics",
547
+ suffix: "get_metrics",
548
+ description: "Get like / repost / reply / quote counts for a post.",
549
+ inputSchema: {
550
+ account: accountField,
551
+ post: z.string().describe("URI/id of the post to measure.")
552
+ }
553
+ },
554
+ {
555
+ capability: "listNotifications",
556
+ suffix: "list_notifications",
557
+ description: "List recent notifications (mentions, replies, likes, follows) for the selected account.",
558
+ inputSchema: {
559
+ account: accountField,
560
+ limit: z.number().int().min(1).max(100).optional().describe("Max notifications (1-100).")
561
+ }
562
+ },
563
+ {
564
+ capability: "search",
565
+ suffix: "search",
566
+ description: "Search posts as the selected account.",
567
+ inputSchema: {
568
+ account: accountField,
569
+ query: z.string().describe("Search query."),
570
+ limit: z.number().int().min(1).max(100).optional().describe("Max results (1-100).")
571
+ }
572
+ },
573
+ {
574
+ capability: "deletePost",
575
+ suffix: "delete_post",
576
+ description: "Delete a post the selected account authored, by its URI/id.",
577
+ inputSchema: {
578
+ account: accountField,
579
+ post: z.string().describe("URI/id of the post to delete.")
580
+ }
581
+ }
582
+ ];
583
+ /**
584
+ * Resolve the live driver method for a spec, or `undefined` if the driver
585
+ * hasn't enabled this capability. The `capability` key names the optional
586
+ * method 1:1, so this is the single place both registration and descriptors
587
+ * ask "is this tool live?".
588
+ */
589
+ function resolveMethod(driver, spec) {
590
+ if (!driver.capabilities[spec.capability]) return void 0;
591
+ const method = driver[spec.capability];
592
+ if (typeof method !== "function") return void 0;
593
+ return (args) => method.call(driver, args);
594
+ }
595
+ /**
596
+ * Register the discovery + health tools and every capability-enabled action
597
+ * tool onto the MCP server. The discovery tool is always `social_list_accounts`;
598
+ * action tools are prefixed with the driver's `toolPrefix` and driven off the
599
+ * shared {@link ACTION_TOOL_SPECS} table.
600
+ */
601
+ function registerTools(server, driver) {
602
+ const register = server.registerTool.bind(server);
603
+ const p = driver.toolPrefix;
604
+ register("social_list_accounts", {
605
+ description: "List the social accounts the agent has connected for this platform. Returns one entry per connection — use the returned handle or accountIdentifier as the `account` selector on every other tool. Entries with connected=false could not be initialised; ask the user to reconnect them.",
606
+ inputSchema: {}
607
+ }, () => ok({ accounts: driver.listAccounts() }));
608
+ register(`${p}_check_connection`, {
609
+ description: "Verify a connected account's session is still valid. Use this if calls are failing with authentication errors.",
610
+ inputSchema: { account: accountField }
611
+ }, async (args) => {
612
+ try {
613
+ return ok((await driver.checkConnection(args.account)).data);
614
+ } catch (err) {
615
+ return fail(err);
616
+ }
617
+ });
618
+ for (const spec of ACTION_TOOL_SPECS) {
619
+ const method = resolveMethod(driver, spec);
620
+ if (!method) continue;
621
+ register(`${p}_${spec.suffix}`, {
622
+ description: spec.description,
623
+ inputSchema: spec.inputSchema
624
+ }, (args) => method(args).then((r) => ok(r.data), (err) => fail(err)));
625
+ }
626
+ }
627
+ /**
628
+ * Build the JSON-Schema-shaped descriptor list for `assertPatternA()`. Every
629
+ * registered action tool carries a required `account` string; the discovery
630
+ * tool (`social_list_accounts`) is exempt. Derived from the SAME
631
+ * {@link ACTION_TOOL_SPECS} table as `registerTools()`, so the guard can never
632
+ * validate a stale hand-built mirror.
633
+ */
634
+ function buildPatternADescriptors(driver) {
635
+ const p = driver.toolPrefix;
636
+ const accountSchema = {
637
+ type: "object",
638
+ properties: { account: { type: "string" } },
639
+ required: ["account"]
640
+ };
641
+ const tools = [{
642
+ name: "social_list_accounts",
643
+ parameters: {
644
+ type: "object",
645
+ properties: {}
646
+ }
647
+ }, {
648
+ name: `${p}_check_connection`,
649
+ parameters: accountSchema
650
+ }];
651
+ for (const spec of ACTION_TOOL_SPECS) if (resolveMethod(driver, spec)) tools.push({
652
+ name: `${p}_${spec.suffix}`,
653
+ parameters: accountSchema
654
+ });
655
+ return tools;
656
+ }
657
+ /** The discovery tool name — the sole Pattern A exemption. */
658
+ const DISCOVERY_TOOL_NAME = "social_list_accounts";
659
+ //#endregion
660
+ //#region src/server.ts
661
+ /**
662
+ * Social MCP Server (Pattern A multi-account, provider selected via --provider).
663
+ *
664
+ * A standalone stdio MCP server that lets an agent post, engage, and read on
665
+ * its connected social accounts using Alfe Connect credentials. Runtime-
666
+ * agnostic — spawned via `npx -y @alfe.ai/social-mcp --provider <id>` from an
667
+ * integration manifest.
668
+ *
669
+ * Architecture:
670
+ * Agent runtime ←(stdio/MCP)→ this server ←(HTTPS/XRPC)→ platform PDS/API
671
+ *
672
+ * Provider selection is via a CLI ARGUMENT, never `env` — see `args.ts` for
673
+ * why (env would break `mcp-applier.resolveEnv` short-circuiting and kill the
674
+ * zero-account graceful-degradation path). Manifest env stays EMPTY, exactly
675
+ * like `@alfe.ai/github-mcp`.
676
+ *
677
+ * Zero-account degradation: startup with no connected accounts is NOT fatal —
678
+ * the server still registers `social_list_accounts` + `<prefix>_check_
679
+ * connection` so the LLM can report the empty state and prompt the user to
680
+ * connect, exactly like github-mcp. It never exits non-zero for zero accounts.
681
+ *
682
+ * Credentials: resolved at startup via `AgentApiClient.getSocialAccounts
683
+ * (provider)`. Token refresh is delegated to connect (the driver calls
684
+ * `refreshSocialAccount` on a 401) — never done in-plugin.
685
+ *
686
+ * Pattern A: every credential-touching tool requires an `account` selector;
687
+ * `assertPatternA()` runs at startup so a forgotten selector fails closed.
688
+ */
689
+ function log(msg) {
690
+ process.stderr.write(`[social-mcp] ${msg}\n`);
691
+ }
692
+ /**
693
+ * Per-provider driver factories. Adding a platform = add a `SocialDriver`
694
+ * implementation and register its factory here (plus `SUPPORTED_PROVIDERS` in
695
+ * `args.ts`). Keyed by provider id so there's no tautological switch while
696
+ * only one provider ships.
697
+ */
698
+ const DRIVER_FACTORIES = { bluesky: (api) => BlueskyDriver.create(api) };
699
+ /** Construct the driver for the selected provider from resolved credentials. */
700
+ function createDriver(provider, api) {
701
+ return DRIVER_FACTORIES[provider](api);
702
+ }
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
+ }
709
+ const provider = parsed.provider;
710
+ const { apiKey, apiUrl } = resolveConfig();
711
+ const driver = await createDriver(provider, new AgentApiClient({
712
+ apiKey,
713
+ apiUrl
714
+ }));
715
+ const usable = driver.listAccounts().filter((a) => a.connected).length;
716
+ if (usable === 0) log(`No ${provider} accounts connected — server will start with ${DISCOVERY_TOOL_NAME} and ${driver.toolPrefix}_check_connection only`);
717
+ const server = new McpServer({
718
+ name: "social-mcp-server",
719
+ version: "0.1.0"
720
+ });
721
+ registerTools(server, driver);
722
+ assertPatternA(buildPatternADescriptors(driver), {
723
+ selector: "account",
724
+ exempt: [DISCOVERY_TOOL_NAME]
725
+ });
726
+ for (const signal of ["SIGTERM", "SIGINT"]) process.on(signal, () => {
727
+ process.exit(0);
728
+ });
729
+ const transport = new StdioServerTransport();
730
+ await server.connect(transport);
731
+ log(`${provider} MCP server running with ${String(usable)} connected account(s) and Pattern A selector enforcement`);
732
+ }
733
+ main().catch((err) => {
734
+ log(`Fatal: ${err instanceof Error ? err.message : String(err)}`);
735
+ process.exit(1);
736
+ });
737
+ //#endregion
738
+ export {};
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@alfe.ai/social-mcp",
3
+ "version": "0.2.0",
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
+ "type": "module",
6
+ "main": "./dist/server.js",
7
+ "bin": {
8
+ "social-mcp-server": "./dist/server.js"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/server.d.ts",
13
+ "import": "./dist/server.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.29.0",
21
+ "zod": "^4.0.5",
22
+ "@alfe.ai/agent-api-client": "0.11.2",
23
+ "@alfe.ai/config": "0.3.0",
24
+ "@alfe.ai/mcp-bundler": "0.3.2"
25
+ },
26
+ "license": "UNLICENSED",
27
+ "homepage": "https://alfe.ai",
28
+ "author": "Alfe (https://alfe.ai)",
29
+ "keywords": [
30
+ "alfe",
31
+ "ai-agents",
32
+ "agent",
33
+ "llm",
34
+ "mcp",
35
+ "model-context-protocol",
36
+ "social",
37
+ "bluesky",
38
+ "atproto"
39
+ ],
40
+ "scripts": {
41
+ "build": "tsdown",
42
+ "dev": "tsdown --watch",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "vitest run",
45
+ "lint": "eslint ."
46
+ }
47
+ }