reddit-mcp-server 1.0.4 → 1.0.6
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/.claude/settings.local.json +13 -1
- package/LICENSE +2 -2
- package/ROADMAP.md +77 -0
- package/dist/bin.js +652 -68
- package/dist/index.js +649 -67
- package/eslint.config.js +2 -0
- package/package.json +10 -7
- package/src/bin.ts +3 -1
- package/src/client/__tests__/reddit-client.test.ts +478 -0
- package/src/client/reddit-client.ts +351 -53
- package/src/index.ts +189 -7
- package/src/tools/__tests__/comment-tools.test.ts +119 -0
- package/src/tools/__tests__/search-tools.test.ts +100 -0
- package/src/tools/__tests__/user-tools.test.ts +258 -0
- package/src/tools/comment-tools.ts +61 -0
- package/src/tools/index.ts +2 -0
- package/src/tools/search-tools.ts +67 -0
- package/src/tools/user-tools.ts +99 -0
- package/src/types.ts +2 -0
- package/src/utils/formatters.ts +17 -0
- package/vitest.config.ts +20 -0
package/src/index.ts
CHANGED
|
@@ -52,10 +52,7 @@ class RedditServer {
|
|
|
52
52
|
|
|
53
53
|
if (!clientId || !clientSecret) {
|
|
54
54
|
// Can't use server logging here as server isn't initialized yet
|
|
55
|
-
//
|
|
56
|
-
console.error(
|
|
57
|
-
"[Error] Missing required Reddit API credentials. Please set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET environment variables.",
|
|
58
|
-
)
|
|
55
|
+
// Exit silently with error code
|
|
59
56
|
process.exit(1)
|
|
60
57
|
}
|
|
61
58
|
|
|
@@ -69,9 +66,9 @@ class RedditServer {
|
|
|
69
66
|
})
|
|
70
67
|
|
|
71
68
|
// Client initialized successfully
|
|
72
|
-
} catch
|
|
69
|
+
} catch {
|
|
73
70
|
// Can't use server logging here as server isn't connected yet
|
|
74
|
-
|
|
71
|
+
// Exit silently with error code
|
|
75
72
|
process.exit(1)
|
|
76
73
|
}
|
|
77
74
|
}
|
|
@@ -217,6 +214,146 @@ class RedditServer {
|
|
|
217
214
|
required: ["post_id", "content"],
|
|
218
215
|
},
|
|
219
216
|
},
|
|
217
|
+
{
|
|
218
|
+
name: "search_reddit",
|
|
219
|
+
description: "Search for posts on Reddit",
|
|
220
|
+
inputSchema: {
|
|
221
|
+
type: "object",
|
|
222
|
+
properties: {
|
|
223
|
+
query: {
|
|
224
|
+
type: "string",
|
|
225
|
+
description: "The search query",
|
|
226
|
+
},
|
|
227
|
+
subreddit: {
|
|
228
|
+
type: "string",
|
|
229
|
+
description: "Search within a specific subreddit (optional)",
|
|
230
|
+
},
|
|
231
|
+
sort: {
|
|
232
|
+
type: "string",
|
|
233
|
+
description: "Sort order: relevance, hot, top, new, comments",
|
|
234
|
+
enum: ["relevance", "hot", "top", "new", "comments"],
|
|
235
|
+
default: "relevance",
|
|
236
|
+
},
|
|
237
|
+
time_filter: {
|
|
238
|
+
type: "string",
|
|
239
|
+
description: "Time filter: hour, day, week, month, year, all",
|
|
240
|
+
enum: ["hour", "day", "week", "month", "year", "all"],
|
|
241
|
+
default: "all",
|
|
242
|
+
},
|
|
243
|
+
limit: {
|
|
244
|
+
type: "number",
|
|
245
|
+
description: "Maximum number of results to return",
|
|
246
|
+
minimum: 1,
|
|
247
|
+
maximum: 100,
|
|
248
|
+
default: 10,
|
|
249
|
+
},
|
|
250
|
+
type: {
|
|
251
|
+
type: "string",
|
|
252
|
+
description: "Type of content: link (posts), sr (subreddits), user (users)",
|
|
253
|
+
enum: ["link", "sr", "user"],
|
|
254
|
+
default: "link",
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
required: ["query"],
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: "get_post_comments",
|
|
262
|
+
description: "Get comments for a specific Reddit post",
|
|
263
|
+
inputSchema: {
|
|
264
|
+
type: "object",
|
|
265
|
+
properties: {
|
|
266
|
+
post_id: {
|
|
267
|
+
type: "string",
|
|
268
|
+
description: "The ID of the post",
|
|
269
|
+
},
|
|
270
|
+
subreddit: {
|
|
271
|
+
type: "string",
|
|
272
|
+
description: "The subreddit where the post is located",
|
|
273
|
+
},
|
|
274
|
+
sort: {
|
|
275
|
+
type: "string",
|
|
276
|
+
description: "Comment sort order: best, top, new, controversial, old, qa",
|
|
277
|
+
enum: ["best", "top", "new", "controversial", "old", "qa"],
|
|
278
|
+
default: "best",
|
|
279
|
+
},
|
|
280
|
+
limit: {
|
|
281
|
+
type: "number",
|
|
282
|
+
description: "Maximum number of comments to load",
|
|
283
|
+
minimum: 1,
|
|
284
|
+
maximum: 500,
|
|
285
|
+
default: 100,
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
required: ["post_id", "subreddit"],
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: "get_user_posts",
|
|
293
|
+
description: "Get posts submitted by a specific user",
|
|
294
|
+
inputSchema: {
|
|
295
|
+
type: "object",
|
|
296
|
+
properties: {
|
|
297
|
+
username: {
|
|
298
|
+
type: "string",
|
|
299
|
+
description: "The username to get posts for",
|
|
300
|
+
},
|
|
301
|
+
sort: {
|
|
302
|
+
type: "string",
|
|
303
|
+
description: "Sort order: new, hot, top, controversial",
|
|
304
|
+
enum: ["new", "hot", "top", "controversial"],
|
|
305
|
+
default: "new",
|
|
306
|
+
},
|
|
307
|
+
time_filter: {
|
|
308
|
+
type: "string",
|
|
309
|
+
description: "Time filter for top/controversial: hour, day, week, month, year, all",
|
|
310
|
+
enum: ["hour", "day", "week", "month", "year", "all"],
|
|
311
|
+
default: "all",
|
|
312
|
+
},
|
|
313
|
+
limit: {
|
|
314
|
+
type: "number",
|
|
315
|
+
description: "Maximum number of posts to return",
|
|
316
|
+
minimum: 1,
|
|
317
|
+
maximum: 100,
|
|
318
|
+
default: 10,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
required: ["username"],
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
name: "get_user_comments",
|
|
326
|
+
description: "Get comments made by a specific user",
|
|
327
|
+
inputSchema: {
|
|
328
|
+
type: "object",
|
|
329
|
+
properties: {
|
|
330
|
+
username: {
|
|
331
|
+
type: "string",
|
|
332
|
+
description: "The username to get comments for",
|
|
333
|
+
},
|
|
334
|
+
sort: {
|
|
335
|
+
type: "string",
|
|
336
|
+
description: "Sort order: new, hot, top, controversial",
|
|
337
|
+
enum: ["new", "hot", "top", "controversial"],
|
|
338
|
+
default: "new",
|
|
339
|
+
},
|
|
340
|
+
time_filter: {
|
|
341
|
+
type: "string",
|
|
342
|
+
description: "Time filter for top/controversial: hour, day, week, month, year, all",
|
|
343
|
+
enum: ["hour", "day", "week", "month", "year", "all"],
|
|
344
|
+
default: "all",
|
|
345
|
+
},
|
|
346
|
+
limit: {
|
|
347
|
+
type: "number",
|
|
348
|
+
description: "Maximum number of comments to return",
|
|
349
|
+
minimum: 1,
|
|
350
|
+
maximum: 100,
|
|
351
|
+
default: 10,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
required: ["username"],
|
|
355
|
+
},
|
|
356
|
+
},
|
|
220
357
|
],
|
|
221
358
|
}))
|
|
222
359
|
|
|
@@ -283,6 +420,48 @@ class RedditServer {
|
|
|
283
420
|
},
|
|
284
421
|
)
|
|
285
422
|
|
|
423
|
+
case "search_reddit":
|
|
424
|
+
return await tools.searchReddit(
|
|
425
|
+
toolParams as {
|
|
426
|
+
query: string
|
|
427
|
+
subreddit?: string
|
|
428
|
+
sort?: string
|
|
429
|
+
time_filter?: string
|
|
430
|
+
limit?: number
|
|
431
|
+
type?: string
|
|
432
|
+
},
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
case "get_post_comments":
|
|
436
|
+
return await tools.getPostComments(
|
|
437
|
+
toolParams as {
|
|
438
|
+
post_id: string
|
|
439
|
+
subreddit: string
|
|
440
|
+
sort?: string
|
|
441
|
+
limit?: number
|
|
442
|
+
},
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
case "get_user_posts":
|
|
446
|
+
return await tools.getUserPosts(
|
|
447
|
+
toolParams as {
|
|
448
|
+
username: string
|
|
449
|
+
sort?: string
|
|
450
|
+
time_filter?: string
|
|
451
|
+
limit?: number
|
|
452
|
+
},
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
case "get_user_comments":
|
|
456
|
+
return await tools.getUserComments(
|
|
457
|
+
toolParams as {
|
|
458
|
+
username: string
|
|
459
|
+
sort?: string
|
|
460
|
+
time_filter?: string
|
|
461
|
+
limit?: number
|
|
462
|
+
},
|
|
463
|
+
)
|
|
464
|
+
|
|
286
465
|
default:
|
|
287
466
|
throw new McpError(ErrorCode.MethodNotFound, `Tool with name ${toolName} not found`)
|
|
288
467
|
}
|
|
@@ -330,7 +509,10 @@ class RedditServer {
|
|
|
330
509
|
// Only run if this is the main module
|
|
331
510
|
if (require.main === module) {
|
|
332
511
|
const server = new RedditServer()
|
|
333
|
-
server.run().catch(
|
|
512
|
+
server.run().catch(() => {
|
|
513
|
+
// Exit silently on error
|
|
514
|
+
process.exit(1)
|
|
515
|
+
})
|
|
334
516
|
}
|
|
335
517
|
|
|
336
518
|
export { RedditServer }
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest"
|
|
2
|
+
import { getPostComments } from "../comment-tools"
|
|
3
|
+
import { getRedditClient } from "../../client/reddit-client"
|
|
4
|
+
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
|
|
5
|
+
|
|
6
|
+
vi.mock("../../client/reddit-client")
|
|
7
|
+
|
|
8
|
+
describe("getPostComments", () => {
|
|
9
|
+
const mockRedditClient = {
|
|
10
|
+
getPostComments: vi.fn(),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks()
|
|
15
|
+
vi.mocked(getRedditClient).mockReturnValue(mockRedditClient as any)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("should fetch post comments with default parameters", async () => {
|
|
19
|
+
const mockData = {
|
|
20
|
+
post: {
|
|
21
|
+
id: "test123",
|
|
22
|
+
title: "Test Post Title",
|
|
23
|
+
author: "testauthor",
|
|
24
|
+
subreddit: "testsubreddit",
|
|
25
|
+
score: 500,
|
|
26
|
+
upvoteRatio: 0.9,
|
|
27
|
+
numComments: 25,
|
|
28
|
+
createdUtc: 1700000000,
|
|
29
|
+
selftext: "This is the post content",
|
|
30
|
+
permalink: "/r/testsubreddit/comments/test123",
|
|
31
|
+
over18: false,
|
|
32
|
+
spoiler: false,
|
|
33
|
+
},
|
|
34
|
+
comments: [
|
|
35
|
+
{
|
|
36
|
+
id: "comment1",
|
|
37
|
+
author: "commenter1",
|
|
38
|
+
body: "This is a comment",
|
|
39
|
+
score: 10,
|
|
40
|
+
createdUtc: 1700000100,
|
|
41
|
+
edited: false,
|
|
42
|
+
isSubmitter: false,
|
|
43
|
+
depth: 0,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "comment2",
|
|
47
|
+
author: "testauthor",
|
|
48
|
+
body: "This is a reply from OP",
|
|
49
|
+
score: 5,
|
|
50
|
+
createdUtc: 1700000200,
|
|
51
|
+
edited: true,
|
|
52
|
+
isSubmitter: true,
|
|
53
|
+
depth: 1,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
mockRedditClient.getPostComments.mockResolvedValue(mockData)
|
|
59
|
+
|
|
60
|
+
const result = await getPostComments({
|
|
61
|
+
post_id: "test123",
|
|
62
|
+
subreddit: "testsubreddit",
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(mockRedditClient.getPostComments).toHaveBeenCalledWith("test123", "testsubreddit", {
|
|
66
|
+
sort: "best",
|
|
67
|
+
limit: 100,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(result.content).toHaveLength(1)
|
|
71
|
+
expect(result.content[0].type).toBe("text")
|
|
72
|
+
expect(result.content[0].text).toContain("Comments for: Test Post Title")
|
|
73
|
+
expect(result.content[0].text).toContain("**u/commenter1**")
|
|
74
|
+
expect(result.content[0].text).toContain("**u/testauthor** **[OP]**")
|
|
75
|
+
expect(result.content[0].text).toContain("└─")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("should fetch comments with custom parameters", async () => {
|
|
79
|
+
mockRedditClient.getPostComments.mockResolvedValue({ post: {}, comments: [] })
|
|
80
|
+
|
|
81
|
+
await getPostComments({
|
|
82
|
+
post_id: "test456",
|
|
83
|
+
subreddit: "programming",
|
|
84
|
+
sort: "controversial",
|
|
85
|
+
limit: 50,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
expect(mockRedditClient.getPostComments).toHaveBeenCalledWith("test456", "programming", {
|
|
89
|
+
sort: "controversial",
|
|
90
|
+
limit: 50,
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("should throw error if Reddit client is not initialized", async () => {
|
|
95
|
+
vi.mocked(getRedditClient).mockReturnValue(null)
|
|
96
|
+
|
|
97
|
+
await expect(getPostComments({ post_id: "test", subreddit: "test" })).rejects.toThrow(
|
|
98
|
+
new McpError(ErrorCode.InternalError, "Reddit client not initialized"),
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("should throw error if required parameters are missing", async () => {
|
|
103
|
+
await expect(getPostComments({ post_id: "", subreddit: "test" })).rejects.toThrow(
|
|
104
|
+
new McpError(ErrorCode.InvalidParams, "post_id and subreddit are required"),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
await expect(getPostComments({ post_id: "test", subreddit: "" })).rejects.toThrow(
|
|
108
|
+
new McpError(ErrorCode.InvalidParams, "post_id and subreddit are required"),
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("should handle API errors", async () => {
|
|
113
|
+
mockRedditClient.getPostComments.mockRejectedValue(new Error("API Error"))
|
|
114
|
+
|
|
115
|
+
await expect(getPostComments({ post_id: "test", subreddit: "test" })).rejects.toThrow(
|
|
116
|
+
new McpError(ErrorCode.InternalError, "Failed to fetch comments: Error: API Error"),
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest"
|
|
2
|
+
import { searchReddit } from "../search-tools"
|
|
3
|
+
import { getRedditClient } from "../../client/reddit-client"
|
|
4
|
+
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
|
|
5
|
+
|
|
6
|
+
vi.mock("../../client/reddit-client")
|
|
7
|
+
|
|
8
|
+
describe("searchReddit", () => {
|
|
9
|
+
const mockRedditClient = {
|
|
10
|
+
searchReddit: vi.fn(),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks()
|
|
15
|
+
vi.mocked(getRedditClient).mockReturnValue(mockRedditClient as any)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("should search Reddit with default parameters", async () => {
|
|
19
|
+
const mockPosts = [
|
|
20
|
+
{
|
|
21
|
+
id: "test1",
|
|
22
|
+
title: "Test Post 1",
|
|
23
|
+
author: "testuser1",
|
|
24
|
+
subreddit: "testsubreddit",
|
|
25
|
+
score: 100,
|
|
26
|
+
upvoteRatio: 0.95,
|
|
27
|
+
numComments: 10,
|
|
28
|
+
createdUtc: 1700000000,
|
|
29
|
+
selftext: "This is a test post",
|
|
30
|
+
permalink: "/r/testsubreddit/comments/test1",
|
|
31
|
+
over18: false,
|
|
32
|
+
spoiler: false,
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
mockRedditClient.searchReddit.mockResolvedValue(mockPosts)
|
|
37
|
+
|
|
38
|
+
const result = await searchReddit({ query: "test query" })
|
|
39
|
+
|
|
40
|
+
expect(mockRedditClient.searchReddit).toHaveBeenCalledWith("test query", {
|
|
41
|
+
subreddit: undefined,
|
|
42
|
+
sort: "relevance",
|
|
43
|
+
timeFilter: "all",
|
|
44
|
+
limit: 10,
|
|
45
|
+
type: "link",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
expect(result.content).toHaveLength(1)
|
|
49
|
+
expect(result.content[0].type).toBe("text")
|
|
50
|
+
expect(result.content[0].text).toContain('Reddit Search Results for: "test query"')
|
|
51
|
+
expect(result.content[0].text).toContain("Test Post 1")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("should search within a specific subreddit", async () => {
|
|
55
|
+
mockRedditClient.searchReddit.mockResolvedValue([])
|
|
56
|
+
|
|
57
|
+
await searchReddit({
|
|
58
|
+
query: "test",
|
|
59
|
+
subreddit: "programming",
|
|
60
|
+
sort: "top",
|
|
61
|
+
time_filter: "week",
|
|
62
|
+
limit: 5,
|
|
63
|
+
type: "link",
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(mockRedditClient.searchReddit).toHaveBeenCalledWith("test", {
|
|
67
|
+
subreddit: "programming",
|
|
68
|
+
sort: "top",
|
|
69
|
+
timeFilter: "week",
|
|
70
|
+
limit: 5,
|
|
71
|
+
type: "link",
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("should throw error if Reddit client is not initialized", async () => {
|
|
76
|
+
vi.mocked(getRedditClient).mockReturnValue(null)
|
|
77
|
+
|
|
78
|
+
await expect(searchReddit({ query: "test" })).rejects.toThrow(
|
|
79
|
+
new McpError(ErrorCode.InternalError, "Reddit client not initialized"),
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("should throw error if query is empty", async () => {
|
|
84
|
+
await expect(searchReddit({ query: "" })).rejects.toThrow(
|
|
85
|
+
new McpError(ErrorCode.InvalidParams, "Search query cannot be empty"),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
await expect(searchReddit({ query: " " })).rejects.toThrow(
|
|
89
|
+
new McpError(ErrorCode.InvalidParams, "Search query cannot be empty"),
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("should handle search errors", async () => {
|
|
94
|
+
mockRedditClient.searchReddit.mockRejectedValue(new Error("API Error"))
|
|
95
|
+
|
|
96
|
+
await expect(searchReddit({ query: "test" })).rejects.toThrow(
|
|
97
|
+
new McpError(ErrorCode.InternalError, "Failed to search Reddit: Error: API Error"),
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
})
|