import express from "express";
import helmet from "helmet";
import pino from "pino";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { InMemoryInstallStore } from "./installStore.js";
import { slackInstallHandler, slackOauthCallbackHandler } from "./slackOauth.js";
import { parseCsvEnv, originAllowed, requireApiKey, setCorsHeaders } from "./security.js";
import { registerTools } from "./mcp.js";
const log = pino({ level: process.env.LOG_LEVEL ?? "info" });
const PORT = Number(process.env.PORT ?? "8080");
const HOST = process.env.HOST ?? "0.0.0.0";
const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL ?? `http://localhost:${PORT}`;
const SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID!;
const SLACK_CLIENT_SECRET = process.env.SLACK_CLIENT_SECRET!;
const SLACK_REDIRECT_URI = process.env.SLACK_REDIRECT_URI!;
const SLACK_SCOPES = process.env.SLACK_SCOPES ?? "";
const AFFINITYBOTS_MCP_API_KEY = process.env.AFFINITYBOTS_MCP_API_KEY ?? "";
// Validate required environment variables
if (!SLACK_CLIENT_ID || !SLACK_CLIENT_SECRET || !SLACK_REDIRECT_URI) {
log.fatal("Missing required Slack OAuth env vars (SLACK_CLIENT_ID/SECRET/REDIRECT_URI)");
throw new Error("Missing Slack OAuth env vars (SLACK_CLIENT_ID/SECRET/REDIRECT_URI).");
}
if (!SLACK_SCOPES) {
log.warn("SLACK_SCOPES is empty - OAuth will fail without proper scopes");
}
if (!AFFINITYBOTS_MCP_API_KEY) {
log.warn("AFFINITYBOTS_MCP_API_KEY is not set - MCP endpoint will be unprotected");
}
const allowedOrigins = parseCsvEnv(process.env.ALLOWED_ORIGINS);
const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS ?? "900000");
// Storage (swap to DB)
const store = new InMemoryInstallStore();
// MCP server
const mcp = new McpServer({ name: "affinitybots-slack", version: "1.0.0" });
registerTools(mcp, store);
// Express
const app = express();
app.disable("x-powered-by");
app.use(helmet());
app.use(express.json({ limit: "1mb" }));
// Set timeout for all requests (30 seconds)
app.use((req, res, next) => {
req.setTimeout(30000);
res.setTimeout(30000);
next();
});
app.get("/health", (_req, res) => res.status(200).send("ok"));
// OAuth endpoints
app.get(
"/slack/install",
slackInstallHandler({
store,
clientId: SLACK_CLIENT_ID,
clientSecret: SLACK_CLIENT_SECRET,
redirectUri: SLACK_REDIRECT_URI,
scopes: SLACK_SCOPES,
publicBaseUrl: PUBLIC_BASE_URL
})
);
app.get(
"/slack/oauth/callback",
slackOauthCallbackHandler({
store,
clientId: SLACK_CLIENT_ID,
clientSecret: SLACK_CLIENT_SECRET,
redirectUri: SLACK_REDIRECT_URI,
scopes: SLACK_SCOPES,
publicBaseUrl: PUBLIC_BASE_URL
})
);
// Streamable HTTP sessions (Mcp-Session-Id)
type Session = { transport: StreamableHTTPServerTransport; expiresAt: number };
const sessions = new Map<string, Session>();
function pruneSessions() {
const now = Date.now();
let pruned = 0;
for (const [id, s] of sessions) {
if (s.expiresAt <= now) {
sessions.delete(id);
pruned++;
}
}
if (pruned > 0) {
log.debug({ pruned, remaining: sessions.size }, "Pruned expired sessions");
}
}
// Prune sessions every 5 minutes instead of on every request
setInterval(pruneSessions, 5 * 60 * 1000);
app.all("/mcp", async (req, res) => {
const startTime = Date.now();
try {
// Handle CORS
const origin = req.headers.origin as string | undefined;
if (!originAllowed(origin, allowedOrigins)) {
log.warn({ origin, ip: req.ip }, "Origin not allowed");
res.status(403).json({ error: "Origin not allowed" });
return;
}
// Set CORS headers if origin is allowed
setCorsHeaders(res, origin, allowedOrigins);
// Handle preflight
if (req.method === "OPTIONS") {
res.status(204).send("");
return;
}
// Protect your MCP endpoint from random traffic
try {
requireApiKey(req.headers.authorization as string | undefined, AFFINITYBOTS_MCP_API_KEY);
} catch (err) {
log.warn({ ip: req.ip, origin }, "Unauthorized MCP access attempt");
res.status(401).json({ error: "Unauthorized" });
return;
}
const sessionId = (req.headers["mcp-session-id"] as string | undefined) ?? undefined;
if (req.method === "POST" && isInitializeRequest(req.body)) {
const newSessionId = randomUUID();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId });
await mcp.connect(transport);
sessions.set(newSessionId, { transport, expiresAt: Date.now() + SESSION_TTL_MS });
log.info({ sessionId: newSessionId, sessionsCount: sessions.size }, "New MCP session initialized");
await transport.handleRequest(req, res);
return;
}
if (!sessionId) {
res.status(400).json({ error: "Missing Mcp-Session-Id" });
return;
}
const session = sessions.get(sessionId);
if (!session) {
log.warn({ sessionId }, "Unknown or expired session");
res.status(404).json({ error: "Unknown or expired session" });
return;
}
session.expiresAt = Date.now() + SESSION_TTL_MS;
await session.transport.handleRequest(req, res);
log.debug({ sessionId, duration: Date.now() - startTime }, "MCP request completed");
} catch (err: unknown) {
const error = err as Error;
const isUnauthorized = error?.message === "Unauthorized";
const statusCode = isUnauthorized ? 401 : 500;
const message = isUnauthorized ? "Unauthorized" : "Internal server error";
res.status(statusCode).json({ error: message });
log.error({ err: error, duration: Date.now() - startTime }, "MCP request failed");
}
});
app.listen(PORT, HOST, () => log.info({ PORT, HOST }, "Slack MCP listening"));