From 6091258dfeeae05e9cad3e29ad1cdb35b05cf07b Mon Sep 17 00:00:00 2001 From: Matteo Redaelli Date: Thu, 23 Jul 2026 11:52:49 +0200 Subject: [PATCH] feat: add local file storage and stdio transport support - Add STORAGE_MODE env variable to switch between 'local' (default) and 'cloud' - Local mode saves generated chart images to ./images/ directory - Cloud mode (BaiduBCE BOS) is now lazily loaded only when needed - Add stdio transport via --stdio flag for direct LLM integration - Add 'start:stdio' npm script for convenience - Remove mandatory .env file check to support zero-config local usage - Improve date formatting in file names with zero-padding --- .env.exmaple | 5 ++- package.json | 1 + src/index.js | 67 +++++++++++++++------------- src/storage.js | 119 ++++++++++++++++++++++++++++++++----------------- 4 files changed, 119 insertions(+), 73 deletions(-) diff --git a/.env.exmaple b/.env.exmaple index f6a137b..ef61399 100644 --- a/.env.exmaple +++ b/.env.exmaple @@ -1,6 +1,9 @@ SERVER_PORT=8081 -# Cloud Storage Configuration +# Storage mode: 'local' (saves to ./images/) or 'cloud' (uploads to BaiduBCE BOS) +STORAGE_MODE=local + +# Cloud Storage Configuration (only needed when STORAGE_MODE=cloud) BOS_AK=... BOS_SK=... BOS_ENDPOINT=... diff --git a/package.json b/package.json index e8f3b13..1e8e839 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "src/index.js", "scripts": { "dev": "nodemon src/index.js", + "start:stdio": "node src/index.js --stdio", "inspect": "mcp-inspector node ./src/index.js" }, "keywords": ["echarts", "mcp", "data-visualization"], diff --git a/src/index.js b/src/index.js index 9684f5f..90f2527 100644 --- a/src/index.js +++ b/src/index.js @@ -20,9 +20,9 @@ import express from 'express'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js'; import dotenv from 'dotenv'; -import fs from 'fs'; import { getChartBase64 } from './chart.js'; import { isTreelike, seriesTypes } from './util.js'; @@ -32,11 +32,6 @@ dotenv.config(); class EChartsServer { constructor() { - // Throw an error if there is no .env file - if (!fs.existsSync('.env')) { - throw new Error('Missing .env file. Please create a .env file with your configuration. See .env.example for reference.'); - } - this.server = new Server( { name: 'echarts', @@ -173,38 +168,46 @@ class EChartsServer { } async run() { - const app = express(); - const transports = {}; + const useStdio = process.argv.includes('--stdio'); - app.get('/', (_, res) => { - res.send('Apache ECharts MCP Server is running'); - }); + if (useStdio) { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.error('ECharts MCP Server running on stdio'); + } else { + const app = express(); + const transports = {}; - app.get('/sse', async (_, res) => { - const transport = new SSEServerTransport('/messages', res); - transports[transport.sessionId] = transport; - res.on('close', () => { - delete transports[transport.sessionId]; + app.get('/', (_, res) => { + res.send('Apache ECharts MCP Server is running'); + }); + + app.get('/sse', async (_, res) => { + const transport = new SSEServerTransport('/messages', res); + transports[transport.sessionId] = transport; + res.on('close', () => { + delete transports[transport.sessionId]; + }); + await this.server.connect(transport); }); - await this.server.connect(transport); - }); - app.post('/messages', async (req, res) => { - const sessionId = req.query.sessionId; - const transport = transports[sessionId]; - if (!transport) { - if (!res.headersSent) { - res.status(400).send('No transport found for sessionId. Please establish SSE connection first.'); + app.post('/messages', async (req, res) => { + const sessionId = req.query.sessionId; + const transport = transports[sessionId]; + if (!transport) { + if (!res.headersSent) { + res.status(400).send('No transport found for sessionId. Please establish SSE connection first.'); + } + return; } - return; - } - await transport.handlePostMessage(req, res); - }); + await transport.handlePostMessage(req, res); + }); - const port = process.env.SERVER_PORT || 8081; - app.listen(port, () => { - console.log(`Server is running on port ${port}`); - }); + const port = process.env.SERVER_PORT || 8081; + app.listen(port, () => { + console.log(`Server is running on port ${port}`); + }); + } } } diff --git a/src/storage.js b/src/storage.js index 69201b5..291fbc9 100644 --- a/src/storage.js +++ b/src/storage.js @@ -17,74 +17,113 @@ * under the License. */ -import bos from '@baiducloud/sdk'; -import dotenv from 'dotenv'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -dotenv.config(); - const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const tmpDir = path.join(__dirname, '../tmp'); -// Create tmp directory if not exists -if (!fs.existsSync(tmpDir)) { - fs.mkdirSync(tmpDir); +/** + * Storage mode: 'local' or 'cloud' + * Set via STORAGE_MODE env variable. Defaults to 'local'. + */ +const storageMode = process.env.STORAGE_MODE || 'local'; + +export async function saveImage(base64) { + if (storageMode === 'cloud') { + return saveImageToCloud(base64); + } + return saveImageLocally(base64); } -const config = { - endpoint: process.env.BOS_ENDPOINT, - credentials: { - ak: process.env.BOS_AK, - sk: process.env.BOS_SK, - }, -}; +// ─── Local Storage ─────────────────────────────────────────────────────────── -const client = new bos.BosClient(config); -const bucket = process.env.BOS_BUCKET; -const basePath = '/upload/echarts'; +const imagesDir = path.join(__dirname, '../images'); + +function ensureImagesDir() { + if (!fs.existsSync(imagesDir)) { + fs.mkdirSync(imagesDir, { recursive: true }); + } +} + +async function saveImageLocally(base64) { + ensureImagesDir(); -export async function saveImage(base64) { const fileName = getFilePrefix() + '.png'; - const key = `${basePath}/${fileName}`; - const tmpPath = path.join(tmpDir, fileName); + const filePath = path.join(imagesDir, fileName); try { - // Remove Base64 prefix if exists const base64Data = base64.replace(/^data:image\/\w+;base64,/, ''); - // Save base64 to tmp file - fs.writeFileSync(tmpPath, Buffer.from(base64Data, 'base64')); + fs.writeFileSync(filePath, Buffer.from(base64Data, 'base64')); + return filePath; + } catch (error) { + console.error('Failed to save image locally:', error); + throw error; + } +} + +// ─── Cloud Storage (BaiduBCE BOS) ──────────────────────────────────────────── - // Upload file - await client.putObjectFromFile(bucket, key, tmpPath); - return process.env.BOS_CDN_ENDPOINT + key; +async function saveImageToCloud(base64) { + const { BosClient } = await import('@baiducloud/sdk'); + + const bosConfig = { + endpoint: process.env.BOS_ENDPOINT, + credentials: { + ak: process.env.BOS_AK, + sk: process.env.BOS_SK, + }, + }; + + const bucket = process.env.BOS_BUCKET || 'echarts-mcp'; + const cdnEndpoint = process.env.BOS_CDN_ENDPOINT; + + if (!bosConfig.endpoint || !bosConfig.credentials.ak || !bosConfig.credentials.sk) { + throw new Error( + 'Cloud storage requires BOS_ENDPOINT, BOS_AK, and BOS_SK environment variables. ' + + 'Set STORAGE_MODE=local to use local storage instead.' + ); + } + + const client = new BosClient(bosConfig); + const fileName = getFilePrefix() + '.png'; + const key = `charts/${fileName}`; + + try { + const base64Data = base64.replace(/^data:image\/\w+;base64,/, ''); + const buffer = Buffer.from(base64Data, 'base64'); + + await client.putObject(bucket, key, buffer, { + 'Content-Type': 'image/png', + }); + + const url = cdnEndpoint + ? `${cdnEndpoint}/${key}` + : `${bosConfig.endpoint}/${bucket}/${key}`; + + return url; } catch (error) { - console.error('Upload failed:', error); + console.error('Failed to save image to cloud:', error); throw error; - } finally { - // Remove tmp file - if (fs.existsSync(tmpPath)) { - fs.unlinkSync(tmpPath); - } } } +// ─── Utils ─────────────────────────────────────────────────────────────────── + function getFilePrefix() { - // Datetime + 10 random characters const date = new Date(); const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = date.getDate(); - const hour = date.getHours(); - const minute = date.getMinutes(); - const second = date.getSeconds(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hour = String(date.getHours()).padStart(2, '0'); + const minute = String(date.getMinutes()).padStart(2, '0'); + const second = String(date.getSeconds()).padStart(2, '0'); const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; let result = ''; for (let i = 0; i < 10; i++) { result += chars[Math.floor(Math.random() * chars.length)]; } - return `${year}${month}${day}${hour}${minute}${second}${result}`; + return `${year}${month}${day}_${hour}${minute}${second}_${result}`; }