Skip to content

Commit b356f4b

Browse files
matixanclaude
andcommitted
fix: version from package.json, python validation, TCP retry, registry cache, screenshot image content
Closes #15, #16, #17, #18, #19 - Read server version dynamically from package.json instead of hardcoding - Validate python3 and app_manager.py before delegating to subprocess - Add retry logic (3x with backoff) for simulator TCP connections - Cache app registry with 5-minute TTL - Return MCP image content type from screenshot tool Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 525bd46 commit b356f4b

3 files changed

Lines changed: 100 additions & 5 deletions

File tree

src/index.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
44
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
55
import { z } from "zod";
66

7+
import fs from "fs";
8+
import { createRequire } from "module";
9+
const require = createRequire(import.meta.url);
10+
const { version } = require("../package.json");
11+
712
// Tool implementations
813
import { crosspadBuild, crosspadRun } from "./tools/build.js";
914
import { crosspadBuildCheck } from "./tools/build-check.js";
@@ -31,7 +36,7 @@ import type { OnLine } from "./utils/exec.js";
3136
import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
3237

3338
const server = new McpServer(
34-
{ name: "crosspad", version: "5.0.0" },
39+
{ name: "crosspad", version },
3540
{ capabilities: { logging: {} } }
3641
);
3742

@@ -146,8 +151,27 @@ server.tool(
146151
},
147152
async ({ action, region, filename, save_to_file, input_action, x, y, pad, velocity, delta, keycode, category, key, value }) => {
148153
switch (action) {
149-
case "screenshot":
150-
return jsonResponse(await crosspadScreenshot(save_to_file ?? true, filename));
154+
case "screenshot": {
155+
const result = await crosspadScreenshot(save_to_file ?? true, filename);
156+
if (result.success) {
157+
let imageData: string | undefined = result.data_base64;
158+
// Read from file if saved to disk
159+
if (!imageData && result.file_path) {
160+
try {
161+
imageData = fs.readFileSync(result.file_path).toString("base64");
162+
} catch { /* fall through to JSON response */ }
163+
}
164+
if (imageData) {
165+
return {
166+
content: [
167+
{ type: "image" as const, data: imageData, mimeType: "image/png" },
168+
{ type: "text" as const, text: JSON.stringify({ success: true, width: result.width, height: result.height, format: result.format, file_path: result.file_path }, null, 2) },
169+
],
170+
};
171+
}
172+
}
173+
return jsonResponse(result);
174+
}
151175

152176
case "input": {
153177
if (!input_action) {

src/tools/app-manager.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import fs from "fs";
1313
import path from "path";
14+
import { execSync } from "child_process";
1415
import { CROSSPAD_IDF_ROOT, CROSSPAD_PC_ROOT, getRepos } from "../config.js";
1516
import { runCommand, runCommandStream, OnLine } from "../utils/exec.js";
1617

@@ -120,13 +121,24 @@ function resolvePlatform(platform: string): PlatformInfo | null {
120121
// HELPERS
121122
// ═══════════════════════════════════════════════════════════════════════
122123

124+
const REGISTRY_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
125+
let _registryCache: { data: Record<string, AppEntry>; timestamp: number; source: string } | null = null;
126+
123127
function loadRegistryJsonFrom(repoRoot: string): Record<string, AppEntry> | null {
124128
const registryPath = path.join(repoRoot, "app-registry.json");
129+
130+
// Return cached if fresh and from same source
131+
if (_registryCache && _registryCache.source === registryPath && Date.now() - _registryCache.timestamp < REGISTRY_CACHE_TTL_MS) {
132+
return _registryCache.data;
133+
}
134+
125135
if (!fs.existsSync(registryPath)) return null;
126136

127137
try {
128138
const data = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
129-
return data.apps ?? {};
139+
const apps = data.apps ?? {};
140+
_registryCache = { data: apps, timestamp: Date.now(), source: registryPath };
141+
return apps;
130142
} catch {
131143
return null;
132144
}
@@ -282,6 +294,30 @@ function requirePlatform(platform: string): { info: PlatformInfo } | { error: Ap
282294
return { info };
283295
}
284296

297+
let _pythonOk: boolean | null = null;
298+
299+
function isPythonAvailable(): boolean {
300+
if (_pythonOk !== null) return _pythonOk;
301+
try {
302+
execSync("python3 --version", { stdio: "ignore", timeout: 5000 });
303+
_pythonOk = true;
304+
} catch {
305+
_pythonOk = false;
306+
}
307+
return _pythonOk;
308+
}
309+
310+
function validatePythonSetup(info: PlatformInfo): string | null {
311+
if (!isPythonAvailable()) {
312+
return "python3 is not installed or not in PATH. Install Python 3 to use app management.";
313+
}
314+
const scriptPath = path.join(info.root, info.scriptDir, "app_manager.py");
315+
if (!fs.existsSync(scriptPath)) {
316+
return `app_manager.py not found at ${scriptPath}. Run 'idf.py app-list' first to bootstrap the script.`;
317+
}
318+
return null;
319+
}
320+
285321
async function runPythonAction(
286322
info: PlatformInfo,
287323
action: string,
@@ -291,6 +327,18 @@ async function runPythonAction(
291327
onLine: OnLine | undefined,
292328
timeoutMs: number,
293329
): Promise<AppActionResult> {
330+
const validationError = validatePythonSetup(info);
331+
if (validationError) {
332+
return {
333+
success: false,
334+
action,
335+
platform: info.label,
336+
app_name: appName,
337+
output: "",
338+
error: validationError,
339+
};
340+
}
341+
294342
const cmd = buildPythonCmd(info.root, info.scriptDir, method, args);
295343

296344
if (onLine) {

src/utils/remote-client.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,34 @@ export interface RemoteResponse {
1515
[key: string]: unknown;
1616
}
1717

18+
const MAX_RETRIES = 3;
19+
const RETRY_DELAY_MS = 500;
20+
21+
function delay(ms: number): Promise<void> {
22+
return new Promise((resolve) => setTimeout(resolve, ms));
23+
}
24+
1825
/**
1926
* Send a JSON command to the running simulator and return the response.
27+
* Retries up to MAX_RETRIES times on timeout errors (simulator may be loading).
2028
* Opens a fresh TCP connection per call (simple, stateless).
2129
*/
22-
export function sendRemoteCommand(command: Record<string, unknown>): Promise<RemoteResponse> {
30+
export async function sendRemoteCommand(command: Record<string, unknown>): Promise<RemoteResponse> {
31+
let lastError: Error | undefined;
32+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
33+
try {
34+
return await sendRemoteCommandOnce(command);
35+
} catch (err: any) {
36+
lastError = err;
37+
// Only retry on timeout, not on connection refused (simulator not running)
38+
if (err.message?.includes("Connection refused")) throw err;
39+
if (attempt < MAX_RETRIES) await delay(RETRY_DELAY_MS * attempt);
40+
}
41+
}
42+
throw lastError!;
43+
}
44+
45+
function sendRemoteCommandOnce(command: Record<string, unknown>): Promise<RemoteResponse> {
2346
return new Promise((resolve, reject) => {
2447
const socket = new Socket();
2548
let buffer = "";

0 commit comments

Comments
 (0)