@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 +22 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +738 -0
- package/package.json +47 -0
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>
|
package/dist/server.d.ts
ADDED
|
@@ -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
|
+
}
|