reddit-mcp-server 1.0.6 → 1.0.7
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/CHANGELOG.md +34 -0
- package/package.json +7 -1
- package/.claude/settings.local.json +0 -35
- package/.env.example +0 -7
- package/.prettierignore +0 -7
- package/.prettierrc +0 -8
- package/CLAUDE.md +0 -104
- package/Dockerfile +0 -17
- package/ROADMAP.md +0 -77
- package/eslint.config.js +0 -76
- package/pnpm-workspace.yaml +0 -2
- package/smithery.yaml +0 -37
- package/src/bin.ts +0 -7
- package/src/client/__tests__/reddit-client.test.ts +0 -478
- package/src/client/reddit-client.ts +0 -638
- package/src/index.ts +0 -518
- package/src/tools/__tests__/comment-tools.test.ts +0 -119
- package/src/tools/__tests__/search-tools.test.ts +0 -100
- package/src/tools/__tests__/user-tools.test.ts +0 -258
- package/src/tools/comment-tools.ts +0 -61
- package/src/tools/index.ts +0 -5
- package/src/tools/post-tools.ts +0 -178
- package/src/tools/search-tools.ts +0 -67
- package/src/tools/subreddit-tools.ts +0 -91
- package/src/tools/user-tools.ts +0 -147
- package/src/types.ts +0 -148
- package/src/utils/formatters.ts +0 -316
- package/tsconfig.json +0 -19
- package/tsup.config.ts +0 -12
- package/vitest.config.ts +0 -20
|
@@ -1,478 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
|
|
2
|
-
import { RedditClient } from "../reddit-client"
|
|
3
|
-
import type { RedditClientConfig } from "../../types"
|
|
4
|
-
|
|
5
|
-
// Store original fetch
|
|
6
|
-
const originalFetch = global.fetch
|
|
7
|
-
|
|
8
|
-
describe("RedditClient", () => {
|
|
9
|
-
let client: RedditClient
|
|
10
|
-
const mockConfig: RedditClientConfig = {
|
|
11
|
-
clientId: "test-client-id",
|
|
12
|
-
clientSecret: "test-client-secret",
|
|
13
|
-
userAgent: "TestApp/1.0.0",
|
|
14
|
-
username: "testuser",
|
|
15
|
-
password: "testpass",
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const mockFetch = vi.fn()
|
|
19
|
-
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
vi.clearAllMocks()
|
|
22
|
-
global.fetch = mockFetch
|
|
23
|
-
client = new RedditClient(mockConfig)
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
afterEach(() => {
|
|
27
|
-
global.fetch = originalFetch
|
|
28
|
-
vi.restoreAllMocks()
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
describe("authenticate", () => {
|
|
32
|
-
it("should authenticate with user credentials", async () => {
|
|
33
|
-
const mockTokenResponse = {
|
|
34
|
-
access_token: "test-token",
|
|
35
|
-
expires_in: 3600,
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
mockFetch.mockResolvedValueOnce({
|
|
39
|
-
ok: true,
|
|
40
|
-
json: async () => mockTokenResponse,
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
await client.authenticate()
|
|
44
|
-
|
|
45
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
46
|
-
"https://www.reddit.com/api/v1/access_token",
|
|
47
|
-
expect.objectContaining({
|
|
48
|
-
method: "POST",
|
|
49
|
-
headers: expect.objectContaining({
|
|
50
|
-
"User-Agent": mockConfig.userAgent,
|
|
51
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
52
|
-
Authorization: expect.stringContaining("Basic "),
|
|
53
|
-
}),
|
|
54
|
-
body: expect.any(String),
|
|
55
|
-
}),
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
const callArgs = mockFetch.mock.calls[0]
|
|
59
|
-
const body = new URLSearchParams(callArgs[1].body as string)
|
|
60
|
-
expect(body.get("grant_type")).toBe("password")
|
|
61
|
-
expect(body.get("username")).toBe("testuser")
|
|
62
|
-
expect(body.get("password")).toBe("testpass")
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it("should authenticate with client credentials only when no username/password", async () => {
|
|
66
|
-
const configWithoutUser: RedditClientConfig = {
|
|
67
|
-
clientId: "test-client-id",
|
|
68
|
-
clientSecret: "test-client-secret",
|
|
69
|
-
userAgent: "TestApp/1.0.0",
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const clientReadOnly = new RedditClient(configWithoutUser)
|
|
73
|
-
const mockTokenResponse = {
|
|
74
|
-
access_token: "test-token-readonly",
|
|
75
|
-
expires_in: 3600,
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
mockFetch.mockResolvedValueOnce({
|
|
79
|
-
ok: true,
|
|
80
|
-
json: async () => mockTokenResponse,
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
await clientReadOnly.authenticate()
|
|
84
|
-
|
|
85
|
-
const callArgs = mockFetch.mock.calls[0]
|
|
86
|
-
const body = new URLSearchParams(callArgs[1].body as string)
|
|
87
|
-
expect(body.get("grant_type")).toBe("client_credentials")
|
|
88
|
-
expect(body.get("username")).toBeNull()
|
|
89
|
-
expect(body.get("password")).toBeNull()
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it("should not re-authenticate if token is still valid", async () => {
|
|
93
|
-
const mockTokenResponse = {
|
|
94
|
-
access_token: "test-token",
|
|
95
|
-
expires_in: 3600,
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
mockFetch.mockResolvedValueOnce({
|
|
99
|
-
ok: true,
|
|
100
|
-
json: async () => mockTokenResponse,
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
// First authentication
|
|
104
|
-
await client.authenticate()
|
|
105
|
-
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
106
|
-
|
|
107
|
-
// Second authentication should not make another request
|
|
108
|
-
await client.authenticate()
|
|
109
|
-
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
it("should throw error on authentication failure", async () => {
|
|
113
|
-
mockFetch.mockResolvedValueOnce({
|
|
114
|
-
ok: false,
|
|
115
|
-
status: 401,
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
await expect(client.authenticate()).rejects.toThrow("Failed to authenticate with Reddit API")
|
|
119
|
-
})
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
describe("getUser", () => {
|
|
123
|
-
it("should fetch user information", async () => {
|
|
124
|
-
const mockUserData = {
|
|
125
|
-
data: {
|
|
126
|
-
name: "testuser",
|
|
127
|
-
id: "123",
|
|
128
|
-
comment_karma: 100,
|
|
129
|
-
link_karma: 200,
|
|
130
|
-
is_mod: false,
|
|
131
|
-
is_gold: true,
|
|
132
|
-
is_employee: false,
|
|
133
|
-
created_utc: 1234567890,
|
|
134
|
-
},
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Mock authentication
|
|
138
|
-
mockFetch.mockResolvedValueOnce({
|
|
139
|
-
ok: true,
|
|
140
|
-
json: async () => ({ access_token: "test-token", expires_in: 3600 }),
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
// Mock user request
|
|
144
|
-
mockFetch.mockResolvedValueOnce({
|
|
145
|
-
ok: true,
|
|
146
|
-
json: async () => mockUserData,
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
const user = await client.getUser("testuser")
|
|
150
|
-
|
|
151
|
-
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
152
|
-
expect(mockFetch).toHaveBeenLastCalledWith(
|
|
153
|
-
"https://oauth.reddit.com/user/testuser/about.json",
|
|
154
|
-
expect.objectContaining({
|
|
155
|
-
headers: expect.objectContaining({
|
|
156
|
-
Authorization: "Bearer test-token",
|
|
157
|
-
}),
|
|
158
|
-
}),
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
expect(user).toEqual({
|
|
162
|
-
name: "testuser",
|
|
163
|
-
id: "123",
|
|
164
|
-
commentKarma: 100,
|
|
165
|
-
linkKarma: 200,
|
|
166
|
-
totalKarma: 300,
|
|
167
|
-
isMod: false,
|
|
168
|
-
isGold: true,
|
|
169
|
-
isEmployee: false,
|
|
170
|
-
createdUtc: 1234567890,
|
|
171
|
-
profileUrl: "https://reddit.com/user/testuser",
|
|
172
|
-
})
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
it("should throw error when user fetch fails", async () => {
|
|
176
|
-
// Mock authentication
|
|
177
|
-
mockFetch.mockResolvedValueOnce({
|
|
178
|
-
ok: true,
|
|
179
|
-
json: async () => ({ access_token: "test-token", expires_in: 3600 }),
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
// Mock failed user request
|
|
183
|
-
mockFetch.mockResolvedValueOnce({
|
|
184
|
-
ok: false,
|
|
185
|
-
status: 404,
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
await expect(client.getUser("testuser")).rejects.toThrow("Failed to get user info for testuser")
|
|
189
|
-
})
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
describe("getSubredditInfo", () => {
|
|
193
|
-
it("should fetch subreddit information", async () => {
|
|
194
|
-
const mockSubredditData = {
|
|
195
|
-
data: {
|
|
196
|
-
display_name: "programming",
|
|
197
|
-
title: "Programming",
|
|
198
|
-
description: "A subreddit for programming",
|
|
199
|
-
public_description: "Public description",
|
|
200
|
-
subscribers: 1000000,
|
|
201
|
-
active_user_count: 5000,
|
|
202
|
-
created_utc: 1234567890,
|
|
203
|
-
over18: false,
|
|
204
|
-
subreddit_type: "public",
|
|
205
|
-
url: "/r/programming/",
|
|
206
|
-
},
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Mock authentication
|
|
210
|
-
mockFetch.mockResolvedValueOnce({
|
|
211
|
-
ok: true,
|
|
212
|
-
json: async () => ({ access_token: "test-token", expires_in: 3600 }),
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
// Mock subreddit request
|
|
216
|
-
mockFetch.mockResolvedValueOnce({
|
|
217
|
-
ok: true,
|
|
218
|
-
json: async () => mockSubredditData,
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
const subreddit = await client.getSubredditInfo("programming")
|
|
222
|
-
|
|
223
|
-
expect(mockFetch).toHaveBeenLastCalledWith(
|
|
224
|
-
"https://oauth.reddit.com/r/programming/about.json",
|
|
225
|
-
expect.any(Object),
|
|
226
|
-
)
|
|
227
|
-
expect(subreddit.displayName).toBe("programming")
|
|
228
|
-
expect(subreddit.subscribers).toBe(1000000)
|
|
229
|
-
})
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
describe("getTopPosts", () => {
|
|
233
|
-
it("should fetch top posts from a subreddit", async () => {
|
|
234
|
-
const mockPostsData = {
|
|
235
|
-
data: {
|
|
236
|
-
children: [
|
|
237
|
-
{
|
|
238
|
-
data: {
|
|
239
|
-
id: "post1",
|
|
240
|
-
title: "Test Post 1",
|
|
241
|
-
author: "author1",
|
|
242
|
-
subreddit: "programming",
|
|
243
|
-
selftext: "Post content",
|
|
244
|
-
url: "https://reddit.com/r/programming/post1",
|
|
245
|
-
score: 100,
|
|
246
|
-
upvote_ratio: 0.95,
|
|
247
|
-
num_comments: 50,
|
|
248
|
-
created_utc: 1234567890,
|
|
249
|
-
over_18: false,
|
|
250
|
-
spoiler: false,
|
|
251
|
-
edited: false,
|
|
252
|
-
is_self: true,
|
|
253
|
-
link_flair_text: null,
|
|
254
|
-
permalink: "/r/programming/comments/post1/",
|
|
255
|
-
},
|
|
256
|
-
},
|
|
257
|
-
],
|
|
258
|
-
},
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Mock authentication
|
|
262
|
-
mockFetch.mockResolvedValueOnce({
|
|
263
|
-
ok: true,
|
|
264
|
-
json: async () => ({ access_token: "test-token", expires_in: 3600 }),
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
// Mock posts request
|
|
268
|
-
mockFetch.mockResolvedValueOnce({
|
|
269
|
-
ok: true,
|
|
270
|
-
json: async () => mockPostsData,
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
const posts = await client.getTopPosts("programming", "week", 10)
|
|
274
|
-
|
|
275
|
-
expect(mockFetch).toHaveBeenLastCalledWith(
|
|
276
|
-
expect.stringContaining("/r/programming/top.json?"),
|
|
277
|
-
expect.any(Object),
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
const lastCallUrl = mockFetch.mock.calls[mockFetch.mock.calls.length - 1][0]
|
|
281
|
-
expect(lastCallUrl).toContain("t=week")
|
|
282
|
-
expect(lastCallUrl).toContain("limit=10")
|
|
283
|
-
|
|
284
|
-
expect(posts).toHaveLength(1)
|
|
285
|
-
expect(posts[0].id).toBe("post1")
|
|
286
|
-
expect(posts[0].title).toBe("Test Post 1")
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
it("should fetch top posts from home when no subreddit specified", async () => {
|
|
290
|
-
const mockPostsData = {
|
|
291
|
-
data: {
|
|
292
|
-
children: [],
|
|
293
|
-
},
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Mock authentication
|
|
297
|
-
mockFetch.mockResolvedValueOnce({
|
|
298
|
-
ok: true,
|
|
299
|
-
json: async () => ({ access_token: "test-token", expires_in: 3600 }),
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
// Mock posts request
|
|
303
|
-
mockFetch.mockResolvedValueOnce({
|
|
304
|
-
ok: true,
|
|
305
|
-
json: async () => mockPostsData,
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
await client.getTopPosts("", "day", 5)
|
|
309
|
-
|
|
310
|
-
const lastCallUrl = mockFetch.mock.calls[mockFetch.mock.calls.length - 1][0]
|
|
311
|
-
expect(lastCallUrl).toContain("/top.json?")
|
|
312
|
-
expect(lastCallUrl).toContain("t=day")
|
|
313
|
-
expect(lastCallUrl).toContain("limit=5")
|
|
314
|
-
})
|
|
315
|
-
})
|
|
316
|
-
|
|
317
|
-
describe("createPost", () => {
|
|
318
|
-
it("should create a new post", async () => {
|
|
319
|
-
const mockSubmitResponse = {
|
|
320
|
-
success: true,
|
|
321
|
-
data: {
|
|
322
|
-
id: "newpost123",
|
|
323
|
-
},
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const mockPostData = {
|
|
327
|
-
data: {
|
|
328
|
-
children: [
|
|
329
|
-
{
|
|
330
|
-
data: {
|
|
331
|
-
id: "newpost123",
|
|
332
|
-
title: "My New Post",
|
|
333
|
-
author: "testuser",
|
|
334
|
-
subreddit: "test",
|
|
335
|
-
selftext: "Post content",
|
|
336
|
-
url: "https://reddit.com/r/test/newpost123",
|
|
337
|
-
score: 1,
|
|
338
|
-
upvote_ratio: 1,
|
|
339
|
-
num_comments: 0,
|
|
340
|
-
created_utc: Date.now() / 1000,
|
|
341
|
-
over_18: false,
|
|
342
|
-
spoiler: false,
|
|
343
|
-
edited: false,
|
|
344
|
-
is_self: true,
|
|
345
|
-
link_flair_text: null,
|
|
346
|
-
permalink: "/r/test/comments/newpost123/",
|
|
347
|
-
},
|
|
348
|
-
},
|
|
349
|
-
],
|
|
350
|
-
},
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Mock authentication
|
|
354
|
-
mockFetch.mockResolvedValueOnce({
|
|
355
|
-
ok: true,
|
|
356
|
-
json: async () => ({ access_token: "test-token", expires_in: 3600 }),
|
|
357
|
-
})
|
|
358
|
-
|
|
359
|
-
// Mock submit request
|
|
360
|
-
mockFetch.mockResolvedValueOnce({
|
|
361
|
-
ok: true,
|
|
362
|
-
json: async () => mockSubmitResponse,
|
|
363
|
-
})
|
|
364
|
-
|
|
365
|
-
// Mock get post request
|
|
366
|
-
mockFetch.mockResolvedValueOnce({
|
|
367
|
-
ok: true,
|
|
368
|
-
json: async () => mockPostData,
|
|
369
|
-
})
|
|
370
|
-
|
|
371
|
-
const post = await client.createPost("test", "My New Post", "Post content")
|
|
372
|
-
|
|
373
|
-
// Check submit call
|
|
374
|
-
const submitCall = mockFetch.mock.calls[1]
|
|
375
|
-
expect(submitCall[0]).toBe("https://oauth.reddit.com/api/submit")
|
|
376
|
-
expect(submitCall[1].method).toBe("POST")
|
|
377
|
-
|
|
378
|
-
const body = new URLSearchParams(submitCall[1].body as string)
|
|
379
|
-
expect(body.get("sr")).toBe("test")
|
|
380
|
-
expect(body.get("kind")).toBe("self")
|
|
381
|
-
expect(body.get("title")).toBe("My New Post")
|
|
382
|
-
expect(body.get("text")).toBe("Post content")
|
|
383
|
-
|
|
384
|
-
expect(post.id).toBe("newpost123")
|
|
385
|
-
expect(post.title).toBe("My New Post")
|
|
386
|
-
})
|
|
387
|
-
|
|
388
|
-
it("should throw error when user is not authenticated", async () => {
|
|
389
|
-
const clientReadOnly = new RedditClient({
|
|
390
|
-
clientId: "test-client-id",
|
|
391
|
-
clientSecret: "test-client-secret",
|
|
392
|
-
userAgent: "TestApp/1.0.0",
|
|
393
|
-
})
|
|
394
|
-
|
|
395
|
-
// Mock authentication
|
|
396
|
-
mockFetch.mockResolvedValueOnce({
|
|
397
|
-
ok: true,
|
|
398
|
-
json: async () => ({ access_token: "test-token", expires_in: 3600 }),
|
|
399
|
-
})
|
|
400
|
-
|
|
401
|
-
await expect(clientReadOnly.createPost("test", "Title", "Content")).rejects.toThrow(
|
|
402
|
-
"User authentication required for posting",
|
|
403
|
-
)
|
|
404
|
-
})
|
|
405
|
-
})
|
|
406
|
-
|
|
407
|
-
describe("replyToPost", () => {
|
|
408
|
-
it("should reply to an existing post", async () => {
|
|
409
|
-
const mockCheckResponse = {
|
|
410
|
-
data: {
|
|
411
|
-
children: [{ data: { id: "post123" } }],
|
|
412
|
-
},
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const mockCommentResponse = {
|
|
416
|
-
id: "comment123",
|
|
417
|
-
subreddit: "test",
|
|
418
|
-
link_title: "Original Post Title",
|
|
419
|
-
permalink: "/r/test/comments/post123/comment123",
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Mock authentication
|
|
423
|
-
mockFetch.mockResolvedValueOnce({
|
|
424
|
-
ok: true,
|
|
425
|
-
json: async () => ({ access_token: "test-token", expires_in: 3600 }),
|
|
426
|
-
})
|
|
427
|
-
|
|
428
|
-
// Mock check post exists
|
|
429
|
-
mockFetch.mockResolvedValueOnce({
|
|
430
|
-
ok: true,
|
|
431
|
-
json: async () => mockCheckResponse,
|
|
432
|
-
})
|
|
433
|
-
|
|
434
|
-
// Mock comment submission
|
|
435
|
-
mockFetch.mockResolvedValueOnce({
|
|
436
|
-
ok: true,
|
|
437
|
-
json: async () => mockCommentResponse,
|
|
438
|
-
})
|
|
439
|
-
|
|
440
|
-
const comment = await client.replyToPost("post123", "Great post!")
|
|
441
|
-
|
|
442
|
-
// Check the comment submission call
|
|
443
|
-
const commentCall = mockFetch.mock.calls[2]
|
|
444
|
-
expect(commentCall[0]).toBe("https://oauth.reddit.com/api/comment")
|
|
445
|
-
expect(commentCall[1].method).toBe("POST")
|
|
446
|
-
|
|
447
|
-
const body = new URLSearchParams(commentCall[1].body as string)
|
|
448
|
-
expect(body.get("thing_id")).toBe("t3_post123")
|
|
449
|
-
expect(body.get("text")).toBe("Great post!")
|
|
450
|
-
|
|
451
|
-
expect(comment.id).toBe("comment123")
|
|
452
|
-
expect(comment.body).toBe("Great post!")
|
|
453
|
-
expect(comment.author).toBe("testuser")
|
|
454
|
-
})
|
|
455
|
-
|
|
456
|
-
it("should throw error when post does not exist", async () => {
|
|
457
|
-
const mockCheckResponse = {
|
|
458
|
-
data: {
|
|
459
|
-
children: [],
|
|
460
|
-
},
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Mock authentication
|
|
464
|
-
mockFetch.mockResolvedValueOnce({
|
|
465
|
-
ok: true,
|
|
466
|
-
json: async () => ({ access_token: "test-token", expires_in: 3600 }),
|
|
467
|
-
})
|
|
468
|
-
|
|
469
|
-
// Mock check post exists (empty response)
|
|
470
|
-
mockFetch.mockResolvedValueOnce({
|
|
471
|
-
ok: true,
|
|
472
|
-
json: async () => mockCheckResponse,
|
|
473
|
-
})
|
|
474
|
-
|
|
475
|
-
await expect(client.replyToPost("nonexistent", "Comment")).rejects.toThrow("Failed to reply to post nonexistent")
|
|
476
|
-
})
|
|
477
|
-
})
|
|
478
|
-
})
|