@prmichaelsen/reddit-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +23 -0
- package/.env.example +13 -0
- package/AGENT.md +1772 -0
- package/README.md +54 -0
- package/agent/commands/acp.clarification-capture.md +386 -0
- package/agent/commands/acp.clarification-create.md +432 -0
- package/agent/commands/acp.clarifications-research.md +326 -0
- package/agent/commands/acp.command-create.md +432 -0
- package/agent/commands/acp.design-create.md +286 -0
- package/agent/commands/acp.design-reference.md +355 -0
- package/agent/commands/acp.index.md +423 -0
- package/agent/commands/acp.init.md +546 -0
- package/agent/commands/acp.package-create.md +895 -0
- package/agent/commands/acp.package-info.md +212 -0
- package/agent/commands/acp.package-install.md +539 -0
- package/agent/commands/acp.package-list.md +280 -0
- package/agent/commands/acp.package-publish.md +541 -0
- package/agent/commands/acp.package-remove.md +293 -0
- package/agent/commands/acp.package-search.md +307 -0
- package/agent/commands/acp.package-update.md +361 -0
- package/agent/commands/acp.package-validate.md +540 -0
- package/agent/commands/acp.pattern-create.md +386 -0
- package/agent/commands/acp.plan.md +577 -0
- package/agent/commands/acp.proceed.md +882 -0
- package/agent/commands/acp.project-create.md +675 -0
- package/agent/commands/acp.project-info.md +312 -0
- package/agent/commands/acp.project-list.md +226 -0
- package/agent/commands/acp.project-remove.md +379 -0
- package/agent/commands/acp.project-set.md +227 -0
- package/agent/commands/acp.project-update.md +307 -0
- package/agent/commands/acp.projects-restore.md +228 -0
- package/agent/commands/acp.projects-sync.md +347 -0
- package/agent/commands/acp.report.md +407 -0
- package/agent/commands/acp.resume.md +239 -0
- package/agent/commands/acp.sessions.md +301 -0
- package/agent/commands/acp.status.md +293 -0
- package/agent/commands/acp.sync.md +364 -0
- package/agent/commands/acp.task-create.md +500 -0
- package/agent/commands/acp.update.md +302 -0
- package/agent/commands/acp.validate.md +466 -0
- package/agent/commands/acp.version-check-for-updates.md +276 -0
- package/agent/commands/acp.version-check.md +191 -0
- package/agent/commands/acp.version-update.md +289 -0
- package/agent/commands/command.template.md +339 -0
- package/agent/commands/git.commit.md +526 -0
- package/agent/commands/git.init.md +514 -0
- package/agent/design/.gitkeep +0 -0
- package/agent/design/design.template.md +154 -0
- package/agent/design/requirements.md +332 -0
- package/agent/design/requirements.template.md +387 -0
- package/agent/index/.gitkeep +0 -0
- package/agent/index/local.main.template.yaml +37 -0
- package/agent/manifest.template.yaml +13 -0
- package/agent/manifest.yaml +61 -0
- package/agent/milestones/.gitkeep +0 -0
- package/agent/milestones/milestone-1-foundation-listings-mvp.md +140 -0
- package/agent/milestones/milestone-1-{title}.template.md +206 -0
- package/agent/milestones/milestone-2-content-interaction.md +56 -0
- package/agent/milestones/milestone-3-users-and-messaging.md +54 -0
- package/agent/milestones/milestone-4-subreddits-and-flair.md +56 -0
- package/agent/milestones/milestone-5-moderation.md +53 -0
- package/agent/milestones/milestone-6-advanced-features-and-polish.md +56 -0
- package/agent/package.template.yaml +86 -0
- package/agent/patterns/.gitkeep +0 -0
- package/agent/patterns/bootstrap.template.md +1237 -0
- package/agent/patterns/pattern.template.md +382 -0
- package/agent/progress.template.yaml +161 -0
- package/agent/progress.yaml +223 -0
- package/agent/schemas/package.schema.yaml +276 -0
- package/agent/scripts/acp.common.sh +1781 -0
- package/agent/scripts/acp.yaml-parser.sh +985 -0
- package/agent/tasks/.gitkeep +0 -0
- package/agent/tasks/milestone-1-foundation-listings-mvp/task-1-project-scaffolding.md +75 -0
- package/agent/tasks/milestone-1-foundation-listings-mvp/task-2-reddit-oauth.md +71 -0
- package/agent/tasks/milestone-1-foundation-listings-mvp/task-3-reddit-api-client.md +71 -0
- package/agent/tasks/milestone-1-foundation-listings-mvp/task-4-listing-tools.md +65 -0
- package/agent/tasks/milestone-1-foundation-listings-mvp/task-5-search-tools.md +43 -0
- package/agent/tasks/milestone-1-foundation-listings-mvp/task-6-testing-verification.md +49 -0
- package/agent/tasks/milestone-2-content-interaction/task-7-post-tools.md +56 -0
- package/agent/tasks/milestone-2-content-interaction/task-8-comment-tools.md +49 -0
- package/agent/tasks/milestone-2-content-interaction/task-9-vote-save-report-tools.md +50 -0
- package/agent/tasks/milestone-3-users-and-messaging/task-10-account-tools.md +44 -0
- package/agent/tasks/milestone-3-users-and-messaging/task-11-user-profile-tools.md +50 -0
- package/agent/tasks/milestone-3-users-and-messaging/task-12-private-message-tools.md +50 -0
- package/agent/tasks/milestone-4-subreddits-and-flair/task-13-subreddit-tools.md +47 -0
- package/agent/tasks/milestone-4-subreddits-and-flair/task-14-flair-tools.md +46 -0
- package/agent/tasks/milestone-4-subreddits-and-flair/task-15-http-transport.md +53 -0
- package/agent/tasks/milestone-5-moderation/task-16-mod-action-tools.md +48 -0
- package/agent/tasks/milestone-5-moderation/task-17-mod-listing-tools.md +47 -0
- package/agent/tasks/milestone-5-moderation/task-18-mod-management-tools.md +42 -0
- package/agent/tasks/milestone-6-advanced-features-and-polish/task-19-multireddit-tools.md +49 -0
- package/agent/tasks/milestone-6-advanced-features-and-polish/task-20-wiki-tools.md +47 -0
- package/agent/tasks/milestone-6-advanced-features-and-polish/task-21-documentation-polish.md +65 -0
- package/agent/tasks/task-1-{title}.template.md +244 -0
- package/dist/auth/oauth.d.ts +15 -0
- package/dist/auth/oauth.d.ts.map +1 -0
- package/dist/client/reddit.d.ts +28 -0
- package/dist/client/reddit.d.ts.map +1 -0
- package/dist/factory.d.ts +2 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +30394 -0
- package/dist/factory.js.map +7 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31955 -0
- package/dist/index.js.map +7 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +30401 -0
- package/dist/server.js.map +7 -0
- package/dist/tools/listings.d.ts +4 -0
- package/dist/tools/listings.d.ts.map +1 -0
- package/dist/tools/search.d.ts +4 -0
- package/dist/tools/search.d.ts.map +1 -0
- package/dist/transport/http.d.ts +7 -0
- package/dist/transport/http.d.ts.map +1 -0
- package/dist/types/index.d.ts +78 -0
- package/dist/types/index.d.ts.map +1 -0
- package/esbuild.build.js +21 -0
- package/jest.config.js +18 -0
- package/package.json +46 -0
- package/src/auth/oauth.ts +200 -0
- package/src/client/reddit.ts +245 -0
- package/src/factory.ts +5 -0
- package/src/index.ts +31 -0
- package/src/server.ts +36 -0
- package/src/tools/listings.ts +202 -0
- package/src/tools/search.ts +85 -0
- package/src/transport/http.ts +49 -0
- package/src/types/index.ts +83 -0
- package/tests/fixtures/reddit-responses.ts +132 -0
- package/tests/helpers/mock-client.ts +36 -0
- package/tests/unit/auth.test.ts +89 -0
- package/tests/unit/client.test.ts +218 -0
- package/tests/unit/listings.test.ts +113 -0
- package/tests/unit/search.test.ts +59 -0
- package/tests/unit/server.test.ts +14 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"listings.d.ts","sourceRoot":"","sources":["../../src/tools/listings.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAiCxD,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,MAAM,EAAE,YAAY,GACnB,IAAI,CAmKN"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/tools/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAcxD,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,SAAS,EACjB,MAAM,EAAE,YAAY,GACnB,IAAI,CAiEN"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
export interface HttpTransportOptions {
|
|
3
|
+
port: number;
|
|
4
|
+
host: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function startHttpTransport(server: McpServer, options: HttpTransportOptions): Promise<void>;
|
|
7
|
+
//# sourceMappingURL=http.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transport/http.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAEpE,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,SAAS,EACjB,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,IAAI,CAAC,CA+Bf"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface RedditAuthConfig {
|
|
2
|
+
clientId: string;
|
|
3
|
+
clientSecret: string;
|
|
4
|
+
redirectUri: string;
|
|
5
|
+
userAgent: string;
|
|
6
|
+
tokenStoragePath?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface TokenData {
|
|
9
|
+
access_token: string;
|
|
10
|
+
refresh_token: string;
|
|
11
|
+
token_type: string;
|
|
12
|
+
expires_in: number;
|
|
13
|
+
scope: string;
|
|
14
|
+
obtained_at: number;
|
|
15
|
+
}
|
|
16
|
+
export interface RedditApiError {
|
|
17
|
+
status: number;
|
|
18
|
+
reason: string;
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
export interface RedditListing<T> {
|
|
22
|
+
kind: "Listing";
|
|
23
|
+
data: {
|
|
24
|
+
after: string | null;
|
|
25
|
+
before: string | null;
|
|
26
|
+
children: Array<{
|
|
27
|
+
kind: string;
|
|
28
|
+
data: T;
|
|
29
|
+
}>;
|
|
30
|
+
dist: number;
|
|
31
|
+
modhash: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export interface RedditPost {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
title: string;
|
|
38
|
+
author: string;
|
|
39
|
+
subreddit: string;
|
|
40
|
+
subreddit_name_prefixed: string;
|
|
41
|
+
selftext: string;
|
|
42
|
+
url: string;
|
|
43
|
+
permalink: string;
|
|
44
|
+
score: number;
|
|
45
|
+
ups: number;
|
|
46
|
+
downs: number;
|
|
47
|
+
num_comments: number;
|
|
48
|
+
created_utc: number;
|
|
49
|
+
is_self: boolean;
|
|
50
|
+
over_18: boolean;
|
|
51
|
+
spoiler: boolean;
|
|
52
|
+
locked: boolean;
|
|
53
|
+
stickied: boolean;
|
|
54
|
+
link_flair_text: string | null;
|
|
55
|
+
thumbnail: string;
|
|
56
|
+
}
|
|
57
|
+
export interface RedditComment {
|
|
58
|
+
id: string;
|
|
59
|
+
name: string;
|
|
60
|
+
body: string;
|
|
61
|
+
author: string;
|
|
62
|
+
subreddit: string;
|
|
63
|
+
score: number;
|
|
64
|
+
ups: number;
|
|
65
|
+
downs: number;
|
|
66
|
+
created_utc: number;
|
|
67
|
+
parent_id: string;
|
|
68
|
+
link_id: string;
|
|
69
|
+
is_submitter: boolean;
|
|
70
|
+
stickied: boolean;
|
|
71
|
+
distinguished: string | null;
|
|
72
|
+
}
|
|
73
|
+
export interface RateLimitInfo {
|
|
74
|
+
remaining: number;
|
|
75
|
+
used: number;
|
|
76
|
+
resetSeconds: number;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,SAAS;IACxB,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa,CAAC,CAAC;IAC9B,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE;QACJ,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QACtB,QAAQ,EAAE,KAAK,CAAC;YACd,IAAI,EAAE,MAAM,CAAC;YACb,IAAI,EAAE,CAAC,CAAC;SACT,CAAC,CAAC;QACH,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;CACH;AAED,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,uBAAuB,EAAE,MAAM,CAAC;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,OAAO,CAAC;IAClB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,OAAO,CAAC;IACtB,QAAQ,EAAE,OAAO,CAAC;IAClB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;CACtB"}
|
package/esbuild.build.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { build } from "esbuild";
|
|
2
|
+
import { execSync } from "child_process";
|
|
3
|
+
|
|
4
|
+
await build({
|
|
5
|
+
entryPoints: ["src/index.ts", "src/server.ts", "src/factory.ts"],
|
|
6
|
+
bundle: true,
|
|
7
|
+
platform: "node",
|
|
8
|
+
target: "node20",
|
|
9
|
+
format: "esm",
|
|
10
|
+
outdir: "dist",
|
|
11
|
+
sourcemap: true,
|
|
12
|
+
external: [],
|
|
13
|
+
banner: {
|
|
14
|
+
js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Generate .d.ts declaration files
|
|
19
|
+
execSync("tsc --emitDeclarationOnly", { stdio: "inherit" });
|
|
20
|
+
|
|
21
|
+
console.log("Build complete");
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
|
2
|
+
export default {
|
|
3
|
+
preset: "ts-jest/presets/default-esm",
|
|
4
|
+
testEnvironment: "node",
|
|
5
|
+
extensionsToTreatAsEsm: [".ts"],
|
|
6
|
+
moduleNameMapper: {
|
|
7
|
+
"^(\\.{1,2}/.*)\\.js$": "$1",
|
|
8
|
+
},
|
|
9
|
+
transform: {
|
|
10
|
+
"^.+\\.tsx?$": [
|
|
11
|
+
"ts-jest",
|
|
12
|
+
{
|
|
13
|
+
useESM: true,
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
testMatch: ["**/tests/**/*.test.ts"],
|
|
18
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prmichaelsen/reddit-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server wrapping the Reddit API for AI agents",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/server.d.ts",
|
|
9
|
+
"import": "./dist/server.js"
|
|
10
|
+
},
|
|
11
|
+
"./factory": {
|
|
12
|
+
"types": "./dist/factory.d.ts",
|
|
13
|
+
"import": "./dist/factory.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node esbuild.build.js",
|
|
19
|
+
"dev": "tsx watch src/index.ts",
|
|
20
|
+
"start": "node dist/index.js",
|
|
21
|
+
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"mcp",
|
|
27
|
+
"reddit",
|
|
28
|
+
"reddit-api",
|
|
29
|
+
"ai-agent"
|
|
30
|
+
],
|
|
31
|
+
"author": "",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
35
|
+
"zod": "^4.3.6"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/jest": "^30.0.0",
|
|
39
|
+
"@types/node": "^25.4.0",
|
|
40
|
+
"esbuild": "^0.27.3",
|
|
41
|
+
"jest": "^30.2.0",
|
|
42
|
+
"ts-jest": "^29.4.6",
|
|
43
|
+
"tsx": "^4.21.0",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import type { RedditAuthConfig, TokenData } from "../types/index.js";
|
|
5
|
+
|
|
6
|
+
export class RedditAuth {
|
|
7
|
+
private config: RedditAuthConfig;
|
|
8
|
+
private tokenData: TokenData | null = null;
|
|
9
|
+
|
|
10
|
+
constructor(config: RedditAuthConfig) {
|
|
11
|
+
this.config = config;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
getAuthUrl(scopes: string[]): string {
|
|
15
|
+
const params = new URLSearchParams({
|
|
16
|
+
client_id: this.config.clientId,
|
|
17
|
+
response_type: "code",
|
|
18
|
+
state: crypto.randomUUID(),
|
|
19
|
+
redirect_uri: this.config.redirectUri,
|
|
20
|
+
duration: "permanent",
|
|
21
|
+
scope: scopes.join(" "),
|
|
22
|
+
});
|
|
23
|
+
return `https://www.reddit.com/api/v1/authorize?${params.toString()}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async exchangeCode(code: string): Promise<TokenData> {
|
|
27
|
+
const response = await fetch(
|
|
28
|
+
"https://www.reddit.com/api/v1/access_token",
|
|
29
|
+
{
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
33
|
+
Authorization: `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString("base64")}`,
|
|
34
|
+
"User-Agent": this.config.userAgent,
|
|
35
|
+
},
|
|
36
|
+
body: new URLSearchParams({
|
|
37
|
+
grant_type: "authorization_code",
|
|
38
|
+
code,
|
|
39
|
+
redirect_uri: this.config.redirectUri,
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Token exchange failed: ${response.status} ${response.statusText}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
51
|
+
|
|
52
|
+
if (data.error) {
|
|
53
|
+
throw new Error(`Token exchange error: ${data.error}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.tokenData = {
|
|
57
|
+
access_token: data.access_token as string,
|
|
58
|
+
refresh_token: data.refresh_token as string,
|
|
59
|
+
token_type: data.token_type as string,
|
|
60
|
+
expires_in: data.expires_in as number,
|
|
61
|
+
scope: data.scope as string,
|
|
62
|
+
obtained_at: Date.now(),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await this.storeTokens();
|
|
66
|
+
return this.tokenData;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async getAccessToken(): Promise<string> {
|
|
70
|
+
if (!this.tokenData) {
|
|
71
|
+
await this.loadStoredTokens();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!this.tokenData) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"No credentials available. Run OAuth authorization flow first.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Refresh if token expires within 5 minutes
|
|
81
|
+
const expiresAt =
|
|
82
|
+
this.tokenData.obtained_at + this.tokenData.expires_in * 1000;
|
|
83
|
+
const fiveMinutes = 5 * 60 * 1000;
|
|
84
|
+
|
|
85
|
+
if (Date.now() > expiresAt - fiveMinutes) {
|
|
86
|
+
await this.refreshToken();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return this.tokenData.access_token;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
hasStoredCredentials(): boolean {
|
|
93
|
+
const storagePath =
|
|
94
|
+
this.config.tokenStoragePath || "./.tokens/reddit.json";
|
|
95
|
+
return existsSync(storagePath);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async refreshToken(): Promise<void> {
|
|
99
|
+
if (!this.tokenData?.refresh_token) {
|
|
100
|
+
throw new Error("No refresh token available");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const response = await fetch(
|
|
104
|
+
"https://www.reddit.com/api/v1/access_token",
|
|
105
|
+
{
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: {
|
|
108
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
109
|
+
Authorization: `Basic ${Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString("base64")}`,
|
|
110
|
+
"User-Agent": this.config.userAgent,
|
|
111
|
+
},
|
|
112
|
+
body: new URLSearchParams({
|
|
113
|
+
grant_type: "refresh_token",
|
|
114
|
+
refresh_token: this.tokenData.refresh_token,
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Token refresh failed: ${response.status} ${response.statusText}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
126
|
+
|
|
127
|
+
if (data.error) {
|
|
128
|
+
throw new Error(`Token refresh error: ${data.error}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.tokenData = {
|
|
132
|
+
access_token: data.access_token as string,
|
|
133
|
+
refresh_token:
|
|
134
|
+
(data.refresh_token as string) || this.tokenData.refresh_token,
|
|
135
|
+
token_type: data.token_type as string,
|
|
136
|
+
expires_in: data.expires_in as number,
|
|
137
|
+
scope: data.scope as string,
|
|
138
|
+
obtained_at: Date.now(),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
await this.storeTokens();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async storeTokens(): Promise<void> {
|
|
145
|
+
if (!this.tokenData) return;
|
|
146
|
+
|
|
147
|
+
const storagePath =
|
|
148
|
+
this.config.tokenStoragePath || "./.tokens/reddit.json";
|
|
149
|
+
const dir = dirname(storagePath);
|
|
150
|
+
|
|
151
|
+
if (!existsSync(dir)) {
|
|
152
|
+
await mkdir(dir, { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await writeFile(storagePath, JSON.stringify(this.tokenData, null, 2), {
|
|
156
|
+
mode: 0o600,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async loadStoredTokens(): Promise<void> {
|
|
161
|
+
const storagePath =
|
|
162
|
+
this.config.tokenStoragePath || "./.tokens/reddit.json";
|
|
163
|
+
|
|
164
|
+
if (!existsSync(storagePath)) return;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const content = await readFile(storagePath, "utf-8");
|
|
168
|
+
this.tokenData = JSON.parse(content) as TokenData;
|
|
169
|
+
} catch {
|
|
170
|
+
// Corrupted token file, ignore
|
|
171
|
+
this.tokenData = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createAuthFromEnv(): RedditAuth {
|
|
177
|
+
const clientId = process.env.REDDIT_CLIENT_ID;
|
|
178
|
+
const clientSecret = process.env.REDDIT_CLIENT_SECRET;
|
|
179
|
+
const redirectUri =
|
|
180
|
+
process.env.REDDIT_REDIRECT_URI || "http://localhost:8080/callback";
|
|
181
|
+
const userAgent =
|
|
182
|
+
process.env.REDDIT_USER_AGENT ||
|
|
183
|
+
"nodejs:reddit-mcp:v0.1.0 (by /u/unknown)";
|
|
184
|
+
const tokenStoragePath =
|
|
185
|
+
process.env.TOKEN_STORAGE_PATH || "./.tokens/reddit.json";
|
|
186
|
+
|
|
187
|
+
if (!clientId || !clientSecret) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
"REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET environment variables are required",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return new RedditAuth({
|
|
194
|
+
clientId,
|
|
195
|
+
clientSecret,
|
|
196
|
+
redirectUri,
|
|
197
|
+
userAgent,
|
|
198
|
+
tokenStoragePath,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { RedditAuth } from "../auth/oauth.js";
|
|
2
|
+
import type { RateLimitInfo } from "../types/index.js";
|
|
3
|
+
|
|
4
|
+
export class RedditApiError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
public status: number,
|
|
7
|
+
public reason: string,
|
|
8
|
+
message: string,
|
|
9
|
+
) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "RedditApiError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class RedditClient {
|
|
16
|
+
private auth: RedditAuth | null;
|
|
17
|
+
private accessToken: string | null;
|
|
18
|
+
private userAgent: string;
|
|
19
|
+
private rateLimitInfo: RateLimitInfo | null = null;
|
|
20
|
+
|
|
21
|
+
constructor(auth: RedditAuth | string, userAgent?: string) {
|
|
22
|
+
if (typeof auth === "string") {
|
|
23
|
+
this.auth = null;
|
|
24
|
+
this.accessToken = auth;
|
|
25
|
+
} else {
|
|
26
|
+
this.auth = auth;
|
|
27
|
+
this.accessToken = null;
|
|
28
|
+
}
|
|
29
|
+
this.userAgent =
|
|
30
|
+
userAgent ||
|
|
31
|
+
process.env.REDDIT_USER_AGENT ||
|
|
32
|
+
"nodejs:reddit-mcp:v0.1.0 (by /u/unknown)";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async get<T>(
|
|
36
|
+
path: string,
|
|
37
|
+
params?: Record<string, string | undefined>,
|
|
38
|
+
): Promise<T> {
|
|
39
|
+
return this.request<T>("GET", path, params);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async post<T>(
|
|
43
|
+
path: string,
|
|
44
|
+
body?: Record<string, string | undefined>,
|
|
45
|
+
): Promise<T> {
|
|
46
|
+
return this.request<T>("POST", path, undefined, body);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async patch<T>(
|
|
50
|
+
path: string,
|
|
51
|
+
body?: Record<string, unknown>,
|
|
52
|
+
): Promise<T> {
|
|
53
|
+
return this.requestJson<T>("PATCH", path, body);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async put<T>(
|
|
57
|
+
path: string,
|
|
58
|
+
body?: Record<string, unknown>,
|
|
59
|
+
): Promise<T> {
|
|
60
|
+
return this.requestJson<T>("PUT", path, body);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async delete<T>(path: string): Promise<T> {
|
|
64
|
+
return this.request<T>("DELETE", path);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getRateLimitInfo(): RateLimitInfo | null {
|
|
68
|
+
return this.rateLimitInfo;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async getToken(): Promise<string> {
|
|
72
|
+
if (this.accessToken) return this.accessToken;
|
|
73
|
+
if (this.auth) return this.auth.getAccessToken();
|
|
74
|
+
throw new Error("No authentication configured");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async request<T>(
|
|
78
|
+
method: string,
|
|
79
|
+
path: string,
|
|
80
|
+
params?: Record<string, string | undefined>,
|
|
81
|
+
body?: Record<string, string | undefined>,
|
|
82
|
+
): Promise<T> {
|
|
83
|
+
return this.executeWithRetry<T>(async () => {
|
|
84
|
+
const token = await this.getToken();
|
|
85
|
+
let url = `https://oauth.reddit.com${path}`;
|
|
86
|
+
|
|
87
|
+
if (params) {
|
|
88
|
+
const filtered = Object.entries(params).filter(
|
|
89
|
+
([, v]) => v !== undefined,
|
|
90
|
+
) as [string, string][];
|
|
91
|
+
if (filtered.length > 0) {
|
|
92
|
+
url += `?${new URLSearchParams(filtered).toString()}`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const headers: Record<string, string> = {
|
|
97
|
+
Authorization: `Bearer ${token}`,
|
|
98
|
+
"User-Agent": this.userAgent,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const init: RequestInit = { method, headers };
|
|
102
|
+
|
|
103
|
+
if (body) {
|
|
104
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
105
|
+
const filtered = Object.entries(body).filter(
|
|
106
|
+
([, v]) => v !== undefined,
|
|
107
|
+
) as [string, string][];
|
|
108
|
+
init.body = new URLSearchParams(filtered).toString();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const response = await fetch(url, init);
|
|
112
|
+
this.parseRateLimitHeaders(response);
|
|
113
|
+
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
throw await this.createError(response);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (await response.json()) as T;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async requestJson<T>(
|
|
123
|
+
method: string,
|
|
124
|
+
path: string,
|
|
125
|
+
body?: Record<string, unknown>,
|
|
126
|
+
): Promise<T> {
|
|
127
|
+
return this.executeWithRetry<T>(async () => {
|
|
128
|
+
const token = await this.getToken();
|
|
129
|
+
const url = `https://oauth.reddit.com${path}`;
|
|
130
|
+
|
|
131
|
+
const headers: Record<string, string> = {
|
|
132
|
+
Authorization: `Bearer ${token}`,
|
|
133
|
+
"User-Agent": this.userAgent,
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const init: RequestInit = { method, headers };
|
|
138
|
+
if (body) {
|
|
139
|
+
init.body = JSON.stringify(body);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const response = await fetch(url, init);
|
|
143
|
+
this.parseRateLimitHeaders(response);
|
|
144
|
+
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
throw await this.createError(response);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (await response.json()) as T;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async executeWithRetry<T>(
|
|
154
|
+
fn: () => Promise<T>,
|
|
155
|
+
maxRetries = 3,
|
|
156
|
+
): Promise<T> {
|
|
157
|
+
let lastError: Error | undefined;
|
|
158
|
+
|
|
159
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
160
|
+
try {
|
|
161
|
+
return await fn();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
lastError = error as Error;
|
|
164
|
+
|
|
165
|
+
if (error instanceof RedditApiError) {
|
|
166
|
+
// Don't retry client errors (except 429)
|
|
167
|
+
if (error.status < 500 && error.status !== 429) {
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// For 429, wait for rate limit reset
|
|
172
|
+
if (error.status === 429 && this.rateLimitInfo) {
|
|
173
|
+
const waitMs = this.rateLimitInfo.resetSeconds * 1000;
|
|
174
|
+
await this.delay(Math.min(waitMs, 30000));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (attempt < maxRetries) {
|
|
180
|
+
// Exponential backoff: 1s, 2s, 4s
|
|
181
|
+
await this.delay(Math.pow(2, attempt) * 1000);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
throw lastError!;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private parseRateLimitHeaders(response: Response): void {
|
|
190
|
+
const remaining = response.headers.get("x-ratelimit-remaining");
|
|
191
|
+
const used = response.headers.get("x-ratelimit-used");
|
|
192
|
+
const reset = response.headers.get("x-ratelimit-reset");
|
|
193
|
+
|
|
194
|
+
if (remaining !== null || used !== null || reset !== null) {
|
|
195
|
+
this.rateLimitInfo = {
|
|
196
|
+
remaining: remaining ? parseFloat(remaining) : 0,
|
|
197
|
+
used: used ? parseInt(used, 10) : 0,
|
|
198
|
+
resetSeconds: reset ? parseInt(reset, 10) : 0,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async createError(response: Response): Promise<RedditApiError> {
|
|
204
|
+
let reason = "unknown";
|
|
205
|
+
let message = `Reddit API error: ${response.status} ${response.statusText}`;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
209
|
+
if (body.error) reason = String(body.error);
|
|
210
|
+
if (body.message) message = String(body.message);
|
|
211
|
+
if (body.reason) reason = String(body.reason);
|
|
212
|
+
} catch {
|
|
213
|
+
// Response body not JSON
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
switch (response.status) {
|
|
217
|
+
case 400:
|
|
218
|
+
message = `Bad request: ${reason}`;
|
|
219
|
+
break;
|
|
220
|
+
case 401:
|
|
221
|
+
message =
|
|
222
|
+
"Unauthorized: Access token expired or invalid. Re-authenticate.";
|
|
223
|
+
break;
|
|
224
|
+
case 403:
|
|
225
|
+
message = `Forbidden: Missing required OAuth scope or banned. ${reason}`;
|
|
226
|
+
break;
|
|
227
|
+
case 404:
|
|
228
|
+
message = "Not found: The requested resource does not exist.";
|
|
229
|
+
break;
|
|
230
|
+
case 429:
|
|
231
|
+
message = `Rate limited: Too many requests. Reset in ${this.rateLimitInfo?.resetSeconds ?? "unknown"}s.`;
|
|
232
|
+
break;
|
|
233
|
+
default:
|
|
234
|
+
if (response.status >= 500) {
|
|
235
|
+
message = `Reddit server error (${response.status}): Try again later.`;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return new RedditApiError(response.status, reason, message);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private delay(ms: number): Promise<void> {
|
|
243
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
244
|
+
}
|
|
245
|
+
}
|
package/src/factory.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
import { createServer } from "./server.js";
|
|
3
|
+
import { createAuthFromEnv } from "./auth/oauth.js";
|
|
4
|
+
import { startHttpTransport } from "./transport/http.js";
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
|
|
8
|
+
function getArg(name: string): string | undefined {
|
|
9
|
+
const index = args.indexOf(`--${name}`);
|
|
10
|
+
if (index !== -1 && index + 1 < args.length) {
|
|
11
|
+
return args[index + 1];
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const transport = getArg("transport") || process.env.TRANSPORT || "stdio";
|
|
17
|
+
const port = parseInt(
|
|
18
|
+
getArg("port") || process.env.HTTP_PORT || "3000",
|
|
19
|
+
10,
|
|
20
|
+
);
|
|
21
|
+
const host = getArg("host") || process.env.HTTP_HOST || "localhost";
|
|
22
|
+
|
|
23
|
+
const auth = createAuthFromEnv();
|
|
24
|
+
const server = createServer(auth);
|
|
25
|
+
|
|
26
|
+
if (transport === "http") {
|
|
27
|
+
await startHttpTransport(server, { port, host });
|
|
28
|
+
} else {
|
|
29
|
+
const stdioTransport = new StdioServerTransport();
|
|
30
|
+
await server.connect(stdioTransport);
|
|
31
|
+
}
|