@retrotech71/appleii-agent 1.0.1 → 1.0.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.
package/README.md CHANGED
@@ -38,19 +38,23 @@ npm install
38
38
 
39
39
  Add to your MCP client configuration. For Claude Code, edit `~/.claude/mcp.json`:
40
40
 
41
- ### If installed globally
41
+ ### Option 1: Using bunx (Recommended)
42
+
43
+ Runs the published package directly with Bun:
42
44
 
43
45
  ```json
44
46
  {
45
47
  "mcpServers": {
46
48
  "appleii-agent": {
47
- "command": "appleii-agent"
49
+ "type": "stdio",
50
+ "command": "bunx",
51
+ "args": ["-y", "@retrotech71/appleii-agent"]
48
52
  }
49
53
  }
50
54
  }
51
55
  ```
52
56
 
53
- ### If installed from source
57
+ ### Option 2: Local development from source
54
58
 
55
59
  ```json
56
60
  {
@@ -109,6 +113,9 @@ Save 256 bytes from memory address $0800 to ~/output.bin
109
113
  | `set_https` | Toggle HTTPS mode |
110
114
  | `set_debug` | Toggle debug logging |
111
115
  | `get_state` | Get current server state |
116
+ | `get_version` | Get MCP server version information |
117
+ | `shutdown_remote_server` | Shutdown another MCP server instance on the same port |
118
+ | `disconnect_clients` | Gracefully disconnect all connected emulator clients |
112
119
 
113
120
  ## Environment Variables
114
121
 
@@ -154,6 +161,13 @@ Or toggle at runtime via the `set_https` tool.
154
161
  - Click the sparkle icon to view connection details
155
162
  - Check that port 3033 is not in use by another process
156
163
 
164
+ **Reclaiming the Apple II Agent port (multiple MCP instances)**
165
+ - The MCP server handles port conflicts gracefully and won't fail
166
+ - Use `server_control` with action `status` to check if port is in use
167
+ - Use `shutdown_remote_server` to reclaim the port by stopping the other instance
168
+ - After shutdown, use `server_control` with action `start` to start this instance on the reclaimed port
169
+ - Note: A server stopped via `shutdown_remote_server` can only be restarted by its owning MCP instance
170
+
157
171
  **Tools return errors**
158
172
  - The emulator must be powered on for most tools to work
159
173
  - SmartPort tools require the SmartPort card to be installed in an expansion slot
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retrotech71/appleii-agent",
3
- "version": "1.0.1",
3
+ "version": "1.0.5",
4
4
  "description": "MCP server for the Apple //e browser emulator — control the emulator and integrate with AI agents",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -30,6 +30,8 @@ export class HttpServer {
30
30
  this.pendingToolResults = new Map();
31
31
  this.eventQueue = [];
32
32
  this.emulatorDomain = null; // Domain where the emulator is running
33
+ this.portInUse = false; // Track if port is in use by another instance
34
+ this.externallyShutdown = false; // Track if shutdown came from external command
33
35
  }
34
36
 
35
37
  /**
@@ -69,6 +71,11 @@ export class HttpServer {
69
71
  * Start the HTTP/HTTPS server
70
72
  */
71
73
  async start() {
74
+ // Log if restarting after external shutdown (informational only)
75
+ if (this.externallyShutdown && this.debug) {
76
+ logger.log("[HTTP] Restarting after external shutdown");
77
+ }
78
+
72
79
  return new Promise((resolve, reject) => {
73
80
  const requestHandler = (req, res) => {
74
81
  this._handleRequest(req, res);
@@ -97,11 +104,29 @@ export class HttpServer {
97
104
  }
98
105
 
99
106
  this.server.listen(this.port, () => {
107
+ // Successfully started - clear flags
108
+ this.portInUse = false;
109
+ this.externallyShutdown = false;
110
+ if (this.debug) {
111
+ logger.log(`[HTTP] Server listening on port ${this.port}`);
112
+ }
100
113
  resolve();
101
114
  });
102
115
 
103
116
  this.server.on("error", (error) => {
104
- reject(error);
117
+ // Handle port already in use gracefully
118
+ if (error.code === "EADDRINUSE") {
119
+ this.portInUse = true;
120
+ this.server = null;
121
+ if (this.debug) {
122
+ logger.log(`[HTTP] Port ${this.port} already in use - server not started`);
123
+ }
124
+ // Resolve instead of reject to keep MCP alive
125
+ resolve();
126
+ } else {
127
+ // Other errors still reject
128
+ reject(error);
129
+ }
105
130
  });
106
131
  });
107
132
  }
@@ -117,7 +142,7 @@ export class HttpServer {
117
142
 
118
143
  // Enable CORS with Private Network Access (required for public HTTPS → localhost)
119
144
  res.setHeader("Access-Control-Allow-Origin", "*");
120
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
145
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS, HEAD");
121
146
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
122
147
  res.setHeader("Access-Control-Allow-Private-Network", "true");
123
148
 
@@ -127,6 +152,18 @@ export class HttpServer {
127
152
  return;
128
153
  }
129
154
 
155
+ if (req.method === "HEAD" && req.url.startsWith("/events")) {
156
+ // Check if connection is allowed (for single-client mode)
157
+ if (this.clients.size > 0) {
158
+ res.writeHead(409, { "Content-Type": "text/plain" });
159
+ res.end("Another Apple //e Emulator Already Connected");
160
+ } else {
161
+ res.writeHead(200, { "Content-Type": "text/event-stream" });
162
+ res.end();
163
+ }
164
+ return;
165
+ }
166
+
130
167
  if (req.method === "GET" && req.url.startsWith("/events")) {
131
168
  // SSE endpoint for streaming events to frontend
132
169
  this._handleEventStream(req, res);
@@ -161,6 +198,33 @@ export class HttpServer {
161
198
  return;
162
199
  }
163
200
 
201
+ if (req.method === "POST" && req.url === "/shutdown") {
202
+ // External shutdown command - stops server and prevents restart
203
+ if (this.debug) {
204
+ logger.log("[HTTP] Received external shutdown command");
205
+ }
206
+
207
+ // Mark as externally shutdown
208
+ this.externallyShutdown = true;
209
+
210
+ // Send response before shutting down
211
+ res.writeHead(200, { "Content-Type": "application/json" });
212
+ res.end(JSON.stringify({
213
+ status: "shutting_down",
214
+ message: "Server shutting down. Can only be restarted by owning MCP instance."
215
+ }));
216
+
217
+ // Stop the server after response is sent (internal=false to preserve externallyShutdown flag)
218
+ setTimeout(async () => {
219
+ await this.stop(false);
220
+ if (this.debug) {
221
+ logger.log("[HTTP] Server stopped by external shutdown");
222
+ }
223
+ }, 100);
224
+
225
+ return;
226
+ }
227
+
164
228
  res.writeHead(404);
165
229
  res.end("Not Found");
166
230
  }
@@ -169,6 +233,16 @@ export class HttpServer {
169
233
  * Handle Server-Sent Events stream
170
234
  */
171
235
  _handleEventStream(req, res) {
236
+ // Check if a client is already connected (single client mode)
237
+ if (this.clients.size > 0) {
238
+ if (this.debug) {
239
+ logger.log("[HTTP] Rejecting connection - another client already connected");
240
+ }
241
+ res.writeHead(409, { "Content-Type": "text/plain" });
242
+ res.end("Another Apple //e Emulator Already Connected");
243
+ return;
244
+ }
245
+
172
246
  // Parse domain from query parameter
173
247
  const url = new URL(req.url, `http://localhost:${this.port}`);
