UNPKG

hackmd-mcp

Version:

A Model Context Protocol server for integrating HackMD's note-taking platform with AI assistants.

199 lines (198 loc) 7.39 kB
#!/usr/bin/env node import express from "express"; import cors from "cors"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { API } from "@hackmd/api"; import dotenv from "dotenv"; import { registerAllTools } from "./tools/index.js"; import { ConfigSchema } from "./utils/schemas.js"; import { HACKMD_API_TOKEN_HEADER, HACKMD_API_URL_HEADER, DEFAULT_HACKMD_API_URL, } from "./utils/constants.js"; dotenv.config({ quiet: true }); const app = express(); const PORT = process.env.PORT || 8081; // CORS configuration for browser-based MCP clients app.use(cors({ origin: process.env.CORS_ORIGIN?.split(",") || "*", exposedHeaders: [ "Mcp-Session-Id", HACKMD_API_TOKEN_HEADER, HACKMD_API_URL_HEADER, ], allowedHeaders: [ "Content-Type", "mcp-session-id", HACKMD_API_TOKEN_HEADER, HACKMD_API_URL_HEADER, ], })); app.use(express.json()); // Helper function to create JSON-RPC error responses function createJsonRpcError(code, message) { return { jsonrpc: "2.0", error: { code, message }, id: null, }; } // Get allowed HackMD API URLs from environment function getAllowedApiUrls() { const allowedUrls = process.env.ALLOWED_HACKMD_API_URLS; if (!allowedUrls || allowedUrls.trim().length === 0) { return [DEFAULT_HACKMD_API_URL]; } return allowedUrls .split(",") .map((url) => url.trim()) .filter((url) => url.length > 0); } // Validate if the provided API URL is allowed function isAllowedApiUrl(url) { const allowedUrls = getAllowedApiUrls(); return allowedUrls.includes(url); } // Parse configuration from header or query parameters (for Smithery) function parseConfig(req) { const hackmdApiTokenHeader = req.headers[HACKMD_API_TOKEN_HEADER.toLowerCase()]; const hackmdApiUrlHeader = req.headers[HACKMD_API_URL_HEADER.toLowerCase()]; const config = {}; if (typeof hackmdApiTokenHeader === "string" && hackmdApiTokenHeader.trim().length > 0) { config.hackmdApiToken = hackmdApiTokenHeader; } if (typeof hackmdApiUrlHeader === "string" && hackmdApiUrlHeader.trim().length > 0) { config.hackmdApiUrl = hackmdApiUrlHeader; } // If any config found in headers, return it if (Object.keys(config).length > 0) { return { config }; } // Smithery passes config as base64-encoded JSON in query parameters const configParam = req.query.config; if (typeof configParam === "string" && configParam.trim().length > 0) { try { const smitheryConfig = JSON.parse(Buffer.from(configParam, "base64").toString()); return { config: smitheryConfig }; } catch (error) { return { error: createJsonRpcError(-32000, "Bad Request: Invalid base64-encoded config parameter"), }; } } // Return empty config if nothing found return { config }; } // Create MCP server with HackMD integration function createServer({ config }) { const server = new McpServer({ name: "hackmd-mcp", version: "1.5.6", }); // Initialize HackMD API client with config const client = new API(config.hackmdApiToken, config.hackmdApiUrl); // Register all tools registerAllTools(server, client); return server; } // Handle MCP requests at /mcp endpoint app.post("/mcp", async (req, res) => { try { // Parse configuration with URL validation const parseResult = parseConfig(req); // Check for parsing errors if (parseResult.error) { return res.status(400).json(parseResult.error); } const rawConfig = parseResult.config || {}; // Check if API token is available (from header, query param, or env var) const hackmdApiToken = rawConfig.hackmdApiToken || process.env.HACKMD_API_TOKEN; if (!hackmdApiToken || hackmdApiToken.trim().length === 0) { return res .status(400) .json(createJsonRpcError(-32000, `Bad Request: Please provide a HackMD API token via header '${HACKMD_API_TOKEN_HEADER}'.`)); } // Extract API URL from config or use default const hackmdApiUrl = rawConfig.hackmdApiUrl || process.env.HACKMD_API_URL || DEFAULT_HACKMD_API_URL; // Validation of the API URL if (!isAllowedApiUrl(hackmdApiUrl)) { return res .status(400) .json(createJsonRpcError(-32000, `Bad Request: HackMD API URL "${hackmdApiUrl}" is not in the allowed list`)); } // Validate and parse configuration const config = ConfigSchema.parse({ hackmdApiToken, hackmdApiUrl, }); const server = createServer({ config }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); // Clean up on request close res.on("close", () => { transport.close(); server.close(); }); await server.connect(transport); await transport.handleRequest(req, res, req.body); } catch (error) { console.error("Error handling MCP request:", error); if (!res.headersSent) { res.status(500).json(createJsonRpcError(-32603, "Internal server error")); } } }); // SSE notifications not supported in stateless mode app.get("/mcp", async (req, res) => { res .writeHead(405) .end(JSON.stringify(createJsonRpcError(-32000, "Method not allowed."))); }); // Session termination not needed in stateless mode app.delete("/mcp", async (req, res) => { res .writeHead(405) .end(JSON.stringify(createJsonRpcError(-32000, "Method not allowed."))); }); // Main function to start the server in the appropriate mode async function main() { const transport = process.env.TRANSPORT || "stdio"; if (transport === "http") { // Run in HTTP mode app.listen(PORT, () => { console.log(`MCP HTTP Server listening on port ${PORT}`); }); } else { if (transport !== "stdio") { console.warn(`Unknown TRANSPORT "${transport}", defaulting to "stdio" mode.`); } // Run in STDIO mode for backward compatibility const API_TOKEN = process.env.HACKMD_API_TOKEN; if (!API_TOKEN) { console.error("Error: HACKMD_API_TOKEN is required"); process.exit(1); } const config = ConfigSchema.parse({ hackmdApiToken: API_TOKEN, hackmdApiUrl: process.env.HACKMD_API_URL || DEFAULT_HACKMD_API_URL, }); // Create server with configuration const server = createServer({ config }); // Start receiving messages on stdin and sending messages on stdout const stdioTransport = new StdioServerTransport(); await server.connect(stdioTransport); console.error("MCP Server running in stdio mode"); } } // Start the server main().catch((error) => { console.error("Server error:", error); process.exit(1); });