@retrotech71/appleii-agent 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mike Daley
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,164 @@
1
+ # @appleii/mcp-agent
2
+
3
+ MCP server for the [Apple //e browser emulator](https://github.com/mikedaley/web-a2e) — control the emulator with AI agents via the AG-UI protocol.
4
+
5
+ ## What It Does
6
+
7
+ This MCP (Model Context Protocol) server bridges AI agents like Claude with the Apple //e browser emulator. Through natural language, agents can:
8
+
9
+ - **Manage windows** — show, hide, and focus emulator windows (BASIC editor, CPU debugger, disk drives, etc.)
10
+ - **Load disk images** — insert floppy disks and SmartPort hard drive images from the filesystem
11
+ - **Write BASIC programs** — read, edit, and load Applesoft BASIC programs into the emulator
12
+ - **Assemble code** — write 65C02 assembly, assemble, and load into memory
13
+ - **Control the emulator** — power on/off, reset, type text, manage expansion slots
14
+ - **Inspect state** — read memory, CPU registers, and emulator status
15
+
16
+ ## Prerequisites
17
+
18
+ - Node.js 18+
19
+ - The [Apple //e emulator](https://github.com/mikedaley/web-a2e) running in your browser
20
+
21
+ ## Installation
22
+
23
+ ### From npm
24
+
25
+ ```bash
26
+ npm install -g @appleii/mcp-agent
27
+ ```
28
+
29
+ ### From source
30
+
31
+ ```bash
32
+ git clone https://github.com/mikedaley/appleii-agent.git
33
+ cd appleii-agent
34
+ npm install
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ Add to your MCP client configuration. For Claude Code, edit `~/.claude/mcp.json`:
40
+
41
+ ### If installed globally
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "appleii-agent": {
47
+ "command": "appleii-agent"
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ ### If installed from source
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "appleii-agent": {
59
+ "command": "node",
60
+ "args": ["/path/to/appleii-agent/src/index.js"]
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ## Usage
67
+
68
+ 1. Start the Apple //e emulator in your browser (`npm run dev` in the emulator repo)
69
+ 2. Open your MCP client (e.g., Claude Code)
70
+ 3. The MCP server starts automatically when the client connects
71
+ 4. Click the sparkle icon in the emulator toolbar to verify the connection (yellow = connected)
72
+
73
+ ### Example Prompts
74
+
75
+ ```
76
+ Show the CPU debugger window
77
+ Load ~/Documents/ProDOS_2_4_2.dsk into drive 1
78
+ Write a BASIC program that draws a sine wave
79
+ Install the SmartPort card in slot 7
80
+ Load ~/Images/Total_Replay.hdv into SmartPort device 1
81
+ Turn on the emulator and boot from disk
82
+ Save 256 bytes from memory address $0800 to ~/output.bin
83
+ ```
84
+
85
+ ## Available Tools
86
+
87
+ ### Emulator Control
88
+ | Tool | Description |
89
+ |------|-------------|
90
+ | `emma_command` | Generic command wrapper — routes to all frontend tools |
91
+ | `showWindow` | Show and bring a window to front |
92
+ | `hideWindow` | Hide a window |
93
+ | `focusWindow` | Bring a window to front |
94
+
95
+ ### File Operations
96
+ | Tool | Description |
97
+ |------|-------------|
98
+ | `load_disk_image` | Load a floppy disk image (.dsk, .do, .po, .nib, .woz) |
99
+ | `load_smartport_image` | Load a SmartPort hard drive image (.hdv, .po, .2mg) |
100
+ | `load_file` | Load any file (binary or text) |
101
+ | `save_basic_file` | Save a BASIC program to a .bas file |
102
+ | `save_asm_file` | Save assembly source to a .s or .asm file |
103
+ | `save_disk_file` | Save binary disk data to a file |
104
+
105
+ ### Server Management
106
+ | Tool | Description |
107
+ |------|-------------|
108
+ | `server_control` | Start, stop, restart, or check server status |
109
+ | `set_https` | Toggle HTTPS mode |
110
+ | `set_debug` | Toggle debug logging |
111
+ | `get_state` | Get current server state |
112
+
113
+ ## Environment Variables
114
+
115
+ | Variable | Default | Description |
116
+ |----------|---------|-------------|
117
+ | `PORT` | `3033` | HTTP server port |
118
+ | `HTTPS` | `false` | Set to `true` for HTTPS mode |
119
+
120
+ ## How It Works
121
+
122
+ ```
123
+ Claude Code ──MCP (stdio)──> MCP Server ──HTTP/SSE──> Browser Emulator
124
+
125
+ AG-UI Protocol
126
+ (event-based, bidirectional)
127
+ ```
128
+
129
+ The MCP server communicates with Claude Code over stdio (standard MCP transport) and with the browser emulator over HTTP using Server-Sent Events (AG-UI protocol). Tool calls from the AI agent are forwarded to the emulator frontend, which executes them and returns results.
130
+
131
+ ## HTTPS Mode
132
+
133
+ For HTTPS support:
134
+
135
+ ```bash
136
+ # Generate self-signed certificate
137
+ npm run generate-cert
138
+
139
+ # Start with HTTPS
140
+ HTTPS=true npm start
141
+ ```
142
+
143
+ Or toggle at runtime via the `set_https` tool.
144
+
145
+ ## Troubleshooting
146
+
147
+ **MCP server won't connect**
148
+ - Ensure Node.js 18+ is installed
149
+ - Check that the path in your MCP config is correct
150
+ - Restart your MCP client
151
+
152
+ **Emulator shows disconnected (gray sparkle)**
153
+ - Make sure the emulator is running in your browser
154
+ - Click the sparkle icon to view connection details
155
+ - Check that port 3033 is not in use by another process
156
+
157
+ **Tools return errors**
158
+ - The emulator must be powered on for most tools to work
159
+ - SmartPort tools require the SmartPort card to be installed in an expansion slot
160
+ - Disk tools validate file formats before loading
161
+
162
+ ## License
163
+
164
+ MIT
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@retrotech71/appleii-agent",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for the Apple //e browser emulator — control the emulator and integrate with AI agents",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "appleii-agent": "src/index.js"
9
+ },
10
+ "files": [
11
+ "src/",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "scripts": {
19
+ "start": "node src/index.js",
20
+ "start:https": "HTTPS=true node src/index.js",
21
+ "generate-cert": "openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' -keyout key.pem -out cert.pem -days 365"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "model-context-protocol",
26
+ "ag-ui",
27
+ "apple-ii",
28
+ "apple-iie",
29
+ "emulator",
30
+ "retro",
31
+ "6502",
32
+ "claude",
33
+ "ai-agent"
34
+ ],
35
+ "author": "Shawn Bullock <shawn@agenticexpert.ai>, Mike Daley <michael_daley@icloud.com>",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/mikedaley/appleii-agent.git"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/mikedaley/appleii-agent/issues"
43
+ },
44
+ "homepage": "https://github.com/mikedaley/appleii-agent#readme",
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.0.4"
47
+ }
48
+ }
@@ -0,0 +1,432 @@
1
+ /*
2
+ * http-server.js - HTTP/HTTPS server for AG-UI event communication
3
+ *
4
+ * Written by
5
+ * Shawn Bullock <shawn@agenticexpert.ai>
6
+ */
7
+
8
+ import http from "http";
9
+ import https from "https";
10
+ import fs from "fs";
11
+ import path from "path";
12
+ import { fileURLToPath } from "url";
13
+ import { execSync } from "child_process";
14
+ import { logger } from "./logger.js";
15
+ import { tools } from "./tools/index.js";
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+
20
+ /**
21
+ * HTTP/HTTPS Server for AG-UI protocol events
22
+ */
23
+ export class HttpServer {
24
+ constructor(port, useHttps = false, debug = true) {
25
+ this.port = port;
26
+ this.useHttps = useHttps;
27
+ this.debug = debug;
28
+ this.server = null;
29
+ this.clients = new Set();
30
+ this.pendingToolResults = new Map();
31
+ this.eventQueue = [];
32
+ }
33
+
34
+ /**
35
+ * Generate self-signed certificate for HTTPS
36
+ */
37
+ _generateCertificate(certPath, keyPath) {
38
+ // Try mkcert first (locally-trusted certs), fall back to openssl (self-signed)
39
+ try {
40
+ execSync("mkcert -version", { stdio: "pipe" });
41
+ logger.log("Generating locally-trusted certificate with mkcert...");
42
+ execSync(`mkcert -key-file "${keyPath}" -cert-file "${certPath}" localhost 127.0.0.1 ::1`, { stdio: "pipe" });
43
+ logger.log("Certificate generated successfully (trusted by browser)");
44
+ return;
45
+ } catch (e) {
46
+ // mkcert not available, fall back to openssl
47
+ }
48
+
49
+ logger.log("Generating self-signed HTTPS certificate with openssl...");
50
+ logger.log("Tip: Install mkcert for browser-trusted certs: brew install mkcert && mkcert -install");
51
+
52
+ try {
53
+ const cmd = `openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' -keyout "${keyPath}" -out "${certPath}" -days 365`;
54
+ execSync(cmd, { stdio: "pipe" });
55
+ logger.log("Certificate generated (self-signed — browser may not trust it)");
56
+ } catch (error) {
57
+ throw new Error(
58
+ "Failed to generate certificate. Install mkcert (recommended) or OpenSSL:\n" +
59
+ " mkcert: brew install mkcert && mkcert -install\n" +
60
+ " macOS: brew install openssl\n" +
61
+ " Linux: sudo apt-get install openssl\n" +
62
+ " Windows: https://slproweb.com/products/Win32OpenSSL.html"
63
+ );
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Start the HTTP/HTTPS server
69
+ */
70
+ async start() {
71
+ return new Promise((resolve, reject) => {
72
+ const requestHandler = (req, res) => {
73
+ this._handleRequest(req, res);
74
+ };
75
+
76
+ if (this.useHttps) {
77
+ const certPath = path.join(__dirname, "cert.pem");
78
+ const keyPath = path.join(__dirname, "key.pem");
79
+
80
+ // Auto-generate certificates if they don't exist
81
+ if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) {
82
+ try {
83
+ this._generateCertificate(certPath, keyPath);
84
+ } catch (error) {
85
+ reject(error);
86
+ return;
87
+ }
88
+ }
89
+
90
+ this.server = https.createServer({
91
+ key: fs.readFileSync(keyPath),
92
+ cert: fs.readFileSync(certPath),
93
+ }, requestHandler);
94
+ } else {
95
+ this.server = http.createServer(requestHandler);
96
+ }
97
+
98
+ this.server.listen(this.port, () => {
99
+ resolve();
100
+ });
101
+
102
+ this.server.on("error", (error) => {
103
+ reject(error);
104
+ });
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Handle incoming HTTP requests
110
+ */
111
+ async _handleRequest(req, res) {
112
+ // Log requests in debug mode, but skip heartbeat to reduce noise
113
+ if (this.debug && req.url !== "/heartbeat") {
114
+ logger.log(`[HTTP] ${req.method} ${req.url}`);
115
+ }
116
+
117
+ // Enable CORS with Private Network Access (required for public HTTPS → localhost)
118
+ res.setHeader("Access-Control-Allow-Origin", "*");
119
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
120
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
121
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
122
+
123
+ if (req.method === "OPTIONS") {
124
+ res.writeHead(204);
125
+ res.end();
126
+ return;
127
+ }
128
+
129
+ if (req.method === "GET" && req.url === "/events") {
130
+ // SSE endpoint for streaming events to frontend
131
+ this._handleEventStream(req, res);
132
+ return;
133
+ }
134
+
135
+ if (req.method === "POST" && req.url === "/tool-result") {
136
+ // Receive TOOL_CALL_RESULT from frontend
137
+ await this._handleToolResult(req, res);
138
+ return;
139
+ }
140
+
141
+ if (req.method === "GET" && req.url === "/health") {
142
+ res.writeHead(200, { "Content-Type": "application/json" });
143
+ res.end(JSON.stringify({ status: "ok" }));
144
+ return;
145
+ }
146
+
147
+ if (req.method === "GET" && req.url === "/heartbeat") {
148
+ // Lightweight heartbeat endpoint for checking if server is running
149
+ res.writeHead(200, {
150
+ "Content-Type": "application/json",
151
+ "Access-Control-Allow-Origin": "*",
152
+ });
153
+ res.end(JSON.stringify({ alive: true }));
154
+ return;
155
+ }
156
+
157
+ if (req.method === "POST" && req.url === "/call-tool") {
158
+ // Call an MCP tool from the frontend
159
+ await this._handleCallTool(req, res);
160
+ return;
161
+ }
162
+
163
+ res.writeHead(404);
164
+ res.end("Not Found");
165
+ }
166
+
167
+ /**
168
+ * Handle Server-Sent Events stream
169
+ */
170
+ _handleEventStream(req, res) {
171
+ if (this.debug) {
172
+ logger.log("[HTTP] SSE client connected");
173
+ }
174
+
175
+ // Set SSE headers
176
+ res.writeHead(200, {
177
+ "Content-Type": "text/event-stream",
178
+ "Cache-Control": "no-cache",
179
+ "Connection": "keep-alive",
180
+ });
181
+
182
+ // Send initial comment to establish connection
183
+ // This ensures the browser fires the 'onopen' event
184
+ res.write(": connected\n\n");
185
+
186
+ // Add client to set
187
+ const client = { req, res };
188
+ this.clients.add(client);
189
+
190
+ // Send queued events to new client
191
+ this.eventQueue.forEach((event) => {
192
+ this._writeSSE(res, event);
193
+ });
194
+
195
+ // Handle client disconnect
196
+ req.on("close", () => {
197
+ if (this.debug) {
198
+ logger.log("[HTTP] SSE client disconnected");
199
+ }
200
+ this.clients.delete(client);
201
+
202
+ // Clear event queue when all clients disconnect
203
+ // This prevents replaying old commands to new sessions
204
+ if (this.clients.size === 0) {
205
+ this.eventQueue = [];
206
+ if (this.debug) {
207
+ logger.log("[HTTP] All clients disconnected, cleared event queue");
208
+ }
209
+ }
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Handle tool result from frontend
215
+ */
216
+ async _handleToolResult(req, res) {
217
+ const body = await this._readBody(req);
218
+
219
+ if (this.debug) {
220
+ logger.log("[HTTP] Received:", body);
221
+ }
222
+
223
+ try {
224
+ const event = JSON.parse(body);
225
+
226
+ if (event.type === "TOOL_CALL_RESULT") {
227
+ const { tool_call_id, content } = event;
228
+
229
+ if (this.debug) {
230
+ logger.log(`[HTTP] Tool result for ${tool_call_id}:`, content);
231
+ }
232
+
233
+ // Resolve pending promise
234
+ const pending = this.pendingToolResults.get(tool_call_id);
235
+ if (pending) {
236
+ pending.resolve(content);
237
+ this.pendingToolResults.delete(tool_call_id);
238
+ }
239
+ }
240
+
241
+ res.writeHead(200, { "Content-Type": "application/json" });
242
+ res.end(JSON.stringify({ status: "ok" }));
243
+
244
+ } catch (error) {
245
+ if (this.debug) {
246
+ logger.log("[HTTP] Error:", error.message);
247
+ }
248
+ res.writeHead(400, { "Content-Type": "application/json" });
249
+ res.end(JSON.stringify({ error: error.message }));
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Handle call tool request from frontend
255
+ */
256
+ async _handleCallTool(req, res) {
257
+ const body = await this._readBody(req);
258
+
259
+ if (this.debug) {
260
+ logger.log("[HTTP] Call tool request:", body);
261
+ }
262
+
263
+ try {
264
+ const request = JSON.parse(body);
265
+ const { tool: toolName, args = {} } = request;
266
+
267
+ if (!toolName) {
268
+ throw new Error("tool parameter is required");
269
+ }
270
+
271
+ // Find the tool
272
+ const toolModule = tools.find(t => t.tool.name === toolName);
273
+ if (!toolModule) {
274
+ throw new Error(`Unknown tool: ${toolName}`);
275
+ }
276
+
277
+ // Call the tool handler
278
+ const result = await toolModule.handler(args, this);
279
+
280
+ if (this.debug) {
281
+ logger.log(`[HTTP] Tool ${toolName} result:`, JSON.stringify(result));
282
+ }
283
+
284
+ res.writeHead(200, { "Content-Type": "application/json" });
285
+ res.end(JSON.stringify(result));
286
+
287
+ } catch (error) {
288
+ if (this.debug) {
289
+ logger.log("[HTTP] Error:", error.message);
290
+ }
291
+ res.writeHead(400, { "Content-Type": "application/json" });
292
+ res.end(JSON.stringify({
293
+ success: false,
294
+ error: error.message
295
+ }));
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Read request body
301
+ */
302
+ _readBody(req) {
303
+ return new Promise((resolve, reject) => {
304
+ let body = "";
305
+ req.on("data", (chunk) => {
306
+ body += chunk.toString();
307
+ });
308
+ req.on("end", () => {
309
+ resolve(body);
310
+ });
311
+ req.on("error", (error) => {
312
+ reject(error);
313
+ });
314
+ });
315
+ }
316
+
317
+ /**
318
+ * Send AG-UI event to all connected clients
319
+ */
320
+ async sendEvent(event) {
321
+ if (this.debug) {
322
+ logger.log("[HTTP] Sending event:", JSON.stringify(event));
323
+ }
324
+
325
+ // Add to queue (keep last 100 events for reconnecting clients)
326
+ this.eventQueue.push(event);
327
+ if (this.eventQueue.length > 100) {
328
+ this.eventQueue.shift();
329
+ }
330
+
331
+ // Send to all connected clients
332
+ this.clients.forEach((client) => {
333
+ this._writeSSE(client.res, event);
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Write Server-Sent Event
339
+ */
340
+ _writeSSE(res, event) {
341
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
342
+ }
343
+
344
+ /**
345
+ * Wait for tool result from frontend
346
+ */
347
+ waitForToolResult(toolCallId, timeoutMs = 5000) {
348
+ return new Promise((resolve, reject) => {
349
+ const timeout = setTimeout(() => {
350
+ this.pendingToolResults.delete(toolCallId);
351
+ reject(new Error("Tool result timeout"));
352
+ }, timeoutMs);
353
+
354
+ this.pendingToolResults.set(toolCallId, {
355
+ resolve: (result) => {
356
+ clearTimeout(timeout);
357
+ resolve(result);
358
+ },
359
+ reject,
360
+ });
361
+ });
362
+ }
363
+
364
+ /**
365
+ * Stop the HTTP/HTTPS server
366
+ */
367
+ async stop() {
368
+ if (this.server) {
369
+ // Close all SSE connections
370
+ this.clients.forEach((client) => {
371
+ client.res.end();
372
+ });
373
+ this.clients.clear();
374
+
375
+ return new Promise((resolve) => {
376
+ this.server.close(() => {
377
+ this.server = null;
378
+ resolve();
379
+ });
380
+ });
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Restart the HTTP/HTTPS server
386
+ */
387
+ async restart() {
388
+ await this.stop();
389
+ await this.start();
390
+ }
391
+
392
+ /**
393
+ * Change HTTPS mode and restart
394
+ */
395
+ async setHttps(enabled) {
396
+ const wasRunning = this.server !== null;
397
+ if (wasRunning) {
398
+ await this.stop();
399
+ }
400
+ this.useHttps = enabled;
401
+ if (wasRunning) {
402
+ await this.start();
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Set debug mode
408
+ */
409
+ setDebug(enabled) {
410
+ this.debug = enabled;
411
+ if (this.debug) {
412
+ logger.log("[HTTP] Debug mode enabled");
413
+ } else {
414
+ logger.log("[HTTP] Debug mode disabled");
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Get server status
420
+ */
421
+ getStatus() {
422
+ return {
423
+ running: this.server !== null,
424
+ https: this.useHttps,
425
+ debug: this.debug,
426
+ port: this.port,
427
+ clients: this.clients.size,
428
+ protocol: this.useHttps ? "https" : "http",
429
+ url: `${this.useHttps ? "https" : "http"}://localhost:${this.port}`,
430
+ };
431
+ }
432
+ }