reddit-mcp-server 1.5.0 → 1.5.2
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/dist/bin.d.ts +1 -1
- package/dist/bin.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +337 -117
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
package/dist/index.js
CHANGED
|
@@ -59,6 +59,21 @@ var NotFoundError = class extends RedditErrorBase {
|
|
|
59
59
|
this.name = "NotFoundError";
|
|
60
60
|
}
|
|
61
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* Reddit refused the request at the network level rather than for this specific resource.
|
|
64
|
+
*
|
|
65
|
+
* Reddit 403s the unauthenticated JSON API from many IP ranges (datacenters, VPNs, flagged
|
|
66
|
+
* addresses), answering with an HTML block page instead of JSON. A bare `HttpError(403)` reads
|
|
67
|
+
* as "this subreddit is private", which sends people looking in the wrong place — the fix is to
|
|
68
|
+
* supply OAuth credentials, which also raises the rate limit from ~10 to 60+ req/min.
|
|
69
|
+
*/
|
|
70
|
+
var NetworkBlockedError = class extends RedditErrorBase {
|
|
71
|
+
_tag = "NetworkBlockedError";
|
|
72
|
+
constructor(message) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "NetworkBlockedError";
|
|
75
|
+
}
|
|
76
|
+
};
|
|
62
77
|
/** Client-side input or safety-policy rejection (invalid sort, duplicate-content guard). */
|
|
63
78
|
var ValidationError = class extends RedditErrorBase {
|
|
64
79
|
_tag = "ValidationError";
|
|
@@ -90,6 +105,71 @@ function classifyRedditError(error, context) {
|
|
|
90
105
|
return new UnknownError(Option(context).fold(() => error.message, (ctx) => `${ctx}: ${error.message}`), error);
|
|
91
106
|
}
|
|
92
107
|
//#endregion
|
|
108
|
+
//#region src/utils/reddit-identifiers.ts
|
|
109
|
+
/**
|
|
110
|
+
* Validation and normalization for Reddit identifiers that get interpolated into API paths.
|
|
111
|
+
*
|
|
112
|
+
* Every identifier reaching the client is model- or user-supplied. Interpolating one raw is a
|
|
113
|
+
* path-injection hole: URL parsing resolves dot segments, so a `subreddit` of `../../api/v1/me`
|
|
114
|
+
* escapes `/r/{sub}/about.json` and steers the OAuth bearer token to a different endpoint, and an
|
|
115
|
+
* embedded `?` or `&` injects query parameters into the request we build.
|
|
116
|
+
*
|
|
117
|
+
* So each identifier is normalized (stripping the `r/`, `/u/`, `t3_` prefixes people naturally
|
|
118
|
+
* type) and then checked against Reddit's own charset. Every value returned from this module
|
|
119
|
+
* matches `[A-Za-z0-9_+-]+`, which is already URL-path-safe — no percent-encoding is applied,
|
|
120
|
+
* because encoding the `+` in a multireddit (`r/science+space`) would break it.
|
|
121
|
+
*/
|
|
122
|
+
const SUBREDDIT_PATTERN = /^(u_)?[A-Za-z0-9][A-Za-z0-9_]{1,20}$/;
|
|
123
|
+
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{1,19}$/;
|
|
124
|
+
const THING_ID_PATTERN = /^[a-z0-9]{1,13}$/;
|
|
125
|
+
const stripLeading = (value, prefixes) => {
|
|
126
|
+
const lower = value.toLowerCase();
|
|
127
|
+
const matched = prefixes.find((prefix) => lower.startsWith(prefix));
|
|
128
|
+
return matched === void 0 ? value : value.slice(matched.length);
|
|
129
|
+
};
|
|
130
|
+
const bare = (value, prefixes) => stripLeading(stripLeading(value.trim(), ["/"]), prefixes).replace(/\/+$/, "");
|
|
131
|
+
/**
|
|
132
|
+
* Normalize a subreddit name for use in a path segment.
|
|
133
|
+
*
|
|
134
|
+
* Accepts `science`, `r/science`, `/r/science`, and `+`-joined multireddits (`science+space`).
|
|
135
|
+
* The empty string passes through unchanged — callers use it to mean "the home feed".
|
|
136
|
+
*/
|
|
137
|
+
function normalizeSubreddit(input) {
|
|
138
|
+
const trimmed = input.trim();
|
|
139
|
+
if (trimmed === "") return "";
|
|
140
|
+
const segments = bare(trimmed, ["r/"]).split("+").map((segment) => segment.trim());
|
|
141
|
+
if (segments.find((segment) => !SUBREDDIT_PATTERN.test(segment)) !== void 0) throw new ValidationError(`Invalid subreddit name "${input}". Expected 2-21 characters of letters, digits, or underscores (e.g. "science").`);
|
|
142
|
+
return segments.join("+");
|
|
143
|
+
}
|
|
144
|
+
/** Normalize a username for use in a path segment. Accepts `spez`, `u/spez`, `/user/spez`. */
|
|
145
|
+
function normalizeUsername(input) {
|
|
146
|
+
const name = bare(input, ["user/", "u/"]);
|
|
147
|
+
if (!USERNAME_PATTERN.test(name)) throw new ValidationError(`Invalid Reddit username "${input}". Expected 2-20 characters of letters, digits, underscores, or hyphens (e.g. "spez").`);
|
|
148
|
+
return name;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Normalize a post or comment ID to its bare base36 form, dropping any `t1_`/`t3_` fullname
|
|
152
|
+
* prefix. Use this wherever the ID lands in a path segment or query value.
|
|
153
|
+
*/
|
|
154
|
+
function normalizeThingId(input) {
|
|
155
|
+
const id = bare(input, [
|
|
156
|
+
"t1_",
|
|
157
|
+
"t3_",
|
|
158
|
+
"t4_",
|
|
159
|
+
"t5_"
|
|
160
|
+
]).toLowerCase();
|
|
161
|
+
if (!THING_ID_PATTERN.test(id)) throw new ValidationError(`Invalid Reddit ID "${input}". Expected a base36 id such as "1abc2de", optionally prefixed with t1_ or t3_.`);
|
|
162
|
+
return id;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Normalize an ID to a Reddit fullname (`t3_1abc2de`), preserving an explicit `t1_`/`t3_` prefix
|
|
166
|
+
* and falling back to `defaultKind` when the caller passed a bare ID.
|
|
167
|
+
*/
|
|
168
|
+
function normalizeFullname(input, defaultKind) {
|
|
169
|
+
const trimmed = input.trim().toLowerCase();
|
|
170
|
+
return `${trimmed.startsWith("t1_") ? "t1" : trimmed.startsWith("t3_") ? "t3" : defaultKind}_${normalizeThingId(input)}`;
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
93
173
|
//#region src/client/response-cache.ts
|
|
94
174
|
const SECOND = 1e3;
|
|
95
175
|
var ResponseCache = class {
|
|
@@ -160,6 +240,10 @@ function listingCursor(data) {
|
|
|
160
240
|
...before
|
|
161
241
|
};
|
|
162
242
|
}
|
|
243
|
+
function headerValue(response, name) {
|
|
244
|
+
const headers = response.headers;
|
|
245
|
+
return (headers === null || headers === void 0 ? void 0 : headers.get(name)) ?? "";
|
|
246
|
+
}
|
|
163
247
|
function parsePostData(post) {
|
|
164
248
|
return {
|
|
165
249
|
id: post.id,
|
|
@@ -254,6 +338,7 @@ var RedditClient = class {
|
|
|
254
338
|
...headers,
|
|
255
339
|
Authorization: await this.reauthorize()
|
|
256
340
|
}, path, 0) : first;
|
|
341
|
+
if (!requiresAuth && response.status === 403 && !headerValue(response, "content-type").includes("json")) throw new NetworkBlockedError("Reddit is blocking unauthenticated requests from this network (HTTP 403 with a block page). Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET to authenticate with OAuth, which also raises the rate limit from ~10 to 60+ requests/min. See https://www.reddit.com/prefs/apps to create an app.");
|
|
257
342
|
if (cacheable && response.ok) {
|
|
258
343
|
const text = await response.text();
|
|
259
344
|
this.cache.set(url, text, response.status);
|
|
@@ -381,7 +466,7 @@ var RedditClient = class {
|
|
|
381
466
|
async getUser(username) {
|
|
382
467
|
const context = `Failed to get user info for ${username}`;
|
|
383
468
|
return (await Try.async(async () => {
|
|
384
|
-
const response = (await this.makeRequest(`/user/${username}/about.json`)).orThrow();
|
|
469
|
+
const response = (await this.makeRequest(`/user/${normalizeUsername(username)}/about.json`)).orThrow();
|
|
385
470
|
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
386
471
|
const { data } = await response.json();
|
|
387
472
|
return {
|
|
@@ -431,14 +516,14 @@ var RedditClient = class {
|
|
|
431
516
|
const { limit = 25, after } = options;
|
|
432
517
|
const params = new URLSearchParams({ limit: limit.toString() });
|
|
433
518
|
if (after !== void 0) params.set("after", after);
|
|
434
|
-
return this.getUserContent(`/user/${this.username}/overview.json?${params}`, "Failed to get your overview");
|
|
519
|
+
return this.getUserContent(`/user/${encodeURIComponent(this.username)}/overview.json?${params}`, "Failed to get your overview");
|
|
435
520
|
}
|
|
436
521
|
async getMySaved(options = {}) {
|
|
437
522
|
if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching saved content requires REDDIT_USERNAME"));
|
|
438
523
|
const { limit = 25, after } = options;
|
|
439
524
|
const params = new URLSearchParams({ limit: limit.toString() });
|
|
440
525
|
if (after !== void 0) params.set("after", after);
|
|
441
|
-
return this.getUserContent(`/user/${this.username}/saved.json?${params}`, "Failed to get saved content");
|
|
526
|
+
return this.getUserContent(`/user/${encodeURIComponent(this.username)}/saved.json?${params}`, "Failed to get saved content");
|
|
442
527
|
}
|
|
443
528
|
async getMe() {
|
|
444
529
|
if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your account requires REDDIT_USERNAME"));
|
|
@@ -464,7 +549,7 @@ var RedditClient = class {
|
|
|
464
549
|
async getSubredditInfo(subredditName) {
|
|
465
550
|
const context = `Failed to get subreddit info for ${subredditName}`;
|
|
466
551
|
return (await Try.async(async () => {
|
|
467
|
-
const response = (await this.makeRequest(`/r/${subredditName}/about.json`)).orThrow();
|
|
552
|
+
const response = (await this.makeRequest(`/r/${normalizeSubreddit(subredditName)}/about.json`)).orThrow();
|
|
468
553
|
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
469
554
|
const { data } = await response.json();
|
|
470
555
|
return {
|
|
@@ -484,7 +569,7 @@ var RedditClient = class {
|
|
|
484
569
|
async getSubredditRules(subreddit) {
|
|
485
570
|
const context = `Failed to get rules for r/${subreddit}`;
|
|
486
571
|
return (await Try.async(async () => {
|
|
487
|
-
const response = (await this.makeRequest(`/r/${subreddit}/about/rules.json`)).orThrow();
|
|
572
|
+
const response = (await this.makeRequest(`/r/${normalizeSubreddit(subreddit)}/about/rules.json`)).orThrow();
|
|
488
573
|
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
489
574
|
return (await response.json()).rules.map((rule) => ({
|
|
490
575
|
shortName: rule.short_name,
|
|
@@ -499,7 +584,7 @@ var RedditClient = class {
|
|
|
499
584
|
async getPostFlairs(subreddit) {
|
|
500
585
|
const context = `Failed to get post flairs for r/${subreddit}`;
|
|
501
586
|
return (await Try.async(async () => {
|
|
502
|
-
const response = (await this.makeRequest(`/r/${subreddit}/api/link_flair_v2.json`)).orThrow();
|
|
587
|
+
const response = (await this.makeRequest(`/r/${normalizeSubreddit(subreddit)}/api/link_flair_v2.json`)).orThrow();
|
|
503
588
|
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
504
589
|
return (await response.json()).map((flair) => ({
|
|
505
590
|
id: flair.id,
|
|
@@ -510,7 +595,6 @@ var RedditClient = class {
|
|
|
510
595
|
})).toEither((error) => classifyRedditError(error, context));
|
|
511
596
|
}
|
|
512
597
|
async getTopPosts(subreddit, timeFilter = "week", limit = 10, after) {
|
|
513
|
-
const endpoint = subreddit !== "" ? `/r/${subreddit}/top.json` : "/top.json";
|
|
514
598
|
const params = new URLSearchParams({
|
|
515
599
|
t: timeFilter,
|
|
516
600
|
limit: limit.toString()
|
|
@@ -518,6 +602,8 @@ var RedditClient = class {
|
|
|
518
602
|
if (after !== void 0) params.set("after", after);
|
|
519
603
|
const context = `Failed to get top posts for ${subreddit !== "" ? subreddit : "home"}`;
|
|
520
604
|
return (await Try.async(async () => {
|
|
605
|
+
const name = normalizeSubreddit(subreddit);
|
|
606
|
+
const endpoint = name !== "" ? `/r/${name}/top.json` : "/top.json";
|
|
521
607
|
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
522
608
|
if (!response.ok) throw new HttpError(response.status, `Failed to get top posts: HTTP ${response.status}`);
|
|
523
609
|
const json = await response.json();
|
|
@@ -536,13 +622,14 @@ var RedditClient = class {
|
|
|
536
622
|
"controversial"
|
|
537
623
|
];
|
|
538
624
|
if (!validSorts.includes(sort)) return Left(new ValidationError(`Invalid sort "${sort}". Valid options are: ${validSorts.join(", ")}`));
|
|
539
|
-
const endpoint = subreddit !== "" ? `/r/${subreddit}/${sort}.json` : `/${sort}.json`;
|
|
540
625
|
const params = new URLSearchParams({ limit: limit.toString() });
|
|
541
626
|
if (sort === "top" || sort === "controversial") params.set("t", timeFilter);
|
|
542
627
|
if (after !== void 0) params.set("after", after);
|
|
543
628
|
const home = subreddit !== "" ? subreddit : "home";
|
|
544
629
|
const context = `Failed to browse r/${home} (${sort})`;
|
|
545
630
|
return (await Try.async(async () => {
|
|
631
|
+
const name = normalizeSubreddit(subreddit);
|
|
632
|
+
const endpoint = name !== "" ? `/r/${name}/${sort}.json` : `/${sort}.json`;
|
|
546
633
|
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
547
634
|
if (!response.ok) throw new HttpError(response.status, `Failed to browse r/${home}: HTTP ${response.status}`);
|
|
548
635
|
const json = await response.json();
|
|
@@ -553,9 +640,10 @@ var RedditClient = class {
|
|
|
553
640
|
})).toEither((error) => classifyRedditError(error, context));
|
|
554
641
|
}
|
|
555
642
|
async getPost(postId, subreddit) {
|
|
556
|
-
const endpoint = Option(subreddit).fold(() => `/api/info.json?id=t3_${postId}`, (sr) => `/r/${sr}/comments/${postId}.json`);
|
|
557
643
|
const context = `Failed to get post with ID ${postId}`;
|
|
558
644
|
return (await Try.async(async () => {
|
|
645
|
+
const id = normalizeThingId(postId);
|
|
646
|
+
const endpoint = Option(subreddit).fold(() => `/api/info.json?id=t3_${id}`, (sr) => `/r/${normalizeSubreddit(sr)}/comments/${id}.json`);
|
|
559
647
|
const response = (await this.makeRequest(endpoint)).orThrow();
|
|
560
648
|
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
561
649
|
if (subreddit !== void 0) return parsePostData((await response.json())[0].data.children[0].data);
|
|
@@ -579,10 +667,12 @@ var RedditClient = class {
|
|
|
579
667
|
this.validateWriteAccess();
|
|
580
668
|
await this.enforceWriteRateLimit();
|
|
581
669
|
this.checkDuplicateContent(title + content, subreddit);
|
|
670
|
+
const targetSubreddit = normalizeSubreddit(subreddit);
|
|
671
|
+
if (targetSubreddit === "") throw new ValidationError("A subreddit is required to create a post.");
|
|
582
672
|
const finalContent = isSelf ? this.appendBotDisclosure(content) : content;
|
|
583
673
|
const kind = isSelf ? "self" : "link";
|
|
584
674
|
const params = new URLSearchParams();
|
|
585
|
-
params.append("sr",
|
|
675
|
+
params.append("sr", targetSubreddit);
|
|
586
676
|
params.append("kind", kind);
|
|
587
677
|
params.append("title", title);
|
|
588
678
|
params.append(isSelf ? "text" : "url", finalContent);
|
|
@@ -599,12 +689,12 @@ var RedditClient = class {
|
|
|
599
689
|
if (json.json.errors !== void 0 && json.json.errors.length > 0) throw new ApiError(`Reddit API errors: ${json.json.errors.map((e) => e[1]).join(", ")}`);
|
|
600
690
|
const postId = ((_json$json$data = json.json.data) === null || _json$json$data === void 0 ? void 0 : _json$json$data.id) ?? ((_json$json$data2 = json.json.data) === null || _json$json$data2 === void 0 || (_json$json$data2 = _json$json$data2.name) === null || _json$json$data2 === void 0 ? void 0 : _json$json$data2.replace("t3_", ""));
|
|
601
691
|
if (postId === void 0) throw new ApiError("No post ID returned from Reddit");
|
|
602
|
-
return (await this.getPost(postId,
|
|
692
|
+
return (await this.getPost(postId, targetSubreddit)).orThrow();
|
|
603
693
|
})).toEither((error) => classifyRedditError(error));
|
|
604
694
|
}
|
|
605
695
|
async checkPostExists(postId) {
|
|
606
696
|
return (await Try.async(async () => {
|
|
607
|
-
const response = (await this.makeRequest(`/api/info.json?id=t3_${postId}`)).orThrow();
|
|
697
|
+
const response = (await this.makeRequest(`/api/info.json?id=t3_${normalizeThingId(postId)}`)).orThrow();
|
|
608
698
|
if (!response.ok) return false;
|
|
609
699
|
return (await response.json()).data.children.length > 0;
|
|
610
700
|
})).orElse(false);
|
|
@@ -616,9 +706,9 @@ var RedditClient = class {
|
|
|
616
706
|
await this.enforceWriteRateLimit();
|
|
617
707
|
this.checkDuplicateContent(content);
|
|
618
708
|
const finalContent = this.appendBotDisclosure(content);
|
|
619
|
-
const fullThingId = postId
|
|
620
|
-
if (!
|
|
621
|
-
if (!await this.checkPostExists(postId
|
|
709
|
+
const fullThingId = normalizeFullname(postId, "t3");
|
|
710
|
+
if (!fullThingId.startsWith("t1_")) {
|
|
711
|
+
if (!await this.checkPostExists(normalizeThingId(postId))) throw new NotFoundError(`Post with ID ${postId} does not exist or is not accessible`);
|
|
622
712
|
}
|
|
623
713
|
const params = new URLSearchParams();
|
|
624
714
|
params.append("thing_id", fullThingId);
|
|
@@ -651,10 +741,10 @@ var RedditClient = class {
|
|
|
651
741
|
else throw new ApiError("Failed to parse reply response");
|
|
652
742
|
})).toEither((error) => classifyRedditError(error));
|
|
653
743
|
}
|
|
654
|
-
async
|
|
744
|
+
async deleteThing(thingId, defaultKind) {
|
|
655
745
|
return (await Try.async(async () => {
|
|
656
746
|
this.validateWriteAccess();
|
|
657
|
-
const fullThingId = thingId
|
|
747
|
+
const fullThingId = normalizeFullname(thingId, defaultKind);
|
|
658
748
|
const params = new URLSearchParams();
|
|
659
749
|
params.append("id", fullThingId);
|
|
660
750
|
const response = (await this.makeRequest("/api/del", {
|
|
@@ -675,17 +765,19 @@ var RedditClient = class {
|
|
|
675
765
|
return classifyRedditError(error);
|
|
676
766
|
});
|
|
677
767
|
}
|
|
768
|
+
async deletePost(thingId) {
|
|
769
|
+
return this.deleteThing(thingId, "t3");
|
|
770
|
+
}
|
|
678
771
|
async deleteComment(thingId) {
|
|
679
|
-
|
|
680
|
-
return this.deletePost(fullThingId);
|
|
772
|
+
return this.deleteThing(thingId, "t1");
|
|
681
773
|
}
|
|
682
|
-
async
|
|
774
|
+
async editThing(thingId, newText, defaultKind) {
|
|
683
775
|
return (await Try.async(async () => {
|
|
684
776
|
this.validateWriteAccess();
|
|
685
777
|
await this.enforceWriteRateLimit();
|
|
686
778
|
this.checkDuplicateContent(newText);
|
|
687
779
|
const finalText = this.appendBotDisclosure(newText);
|
|
688
|
-
const fullThingId = thingId
|
|
780
|
+
const fullThingId = normalizeFullname(thingId, defaultKind);
|
|
689
781
|
const params = new URLSearchParams();
|
|
690
782
|
params.append("thing_id", fullThingId);
|
|
691
783
|
params.append("text", finalText);
|
|
@@ -701,13 +793,14 @@ var RedditClient = class {
|
|
|
701
793
|
return true;
|
|
702
794
|
})).toEither((error) => classifyRedditError(error));
|
|
703
795
|
}
|
|
796
|
+
async editPost(thingId, newText) {
|
|
797
|
+
return this.editThing(thingId, newText, "t3");
|
|
798
|
+
}
|
|
704
799
|
async editComment(thingId, newText) {
|
|
705
|
-
|
|
706
|
-
return this.editPost(fullThingId, newText);
|
|
800
|
+
return this.editThing(thingId, newText, "t1");
|
|
707
801
|
}
|
|
708
802
|
async searchReddit(query, options = {}) {
|
|
709
803
|
const { subreddit, sort = "relevance", timeFilter = "all", limit = 25, type = "link", after, before } = options;
|
|
710
|
-
const endpoint = Option(subreddit).fold(() => "/search.json", (sr) => `/r/${sr}/search.json`);
|
|
711
804
|
const params = new URLSearchParams({
|
|
712
805
|
q: query,
|
|
713
806
|
sort,
|
|
@@ -720,6 +813,7 @@ var RedditClient = class {
|
|
|
720
813
|
});
|
|
721
814
|
const context = `Failed to search Reddit for: ${query}`;
|
|
722
815
|
return (await Try.async(async () => {
|
|
816
|
+
const endpoint = Option(subreddit).fold(() => "/search.json", (sr) => `/r/${normalizeSubreddit(sr)}/search.json`);
|
|
723
817
|
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
724
818
|
if (!response.ok) throw new HttpError(response.status, `Failed to search Reddit: HTTP ${response.status}`);
|
|
725
819
|
const json = await response.json();
|
|
@@ -737,7 +831,7 @@ var RedditClient = class {
|
|
|
737
831
|
});
|
|
738
832
|
const context = `Failed to get comments for post ${postId}`;
|
|
739
833
|
return (await Try.async(async () => {
|
|
740
|
-
const response = (await this.makeRequest(`/r/${subreddit}/comments/${postId}.json?${params}`)).orThrow();
|
|
834
|
+
const response = (await this.makeRequest(`/r/${normalizeSubreddit(subreddit)}/comments/${normalizeThingId(postId)}.json?${params}`)).orThrow();
|
|
741
835
|
if (!response.ok) throw new HttpError(response.status, `Failed to get comments: HTTP ${response.status}`);
|
|
742
836
|
const json = await response.json();
|
|
743
837
|
const postData = json[0].data.children[0].data;
|
|
@@ -769,15 +863,14 @@ var RedditClient = class {
|
|
|
769
863
|
})).toEither((error) => classifyRedditError(error, context));
|
|
770
864
|
}
|
|
771
865
|
async getMoreComments(linkId, commentIds) {
|
|
772
|
-
const
|
|
773
|
-
const context = `Failed to expand comments for ${fullLinkId}`;
|
|
774
|
-
const params = new URLSearchParams({
|
|
775
|
-
api_type: "json",
|
|
776
|
-
link_id: fullLinkId,
|
|
777
|
-
children: commentIds.join(",")
|
|
778
|
-
});
|
|
866
|
+
const context = `Failed to expand comments for ${linkId}`;
|
|
779
867
|
return (await Try.async(async () => {
|
|
780
868
|
var _json$json$data4;
|
|
869
|
+
const params = new URLSearchParams({
|
|
870
|
+
api_type: "json",
|
|
871
|
+
link_id: normalizeFullname(linkId, "t3"),
|
|
872
|
+
children: commentIds.map((id) => normalizeThingId(id)).join(",")
|
|
873
|
+
});
|
|
781
874
|
const response = (await this.makeRequest(`/api/morechildren?${params}`)).orThrow();
|
|
782
875
|
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
783
876
|
return (((_json$json$data4 = (await response.json()).json.data) === null || _json$json$data4 === void 0 ? void 0 : _json$json$data4.things) ?? []).filter((thing) => thing.kind === "t1" && thing.data.body !== void 0).map((thing) => {
|
|
@@ -809,7 +902,7 @@ var RedditClient = class {
|
|
|
809
902
|
if (after !== void 0) params.set("after", after);
|
|
810
903
|
const context = `Failed to get posts for user ${username}`;
|
|
811
904
|
return (await Try.async(async () => {
|
|
812
|
-
const response = (await this.makeRequest(`/user/${username}/submitted.json?${params}`)).orThrow();
|
|
905
|
+
const response = (await this.makeRequest(`/user/${normalizeUsername(username)}/submitted.json?${params}`)).orThrow();
|
|
813
906
|
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
814
907
|
const json = await response.json();
|
|
815
908
|
return {
|
|
@@ -828,7 +921,7 @@ var RedditClient = class {
|
|
|
828
921
|
if (after !== void 0) params.set("after", after);
|
|
829
922
|
const context = `Failed to get comments for user ${username}`;
|
|
830
923
|
return (await Try.async(async () => {
|
|
831
|
-
const response = (await this.makeRequest(`/user/${username}/comments.json?${params}`)).orThrow();
|
|
924
|
+
const response = (await this.makeRequest(`/user/${normalizeUsername(username)}/comments.json?${params}`)).orThrow();
|
|
832
925
|
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
833
926
|
const json = await response.json();
|
|
834
927
|
return {
|
|
@@ -872,7 +965,7 @@ function formatTimestamp(timestamp) {
|
|
|
872
965
|
function analyzeUserActivity(karmaRatio, isMod, accountAgeDays) {
|
|
873
966
|
return [
|
|
874
967
|
...karmaRatio > 5 ? ["Primarily a commenter, highly engaged in discussions"] : karmaRatio < .2 ? ["Content creator, focuses on sharing posts"] : ["Balanced participation in both posting and commenting"],
|
|
875
|
-
...accountAgeDays < 30 ? ["New user, still exploring Reddit"] : accountAgeDays >
|
|
968
|
+
...accountAgeDays < 30 ? ["New user, still exploring Reddit"] : accountAgeDays > 1825 ? ["Long-time Redditor with extensive platform experience"] : [],
|
|
876
969
|
...isMod ? ["Community leader who helps maintain subreddit quality"] : []
|
|
877
970
|
].join("\n - ");
|
|
878
971
|
}
|
|
@@ -882,7 +975,7 @@ function analyzePostEngagement(score, ratio, numComments) {
|
|
|
882
975
|
function analyzeSubredditHealth(subscribers, activeUsers, ageDays) {
|
|
883
976
|
const sizeInsights = subscribers > 1e6 ? ["Major subreddit with massive following"] : subscribers > 1e5 ? ["Well-established community"] : subscribers < 1e3 ? ["Niche community, potential for growth"] : [];
|
|
884
977
|
const activityInsights = activeUsers.map((active) => active / subscribers).fold(() => [], (activityRatio) => activityRatio > .1 ? ["Highly active community with strong engagement"] : activityRatio < .01 ? ["Could benefit from more community engagement initiatives"] : []);
|
|
885
|
-
const ageInsights = ageDays >
|
|
978
|
+
const ageInsights = ageDays > 1825 ? ["Mature subreddit with established culture"] : ageDays < 90 ? ["New subreddit still forming its community"] : [];
|
|
886
979
|
return [
|
|
887
980
|
...sizeInsights,
|
|
888
981
|
...activityInsights,
|
|
@@ -910,7 +1003,7 @@ function getSubredditEngagementTips(subreddit) {
|
|
|
910
1003
|
return allTips.length > 0 ? allTips.join("\n - ") : "Regular engagement recommended to maintain community presence";
|
|
911
1004
|
}
|
|
912
1005
|
function formatUserInfo(user) {
|
|
913
|
-
const accountAgeDays = (Date.now() / 1e3 - user.createdUtc) /
|
|
1006
|
+
const accountAgeDays = (Date.now() / 1e3 - user.createdUtc) / 86400;
|
|
914
1007
|
const karmaRatio = user.commentKarma / (user.linkKarma === 0 ? 1 : user.linkKarma);
|
|
915
1008
|
const status = [
|
|
916
1009
|
...user.isMod ? ["Moderator"] : [],
|
|
@@ -965,7 +1058,7 @@ function formatPostInfo(post) {
|
|
|
965
1058
|
}
|
|
966
1059
|
function formatSubredditInfo(subreddit) {
|
|
967
1060
|
const flags = [...subreddit.over18 ? ["NSFW"] : [], ...Option(subreddit.subredditType).fold(() => [], (type) => [`Type: ${type}`])];
|
|
968
|
-
const ageDays = (Date.now() / 1e3 - subreddit.createdUtc) /
|
|
1061
|
+
const ageDays = (Date.now() / 1e3 - subreddit.createdUtc) / 86400;
|
|
969
1062
|
return {
|
|
970
1063
|
name: subreddit.displayName,
|
|
971
1064
|
title: subreddit.title,
|
|
@@ -992,7 +1085,7 @@ function formatSubredditInfo(subreddit) {
|
|
|
992
1085
|
//#endregion
|
|
993
1086
|
//#region src/index.ts
|
|
994
1087
|
dotenv.config({ quiet: true });
|
|
995
|
-
const VERSION = "1.5.
|
|
1088
|
+
const VERSION = "1.5.2";
|
|
996
1089
|
function validateUserAgent(userAgent, username) {
|
|
997
1090
|
if (!/^[\w-]+:[\w-]+:[\d.]+ \(by \/u\/\w+\)$/.test(userAgent)) {
|
|
998
1091
|
console.error("[Warning] User-Agent does not follow Reddit's recommended format");
|
|
@@ -1193,7 +1286,12 @@ For details: https://support.reddithelp.com/hc/en-us/articles/42728983564564-Res
|
|
|
1193
1286
|
});
|
|
1194
1287
|
server.addTool({
|
|
1195
1288
|
name: "test_reddit_mcp_server",
|
|
1196
|
-
description: "
|
|
1289
|
+
description: "Health check for the Reddit MCP server. Read-only and side-effect-free — inspects local configuration only and makes no Reddit API calls. Returns the server version, whether the Reddit client is initialized, whether OAuth credentials are present, and whether write access (REDDIT_USERNAME/REDDIT_PASSWORD) is configured. Use this first to diagnose setup/auth problems. Do NOT use it to check Reddit's own status or connectivity — it never contacts Reddit. A \"✗ Write Access\" result means the write tools (create_post, reply_to_post, edit_*, delete_*) will fail.",
|
|
1290
|
+
annotations: {
|
|
1291
|
+
title: "Test Reddit MCP Server",
|
|
1292
|
+
readOnlyHint: true,
|
|
1293
|
+
openWorldHint: false
|
|
1294
|
+
},
|
|
1197
1295
|
parameters: z.object({}),
|
|
1198
1296
|
execute: () => {
|
|
1199
1297
|
const client = getRedditClient();
|
|
@@ -1210,8 +1308,13 @@ Ready to handle Reddit API requests!`);
|
|
|
1210
1308
|
});
|
|
1211
1309
|
server.addTool({
|
|
1212
1310
|
name: "get_user_info",
|
|
1213
|
-
description: "Get
|
|
1214
|
-
|
|
1311
|
+
description: "Get a public profile for any Reddit user: comment/post/total karma, account age and status flags, plus a short activity analysis and engagement tips. Read-only; works in anonymous mode. Returns profile stats only — use get_user_posts / get_user_comments for their actual content. Use get_me instead for your own authenticated account; do NOT expect private fields here, as only public data is returned.",
|
|
1312
|
+
annotations: {
|
|
1313
|
+
title: "Get User Info",
|
|
1314
|
+
readOnlyHint: true,
|
|
1315
|
+
openWorldHint: true
|
|
1316
|
+
},
|
|
1317
|
+
parameters: z.object({ username: z.string().describe("The target user's Reddit username, without the u/ prefix (e.g. 'spez', not 'u/spez').") }),
|
|
1215
1318
|
execute: async (args) => {
|
|
1216
1319
|
return (await unwrapClient().getUser(args.username)).fold((err) => {
|
|
1217
1320
|
throw new Error(`Failed to get user info: ${err.message}`);
|
|
@@ -1239,7 +1342,12 @@ server.addTool({
|
|
|
1239
1342
|
});
|
|
1240
1343
|
server.addTool({
|
|
1241
1344
|
name: "get_me",
|
|
1242
|
-
description: "Get the authenticated user's own
|
|
1345
|
+
description: "Get the authenticated user's own profile (karma, account age, status flags). Read-only, but requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD) and fails in anonymous mode. Use this instead of get_user_info when you need the current account rather than an arbitrary user. Do NOT use it to look up other users — it always returns the logged-in account.",
|
|
1346
|
+
annotations: {
|
|
1347
|
+
title: "Get My Account",
|
|
1348
|
+
readOnlyHint: true,
|
|
1349
|
+
openWorldHint: true
|
|
1350
|
+
},
|
|
1243
1351
|
parameters: z.object({}),
|
|
1244
1352
|
execute: async () => {
|
|
1245
1353
|
return (await unwrapClient().getMe()).fold((err) => {
|
|
@@ -1262,10 +1370,15 @@ server.addTool({
|
|
|
1262
1370
|
});
|
|
1263
1371
|
server.addTool({
|
|
1264
1372
|
name: "get_my_overview",
|
|
1265
|
-
description: "Get
|
|
1373
|
+
description: "Get the authenticated user's own recent activity — posts and comments interleaved, newest first. Read-only but requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD). Returns up to `limit` items plus an `after` cursor for the next page. Use get_my_saved for saved items, or get_user_posts / get_user_comments for another user. Do NOT use this to fetch a specific post's thread — use get_post_comments.",
|
|
1374
|
+
annotations: {
|
|
1375
|
+
title: "Get My Overview",
|
|
1376
|
+
readOnlyHint: true,
|
|
1377
|
+
openWorldHint: true
|
|
1378
|
+
},
|
|
1266
1379
|
parameters: z.object({
|
|
1267
|
-
limit: z.number().min(1).max(100).default(25).describe("
|
|
1268
|
-
after: z.string().optional().describe("
|
|
1380
|
+
limit: z.number().min(1).max(100).default(25).describe("How many activity items to return, 1–100 (default 25)."),
|
|
1381
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value returned by a previous call. Omit for the first page.")
|
|
1269
1382
|
}),
|
|
1270
1383
|
execute: async (args) => {
|
|
1271
1384
|
return (await unwrapClient().getMyOverview({
|
|
@@ -1278,10 +1391,15 @@ server.addTool({
|
|
|
1278
1391
|
});
|
|
1279
1392
|
server.addTool({
|
|
1280
1393
|
name: "get_my_saved",
|
|
1281
|
-
description: "Get
|
|
1394
|
+
description: "Get the authenticated user's saved posts and comments (private to the account). Read-only but requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD). Returns up to `limit` items plus an `after` pagination cursor. Use get_my_overview for your authored activity. Do NOT use this for another user — saved items are private and have no cross-user equivalent.",
|
|
1395
|
+
annotations: {
|
|
1396
|
+
title: "Get My Saved",
|
|
1397
|
+
readOnlyHint: true,
|
|
1398
|
+
openWorldHint: true
|
|
1399
|
+
},
|
|
1282
1400
|
parameters: z.object({
|
|
1283
|
-
limit: z.number().min(1).max(100).default(25).describe("
|
|
1284
|
-
after: z.string().optional().describe("
|
|
1401
|
+
limit: z.number().min(1).max(100).default(25).describe("How many saved items to return, 1–100 (default 25)."),
|
|
1402
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value returned by a previous call. Omit for the first page.")
|
|
1285
1403
|
}),
|
|
1286
1404
|
execute: async (args) => {
|
|
1287
1405
|
return (await unwrapClient().getMySaved({
|
|
@@ -1294,14 +1412,19 @@ server.addTool({
|
|
|
1294
1412
|
});
|
|
1295
1413
|
server.addTool({
|
|
1296
1414
|
name: "get_user_posts",
|
|
1297
|
-
description: "Get
|
|
1415
|
+
description: "Get posts submitted by a specific user, with sort (new/hot/top) and time filter. Read-only; works anonymously. Returns a page of posts (title, subreddit, score, upvote ratio, comment count, permalink) plus an `after` cursor for paging. Use get_user_comments for their comments, or get_user_info for karma/profile stats. Do NOT use this to search a subreddit — use search_reddit or browse_subreddit.",
|
|
1416
|
+
annotations: {
|
|
1417
|
+
title: "Get User Posts",
|
|
1418
|
+
readOnlyHint: true,
|
|
1419
|
+
openWorldHint: true
|
|
1420
|
+
},
|
|
1298
1421
|
parameters: z.object({
|
|
1299
|
-
username: z.string().describe("The Reddit username
|
|
1422
|
+
username: z.string().describe("The author's Reddit username, without the u/ prefix (e.g. 'spez')."),
|
|
1300
1423
|
sort: z.enum([
|
|
1301
1424
|
"new",
|
|
1302
1425
|
"hot",
|
|
1303
1426
|
"top"
|
|
1304
|
-
]).default("new").describe("
|
|
1427
|
+
]).default("new").describe("Ordering: 'new' (most recent), 'hot' (currently active), or 'top' (highest score within `time_filter`). Default 'new'."),
|
|
1305
1428
|
time_filter: z.enum([
|
|
1306
1429
|
"hour",
|
|
1307
1430
|
"day",
|
|
@@ -1309,9 +1432,9 @@ server.addTool({
|
|
|
1309
1432
|
"month",
|
|
1310
1433
|
"year",
|
|
1311
1434
|
"all"
|
|
1312
|
-
]).default("all").describe("Time
|
|
1313
|
-
limit: z.number().min(1).max(100).default(10).describe("
|
|
1314
|
-
after: z.string().optional().describe("
|
|
1435
|
+
]).default("all").describe("Time window for scoring; only applies when sort='top'. Ignored for 'new'/'hot'. Default 'all'."),
|
|
1436
|
+
limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
|
|
1437
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
|
|
1315
1438
|
}),
|
|
1316
1439
|
execute: async (args) => {
|
|
1317
1440
|
return (await unwrapClient().getUserPosts(args.username, {
|
|
@@ -1341,14 +1464,19 @@ ${postSummaries}${nextPageHint(page.after)}`;
|
|
|
1341
1464
|
});
|
|
1342
1465
|
server.addTool({
|
|
1343
1466
|
name: "get_user_comments",
|
|
1344
|
-
description: "Get
|
|
1467
|
+
description: "Get comments made by a specific user, with sort (new/hot/top) and time filter. Read-only; works anonymously. Returns a page of comments (subreddit, parent post title, body excerpt, score, permalink) plus an `after` cursor. Use get_user_posts for their submissions, or get_user_info for karma/profile stats. Do NOT use this to read one post's thread — use get_post_comments.",
|
|
1468
|
+
annotations: {
|
|
1469
|
+
title: "Get User Comments",
|
|
1470
|
+
readOnlyHint: true,
|
|
1471
|
+
openWorldHint: true
|
|
1472
|
+
},
|
|
1345
1473
|
parameters: z.object({
|
|
1346
|
-
username: z.string().describe("The Reddit username
|
|
1474
|
+
username: z.string().describe("The author's Reddit username, without the u/ prefix (e.g. 'spez')."),
|
|
1347
1475
|
sort: z.enum([
|
|
1348
1476
|
"new",
|
|
1349
1477
|
"hot",
|
|
1350
1478
|
"top"
|
|
1351
|
-
]).default("new").describe("
|
|
1479
|
+
]).default("new").describe("Ordering: 'new' (most recent), 'hot' (currently active), or 'top' (highest score within `time_filter`). Default 'new'."),
|
|
1352
1480
|
time_filter: z.enum([
|
|
1353
1481
|
"hour",
|
|
1354
1482
|
"day",
|
|
@@ -1356,9 +1484,9 @@ server.addTool({
|
|
|
1356
1484
|
"month",
|
|
1357
1485
|
"year",
|
|
1358
1486
|
"all"
|
|
1359
|
-
]).default("all").describe("Time
|
|
1360
|
-
limit: z.number().min(1).max(100).default(10).describe("
|
|
1361
|
-
after: z.string().optional().describe("
|
|
1487
|
+
]).default("all").describe("Time window for scoring; only applies when sort='top'. Ignored for 'new'/'hot'. Default 'all'."),
|
|
1488
|
+
limit: z.number().min(1).max(100).default(10).describe("How many comments to return, 1–100 (default 10)."),
|
|
1489
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
|
|
1362
1490
|
}),
|
|
1363
1491
|
execute: async (args) => {
|
|
1364
1492
|
return (await unwrapClient().getUserComments(args.username, {
|
|
@@ -1391,10 +1519,15 @@ ${commentSummaries}${nextPageHint(page.after)}`;
|
|
|
1391
1519
|
});
|
|
1392
1520
|
server.addTool({
|
|
1393
1521
|
name: "get_reddit_post",
|
|
1394
|
-
description: "Get
|
|
1522
|
+
description: "Get a single post by subreddit + post id: title, author, self-text or link content, score, upvote ratio, comment count, flair/flags, and an engagement analysis. Read-only; works anonymously. Returns the post only — use get_post_comments for its comment thread. Do NOT use this to list a subreddit's posts (use browse_subreddit / get_top_posts) or to find posts by keyword (use search_reddit).",
|
|
1523
|
+
annotations: {
|
|
1524
|
+
title: "Get Reddit Post",
|
|
1525
|
+
readOnlyHint: true,
|
|
1526
|
+
openWorldHint: true
|
|
1527
|
+
},
|
|
1395
1528
|
parameters: z.object({
|
|
1396
|
-
subreddit: z.string().describe("The subreddit
|
|
1397
|
-
post_id: z.string().describe("
|
|
1529
|
+
subreddit: z.string().describe("The subreddit the post lives in, without the r/ prefix (e.g. 'programming')."),
|
|
1530
|
+
post_id: z.string().describe("Base36 post id — the segment after /comments/ in a permalink like reddit.com/r/<sub>/comments/<post_id>/... (e.g. '1abc23'). With or without a t3_ prefix.")
|
|
1398
1531
|
}),
|
|
1399
1532
|
execute: async (args) => {
|
|
1400
1533
|
return (await unwrapClient().getPost(args.post_id, args.subreddit)).fold((err) => {
|
|
@@ -1435,9 +1568,14 @@ ${formattedPost.bestTimeToEngage}`;
|
|
|
1435
1568
|
});
|
|
1436
1569
|
server.addTool({
|
|
1437
1570
|
name: "get_top_posts",
|
|
1438
|
-
description: "Get top posts from a subreddit or from the
|
|
1571
|
+
description: "Get the top-scoring posts from a subreddit — or from the authenticated home feed if no subreddit is given — within a time window (hour…all). Read-only; works anonymously. Returns a page of posts (title, author, score, upvote ratio, comments, link) plus an `after` cursor. This is a shortcut for the 'top' sort; use browse_subreddit for hot/new/rising/controversial, or search_reddit to find posts by keyword.",
|
|
1572
|
+
annotations: {
|
|
1573
|
+
title: "Get Top Posts",
|
|
1574
|
+
readOnlyHint: true,
|
|
1575
|
+
openWorldHint: true
|
|
1576
|
+
},
|
|
1439
1577
|
parameters: z.object({
|
|
1440
|
-
subreddit: z.string().optional().describe("
|
|
1578
|
+
subreddit: z.string().optional().describe("Subreddit to read, without the r/ prefix (e.g. 'science'). Omit to use the authenticated home feed (requires credentials)."),
|
|
1441
1579
|
time_filter: z.enum([
|
|
1442
1580
|
"hour",
|
|
1443
1581
|
"day",
|
|
@@ -1445,9 +1583,9 @@ server.addTool({
|
|
|
1445
1583
|
"month",
|
|
1446
1584
|
"year",
|
|
1447
1585
|
"all"
|
|
1448
|
-
]).default("week").describe("Time
|
|
1449
|
-
limit: z.number().min(1).max(100).default(10).describe("
|
|
1450
|
-
after: z.string().optional().describe("
|
|
1586
|
+
]).default("week").describe("Time window the 'top' ranking is computed over (e.g. 'day' = top today). Default 'week'."),
|
|
1587
|
+
limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
|
|
1588
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
|
|
1451
1589
|
}),
|
|
1452
1590
|
execute: async (args) => {
|
|
1453
1591
|
return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit, args.after)).fold((err) => {
|
|
@@ -1469,16 +1607,21 @@ ${postSummaries}${nextPageHint(page.after)}`;
|
|
|
1469
1607
|
});
|
|
1470
1608
|
server.addTool({
|
|
1471
1609
|
name: "browse_subreddit",
|
|
1472
|
-
description: "Browse
|
|
1610
|
+
description: "Browse a subreddit — or the authenticated home feed when no subreddit is given — by sort order: hot, new, top, rising, or controversial. Read-only; works anonymously. `time_filter` applies only to the top and controversial sorts. Returns a page of posts (title, author, score, upvote ratio, comments, link) plus an `after` cursor. Use get_top_posts as a shortcut for the top sort, or search_reddit to find posts by keyword rather than by feed order.",
|
|
1611
|
+
annotations: {
|
|
1612
|
+
title: "Browse Subreddit",
|
|
1613
|
+
readOnlyHint: true,
|
|
1614
|
+
openWorldHint: true
|
|
1615
|
+
},
|
|
1473
1616
|
parameters: z.object({
|
|
1474
|
-
subreddit: z.string().optional().describe("
|
|
1617
|
+
subreddit: z.string().optional().describe("Subreddit to browse, without the r/ prefix (e.g. 'news'). Omit to use the authenticated home feed (requires credentials)."),
|
|
1475
1618
|
sort: z.enum([
|
|
1476
1619
|
"hot",
|
|
1477
1620
|
"new",
|
|
1478
1621
|
"top",
|
|
1479
1622
|
"rising",
|
|
1480
1623
|
"controversial"
|
|
1481
|
-
]).default("hot").describe("
|
|
1624
|
+
]).default("hot").describe("Feed ordering: 'hot' (default), 'new', 'rising', 'top', or 'controversial'. 'top'/'controversial' honor `time_filter`."),
|
|
1482
1625
|
time_filter: z.enum([
|
|
1483
1626
|
"hour",
|
|
1484
1627
|
"day",
|
|
@@ -1486,9 +1629,9 @@ server.addTool({
|
|
|
1486
1629
|
"month",
|
|
1487
1630
|
"year",
|
|
1488
1631
|
"all"
|
|
1489
|
-
]).default("week").describe("Time
|
|
1490
|
-
limit: z.number().min(1).max(100).default(10).describe("
|
|
1491
|
-
after: z.string().optional().describe("
|
|
1632
|
+
]).default("week").describe("Time window; only applies to sort='top' or 'controversial'. Ignored otherwise. Default 'week'."),
|
|
1633
|
+
limit: z.number().min(1).max(100).default(10).describe("How many posts to return, 1–100 (default 10)."),
|
|
1634
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
|
|
1492
1635
|
}),
|
|
1493
1636
|
execute: async (args) => {
|
|
1494
1637
|
return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit, args.after)).fold((err) => {
|
|
@@ -1513,8 +1656,13 @@ ${postSummaries}${nextPageHint(page.after)}`;
|
|
|
1513
1656
|
});
|
|
1514
1657
|
server.addTool({
|
|
1515
1658
|
name: "get_subreddit_info",
|
|
1516
|
-
description: "Get
|
|
1517
|
-
|
|
1659
|
+
description: "Get a subreddit's profile: title, description, subscriber and active-user counts, creation date, flags, wiki/link URLs, plus a community analysis and posting tips. Read-only; works anonymously. Returns metadata about the community itself — use browse_subreddit / get_top_posts for its posts, or get_subreddit_rules for its posting rules. Do NOT use this to find subreddits by topic — use search_reddit with type='sr'.",
|
|
1660
|
+
annotations: {
|
|
1661
|
+
title: "Get Subreddit Info",
|
|
1662
|
+
readOnlyHint: true,
|
|
1663
|
+
openWorldHint: true
|
|
1664
|
+
},
|
|
1665
|
+
parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'askscience').") }),
|
|
1518
1666
|
execute: async (args) => {
|
|
1519
1667
|
return (await unwrapClient().getSubredditInfo(args.subreddit_name)).fold((err) => {
|
|
1520
1668
|
throw new Error(`Failed to get subreddit info: ${err.message}`);
|
|
@@ -1552,8 +1700,13 @@ ${formattedSubreddit.description.full}
|
|
|
1552
1700
|
});
|
|
1553
1701
|
server.addTool({
|
|
1554
1702
|
name: "get_subreddit_rules",
|
|
1555
|
-
description: "Get a subreddit's posting rules
|
|
1556
|
-
|
|
1703
|
+
description: "Get a subreddit's posting rules (each rule's name, what it applies to, and its description). Read-only; works anonymously. Returns the rules list, or a note when the subreddit lists none. Call this before create_post to check requirements and avoid auto-removal. For available post flairs use get_post_flairs instead.",
|
|
1704
|
+
annotations: {
|
|
1705
|
+
title: "Get Subreddit Rules",
|
|
1706
|
+
readOnlyHint: true,
|
|
1707
|
+
openWorldHint: true
|
|
1708
|
+
},
|
|
1709
|
+
parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'AskReddit').") }),
|
|
1557
1710
|
execute: async (args) => {
|
|
1558
1711
|
return (await unwrapClient().getSubredditRules(args.subreddit_name)).fold((err) => {
|
|
1559
1712
|
throw new Error(`Failed to get subreddit rules: ${err.message}`);
|
|
@@ -1572,8 +1725,13 @@ ${ruleList}`;
|
|
|
1572
1725
|
});
|
|
1573
1726
|
server.addTool({
|
|
1574
1727
|
name: "get_post_flairs",
|
|
1575
|
-
description: "List
|
|
1576
|
-
|
|
1728
|
+
description: "List a subreddit's selectable link flairs (flair text + flair_id) for use with create_post. Read-only, but requires user credentials; many subreddits expose flairs only to members, so this can 403 or return empty anonymously. Pass a returned flair_id (and flair_text for text-editable flairs) to create_post. For the subreddit's posting rules use get_subreddit_rules instead.",
|
|
1729
|
+
annotations: {
|
|
1730
|
+
title: "Get Post Flairs",
|
|
1731
|
+
readOnlyHint: true,
|
|
1732
|
+
openWorldHint: true
|
|
1733
|
+
},
|
|
1734
|
+
parameters: z.object({ subreddit_name: z.string().describe("The subreddit name, without the r/ prefix (e.g. 'gadgets').") }),
|
|
1577
1735
|
execute: async (args) => {
|
|
1578
1736
|
return (await unwrapClient().getPostFlairs(args.subreddit_name)).fold((err) => {
|
|
1579
1737
|
throw new Error(`Failed to get post flairs: ${err.message}`);
|
|
@@ -1593,7 +1751,12 @@ Pass the desired \`flair_id\` to \`create_post\`.`;
|
|
|
1593
1751
|
});
|
|
1594
1752
|
server.addTool({
|
|
1595
1753
|
name: "get_trending_subreddits",
|
|
1596
|
-
description: "Get a list of
|
|
1754
|
+
description: "Get the subreddits Reddit is currently featuring as trending/popular. Read-only, no parameters; works anonymously. Returns a list of subreddit names that changes through the day (cached briefly server-side). To find subreddits by keyword instead of by trend, use search_reddit with type='sr'.",
|
|
1755
|
+
annotations: {
|
|
1756
|
+
title: "Get Trending Subreddits",
|
|
1757
|
+
readOnlyHint: true,
|
|
1758
|
+
openWorldHint: true
|
|
1759
|
+
},
|
|
1597
1760
|
parameters: z.object({}),
|
|
1598
1761
|
execute: async () => {
|
|
1599
1762
|
return (await unwrapClient().getTrendingSubreddits()).fold((err) => {
|
|
@@ -1605,10 +1768,15 @@ ${trendingSubreddits.map((subreddit, index) => `${index + 1}. r/${subreddit}`).j
|
|
|
1605
1768
|
});
|
|
1606
1769
|
server.addTool({
|
|
1607
1770
|
name: "search_reddit",
|
|
1608
|
-
description: "Search Reddit for posts and content
|
|
1771
|
+
description: "Search Reddit for posts — or subreddits/users via `type` — optionally scoped to one subreddit, with sort and time filters. Read-only; works anonymously. Returns a page of results (title, subreddit, author, score, comments, link) plus an `after` cursor for paging. Use this to find content by keyword; use browse_subreddit / get_top_posts to list a known subreddit's feed instead.",
|
|
1772
|
+
annotations: {
|
|
1773
|
+
title: "Search Reddit",
|
|
1774
|
+
readOnlyHint: true,
|
|
1775
|
+
openWorldHint: true
|
|
1776
|
+
},
|
|
1609
1777
|
parameters: z.object({
|
|
1610
|
-
query: z.string().describe("Search
|
|
1611
|
-
subreddit: z.string().optional().describe("
|
|
1778
|
+
query: z.string().describe("Search terms; supports Reddit operators (quotes for exact phrases, author:name, self:yes). Must be non-empty."),
|
|
1779
|
+
subreddit: z.string().optional().describe("Restrict results to this subreddit, without the r/ prefix (e.g. 'python'). Omit to search all of Reddit."),
|
|
1612
1780
|
sort: z.enum([
|
|
1613
1781
|
"relevance",
|
|
1614
1782
|
"hot",
|
|
@@ -1623,14 +1791,14 @@ server.addTool({
|
|
|
1623
1791
|
"month",
|
|
1624
1792
|
"year",
|
|
1625
1793
|
"all"
|
|
1626
|
-
]).default("all").describe("
|
|
1627
|
-
limit: z.number().min(1).max(100).default(10).describe("
|
|
1794
|
+
]).default("all").describe("Restrict to results from this recent window (e.g. 'week'). Default 'all' (no time limit)."),
|
|
1795
|
+
limit: z.number().min(1).max(100).default(10).describe("How many results to return, 1–100 (default 10)."),
|
|
1628
1796
|
type: z.enum([
|
|
1629
1797
|
"link",
|
|
1630
1798
|
"sr",
|
|
1631
1799
|
"user"
|
|
1632
|
-
]).default("link").describe("
|
|
1633
|
-
after: z.string().optional().describe("
|
|
1800
|
+
]).default("link").describe("What to search for: 'link' = posts (default), 'sr' = subreddits, 'user' = users."),
|
|
1801
|
+
after: z.string().optional().describe("Forward pagination cursor: the `after` value from a previous call. Omit for the first page.")
|
|
1634
1802
|
}),
|
|
1635
1803
|
execute: async (args) => {
|
|
1636
1804
|
const client = unwrapClient();
|
|
@@ -1671,14 +1839,21 @@ ${searchResults}${nextPageHint(page.after)}`;
|
|
|
1671
1839
|
});
|
|
1672
1840
|
server.addTool({
|
|
1673
1841
|
name: "create_post",
|
|
1674
|
-
description: "Create a new post in a subreddit
|
|
1842
|
+
description: "Create a new text or link post in a subreddit. Mutating and NOT idempotent — each call publishes a separate post. Requires REDDIT_USERNAME and REDDIT_PASSWORD; fails without them. Returns the new post's id and URL. Check get_subreddit_rules and get_post_flairs first, since many subreddits require a flair or reject certain content. WARNING: rapid posting or duplicate content may trigger Reddit's spam detection and account bans — enable REDDIT_SAFE_MODE=standard for rate limiting and duplicate detection.",
|
|
1843
|
+
annotations: {
|
|
1844
|
+
title: "Create Post",
|
|
1845
|
+
readOnlyHint: false,
|
|
1846
|
+
destructiveHint: false,
|
|
1847
|
+
idempotentHint: false,
|
|
1848
|
+
openWorldHint: true
|
|
1849
|
+
},
|
|
1675
1850
|
parameters: z.object({
|
|
1676
|
-
subreddit: z.string().describe("
|
|
1677
|
-
title: z.string().describe("
|
|
1678
|
-
content: z.string().describe("
|
|
1679
|
-
is_self: z.boolean().default(true).describe("
|
|
1680
|
-
flair_id: z.string().optional().describe("Link flair template id
|
|
1681
|
-
flair_text: z.string().optional().describe("Custom flair text, only for flairs whose template is text-editable")
|
|
1851
|
+
subreddit: z.string().describe("Target subreddit, without the r/ prefix (e.g. 'test')."),
|
|
1852
|
+
title: z.string().describe("Post title (cannot be edited after creation)."),
|
|
1853
|
+
content: z.string().describe("For a self post (is_self=true): the body text, Reddit markdown supported. For a link post (is_self=false): the destination URL."),
|
|
1854
|
+
is_self: z.boolean().default(true).describe("true = text/self post using `content` as the body (default); false = link post using `content` as the URL."),
|
|
1855
|
+
flair_id: z.string().optional().describe("Link flair template id from get_post_flairs; many subreddits require one or the post is auto-removed."),
|
|
1856
|
+
flair_text: z.string().optional().describe("Custom flair text, allowed only for flairs whose template is text-editable.")
|
|
1682
1857
|
}),
|
|
1683
1858
|
execute: async (args) => {
|
|
1684
1859
|
const client = unwrapClient();
|
|
@@ -1701,10 +1876,17 @@ Your post has been successfully submitted to r/${formattedPost.subreddit}.`;
|
|
|
1701
1876
|
});
|
|
1702
1877
|
server.addTool({
|
|
1703
1878
|
name: "reply_to_post",
|
|
1704
|
-
description: "Post a reply to an existing
|
|
1879
|
+
description: "Post a reply to an existing post or comment. Mutating and NOT idempotent — each call adds a new comment. Requires REDDIT_USERNAME and REDDIT_PASSWORD. The parent is identified by its thing id — t3_ for a post, t1_ for a comment — so this creates both top-level and nested replies. Returns the new comment's id. Use edit_comment to change a reply you already posted. WARNING: rapid or duplicate replies may trigger Reddit's spam detection; enable REDDIT_SAFE_MODE=standard for rate limiting and duplicate detection.",
|
|
1880
|
+
annotations: {
|
|
1881
|
+
title: "Reply to Post or Comment",
|
|
1882
|
+
readOnlyHint: false,
|
|
1883
|
+
destructiveHint: false,
|
|
1884
|
+
idempotentHint: false,
|
|
1885
|
+
openWorldHint: true
|
|
1886
|
+
},
|
|
1705
1887
|
parameters: z.object({
|
|
1706
|
-
post_id: z.string().describe("
|
|
1707
|
-
content: z.string().describe("
|
|
1888
|
+
post_id: z.string().describe("Parent thing id to reply under: t3_<id> for a post (creates a top-level comment) or t1_<id> for a comment (creates a nested reply)."),
|
|
1889
|
+
content: z.string().describe("Reply body text; Reddit markdown supported.")
|
|
1708
1890
|
}),
|
|
1709
1891
|
execute: async (args) => {
|
|
1710
1892
|
const client = unwrapClient();
|
|
@@ -1723,8 +1905,15 @@ Your reply has been successfully posted.`);
|
|
|
1723
1905
|
});
|
|
1724
1906
|
server.addTool({
|
|
1725
1907
|
name: "delete_post",
|
|
1726
|
-
description: "
|
|
1727
|
-
|
|
1908
|
+
description: "Permanently delete one of your own posts. Mutating and destructive but idempotent — deleting an already-deleted post is a no-op. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and only works on posts authored by the authenticated account. Only affects the post you name; use delete_comment for comments. WARNING: this cannot be undone — the content is removed, though the post id remains.",
|
|
1909
|
+
annotations: {
|
|
1910
|
+
title: "Delete Post",
|
|
1911
|
+
readOnlyHint: false,
|
|
1912
|
+
destructiveHint: true,
|
|
1913
|
+
idempotentHint: true,
|
|
1914
|
+
openWorldHint: true
|
|
1915
|
+
},
|
|
1916
|
+
parameters: z.object({ thing_id: z.string().describe("The post to delete: a full thing id 't3_<id>' or just the base36 post id '<id>' (the 't3_' prefix is added automatically). Must be a post you authored.") }),
|
|
1728
1917
|
execute: async (args) => {
|
|
1729
1918
|
const client = unwrapClient();
|
|
1730
1919
|
if (process.env.REDDIT_USERNAME === void 0 || process.env.REDDIT_PASSWORD === void 0) throw new Error("User authentication required. Please set REDDIT_USERNAME and REDDIT_PASSWORD environment variables.");
|
|
@@ -1739,8 +1928,15 @@ The post ${args.thing_id} has been permanently deleted from Reddit.
|
|
|
1739
1928
|
});
|
|
1740
1929
|
server.addTool({
|
|
1741
1930
|
name: "delete_comment",
|
|
1742
|
-
description: "
|
|
1743
|
-
|
|
1931
|
+
description: "Permanently delete one of your own comments. Mutating and destructive but idempotent — deleting an already-deleted comment is a no-op. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and only works on comments authored by the authenticated account. Only affects the comment you name; use delete_post for posts. WARNING: this cannot be undone — the content is removed, though the comment id remains.",
|
|
1932
|
+
annotations: {
|
|
1933
|
+
title: "Delete Comment",
|
|
1934
|
+
readOnlyHint: false,
|
|
1935
|
+
destructiveHint: true,
|
|
1936
|
+
idempotentHint: true,
|
|
1937
|
+
openWorldHint: true
|
|
1938
|
+
},
|
|
1939
|
+
parameters: z.object({ thing_id: z.string().describe("The comment to delete: a full thing id 't1_<id>' or just the base36 comment id '<id>' (the 't1_' prefix is added automatically). Must be a comment you authored.") }),
|
|
1744
1940
|
execute: async (args) => {
|
|
1745
1941
|
const client = unwrapClient();
|
|
1746
1942
|
if (process.env.REDDIT_USERNAME === void 0 || process.env.REDDIT_PASSWORD === void 0) throw new Error("User authentication required. Please set REDDIT_USERNAME and REDDIT_PASSWORD environment variables.");
|
|
@@ -1755,10 +1951,17 @@ The comment ${args.thing_id} has been permanently deleted from Reddit.
|
|
|
1755
1951
|
});
|
|
1756
1952
|
server.addTool({
|
|
1757
1953
|
name: "edit_post",
|
|
1758
|
-
description: "
|
|
1954
|
+
description: "Replace the body text of one of your own self-text posts. Mutating and idempotent (same text → same result); it overwrites the previous body. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and works only on self posts you authored — titles and link posts cannot be edited. Adds an \"edited\" marker. Use create_post to make a new post, or edit_comment for comments. WARNING: rapid edits may trigger spam detection; enable REDDIT_SAFE_MODE for protection.",
|
|
1955
|
+
annotations: {
|
|
1956
|
+
title: "Edit Post",
|
|
1957
|
+
readOnlyHint: false,
|
|
1958
|
+
destructiveHint: true,
|
|
1959
|
+
idempotentHint: true,
|
|
1960
|
+
openWorldHint: true
|
|
1961
|
+
},
|
|
1759
1962
|
parameters: z.object({
|
|
1760
|
-
thing_id: z.string().describe("The full
|
|
1761
|
-
new_text: z.string().describe("
|
|
1963
|
+
thing_id: z.string().describe("The post to edit: a full thing id 't3_<id>' or just the base36 post id '<id>' (the 't3_' prefix is added automatically). Must be a self-text post you authored."),
|
|
1964
|
+
new_text: z.string().describe("Replacement body text; fully overwrites the current body. Reddit markdown supported.")
|
|
1762
1965
|
}),
|
|
1763
1966
|
execute: async (args) => {
|
|
1764
1967
|
const client = unwrapClient();
|
|
@@ -1778,10 +1981,17 @@ The post ${args.thing_id} has been updated with your new content.
|
|
|
1778
1981
|
});
|
|
1779
1982
|
server.addTool({
|
|
1780
1983
|
name: "edit_comment",
|
|
1781
|
-
description: "
|
|
1984
|
+
description: "Replace the text of one of your own comments. Mutating and idempotent (same text → same result); it overwrites the previous content. Requires REDDIT_USERNAME and REDDIT_PASSWORD, and works only on comments you authored. Adds an \"edited\" marker. Use reply_to_post to add a new comment, or edit_post for posts. WARNING: rapid edits may trigger spam detection; enable REDDIT_SAFE_MODE for protection.",
|
|
1985
|
+
annotations: {
|
|
1986
|
+
title: "Edit Comment",
|
|
1987
|
+
readOnlyHint: false,
|
|
1988
|
+
destructiveHint: true,
|
|
1989
|
+
idempotentHint: true,
|
|
1990
|
+
openWorldHint: true
|
|
1991
|
+
},
|
|
1782
1992
|
parameters: z.object({
|
|
1783
|
-
thing_id: z.string().describe("The full
|
|
1784
|
-
new_text: z.string().describe("
|
|
1993
|
+
thing_id: z.string().describe("The comment to edit: a full thing id 't1_<id>' or just the base36 comment id '<id>' (the 't1_' prefix is added automatically). Must be a comment you authored."),
|
|
1994
|
+
new_text: z.string().describe("Replacement comment text; fully overwrites the current content. Reddit markdown supported.")
|
|
1785
1995
|
}),
|
|
1786
1996
|
execute: async (args) => {
|
|
1787
1997
|
const client = unwrapClient();
|
|
@@ -1797,10 +2007,15 @@ The comment ${args.thing_id} has been updated with your new content.
|
|
|
1797
2007
|
});
|
|
1798
2008
|
server.addTool({
|
|
1799
2009
|
name: "get_post_comments",
|
|
1800
|
-
description: "Get
|
|
2010
|
+
description: "Get the comment thread for a post (by post id + subreddit), sorted best/top/new/controversial/old/qa. Read-only; works anonymously. Returns the post header plus threaded comments (author, OP/edited badges, score, body, nesting depth) up to `limit`. Long threads are truncated with 'load more' stubs — expand those with get_more_comments. Use get_reddit_post for just the post body, not the thread.",
|
|
2011
|
+
annotations: {
|
|
2012
|
+
title: "Get Post Comments",
|
|
2013
|
+
readOnlyHint: true,
|
|
2014
|
+
openWorldHint: true
|
|
2015
|
+
},
|
|
1801
2016
|
parameters: z.object({
|
|
1802
|
-
post_id: z.string().describe("
|
|
1803
|
-
subreddit: z.string().describe("The subreddit
|
|
2017
|
+
post_id: z.string().describe("Base36 post id — the segment after /comments/ in a permalink (e.g. '1abc23'). With or without a t3_ prefix."),
|
|
2018
|
+
subreddit: z.string().describe("The subreddit the post lives in, without the r/ prefix (e.g. 'movies')."),
|
|
1804
2019
|
sort: z.enum([
|
|
1805
2020
|
"best",
|
|
1806
2021
|
"top",
|
|
@@ -1808,8 +2023,8 @@ server.addTool({
|
|
|
1808
2023
|
"controversial",
|
|
1809
2024
|
"old",
|
|
1810
2025
|
"qa"
|
|
1811
|
-
]).default("best").describe("Comment
|
|
1812
|
-
limit: z.number().min(1).max(500).default(100).describe("Maximum
|
|
2026
|
+
]).default("best").describe("Comment ordering: 'best' (default), 'top', 'new', 'controversial', 'old', or 'qa' (Q&A)."),
|
|
2027
|
+
limit: z.number().min(1).max(500).default(100).describe("Maximum comments to return, 1–500 (default 100). Deeply nested replies may still be truncated as 'load more' stubs.")
|
|
1813
2028
|
}),
|
|
1814
2029
|
execute: async (args) => {
|
|
1815
2030
|
const client = unwrapClient();
|
|
@@ -1845,10 +2060,15 @@ ${comment.body}
|
|
|
1845
2060
|
});
|
|
1846
2061
|
server.addTool({
|
|
1847
2062
|
name: "get_more_comments",
|
|
1848
|
-
description: "Expand truncated 'load more comments' stubs in a thread. Pass the post's link id and the comment ids from a 'more' node (
|
|
2063
|
+
description: "Expand truncated 'load more comments' stubs in a thread. Read-only; works anonymously. Pass the post's link id and the comment ids from a 'more' node (surfaced by get_post_comments) to fetch those hidden comments; returns the expanded comments (author, body excerpt, score, link). Call get_post_comments first to obtain the thread and its 'more' node ids — do NOT invent ids.",
|
|
2064
|
+
annotations: {
|
|
2065
|
+
title: "Get More Comments",
|
|
2066
|
+
readOnlyHint: true,
|
|
2067
|
+
openWorldHint: true
|
|
2068
|
+
},
|
|
1849
2069
|
parameters: z.object({
|
|
1850
|
-
link_id: z.string().describe("The post
|
|
1851
|
-
comment_ids: z.array(z.string()).min(1).describe("
|
|
2070
|
+
link_id: z.string().describe("The parent post's link id (base36, with or without the t3_ prefix) that the stub belongs to."),
|
|
2071
|
+
comment_ids: z.array(z.string()).min(1).describe("Base36 comment ids to expand, taken from a 'more' node returned by get_post_comments (not arbitrary ids).")
|
|
1852
2072
|
}),
|
|
1853
2073
|
execute: async (args) => {
|
|
1854
2074
|
return (await unwrapClient().getMoreComments(args.link_id, args.comment_ids)).fold((err) => {
|