@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.
- package/README.md +19 -0
- package/dist/server.js +517 -159
- 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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
100
|
-
if (!
|
|
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
|
-
|
|
122
|
-
const
|
|
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
|
-
/**
|
|
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(
|
|
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 =
|
|
151
|
-
const
|
|
152
|
-
const
|
|
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
|
|
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
|
|
389
|
+
} catch {
|
|
175
390
|
return {
|
|
176
391
|
...base,
|
|
177
|
-
reason:
|
|
392
|
+
reason: "invalid_pds_host"
|
|
178
393
|
};
|
|
179
394
|
}
|
|
180
395
|
return {
|
|
181
396
|
...base,
|
|
182
|
-
pdsHost,
|
|
183
|
-
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
|
-
|
|
194
|
-
|
|
408
|
+
resolveHost;
|
|
409
|
+
accounts = [];
|
|
195
410
|
degraded = [];
|
|
196
|
-
|
|
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
|
-
|
|
425
|
+
driver.replaceAccounts(accounts);
|
|
209
426
|
return driver;
|
|
210
427
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
293
|
-
detail =
|
|
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
|
|
528
|
+
throw new Error(`Bluesky request failed: HTTP ${String(res.status)}`);
|
|
304
529
|
}
|
|
305
|
-
return res
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
329
|
-
handle
|
|
551
|
+
did,
|
|
552
|
+
handle
|
|
330
553
|
} };
|
|
331
554
|
}
|
|
332
555
|
async postText(args) {
|
|
333
556
|
const acct = this.resolve(args.account);
|
|
334
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
|
359
|
-
|
|
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:
|
|
362
|
-
cid:
|
|
585
|
+
uri: parentUri,
|
|
586
|
+
cid: parentCid
|
|
363
587
|
};
|
|
364
|
-
const rootRef = deriveRoot(
|
|
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
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
|
397
|
-
reposts: post.repostCount
|
|
398
|
-
replies: post.replyCount
|
|
399
|
-
quotes: post.quoteCount
|
|
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 })
|
|
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
|
|
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 })
|
|
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
|
|
417
|
-
if (
|
|
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:
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
496
|
-
type: "text",
|
|
497
|
-
text: JSON.stringify(data, null, 2)
|
|
498
|
-
}] };
|
|
836
|
+
return toolResult(data);
|
|
499
837
|
}
|
|
500
|
-
function fail(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
615
|
-
return fail(
|
|
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), (
|
|
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
|
|
704
|
-
const parsed = parseProviderArg(
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|
|
23
|
-
"@alfe.ai/config": "0.
|
|
24
|
-
"@alfe.ai/mcp-bundler": "0.4.
|
|
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",
|