server.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. #!/usr/bin/env node
  2. /* eslint-disable no-console */
  3. /* global process */
  4. import { promises as fs } from "node:fs";
  5. import path from "node:path";
  6. import { fileURLToPath } from "node:url";
  7. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  8. import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
  9. import cors from "cors";
  10. import express from "express";
  11. import * as z from "zod";
  12. const PORT = process.env.PORT || 3000;
  13. const HOST = "0.0.0.0";
  14. const MCP_ENDPOINT = "/mcp";
  15. const DEFAULT_SAVE_DIR = "./saved-pages";
  16. const DEFAULT_BODY_SIZE_LIMIT_MB = 100;
  17. const CORS_ORIGIN = "*";
  18. const CORS_HEADERS = ["Content-Type", "Accept", "Mcp-Session-Id"];
  19. const CORS_EXPOSED_HEADERS = ["Mcp-Session-Id"];
  20. const SERVER_NAME = "filesystem";
  21. const SERVER_VERSION = "1.0.0";
  22. const CONTENT_TYPE_TEXT = "text";
  23. const CONTENT_ENCODING_UTF8 = "utf-8";
  24. const JSONRPC_VERSION = "2.0";
  25. const ERROR_CODE_INTERNAL = -32603;
  26. const FILE_NOT_FOUND_CODE = "ENOENT";
  27. const TITLE_TEXT = "MCP Filesystem Server for SingleFile";
  28. const SEPARATOR_TEXT = "═".repeat(80);
  29. const HELP_TEXT = `
  30. ${TITLE_TEXT}
  31. Usage:
  32. node server.js [save-directory] [--limit=<size-in-mb>]
  33. Arguments:
  34. save-directory Directory where files will be saved (default: ./saved-pages)
  35. --limit=<mb> Maximum request body size in MB (default: 100)
  36. Use higher values for large HTML files (e.g., --limit=200)
  37. Environment:
  38. PORT Server port (default: 3000)
  39. Examples:
  40. node server.js
  41. node server.js /path/to/saves
  42. node server.js --limit=200
  43. node server.js /path/to/saves --limit=150
  44. PORT=8080 node server.js --limit=200
  45. `;
  46. let SAVE_DIR;
  47. let BODY_SIZE_LIMIT;
  48. await main();
  49. async function main() {
  50. const __filename = fileURLToPath(import.meta.url);
  51. const __dirname = path.dirname(__filename);
  52. const args = process.argv.slice(2);
  53. if (args.includes("--help") || args.includes("-h")) {
  54. console.log(HELP_TEXT);
  55. process.exit(0);
  56. }
  57. let saveDir = path.join(__dirname, DEFAULT_SAVE_DIR);
  58. let bodySizeLimitMB = DEFAULT_BODY_SIZE_LIMIT_MB;
  59. for (const arg of args) {
  60. if (arg.startsWith("--limit=")) {
  61. const limitValue = parseInt(arg.split("=")[1], 10);
  62. if (!isNaN(limitValue) && limitValue > 0) {
  63. bodySizeLimitMB = limitValue;
  64. } else {
  65. console.error(`Invalid limit value: ${arg}. Using default 100MB.`);
  66. }
  67. } else if (!arg.startsWith("--")) {
  68. saveDir = arg;
  69. }
  70. }
  71. BODY_SIZE_LIMIT = `${bodySizeLimitMB}mb`;
  72. SAVE_DIR = saveDir;
  73. try {
  74. await start();
  75. } catch (err) {
  76. console.error("Failed to start:", err);
  77. process.exit(1);
  78. }
  79. }
  80. async function start() {
  81. await fs.mkdir(SAVE_DIR, { recursive: true });
  82. const mcpServer = createMcpServer();
  83. const app = express();
  84. app.use(express.json({ limit: BODY_SIZE_LIMIT }));
  85. app.use(express.urlencoded({ limit: BODY_SIZE_LIMIT, extended: true }));
  86. app.use(cors({
  87. origin: CORS_ORIGIN,
  88. credentials: true,
  89. exposedHeaders: CORS_EXPOSED_HEADERS,
  90. allowedHeaders: CORS_HEADERS
  91. }));
  92. app.post(MCP_ENDPOINT, async (req, res) => {
  93. try {
  94. const transport = new StreamableHTTPServerTransport({
  95. sessionIdGenerator: undefined,
  96. enableJsonResponse: true
  97. });
  98. res.on("close", async () => {
  99. try {
  100. await transport.close();
  101. } catch {
  102. // ignored
  103. }
  104. });
  105. await mcpServer.connect(transport);
  106. await transport.handleRequest(req, res, req.body);
  107. } catch (error) {
  108. console.error("MCP error:", error);
  109. if (!res.headersSent) {
  110. res.status(500).json({
  111. jsonrpc: JSONRPC_VERSION,
  112. error: { code: ERROR_CODE_INTERNAL, message: error.message },
  113. id: null
  114. });
  115. }
  116. }
  117. });
  118. app.listen(PORT, HOST, () => {
  119. console.log(SEPARATOR_TEXT);
  120. console.log(` ${TITLE_TEXT}`);
  121. console.log(SEPARATOR_TEXT);
  122. console.log(` Endpoint: http://localhost:${PORT}${MCP_ENDPOINT}`);
  123. console.log(` Directory: ${SAVE_DIR}`);
  124. console.log(SEPARATOR_TEXT + "\n");
  125. });
  126. }
  127. function createMcpServer() {
  128. const server = new McpServer({
  129. name: SERVER_NAME,
  130. version: SERVER_VERSION
  131. });
  132. server.registerTool(
  133. "write_file",
  134. {
  135. title: "Write File",
  136. description: "Create or overwrite a file with content",
  137. inputSchema: {
  138. path: z.string(),
  139. content: z.string()
  140. }
  141. },
  142. async ({ path: filePath, content }) => {
  143. const sanitized = sanitizePath(filePath);
  144. const fullPath = path.join(SAVE_DIR, sanitized);
  145. const dir = path.dirname(fullPath);
  146. await fs.mkdir(dir, { recursive: true });
  147. await fs.writeFile(fullPath, content, CONTENT_ENCODING_UTF8);
  148. const text = `Successfully wrote to ${sanitized}`;
  149. return {
  150. content: [{ type: CONTENT_TYPE_TEXT, text }]
  151. };
  152. }
  153. );
  154. server.registerTool(
  155. "get_file_info",
  156. {
  157. title: "Get File Info",
  158. description: "Get file metadata",
  159. inputSchema: {
  160. path: z.string()
  161. }
  162. },
  163. async ({ path: filePath }) => {
  164. const sanitized = sanitizePath(filePath);
  165. const fullPath = path.join(SAVE_DIR, sanitized);
  166. try {
  167. const stats = await fs.stat(fullPath);
  168. const info = {
  169. size: stats.size,
  170. created: stats.birthtime.toISOString(),
  171. modified: stats.mtime.toISOString(),
  172. isDirectory: stats.isDirectory(),
  173. isFile: stats.isFile()
  174. };
  175. const text = Object.entries(info)
  176. .map(([k, v]) => `${k}: ${v}`)
  177. .join("\n");
  178. return {
  179. content: [{ type: CONTENT_TYPE_TEXT, text }]
  180. };
  181. } catch (error) {
  182. if (error.code === FILE_NOT_FOUND_CODE) {
  183. throw new Error(`File not found: ${sanitized}`);
  184. }
  185. throw error;
  186. }
  187. }
  188. );
  189. return server;
  190. }
  191. function sanitizePath(filePath) {
  192. const normalized = path.normalize(filePath);
  193. if (normalized.includes("..")) {
  194. throw new Error("Invalid path: directory traversal detected");
  195. }
  196. return normalized;
  197. }