@nataliapc/mcp-openmsx 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,912 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MCP openMSX Server
4
+ *
5
+ * Model Context Protocol server that manages openMSX emulator instances
6
+ * through TCL commands via stdio.
7
+ *
8
+ * @package @nataliapc/mcp-openmsx
9
+ * @version 1.0.0
10
+ * @author Natalia Pujol Cremades (@nataliapc)
11
+ * @license GPL2
12
+ */
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
16
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
17
+ import { randomUUID } from "node:crypto";
18
+ import { z } from "zod";
19
+ import express from "express";
20
+ import fs from "fs/promises";
21
+ import path from "path";
22
+ import { openMSXInstance } from "./openmsx.js";
23
+ // Version info for CLI
24
+ const PACKAGE_VERSION = "1.0.0";
25
+ // Defaults for openMSX paths
26
+ var OPENMSX_EXECUTABLE = 'openmsx';
27
+ var OPENMSX_SHARE_DIR = '/usr/share/openmsx';
28
+ var OPENMSX_SCREENSHOT_DIR = '';
29
+ var OPENMSX_SCREENDUMP_DIR = '';
30
+ var MACHINES_DIR = `${OPENMSX_SHARE_DIR}/machines`;
31
+ var EXTENSIONS_DIR = `${OPENMSX_SHARE_DIR}/extensions`;
32
+ // ============================================================================
33
+ // Tools available in the MCP server
34
+ // https://modelcontextprotocol.io/docs/concepts/tools#tool-definition-structure
35
+ //
36
+ function registerAllTools(server) {
37
+ server.tool(
38
+ // Name of the tool (used to call it)
39
+ "emu_control",
40
+ // Description of the tool (what it does)
41
+ "Controls an openMSX emulator. Commands: " +
42
+ "'launch [machine] [extensions]': opens a powered on openMSX emulator, machine and extensions parameters could be specified, always use 'machine_list' and 'extension_list' resources to obtain valid values. " +
43
+ "'close': closes the openMSX emulator. " +
44
+ "'powerOn': powers on the openMSX emulator. " +
45
+ "'powerOff': powers off the openMSX emulator. " +
46
+ "'reset': resets the current machine. " +
47
+ "'getEmulatorSpeed': get current emulator speed in percentage, default is 100. " +
48
+ "'setEmulatorSpeed <emuspeed>': set the emulator speed in percentage, valid values are 1-10000, default is 100. " +
49
+ "'machineList': get a list of all available MSX machines that can be emulated with openMSX. " +
50
+ "'extensionList': get a list of all available MSX extensions that can be used with openMSX. ",
51
+ // Schema for the tool (input validation)
52
+ {
53
+ command: z.enum(["launch", "close", "powerOn", "powerOff", "reset", "getEmulatorSpeed", "setEmulatorSpeed", "machineList", "extensionList"]),
54
+ machine: z.string().min(1).max(100).optional(),
55
+ extensions: z.array(z.string().min(1).max(100)).optional(),
56
+ emuspeed: z.number().min(1).max(10000).optional().default(100),
57
+ },
58
+ // Handler for the tool (function to be executed when the tool is called)
59
+ async ({ command, machine, extensions, emuspeed }) => {
60
+ let result = "Error";
61
+ switch (command) {
62
+ case "launch":
63
+ result = await openMSXInstance.emu_launch(OPENMSX_EXECUTABLE, machine || "", extensions || []);
64
+ // Check if launch was successful
65
+ if (result === "Ok") {
66
+ result = "openMSX emulator launched";
67
+ if (machine) {
68
+ result += ` with machine "${machine}"`;
69
+ }
70
+ if (extensions && extensions.length > 0) {
71
+ if (machine) {
72
+ result += ' and ';
73
+ }
74
+ result += ` with extensions: ${extensions.join(', ')}`;
75
+ }
76
+ }
77
+ break;
78
+ case "close":
79
+ result = await openMSXInstance.emu_close();
80
+ break;
81
+ case "powerOn":
82
+ result = await openMSXInstance.sendCommand('set power on');
83
+ result += result === "Ok" ? ": openMSX emulator powered on" : "";
84
+ break;
85
+ case "powerOff":
86
+ result = await openMSXInstance.sendCommand('set power off');
87
+ result += result === "Ok" ? ": openMSX emulator powered off" : "";
88
+ break;
89
+ case "reset":
90
+ result = await openMSXInstance.sendCommand('reset');
91
+ result += result === "Ok" ? ": openMSX emulator reset" : "";
92
+ break;
93
+ case 'getEmulatorSpeed':
94
+ result = await openMSXInstance.sendCommand('set speed');
95
+ result = !isNaN(Number(result)) ? `Current emulator speed is ${result}%` : result;
96
+ break;
97
+ case 'setEmulatorSpeed':
98
+ result = await openMSXInstance.sendCommand(`set speed ${emuspeed}`);
99
+ result = !isNaN(Number(result)) ? `Emulator speed set to ${emuspeed}%` : result;
100
+ break;
101
+ case "machineList":
102
+ result = await openMSXInstance.getMachineList(MACHINES_DIR);
103
+ break;
104
+ case "extensionList":
105
+ result = await openMSXInstance.getExtensionList(EXTENSIONS_DIR);
106
+ break;
107
+ default:
108
+ result = `Error: Unknown command "${command}".`;
109
+ break;
110
+ }
111
+ // Return result with proper format for MCP
112
+ return {
113
+ content: [{
114
+ type: "text",
115
+ text: result,
116
+ }],
117
+ };
118
+ });
119
+ server.tool(
120
+ // Name of the tool (used to call it)
121
+ "emu_media",
122
+ // Description of the tool (what it does)
123
+ "Manage tapes, rom cartridges, and floppy disks. Commands: " +
124
+ "'tapeInsert <tapefile>': insert a valid tape file (*.cas, *.wav, *.tsx). " +
125
+ "'tapeRewind': rewind the current tape. " +
126
+ "'tapeEject': remove tape from virtual cassette player. " +
127
+ "'romInsert <romfile>': insert a valid ROM cartridge file (*.rom) at cartridge slot A. " +
128
+ "'romEject': remove the current ROM cartridge from cartridge slot A. " +
129
+ "'diskInsert <diskfile>': insert a valid disk file (*.dsk) in floppy disk A. " +
130
+ "'diskInsertFolder <diskfolder>': use a host folder as a floppy disk A root directory. " +
131
+ "'diskEject': remove the current disk from floppy disk A. ",
132
+ // Schema for the tool (input validation)
133
+ {
134
+ command: z.enum(["tapeInsert", "tapeRewind", "tapeEject", "romInsert", "romEject", "diskInsert", "diskInsertFolder", "diskEject"]),
135
+ tapefile: z.string().min(1).max(200).optional(), // Tape file to insert
136
+ romfile: z.string().min(1).max(200).optional(), // ROM file to insert
137
+ diskfile: z.string().min(1).max(200).optional(), // Disk file to insert
138
+ diskfolder: z.string().min(1).max(200).optional(), // Disk folder to insert
139
+ },
140
+ // Handler for the tool (function to be executed when the tool is called)
141
+ async ({ command, tapefile, romfile, diskfile, diskfolder }) => {
142
+ let tclCommand;
143
+ switch (command) {
144
+ case "tapeInsert":
145
+ tclCommand = `cassetteplayer insert "${tapefile}"`;
146
+ break;
147
+ case "tapeRewind":
148
+ tclCommand = "cassetteplayer rewind";
149
+ break;
150
+ case "tapeEject":
151
+ tclCommand = "cassetteplayer eject";
152
+ break;
153
+ case "romInsert":
154
+ tclCommand = `carta insert "${romfile}"`;
155
+ break;
156
+ case "romEject":
157
+ tclCommand = "carta eject";
158
+ break;
159
+ case "diskInsert":
160
+ tclCommand = `diska insert "${diskfile}"`;
161
+ break;
162
+ case "diskInsertFolder":
163
+ tclCommand = `diska insert "${diskfolder}"`;
164
+ break;
165
+ case "diskEject":
166
+ tclCommand = "diska eject";
167
+ break;
168
+ default:
169
+ return {
170
+ content: [{
171
+ type: "text",
172
+ text: `Error: Unknown emulator media command "${command}".`,
173
+ }],
174
+ };
175
+ }
176
+ const response = await openMSXInstance.sendCommand(tclCommand);
177
+ // Return the response from openMSX
178
+ return {
179
+ content: [{
180
+ type: "text",
181
+ text: response === "" ? "Ok" : response,
182
+ }],
183
+ };
184
+ });
185
+ server.tool(
186
+ // Name of the tool (used to call it)
187
+ "emu_info",
188
+ // Description of the tool (what it does)
189
+ "Obtain informacion about the current emulated machine. Commands: " +
190
+ "'getStatus': returns the status of the openMSX emulator. " +
191
+ "'getSlotsMap': shows what devices/ROM/RAM are inserted into which slots. " +
192
+ "'getIOPortsMap': shows an overview about the I/O mapped devices. ",
193
+ // Schema for the tool (input validation)
194
+ {
195
+ command: z.enum(["getStatus", "getSlotsMap", "getIOPortsMap"]),
196
+ },
197
+ // Handler for the tool (function to be executed when the tool is called)
198
+ async ({ command }) => {
199
+ let tclCommand;
200
+ switch (command) {
201
+ case "getStatus":
202
+ return {
203
+ content: [{
204
+ type: "text",
205
+ text: await openMSXInstance.emu_status(),
206
+ }],
207
+ };
208
+ case "getSlotsMap":
209
+ tclCommand = "slotmap";
210
+ break;
211
+ case "getIOPortsMap":
212
+ tclCommand = "iomap";
213
+ break;
214
+ default:
215
+ return {
216
+ content: [{
217
+ type: "text",
218
+ text: `Error: Unknown emulator info command "${command}".`,
219
+ }],
220
+ };
221
+ }
222
+ const response = await openMSXInstance.sendCommand(tclCommand);
223
+ return {
224
+ content: [{
225
+ type: "text",
226
+ text: response,
227
+ }],
228
+ };
229
+ });
230
+ server.tool(
231
+ // Name of the tool (used to call it)
232
+ "emu_vdp",
233
+ // Description of the tool (what it does)
234
+ "Manage VDP (Video Display Processor). Commands: " +
235
+ "'getPalette': return the current V9938/V9958 colors palette in format RGB333. " +
236
+ "'getRegisters': return all the VDP register values. " +
237
+ "'getRegisterValue <register>': return the value of a specific VDP register (0-31) in decimal format. " +
238
+ "'setRegisterValue <register> <value>': set a hexadecimal value to a specific VDP register (0-31). " +
239
+ "'screenGetMode': returns the current screen mode (0-12) as a number which would also be used for the basic command SCREEN. " +
240
+ "'screenGetFullText': return the full content of an MSX text screen (screen 0 or 1) as a string, useful for debugging. ",
241
+ // Schema for the tool (input validation)
242
+ {
243
+ command: z.enum(["getPalette", "getRegisters", "getRegisterValue", "setRegisterValue", "screenGetMode", "screenGetFullText"]),
244
+ register: z.number().min(0).max(31).optional(), // Register to read/write
245
+ value: z.string().regex(/^0x[0-9a-fA-F]{2}$/).optional(),
246
+ },
247
+ // Handler for the tool (function to be executed when the tool is called)
248
+ async ({ command, register, value }) => {
249
+ let tclCommand;
250
+ switch (command) {
251
+ case "getPalette":
252
+ tclCommand = "palette";
253
+ break;
254
+ case "getRegisters":
255
+ tclCommand = "vdpregs";
256
+ break;
257
+ case "getRegisterValue":
258
+ tclCommand = `vdpreg ${register}`;
259
+ break;
260
+ case "setRegisterValue":
261
+ tclCommand = `vdpreg ${register} ${value}`;
262
+ break;
263
+ case "screenGetMode":
264
+ tclCommand = "get_screen_mode";
265
+ break;
266
+ case "screenGetFullText":
267
+ const response = await openMSXInstance.sendCommand('get_screen');
268
+ return response.startsWith('Error:') ? {
269
+ content: [{
270
+ type: "text",
271
+ text: response,
272
+ }]
273
+ } : {
274
+ content: [{
275
+ type: "text",
276
+ text: "The screen text is:",
277
+ }, {
278
+ type: "text",
279
+ text: response,
280
+ }],
281
+ };
282
+ default:
283
+ return {
284
+ content: [{
285
+ type: "text",
286
+ text: `Error: Unknown emulator vdp command "${command}".`,
287
+ }],
288
+ };
289
+ }
290
+ const response = await openMSXInstance.sendCommand(tclCommand);
291
+ return {
292
+ content: [{
293
+ type: "text",
294
+ text: response === "" ? "Ok" : response,
295
+ }],
296
+ };
297
+ });
298
+ server.tool(
299
+ // Name of the tool (used to call it)
300
+ "debug_run",
301
+ // Description of the tool (what it does)
302
+ "Control execution (break, continue, step). Commands: " +
303
+ "'break': to break CPU at current execution position. " +
304
+ "'isBreaked': to check if the CPU is currently in break state (1) or not (0). " +
305
+ "'continue': to continue execution after break. " +
306
+ "'stepIn': to execute one CPU instruction, go into subroutines. " +
307
+ "'stepOver': to execute one CPU instruction, but don't go into subroutines. " +
308
+ "'stepOut': to step out of the current subroutine. " +
309
+ "'stepBack': to step one instruction back in time. " +
310
+ "'runTo <address>': to run the CPU until it reaches the specified address. " +
311
+ "Note: Addresses and values are in hexadecimal format (e.g. 0x0000).",
312
+ // Schema for the tool (input validation)
313
+ {
314
+ command: z.enum(["break", "isBreaked", "continue", "stepIn", "stepOut", "stepOver", "stepBack", "runTo"]),
315
+ address: z.string().regex(/^0x[0-9a-fA-F]{4}$/).optional()
316
+ },
317
+ // Handler for the tool (function to be executed when the tool is called)
318
+ async ({ command, address }) => {
319
+ let tclCommand;
320
+ switch (command) {
321
+ case "break":
322
+ tclCommand = "debug break";
323
+ break;
324
+ case "isBreaked":
325
+ tclCommand = "debug breaked";
326
+ break;
327
+ case "continue":
328
+ tclCommand = "debug cont";
329
+ break;
330
+ case "stepIn":
331
+ tclCommand = "step_in";
332
+ break;
333
+ case "stepOver":
334
+ tclCommand = "step_over";
335
+ break;
336
+ case "stepOut":
337
+ tclCommand = "step_out";
338
+ break;
339
+ case "stepBack":
340
+ tclCommand = "step_back";
341
+ break;
342
+ case "runTo":
343
+ tclCommand = `run_to ${address}`;
344
+ break;
345
+ default:
346
+ return {
347
+ content: [{
348
+ type: "text",
349
+ text: `Error: Unknown debug command "${command}".`,
350
+ }],
351
+ };
352
+ }
353
+ const response = await openMSXInstance.sendCommand(tclCommand);
354
+ return {
355
+ content: [{
356
+ type: "text",
357
+ text: response,
358
+ }],
359
+ };
360
+ });
361
+ server.tool(
362
+ // Name of the tool (used to call it)
363
+ "debug_cpu",
364
+ // Description of the tool (what it does)
365
+ "Read/write CPU registers, CPU info, Stack pile, and Disassemble code from memory. Commands: " +
366
+ "'getCpuRegisters': to get an overview of all the CPU registers. " +
367
+ "'getRegister <register>': to get the decimal value of a specific CPU register (pc, sp, ix, iy, af, bc, de, hl, ixh, ixl, iyh, iyl, a, f, b, c, d, e, h, l, i, r, im, iff). " +
368
+ "'setRegister <register> <value>': to set the value of a specific CPU register (pc, sp, ix, iy, af, bc, de, hl, ixh, ixl, iyh, iyl, a, f, b, c, d, e, h, l, i, r, im, iff). " +
369
+ "'getStackPile': to get an overview of the CPU stack. " +
370
+ "'disassemble [address] [size]': to print disassembled instructions at the address parameter location or PC register if empty. " +
371
+ "'getActiveCpu': to return the active cpu: z80 or r800." +
372
+ "Note: Addresses and values are in hexadecimal format (e.g. 0x0000).",
373
+ // Schema for the tool (input validation)
374
+ {
375
+ command: z.enum(["getCpuRegisters", "getRegister", "setRegister", "getStackPile", "disassemble", "getActiveCpu"]),
376
+ register: z.enum(["pc", "sp", "ix", "iy", "af", "bc", "de", "hl", "ixh", "ixl", "iyh", "iyl", "a", "f", "b", "c", "d", "e", "h", "l", "i", "r", "im", "iff"]).optional(),
377
+ address: z.string().regex(/^0x[0-9a-fA-F]{4}$/), // 4 hex digits for MSX memory address
378
+ value: z.string().regex(/^0x[0-9a-fA-F]{2-4}$/).optional(), // 2-4 hex digits for byte value for writeByte command
379
+ size: z.number().min(1).max(50).optional().default(8), // Number of bytes for disassemble command
380
+ },
381
+ // Handler for the tool (function to be executed when the tool is called)
382
+ async ({ command, address, register, value, size }) => {
383
+ let tclCommand;
384
+ switch (command) {
385
+ case "getCpuRegisters":
386
+ tclCommand = "cpuregs";
387
+ break;
388
+ case "getRegister":
389
+ tclCommand = "reg ${register}";
390
+ break;
391
+ case "setRegister":
392
+ tclCommand = "reg ${register} ${value}";
393
+ break;
394
+ case "getStackPile":
395
+ tclCommand = "stack";
396
+ break;
397
+ case "disassemble":
398
+ tclCommand = `disasm ${address || ""} ${size || ""}`;
399
+ break;
400
+ case "getActiveCpu":
401
+ tclCommand = "get_active_cpu";
402
+ break;
403
+ default:
404
+ return {
405
+ content: [{
406
+ type: "text",
407
+ text: `Error: Unknown memory command "${command}".`,
408
+ }],
409
+ };
410
+ }
411
+ const response = await openMSXInstance.sendCommand(tclCommand);
412
+ return {
413
+ content: [{
414
+ type: "text",
415
+ text: response,
416
+ }],
417
+ };
418
+ });
419
+ server.tool(
420
+ // Name of the tool (used to call it)
421
+ "debug_memory",
422
+ // Description of the tool (what it does)
423
+ "Slots info, and Read/write from/to memory in the openMSX emulator. Commands: " +
424
+ "'selectedSlots': to get a list of the currently selected memory slots. " +
425
+ "'getBlock <address> [lines]': to read a block of memory from the specified address. " +
426
+ "'readByte <address>': to read a BYTE from the specified address. " +
427
+ "'readWord <address>': to read a WORD from the specified address. " +
428
+ "'writeByte <address> <value8>': to write a BYTE to the specified address. " +
429
+ "'writeWord <address> <value16>': to write a WORD to the specified address. " +
430
+ "'advanced_basic_listing': to list the current BASIC program, with the ram address of each line listed. " +
431
+ "Note: Addresses and values are in hexadecimal format (e.g. 0x0000).",
432
+ // Schema for the tool (input validation)
433
+ {
434
+ command: z.enum(["selectedSlots", "getBlock", "readByte", "readWord", "writeByte", "writeWord", "advanced_basic_listing"]),
435
+ address: z.string().regex(/^0x[0-9a-fA-F]{4}$/).optional(), // 4 hex digits for MSX memory address
436
+ lines: z.number().min(1).max(50).optional().default(8), // Number of lines for getBlock command
437
+ value8: z.string().regex(/^0x[0-9a-fA-F]{2}$/).optional(), // 2 hex digits for byte value for writeByte command
438
+ value16: z.string().regex(/^0x[0-9a-fA-F]{4}$/).optional(), // 4 hex digits for byte value for writeByte command
439
+ },
440
+ // Handler for the tool (function to be executed when the tool is called)
441
+ async ({ command, address, lines, value8, value16 }) => {
442
+ let tclCommand;
443
+ switch (command) {
444
+ case "selectedSlots":
445
+ tclCommand = "slotselect";
446
+ break;
447
+ case "getBlock":
448
+ tclCommand = `showmem ${address} ${lines}`;
449
+ break;
450
+ case "readByte":
451
+ tclCommand = `peek ${address}`;
452
+ break;
453
+ case "readWord":
454
+ tclCommand = `peek16 ${address}`;
455
+ break;
456
+ case "writeByte":
457
+ tclCommand = `poke ${address} ${value8}`;
458
+ break;
459
+ case "writeWord":
460
+ tclCommand = `poke16 ${address} ${value16}`;
461
+ break;
462
+ case "advanced_basic_listing":
463
+ tclCommand = "listing";
464
+ break;
465
+ default:
466
+ return {
467
+ content: [{
468
+ type: "text",
469
+ text: `Error: Unknown memory command "${command}".`,
470
+ }],
471
+ };
472
+ }
473
+ const response = await openMSXInstance.sendCommand(tclCommand);
474
+ return {
475
+ content: [{
476
+ type: "text",
477
+ text: response,
478
+ }],
479
+ };
480
+ });
481
+ server.tool(
482
+ // Name of the tool (used to call it)
483
+ "debug_vram",
484
+ // Description of the tool (what it does)
485
+ "Read or write from/to VRAM video memory in the openMSX emulator. Commands: " +
486
+ "'getBlock <address> [lines]': to read a block of VRAM memory from the specified address. " +
487
+ "'readByte <address>': to read a BYTE from the specified VRAM address. " +
488
+ "'writeByte <address> <value8>': to write a BYTE to the specified VRAM address. " +
489
+ "Note: Addresses and values are in hexadecimal format (e.g. 0x0000).",
490
+ // Schema for the tool (input validation)
491
+ {
492
+ command: z.enum(["getBlock", "readByte", "writeByte"]),
493
+ address: z.string().regex(/^0x[0-9a-fA-F]{4}$/).optional(), // 4 hex digits for MSX memory address
494
+ lines: z.number().min(1).max(50).optional().default(8), // Number of lines for getBlock command
495
+ value8: z.string().regex(/^0x[0-9a-fA-F]{2}$/).optional(), // 2 hex digits for byte value for writeByte command
496
+ },
497
+ // Handler for the tool (function to be executed when the tool is called)
498
+ async ({ command, address, lines, value8 }) => {
499
+ let tclCommand;
500
+ switch (command) {
501
+ case "getBlock":
502
+ tclCommand = `showdebuggable VRAM ${address} ${lines}`;
503
+ break;
504
+ case "readByte":
505
+ tclCommand = `vpeek ${address}`;
506
+ break;
507
+ case "writeByte":
508
+ tclCommand = `vpoke ${address} ${value8}`;
509
+ break;
510
+ default:
511
+ return {
512
+ content: [{
513
+ type: "text",
514
+ text: `Error: Unknown video memory command "${command}".`,
515
+ }],
516
+ };
517
+ }
518
+ const response = await openMSXInstance.sendCommand(tclCommand);
519
+ return {
520
+ content: [{
521
+ type: "text",
522
+ text: response,
523
+ }],
524
+ };
525
+ });
526
+ server.tool(
527
+ // Name of the tool (used to call it)
528
+ "debug_breakpoints",
529
+ // Description of the tool (what it does)
530
+ "Create, remove, and list breakpoints. Commands: " +
531
+ "'create <address>': create a breakpoint at a specified address, and returns its name. " +
532
+ "'remove <bpname>': remove a breakpoint by name (e.g. bp#1). " +
533
+ "'list': enumerate the active breakpoints. " +
534
+ "Note: Addresses and values are in hexadecimal format (e.g. 0x0000). " +
535
+ "Note: The memory addresses of functions and variables can be previously obtained from *.sym or *.map files.",
536
+ // Schema for the tool (input validation)
537
+ {
538
+ command: z.enum(["create", "remove", "list"]),
539
+ address: z.string().regex(/^0x[0-9a-fA-F]{4}$/).optional(), // 4 hex digits for MSX memory address
540
+ bpname: z.string().min(3).max(10).optional(), // breakpoint name (e.g. bp#1)
541
+ },
542
+ // Handler for the tool (function to be executed when the tool is called)
543
+ async ({ command, address, bpname }) => {
544
+ let tclCommand;
545
+ switch (command) {
546
+ case "create":
547
+ tclCommand = `debug set_bp ${address}`;
548
+ break;
549
+ case "remove":
550
+ tclCommand = `debug remove_bp ${bpname}`;
551
+ break;
552
+ case "list":
553
+ tclCommand = 'debug list_bp';
554
+ break;
555
+ default:
556
+ return {
557
+ content: [{
558
+ type: "text",
559
+ text: `Error: Unknown breakpoint command "${command}".`,
560
+ }],
561
+ };
562
+ }
563
+ const response = await openMSXInstance.sendCommand(tclCommand);
564
+ return {
565
+ content: [{
566
+ type: "text",
567
+ text: response,
568
+ }],
569
+ };
570
+ });
571
+ server.tool(
572
+ // Name of the tool (used to call it)
573
+ "emu_savestates",
574
+ // Description of the tool (what it does)
575
+ "Load, save, and list savestates. Commands: " +
576
+ "'load <name>': restores a previously created savestate. " +
577
+ "'save <name>': creates a snapshot of the currently emulated MSX machine specifying a name for the savestate. " +
578
+ "'list': returns the names of all previously created savestates, separated by spaces. " +
579
+ "Note: names with spaces are enclosed in {}.",
580
+ // Schema for the tool (input validation)
581
+ {
582
+ command: z.enum(["load", "save", "list"]),
583
+ name: z.string().min(1).max(20).optional(), // breakpoint name (e.g. bp#1)
584
+ },
585
+ // Handler for the tool (function to be executed when the tool is called)
586
+ async ({ command, name }) => {
587
+ let tclCommand;
588
+ let textResponse = "Error:";
589
+ switch (command) {
590
+ case "load":
591
+ textResponse = "Loaded savestate: ";
592
+ tclCommand = `loadstate ${name}`;
593
+ break;
594
+ case "save":
595
+ textResponse = "Saved savestate: ";
596
+ tclCommand = `savestate ${name}`;
597
+ break;
598
+ case "list":
599
+ textResponse = "Savestate names: ";
600
+ tclCommand = 'list_savestates';
601
+ break;
602
+ default:
603
+ return {
604
+ content: [{
605
+ type: "text",
606
+ text: `Error: Unknown savestate command "${command}".`,
607
+ }],
608
+ };
609
+ }
610
+ const response = await openMSXInstance.sendCommand(tclCommand);
611
+ return {
612
+ content: [{
613
+ type: "text",
614
+ text: textResponse,
615
+ }, {
616
+ type: "text",
617
+ text: response,
618
+ }],
619
+ };
620
+ });
621
+ server.tool(
622
+ // Name of the tool (used to call it)
623
+ "emu_keyboard",
624
+ // Description of the tool (what it does)
625
+ "Send a text to the openMSX emulator. Commands: " +
626
+ "'sendText <text>': type a string in the emulated MSX, this command automatically press and release keys in the MSX keyboard matrix, is useful for automating tasks in BASIC, use \\r for Return key. ",
627
+ // Schema for the tool (input validation)
628
+ {
629
+ command: z.enum(["sendText"]),
630
+ text: z.string().min(1).max(100).optional().default(''), // Key to send to the emulator
631
+ },
632
+ // Handler for the tool (function to be executed when the tool is called)
633
+ async ({ command, text }) => {
634
+ let tclCommand;
635
+ switch (command) {
636
+ case "sendText":
637
+ tclCommand = `type "${text}"`;
638
+ break;
639
+ default:
640
+ return {
641
+ content: [{
642
+ type: "text",
643
+ text: `Error: Unknown keyboard command "${command}".`,
644
+ }],
645
+ };
646
+ }
647
+ const response = await openMSXInstance.sendCommand(tclCommand);
648
+ return {
649
+ content: [{
650
+ type: "text",
651
+ text: response,
652
+ }],
653
+ };
654
+ });
655
+ server.tool(
656
+ // Name of the tool (used to call it)
657
+ "screen_shot",
658
+ // Description of the tool (what it does)
659
+ "Take a screenshot of the openMSX emulator screen. Commands: " +
660
+ "'as_image': take a screenshot and the image is returned in the response. " +
661
+ "'to_file': take a screenshot and save it to a file, the file name is returned in the response.",
662
+ // Schema for the tool (input validation)
663
+ {
664
+ command: z.enum(["as_image", "to_file"]),
665
+ },
666
+ // Handler for the tool (function to be executed when the tool is called)
667
+ async ({ command }) => {
668
+ const openmsxCommand = `screenshot -raw -prefix "${OPENMSX_SCREENSHOT_DIR}mcp_"`;
669
+ const response = await openMSXInstance.sendCommand(openmsxCommand);
670
+ switch (command) {
671
+ case "as_image":
672
+ try {
673
+ const imageBuffer = await fs.readFile(response);
674
+ const base64image = imageBuffer.toString('base64');
675
+ return {
676
+ content: [{
677
+ type: "text",
678
+ text: "Screenshot taken successfully:",
679
+ }, {
680
+ type: "image",
681
+ data: base64image,
682
+ mimeType: "image/png",
683
+ }],
684
+ };
685
+ }
686
+ catch (error) {
687
+ return {
688
+ content: [{
689
+ type: "text",
690
+ text: 'Error creating screenshot: ' + response,
691
+ }, {
692
+ type: "text",
693
+ text: error instanceof Error ? error.message : String(error),
694
+ }],
695
+ };
696
+ }
697
+ case "to_file":
698
+ return {
699
+ content: [{
700
+ type: "text",
701
+ text: response.startsWith('Error:') ? response : 'Screenshot taken in file: ' + response,
702
+ }],
703
+ };
704
+ }
705
+ return {
706
+ content: [{
707
+ type: "text",
708
+ text: `Error: Unknown screen_shot command "${command}".`,
709
+ }],
710
+ };
711
+ });
712
+ server.tool(
713
+ // Name of the tool (used to call it)
714
+ "screen_dump",
715
+ // Description of the tool (what it does)
716
+ "Take a screendump of the openMSX emulator screen as SC?. The parameter scrbasename is the name of the filename (without path) to save the screendump, default is 'screendump'. ",
717
+ // Schema for the tool (input validation)
718
+ {
719
+ scrbasename: z.string().min(1).max(100).default("screendump"),
720
+ },
721
+ // Handler for the tool (function to be executed when the tool is called)
722
+ async ({ scrbasename }) => {
723
+ const openmsxCommand = `save_msx_screen "${OPENMSX_SCREENDUMP_DIR + scrbasename}"`;
724
+ const response = await openMSXInstance.sendCommand(openmsxCommand);
725
+ return {
726
+ content: [{
727
+ type: "text",
728
+ text: response,
729
+ }],
730
+ };
731
+ });
732
+ }
733
+ // ============================================================================
734
+ // Cleanup handlers for graceful shutdown of MCP server
735
+ // Ensure openMSX emulator is closed when MCP server stops
736
+ let isShuttingDown = false;
737
+ async function gracefulShutdown(exitCode = 0) {
738
+ if (isShuttingDown)
739
+ return;
740
+ isShuttingDown = true;
741
+ try {
742
+ // Try async close first
743
+ await Promise.race([
744
+ openMSXInstance.emu_close(),
745
+ new Promise(resolve => setTimeout(resolve, 2000)) // 2 second timeout
746
+ ]);
747
+ }
748
+ catch (error) {
749
+ // If async close fails or times out, force close
750
+ openMSXInstance.forceClose();
751
+ }
752
+ // Give a moment for cleanup to complete
753
+ setTimeout(() => {
754
+ process.exit(exitCode);
755
+ }, 100);
756
+ }
757
+ // Handle process termination signals
758
+ process.on('SIGINT', () => gracefulShutdown(0));
759
+ process.on('SIGTERM', () => gracefulShutdown(0));
760
+ // Handle when the transport connection is closed (more reliable for MCP)
761
+ process.on('disconnect', () => gracefulShutdown(0));
762
+ // Handle uncaught exceptions and unhandled rejections
763
+ process.on('uncaughtException', async (error) => {
764
+ await gracefulShutdown(1);
765
+ });
766
+ process.on('unhandledRejection', async (reason, promise) => {
767
+ await gracefulShutdown(1);
768
+ });
769
+ // Additional cleanup when the process is about to exit
770
+ process.on('exit', () => {
771
+ // This is synchronous only - can't use async here
772
+ // Force close as last resort
773
+ openMSXInstance.forceClose();
774
+ });
775
+ // ============================================================================
776
+ // Help function to display usage information
777
+ //
778
+ function showHelp() {
779
+ console.log(`
780
+ MCP-openMSX Server v${PACKAGE_VERSION}
781
+ Model Context Protocol server for openMSX emulator automation
782
+
783
+ Usage:
784
+ mcp-openmsx [transport]
785
+
786
+ Transport options:
787
+ stdio Use stdio transport (default)
788
+ http Use HTTP transport
789
+
790
+ Environment variables:
791
+ OPENMSX_EXECUTABLE Path to openMSX executable
792
+ OPENMSX_SHARE_DIR openMSX share directory
793
+ OPENMSX_SCREENSHOT_DIR Screenshot output directory
794
+ OPENMSX_SCREENDUMP_DIR Screen dump output directory
795
+ MCP_HTTP_PORT HTTP server port (default: 3000)
796
+
797
+ Examples:
798
+ mcp-openmsx # stdio transport
799
+ mcp-openmsx http # HTTP transport
800
+ MCP_TRANSPORT=http mcp-openmsx # HTTP via environment
801
+ `);
802
+ }
803
+ // ============================================================================
804
+ // Start the server
805
+ //
806
+ async function startHttpServer() {
807
+ const app = express();
808
+ app.use(express.json());
809
+ const transports = {};
810
+ // Handle POST requests for client-to-server communication
811
+ app.post('/mcp', async (req, res) => {
812
+ const sessionId = req.headers['mcp-session-id'];
813
+ let transport;
814
+ if (sessionId && transports[sessionId]) {
815
+ transport = transports[sessionId];
816
+ }
817
+ else if (!sessionId && isInitializeRequest(req.body)) {
818
+ transport = new StreamableHTTPServerTransport({
819
+ sessionIdGenerator: () => randomUUID(),
820
+ onsessioninitialized: (sessionId) => {
821
+ transports[sessionId] = transport;
822
+ }
823
+ });
824
+ transport.onclose = () => {
825
+ if (transport.sessionId) {
826
+ delete transports[transport.sessionId];
827
+ }
828
+ };
829
+ // Create a new server instance for this session
830
+ const httpServer = createServerInstance();
831
+ await httpServer.connect(transport);
832
+ }
833
+ else {
834
+ res.status(400).json({
835
+ jsonrpc: '2.0',
836
+ error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
837
+ id: null,
838
+ });
839
+ return;
840
+ }
841
+ await transport.handleRequest(req, res, req.body);
842
+ });
843
+ // Handle GET requests for server-to-client notifications via SSE
844
+ app.get('/mcp', async (req, res) => {
845
+ const sessionId = req.headers['mcp-session-id'];
846
+ if (!sessionId || !transports[sessionId]) {
847
+ res.status(400).send('Invalid or missing session ID');
848
+ return;
849
+ }
850
+ const transport = transports[sessionId];
851
+ await transport.handleRequest(req, res);
852
+ });
853
+ const port = process.env.MCP_HTTP_PORT || 3000;
854
+ app.listen(port, () => {
855
+ console.log(`MCP Server listening on port ${port}`);
856
+ });
857
+ }
858
+ function createServerInstance() {
859
+ // Create a new server instance (you might want to extract server creation logic)
860
+ const newServer = new McpServer({
861
+ name: "mcp-stdio-server",
862
+ version: "1.0.0"
863
+ });
864
+ // Re-register all tools (you might want to extract this to a separate function)
865
+ registerAllTools(newServer);
866
+ return newServer;
867
+ }
868
+ // ============================================================================
869
+ // Main function to start the MCP server
870
+ //
871
+ async function main() {
872
+ // Handle CLI arguments
873
+ const args = process.argv.slice(2);
874
+ if (args.includes('--help') || args.includes('-h')) {
875
+ showHelp();
876
+ return;
877
+ }
878
+ if (args.includes('--version') || args.includes('-v')) {
879
+ console.log(PACKAGE_VERSION);
880
+ return;
881
+ }
882
+ // Environment variables setup
883
+ if (process.env.OPENMSX_EXECUTABLE) {
884
+ OPENMSX_EXECUTABLE = process.env.OPENMSX_EXECUTABLE;
885
+ }
886
+ if (process.env.OPENMSX_SCREENSHOT_DIR && process.env.OPENMSX_SCREENSHOT_DIR !== '') {
887
+ OPENMSX_SCREENSHOT_DIR = process.env.OPENMSX_SCREENSHOT_DIR + path.sep;
888
+ }
889
+ if (process.env.OPENMSX_SCREENDUMP_DIR && process.env.OPENMSX_SCREENDUMP_DIR !== '') {
890
+ OPENMSX_SCREENDUMP_DIR = process.env.OPENMSX_SCREENDUMP_DIR + path.sep;
891
+ }
892
+ if (process.env.OPENMSX_SHARE_DIR) {
893
+ OPENMSX_SHARE_DIR = process.env.OPENMSX_SHARE_DIR + path.sep;
894
+ MACHINES_DIR = `${OPENMSX_SHARE_DIR}machines`;
895
+ EXTENSIONS_DIR = `${OPENMSX_SHARE_DIR}extensions`;
896
+ }
897
+ // Detect transport type from environment or command line
898
+ const transportType = process.env.MCP_TRANSPORT || process.argv[2] || 'stdio';
899
+ if (transportType === 'http') {
900
+ // Start Streamable HTTP server
901
+ await startHttpServer();
902
+ }
903
+ else {
904
+ // Default to stdio
905
+ const transport = new StdioServerTransport();
906
+ await createServerInstance().connect(transport);
907
+ }
908
+ }
909
+ main().catch((error) => {
910
+ gracefulShutdown(1);
911
+ process.exit(1);
912
+ });