Przeglądaj źródła

add basic implementation of MCP server

Gildas 2 miesięcy temu
rodzic
commit
5198154c9e

+ 21 - 0
tools/mcp-server/package.json

@@ -0,0 +1,21 @@
+{
+  "name": "singlefile-mcp-server",
+  "version": "1.0.0",
+  "description": "Simple MCP Filesystem Server for SingleFile",
+  "author": "Gildas Lormeau",
+  "license": "MIT",
+  "main": "server.js",
+  "type": "module",
+  "scripts": {
+    "start": "./start.sh"
+  },
+  "keywords": [
+    "mcp"
+  ],
+  "dependencies": {
+    "@modelcontextprotocol/sdk": "^1.22.0",
+    "cors": "^2.8.5",
+    "express": "^5.1.0",
+    "zod": "^3.25.0"
+  }
+}

+ 211 - 0
tools/mcp-server/server.js

@@ -0,0 +1,211 @@
+#!/usr/bin/env node
+
+/* eslint-disable no-console */
+/* global process */
+
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
+import cors from "cors";
+import express from "express";
+import * as z from "zod";
+
+const PORT = process.env.PORT || 3000;
+const HOST = "0.0.0.0";
+const MCP_ENDPOINT = "/mcp";
+const DEFAULT_SAVE_DIR = "./saved-pages";
+const DEFAULT_BODY_SIZE_LIMIT_MB = 100;
+
+const CORS_ORIGIN = "*";
+const CORS_HEADERS = ["Content-Type", "Accept", "Mcp-Session-Id"];
+const CORS_EXPOSED_HEADERS = ["Mcp-Session-Id"];
+const SERVER_NAME = "filesystem";
+const SERVER_VERSION = "1.0.0";
+const CONTENT_TYPE_TEXT = "text";
+const CONTENT_ENCODING_UTF8 = "utf-8";
+const JSONRPC_VERSION = "2.0";
+const ERROR_CODE_INTERNAL = -32603;
+const FILE_NOT_FOUND_CODE = "ENOENT";
+const TITLE_TEXT = "MCP Filesystem Server for SingleFile";
+const SEPARATOR_TEXT = "═".repeat(80);
+const HELP_TEXT = `
+${TITLE_TEXT}
+
+Usage:
+  node server.js [save-directory] [--limit=<size-in-mb>]
+
+Arguments:
+  save-directory     Directory where files will be saved (default: ./saved-pages)
+  --limit=<mb>       Maximum request body size in MB (default: 100)
+                     Use higher values for large HTML files (e.g., --limit=200)
+
+Environment:
+  PORT               Server port (default: 3000)
+
+Examples:
+  node server.js
+  node server.js /path/to/saves
+  node server.js --limit=200
+  node server.js /path/to/saves --limit=150
+  PORT=8080 node server.js --limit=200
+`;
+let SAVE_DIR;
+let BODY_SIZE_LIMIT;
+
+await main();
+
+async function main() {
+    const __filename = fileURLToPath(import.meta.url);
+    const __dirname = path.dirname(__filename);
+    const args = process.argv.slice(2);
+    if (args.includes("--help") || args.includes("-h")) {
+        console.log(HELP_TEXT);
+        process.exit(0);
+    }
+    let saveDir = path.join(__dirname, DEFAULT_SAVE_DIR);
+    let bodySizeLimitMB = DEFAULT_BODY_SIZE_LIMIT_MB;
+    for (const arg of args) {
+        if (arg.startsWith("--limit=")) {
+            const limitValue = parseInt(arg.split("=")[1], 10);
+            if (!isNaN(limitValue) && limitValue > 0) {
+                bodySizeLimitMB = limitValue;
+            } else {
+                console.error(`Invalid limit value: ${arg}. Using default 100MB.`);
+            }
+        } else if (!arg.startsWith("--")) {
+            saveDir = arg;
+        }
+    }
+    BODY_SIZE_LIMIT = `${bodySizeLimitMB}mb`;
+    SAVE_DIR = saveDir;
+    try {
+        await start();
+    } catch (err) {
+        console.error("Failed to start:", err);
+        process.exit(1);
+    }
+}
+
+async function start() {
+    await fs.mkdir(SAVE_DIR, { recursive: true });
+    const mcpServer = createMcpServer();
+    const app = express();
+    app.use(express.json({ limit: BODY_SIZE_LIMIT }));
+    app.use(express.urlencoded({ limit: BODY_SIZE_LIMIT, extended: true }));
+    app.use(cors({
+        origin: CORS_ORIGIN,
+        credentials: true,
+        exposedHeaders: CORS_EXPOSED_HEADERS,
+        allowedHeaders: CORS_HEADERS
+    }));
+    app.post(MCP_ENDPOINT, async (req, res) => {
+        try {
+            const transport = new StreamableHTTPServerTransport({
+                sessionIdGenerator: undefined,
+                enableJsonResponse: true
+            });
+            res.on("close", async () => {
+                try {
+                    await transport.close();
+                } catch {
+                    // ignored
+                }
+            });
+            await mcpServer.connect(transport);
+            await transport.handleRequest(req, res, req.body);
+        } catch (error) {
+            console.error("MCP error:", error);
+            if (!res.headersSent) {
+                res.status(500).json({
+                    jsonrpc: JSONRPC_VERSION,
+                    error: { code: ERROR_CODE_INTERNAL, message: error.message },
+                    id: null
+                });
+            }
+        }
+    });
+    app.listen(PORT, HOST, () => {
+        console.log(SEPARATOR_TEXT);
+        console.log(`  ${TITLE_TEXT}`);
+        console.log(SEPARATOR_TEXT);
+        console.log(`   Endpoint: http://localhost:${PORT}${MCP_ENDPOINT}`);
+        console.log(`   Directory: ${SAVE_DIR}`);
+        console.log(SEPARATOR_TEXT + "\n");
+    });
+}
+
+function createMcpServer() {
+    const server = new McpServer({
+        name: SERVER_NAME,
+        version: SERVER_VERSION
+    });
+    server.registerTool(
+        "write_file",
+        {
+            title: "Write File",
+            description: "Create or overwrite a file with content",
+            inputSchema: {
+                path: z.string(),
+                content: z.string()
+            }
+        },
+        async ({ path: filePath, content }) => {
+            const sanitized = sanitizePath(filePath);
+            const fullPath = path.join(SAVE_DIR, sanitized);
+            const dir = path.dirname(fullPath);
+            await fs.mkdir(dir, { recursive: true });
+            await fs.writeFile(fullPath, content, CONTENT_ENCODING_UTF8);
+            const text = `Successfully wrote to ${sanitized}`;
+            return {
+                content: [{ type: CONTENT_TYPE_TEXT, text }]
+            };
+        }
+    );
+    server.registerTool(
+        "get_file_info",
+        {
+            title: "Get File Info",
+            description: "Get file metadata",
+            inputSchema: {
+                path: z.string()
+            }
+        },
+        async ({ path: filePath }) => {
+            const sanitized = sanitizePath(filePath);
+            const fullPath = path.join(SAVE_DIR, sanitized);
+            try {
+                const stats = await fs.stat(fullPath);
+                const info = {
+                    size: stats.size,
+                    created: stats.birthtime.toISOString(),
+                    modified: stats.mtime.toISOString(),
+                    isDirectory: stats.isDirectory(),
+                    isFile: stats.isFile()
+                };
+                const text = Object.entries(info)
+                    .map(([k, v]) => `${k}: ${v}`)
+                    .join("\n");
+                return {
+                    content: [{ type: CONTENT_TYPE_TEXT, text }]
+                };
+            } catch (error) {
+                if (error.code === FILE_NOT_FOUND_CODE) {
+                    throw new Error(`File not found: ${sanitized}`);
+                }
+                throw error;
+            }
+        }
+    );
+    return server;
+}
+
+function sanitizePath(filePath) {
+    const normalized = path.normalize(filePath);
+    if (normalized.includes("..")) {
+        throw new Error("Invalid path: directory traversal detected");
+    }
+    return normalized;
+}

+ 9 - 0
tools/mcp-server/start.sh

@@ -0,0 +1,9 @@
+#!/bin/bash
+
+if [ ! -d "node_modules" ]; then
+    echo "Installing dependencies..."
+    npm install
+    echo ""
+fi
+
+node server.js "$@"