Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.exmaple
Original file line number Diff line number Diff line change
@@ -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=...
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
67 changes: 35 additions & 32 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -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}`);
});
}
}
}

Expand Down
119 changes: 79 additions & 40 deletions src/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}