@nataliapc/mcp-openmsx 1.2.3 → 1.2.5

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.
@@ -0,0 +1,178 @@
1
+ import { openMSXInstance } from "./openmsx.js";
2
+ import { isErrorResponse } from "./utils.js";
3
+ import { samplingFindMatches } from "./server_sampling.js";
4
+ // ============================================================================
5
+ // Default machines offered when no machine is specified in launch command
6
+ // ============================================================================
7
+ const DEFAULT_MACHINES = [
8
+ { const: 'C-BIOS_MSX1', title: 'MSX1 (C-BIOS, no system ROMs needed)' },
9
+ { const: 'Philips_VG_8020', title: 'MSX1 European (Philips VG-8020)' },
10
+ { const: 'C-BIOS_MSX2', title: 'MSX2 (C-BIOS, no system ROMs needed)' },
11
+ { const: 'Philips_NMS_8250', title: 'MSX2 European (Philips NMS-8250)' },
12
+ { const: 'C-BIOS_MSX2+', title: 'MSX2+ (C-BIOS, openMSX default)' },
13
+ { const: 'Panasonic_FS-A1GT', title: 'MSX turboR (Panasonic FS-A1GT)' },
14
+ ];
15
+ // ============================================================================
16
+ // Helper: Resolve machine and extensions for launch using elicitation + sampling
17
+ // ============================================================================
18
+ export async function resolveLaunchParams(server, emuDirectories, machine, extensions) {
19
+ // Load lists lazily only when needed
20
+ let machinesList;
21
+ let extensionsList;
22
+ const loadMachinesList = async () => {
23
+ if (!machinesList) {
24
+ const raw = await openMSXInstance.getMachineList(emuDirectories.MACHINES_DIR);
25
+ if (isErrorResponse(raw))
26
+ throw new Error(raw);
27
+ machinesList = JSON.parse(raw);
28
+ }
29
+ return machinesList;
30
+ };
31
+ const loadExtensionsList = async () => {
32
+ if (!extensionsList) {
33
+ const raw = await openMSXInstance.getExtensionList(emuDirectories.EXTENSIONS_DIR);
34
+ if (isErrorResponse(raw))
35
+ throw new Error(raw);
36
+ extensionsList = JSON.parse(raw);
37
+ }
38
+ return extensionsList;
39
+ };
40
+ // ── Resolve machine ──────────────────────────────────────────────────
41
+ let resolvedMachine = machine || '';
42
+ if (machine) {
43
+ // Check for exact match
44
+ const machines = await loadMachinesList();
45
+ const exactMatch = machines.find(m => m.name.toLowerCase() === machine.toLowerCase());
46
+ if (exactMatch) {
47
+ resolvedMachine = exactMatch.name;
48
+ }
49
+ else {
50
+ // Ambiguous machine name → use sampling to find suggestions, then elicit
51
+ try {
52
+ const suggestions = await samplingFindMatches(server, machine, machines, 'machine');
53
+ if (suggestions.length > 0) {
54
+ const elicitResult = await server.server.elicitInput({
55
+ mode: 'form',
56
+ message: `No exact match found for machine "${machine}". Please select the MSX machine to emulate:`,
57
+ requestedSchema: {
58
+ type: 'object',
59
+ properties: {
60
+ machine: {
61
+ type: 'string',
62
+ title: 'MSX Machine',
63
+ description: `Best matches for "${machine}"`,
64
+ oneOf: suggestions,
65
+ },
66
+ },
67
+ required: ['machine'],
68
+ },
69
+ });
70
+ if (elicitResult.action !== 'accept' || !elicitResult.content) {
71
+ return { machine: '', extensions: [], cancelled: true };
72
+ }
73
+ resolvedMachine = elicitResult.content.machine;
74
+ }
75
+ else {
76
+ // Sampling found nothing → use the original value (let openMSX handle the error)
77
+ resolvedMachine = machine;
78
+ }
79
+ }
80
+ catch {
81
+ // Client doesn't support sampling/elicitation → fallback
82
+ resolvedMachine = machine;
83
+ }
84
+ }
85
+ }
86
+ else {
87
+ // No machine provided → elicit with common defaults
88
+ try {
89
+ const elicitResult = await server.server.elicitInput({
90
+ mode: 'form',
91
+ message: 'No machine specified. Please select the MSX machine to emulate:',
92
+ requestedSchema: {
93
+ type: 'object',
94
+ properties: {
95
+ machine: {
96
+ type: 'string',
97
+ title: 'MSX Machine',
98
+ description: 'Select the type of MSX machine to launch',
99
+ oneOf: DEFAULT_MACHINES,
100
+ },
101
+ },
102
+ required: ['machine'],
103
+ },
104
+ });
105
+ if (elicitResult.action !== 'accept' || !elicitResult.content) {
106
+ return { machine: '', extensions: [], cancelled: true };
107
+ }
108
+ resolvedMachine = elicitResult.content.machine;
109
+ }
110
+ catch {
111
+ // Client doesn't support elicitation → launch with openMSX default
112
+ resolvedMachine = '';
113
+ }
114
+ }
115
+ // ── Resolve extensions ───────────────────────────────────────────────
116
+ let resolvedExtensions = extensions || [];
117
+ if (extensions && extensions.length > 0) {
118
+ try {
119
+ const allExtensions = await loadExtensionsList();
120
+ const extNameMap = new Map(allExtensions.map(e => [e.name.toLowerCase(), e]));
121
+ const matched = [];
122
+ const ambiguous = [];
123
+ for (const ext of extensions) {
124
+ const exact = extNameMap.get(ext.toLowerCase());
125
+ if (exact) {
126
+ matched.push(exact.name);
127
+ }
128
+ else {
129
+ ambiguous.push(ext);
130
+ }
131
+ }
132
+ if (ambiguous.length > 0) {
133
+ // Use sampling to find best matches for ALL ambiguous extensions at once
134
+ const ambiguousQuery = ambiguous.join(', ');
135
+ const suggestions = await samplingFindMatches(server, ambiguousQuery, allExtensions, 'extension', 6);
136
+ if (suggestions.length > 0) {
137
+ // Pre-select the already matched extensions in the suggestions
138
+ const elicitResult = await server.server.elicitInput({
139
+ mode: 'form',
140
+ message: `No exact match found for extension(s): ${ambiguous.map(e => `"${e}"`).join(', ')}. Please select the extensions to use:`,
141
+ requestedSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ extensions: {
145
+ type: 'array',
146
+ title: 'MSX Extensions',
147
+ description: `Best matches for: ${ambiguousQuery}`,
148
+ items: {
149
+ anyOf: suggestions,
150
+ },
151
+ },
152
+ },
153
+ required: ['extensions'],
154
+ },
155
+ });
156
+ if (elicitResult.action !== 'accept' || !elicitResult.content) {
157
+ return { machine: resolvedMachine, extensions: [], cancelled: true };
158
+ }
159
+ const selectedExtensions = elicitResult.content.extensions;
160
+ resolvedExtensions = [...matched, ...selectedExtensions];
161
+ }
162
+ else {
163
+ // Sampling found nothing → use original values
164
+ resolvedExtensions = extensions;
165
+ }
166
+ }
167
+ else {
168
+ // All extensions matched exactly
169
+ resolvedExtensions = matched;
170
+ }
171
+ }
172
+ catch {
173
+ // Client doesn't support sampling/elicitation → fallback
174
+ resolvedExtensions = extensions;
175
+ }
176
+ }
177
+ return { machine: resolvedMachine, extensions: resolvedExtensions };
178
+ }
@@ -0,0 +1,69 @@
1
+ import { z } from "zod";
2
+ import { basicInstructions } from "./server_resources.js";
3
+ // ============================================================================
4
+ // Prompts available in the MCP server
5
+ // https://modelcontextprotocol.io/docs/concepts/prompts
6
+ export async function registerPrompts(server) {
7
+ server.registerPrompt(
8
+ // Name of the prompt (used to call it)
9
+ "basic", {
10
+ title: "MSX BASIC Instruction Manual",
11
+ // Description of the prompt (what it does)
12
+ description: "MSX BASIC instructions manual",
13
+ // Schema for the prompt (input validation)
14
+ argsSchema: {
15
+ instruction: z.string()
16
+ .min(1, "Instruction name cannot be empty")
17
+ .max(50, "Instruction name too long")
18
+ .describe("Name of the MSX BASIC instruction to get information about (e.g. 'PRINT', 'FOR', 'POKE'). Case insensitive.")
19
+ .transform(val => val.trim().toUpperCase()),
20
+ },
21
+ },
22
+ // Handler for the prompt (function to be executed when the prompt is called)
23
+ ({ instruction }) => {
24
+ // Normalize instruction name for better matching
25
+ const normalizedInstruction = instruction.replace(/[()]/g, '').trim();
26
+ let prompt = '';
27
+ // Check if instruction is in the known list
28
+ const isKnownInstruction = basicInstructions.some(basic => basic.replace(/[()]/g, '').toUpperCase() === normalizedInstruction);
29
+ if (isKnownInstruction) {
30
+ prompt = `Provide comprehensive information about the MSX BASIC instruction '${instruction}'.
31
+ STRICT REQUIREMENTS:
32
+ - NEVER invent or assume any information not explicitly found in the MCP resources
33
+ - ONLY use information from 'msxdocs:' resources and tools #msxdocs_resource_get and #vector_db_query
34
+ - If specific details (ranges, values, behaviors) are not in the resources, state "Not specified in available documentation"
35
+ - Do NOT use general knowledge about MSX systems - stick ONLY to what the resources contain
36
+ - When citing parameters or ranges, quote them EXACTLY as written in the source documentation
37
+ MANDATORY SECTIONS:
38
+ - Include these sections if information is available in resources: 'Description', 'Syntax', 'Parameters', 'Notes', 'Usage examples', 'Related instructions', and 'Availability'.
39
+ - You MUST respond in the user language, unless otherwise specified.
40
+ SOURCES:
41
+ - Always cite the specific MCP resources used at the end in section 'Sources' with exact resource uri.
42
+ - For each resource, add a markdown link to the resource if external url is available.`;
43
+ }
44
+ else {
45
+ const suggestions = basicInstructions
46
+ .filter(basic => basic.toUpperCase().includes(normalizedInstruction) ||
47
+ normalizedInstruction.includes(basic.replace(/[()]/g, '').toUpperCase()))
48
+ .slice(0, 5);
49
+ // If the instruction is not known, suggest alternatives
50
+ prompt = `Explain that '${instruction}' does not appear to be a standard MSX BASIC instruction.
51
+ STRICT REQUIREMENTS:
52
+ - ONLY use information found in MCP resources via #vector_db_query tool
53
+ - Do NOT assume or invent any information about MSX BASIC instructions
54
+ - If suggesting alternatives, provide ONLY what is found in the search results
55
+ ${suggestions.length > 0 ? `Suggest one of these: ${suggestions.join(', ')} instructions as a correct search string. List them with a brief description using ONLY information found in the resources 'msxdocs://basic_wiki/{instruction}'` : 'Use #vector_db_query to search for similar instructions.'}
56
+ IMPORTANT: Review the available MSX BASIC instructions using the available MCP resources. You may still try to find information using the available resources, as it could be documented under a different name or as part of another instruction. Base your response EXCLUSIVELY on what the resources and tools return.
57
+ You MUST respond in the user language, unless otherwise specified.`;
58
+ }
59
+ return {
60
+ messages: [{
61
+ role: "assistant",
62
+ content: {
63
+ type: "text",
64
+ text: prompt,
65
+ },
66
+ }]
67
+ };
68
+ });
69
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @package @nataliapc/mcp-openmsx
3
+ * @author Natalia Pujol Cremades (@nataliapc)
4
+ * @license GPL2
5
+ */
6
+ import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import fs from "fs/promises";
8
+ import path from "path";
9
+ import { fetchCleanWebpage, addFileExtension, listResourcesDirectory } from "./utils.js";
10
+ // Source: https://www.msx.org/wiki/Category:MSX-BASIC_Instructions
11
+ export const basicInstructions = [
12
+ "ABS()", "AND", "ASC()", "ATN()", "AUTO", "BASE()", "BEEP", "BIN$()", "BLOAD", "BSAVE", "CALL", "CALL ADJUST",
13
+ "CALL IMPOSE", "CALL OPTIONS", "CALL PAUSE", "CALL PCMPLAY", "CALL PCMREC", "CDBL()", "CHR$()", "CINT()", "CIRCLE",
14
+ "CLEAR", "CLOAD", "CLOAD?", "CLOSE", "CLS", "COLOR", "COLOR=", "COLOR SPRITE()", "COLOR SPRITE$()", "CONT", "COPY",
15
+ "COPY SCREEN", "COS()", "CSAVE", "CSNG()", "CSRLIN", "DATA", "DEFDBL", "DEF FN", "DEFINT", "DEFSNG", "DEFSTR",
16
+ "DEF USR", "DELETE", "DIM", "DRAW", "ELSE", "END", "EOF()", "EQV", "ERASE", "ERL", "ERR", "ERROR", "EXP()", "FIELD",
17
+ "FIX()", "FN", "FOR...NEXT", "FRE()", "GET DATE", "GET TIME", "GOSUB", "GOTO", "HEX$()", "IF...GOTO...ELSE",
18
+ "IF...THEN...ELSE", "IMP", "INKEY$", "INP()", "INPUT", "INPUT$()", "INSTR()", "INT()", "INTERVAL", "KEY", "KEY()",
19
+ "LEFT$()", "LEN()", "LET", "LINE", "LINE INPUT", "LIST", "LLIST", "LOAD", "LOCATE", "LOG()", "LPOS()", "LPRINT",
20
+ "MAXFILES", "MERGE", "MID$()", "MOD", "MOTOR", "NEW", "NOT", "OCT$()", "ON...GOSUB", "ON...GOTO", "ON ERROR GOTO",
21
+ "ON INTERVAL GOSUB", "ON KEY GOSUB", "ON SPRITE GOSUB", "ON STOP GOSUB", "ON STRIG GOSUB", "OPEN", "OR", "OUT",
22
+ "PAD()", "PAINT", "PDL()", "PEEK()", "PLAY", "PLAY()", "POINT", "POKE", "POS()", "PRESET", "PRINT", "PSET",
23
+ "PUT KANJI", "PUT SPRITE", "READ", "REM", "RENUM", "RESTORE", "RESUME", "RETURN", "RIGHT$()", "RND()", "RUN",
24
+ "SAVE", "SCREEN", "SET ADJUST", "SET BEEP", "SET DATE", "SET PAGE", "SET PASSWORD", "SET PROMPT", "SET SCREEN",
25
+ "SET SCROLL", "SET TIME", "SET TITLE", "SET VIDEO", "SGN()", "SIN()", "SOUND", "SPACE$()", "SPC()", "SPRITE",
26
+ "SPRITE$()", "SQR()", "STICK()", "STOP", "STR$()", "STRIG()", "STRING$()", "SWAP", "TAB()", "TAN()", "TIME",
27
+ "TROFF", "TRON", "USR()", "VAL()", "VARPTR()", "VDP()", "VPEEK()", "VPOKE", "WAIT", "WIDTH", "XOR"
28
+ ];
29
+ const regResources = [];
30
+ export function getRegisteredResourcesList() {
31
+ return regResources;
32
+ }
33
+ // ============================================================================
34
+ // Resources available in the MCP server
35
+ // https://modelcontextprotocol.io/docs/concepts/resources
36
+ export async function registerResources(server, resourcesDir) {
37
+ // ============================================================================
38
+ // MSX Documentation resources
39
+ const resdocs = (await listResourcesDirectory(resourcesDir)).sort();
40
+ for (let index = 0; index < resdocs.length; index++) {
41
+ // Read the toc.json file if exists, otherwise skip this section
42
+ const sectionName = resdocs[index];
43
+ const tocFile = path.join(resourcesDir, `${sectionName}/toc.json`);
44
+ let tocContent = { toc: [] };
45
+ try {
46
+ tocContent = JSON.parse(await fs.readFile(tocFile, 'utf8'));
47
+ }
48
+ catch (error) {
49
+ // The toc.json file does not exist or is invalid, skip this section
50
+ continue;
51
+ }
52
+ // Register each item in the toc.json as a resource
53
+ tocContent.toc.forEach((item, itemIndex) => {
54
+ const itemName = path.parse(item.uri.split('/').pop()).base || '';
55
+ let resource = {
56
+ uri: item.uri,
57
+ filename: '',
58
+ resource: server.registerResource(
59
+ // Name of the resource (used to call it)
60
+ `msxdocs_${sectionName}_${item.title.replace(/[^a-z0-9]+/gi, '_').toLowerCase()}`,
61
+ // Resource URI template
62
+ item.uri,
63
+ // Metadata for the resource
64
+ {
65
+ title: item.title || `MSX Documentation '${sectionName}': ${itemName}`,
66
+ description: item.description || `Documentation for MSX resource '${sectionName}': ${itemName}`,
67
+ mimeType: item.mimeType || 'text/markdown',
68
+ annotations: {
69
+ "audience": ["user", "assistant"],
70
+ "priority": 0.8,
71
+ },
72
+ },
73
+ // Handler for the resource (function to be executed when the resource is called)
74
+ async (uri) => {
75
+ let resourceContent;
76
+ let mimeType;
77
+ if (uri.href.startsWith('http://') || uri.href.startsWith('https://')) {
78
+ // Fetch the resource from the URL
79
+ try {
80
+ [resourceContent, mimeType] = await fetchCleanWebpage(uri.href);
81
+ }
82
+ catch (error) {
83
+ // Throw exception (MCP protocol requirement)
84
+ throw error;
85
+ }
86
+ }
87
+ else {
88
+ // Read the resource from the local MCP server resources directory
89
+ try {
90
+ let resourceFile;
91
+ [mimeType, resourceFile] = await addFileExtension(path.join(resourcesDir, `${sectionName}/${itemName}`));
92
+ resourceContent = await fs.readFile(resourceFile, 'utf8');
93
+ }
94
+ catch (error) {
95
+ // Throw exception (MCP protocol requirement)
96
+ throw new Error(`Error reading resource ${sectionName}/${item.uri}: ${error instanceof Error ? error.message : String(error)}`);
97
+ }
98
+ }
99
+ return {
100
+ contents: [{
101
+ uri: uri.href,
102
+ text: resourceContent,
103
+ mimeType: mimeType || 'text/plain',
104
+ }],
105
+ };
106
+ })
107
+ };
108
+ regResources.push(resource);
109
+ });
110
+ }
111
+ ;
112
+ // Source: https://www.msx.org/wiki/Category:MSX-BASIC_Instructions
113
+ server.registerResource("msxdocs_basic_wiki", new ResourceTemplate("msxdocs://basic_wiki/{instruction}", {
114
+ list: undefined,
115
+ complete: {
116
+ instruction: (value) => basicInstructions,
117
+ },
118
+ }), {
119
+ title: "MSX BASIC Instructions Documentation",
120
+ description: "Documentation about all the standard MSX BASIC instructions from www.msx.org/wiki/Category:MSX-BASIC_Instructions",
121
+ mimeType: "text/html",
122
+ annotations: {
123
+ "audience": ["user", "assistant"],
124
+ "priority": 0.8,
125
+ },
126
+ }, async (uri, variables) => {
127
+ let instruction = variables.instruction;
128
+ let resourceContent;
129
+ let mimeType;
130
+ // urldecode the instruction to avoid issues with special characters
131
+ instruction = decodeURIComponent(instruction).replaceAll(' ', '_');
132
+ try {
133
+ let resourceFile;
134
+ [mimeType, resourceFile] = await addFileExtension(path.join(resourcesDir, 'programming', 'basic_wiki', instruction));
135
+ resourceContent = await fs.readFile(resourceFile, 'utf8');
136
+ }
137
+ catch (error) {
138
+ // Throw exception (MCP protocol requirement)
139
+ throw new Error(`Error reading resource programming/basic_wiki/${instruction}: ${error instanceof Error ? error.message : String(error)}`);
140
+ }
141
+ return {
142
+ contents: [{
143
+ uri: uri.href,
144
+ text: resourceContent,
145
+ mimeType: mimeType || 'text/plain',
146
+ }],
147
+ };
148
+ });
149
+ }
@@ -0,0 +1,35 @@
1
+ // ============================================================================
2
+ // Helper: Use sampling (LLM) to pick the best matches from a list
3
+ // ============================================================================
4
+ export async function samplingFindMatches(server, userQuery, items, itemType, maxResults = 6) {
5
+ // Build a compact list for the LLM
6
+ const itemList = items.map(m => `${m.name}: ${m.description}`).join('\n');
7
+ const samplingResult = await server.server.createMessage({
8
+ messages: [{
9
+ role: 'user',
10
+ content: {
11
+ type: 'text',
12
+ text: `The user wants to launch an MSX emulator with ${itemType}: "${userQuery}".\n\nHere is the full list of available ${itemType}s:\n${itemList}\n\nReturn ONLY the top ${maxResults} best matching ${itemType} names (exact names from the list), one per line, no numbering, no descriptions, no extra text. Most relevant first.`
13
+ }
14
+ }],
15
+ maxTokens: 300,
16
+ systemPrompt: `You are a helpful assistant that matches user queries to MSX ${itemType} names. Return ONLY exact names from the provided list, one per line. No extra text.`,
17
+ temperature: 0,
18
+ });
19
+ if (samplingResult.content.type !== 'text')
20
+ return [];
21
+ const suggestedNames = samplingResult.content.text
22
+ .split('\n')
23
+ .map(l => l.trim())
24
+ .filter(l => l.length > 0);
25
+ // Map back to items with validation
26
+ const nameMap = new Map(items.map(m => [m.name.toLowerCase(), m]));
27
+ const matches = [];
28
+ for (const name of suggestedNames) {
29
+ const item = nameMap.get(name.toLowerCase());
30
+ if (item && matches.length < maxResults) {
31
+ matches.push({ const: item.name, title: `${item.name} — ${item.description}` });
32
+ }
33
+ }
34
+ return matches;
35
+ }