#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { createServer as createHttpServer } from "node:http";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import { generateUserApiKey } from "./user-api-key-generator.js";
// Read package version at runtime to avoid import-attributes incompatibility
async function getPackageVersion(): Promise<string> {
try {
const pkgPath = new URL("../package.json", import.meta.url);
const raw = await readFile(pkgPath, "utf8");
const pkg = JSON.parse(raw) as { version?: string };
return pkg.version || "0.0.0";
} catch {
return "0.0.0";
}
}
import { Logger, type LogLevel } from "./util/logger.js";
import { redactObject } from "./util/redact.js";
import { type AuthMode } from "./http/client.js";
import { registerAllTools, type ToolsMode } from "./tools/registry.js";
import { tryRegisterRemoteTools } from "./tools/remote/tool_exec_api.js";
import { SiteState, type AuthOverride } from "./site/state.js";
// =============================================================================
// Smithery Configuration Schema
// =============================================================================
// This schema is used by Smithery to generate configuration forms for users.
// Export it so Smithery CLI can discover and use it.
export const configSchema = z.object({
site: z.string().url().optional().describe("Discourse site URL (e.g., https://meta.discourse.org)"),
api_key: z.string().optional().describe("Admin API key for authenticated requests"),
api_username: z.string().optional().describe("API username (required when using admin API key)"),
user_api_key: z.string().optional().describe("User API key for user-level authenticated requests"),
user_api_client_id: z.string().optional().describe("User API client ID (required when using user API key)"),
read_only: z.boolean().default(true).describe("Enable read-only mode (prevents write operations)"),
allow_writes: z.boolean().default(false).describe("Allow write operations (create posts, topics, etc.)"),
tools_mode: z
.enum(["auto", "discourse_api_only", "tool_exec_api"])
.default("auto")
.describe("Tool discovery mode: auto (detect), discourse_api_only (built-in only), tool_exec_api (remote tools)"),
default_search: z.string().optional().describe("Default search prefix added to every search query"),
max_read_length: z
.number()
.int()
.positive()
.default(50000)
.describe("Maximum characters to return when reading post content"),
log_level: z
.enum(["silent", "error", "info", "debug"])
.default("info")
.describe("Logging verbosity level"),
});
export type SmitheryConfig = z.infer<typeof configSchema>;
// =============================================================================
// Smithery createServer Factory Function
// =============================================================================
// This is the main export for Smithery. It creates and returns an MCP server
// configured with the provided session config. Smithery handles the transport.
export default function createServer({
config,
}: {
config: SmitheryConfig;
}) {
// Use hardcoded version for Smithery - avoid async file reads during initialization
// Smithery calls createServer with dummy config to discover capabilities
const version = "0.1.15";
const logger = new Logger(config.log_level ?? "info");
logger.info(`Creating Discourse MCP server v${version} for Smithery`);
// Build auth from Smithery config (single site mode)
let auth: AuthMode = { type: "none" };
const authOverrides: AuthOverride[] = [];
// Only build auth if we have valid config (not dummy config from Smithery discovery)
if (config.site && config.api_key && config.api_username) {
authOverrides.push({
site: config.site,
api_key: config.api_key,
api_username: config.api_username,
});
} else if (config.site && config.user_api_key && config.user_api_client_id) {
authOverrides.push({
site: config.site,
user_api_key: config.user_api_key,
user_api_client_id: config.user_api_client_id,
});
}
// Initialize site state
const siteState = new SiteState({
logger,
timeoutMs: 15000,
defaultAuth: auth,
authOverrides: authOverrides.length > 0 ? authOverrides : undefined,
});
// Create MCP server
const server = new McpServer(
{
name: "@discourse/mcp",
version,
icons: [{
src: "https://raw.githubusercontent.com/king-of-the-grackles/discourse-mcp/main/assets/icon.svg",
sizes: ["512x512"],
mimeType: "image/svg+xml",
}],
websiteUrl: "https://github.com/discourse/discourse-mcp",
},
{
capabilities: {
tools: { listChanged: false },
prompts: { listChanged: false },
resources: { listChanged: false },
},
}
);
const allowWrites = Boolean(
config.allow_writes && !config.read_only && authOverrides.length > 0
);
// Pre-select the site if provided (no HTTP validation - that happens on first tool call)
// Smithery may call with dummy config, so only select if site looks valid
if (config.site && config.site.startsWith("http")) {
try {
const { base } = siteState.buildClientForSite(config.site);
siteState.selectSite(base);
logger.info(`Site configured: ${base}`);
} catch (e: any) {
logger.debug(`Could not pre-select site: ${e?.message || String(e)}`);
}
}
// Register all tools synchronously
// Note: registerAllTools is sync - it just sets up handlers
registerAllTools(server as any, siteState, logger, {
allowWrites,
toolsMode: config.tools_mode ?? "auto",
hideSelectSite: false, // Allow site selection for flexibility
defaultSearchPrefix: config.default_search,
maxReadLength: config.max_read_length ?? 50000,
});
// Register prompts for Smithery quality score
server.registerPrompt(
"discourse-search-help",
{
title: "Discourse Search Helper",
description: "Generate an optimized search query for Discourse using search operators",
argsSchema: {
topic: z.string().describe("What you're searching for"),
},
},
async ({ topic }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Help me search Discourse for information about: ${topic}\n\nGenerate a search query using operators like @username, #tag, category:name, status:open, order:latest`,
},
},
],
})
);
server.registerPrompt(
"discourse-post-draft",
{
title: "Draft Discourse Post",
description: "Help draft a well-structured Discourse post or reply",
argsSchema: {
type: z.enum(["question", "discussion", "announcement", "bug_report", "feature_request"]).describe("Type of post to draft"),
topic: z.string().describe("What the post is about"),
},
},
async ({ type, topic }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Help me draft a ${type.replace("_", " ")} post about: ${topic}\n\nProvide a well-structured post with appropriate formatting for Discourse, including a clear title suggestion and body content.`,
},
},
],
})
);
server.registerPrompt(
"discourse-summarize-topic",
{
title: "Summarize Discourse Topic",
description: "Generate a summary of a Discourse topic discussion",
argsSchema: {
topic_id: z.number().describe("Topic ID to summarize"),
focus: z.enum(["key_points", "action_items", "decisions", "full"]).optional().describe("What aspect to focus on in the summary"),
},
},
async ({ topic_id, focus }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Please read topic ${topic_id} using discourse_read_topic and provide a ${focus || "full"} summary of the discussion.\n\nInclude the main points, any decisions made, and action items if applicable.`,
},
},
],
})
);
server.registerPrompt(
"discourse-reply-suggestions",
{
title: "Suggest Reply to Topic",
description: "Generate thoughtful reply suggestions for a Discourse topic",
argsSchema: {
topic_id: z.number().describe("Topic ID to reply to"),
tone: z.enum(["helpful", "professional", "casual", "technical"]).optional().describe("Desired tone for the reply"),
},
},
async ({ topic_id, tone }) => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `Read topic ${topic_id} using discourse_read_topic and suggest a ${tone || "helpful"} reply that addresses the discussion.\n\nConsider the context and provide a constructive response.`,
},
},
],
})
);
// Register resources for Smithery quality score
server.registerResource(
"site-info",
"discourse://site-info",
{
title: "Site Information",
description: "Current Discourse site configuration and connection status",
mimeType: "application/json",
},
async () => ({
contents: [
{
uri: "discourse://site-info",
text: JSON.stringify(
{
connected: siteState.getSiteBase() !== undefined,
site: siteState.getSiteBase(),
readOnly: !allowWrites,
toolsMode: config.tools_mode ?? "auto",
},
null,
2
),
},
],
})
);
// Skip remote tools discovery during initialization - it requires HTTP calls
// Remote tools will be discovered lazily if needed
// Return the underlying Server object (NOT the McpServer wrapper)
// Smithery will handle connecting the transport
return server.server;
}
const DEFAULT_TIMEOUT_MS = 15000;
// CLI config schema
const ProfileSchema = z
.object({
auth_pairs: z
.array(
z
.object({
site: z.string().url(),
api_key: z.string().optional(),
api_username: z.string().optional(),
user_api_key: z.string().optional(),
user_api_client_id: z.string().optional(),
})
.strict()
)
.optional(),
read_only: z.boolean().optional().default(true),
allow_writes: z.boolean().optional().default(false),
timeout_ms: z.number().int().positive().optional().default(DEFAULT_TIMEOUT_MS),
concurrency: z.number().int().positive().optional().default(4),
cache_dir: z.string().optional(),
log_level: z.enum(["silent", "error", "info", "debug"]).optional().default("info"),
tools_mode: z.enum(["auto", "discourse_api_only", "tool_exec_api"]).optional().default("auto"),
site: z.string().url().optional().describe("Tether MCP to a single Discourse site; hides select_site and preselects this site"),
default_search: z.string().optional().describe("Optional search prefix added to every search query (set via --default-search)"),
max_read_length: z
.number()
.int()
.positive()
.optional()
.default(50000)
.describe("Maximum number of characters to include when returning post content (set via --max-read-length)"),
transport: z.enum(["stdio", "http"]).optional().default("stdio").describe("Transport type: stdio (default) or http"),
port: z.number().int().positive().optional().default(3000).describe("Port to listen on when using HTTP transport"),
})
.strict();
type Profile = z.infer<typeof ProfileSchema>;
function parseArgs(argv: string[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const eq = arg.indexOf("=");
if (eq !== -1) {
const key = arg.slice(2, eq);
const val = arg.slice(eq + 1);
out[key] = coerceValue(val);
} else {
const key = arg.slice(2);
const next = argv[i + 1];
if (next && !next.startsWith("--")) {
out[key] = coerceValue(next);
i++;
} else {
out[key] = true;
}
}
}
return out;
}
function coerceValue(val: string): unknown {
if (val === "true") return true;
if (val === "false") return false;
const num = Number(val);
if (!Number.isNaN(num) && val.trim() !== "") return num;
return val;
}
async function loadProfile(path?: string): Promise<Partial<Profile>> {
if (!path) return {};
const txt = await readFile(path, "utf8");
const raw = JSON.parse(txt);
const parsed = ProfileSchema.partial().safeParse(raw);
if (!parsed.success) throw new Error(`Invalid profile JSON: ${parsed.error.message}`);
return parsed.data;
}
function mergeConfig(profile: Partial<Profile>, flags: Record<string, unknown>): Profile {
const merged = {
auth_pairs: (flags.auth_pairs as any) ?? profile.auth_pairs,
read_only: ((flags.read_only ?? flags["read-only"]) as boolean | undefined) ?? profile.read_only ?? true,
allow_writes: ((flags.allow_writes ?? flags["allow-writes"]) as boolean | undefined) ?? profile.allow_writes ?? false,
timeout_ms: ((flags.timeout_ms ?? flags["timeout-ms"]) as number | undefined) ?? profile.timeout_ms ?? DEFAULT_TIMEOUT_MS,
concurrency: (flags.concurrency as number | undefined) ?? profile.concurrency ?? 4,
cache_dir: ((flags.cache_dir ?? flags["cache-dir"]) as string | undefined) ?? profile.cache_dir,
log_level: (((flags.log_level ?? flags["log-level"]) as LogLevel | undefined) ?? (profile.log_level as LogLevel | undefined) ?? "info") as LogLevel,
tools_mode: (((flags.tools_mode ?? flags["tools-mode"]) as ToolsMode | undefined) ?? (profile.tools_mode as ToolsMode | undefined) ?? "auto") as ToolsMode,
site: (flags.site as string | undefined) ?? profile.site,
default_search: (((flags.default_search ?? flags["default-search"]) as string | undefined) ?? profile.default_search) as string | undefined,
max_read_length: (((flags.max_read_length ?? flags["max-read-length"]) as number | undefined) ?? profile.max_read_length ?? 50000) as number,
transport: ((flags.transport as "stdio" | "http" | undefined) ?? profile.transport ?? "stdio") as "stdio" | "http",
port: ((flags.port as number | undefined) ?? profile.port ?? 3000) as number,
} satisfies Profile;
const result = ProfileSchema.safeParse(merged);
if (!result.success) throw new Error(`Invalid configuration: ${result.error.message}`);
return result.data;
}
function buildAuth(_config: Profile): AuthMode {
// Global default is no auth; use per-site overrides via auth_pairs when provided
return { type: "none" };
}
async function main() {
// Check if user wants to generate a User API Key
const args = process.argv.slice(2);
if (args[0] === "generate-user-api-key") {
const options: any = { site: "" };
for (let i = 1; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
if (arg === "--site") { options.site = next; i++; }
else if (arg === "--scopes") { options.scopes = next; i++; }
else if (arg === "--application-name") { options.applicationName = next; i++; }
else if (arg === "--client-id") { options.clientId = next; i++; }
else if (arg === "--nonce") { options.nonce = next; i++; }
else if (arg === "--payload") { options.payload = next; i++; }
else if (arg === "--save-to") { options.saveTo = next; i++; }
else if (arg === "--help" || arg === "-h") {
await generateUserApiKey({ site: "" }); // Will show help and exit
return;
}
}
await generateUserApiKey(options);
return;
}
const argv = parseArgs(process.argv.slice(2));
const profilePath = (argv.profile as string | undefined) ?? undefined;
const profile = await loadProfile(profilePath).catch((e) => {
throw new Error(`Failed to load profile: ${e?.message || String(e)}`);
});
const config = mergeConfig(profile, argv);
const logger = new Logger(config.log_level);
const auth = buildAuth(config);
// Meta log (stderr) without leaking secrets
const version = await getPackageVersion();
logger.info(`Starting Discourse MCP v${version}`);
logger.debug(`Config: ${JSON.stringify(redactObject({ ...config }))}`);
// Initialize dynamic site state
let authOverrides: AuthOverride[] | undefined = undefined;
if (Array.isArray(config.auth_pairs)) {
authOverrides = config.auth_pairs as unknown as AuthOverride[];
} else if (typeof (config as any).auth_pairs === "string") {
try {
const parsed = JSON.parse((config as any).auth_pairs);
if (Array.isArray(parsed)) authOverrides = parsed as AuthOverride[];
} catch {
// ignore
}
}
const siteState = new SiteState({
logger,
timeoutMs: config.timeout_ms,
defaultAuth: auth,
authOverrides,
});
const server = new McpServer(
{
name: "@discourse/mcp",
version,
icons: [{
src: "https://raw.githubusercontent.com/king-of-the-grackles/discourse-mcp/main/assets/icon.svg",
sizes: ["512x512"],
mimeType: "image/svg+xml",
}],
websiteUrl: "https://github.com/discourse/discourse-mcp",
},
{
capabilities: {
tools: { listChanged: false },
},
}
);
const allowWrites = Boolean(config.allow_writes && !config.read_only && (config.auth_pairs && config.auth_pairs.length > 0));
// If tethered to a site, validate and preselect it before registering tools,
// and trigger remote tool discovery when enabled.
let hideSelectSite = false;
if (config.site) {
try {
const { base, client } = siteState.buildClientForSite(config.site);
const about = (await client.get(`/about.json`)) as any;
const title = about?.about?.title || about?.title || base;
siteState.selectSite(base);
hideSelectSite = true;
logger.info(`Tethered to site: ${base} (${title})`);
} catch (e: any) {
throw new Error(`Failed to validate --site ${config.site}: ${e?.message || String(e)}`);
}
}
await registerAllTools(server as any, siteState, logger, {
allowWrites,
toolsMode: config.tools_mode,
hideSelectSite,
defaultSearchPrefix: config.default_search,
maxReadLength: config.max_read_length,
});
// If tethered and remote tool discovery is enabled, discover now
if (config.site && config.tools_mode !== "discourse_api_only") {
await tryRegisterRemoteTools(server as any, siteState, logger);
}
// Create transport based on configuration
if (config.transport === "http") {
// HTTP transport using Streamable HTTP (stateless mode)
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // Stateless mode
enableJsonResponse: true,
});
await server.connect(transport);
const httpServer = createHttpServer(async (req, res) => {
// Health check endpoint
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
// MCP endpoint - handle via StreamableHTTPServerTransport
if (req.url === "/mcp" || req.url === "/") {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", async () => {
try {
const parsedBody = body ? JSON.parse(body) : undefined;
await transport.handleRequest(req, res, parsedBody);
} catch (error) {
logger.error(`Request handling error: ${error}`);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
}
}
});
return;
}
// Unknown endpoint
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
});
httpServer.listen(config.port, () => {
logger.info(`HTTP transport listening on port ${config.port}`);
logger.info(`Health check available at http://localhost:${config.port}/health`);
logger.info(`MCP endpoint available at http://localhost:${config.port}/mcp`);
});
// Exit cleanly on SIGTERM/SIGINT
const onExit = () => {
httpServer.close(() => {
transport.close().then(() => {
logger.info("HTTP server closed");
process.exit(0);
});
});
};
process.on("SIGTERM", onExit);
process.on("SIGINT", onExit);
} else {
// Default stdio transport
const transport = new StdioServerTransport();
// Exit cleanly on stdin close or SIGTERM
const onExit = () => process.exit(0);
process.on("SIGTERM", onExit);
process.on("SIGINT", onExit);
process.stdin.on("close", onExit);
await server.connect(transport);
}
}
main().catch((err) => {
const msg = err?.message || String(err);
process.stderr.write(`[${new Date().toISOString()}] ERROR ${msg}\n`);
process.exit(1);
});