Skip to content

Commit ca8c527

Browse files
2niuhehustcc
andauthored
feat: support mcp sse/streamable http transport (#21)
* feat: enhance MCP server with new transport options and update documentation - Added support for Express.js to handle SSE and Streamable transport protocols. - Introduced command line options for transport type, port, and endpoint configuration. - Updated README to include new usage instructions for desktop applications and transport options. - Added health check and message endpoints for improved server functionality. - Included automatic server instance creation for each request to ensure stateless operation. * feat: add error handling for uncaught exceptions and unhandled rejections - Implemented global error handling for uncaught exceptions and unhandled promise rejections. - Added logging for errors to improve debugging and application stability. * fix: remove error logging on server start failure * chore: add contributor --------- Co-authored-by: hustcc <i@hust.cc>
1 parent 98a5e29 commit ca8c527

File tree

3 files changed

+247
-41
lines changed

3 files changed

+247
-41
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ Generate <img src="https://echarts.apache.org/zh/images/favicon.png" height="14"
2121

2222
## 🤖 Usage
2323

24+
### Desktop Applications (stdio transport)
25+
2426
To use with `Desktop APP`, such as Claude, VSCode, Cline, Cherry Studio, and so on, add the MCP server config below. On Mac system:
2527

2628
```json
@@ -58,6 +60,46 @@ On Window system:
5860
Also, you can use it on [modelscope](https://www.modelscope.cn/mcp/servers/hustcc/MCP-ECharts), [glama.ai](https://glama.ai/mcp/servers/@hustcc/mcp-echarts), [smithery.ai](https://smithery.ai/server/@hustcc/mcp-echarts) or others with HTTP, SSE Protocol.
5961

6062

63+
## 🚰 Run with SSE or Streamable transport
64+
65+
Install the package globally.
66+
67+
```bash
68+
npm install -g mcp-echarts
69+
```
70+
71+
Run the server with your preferred transport option:
72+
73+
```bash
74+
# For SSE transport (default endpoint: /sse)
75+
mcp-echarts -t sse
76+
77+
# For Streamable transport with custom endpoint
78+
mcp-echarts -t streamable
79+
```
80+
81+
Then you can access the server at:
82+
- SSE transport: `http://localhost:3033/sse`
83+
- Streamable transport: `http://localhost:3033/mcp`
84+
85+
86+
## 🎮 CLI Options
87+
88+
You can also use the following CLI options when running the MCP server. Command options by run cli with `-h`.
89+
90+
```plain
91+
MCP ECharts CLI
92+
93+
Options:
94+
--transport, -t Specify the transport protocol: "stdio", "sse", or "streamable" (default: "stdio")
95+
--port, -p Specify the port for SSE or streamable transport (default: 3033)
96+
--endpoint, -e Specify the endpoint for the transport:
97+
- For SSE: default is "/sse"
98+
- For streamable: default is "/mcp"
99+
--help, -h Show this help message
100+
```
101+
102+
61103
## 🗂️ MinIO Configuration (Optional)
62104

63105
For better performance and sharing capabilities, you can configure MinIO object storage to store chart images as URLs instead of Base64 data.
@@ -126,6 +168,7 @@ npm run start
126168
## 🧑🏻‍💻 Contributors
127169

128170
- [lyw405](https://github.com/lyw405): Supports `15+` charting MCP tool. [#2](https://github.com/hustcc/mcp-echarts/issues/2)
171+
- [2niuhe](https://github.com/2niuhe): Support MCP with SSE and Streaming HTTP. [#17](https://github.com/hustcc/mcp-echarts/issues/17)
129172
- [susuperli](https://github.com/susuperli): Use `MinIO` to save the chart image base64 and return the url. [#10](https://github.com/hustcc/mcp-echarts/issues/10)
130173
- [BQXBQX](https://github.com/BQXBQX): Use `@napi-rs/canvas` instead node-canvas. [#3](https://github.com/hustcc/mcp-echarts/issues/3)
131174
- [hustcc](https://github.com/hustcc): Initial the repo.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@
2121
"@napi-rs/canvas": "^0.1.73",
2222
"dotenv": "^17.2.1",
2323
"echarts": "^6.0.0",
24+
"express": "^5.1.0",
2425
"minio": "^8.0.5",
2526
"zod": "^3.25.16"
2627
},
2728
"devDependencies": {
2829
"@biomejs/biome": "1.9.4",
2930
"@modelcontextprotocol/inspector": "^0.15.0",
31+
"@types/express": "^5.0.3",
3032
"@types/node": "^22.15.21",
3133
"@types/pixelmatch": "^5.2.6",
3234
"@types/pngjs": "^6.0.5",

src/index.ts

Lines changed: 202 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
#!/usr/bin/env node
2+
import { randomUUID } from "node:crypto";
23
import process from "node:process";
4+
import { parseArgs } from "node:util";
35
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
47
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
510
import { config } from "dotenv";
11+
import express from "express";
612
import { tools } from "./tools";
713

814
// Load environment variables from .env file (completely silent to avoid stdout contamination)
@@ -13,63 +19,218 @@ config({ override: false, debug: false });
1319
* MCP Server for ECharts.
1420
* This server provides tools for generating ECharts visualizations and validate ECharts configurations.
1521
*/
16-
class MCPServerECharts {
17-
/**
18-
* The MCP server instance.
19-
* This server handles requests and provides tools for ECharts-related tasks.
20-
*/
21-
private readonly server: McpServer;
22-
23-
constructor() {
24-
this.server = new McpServer({
25-
name: "mcp-echarts",
26-
version: "0.1.0",
27-
});
22+
function createEChartsServer(): McpServer {
23+
const server = new McpServer({
24+
name: "mcp-echarts",
25+
version: "0.1.0",
26+
});
2827

29-
for (const tool of tools) {
30-
const { name, description, inputSchema, run } = tool;
31-
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
32-
this.server.tool(name, description, inputSchema.shape, run as any);
33-
}
28+
for (const tool of tools) {
29+
const { name, description, inputSchema, run } = tool;
30+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
31+
server.tool(name, description, inputSchema.shape, run as any);
3432
}
3533

36-
async runWithStdio(): Promise<void> {
37-
const transport = new StdioServerTransport();
38-
await this.server.connect(transport);
39-
}
34+
return server;
35+
}
36+
37+
// Parse command line arguments
38+
const { values } = parseArgs({
39+
options: {
40+
transport: {
41+
type: "string",
42+
short: "t",
43+
default: "stdio",
44+
},
45+
port: {
46+
type: "string",
47+
short: "p",
48+
default: "3033",
49+
},
50+
endpoint: {
51+
type: "string",
52+
short: "e",
53+
default: "", // We'll handle defaults per transport type
54+
},
55+
help: {
56+
type: "boolean",
57+
short: "h",
58+
},
59+
},
60+
});
4061

41-
async shutdown(): Promise<void> {}
62+
// Display help information if requested
63+
if (values.help) {
64+
console.log(`
65+
MCP ECharts CLI
66+
67+
Options:
68+
--transport, -t Specify the transport protocol: "stdio", "sse", or "streamable" (default: "stdio")
69+
--port, -p Specify the port for SSE or streamable transport (default: 3033)
70+
--endpoint, -e Specify the endpoint for the transport:
71+
- For SSE: default is "/sse"
72+
- For streamable: default is "/mcp"
73+
--help, -h Show this help message
74+
`);
75+
process.exit(0);
4276
}
4377

44-
/**
45-
* Main entry point for the MCP ECharts server application.
46-
* This function initializes the server, sets up error handling, and starts the server.
47-
* It handles uncaught exceptions and unhandled promise rejections to ensure graceful shutdown.
48-
*/
78+
// Main function to start the server
4979
async function main(): Promise<void> {
50-
const server = new MCPServerECharts();
80+
const transport = values.transport?.toLowerCase() || "stdio";
81+
const port = Number.parseInt(values.port as string, 10);
82+
83+
if (transport === "sse") {
84+
const endpoint = values.endpoint || "/sse";
85+
await runSSEServer(port, endpoint);
86+
} else if (transport === "streamable") {
87+
const endpoint = values.endpoint || "/mcp";
88+
await runStreamableHTTPServer(port, endpoint);
89+
} else {
90+
await runStdioServer();
91+
}
92+
}
5193

52-
process.on("uncaughtException", (error) => {
53-
server.shutdown().finally(() => {
54-
process.exit(1);
94+
async function runStdioServer(): Promise<void> {
95+
const server = createEChartsServer();
96+
const transport = new StdioServerTransport();
97+
await server.connect(transport);
98+
}
99+
100+
async function runSSEServer(port: number, endpoint: string): Promise<void> {
101+
const app = express();
102+
app.use(express.json());
103+
104+
// Store transports by session ID
105+
const transports: Record<string, SSEServerTransport> = {};
106+
107+
// SSE endpoint
108+
app.get(endpoint, async (req, res) => {
109+
const server = createEChartsServer();
110+
const transport = new SSEServerTransport("/messages", res);
111+
transports[transport.sessionId] = transport;
112+
113+
res.on("close", () => {
114+
delete transports[transport.sessionId];
55115
});
116+
117+
await server.connect(transport);
56118
});
57119

58-
process.on("unhandledRejection", (reason, promise) => {
59-
server.shutdown().finally(() => {
60-
process.exit(1);
61-
});
120+
// Message endpoint for SSE
121+
app.post("/messages", async (req, res) => {
122+
const sessionId = req.query.sessionId as string;
123+
const transport = transports[sessionId];
124+
if (transport) {
125+
await transport.handlePostMessage(req, res, req.body);
126+
} else {
127+
res.status(400).send("No transport found for sessionId");
128+
}
62129
});
63130

64-
try {
65-
await server.runWithStdio();
66-
} catch (error) {
67-
process.exit(1);
68-
}
131+
app.listen(port, () => {
132+
console.log(
133+
`MCP ECharts SSE server running on http://localhost:${port}${endpoint}`,
134+
);
135+
});
69136
}
70137

138+
async function runStreamableHTTPServer(
139+
port: number,
140+
endpoint: string,
141+
): Promise<void> {
142+
const app = express();
143+
app.use(express.json());
144+
145+
// Store transports by session ID
146+
const transports: Record<string, StreamableHTTPServerTransport> = {};
147+
148+
// Handle POST requests for client-to-server communication
149+
app.post(endpoint, async (req, res) => {
150+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
151+
let transport: StreamableHTTPServerTransport;
152+
153+
if (sessionId && transports[sessionId]) {
154+
// Reuse existing transport
155+
transport = transports[sessionId];
156+
} else if (!sessionId && isInitializeRequest(req.body)) {
157+
// New initialization request
158+
transport = new StreamableHTTPServerTransport({
159+
sessionIdGenerator: () => randomUUID(),
160+
onsessioninitialized: (sessionId) => {
161+
transports[sessionId] = transport;
162+
},
163+
});
164+
165+
// Clean up transport when closed
166+
transport.onclose = () => {
167+
if (transport.sessionId) {
168+
delete transports[transport.sessionId];
169+
}
170+
};
171+
172+
const server = createEChartsServer();
173+
await server.connect(transport);
174+
} else {
175+
// Invalid request
176+
res.status(400).json({
177+
jsonrpc: "2.0",
178+
error: {
179+
code: -32000,
180+
message: "Bad Request: No valid session ID provided",
181+
},
182+
id: null,
183+
});
184+
return;
185+
}
186+
187+
// Handle the request
188+
await transport.handleRequest(req, res, req.body);
189+
});
190+
191+
// Handle GET requests for server-to-client notifications via SSE
192+
app.get(endpoint, async (req, res) => {
193+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
194+
if (!sessionId || !transports[sessionId]) {
195+
res.status(400).send("Invalid or missing session ID");
196+
return;
197+
}
198+
199+
const transport = transports[sessionId];
200+
await transport.handleRequest(req, res);
201+
});
202+
203+
// Handle DELETE requests for session termination
204+
app.delete(endpoint, async (req, res) => {
205+
const sessionId = req.headers["mcp-session-id"] as string | undefined;
206+
if (!sessionId || !transports[sessionId]) {
207+
res.status(400).send("Invalid or missing session ID");
208+
return;
209+
}
210+
211+
const transport = transports[sessionId];
212+
await transport.handleRequest(req, res);
213+
});
214+
215+
app.listen(port, () => {
216+
console.log(
217+
`MCP ECharts Streamable HTTP server running on http://localhost:${port}${endpoint}`,
218+
);
219+
});
220+
}
221+
222+
// Error handling for uncaught exceptions and unhandled rejections
223+
process.on("uncaughtException", (error) => {
224+
console.error("Uncaught exception:", error);
225+
process.exit(1);
226+
});
227+
228+
process.on("unhandledRejection", (reason, promise) => {
229+
console.error("Unhandled rejection at:", promise, "reason:", reason);
230+
process.exit(1);
231+
});
232+
71233
// Start application
72234
main().catch((error) => {
73-
// Don't use console.error in MCP servers as it interferes with JSON protocol
74235
process.exit(1);
75236
});

0 commit comments

Comments
 (0)