#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import express from "express";
import cors from "cors";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { ApifyClient } from "apify-client";
import * as dotenv from "dotenv";
import { z } from "zod";
// Redirect console.log to console.error to prevent stray output from breaking the MCP protocol
console.log = console.error;
dotenv.config({ quiet: true });
const APIFY_API_TOKEN = process.env.APIFY_API_TOKEN;
const apifyClient = new ApifyClient({
token: APIFY_API_TOKEN || "dummy-token", // Use dummy token to allow initialization
});
const server = new Server(
{
name: "Reddit MCP",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
/**
* Tool Definitions
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "reddit_fast_search",
description: "Quickly search for Reddit posts, comments, or users. Best for general information gathering.",
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "The search keyword." },
subreddits: { type: "array", items: { type: "string" }, description: "Limit search to specific subreddits (e.g., [\"marketing\", \"SaaS\"])." },
limit: { type: "number", default: 10, description: "Max number of results." },
sort: { type: "string", enum: ["relevance", "hot", "top", "new"], description: "Sort order." },
},
required: ["query"],
},
},
{
name: "reddit_lead_monitor",
description: "Find high-intent leads or brand mentions while filtering out noise. Use this when the user wants to find 'buyers' or specific discussions.",
inputSchema: {
type: "object",
properties: {
keywords: { type: "array", items: { type: "string" }, description: "Main search terms." },
negative_keywords: { type: "array", items: { type: "string" }, description: "Words to exclude (e.g., \"crypto\", \"hiring\")." },
target_subreddits: { type: "array", items: { type: "string" }, description: "Limit search to specific subreddits." },
hours_back: { type: "number", default: 24, description: "How far back to search in hours." },
},
required: ["keywords"],
},
},
],
};
});
/**
* Tool Execution
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
if (!APIFY_API_TOKEN) {
throw new Error("APIFY_API_TOKEN is not set. Please provide your Apify API Token in the environment variables.");
}
if (name === "reddit_fast_search") {
const { query, subreddits, limit = 25, sort = "new" } = args as any;
// Build search queries. If subreddits are provided, use Reddit's search syntax
const searchQueries = subreddits && subreddits.length > 0
? subreddits.map((s: string) => `${query} subreddit:${s}`)
: [query];
const input = {
searches: searchQueries,
maxItems: limit,
sort: sort,
searchPosts: true,
searchComments: false,
searchCommunities: false,
searchUsers: false,
skipCommunity: true,
};
const run = await apifyClient.actor("practicaltools/apify-reddit-api").call(input);
const { items } = await apifyClient.dataset(run.defaultDatasetId).listItems();
const formattedResults = items.map((item: any) => {
const rawText = item.text || item.body || item.selftext || item.description || "";
return {
title: item.title,
text: rawText.length > 500 ? rawText.substring(0, 500) + "..." : rawText,
url: item.url,
author: item.author || "unknown",
subreddit: item.subreddit || "unknown",
upvotes: item.upvotes || 0,
createdAt: item.createdAt,
};
});
return {
content: [{ type: "text", text: JSON.stringify(formattedResults, null, 2) }],
};
}
if (name === "reddit_lead_monitor") {
const { keywords, negative_keywords, target_subreddits, hours_back = 24 } = args as any;
// Map keywords and subreddits to the actor's expected object format
const searches = keywords.flatMap((k: string) => {
if (target_subreddits && target_subreddits.length > 0) {
return target_subreddits.map((s: string) => ({ keyword: k, subreddit: s }));
}
return [{ keyword: k }];
});
const input = {
searches: searches,
negative_keywords: negative_keywords,
hours_back: hours_back,
searchPosts: true,
searchComments: true,
maxItems: 50,
};
const run = await apifyClient.actor("practicaltools/reddit-keyword-monitor").call(input);
const { items } = await apifyClient.dataset(run.defaultDatasetId).listItems();
const formattedResults = items.map((item: any) => {
const rawText = item.text || item.body || item.selftext || item.description || "";
return {
title: item.title,
text: rawText.length > 500 ? rawText.substring(0, 500) + "..." : rawText,
url: item.url,
author: item.author || "unknown",
subreddit: item.subreddit || "unknown",
matchedKeywords: item.matchedKeywords,
type: item.dataType || "post", // post or comment
createdAt: item.createdAt,
};
});
return {
content: [{ type: "text", text: JSON.stringify(formattedResults, null, 2) }],
};
}
throw new Error(`Tool not found: ${name}`);
} catch (error: any) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
});
/**
* Server Startup
*/
async function main() {
const isSse = process.argv.includes("--sse");
if (isSse) {
const app = express();
app.use(cors());
let transport: SSEServerTransport | null = null;
app.get("/sse", async (req, res) => {
console.error("New SSE connection established");
transport = new SSEServerTransport("/messages", res);
await server.connect(transport);
});
app.post("/messages", async (req, res) => {
if (transport) {
await transport.handlePostMessage(req, res);
} else {
res.status(400).send("No active SSE transport");
}
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.error(`Reddit MCP server running on SSE at http://localhost:${PORT}/sse`);
});
} else {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Reddit MCP server running on stdio");
}
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});