mcp-mgba 0.1.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/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/mgba.d.ts +28 -0
- package/dist/mgba.d.ts.map +1 -0
- package/dist/mgba.js +110 -0
- package/dist/mgba.js.map +1 -0
- package/dist/tools.d.ts +4 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +258 -0
- package/dist/tools.js.map +1 -0
- package/docs/demo.gif +0 -0
- package/lua/bridge.lua +246 -0
- package/lua/json.lua +200 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dmang-dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# mcp-mgba
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server that exposes the [mGBA](https://mgba.io) Game Boy Advance emulator to any MCP-compatible client (Claude Desktop, Claude Code, etc.).
|
|
4
|
+
|
|
5
|
+
Lets your model **read and write GBA memory, inject button presses, take screenshots, and step the emulator** — all through a clean tool interface.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
*Claude driving an in-development homebrew side-scroller through `mgba_press_buttons` — Start to begin, A to confirm New Game, then Right to walk and A to jump. Each frame is captured via `mgba_screenshot`.*
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
+------------------+ stdio +------------------+ TCP :8765 +------------------+
|
|
15
|
+
| MCP client | JSON-RPC | mcp-mgba | newline JSON | mGBA emulator |
|
|
16
|
+
| (Claude / etc.) | ===========> | (Node.js) | ============> | bridge.lua |
|
|
17
|
+
+------------------+ +------------------+ +------------------+
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Two pieces:
|
|
21
|
+
- **`lua/bridge.lua`** — runs *inside* mGBA's scripting engine, opens a loopback TCP server on port 8765
|
|
22
|
+
- **`dist/index.js`** — Node.js MCP server, talks to the Lua bridge over TCP, exposes tools over stdio
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- [mGBA](https://mgba.io/downloads.html) **0.10 or newer** (with Lua scripting)
|
|
27
|
+
- **Node.js 18+** (for the MCP server)
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
### Option A — clone and install globally (recommended for now)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git clone https://github.com/dmang-dev/mcp-mgba
|
|
35
|
+
cd mcp-mgba
|
|
36
|
+
npm install -g .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
That puts `mcp-mgba` on your `PATH` (the build runs automatically via `npm install`'s `prepare` hook). Verify with `mcp-mgba --help` (it'll print a startup line and wait for stdio — `Ctrl+C` to exit).
|
|
40
|
+
|
|
41
|
+
### Option B — clone without global install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
git clone https://github.com/dmang-dev/mcp-mgba
|
|
45
|
+
cd mcp-mgba
|
|
46
|
+
npm install # runs the build automatically
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then reference the absolute path to `dist/index.js` when registering.
|
|
50
|
+
|
|
51
|
+
### Option C — `npx` from GitHub (no clone needed)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx -y github:dmang-dev/mcp-mgba
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`npx` will fetch, build (via `prepare`), and run the server in one shot.
|
|
58
|
+
|
|
59
|
+
## Set up the mGBA bridge
|
|
60
|
+
|
|
61
|
+
1. Launch mGBA and load any GBA ROM.
|
|
62
|
+
2. Open **Tools > Scripting…**
|
|
63
|
+
3. Click **File > Load script** and select `lua/bridge.lua` from this repo.
|
|
64
|
+
|
|
65
|
+
You should see in the scripting console:
|
|
66
|
+
```
|
|
67
|
+
[mcp-mgba] bridge listening on 127.0.0.1:8765
|
|
68
|
+
[mcp-mgba] frame callback registered — bridge is active
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If you see a `bind failed` error, the previous instance's socket is still held — quit and relaunch mGBA.
|
|
72
|
+
|
|
73
|
+
## Register with your MCP client
|
|
74
|
+
|
|
75
|
+
### Claude Code (CLI)
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
claude mcp add mgba --scope user mcp-mgba
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
(if you used Option B without global install, replace `mcp-mgba` with `node /absolute/path/to/dist/index.js`)
|
|
82
|
+
|
|
83
|
+
Verify:
|
|
84
|
+
```bash
|
|
85
|
+
claude mcp list
|
|
86
|
+
# mgba: mcp-mgba - ✓ Connected
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Claude Desktop
|
|
90
|
+
|
|
91
|
+
Edit `claude_desktop_config.json`:
|
|
92
|
+
|
|
93
|
+
| Platform | Path |
|
|
94
|
+
|---|---|
|
|
95
|
+
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
|
96
|
+
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
|
|
97
|
+
| Linux | `~/.config/Claude/claude_desktop_config.json` |
|
|
98
|
+
|
|
99
|
+
Add (assuming Option A — globally installed):
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"mcpServers": {
|
|
103
|
+
"mgba": {
|
|
104
|
+
"command": "mcp-mgba"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or with explicit Node + path (Option B):
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"mcpServers": {
|
|
114
|
+
"mgba": {
|
|
115
|
+
"command": "node",
|
|
116
|
+
"args": ["/absolute/path/to/mcp-mgba/dist/index.js"]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Restart Claude Desktop after editing.
|
|
123
|
+
|
|
124
|
+
### Other MCP clients
|
|
125
|
+
|
|
126
|
+
The server speaks standard MCP over stdio. Run `mcp-mgba` (or `node dist/index.js`) and connect any MCP client to its stdio.
|
|
127
|
+
|
|
128
|
+
## Configuration
|
|
129
|
+
|
|
130
|
+
| Env var | Default | Purpose |
|
|
131
|
+
|-------------|---------------|------------------------|
|
|
132
|
+
| `MGBA_HOST` | `127.0.0.1` | Bridge host to dial |
|
|
133
|
+
| `MGBA_PORT` | `8765` | Bridge port to dial |
|
|
134
|
+
|
|
135
|
+
## Tools
|
|
136
|
+
|
|
137
|
+
| Tool | Description |
|
|
138
|
+
|------|-------------|
|
|
139
|
+
| `mgba_ping` | Verify bridge connectivity (returns `pong`) |
|
|
140
|
+
| `mgba_get_info` | Game title, code, frame count |
|
|
141
|
+
| `mgba_read8` / `mgba_read16` / `mgba_read32` | Read memory at an address |
|
|
142
|
+
| `mgba_write8` / `mgba_write16` / `mgba_write32` | Write to RAM |
|
|
143
|
+
| `mgba_read_range` | Read up to 4096 bytes as a byte array |
|
|
144
|
+
| `mgba_press_buttons` | Hold GBA buttons for N frames |
|
|
145
|
+
| `mgba_advance_frames` | Step the emulator N frames |
|
|
146
|
+
| `mgba_pause` / `mgba_unpause` | Pause / resume emulation |
|
|
147
|
+
| `mgba_reset` | Reset the loaded ROM |
|
|
148
|
+
| `mgba_screenshot` | Save a PNG of the current display |
|
|
149
|
+
|
|
150
|
+
### GBA button names
|
|
151
|
+
|
|
152
|
+
`A`, `B`, `Select`, `Start`, `Right`, `Left`, `Up`, `Down`, `R`, `L`
|
|
153
|
+
|
|
154
|
+
### GBA address space (cheat sheet)
|
|
155
|
+
|
|
156
|
+
| Range | Region |
|
|
157
|
+
|----------------|-------------------------------|
|
|
158
|
+
| `0x02000000` | EWRAM (256 KiB, general) |
|
|
159
|
+
| `0x03000000` | IWRAM (32 KiB, fast) |
|
|
160
|
+
| `0x04000000` | I/O registers |
|
|
161
|
+
| `0x05000000` | Palette RAM |
|
|
162
|
+
| `0x06000000` | VRAM |
|
|
163
|
+
| `0x07000000` | OAM |
|
|
164
|
+
| `0x08000000` | ROM (read-only) |
|
|
165
|
+
|
|
166
|
+
## Troubleshooting
|
|
167
|
+
|
|
168
|
+
| Symptom | Cause / Fix |
|
|
169
|
+
|---|---|
|
|
170
|
+
| `Cannot reach mGBA bridge at 127.0.0.1:8765` | mGBA isn't running, or `bridge.lua` isn't loaded — open Tools > Scripting and load it |
|
|
171
|
+
| `bind failed — port 8765 may already be in use` | A previous mGBA instance still holds the socket; quit and relaunch mGBA |
|
|
172
|
+
| Tool calls hang | The bridge script may have errored out silently after a hot-reload — check the mGBA scripting console |
|
|
173
|
+
| Tools missing in Claude after install | Restart your MCP client; Claude only enumerates servers on startup |
|
|
174
|
+
|
|
175
|
+
## Development
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
npm install
|
|
179
|
+
npm run dev # tsc --watch — autobuilds on src/ changes
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The Lua side (`lua/bridge.lua` and `lua/json.lua`) needs no build step. Edit and reload via mGBA's `File > Load script`.
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
[MIT](LICENSE)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { MgbaClient } from "./mgba.js";
|
|
5
|
+
import { registerTools } from "./tools.js";
|
|
6
|
+
const HOST = process.env.MGBA_HOST ?? "127.0.0.1";
|
|
7
|
+
const PORT = parseInt(process.env.MGBA_PORT ?? "8765", 10);
|
|
8
|
+
async function main() {
|
|
9
|
+
const mgba = new MgbaClient(HOST, PORT);
|
|
10
|
+
// Connect eagerly — if mGBA isn't running the server still starts, but
|
|
11
|
+
// each tool call will return a clear "not connected" error rather than
|
|
12
|
+
// crashing the MCP host.
|
|
13
|
+
try {
|
|
14
|
+
await mgba.connect();
|
|
15
|
+
process.stderr.write(`[mcp-mgba] connected to mGBA bridge at ${HOST}:${PORT}\n`);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
process.stderr.write(`[mcp-mgba] WARNING: could not connect to mGBA bridge (${HOST}:${PORT}): ${err}\n` +
|
|
19
|
+
` Start mGBA, load a ROM, then open Tools > Scripting and run lua/bridge.lua.\n`);
|
|
20
|
+
}
|
|
21
|
+
const server = new Server({ name: "mcp-mgba", version: "0.1.0" }, { capabilities: { tools: {} } });
|
|
22
|
+
registerTools(server, mgba);
|
|
23
|
+
const transport = new StdioServerTransport();
|
|
24
|
+
await server.connect(transport);
|
|
25
|
+
process.stderr.write("[mcp-mgba] MCP server ready (stdio)\n");
|
|
26
|
+
}
|
|
27
|
+
main().catch((err) => {
|
|
28
|
+
process.stderr.write(`[mcp-mgba] fatal: ${err}\n`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
|
31
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACnE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE3C,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,WAAW,CAAC;AAClD,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,IAAI,MAAM,EAAE,EAAE,CAAC,CAAC;AAE3D,KAAK,UAAU,IAAI;IACjB,MAAM,IAAI,GAAG,IAAI,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAExC,uEAAuE;IACvE,uEAAuE;IACvE,yBAAyB;IACzB,IAAI,CAAC;QACH,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;QACrB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,0CAA0C,IAAI,IAAI,IAAI,IAAI,CAAC,CAAC;IACnF,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,yDAAyD,IAAI,IAAI,IAAI,MAAM,GAAG,IAAI;YAClF,0FAA0F,CAC3F,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,MAAM,CACvB,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,OAAO,EAAE,EACtC,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,CAChC,CAAC;IAEF,aAAa,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IAE5B,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,uCAAuC,CAAC,CAAC;AAChE,CAAC;AAED,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;IACnB,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;IACnD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
package/dist/mgba.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface RpcRequest {
|
|
2
|
+
id: number;
|
|
3
|
+
method: string;
|
|
4
|
+
params?: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export interface RpcResponse {
|
|
7
|
+
id: number | null;
|
|
8
|
+
result?: unknown;
|
|
9
|
+
error?: {
|
|
10
|
+
code: number;
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export declare class MgbaClient {
|
|
15
|
+
private readonly host;
|
|
16
|
+
private readonly port;
|
|
17
|
+
private socket;
|
|
18
|
+
private pending;
|
|
19
|
+
private nextId;
|
|
20
|
+
private buf;
|
|
21
|
+
private connectPromise;
|
|
22
|
+
constructor(host?: string, port?: number);
|
|
23
|
+
connect(): Promise<void>;
|
|
24
|
+
disconnect(): void;
|
|
25
|
+
get connected(): boolean;
|
|
26
|
+
call<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=mgba.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mgba.d.ts","sourceRoot":"","sources":["../src/mgba.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC3C;AAED,qBAAa,UAAU;IAQnB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,IAAI;IARvB,OAAO,CAAC,MAAM,CAA2B;IACzC,OAAO,CAAC,OAAO,CAAiD;IAChE,OAAO,CAAC,MAAM,CAAK;IACnB,OAAO,CAAC,GAAG,CAAM;IACjB,OAAO,CAAC,cAAc,CAA8B;gBAGjC,IAAI,GAAE,MAAoB,EAC1B,IAAI,GAAE,MAAa;IAGtC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAoDxB,UAAU,IAAI,IAAI;IAMlB,IAAI,SAAS,IAAI,OAAO,CAEvB;IAEK,IAAI,CAAC,CAAC,GAAG,OAAO,EACpB,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,OAAO,CAAC,CAAC,CAAC;CAwCd"}
|
package/dist/mgba.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import net from "net";
|
|
2
|
+
export class MgbaClient {
|
|
3
|
+
host;
|
|
4
|
+
port;
|
|
5
|
+
socket = null;
|
|
6
|
+
pending = new Map();
|
|
7
|
+
nextId = 1;
|
|
8
|
+
buf = "";
|
|
9
|
+
connectPromise = null;
|
|
10
|
+
constructor(host = "127.0.0.1", port = 8765) {
|
|
11
|
+
this.host = host;
|
|
12
|
+
this.port = port;
|
|
13
|
+
}
|
|
14
|
+
connect() {
|
|
15
|
+
if (this.connectPromise)
|
|
16
|
+
return this.connectPromise;
|
|
17
|
+
this.connectPromise = new Promise((resolve, reject) => {
|
|
18
|
+
const sock = net.createConnection({ host: this.host, port: this.port });
|
|
19
|
+
sock.setEncoding("utf8");
|
|
20
|
+
sock.once("connect", () => {
|
|
21
|
+
this.socket = sock;
|
|
22
|
+
resolve();
|
|
23
|
+
});
|
|
24
|
+
sock.once("error", (err) => {
|
|
25
|
+
this.connectPromise = null;
|
|
26
|
+
reject(err);
|
|
27
|
+
});
|
|
28
|
+
sock.on("data", (chunk) => {
|
|
29
|
+
this.buf += chunk;
|
|
30
|
+
let nl;
|
|
31
|
+
while ((nl = this.buf.indexOf("\n")) !== -1) {
|
|
32
|
+
const line = this.buf.slice(0, nl).trim();
|
|
33
|
+
this.buf = this.buf.slice(nl + 1);
|
|
34
|
+
if (line.length === 0)
|
|
35
|
+
continue;
|
|
36
|
+
let resp;
|
|
37
|
+
try {
|
|
38
|
+
resp = JSON.parse(line);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (resp.id != null) {
|
|
44
|
+
const cb = this.pending.get(resp.id);
|
|
45
|
+
if (cb) {
|
|
46
|
+
this.pending.delete(resp.id);
|
|
47
|
+
cb(resp);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
sock.on("close", () => {
|
|
53
|
+
this.socket = null;
|
|
54
|
+
this.connectPromise = null;
|
|
55
|
+
// Reject all in-flight calls
|
|
56
|
+
for (const cb of this.pending.values()) {
|
|
57
|
+
cb({ id: null, error: { code: -1, message: "connection closed" } });
|
|
58
|
+
}
|
|
59
|
+
this.pending.clear();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
return this.connectPromise;
|
|
63
|
+
}
|
|
64
|
+
disconnect() {
|
|
65
|
+
this.socket?.destroy();
|
|
66
|
+
this.socket = null;
|
|
67
|
+
this.connectPromise = null;
|
|
68
|
+
}
|
|
69
|
+
get connected() {
|
|
70
|
+
return this.socket !== null && !this.socket.destroyed;
|
|
71
|
+
}
|
|
72
|
+
async call(method, params) {
|
|
73
|
+
// Lazy (re)connect — bridge.lua reloads kill the socket, and the user
|
|
74
|
+
// shouldn't have to restart the MCP host every time they edit the script.
|
|
75
|
+
if (!this.socket || this.socket.destroyed) {
|
|
76
|
+
try {
|
|
77
|
+
await this.connect();
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw new Error(`Cannot reach mGBA bridge at ${this.host}:${this.port}. ` +
|
|
81
|
+
`Make sure mGBA is running with bridge.lua loaded (Tools > Scripting). ` +
|
|
82
|
+
`Underlying error: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const sock = this.socket;
|
|
87
|
+
if (!sock) {
|
|
88
|
+
reject(new Error("socket vanished after connect"));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const id = this.nextId++;
|
|
92
|
+
this.pending.set(id, (resp) => {
|
|
93
|
+
if (resp.error) {
|
|
94
|
+
reject(new Error(`mGBA RPC error [${resp.error.code}]: ${resp.error.message}`));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
resolve(resp.result);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
const msg = JSON.stringify({ id, method, params: params ?? {} }) + "\n";
|
|
101
|
+
sock.write(msg, (err) => {
|
|
102
|
+
if (err) {
|
|
103
|
+
this.pending.delete(id);
|
|
104
|
+
reject(err);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=mgba.js.map
|
package/dist/mgba.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mgba.js","sourceRoot":"","sources":["../src/mgba.ts"],"names":[],"mappings":"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"}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACxE,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AA0LvC,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,IAAI,CA+FpE"}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
// GBA memory map landmarks (useful in tool descriptions)
|
|
3
|
+
const GBA_REGIONS = `
|
|
4
|
+
GBA address space:
|
|
5
|
+
0x02000000 EWRAM (256 KiB, general-purpose)
|
|
6
|
+
0x03000000 IWRAM (32 KiB, fast stack/variables)
|
|
7
|
+
0x04000000 IO registers
|
|
8
|
+
0x05000000 Palette RAM (1 KiB)
|
|
9
|
+
0x06000000 VRAM (96 KiB)
|
|
10
|
+
0x07000000 OAM (1 KiB)
|
|
11
|
+
0x08000000 ROM (up to 32 MiB, read-only)`.trim();
|
|
12
|
+
const VALID_KEYS = ["A", "B", "Select", "Start", "Right", "Left", "Up", "Down", "R", "L"];
|
|
13
|
+
// ── Tool definitions ─────────────────────────────────────────────────────────
|
|
14
|
+
const TOOLS = [
|
|
15
|
+
{
|
|
16
|
+
name: "mgba_ping",
|
|
17
|
+
description: "Check connectivity to the mGBA bridge. Returns 'pong' if the emulator is running and the Lua bridge is loaded.",
|
|
18
|
+
inputSchema: { type: "object", properties: {} },
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "mgba_get_info",
|
|
22
|
+
description: "Get the currently-loaded game title, game code (e.g. AGBE), and frame count.",
|
|
23
|
+
inputSchema: { type: "object", properties: {} },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "mgba_read8",
|
|
27
|
+
description: `Read a single unsigned byte (u8) from a GBA memory address.\n\n${GBA_REGIONS}`,
|
|
28
|
+
inputSchema: {
|
|
29
|
+
type: "object",
|
|
30
|
+
required: ["address"],
|
|
31
|
+
properties: {
|
|
32
|
+
address: {
|
|
33
|
+
type: "integer",
|
|
34
|
+
description: "GBA memory address (decimal or hex — use 0x prefix in JSON strings, or pass as decimal integer)",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "mgba_read16",
|
|
41
|
+
description: "Read an unsigned 16-bit little-endian value from a GBA memory address. Address should be 2-byte aligned.",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
required: ["address"],
|
|
45
|
+
properties: {
|
|
46
|
+
address: { type: "integer", description: "GBA memory address (must be 2-byte aligned)" },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "mgba_read32",
|
|
52
|
+
description: "Read an unsigned 32-bit little-endian value from a GBA memory address. Address should be 4-byte aligned.",
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: "object",
|
|
55
|
+
required: ["address"],
|
|
56
|
+
properties: {
|
|
57
|
+
address: { type: "integer", description: "GBA memory address (must be 4-byte aligned)" },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "mgba_write8",
|
|
63
|
+
description: "Write a single byte value to a GBA memory address. Only works on RAM regions (EWRAM, IWRAM). Writing to ROM has no effect.",
|
|
64
|
+
inputSchema: {
|
|
65
|
+
type: "object",
|
|
66
|
+
required: ["address", "value"],
|
|
67
|
+
properties: {
|
|
68
|
+
address: { type: "integer", description: "GBA RAM address" },
|
|
69
|
+
value: { type: "integer", minimum: 0, maximum: 255, description: "Byte value (0–255)" },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "mgba_write16",
|
|
75
|
+
description: "Write a 16-bit value to a GBA memory address (little-endian). Address must be 2-byte aligned.",
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: "object",
|
|
78
|
+
required: ["address", "value"],
|
|
79
|
+
properties: {
|
|
80
|
+
address: { type: "integer", description: "GBA RAM address (2-byte aligned)" },
|
|
81
|
+
value: { type: "integer", minimum: 0, maximum: 65535, description: "16-bit value (0–65535)" },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "mgba_write32",
|
|
87
|
+
description: "Write a 32-bit value to a GBA memory address (little-endian). Address must be 4-byte aligned.",
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: "object",
|
|
90
|
+
required: ["address", "value"],
|
|
91
|
+
properties: {
|
|
92
|
+
address: { type: "integer", description: "GBA RAM address (4-byte aligned)" },
|
|
93
|
+
value: { type: "integer", minimum: 0, description: "32-bit value" },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "mgba_read_range",
|
|
99
|
+
description: "Read a contiguous range of bytes from GBA memory and return them as an array of integers. Maximum 4096 bytes per call.",
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: "object",
|
|
102
|
+
required: ["address", "length"],
|
|
103
|
+
properties: {
|
|
104
|
+
address: { type: "integer", description: "Start address" },
|
|
105
|
+
length: { type: "integer", minimum: 1, maximum: 4096, description: "Number of bytes to read" },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "mgba_press_buttons",
|
|
111
|
+
description: `Press one or more GBA buttons for a given number of frames. Valid button names: ${VALID_KEYS.join(", ")}.`,
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: "object",
|
|
114
|
+
required: ["buttons"],
|
|
115
|
+
properties: {
|
|
116
|
+
buttons: {
|
|
117
|
+
type: "array",
|
|
118
|
+
items: { type: "string", enum: VALID_KEYS },
|
|
119
|
+
description: "List of button names to hold simultaneously",
|
|
120
|
+
},
|
|
121
|
+
frames: {
|
|
122
|
+
type: "integer",
|
|
123
|
+
minimum: 1,
|
|
124
|
+
default: 1,
|
|
125
|
+
description: "Number of frames to hold the buttons (at 60 fps; default 1)",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "mgba_advance_frames",
|
|
132
|
+
description: "Advance emulation by N frames without returning to the event loop. Useful for precise timing in tests.",
|
|
133
|
+
inputSchema: {
|
|
134
|
+
type: "object",
|
|
135
|
+
properties: {
|
|
136
|
+
count: { type: "integer", minimum: 1, default: 1, description: "Number of frames to advance (default 1)" },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "mgba_pause",
|
|
142
|
+
description: "Pause emulation.",
|
|
143
|
+
inputSchema: { type: "object", properties: {} },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "mgba_unpause",
|
|
147
|
+
description: "Resume emulation after a pause.",
|
|
148
|
+
inputSchema: { type: "object", properties: {} },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "mgba_reset",
|
|
152
|
+
description: "Reset the currently-loaded ROM (equivalent to pressing the GBA reset button).",
|
|
153
|
+
inputSchema: { type: "object", properties: {} },
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: "mgba_screenshot",
|
|
157
|
+
description: "Take a screenshot of the current GBA display and save it to a file. Returns the saved file path.",
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: {
|
|
161
|
+
path: {
|
|
162
|
+
type: "string",
|
|
163
|
+
description: "Absolute file path to save the PNG (optional — defaults to a temp file)",
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
170
|
+
function ok(text) {
|
|
171
|
+
return { content: [{ type: "text", text }] };
|
|
172
|
+
}
|
|
173
|
+
function formatHex(n) {
|
|
174
|
+
if (typeof n !== "number")
|
|
175
|
+
return String(n);
|
|
176
|
+
return `${n} (0x${n.toString(16).toUpperCase()})`;
|
|
177
|
+
}
|
|
178
|
+
// ── Registration ─────────────────────────────────────────────────────────────
|
|
179
|
+
export function registerTools(server, mgba) {
|
|
180
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
181
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
182
|
+
const { name, arguments: args = {} } = req.params;
|
|
183
|
+
const p = args;
|
|
184
|
+
switch (name) {
|
|
185
|
+
case "mgba_ping": {
|
|
186
|
+
const r = await mgba.call("ping");
|
|
187
|
+
return ok(r);
|
|
188
|
+
}
|
|
189
|
+
case "mgba_get_info": {
|
|
190
|
+
const r = await mgba.call("get_info");
|
|
191
|
+
return ok(`Title: ${r.title}\nCode: ${r.code}\nFrame: ${r.frame}`);
|
|
192
|
+
}
|
|
193
|
+
case "mgba_read8": {
|
|
194
|
+
const v = await mgba.call("read8", { address: p.address });
|
|
195
|
+
return ok(`0x${p.address.toString(16).toUpperCase()}: ${formatHex(v)}`);
|
|
196
|
+
}
|
|
197
|
+
case "mgba_read16": {
|
|
198
|
+
const v = await mgba.call("read16", { address: p.address });
|
|
199
|
+
return ok(`0x${p.address.toString(16).toUpperCase()}: ${formatHex(v)}`);
|
|
200
|
+
}
|
|
201
|
+
case "mgba_read32": {
|
|
202
|
+
const v = await mgba.call("read32", { address: p.address });
|
|
203
|
+
return ok(`0x${p.address.toString(16).toUpperCase()}: ${formatHex(v)}`);
|
|
204
|
+
}
|
|
205
|
+
case "mgba_write8": {
|
|
206
|
+
await mgba.call("write8", { address: p.address, value: p.value });
|
|
207
|
+
return ok(`Wrote ${formatHex(p.value)} → 0x${p.address.toString(16).toUpperCase()}`);
|
|
208
|
+
}
|
|
209
|
+
case "mgba_write16": {
|
|
210
|
+
await mgba.call("write16", { address: p.address, value: p.value });
|
|
211
|
+
return ok(`Wrote ${formatHex(p.value)} → 0x${p.address.toString(16).toUpperCase()}`);
|
|
212
|
+
}
|
|
213
|
+
case "mgba_write32": {
|
|
214
|
+
await mgba.call("write32", { address: p.address, value: p.value });
|
|
215
|
+
return ok(`Wrote ${formatHex(p.value)} → 0x${p.address.toString(16).toUpperCase()}`);
|
|
216
|
+
}
|
|
217
|
+
case "mgba_read_range": {
|
|
218
|
+
const bytes = await mgba.call("read_range", {
|
|
219
|
+
address: p.address,
|
|
220
|
+
length: p.length,
|
|
221
|
+
});
|
|
222
|
+
const hex = bytes
|
|
223
|
+
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
|
|
224
|
+
.join(" ");
|
|
225
|
+
const addr = p.address.toString(16).toUpperCase();
|
|
226
|
+
return ok(`0x${addr} [${bytes.length} bytes]:\n${hex}`);
|
|
227
|
+
}
|
|
228
|
+
case "mgba_press_buttons": {
|
|
229
|
+
await mgba.call("press_buttons", { buttons: p.buttons, frames: p.frames ?? 1 });
|
|
230
|
+
const keys = p.buttons.join("+");
|
|
231
|
+
return ok(`Pressed ${keys} for ${p.frames ?? 1} frame(s)`);
|
|
232
|
+
}
|
|
233
|
+
case "mgba_advance_frames": {
|
|
234
|
+
const frame = await mgba.call("advance_frames", { count: p.count ?? 1 });
|
|
235
|
+
return ok(`Advanced ${p.count ?? 1} frame(s). Current frame: ${frame}`);
|
|
236
|
+
}
|
|
237
|
+
case "mgba_pause": {
|
|
238
|
+
await mgba.call("pause");
|
|
239
|
+
return ok("Emulation paused");
|
|
240
|
+
}
|
|
241
|
+
case "mgba_unpause": {
|
|
242
|
+
await mgba.call("unpause");
|
|
243
|
+
return ok("Emulation resumed");
|
|
244
|
+
}
|
|
245
|
+
case "mgba_reset": {
|
|
246
|
+
await mgba.call("reset");
|
|
247
|
+
return ok("ROM reset");
|
|
248
|
+
}
|
|
249
|
+
case "mgba_screenshot": {
|
|
250
|
+
const path = await mgba.call("screenshot", p.path ? { path: p.path } : {});
|
|
251
|
+
return ok(`Screenshot saved: ${path}`);
|
|
252
|
+
}
|
|
253
|
+
default:
|
|
254
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
//# sourceMappingURL=tools.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tools.js","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"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"}
|
package/docs/demo.gif
ADDED
|
Binary file
|
package/lua/bridge.lua
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
-- bridge.lua: mGBA scripting bridge for mcp-mgba
|
|
2
|
+
--
|
|
3
|
+
-- Exposes a newline-delimited JSON-RPC server on 127.0.0.1:8765.
|
|
4
|
+
-- Load via mGBA: Tools > Scripting... > Open Script (select this file).
|
|
5
|
+
--
|
|
6
|
+
-- json.lua must live in the same folder as this file.
|
|
7
|
+
-- socket is a pre-registered global in mGBA's Lua environment.
|
|
8
|
+
--
|
|
9
|
+
-- mGBA socket API (discovered via metatable probe):
|
|
10
|
+
-- bind, listen, accept, connect, send, receive, hasdata, poll, _hook
|
|
11
|
+
--
|
|
12
|
+
-- Requires mGBA >= 0.10.
|
|
13
|
+
|
|
14
|
+
local json = require("json")
|
|
15
|
+
|
|
16
|
+
local HOST = "127.0.0.1"
|
|
17
|
+
local PORT = 8765
|
|
18
|
+
|
|
19
|
+
-- ── GBA key name → bitmask bit index ────────────────────────────────────────
|
|
20
|
+
local KEY_BIT = {
|
|
21
|
+
A = 0, B = 1, Select = 2, Start = 3,
|
|
22
|
+
Right = 4, Left = 5, Up = 6, Down = 7,
|
|
23
|
+
R = 8, L = 9,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
-- ── Per-frame key-hold state ─────────────────────────────────────────────────
|
|
27
|
+
local hold_bits = 0
|
|
28
|
+
local hold_frames = 0
|
|
29
|
+
|
|
30
|
+
-- ── Command handlers ─────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
local function cmd_ping() return "pong" end
|
|
33
|
+
local function cmd_get_info()
|
|
34
|
+
return { title = emu:getGameTitle(), code = emu:getGameCode(), frame = emu:currentFrame() }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
-- emu:read8/16/32 are flaky when called repeatedly via pcall from the frame
|
|
38
|
+
-- callback ("invoking failed" intermittently). emu:readRange is reliable, so
|
|
39
|
+
-- we route the typed reads through it and decode little-endian on the Lua side.
|
|
40
|
+
local function cmd_read8(p)
|
|
41
|
+
local raw = emu:readRange(assert(p.address, "address required"), 1)
|
|
42
|
+
return raw:byte(1)
|
|
43
|
+
end
|
|
44
|
+
local function cmd_read16(p)
|
|
45
|
+
local raw = emu:readRange(assert(p.address, "address required"), 2)
|
|
46
|
+
return raw:byte(1) | (raw:byte(2) << 8)
|
|
47
|
+
end
|
|
48
|
+
local function cmd_read32(p)
|
|
49
|
+
local raw = emu:readRange(assert(p.address, "address required"), 4)
|
|
50
|
+
return raw:byte(1) | (raw:byte(2) << 8) | (raw:byte(3) << 16) | (raw:byte(4) << 24)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
-- emu:writeN — like emu:readN — intermittently throws "invoking failed" when
|
|
54
|
+
-- pcall'd from a frame callback. Retry up to a few times before giving up.
|
|
55
|
+
local function retry_call(fn, ...)
|
|
56
|
+
local attempts = 8
|
|
57
|
+
local last_err
|
|
58
|
+
for _ = 1, attempts do
|
|
59
|
+
local ok, err = pcall(fn, ...)
|
|
60
|
+
if ok then return true end
|
|
61
|
+
last_err = err
|
|
62
|
+
end
|
|
63
|
+
error(last_err)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
local function cmd_write8(p)
|
|
67
|
+
local addr = assert(p.address, "address required")
|
|
68
|
+
local val = assert(p.value, "value required")
|
|
69
|
+
retry_call(function() emu:write8(addr, val) end)
|
|
70
|
+
return true
|
|
71
|
+
end
|
|
72
|
+
local function cmd_write16(p)
|
|
73
|
+
local addr = assert(p.address, "address required")
|
|
74
|
+
local val = assert(p.value, "value required")
|
|
75
|
+
retry_call(function() emu:write16(addr, val) end)
|
|
76
|
+
return true
|
|
77
|
+
end
|
|
78
|
+
local function cmd_write32(p)
|
|
79
|
+
local addr = assert(p.address, "address required")
|
|
80
|
+
local val = assert(p.value, "value required")
|
|
81
|
+
retry_call(function() emu:write32(addr, val) end)
|
|
82
|
+
return true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
local function cmd_read_range(p)
|
|
86
|
+
local addr = assert(p.address, "address required")
|
|
87
|
+
local len = assert(p.length, "length required")
|
|
88
|
+
if len > 4096 then error("length exceeds 4096 byte limit") end
|
|
89
|
+
local raw = emu:readRange(addr, len)
|
|
90
|
+
local bytes = {}
|
|
91
|
+
for i = 1, #raw do bytes[i] = raw:byte(i) end
|
|
92
|
+
return bytes
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
local function cmd_press_buttons(p)
|
|
96
|
+
local keys = assert(p.buttons, "buttons required")
|
|
97
|
+
local bits = 0
|
|
98
|
+
for _, name in ipairs(keys) do
|
|
99
|
+
local b = KEY_BIT[name]
|
|
100
|
+
if not b then error("unknown key: " .. tostring(name)) end
|
|
101
|
+
bits = bits | (1 << b)
|
|
102
|
+
end
|
|
103
|
+
hold_bits = bits
|
|
104
|
+
hold_frames = p.frames or 1
|
|
105
|
+
return true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
local function cmd_advance_frames(p)
|
|
109
|
+
local n = p.count or 1
|
|
110
|
+
for _ = 1, n do emu:frameAdvance() end
|
|
111
|
+
return emu:currentFrame()
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
local function cmd_pause() emu:pause(); return true end
|
|
115
|
+
local function cmd_unpause() emu:unpause(); return true end
|
|
116
|
+
local function cmd_reset() emu:reset(); return true end
|
|
117
|
+
|
|
118
|
+
local function cmd_screenshot(p)
|
|
119
|
+
local path = p.path or (os.tmpname() .. ".png")
|
|
120
|
+
-- mGBA's emu:screenshot takes the destination path directly and writes
|
|
121
|
+
-- the PNG itself; it does not return an image object.
|
|
122
|
+
emu:screenshot(path)
|
|
123
|
+
return path
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
-- ── Dispatch table ───────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
local HANDLERS = {
|
|
129
|
+
ping = cmd_ping,
|
|
130
|
+
get_info = cmd_get_info,
|
|
131
|
+
read8 = cmd_read8,
|
|
132
|
+
read16 = cmd_read16,
|
|
133
|
+
read32 = cmd_read32,
|
|
134
|
+
write8 = cmd_write8,
|
|
135
|
+
write16 = cmd_write16,
|
|
136
|
+
write32 = cmd_write32,
|
|
137
|
+
read_range = cmd_read_range,
|
|
138
|
+
press_buttons = cmd_press_buttons,
|
|
139
|
+
advance_frames = cmd_advance_frames,
|
|
140
|
+
pause = cmd_pause,
|
|
141
|
+
unpause = cmd_unpause,
|
|
142
|
+
reset = cmd_reset,
|
|
143
|
+
screenshot = cmd_screenshot,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
local function dispatch(cmd)
|
|
147
|
+
if not cmd.method then
|
|
148
|
+
return nil, { code = -32600, message = "missing method field" }
|
|
149
|
+
end
|
|
150
|
+
local handler = HANDLERS[cmd.method]
|
|
151
|
+
if not handler then
|
|
152
|
+
return nil, { code = -32601, message = "unknown method: " .. cmd.method }
|
|
153
|
+
end
|
|
154
|
+
local ok, result = pcall(handler, cmd.params or {})
|
|
155
|
+
if not ok then
|
|
156
|
+
return nil, { code = -32603, message = tostring(result) }
|
|
157
|
+
end
|
|
158
|
+
return result, nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
-- ── Process one client's buffer — call after appending new data ──────────────
|
|
162
|
+
|
|
163
|
+
local function process_buffer(c)
|
|
164
|
+
while true do
|
|
165
|
+
local nl = c.buf:find("\n", 1, true)
|
|
166
|
+
if not nl then break end
|
|
167
|
+
|
|
168
|
+
local line = c.buf:sub(1, nl - 1)
|
|
169
|
+
c.buf = c.buf:sub(nl + 1)
|
|
170
|
+
|
|
171
|
+
if #line > 0 then
|
|
172
|
+
local parse_ok, cmd = pcall(json.decode, line)
|
|
173
|
+
local response
|
|
174
|
+
if parse_ok and type(cmd) == "table" then
|
|
175
|
+
local result, rpc_err = dispatch(cmd)
|
|
176
|
+
if rpc_err then
|
|
177
|
+
response = { id = cmd.id, error = rpc_err }
|
|
178
|
+
else
|
|
179
|
+
response = { id = cmd.id, result = result }
|
|
180
|
+
end
|
|
181
|
+
else
|
|
182
|
+
response = { id = nil, error = { code = -32700, message = "parse error" } }
|
|
183
|
+
end
|
|
184
|
+
c.sock:send(json.encode(response) .. "\n")
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
-- ── Server socket ────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
local server = assert(socket.tcp(), "socket.tcp() failed")
|
|
192
|
+
assert(server:bind(HOST, PORT), "bind failed — port " .. PORT .. " may already be in use")
|
|
193
|
+
assert(server:listen(), "listen failed")
|
|
194
|
+
|
|
195
|
+
-- Active client table: array of { sock, buf }
|
|
196
|
+
local clients = {}
|
|
197
|
+
|
|
198
|
+
-- ── Per-frame callback ───────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
callbacks:add("frame", function()
|
|
201
|
+
|
|
202
|
+
-- Key hold
|
|
203
|
+
if hold_frames > 0 then
|
|
204
|
+
emu:setKeys(hold_bits)
|
|
205
|
+
hold_frames = hold_frames - 1
|
|
206
|
+
if hold_frames == 0 then emu:setKeys(0) end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
-- poll() flushes the socket's internal event queue. Without it, accept()
|
|
210
|
+
-- and hasdata() see stale state and never observe new I/O.
|
|
211
|
+
server:poll()
|
|
212
|
+
local client = server:accept()
|
|
213
|
+
if client then
|
|
214
|
+
console:log("[mcp-mgba] client connected")
|
|
215
|
+
table.insert(clients, { sock = client, buf = "" })
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
-- Service existing clients
|
|
219
|
+
local i = 1
|
|
220
|
+
while i <= #clients do
|
|
221
|
+
local c = clients[i]
|
|
222
|
+
c.sock:poll()
|
|
223
|
+
if c.sock:hasdata() then
|
|
224
|
+
-- mGBA's receive(maxBytes) reads up to maxBytes — non-blocking
|
|
225
|
+
-- when guarded by hasdata(). Wrap in pcall so any internal error
|
|
226
|
+
-- doesn't spam the console every frame.
|
|
227
|
+
local ok, data = pcall(function() return c.sock:receive(4096) end)
|
|
228
|
+
if ok and data and #data > 0 then
|
|
229
|
+
c.buf = c.buf .. data
|
|
230
|
+
process_buffer(c)
|
|
231
|
+
i = i + 1
|
|
232
|
+
elseif ok and data == nil then
|
|
233
|
+
console:log("[mcp-mgba] client disconnected")
|
|
234
|
+
table.remove(clients, i)
|
|
235
|
+
else
|
|
236
|
+
console:log("[mcp-mgba] receive error: " .. tostring(data))
|
|
237
|
+
table.remove(clients, i)
|
|
238
|
+
end
|
|
239
|
+
else
|
|
240
|
+
i = i + 1
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end)
|
|
244
|
+
|
|
245
|
+
console:log(string.format("[mcp-mgba] bridge listening on %s:%d", HOST, PORT))
|
|
246
|
+
console:log("[mcp-mgba] frame callback registered — bridge is active")
|
package/lua/json.lua
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
-- json.lua: minimal JSON encode/decode for mGBA's Lua environment
|
|
2
|
+
-- Supports objects, arrays, strings, numbers, booleans, null.
|
|
3
|
+
-- No external dependencies.
|
|
4
|
+
|
|
5
|
+
local json = {}
|
|
6
|
+
|
|
7
|
+
-- ── Encoder ─────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
local escape_map = {
|
|
10
|
+
['"'] = '\\"',
|
|
11
|
+
['\\'] = '\\\\',
|
|
12
|
+
['\n'] = '\\n',
|
|
13
|
+
['\r'] = '\\r',
|
|
14
|
+
['\t'] = '\\t',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
local function encode_string(s)
|
|
18
|
+
return '"' .. s:gsub('["\\\n\r\t]', escape_map) .. '"'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
local encode_value -- forward declaration
|
|
22
|
+
|
|
23
|
+
local function encode_array(t, n)
|
|
24
|
+
local parts = {}
|
|
25
|
+
for i = 1, n do parts[i] = encode_value(t[i]) end
|
|
26
|
+
return "[" .. table.concat(parts, ",") .. "]"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
local function encode_object(t)
|
|
30
|
+
local parts = {}
|
|
31
|
+
for k, v in pairs(t) do
|
|
32
|
+
parts[#parts + 1] = encode_string(tostring(k)) .. ":" .. encode_value(v)
|
|
33
|
+
end
|
|
34
|
+
return "{" .. table.concat(parts, ",") .. "}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
encode_value = function(v)
|
|
38
|
+
local tv = type(v)
|
|
39
|
+
if tv == "nil" then return "null"
|
|
40
|
+
elseif tv == "boolean" then return tostring(v)
|
|
41
|
+
elseif tv == "number" then
|
|
42
|
+
-- integers stay integers, floats keep decimals
|
|
43
|
+
if v ~= v then return "null" end -- NaN guard
|
|
44
|
+
if v == math.huge or v == -math.huge then return "null" end
|
|
45
|
+
if math.floor(v) == v then return string.format("%d", v) end
|
|
46
|
+
return string.format("%.17g", v)
|
|
47
|
+
elseif tv == "string" then
|
|
48
|
+
return encode_string(v)
|
|
49
|
+
elseif tv == "table" then
|
|
50
|
+
-- detect array: sequential integer keys starting at 1
|
|
51
|
+
local n = #v
|
|
52
|
+
local is_arr = (n > 0)
|
|
53
|
+
if is_arr then
|
|
54
|
+
for k in pairs(v) do
|
|
55
|
+
if type(k) ~= "number" or k < 1 or k > n or math.floor(k) ~= k then
|
|
56
|
+
is_arr = false; break
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
-- empty table with no keys → emit as object {}
|
|
61
|
+
local has_keys = false
|
|
62
|
+
for _ in pairs(v) do has_keys = true; break end
|
|
63
|
+
if not has_keys then return "{}" end
|
|
64
|
+
end
|
|
65
|
+
return is_arr and encode_array(v, n) or encode_object(v)
|
|
66
|
+
end
|
|
67
|
+
return "null"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
function json.encode(v)
|
|
71
|
+
return encode_value(v)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
-- ── Decoder ──────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
local function skip_ws(s, i)
|
|
77
|
+
while i <= #s do
|
|
78
|
+
local c = s:sub(i, i)
|
|
79
|
+
if c == ' ' or c == '\t' or c == '\n' or c == '\r' then
|
|
80
|
+
i = i + 1
|
|
81
|
+
else
|
|
82
|
+
break
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
return i
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
local decode_value -- forward declaration
|
|
89
|
+
|
|
90
|
+
local function decode_string(s, i)
|
|
91
|
+
-- i points to the opening '"'
|
|
92
|
+
i = i + 1
|
|
93
|
+
local buf = {}
|
|
94
|
+
while i <= #s do
|
|
95
|
+
local c = s:sub(i, i)
|
|
96
|
+
if c == '"' then
|
|
97
|
+
return table.concat(buf), i + 1
|
|
98
|
+
elseif c == '\\' then
|
|
99
|
+
local e = s:sub(i + 1, i + 1)
|
|
100
|
+
if e == '"' then buf[#buf+1] = '"'; i = i + 2
|
|
101
|
+
elseif e == '\\' then buf[#buf+1] = '\\'; i = i + 2
|
|
102
|
+
elseif e == '/' then buf[#buf+1] = '/'; i = i + 2
|
|
103
|
+
elseif e == 'n' then buf[#buf+1] = '\n'; i = i + 2
|
|
104
|
+
elseif e == 'r' then buf[#buf+1] = '\r'; i = i + 2
|
|
105
|
+
elseif e == 't' then buf[#buf+1] = '\t'; i = i + 2
|
|
106
|
+
elseif e == 'b' then buf[#buf+1] = '\b'; i = i + 2
|
|
107
|
+
elseif e == 'f' then buf[#buf+1] = '\f'; i = i + 2
|
|
108
|
+
elseif e == 'u' then
|
|
109
|
+
-- \uXXXX — keep raw for now (ASCII subset only needed)
|
|
110
|
+
local hex = s:sub(i + 2, i + 5)
|
|
111
|
+
local cp = tonumber(hex, 16)
|
|
112
|
+
if cp and cp < 128 then
|
|
113
|
+
buf[#buf+1] = string.char(cp)
|
|
114
|
+
else
|
|
115
|
+
buf[#buf+1] = '?' -- non-ASCII placeholder
|
|
116
|
+
end
|
|
117
|
+
i = i + 6
|
|
118
|
+
else
|
|
119
|
+
buf[#buf+1] = e; i = i + 2
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
buf[#buf+1] = c; i = i + 1
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
error("json: unterminated string at " .. i)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
local function decode_number(s, i)
|
|
129
|
+
-- grab the full number token
|
|
130
|
+
local j = i
|
|
131
|
+
if s:sub(j, j) == '-' then j = j + 1 end
|
|
132
|
+
while j <= #s and s:sub(j, j):match('%d') do j = j + 1 end
|
|
133
|
+
if j <= #s and s:sub(j, j) == '.' then
|
|
134
|
+
j = j + 1
|
|
135
|
+
while j <= #s and s:sub(j, j):match('%d') do j = j + 1 end
|
|
136
|
+
end
|
|
137
|
+
if j <= #s and s:sub(j, j):match('[eE]') then
|
|
138
|
+
j = j + 1
|
|
139
|
+
if j <= #s and s:sub(j, j):match('[+-]') then j = j + 1 end
|
|
140
|
+
while j <= #s and s:sub(j, j):match('%d') do j = j + 1 end
|
|
141
|
+
end
|
|
142
|
+
return tonumber(s:sub(i, j - 1)), j
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
local function decode_object(s, i)
|
|
146
|
+
local t = {}
|
|
147
|
+
i = skip_ws(s, i + 1) -- skip '{'
|
|
148
|
+
if s:sub(i, i) == '}' then return t, i + 1 end
|
|
149
|
+
while true do
|
|
150
|
+
i = skip_ws(s, i)
|
|
151
|
+
local k; k, i = decode_string(s, i)
|
|
152
|
+
i = skip_ws(s, i)
|
|
153
|
+
if s:sub(i, i) ~= ':' then error("json: expected ':' at " .. i) end
|
|
154
|
+
i = skip_ws(s, i + 1)
|
|
155
|
+
local v; v, i = decode_value(s, i)
|
|
156
|
+
t[k] = v
|
|
157
|
+
i = skip_ws(s, i)
|
|
158
|
+
local c = s:sub(i, i)
|
|
159
|
+
if c == '}' then return t, i + 1
|
|
160
|
+
elseif c == ',' then i = i + 1
|
|
161
|
+
else error("json: expected ',' or '}' at " .. i) end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
local function decode_array(s, i)
|
|
166
|
+
local t = {}
|
|
167
|
+
i = skip_ws(s, i + 1) -- skip '['
|
|
168
|
+
if s:sub(i, i) == ']' then return t, i + 1 end
|
|
169
|
+
while true do
|
|
170
|
+
i = skip_ws(s, i)
|
|
171
|
+
local v; v, i = decode_value(s, i)
|
|
172
|
+
t[#t + 1] = v
|
|
173
|
+
i = skip_ws(s, i)
|
|
174
|
+
local c = s:sub(i, i)
|
|
175
|
+
if c == ']' then return t, i + 1
|
|
176
|
+
elseif c == ',' then i = i + 1
|
|
177
|
+
else error("json: expected ',' or ']' at " .. i) end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
decode_value = function(s, i)
|
|
182
|
+
i = skip_ws(s, i)
|
|
183
|
+
if i > #s then error("json: unexpected end of input") end
|
|
184
|
+
local c = s:sub(i, i)
|
|
185
|
+
if c == '"' then return decode_string(s, i)
|
|
186
|
+
elseif c == '{' then return decode_object(s, i)
|
|
187
|
+
elseif c == '[' then return decode_array(s, i)
|
|
188
|
+
elseif c == 't' then return true, i + 4
|
|
189
|
+
elseif c == 'f' then return false, i + 5
|
|
190
|
+
elseif c == 'n' then return nil, i + 4
|
|
191
|
+
elseif c == '-' or c:match('%d') then return decode_number(s, i)
|
|
192
|
+
else error("json: unexpected character '" .. c .. "' at " .. i) end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
function json.decode(s)
|
|
196
|
+
local v, _ = decode_value(s, 1)
|
|
197
|
+
return v
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
return json
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-mgba",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server that bridges Claude to the mGBA Game Boy Advance emulator via Lua scripting",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-mgba": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/",
|
|
12
|
+
"lua/",
|
|
13
|
+
"docs/",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc && node -e \"import('node:fs').then(fs => fs.chmodSync('dist/index.js', 0o755))\"",
|
|
19
|
+
"dev": "tsc --watch",
|
|
20
|
+
"start": "node dist/index.js",
|
|
21
|
+
"prepare": "npm run build",
|
|
22
|
+
"inspector": "npx @modelcontextprotocol/inspector node dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/dmang-dev/mcp-mgba.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/dmang-dev/mcp-mgba/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/dmang-dev/mcp-mgba#readme",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.0.0",
|
|
40
|
+
"typescript": "^5.5.0"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"mcp",
|
|
44
|
+
"model-context-protocol",
|
|
45
|
+
"mgba",
|
|
46
|
+
"gba",
|
|
47
|
+
"game-boy-advance",
|
|
48
|
+
"emulator",
|
|
49
|
+
"lua"
|
|
50
|
+
]
|
|
51
|
+
}
|