@firefly-iii-mcp/server
Version:
Host Firefly III MCP server as a service.
242 lines (239 loc) • 9.27 kB
JavaScript
import express from 'express';
import { getServer } from '@firefly-iii-mcp/core';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { InMemoryEventStore } from './event-store.js';
import { randomUUID } from 'node:crypto';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import http from 'http';
import https from 'https';
/**
* Create an Express-based MCP server
* @param config Server configuration
* @returns MCP server instance
*/
export function createServer(config) {
const { port = 3000, pat, baseUrl, https: httpsOptions, corsOptions, logLevel = 'info', enableToolTags } = config;
// Set up logging based on log level
const logDebug = logLevel === 'debug' ? console.debug : () => { };
const logInfo = ['debug', 'info'].includes(logLevel) ? console.info : () => { };
const logWarn = ['debug', 'info', 'warn'].includes(logLevel) ? console.warn : () => { };
const logError = console.error;
// Setup Express app
const app = express();
// Apply middleware
app.use(express.json());
app.use(compression());
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
},
},
}));
// Apply CORS if options provided
if (corsOptions) {
app.use(cors(corsOptions));
}
// Store transports by session ID
const transports = {};
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Main MCP endpoint for Streamable HTTP
app.all('/mcp', async (req, res) => {
logDebug(`Received ${req.method} request to /mcp`);
try {
// Check for existing session ID
const sessionId = req.headers['mcp-session-id'];
let transport;
if (sessionId && transports[sessionId]) {
// Check if the transport is of the correct type
const existingTransport = transports[sessionId];
if (existingTransport instanceof StreamableHTTPServerTransport) {
// Reuse existing transport
transport = existingTransport;
}
else {
// Transport exists but is not a StreamableHTTPServerTransport
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: Session exists but uses a different transport protocol',
},
id: null,
});
return;
}
}
else if (!sessionId && req.method === 'POST' && isInitializeRequest(req.body)) {
const eventStore = new InMemoryEventStore();
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore, // Enable resumability
onsessioninitialized: (sessionId) => {
// Store the transport by session ID when session is initialized
logDebug(`StreamableHTTP session initialized with ID: ${sessionId}`);
transports[sessionId] = transport;
}
});
// Set up onclose handler to clean up transport when closed
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && transports[sid]) {
logDebug(`Transport closed for session ${sid}, removing from transports map`);
delete transports[sid];
}
};
// Connect the transport to the MCP server
const mcpServerConfig = {
baseUrl,
pat,
enableToolTags
};
const server = getServer(mcpServerConfig);
await server.connect(transport);
}
else {
// Invalid request - no session ID or not initialization request
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
});
return;
}
// Handle the request with the transport
await transport.handleRequest(req, res, req.body);
}
catch (error) {
logError('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
});
}
}
});
// SSE endpoint
app.get('/sse', async (req, res) => {
logDebug('Received GET request to /sse');
const transport = new SSEServerTransport('/messages', res);
transports[transport.sessionId] = transport;
res.on("close", () => {
delete transports[transport.sessionId];
});
const mcpServerConfig = {
baseUrl,
pat,
};
const server = getServer(mcpServerConfig);
await server.connect(transport);
});
// Messages endpoint for SSE
app.post("/messages", async (req, res) => {
const sessionId = req.query.sessionId;
let transport;
const existingTransport = transports[sessionId];
if (existingTransport instanceof SSEServerTransport) {
// Reuse existing transport
transport = existingTransport;
}
else {
// Transport exists but is not a SSEServerTransport
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: Session exists but uses a different transport protocol',
},
id: null,
});
return;
}
if (transport) {
await transport.handlePostMessage(req, res, req.body);
}
else {
res.status(400).send('No transport found for sessionId');
}
});
// Create HTTP or HTTPS server
const server = httpsOptions
? https.createServer(httpsOptions, app)
: http.createServer(app);
return {
app,
server,
async start() {
return new Promise((resolve) => {
server.listen(port, () => {
logInfo(`🦋 Firefly III MCP server running on port ${port}`);
logInfo(`
==============================================
SUPPORTED TRANSPORT OPTIONS:
1. Streamable Http (Protocol version: 2025-03-26)
Endpoint: /mcp
Methods: GET, POST, DELETE
Usage:
- Initialize with POST to /mcp
- Establish SSE stream with GET to /mcp
- Send requests with POST to /mcp
- Terminate session with DELETE to /mcp
2. Http + SSE (Protocol version: 2024-11-05)
Endpoints: /sse (GET) and /messages (POST)
Usage:
- Establish SSE stream with GET to /sse
- Send requests with POST to /messages?sessionId=<id>
3. Health Check
Endpoint: /health
Method: GET
==============================================`);
resolve();
});
});
},
async stop() {
return new Promise((resolve, reject) => {
logInfo('Shutting down server...');
// Close all active transports to properly clean up resources
for (const sessionId in transports) {
try {
logDebug(`Closing transport for session ${sessionId}`);
transports[sessionId].close();
delete transports[sessionId];
}
catch (error) {
logWarn(`Error closing transport for session ${sessionId}:`, error);
}
}
server.close((err) => {
if (err) {
logError('Error shutting down server:', err);
reject(err);
}
else {
logInfo('Server shutdown complete');
resolve();
}
});
});
}
};
}
//# sourceMappingURL=server.js.map