replicant-mcp 1.1.0 → 1.2.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/README.md CHANGED
@@ -63,6 +63,9 @@ replicant-mcp wraps all of this into a clean interface that AI can understand an
63
63
  | **Developer Experience** | Simplified tool authoring with `defineTool()` helper | Future |
64
64
  | | Auto-generate JSON schema from Zod via `zod-to-json-schema` | Future |
65
65
  | | Convention-based tool auto-discovery (no manual wiring) | Future |
66
+ | **Screenshot Scaling** | Auto-resize screenshots to prevent API context limits | Planned |
67
+ | | Transparent coordinate conversion (image ↔ device space) | Planned |
68
+ | | Raw mode for external context management | Planned |
66
69
 
67
70
  ---
68
71
 
@@ -14,18 +14,48 @@ export interface CurrentApp {
14
14
  export interface ScreenshotOptions {
15
15
  localPath?: string;
16
16
  inline?: boolean;
17
+ maxDimension?: number;
18
+ raw?: boolean;
17
19
  }
18
20
  export interface ScreenshotResult {
19
21
  mode: "file" | "inline";
20
22
  path?: string;
21
23
  base64?: string;
22
24
  sizeBytes?: number;
25
+ device?: {
26
+ width: number;
27
+ height: number;
28
+ };
29
+ image?: {
30
+ width: number;
31
+ height: number;
32
+ };
33
+ scaleFactor?: number;
34
+ warning?: string;
23
35
  }
24
36
  export type FindWithOcrResult = FindWithFallbacksResult;
25
37
  export type FindOptions = IconFindOptions;
38
+ /**
39
+ * Tracks the current scaling state between device and image coordinates.
40
+ * Updated on every screenshot operation.
41
+ */
42
+ export interface ScalingState {
43
+ scaleFactor: number;
44
+ deviceWidth: number;
45
+ deviceHeight: number;
46
+ imageWidth: number;
47
+ imageHeight: number;
48
+ }
26
49
  export declare class UiAutomatorAdapter {
27
50
  private adb;
51
+ private scalingState;
28
52
  constructor(adb?: AdbAdapter);
53
+ getScalingState(): ScalingState | null;
54
+ /**
55
+ * Transforms accessibility tree nodes from device space to image space.
56
+ * This ensures bounds/coordinates match the scaled screenshot when scaling is active.
57
+ */
58
+ private transformTreeToImageSpace;
29
59
  dump(deviceId: string): Promise<AccessibilityNode[]>;
30
60
  find(deviceId: string, selector: {
31
61
  resourceId?: string;
@@ -1,5 +1,6 @@
1
1
  import * as path from "path";
2
2
  import * as fs from "fs";
3
+ import sharp from "sharp";
3
4
  import { AdbAdapter } from "./adb.js";
4
5
  import { parseUiDump, findElements, flattenTree } from "../parsers/ui-dump.js";
5
6
  import { ReplicantError, ErrorCode } from "../types/index.js";
@@ -7,6 +8,7 @@ import { extractText, searchText } from "../services/ocr.js";
7
8
  import { matchIconPattern, matchesResourceId } from "../services/icon-patterns.js";
8
9
  import { filterIconCandidates, formatBounds, cropCandidateImage } from "../services/visual-candidates.js";
9
10
  import { calculateGridCellBounds, calculatePositionCoordinates, createGridOverlay, POSITION_LABELS, } from "../services/grid.js";
11
+ import { calculateScaleFactor, toImageSpace, toDeviceSpace, boundsToImageSpace } from "../services/scaling.js";
10
12
  /**
11
13
  * Get default screenshot path in project-relative .replicant/screenshots directory.
12
14
  * Creates the directory if it doesn't exist.
@@ -18,9 +20,35 @@ function getDefaultScreenshotPath() {
18
20
  }
19
21
  export class UiAutomatorAdapter {
20
22
  adb;
23
+ scalingState = null;
21
24
  constructor(adb = new AdbAdapter()) {
22
25
  this.adb = adb;
23
26
  }
27
+ // Getter for tests
28
+ getScalingState() {
29
+ return this.scalingState;
30
+ }
31
+ /**
32
+ * Transforms accessibility tree nodes from device space to image space.
33
+ * This ensures bounds/coordinates match the scaled screenshot when scaling is active.
34
+ */
35
+ transformTreeToImageSpace(nodes) {
36
+ if (!this.scalingState || this.scalingState.scaleFactor === 1.0) {
37
+ return nodes;
38
+ }
39
+ const sf = this.scalingState.scaleFactor;
40
+ return nodes.map((node) => {
41
+ const newBounds = boundsToImageSpace(node.bounds, sf);
42
+ const center = toImageSpace(node.centerX, node.centerY, sf);
43
+ return {
44
+ ...node,
45
+ bounds: newBounds,
46
+ centerX: center.x,
47
+ centerY: center.y,
48
+ children: node.children ? this.transformTreeToImageSpace(node.children) : [],
49
+ };
50
+ });
51
+ }
24
52
  async dump(deviceId) {
25
53
  // Dump UI hierarchy to device
26
54
  await this.adb.shell(deviceId, "uiautomator dump /sdcard/ui-dump.xml");
@@ -28,14 +56,23 @@ export class UiAutomatorAdapter {
28
56
  const result = await this.adb.shell(deviceId, "cat /sdcard/ui-dump.xml");
29
57
  // Clean up
30
58
  await this.adb.shell(deviceId, "rm /sdcard/ui-dump.xml");
31
- return parseUiDump(result.stdout);
59
+ const tree = parseUiDump(result.stdout);
60
+ return this.transformTreeToImageSpace(tree);
32
61
  }
33
62
  async find(deviceId, selector) {
34
63
  const tree = await this.dump(deviceId);
35
64
  return findElements(tree, selector);
36
65
  }
37
66
  async tap(deviceId, x, y) {
38
- await this.adb.shell(deviceId, `input tap ${x} ${y}`);
67
+ // Convert from image space to device space if scaling is active
68
+ let tapX = x;
69
+ let tapY = y;
70
+ if (this.scalingState && this.scalingState.scaleFactor !== 1.0) {
71
+ const converted = toDeviceSpace(x, y, this.scalingState.scaleFactor);
72
+ tapX = converted.x;
73
+ tapY = converted.y;
74
+ }
75
+ await this.adb.shell(deviceId, `input tap ${tapX} ${tapY}`);
39
76
  }
40
77
  async tapElement(deviceId, element) {
41
78
  await this.tap(deviceId, element.centerX, element.centerY);
@@ -47,6 +84,7 @@ export class UiAutomatorAdapter {
47
84
  }
48
85
  async screenshot(deviceId, options = {}) {
49
86
  const remotePath = "/sdcard/replicant-screenshot.png";
87
+ const maxDimension = options.maxDimension ?? 1000;
50
88
  // Capture screenshot on device
51
89
  const captureResult = await this.adb.shell(deviceId, `screencap -p ${remotePath}`);
52
90
  if (captureResult.exitCode !== 0) {
@@ -54,7 +92,9 @@ export class UiAutomatorAdapter {
54
92
  }
55
93
  try {
56
94
  if (options.inline) {
57
- // Inline mode: return base64
95
+ // Inline mode: return base64 (no scaling support for inline mode)
96
+ // Clear scaling state since inline mode doesn't support coordinate conversion
97
+ this.scalingState = null;
58
98
  const base64Result = await this.adb.shell(deviceId, `base64 ${remotePath}`);
59
99
  const sizeResult = await this.adb.shell(deviceId, `stat -c%s ${remotePath}`);
60
100
  return {
@@ -64,10 +104,74 @@ export class UiAutomatorAdapter {
64
104
  };
65
105
  }
66
106
  else {
67
- // File mode (default): pull to local
107
+ // File mode: pull to local, then optionally scale
68
108
  const localPath = options.localPath || getDefaultScreenshotPath();
69
109
  await this.adb.pull(deviceId, remotePath, localPath);
70
- return { mode: "file", path: localPath };
110
+ // Get image dimensions
111
+ const metadata = await sharp(localPath).metadata();
112
+ const deviceWidth = metadata.width;
113
+ const deviceHeight = metadata.height;
114
+ // Handle raw mode
115
+ if (options.raw) {
116
+ this.scalingState = {
117
+ scaleFactor: 1.0,
118
+ deviceWidth,
119
+ deviceHeight,
120
+ imageWidth: deviceWidth,
121
+ imageHeight: deviceHeight,
122
+ };
123
+ return {
124
+ mode: "file",
125
+ path: localPath,
126
+ device: { width: deviceWidth, height: deviceHeight },
127
+ image: { width: deviceWidth, height: deviceHeight },
128
+ scaleFactor: 1.0,
129
+ warning: "Raw mode: no scaling applied. May exceed API limits with multiple images.",
130
+ };
131
+ }
132
+ // Calculate scale factor
133
+ const scaleFactor = calculateScaleFactor(deviceWidth, deviceHeight, maxDimension);
134
+ if (scaleFactor === 1.0) {
135
+ // No scaling needed
136
+ this.scalingState = {
137
+ scaleFactor: 1.0,
138
+ deviceWidth,
139
+ deviceHeight,
140
+ imageWidth: deviceWidth,
141
+ imageHeight: deviceHeight,
142
+ };
143
+ return {
144
+ mode: "file",
145
+ path: localPath,
146
+ device: { width: deviceWidth, height: deviceHeight },
147
+ image: { width: deviceWidth, height: deviceHeight },
148
+ scaleFactor: 1.0,
149
+ };
150
+ }
151
+ // Scale the image
152
+ const imageWidth = Math.round(deviceWidth / scaleFactor);
153
+ const imageHeight = Math.round(deviceHeight / scaleFactor);
154
+ await sharp(localPath)
155
+ .resize(imageWidth, imageHeight)
156
+ .toFile(localPath + ".tmp");
157
+ // Replace original with scaled version
158
+ const fsPromises = await import("fs/promises");
159
+ await fsPromises.rename(localPath + ".tmp", localPath);
160
+ // Update scaling state
161
+ this.scalingState = {
162
+ scaleFactor,
163
+ deviceWidth,
164
+ deviceHeight,
165
+ imageWidth,
166
+ imageHeight,
167
+ };
168
+ return {
169
+ mode: "file",
170
+ path: localPath,
171
+ device: { width: deviceWidth, height: deviceHeight },
172
+ image: { width: imageWidth, height: imageHeight },
173
+ scaleFactor,
174
+ };
71
175
  }
72
176
  }
73
177
  finally {
package/dist/server.js CHANGED
@@ -40,7 +40,7 @@ Tool mapping:
40
40
  - Emulator control → emulator-device (not \`emulator\` CLI)
41
41
  - Builds → gradle-build (not \`./gradlew\`)
42
42
  - Tests → gradle-test (not \`./gradlew test\`)
43
- - UI automation → ui (not \`adb shell uiautomator\` or \`screencap\`)
43
+ - UI automation → ui (accessibility-first, screenshots auto-scaled to 1000px)
44
44
 
45
45
  Start with \`adb-device list\` to see connected devices.
46
46
  Use \`rtfm\` for detailed documentation on any tool.`,
@@ -40,6 +40,7 @@ function mergeUiConfig(defaults, overrides) {
40
40
  visualModePackages: overrides.visualModePackages ?? defaults.visualModePackages,
41
41
  autoFallbackScreenshot: overrides.autoFallbackScreenshot ?? defaults.autoFallbackScreenshot,
42
42
  includeBase64: overrides.includeBase64 ?? defaults.includeBase64,
43
+ maxImageDimension: overrides.maxImageDimension ?? defaults.maxImageDimension,
43
44
  };
44
45
  }
45
46
  /**
@@ -7,3 +7,4 @@ export * from "./ocr.js";
7
7
  export * from "./icon-patterns.js";
8
8
  export * from "./grid.js";
9
9
  export * from "./visual-candidates.js";
10
+ export * from "./scaling.js";
@@ -7,3 +7,4 @@ export * from "./ocr.js";
7
7
  export * from "./icon-patterns.js";
8
8
  export * from "./grid.js";
9
9
  export * from "./visual-candidates.js";
10
+ export * from "./scaling.js";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Calculate the scale factor needed to fit device dimensions within max dimension.
3
+ * Returns 1.0 if no scaling needed.
4
+ */
5
+ export declare function calculateScaleFactor(deviceWidth: number, deviceHeight: number, maxDimension: number): number;
6
+ export interface Bounds {
7
+ left: number;
8
+ top: number;
9
+ right: number;
10
+ bottom: number;
11
+ }
12
+ /**
13
+ * Convert device coordinates to image coordinates.
14
+ */
15
+ export declare function toImageSpace(deviceX: number, deviceY: number, scaleFactor: number): {
16
+ x: number;
17
+ y: number;
18
+ };
19
+ /**
20
+ * Convert image coordinates to device coordinates.
21
+ */
22
+ export declare function toDeviceSpace(imageX: number, imageY: number, scaleFactor: number): {
23
+ x: number;
24
+ y: number;
25
+ };
26
+ /**
27
+ * Convert bounds from device space to image space.
28
+ */
29
+ export declare function boundsToImageSpace(bounds: Bounds, scaleFactor: number): Bounds;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Calculate the scale factor needed to fit device dimensions within max dimension.
3
+ * Returns 1.0 if no scaling needed.
4
+ */
5
+ export function calculateScaleFactor(deviceWidth, deviceHeight, maxDimension) {
6
+ const longestSide = Math.max(deviceWidth, deviceHeight);
7
+ if (longestSide <= maxDimension) {
8
+ return 1.0;
9
+ }
10
+ return longestSide / maxDimension;
11
+ }
12
+ /**
13
+ * Convert device coordinates to image coordinates.
14
+ */
15
+ export function toImageSpace(deviceX, deviceY, scaleFactor) {
16
+ if (scaleFactor === 1.0) {
17
+ return { x: deviceX, y: deviceY };
18
+ }
19
+ return {
20
+ x: Math.round(deviceX / scaleFactor),
21
+ y: Math.round(deviceY / scaleFactor),
22
+ };
23
+ }
24
+ /**
25
+ * Convert image coordinates to device coordinates.
26
+ */
27
+ export function toDeviceSpace(imageX, imageY, scaleFactor) {
28
+ if (scaleFactor === 1.0) {
29
+ return { x: imageX, y: imageY };
30
+ }
31
+ return {
32
+ x: Math.round(imageX * scaleFactor),
33
+ y: Math.round(imageY * scaleFactor),
34
+ };
35
+ }
36
+ /**
37
+ * Convert bounds from device space to image space.
38
+ */
39
+ export function boundsToImageSpace(bounds, scaleFactor) {
40
+ if (scaleFactor === 1.0) {
41
+ return bounds;
42
+ }
43
+ return {
44
+ left: Math.round(bounds.left / scaleFactor),
45
+ top: Math.round(bounds.top / scaleFactor),
46
+ right: Math.round(bounds.right / scaleFactor),
47
+ bottom: Math.round(bounds.bottom / scaleFactor),
48
+ };
49
+ }
@@ -27,6 +27,8 @@ export declare const uiInputSchema: z.ZodObject<{
27
27
  debug: z.ZodOptional<z.ZodBoolean>;
28
28
  gridCell: z.ZodOptional<z.ZodNumber>;
29
29
  gridPosition: z.ZodOptional<z.ZodNumber>;
30
+ maxDimension: z.ZodOptional<z.ZodNumber>;
31
+ raw: z.ZodOptional<z.ZodBoolean>;
30
32
  }, z.core.$strip>;
31
33
  export type UiInput = z.infer<typeof uiInputSchema>;
32
34
  export declare function handleUiTool(input: UiInput, context: ServerContext, uiConfig?: UiConfig): Promise<Record<string, unknown>>;
@@ -102,6 +104,14 @@ export declare const uiToolDefinition: {
102
104
  maximum: number;
103
105
  description: string;
104
106
  };
107
+ maxDimension: {
108
+ type: string;
109
+ description: string;
110
+ };
111
+ raw: {
112
+ type: string;
113
+ description: string;
114
+ };
105
115
  };
106
116
  required: string[];
107
117
  };
package/dist/tools/ui.js CHANGED
@@ -19,6 +19,8 @@ export const uiInputSchema = z.object({
19
19
  debug: z.boolean().optional(),
20
20
  gridCell: z.number().min(1).max(24).optional(),
21
21
  gridPosition: z.number().min(1).max(5).optional(),
22
+ maxDimension: z.number().optional(),
23
+ raw: z.boolean().optional(),
22
24
  });
23
25
  // Store last find results for elementIndex reference
24
26
  // Updated to support accessibility, OCR, and grid elements
@@ -93,6 +95,7 @@ export async function handleUiTool(input, context, uiConfig) {
93
95
  visualModePackages: [],
94
96
  autoFallbackScreenshot: true,
95
97
  includeBase64: false,
98
+ maxImageDimension: 1000,
96
99
  };
97
100
  switch (input.operation) {
98
101
  case "dump": {
@@ -319,6 +322,8 @@ export async function handleUiTool(input, context, uiConfig) {
319
322
  const result = await context.ui.screenshot(deviceId, {
320
323
  localPath: input.localPath,
321
324
  inline: input.inline,
325
+ maxDimension: input.maxDimension ?? config.maxImageDimension,
326
+ raw: input.raw,
322
327
  });
323
328
  return { ...result, deviceId };
324
329
  }
@@ -366,6 +371,14 @@ export const uiToolDefinition = {
366
371
  debug: { type: "boolean", description: "Include source (accessibility/ocr) and confidence in response" },
367
372
  gridCell: { type: "number", minimum: 1, maximum: 24, description: "Grid cell number (1-24) for Tier 5 refinement" },
368
373
  gridPosition: { type: "number", minimum: 1, maximum: 5, description: "Position within cell (1=TL, 2=TR, 3=Center, 4=BL, 5=BR)" },
374
+ maxDimension: {
375
+ type: "number",
376
+ description: "Max image dimension in pixels (default: 1000). Higher = better quality, more tokens.",
377
+ },
378
+ raw: {
379
+ type: "boolean",
380
+ description: "Skip scaling, return full device resolution. Warning: may exceed API limits.",
381
+ },
369
382
  },
370
383
  required: ["operation"],
371
384
  },
@@ -9,6 +9,8 @@ export interface UiConfig {
9
9
  autoFallbackScreenshot: boolean;
10
10
  /** Include base64-encoded screenshot in response (default: false) */
11
11
  includeBase64: boolean;
12
+ /** Maximum dimension (width or height) for screenshots in pixels (default: 1000) */
13
+ maxImageDimension: number;
12
14
  }
13
15
  export interface ReplicantConfig {
14
16
  ui: UiConfig;
@@ -7,5 +7,6 @@ export const DEFAULT_CONFIG = {
7
7
  visualModePackages: [],
8
8
  autoFallbackScreenshot: true,
9
9
  includeBase64: false,
10
+ maxImageDimension: 1000,
10
11
  },
11
12
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replicant-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Android MCP server for AI-assisted Android development",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,7 +18,7 @@
18
18
  "check-prereqs": "bash scripts/check-prerequisites.sh",
19
19
  "smoke-test": "bash scripts/smoke-test.sh",
20
20
  "test:device": "tsx scripts/real-device-test.ts",
21
- "start": "node dist/index.js",
21
+ "start": "npm run build && node dist/index.js",
22
22
  "validate": "npm run build && npm run test -- --run",
23
23
  "prepublishOnly": "npm run build && npm test -- --run"
24
24
  },