@nataliapc/mcp-openmsx
Version:
Model context protocol server for openMSX automation and control
246 lines (241 loc) • 9.35 kB
JavaScript
/**
* MCP openMSX Server
*
* Model Context Protocol server that manages openMSX emulator instances
* through TCL commands via stdio.
*
* @package @nataliapc/mcp-openmsx
* @author Natalia Pujol Cremades (@nataliapc)
* @license GPL2
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { randomUUID } from "node:crypto";
import express from "express";
import path from "path";
import { createRequire } from 'module';
import { openMSXInstance } from "./openmsx.js";
import { VectorDB } from "./vectordb.js";
import { detectOpenMSXShareDir } from "./utils.js";
import { registerTools } from "./server_tools.js";
import { registerResources } from "./server_resources.js";
import { registerPrompts } from "./server_prompts.js";
// Dynamically obtain PACKAGE_VERSION from package.json at runtime
const require = createRequire(import.meta.url);
export const PACKAGE_VERSION = require('../package.json').version;
const resourcesDir = path.join(path.dirname(new URL(import.meta.url).pathname), "../resources");
const vectorDbDir = path.join(path.dirname(new URL(import.meta.url).pathname), "../vector-db");
export const emuDirectories = {
OPENMSX_SHARE_DIR: '',
OPENMSX_EXECUTABLE: 'openmsx',
OPENMSX_REPLAYS_DIR: '',
OPENMSX_SCREENSHOT_DIR: '',
OPENMSX_SCREENDUMP_DIR: '',
MACHINES_DIR: '',
EXTENSIONS_DIR: '',
};
// ============================================================================
// Cleanup handlers for graceful shutdown of MCP server
// Ensure openMSX emulator is closed when MCP server stops
let isShuttingDown = false;
async function gracefulShutdown(exitCode = 0) {
if (isShuttingDown)
return;
isShuttingDown = true;
try {
// Try async close first
await Promise.race([
openMSXInstance.emu_close(),
new Promise(resolve => setTimeout(resolve, 2000)) // 2 second timeout
]);
}
catch (error) {
// If async close fails or times out, force close
openMSXInstance.forceClose();
}
// Give a moment for cleanup to complete
setTimeout(() => {
process.exit(exitCode);
}, 100);
}
// Handle process termination signals
process.on('SIGINT', () => gracefulShutdown(0));
process.on('SIGTERM', () => gracefulShutdown(0));
// Handle when the transport connection is closed (more reliable for MCP)
process.on('disconnect', () => gracefulShutdown(0));
// Handle uncaught exceptions and unhandled rejections
process.on('uncaughtException', async (error) => {
await gracefulShutdown(1);
});
process.on('unhandledRejection', async (reason, promise) => {
await gracefulShutdown(1);
});
// Additional cleanup when the process is about to exit
process.on('exit', () => {
// This is synchronous only - can't use async here
// Force close as last resort
openMSXInstance.forceClose();
});
// ============================================================================
// Help function to display usage information
//
function showHelp() {
console.log(`
MCP-openMSX Server v${PACKAGE_VERSION}
Model Context Protocol server for openMSX emulator automation
Usage:
mcp-openmsx [transport]
Transport options:
stdio Use stdio transport (default)
http Use HTTP transport
Environment variables:
OPENMSX_EXECUTABLE Path to openMSX executable
OPENMSX_SHARE_DIR openMSX share directory
OPENMSX_SCREENSHOT_DIR Screenshot output directory
OPENMSX_SCREENDUMP_DIR Screen dump output directory
OPENMSX_REPLAYS_DIR Replay output directory
MCP_HTTP_PORT HTTP server port (default: 3000)
Examples:
mcp-openmsx # stdio transport
mcp-openmsx http # HTTP transport
MCP_TRANSPORT=http mcp-openmsx # HTTP via environment
`);
}
// ============================================================================
// Start the server
//
async function startHttpServer() {
const app = express();
app.use(express.json());
const transports = {};
// Handle POST requests for client-to-server communication
app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
let transport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
}
else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
transports[sessionId] = transport;
}
});
transport.onclose = () => {
if (transport.sessionId) {
delete transports[transport.sessionId];
}
};
// Create a new server instance for this session
const httpServer = await createServerInstance();
await httpServer.connect(transport);
}
else {
res.status(400).json({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null,
});
return;
}
await transport.handleRequest(req, res, req.body);
});
// Reusable handle GET / DELETE requests
const handleSessionRequest = async (req, res) => {
const sessionId = req.headers['mcp-session-id'];
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
const transport = transports[sessionId];
await transport.handleRequest(req, res);
};
app.get('/mcp', handleSessionRequest);
app.delete('/mcp', handleSessionRequest);
const port = process.env.MCP_HTTP_PORT || 3000;
app.listen(port, () => {
console.log(`MCP Server listening on port ${port}`);
});
}
async function createServerInstance() {
// Create a new server instance (you might want to extract server creation logic)
const newServer = new McpServer({
name: "mcp-openmsx",
version: PACKAGE_VERSION,
});
// Re-register all tools (you might want to extract this to a separate function)
try {
await registerResources(newServer, resourcesDir);
await registerTools(newServer, emuDirectories);
await registerPrompts(newServer);
}
catch (error) {
console.error("Error registering tools/resources:", error);
throw error;
}
return newServer;
}
// ============================================================================
// Main function to start the MCP server
//
async function main() {
// Handle CLI arguments
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
showHelp();
return;
}
if (args.includes('--version') || args.includes('-v')) {
console.log(PACKAGE_VERSION);
return;
}
// Environment variables setup
if (process.env.OPENMSX_EXECUTABLE) {
emuDirectories.OPENMSX_EXECUTABLE = process.env.OPENMSX_EXECUTABLE;
}
if (process.env.OPENMSX_SCREENSHOT_DIR && process.env.OPENMSX_SCREENSHOT_DIR !== '') {
emuDirectories.OPENMSX_SCREENSHOT_DIR = process.env.OPENMSX_SCREENSHOT_DIR;
}
if (process.env.OPENMSX_SCREENDUMP_DIR && process.env.OPENMSX_SCREENDUMP_DIR !== '') {
emuDirectories.OPENMSX_SCREENDUMP_DIR = process.env.OPENMSX_SCREENDUMP_DIR;
}
if (process.env.OPENMSX_REPLAYS_DIR && process.env.OPENMSX_REPLAYS_DIR !== '') {
emuDirectories.OPENMSX_REPLAYS_DIR = process.env.OPENMSX_REPLAYS_DIR;
}
if (process.env.OPENMSX_SHARE_DIR && process.env.OPENMSX_SHARE_DIR !== '') {
emuDirectories.OPENMSX_SHARE_DIR = process.env.OPENMSX_SHARE_DIR;
}
else {
// Auto-detect openMSX share directory if not set
const detectedShareDir = detectOpenMSXShareDir();
if (detectedShareDir) {
emuDirectories.OPENMSX_SHARE_DIR = detectedShareDir;
console.warn(`Auto-detected openMSX share folder: ${emuDirectories.OPENMSX_SHARE_DIR}`);
}
else {
console.error("Error: OPENMSX_SHARE_DIR environment variable is not set and could not be auto-detected.");
}
}
emuDirectories.MACHINES_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'machines');
emuDirectories.EXTENSIONS_DIR = path.join(emuDirectories.OPENMSX_SHARE_DIR, 'extensions');
VectorDB.setIndexDirectory(vectorDbDir);
// Detect transport type from environment or command line
const transportType = process.env.MCP_TRANSPORT || process.argv[2] || 'stdio';
if (transportType === 'http') {
// Start Streamable HTTP server
await startHttpServer();
}
else {
// Default to stdio
const transport = new StdioServerTransport();
(await createServerInstance()).connect(transport);
}
}
main().catch((error) => {
gracefulShutdown(1);
process.exit(1);
});