174
248
  const domain = url.searchParams.get("domain");
@@ -196,7 +270,7 @@ export class HttpServer {
196
270
  res.write(": connected\n\n");
197
271
 
198
272
  // Add client to set
199
- const client = { req, res };
273
+ const client = { req, res, domain };
200
274
  this.clients.add(client);
201
275
 
202
276
  // Send queued events to new client
@@ -376,7 +450,7 @@ export class HttpServer {
376
450
  /**
377
451
  * Stop the HTTP/HTTPS server
378
452
  */
379
- async stop() {
453
+ async stop(internal = true) {
380
454
  if (this.server) {
381
455
  // Close all SSE connections
382
456
  this.clients.forEach((client) => {
@@ -387,6 +461,14 @@ export class HttpServer {
387
461
  return new Promise((resolve) => {
388
462
  this.server.close(() => {
389
463
  this.server = null;
464
+ // Only clear externallyShutdown flag if this is an internal stop
465
+ // (from the owning MCP instance)
466
+ if (internal && this.externallyShutdown) {
467
+ this.externallyShutdown = false;
468
+ if (this.debug) {
469
+ logger.log("[HTTP] External shutdown flag cleared by owning instance");
470
+ }
471
+ }
390
472
  resolve();
391
473
  });
392
474
  });
@@ -441,6 +523,8 @@ export class HttpServer {
441
523
  url: `${this.useHttps ? "https" : "http"}://localhost:${this.port}`,
442
524
  emulatorDomain: this.emulatorDomain,
443
525
  llmsTxtUrl: this.emulatorDomain ? `${this.emulatorDomain}/llms.txt` : null,
526
+ portInUse: this.portInUse,
527
+ externallyShutdown: this.externallyShutdown,
444
528
  };
445
529
  }
446
530
 
@@ -450,4 +534,46 @@ export class HttpServer {
450
534
  getLlmsTxtUrl() {
451
535
  return this.emulatorDomain ? `${this.emulatorDomain}/llms.txt` : null;
452
536
  }
537
+
538
+ /**
539
+ * Gracefully disconnect all clients
540
+ */
541
+ disconnectAllClients() {
542
+ if (this.clients.size === 0) {
543
+ if (this.debug) {
544
+ logger.log("[HTTP] No clients to disconnect");
545
+ }
546
+ return { disconnected: 0 };
547
+ }
548
+
549
+ const count = this.clients.size;
550
+
551
+ // Send disconnect event to all clients before closing
552
+ this.clients.forEach((client) => {
553
+ try {
554
+ // Send a custom disconnect event
555
+ this._writeSSE(client.res, {
556
+ type: "DISCONNECT",
557
+ reason: "Server requested disconnect",
558
+ graceful: true,
559
+ });
560
+ // Close the connection
561
+ client.res.end();
562
+ } catch (error) {
563
+ if (this.debug) {
564
+ logger.log(`[HTTP] Error disconnecting client: ${error.message}`);
565
+ }
566
+ }
567
+ });
568
+
569
+ this.clients.clear();
570
+ this.eventQueue = [];
571
+ this.emulatorDomain = null;
572
+
573
+ if (this.debug) {
574
+ logger.log(`[HTTP] Gracefully disconnected ${count} client(s)`);
575
+ }
576
+
577
+ return { disconnected: count };
578
+ }
453
579
  }
package/src/index.js CHANGED
@@ -29,7 +29,18 @@ async function main() {
29
29
 
30
30
  const protocol = USE_HTTPS ? "https" : "http";
31
31
  logger.log("Apple II MCP Agent initialized");
32
- logger.log(`${protocol.toUpperCase()} server listening on ${protocol}://localhost:${HTTP_PORT}`);
32
+
33
+ // Check if HTTP server actually started
34
+ const status = httpServer.getStatus();
35
+ if (status.running) {
36
+ logger.log(`${protocol.toUpperCase()} server listening on ${protocol}://localhost:${HTTP_PORT}`);
37
+ } else if (status.portInUse) {
38
+ logger.log(`Port ${HTTP_PORT} already in use - HTTP server not started`);
39
+ logger.log("Use server_control tool to attempt start again or shutdown_remote_server to stop the other instance");
40
+ } else if (status.externallyShutdown) {
41
+ logger.log("HTTP server cannot start - was externally shutdown");
42
+ logger.log("Restart the MCP instance to enable HTTP server");
43
+ }
33
44
 
34
45
  } catch (error) {
35
46
  logger.log("Failed to start Apple II MCP Agent:", error);
@@ -0,0 +1,27 @@
1
+ /*
2
+ * disconnect-clients.js - Disconnect all connected clients
3
+ *
4
+ * Written by
5
+ * Shawn Bullock <shawn@agenticexpert.ai>
6
+ */
7
+
8
+ export const tool = {
9
+ name: "disconnect_clients",
10
+ description: "Gracefully disconnect all connected Apple //e emulator clients",
11
+ inputSchema: {
12
+ type: "object",
13
+ properties: {},
14
+ },
15
+ };
16
+
17
+ export function handler(args, httpServer) {
18
+ const result = httpServer.disconnectAllClients();
19
+
20
+ return {
21
+ success: true,
22
+ disconnected: result.disconnected,
23
+ message: result.disconnected > 0
24
+ ? `Disconnected ${result.disconnected} client(s)`
25
+ : "No clients were connected",
26
+ };
27
+ }
@@ -0,0 +1,27 @@
1
+ /*
2
+ * get-version.js - Get MCP server version information
3
+ *
4
+ * Written by
5
+ * Shawn Bullock <shawn@agenticexpert.ai>
6
+ */
7
+
8
+ import { VERSION, NAME, DESCRIPTION } from "../version.js";
9
+
10
+ export const tool = {
11
+ name: "get_version",
12
+ description: "Get the Apple II MCP Agent version information",
13
+ inputSchema: {
14
+ type: "object",
15
+ properties: {},
16
+ required: [],
17
+ },
18
+ };
19
+
20
+ export function handler() {
21
+ return {
22
+ success: true,
23
+ name: NAME,
24
+ version: VERSION,
25
+ description: DESCRIPTION,
26
+ };
27
+ }
@@ -10,6 +10,9 @@ import * as serverControl from "./server-control.js";
10
10
  import * as setHttps from "./set-https.js";
11
11
  import * as setDebug from "./set-debug.js";
12
12
  import * as getState from "./get-state.js";
13
+ import * as getVersion from "./get-version.js";
14
+ import * as disconnectClients from "./disconnect-clients.js";
15
+ import * as shutdownRemoteServer from "./shutdown-remote-server.js";
13
16
  import * as showWindow from "./show-window.js";
14
17
  import * as hideWindow from "./hide-window.js";
15
18
  import * as focusWindow from "./focus-window.js";
@@ -25,6 +28,9 @@ export const tools = [
25
28
  setHttps,
26
29
  setDebug,
27
30
  getState,
31
+ getVersion,
32
+ disconnectClients,
33
+ shutdownRemoteServer,
28
34
  showWindow,
29
35
  hideWindow,
30
36
  focusWindow,
@@ -7,7 +7,7 @@
7
7
 
8
8
  export const tool = {
9
9
  name: "server_control",
10
- description: "Control the AG-UI HTTP/HTTPS server (start, stop, restart)",
10
+ description: "Control the AG-UI HTTP/HTTPS server (start, stop, restart, status). To reclaim or take over a port when another instance is using it: (1) First call shutdown_remote_server to stop the other instance, then (2) Call this tool with action 'start' to start this instance.",
11
11
  inputSchema: {
12
12
  type: "object",
13
13
  properties: {
@@ -25,26 +25,65 @@ export async function handler(args, httpServer) {
25
25
  const { action } = args;
26
26
 
27
27
  switch (action) {
28
- case "start":
29
- if (httpServer.getStatus().running) {
30
- return { status: "already_running", ...httpServer.getStatus() };
28
+ case "start": {
29
+ const currentStatus = httpServer.getStatus();
30
+
31
+ if (currentStatus.running) {
32
+ return { status: "already_running", ...currentStatus };
31
33
  }
34
+
32
35
  await httpServer.start();
33
- return { status: "started", ...httpServer.getStatus() };
36
+ const newStatus = httpServer.getStatus();
37
+
38
+ if (newStatus.portInUse) {
39
+ return {
40
+ status: "failed_to_start",
41
+ reason: "port_in_use",
42
+ message: `Port ${newStatus.port} is already in use by another instance. Use shutdown_remote_server tool to stop it.`,
43
+ ...newStatus
44
+ };
45
+ }
46
+
47
+ return { status: "started", ...newStatus };
48
+ }
34
49
 
35
- case "stop":
36
- if (!httpServer.getStatus().running) {
37
- return { status: "already_stopped", ...httpServer.getStatus() };
50
+ case "stop": {
51
+ const currentStatus = httpServer.getStatus();
52
+ if (!currentStatus.running) {
53
+ return { status: "already_stopped", ...currentStatus };
38
54
  }
39
55
  await httpServer.stop();
40
56
  return { status: "stopped", ...httpServer.getStatus() };
57
+ }
41
58
 
42
- case "restart":
59
+ case "restart": {
43
60
  await httpServer.restart();
44
- return { status: "restarted", ...httpServer.getStatus() };
61
+ const newStatus = httpServer.getStatus();
62
+
63
+ if (newStatus.portInUse) {
64
+ return {
65
+ status: "failed_to_restart",
66
+ reason: "port_in_use",
67
+ message: `Port ${newStatus.port} is already in use by another instance. Use shutdown_remote_server tool to stop it.`,
68
+ ...newStatus
69
+ };
70
+ }
71
+
72
+ return { status: "restarted", ...newStatus };
73
+ }
74
+
75
+ case "status": {
76
+ const status = httpServer.getStatus();
77
+
78
+ // Add helpful messages based on state
79
+ if (status.portInUse && !status.running) {
80
+ status.message = `Port ${status.port} is in use. Use shutdown_remote_server to stop the other instance.`;
81
+ } else if (status.externallyShutdown && !status.running) {
82
+ status.message = "Server was externally shutdown. Use server_control to restart.";
83
+ }
45
84
 
46
- case "status":
47
- return httpServer.getStatus();
85
+ return status;
86
+ }
48
87
 
49
88
  default:
50
89
  throw new Error(`Unknown action: ${action}`);
@@ -0,0 +1,113 @@
1
+ /*
2
+ * shutdown-remote-server.js - Shutdown remote MCP server instance
3
+ *
4
+ * Written by
5
+ * Shawn Bullock <shawn@agenticexpert.ai>
6
+ */
7
+
8
+ import http from "http";
9
+ import https from "https";
10
+
11
+ export const tool = {
12
+ name: "shutdown_remote_server",
13
+ description: "Shutdown a remote Apple II MCP server instance running on the same port. Use this when port is already in use by another instance. To reclaim or take over a port: (1) Call this tool to shutdown the other instance, then (2) Call server_control with action 'start' to start this instance.",
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {
17
+ port: {
18
+ type: "number",
19
+ description: "Port number of the remote server (default: 3033)",
20
+ default: 3033,
21
+ },
22
+ useHttps: {
23
+ type: "boolean",
24
+ description: "Whether the remote server is using HTTPS (default: false)",
25
+ default: false,
26
+ },
27
+ },
28
+ },
29
+ };
30
+
31
+ export async function handler(args, httpServer) {
32
+ const port = args.port || 3033;
33
+ const useHttps = args.useHttps || false;
34
+ const protocol = useHttps ? "https" : "http";
35
+ const url = `${protocol}://localhost:${port}/shutdown`;
36
+
37
+ return new Promise((resolve, reject) => {
38
+ const requestModule = useHttps ? https : http;
39
+
40
+ const options = {
41
+ method: "POST",
42
+ headers: {
43
+ "Content-Type": "application/json",
44
+ },
45
+ // For self-signed certificates, ignore certificate errors
46
+ rejectUnauthorized: false,
47
+ };
48
+
49
+ const req = requestModule.request(url, options, (res) => {
50
+ let data = "";
51
+
52
+ res.on("data", (chunk) => {
53
+ data += chunk;
54
+ });
55
+
56
+ res.on("end", () => {
57
+ try {
58
+ const response = JSON.parse(data);
59
+ resolve({
60
+ success: true,
61
+ port,
62
+ protocol,
63
+ message: `Remote server on ${protocol}://localhost:${port} successfully shutdown`,
64
+ response,
65
+ });
66
+ } catch (error) {
67
+ resolve({
68
+ success: true,
69
+ port,
70
+ protocol,
71
+ message: `Remote server on ${protocol}://localhost:${port} shutdown (raw response)`,
72
+ rawResponse: data,
73
+ });
74
+ }
75
+ });
76
+ });
77
+
78
+ req.on("error", (error) => {
79
+ // Connection refused or other errors
80
+ if (error.code === "ECONNREFUSED") {
81
+ resolve({
82
+ success: false,
83
+ port,
84
+ protocol,
85
+ error: "connection_refused",
86
+ message: `No server found at ${protocol}://localhost:${port}`,
87
+ });
88
+ } else {
89
+ resolve({
90
+ success: false,
91
+ port,
92
+ protocol,
93
+ error: error.code || "unknown",
94
+ message: `Failed to connect to ${protocol}://localhost:${port}: ${error.message}`,
95
+ });
96
+ }
97
+ });
98
+
99
+ req.on("timeout", () => {
100
+ req.destroy();
101
+ resolve({
102
+ success: false,
103
+ port,
104
+ protocol,
105
+ error: "timeout",
106
+ message: `Request to ${protocol}://localhost:${port} timed out`,
107
+ });
108
+ });
109
+
110
+ req.setTimeout(5000); // 5 second timeout
111
+ req.end();
112
+ });
113
+ }
package/src/version.js ADDED
@@ -0,0 +1,20 @@
1
+ /*
2
+ * version.js - Version information from package.json
3
+ *
4
+ * Written by
5
+ * Shawn Bullock <shawn@agenticexpert.ai>
6
+ */
7
+
8
+ import { readFileSync } from "fs";
9
+ import { fileURLToPath } from "url";
10
+ import { dirname, join } from "path";
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ const packageJsonPath = join(__dirname, "..", "package.json");
16
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
17
+
18
+ export const VERSION = packageJson.version;
19
+ export const NAME = packageJson.name;
20
+ export const DESCRIPTION = packageJson.description;