android-mcp-toolkit 1.1.0 → 1.3.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.
Files changed (3) hide show
  1. package/README.md +15 -11
  2. package/dist/index.js +470 -321
  3. package/package.json +5 -5
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Android MCP Toolkit for AI Agents
2
2
 
3
- Small MCP server with two tools:
3
+ Small MCP server with three tools:
4
4
  - Fast SVG → Android VectorDrawable conversion (cached, file or inline).
5
5
  - adb logcat reader with package/pid/tag filters for quick crash triage.
6
+ - Translation length difference estimator to flag risky length deltas before layout breaks.
6
7
 
7
8
  ## Why this exists
8
9
  **The Mission: Bringing Native Android to the AI Agent Era**
@@ -72,6 +73,10 @@ While the AI ecosystem flourishes with web-first tools, Android development ofte
72
73
  - Inputs: `timeoutMs` (default `5000`, max `15000`).
73
74
  - Behavior: Runs `adb logcat -c` to clear buffers before a new scenario.
74
75
 
76
+ - `estimate-text-length-difference`
77
+ - Inputs: `sourceText` (original), `translatedText` (to compare), `tolerancePercent` (default `30`, max `500`).
78
+ - Behavior: Measures grapheme length of both strings, computes percent change, and reports whether it exceeds the tolerance (useful to catch translation length blowups that could break layouts).
79
+
75
80
  ## Roadmap (planned)
76
81
  - Additional MCP tools for Android assets (e.g., batch conversions, validations, optimizers).
77
82
  - Optional resource prompts for common Android drawables/templates.
@@ -83,15 +88,11 @@ While the AI ecosystem flourishes with web-first tools, Android development ofte
83
88
 
84
89
  ## Quick start
85
90
  - `npm install`
86
- - `npm start` (keeps running on stdio; point your MCP client at `node src/index.js`)
91
+ - `npm run build`
92
+ - `node dist/index.js` (stdio MCP server)
87
93
 
88
94
  ## Run via npx
89
- - From repo root: `npx .` (uses `svg-to-drawable-mcp` bin; runs on stdio)
90
-
91
- ## Run with Docker
92
- - Build: `docker build -t svg-to-drawable-mcp .`
93
- - Run: `docker run --rm -it svg-to-drawable-mcp`
94
- - The container prints to stdio; point your MCP client at `docker run --rm -i svg-to-drawable-mcp`.
95
+ - Global: `npx android-mcp-toolkit`
95
96
 
96
97
  ## Use in Cursor (MCP config)
97
98
  Add to your Cursor settings JSON:
@@ -101,17 +102,20 @@ Add to your Cursor settings JSON:
101
102
  "figma-desktop": {
102
103
  "url": "http://127.0.0.1:3845/mcp"
103
104
  },
104
- "svg-to-android-drawable": {
105
+ "android-mcp-toolkit": {
105
106
  "command": "npx",
106
107
  "args": [
107
108
  "-y",
108
- "/Users/admin/code/android_util_mcp_server"
109
+ "android-mcp-toolkit"
109
110
  ]
110
111
  }
111
112
  }
112
113
  }
113
114
  ```
114
- Adjust the local path if your repo lives elsewhere.
115
+ The npx call downloads the published package; no local path required.
116
+
117
+ Quick install via Cursor deep link:
118
+ - `cursor://anysphere.cursor-deeplink/mcp/install?name=android-mcp-toolkit&config=eyJjb21tYW5kIjoibnB4IC15IGFuZHJvaWQtbWNwLXRvb2xraXQifQ%3D%3D`
115
119
 
116
120
  ## Examples
117
121
  - Input SVG: `sample_svg.svg`
package/dist/index.js CHANGED
@@ -4,11 +4,160 @@ var __commonJS = (cb, mod) => function __require() {
4
4
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
5
5
  };
6
6
 
7
+ // vendor/svg2vectordrawable/svgo-adapter.js
8
+ var require_svgo_adapter = __commonJS({
9
+ "vendor/svg2vectordrawable/svgo-adapter.js"(exports2, module2) {
10
+ var { optimize } = require("svgo");
11
+ var JSAPI = class _JSAPI {
12
+ constructor(data, parentNode) {
13
+ this.parentNode = parentNode || null;
14
+ this.type = data.type || "element";
15
+ this.name = data.name || "";
16
+ this.children = [];
17
+ this.attrs = {};
18
+ if (data.attributes) {
19
+ for (const [key, value] of Object.entries(data.attributes)) {
20
+ this._addAttrInternal(key, value);
21
+ }
22
+ } else if (data.attrs) {
23
+ this.attrs = data.attrs;
24
+ }
25
+ if (data.children && Array.isArray(data.children)) {
26
+ this.children = data.children.map((c) => new _JSAPI(c, this));
27
+ }
28
+ }
29
+ _addAttrInternal(name, value) {
30
+ const parts = name.split(":");
31
+ let local = parts[0];
32
+ let prefix = "";
33
+ if (parts.length > 1) {
34
+ prefix = parts[0];
35
+ local = parts[1];
36
+ }
37
+ this.attrs[name] = {
38
+ name,
39
+ value,
40
+ local,
41
+ prefix
42
+ };
43
+ }
44
+ // Legacy JSAPI text node handling?
45
+ // SVGO v2 had text nodes? XAST has { type: 'text', value: '...' }
46
+ // Android VectorDrawable doesn't support text, so maybe it's ignored or handled?
47
+ // The converter doesn't seem to handle text nodes explicitly, it iterates children.
48
+ hasAttr(name, value) {
49
+ const attr = this.attrs[name];
50
+ if (!attr) return false;
51
+ if (value !== void 0) return attr.value === value;
52
+ return true;
53
+ }
54
+ attr(name) {
55
+ return this.attrs[name];
56
+ }
57
+ addAttr(attrObj) {
58
+ this.attrs[attrObj.name] = attrObj;
59
+ }
60
+ removeAttr(name) {
61
+ delete this.attrs[name];
62
+ }
63
+ renameElem(newName) {
64
+ this.name = newName;
65
+ }
66
+ eachAttr(callback, context) {
67
+ for (const key in this.attrs) {
68
+ callback.call(context || this, this.attrs[key]);
69
+ }
70
+ }
71
+ isEmpty() {
72
+ return !this.children || this.children.length === 0;
73
+ }
74
+ // Helper to find specific children (used in converter?)
75
+ // converter uses querySelectorAll on `data` (which is Root node)
76
+ // We need to implement querySelectorAll if it was part of JSAPI or SVGO API.
77
+ // Looking at converter:
78
+ // `data.querySelectorAll('use')`
79
+ // `root.querySelector('svg')`
80
+ // Wait, JSAPI v2 had querySelector/All?
81
+ // If so, I MUST implement them.
82
+ querySelector(selector) {
83
+ const results = this.querySelectorAll(selector);
84
+ return results.length > 0 ? results[0] : null;
85
+ }
86
+ querySelectorAll(selector) {
87
+ const results = [];
88
+ this._traverse((node) => {
89
+ if (this._matches(node, selector)) {
90
+ results.push(node);
91
+ }
92
+ });
93
+ return results;
94
+ }
95
+ _traverse(callback) {
96
+ callback(this);
97
+ if (this.children) {
98
+ this.children.forEach((c) => c._traverse(callback));
99
+ }
100
+ }
101
+ _matches(node, selector) {
102
+ if (node.type !== "element") return false;
103
+ if (selector.includes(",")) {
104
+ const parts = selector.split(",").map((s) => s.trim());
105
+ return parts.some((p) => this._matches(node, p));
106
+ }
107
+ if (/^[a-zA-Z0-9\-_:]+$/.test(selector)) {
108
+ return node.name === selector;
109
+ }
110
+ const attrMatch = selector.match(/^([a-zA-Z0-9\-_:]+)?\[([a-zA-Z0-9\-_:]+)="([^"]+)"\]$/);
111
+ if (attrMatch) {
112
+ const tagName = attrMatch[1];
113
+ const attrName = attrMatch[2];
114
+ const attrVal = attrMatch[3];
115
+ if (tagName && node.name !== tagName) return false;
116
+ return node.hasAttr(attrName, attrVal);
117
+ }
118
+ return false;
119
+ }
120
+ spliceContent(index, count, newItems) {
121
+ const items = Array.isArray(newItems) ? newItems : [newItems];
122
+ const validItems = items.filter((i) => i && (i instanceof _JSAPI || Array.isArray(i) && i.length === 0 ? false : true));
123
+ const flatItems = items.flat();
124
+ flatItems.forEach((item) => {
125
+ if (item instanceof _JSAPI) {
126
+ item.parentNode = this;
127
+ }
128
+ });
129
+ this.children.splice(index, count, ...flatItems);
130
+ }
131
+ };
132
+ function parseSvg(svgString) {
133
+ let xastRoot = null;
134
+ optimize(svgString, {
135
+ plugins: [
136
+ {
137
+ name: "fetch-ast",
138
+ fn: (root) => {
139
+ xastRoot = root;
140
+ return {};
141
+ }
142
+ }
143
+ ]
144
+ });
145
+ if (!xastRoot) {
146
+ throw new Error("SVGO failed to parse SVG");
147
+ }
148
+ return new JSAPI(xastRoot);
149
+ }
150
+ module2.exports = {
151
+ JSAPI,
152
+ parseSvg
153
+ };
154
+ }
155
+ });
156
+
7
157
  // vendor/svg2vectordrawable/svg-to-vectordrawable.js
8
158
  var require_svg_to_vectordrawable = __commonJS({
9
159
  "vendor/svg2vectordrawable/svg-to-vectordrawable.js"(exports2, module2) {
10
- var { parseSvg } = require("svgo/lib/parser");
11
- var JSAPI = require("svgo/lib/svgo/jsAPI");
160
+ var { parseSvg, JSAPI } = require_svgo_adapter();
12
161
  var pathBounds = require("svg-path-bounds");
13
162
  var svgpath = require("svgpath");
14
163
  var JS2XML = function() {
@@ -908,189 +1057,50 @@ var require_svg_to_vectordrawable = __commonJS({
908
1057
  var require_svgo_config = __commonJS({
909
1058
  "vendor/svg2vectordrawable/svgo-config.js"(exports2, module2) {
910
1059
  module2.exports = function(floatPrecision = 2) {
911
- const svgoConfig = {
912
- info: {
913
- input: "string"
914
- },
1060
+ return {
915
1061
  plugins: [
916
1062
  {
917
- name: "removeDoctype"
918
- },
919
- {
920
- name: "removeXMLProcInst"
921
- },
922
- {
923
- name: "removeComments"
924
- },
925
- {
926
- name: "removeMetadata"
927
- },
928
- {
929
- name: "removeEditorsNSData"
930
- },
931
- {
932
- name: "cleanupAttrs"
933
- },
934
- {
935
- name: "mergeStyles"
936
- },
937
- {
938
- name: "inlineStyles",
939
- params: { onlyMatchedOnce: false }
940
- },
941
- {
942
- name: "minifyStyles"
943
- },
944
- {
945
- name: "cleanupIDs",
946
- active: false
947
- },
948
- {
949
- name: "removeUselessDefs"
950
- },
951
- {
952
- name: "cleanupNumericValues",
953
- params: { floatPrecision, leadingZero: false }
954
- },
955
- {
956
- name: "convertColors",
957
- params: { shorthex: false, shortname: false }
958
- },
959
- {
960
- name: "removeUnknownsAndDefaults",
961
- params: { unknownContent: false, unknownAttrs: false }
962
- },
963
- {
964
- name: "removeNonInheritableGroupAttrs"
965
- },
966
- {
967
- name: "removeUselessStrokeAndFill"
968
- },
969
- {
970
- name: "removeViewBox",
971
- active: false
972
- },
973
- {
974
- name: "cleanupEnableBackground"
975
- },
976
- {
977
- name: "removeHiddenElems"
978
- },
979
- {
980
- name: "removeEmptyText"
981
- },
982
- {
983
- name: "convertShapeToPath",
984
- params: { convertArcs: true, floatPrecision }
985
- },
986
- {
987
- name: "convertEllipseToCircle"
988
- },
989
- {
990
- name: "moveElemsAttrsToGroup",
991
- active: false
992
- },
993
- {
994
- name: "moveGroupAttrsToElems"
995
- },
996
- {
997
- name: "collapseGroups"
998
- },
999
- {
1000
- name: "convertPathData",
1001
- params: { floatPrecision, transformPrecision: floatPrecision, leadingZero: false, makeArcs: false, noSpaceAfterFlags: false, collapseRepeated: false }
1002
- },
1003
- {
1004
- name: "convertTransform"
1005
- },
1006
- {
1007
- name: "removeEmptyAttrs"
1008
- },
1009
- {
1010
- name: "removeEmptyContainers"
1011
- },
1012
- {
1013
- name: "mergePaths",
1014
- active: false
1015
- },
1016
- {
1017
- name: "removeUnusedNS"
1018
- },
1019
- {
1020
- name: "sortDefsChildren"
1021
- },
1022
- {
1023
- name: "removeTitle"
1024
- },
1025
- {
1026
- name: "removeDesc"
1027
- },
1028
- {
1029
- name: "removeXMLNS",
1030
- active: false
1063
+ name: "preset-default",
1064
+ params: {
1065
+ overrides: {
1066
+ // Disable things that were active: false
1067
+ cleanupIds: false,
1068
+ mergePaths: false,
1069
+ // active: false in legacy
1070
+ // Parameter overrides
1071
+ convertPathData: {
1072
+ floatPrecision,
1073
+ transformPrecision: floatPrecision,
1074
+ leadingZero: false,
1075
+ makeArcs: false,
1076
+ noSpaceAfterFlags: false,
1077
+ collapseRepeated: false
1078
+ },
1079
+ cleanupNumericValues: {
1080
+ floatPrecision,
1081
+ leadingZero: false
1082
+ },
1083
+ convertShapeToPath: {
1084
+ convertArcs: true,
1085
+ floatPrecision
1086
+ }
1087
+ // convertColors: { shorthex: false, shortname: false } // Legacy params
1088
+ }
1089
+ }
1031
1090
  },
1091
+ // Additional plugins explicitly enabled in legacy
1032
1092
  {
1033
1093
  name: "removeRasterImages"
1034
1094
  },
1035
1095
  {
1036
- name: "cleanupListOfValues",
1037
- params: { floatPrecision, leadingZero: false }
1038
- },
1039
- {
1040
- name: "sortAttrs",
1041
- active: false
1042
- },
1043
- {
1044
- name: "convertStyleToAttrs",
1045
- active: false
1046
- },
1047
- {
1048
- name: "prefixIds",
1049
- active: false
1050
- },
1051
- {
1052
- name: "removeDimensions",
1053
- active: false
1054
- },
1055
- {
1056
- name: "removeAttrs",
1057
- active: false
1058
- },
1059
- {
1060
- name: "removeAttributesBySelector",
1061
- active: false
1062
- },
1063
- {
1064
- name: "removeElementsByAttr",
1065
- active: false
1066
- },
1067
- {
1068
- name: "addClassesToSVGElement",
1069
- active: false
1070
- },
1071
- {
1072
- name: "removeStyleElement",
1073
- active: false
1074
- },
1075
- {
1076
- name: "removeScriptElement",
1077
- active: false
1078
- },
1079
- {
1080
- name: "addAttributesToSVGElement",
1081
- active: false
1082
- },
1083
- {
1084
- name: "removeOffCanvasPaths",
1085
- active: false
1086
- },
1087
- {
1088
- name: "reusePaths",
1089
- active: false
1096
+ name: "convertColors",
1097
+ params: {
1098
+ shorthex: false,
1099
+ shortname: false
1100
+ }
1090
1101
  }
1091
1102
  ]
1092
1103
  };
1093
- return svgoConfig;
1094
1104
  };
1095
1105
  }
1096
1106
  });
@@ -1245,42 +1255,21 @@ var require_logcatTool = __commonJS({
1245
1255
  var z = require("zod/v4");
1246
1256
  var execFileAsync = promisify(execFile);
1247
1257
  var logcatToolInstructions2 = [
1248
- "Use read-adb-logcat to tail device logs for a package, pid, or tag; default tail=200 lines.",
1249
- "Use get-pid-by-package to resolve pid quickly via adb shell pidof -s.",
1250
- "Use get-current-activity to inspect current focus (Activity/Window) via dumpsys window.",
1251
- "Use fetch-crash-stacktrace to pull the latest crash buffer (-b crash) optionally filtered by pid.",
1252
- "Use check-anr-state to inspect ActivityManager ANR logs and /data/anr/traces.txt (best-effort).",
1253
- "Use clear-logcat-buffer to reset logcat (-c) before running new scenarios."
1258
+ "Use manage-logcat to read logs, fetch crash stacktraces, check ANR state, or clear logcat buffers.",
1259
+ "Use get-current-activity to inspect current focus (Activity/Window) via dumpsys window."
1254
1260
  ].join("\n");
1255
- var logcatInputSchema = z.object({
1261
+ var manageLogcatSchema = z.object({
1262
+ action: z.enum(["read", "crash", "anr", "clear"]).default("read").describe("Action to perform: read logs, get crash buffer, check ANR, or clear buffer."),
1256
1263
  packageName: z.string().min(1).describe("Android package name; resolves pid via adb shell pidof").optional(),
1257
1264
  pid: z.string().min(1).describe("Explicit process id for logcat --pid").optional(),
1258
1265
  tag: z.string().min(1).describe("Logcat tag to include (uses -s tag)").optional(),
1259
- priority: z.enum(["V", "D", "I", "W", "E", "F", "S"]).default("V").describe("Minimum priority when tag is provided (e.g., D for debug)"),
1260
- maxLines: z.number().int().min(1).max(2e3).default(200).describe("Tail line count via logcat -t"),
1261
- timeoutMs: z.number().int().min(1e3).max(15e3).default(5e3).describe("Timeout per adb call in milliseconds")
1262
- }).refine((data) => data.packageName || data.pid || data.tag, {
1263
- message: "Provide packageName, pid, or tag to avoid unfiltered logs"
1264
- });
1265
- var pidInputSchema = z.object({
1266
- packageName: z.string().min(1).describe("Android package name to resolve pid via adb shell pidof -s"),
1266
+ priority: z.enum(["V", "D", "I", "W", "E", "F", "S"]).default("V").describe("Minimum priority (e.g. D for debug)."),
1267
+ maxLines: z.number().int().min(1).max(2e3).default(200).describe("Tail line count (logcat -t)."),
1267
1268
  timeoutMs: z.number().int().min(1e3).max(15e3).default(5e3).describe("Timeout per adb call in milliseconds")
1268
1269
  });
1269
1270
  var currentActivityInputSchema = z.object({
1270
1271
  timeoutMs: z.number().int().min(1e3).max(15e3).default(5e3).describe("Timeout per adb call in milliseconds")
1271
1272
  });
1272
- var crashStackInputSchema = z.object({
1273
- packageName: z.string().min(1).describe("Optional package to resolve pid; filters crash buffer with --pid").optional(),
1274
- maxLines: z.number().int().min(50).max(2e3).default(400).describe("Tail line count from crash buffer (-b crash -t)"),
1275
- timeoutMs: z.number().int().min(1e3).max(15e3).default(5e3).describe("Timeout per adb call in milliseconds")
1276
- });
1277
- var anrStateInputSchema = z.object({
1278
- maxLines: z.number().int().min(50).max(2e3).default(400).describe("Tail line count from ActivityManager:E"),
1279
- timeoutMs: z.number().int().min(1e3).max(15e3).default(5e3).describe("Timeout per adb call in milliseconds")
1280
- });
1281
- var clearLogcatInputSchema = z.object({
1282
- timeoutMs: z.number().int().min(1e3).max(15e3).default(5e3).describe("Timeout per adb call in milliseconds")
1283
- });
1284
1273
  async function runAdbCommand(args, timeoutMs) {
1285
1274
  try {
1286
1275
  const { stdout } = await execFileAsync("adb", args, {
@@ -1298,164 +1287,315 @@ var require_logcatTool = __commonJS({
1298
1287
  }
1299
1288
  }
1300
1289
  async function resolvePid(packageName, timeoutMs) {
1301
- const output = await runAdbCommand(["shell", "pidof", "-s", packageName], timeoutMs);
1302
- const pid = output.split(/\s+/).find(Boolean);
1303
- if (!pid) {
1304
- throw new Error(`Could not resolve pid for package ${packageName}`);
1305
- }
1306
- return pid;
1307
- }
1308
- function buildLogcatArgs(params, pid) {
1309
- const args = ["logcat", "-d", "-t", String(params.maxLines)];
1310
- if (pid) {
1311
- args.push(`--pid=${pid}`);
1312
- }
1313
- if (params.tag) {
1314
- const filterSpec = `${params.tag}:${params.priority}`;
1315
- args.push("-s", filterSpec);
1290
+ try {
1291
+ const output = await runAdbCommand(["shell", "pidof", "-s", packageName], timeoutMs);
1292
+ const pid = output.split(/\s+/).find(Boolean);
1293
+ return pid || null;
1294
+ } catch (e) {
1295
+ return null;
1316
1296
  }
1317
- return args;
1318
1297
  }
1319
1298
  function registerLogcatTool2(server2) {
1320
1299
  server2.registerTool(
1321
- "read-adb-logcat",
1300
+ "manage-logcat",
1322
1301
  {
1323
- title: "Read adb logcat",
1324
- description: "Dump recent adb logcat output scoped by package, pid, or tag with tail and timeout controls.",
1325
- inputSchema: logcatInputSchema
1302
+ title: "Manage ADB Logcat",
1303
+ description: "Unified tool to read logs, capture crashes, check ANRs, and clear buffers.",
1304
+ inputSchema: manageLogcatSchema
1326
1305
  },
1327
- async (params, extra) => {
1328
- const timeoutMs = params.timeoutMs;
1329
- const pid = params.pid || (params.packageName ? await resolvePid(params.packageName, timeoutMs) : null);
1330
- const args = buildLogcatArgs(params, pid);
1331
- const startTime = process.hrtime.bigint();
1332
- const output = await runAdbCommand(args, timeoutMs);
1333
- const elapsedMs = Number(process.hrtime.bigint() - startTime) / 1e6;
1334
- if (extra && typeof extra.sessionId === "string") {
1335
- server2.sendLoggingMessage(
1336
- {
1337
- level: "info",
1338
- data: `Read logcat (${params.maxLines} lines` + (pid ? `, pid=${pid}` : "") + (params.tag ? `, tag=${params.tag}:${params.priority}` : "") + `) in ${elapsedMs.toFixed(2)}ms`
1339
- },
1340
- extra.sessionId
1341
- ).catch(() => {
1342
- });
1306
+ async (params) => {
1307
+ const { action, timeoutMs } = params;
1308
+ if (action === "clear") {
1309
+ await runAdbCommand(["logcat", "-c"], timeoutMs);
1310
+ return { content: [{ type: "text", text: "Cleared logcat buffers." }] };
1343
1311
  }
1344
- if (!output) {
1345
- return { content: [{ type: "text", text: "Logcat returned no lines." }] };
1312
+ let pid = params.pid;
1313
+ if (!pid && params.packageName) {
1314
+ pid = await resolvePid(params.packageName, timeoutMs);
1346
1315
  }
1347
- return { content: [{ type: "text", text: output }] };
1348
- }
1349
- );
1350
- server2.registerTool(
1351
- "get-pid-by-package",
1352
- {
1353
- title: "Get pid by package",
1354
- description: "Resolve process id for a package via adb shell pidof -s.",
1355
- inputSchema: pidInputSchema
1356
- },
1357
- async (params) => {
1358
- const pid = await resolvePid(params.packageName, params.timeoutMs);
1359
- return { content: [{ type: "text", text: pid }] };
1316
+ if (action === "anr") {
1317
+ const sections = [];
1318
+ try {
1319
+ const logArgs = ["logcat", "-d", "-t", String(params.maxLines), "ActivityManager:E", "*:S"];
1320
+ const amLogs = await runAdbCommand(logArgs, timeoutMs);
1321
+ sections.push("ActivityManager (recent):\n" + (amLogs || "No entries."));
1322
+ } catch (e) {
1323
+ sections.push("ActivityManager error: " + e.message);
1324
+ }
1325
+ try {
1326
+ const tail = await runAdbCommand(["shell", "tail", "-n", "200", "/data/anr/traces.txt"], timeoutMs);
1327
+ sections.push("traces.txt tail (200 lines):\n" + (tail || "Empty."));
1328
+ } catch (e) {
1329
+ sections.push("traces.txt error: " + e.message);
1330
+ }
1331
+ return { content: [{ type: "text", text: sections.join("\n\n") }] };
1332
+ }
1333
+ if (action === "crash") {
1334
+ const args2 = ["logcat", "-b", "crash", "-d", "-t", String(params.maxLines)];
1335
+ if (pid) args2.push(`--pid=${pid}`);
1336
+ const output2 = await runAdbCommand(args2, timeoutMs);
1337
+ return { content: [{ type: "text", text: output2 || "No crash entries found." }] };
1338
+ }
1339
+ const args = ["logcat", "-d", "-t", String(params.maxLines)];
1340
+ if (pid) args.push(`--pid=${pid}`);
1341
+ if (params.tag) {
1342
+ args.push("-s", `${params.tag}:${params.priority}`);
1343
+ }
1344
+ const output = await runAdbCommand(args, timeoutMs);
1345
+ return { content: [{ type: "text", text: output || "Logcat returned no lines." }] };
1360
1346
  }
1361
1347
  );
1362
1348
  server2.registerTool(
1363
1349
  "get-current-activity",
1364
1350
  {
1365
1351
  title: "Get current activity/window focus",
1366
- description: "Inspect current focused app/window via dumpsys window (mCurrentFocus/mFocusedApp). Useful even in single-activity apps to verify top window.",
1352
+ description: "Inspect current focused app/window via dumpsys window.",
1367
1353
  inputSchema: currentActivityInputSchema
1368
1354
  },
1369
1355
  async (params) => {
1370
1356
  const dump = await runAdbCommand(["shell", "dumpsys", "window"], params.timeoutMs);
1371
1357
  const lines = dump.split("\n").filter((line) => line.includes("mCurrentFocus") || line.includes("mFocusedApp"));
1372
1358
  const trimmed = lines.slice(0, 8).join("\n").trim();
1373
- if (!trimmed) {
1374
- return { content: [{ type: "text", text: "No focus info found in dumpsys window." }] };
1375
- }
1376
- return { content: [{ type: "text", text: trimmed }] };
1359
+ return { content: [{ type: "text", text: trimmed || "No focus info found." }] };
1360
+ }
1361
+ );
1362
+ }
1363
+ module2.exports = {
1364
+ registerLogcatTool: registerLogcatTool2,
1365
+ logcatToolInstructions: logcatToolInstructions2
1366
+ };
1367
+ }
1368
+ });
1369
+
1370
+ // src/tools/textLengthTool.js
1371
+ var require_textLengthTool = __commonJS({
1372
+ "src/tools/textLengthTool.js"(exports2, module2) {
1373
+ var z = require("zod/v4");
1374
+ var textLengthToolInstructions2 = [
1375
+ "Use estimate-text-length-difference to compare original vs translated text lengths and flag large deltas.",
1376
+ "Configure tolerancePercent to set the allowed absolute percentage difference (default 30%).",
1377
+ "The tool reports both lengths, percent change, and whether the change exceeds tolerance."
1378
+ ].join("\n");
1379
+ var lengthDiffInputSchema = z.object({
1380
+ sourceText: z.string().min(1).describe("Original text before translation"),
1381
+ translatedText: z.string().min(1).describe("Translated text to compare against the original"),
1382
+ tolerancePercent: z.number().min(1).max(500).default(30).describe("Allowed absolute percent difference between lengths before flagging risk")
1383
+ });
1384
+ function measureLength(text) {
1385
+ return Array.from(text).length;
1386
+ }
1387
+ function registerTextLengthTool2(server2) {
1388
+ server2.registerTool(
1389
+ "estimate-text-length-difference",
1390
+ {
1391
+ title: "Estimate text length difference",
1392
+ description: "Compare original and translated text lengths to detect layout risk; configurable tolerancePercent (default 30%).",
1393
+ inputSchema: lengthDiffInputSchema
1394
+ },
1395
+ async (params) => {
1396
+ const sourceLength = measureLength(params.sourceText);
1397
+ const translatedLength = measureLength(params.translatedText);
1398
+ const delta = translatedLength - sourceLength;
1399
+ const percentChange = sourceLength === 0 ? null : delta / sourceLength * 100;
1400
+ const exceeds = percentChange === null ? translatedLength > 0 : Math.abs(percentChange) > params.tolerancePercent;
1401
+ const direction = delta === 0 ? "no change" : delta > 0 ? "longer" : "shorter";
1402
+ const verdict = percentChange === null && translatedLength === 0 ? "\u2705 Both texts are empty; no length risk." : percentChange === null ? "\u26A0\uFE0F Source length is 0; percent change undefined and translated text is present." : exceeds ? "\u26A0\uFE0F Length difference exceeds tolerance (layout risk likely)." : "\u2705 Length difference within tolerance.";
1403
+ const summary = [
1404
+ verdict,
1405
+ `Source length: ${sourceLength}`,
1406
+ `Translated length: ${translatedLength}`,
1407
+ percentChange === null ? `Change: N/A (source length is 0; direction: ${direction})` : `Change: ${percentChange.toFixed(2)}% (${direction})`,
1408
+ `Tolerance: \xB1${params.tolerancePercent}%`
1409
+ ].join("\n");
1410
+ return { content: [{ type: "text", text: summary }] };
1377
1411
  }
1378
1412
  );
1413
+ }
1414
+ module2.exports = {
1415
+ registerTextLengthTool: registerTextLengthTool2,
1416
+ textLengthToolInstructions: textLengthToolInstructions2
1417
+ };
1418
+ }
1419
+ });
1420
+
1421
+ // src/tools/deviceTool.js
1422
+ var require_deviceTool = __commonJS({
1423
+ "src/tools/deviceTool.js"(exports2, module2) {
1424
+ var { execFile } = require("child_process");
1425
+ var { promisify } = require("util");
1426
+ var fs = require("fs");
1427
+ var path = require("path");
1428
+ var os = require("os");
1429
+ var z = require("zod/v4");
1430
+ var execFileAsync = promisify(execFile);
1431
+ var deviceToolInstructions2 = [
1432
+ "Use dump-ui-hierarchy to capture the current screen structure (XML) via uiautomator.",
1433
+ "Use take-screenshot to capture the device screen to a local file (PNG).",
1434
+ "Use inject-input to send interactions like tap, text, swipe, or key events to the device."
1435
+ ].join("\n");
1436
+ var dumpUiSchema = z.object({
1437
+ timeoutMs: z.number().int().min(1e3).max(2e4).default(1e4).describe("Timeout in milliseconds")
1438
+ });
1439
+ var screenshotSchema = z.object({
1440
+ outputPath: z.string().min(1).describe("Local path to save the screenshot (e.g. screenshot.png)"),
1441
+ timeoutMs: z.number().int().min(1e3).max(2e4).default(1e4).describe("Timeout in milliseconds")
1442
+ });
1443
+ var injectInputSchema = z.object({
1444
+ command: z.enum(["tap", "text", "swipe", "keyevent", "back", "home"]).describe("Input command type"),
1445
+ args: z.array(z.string().or(z.number())).optional().describe('Arguments for the command (e.g. [x, y] for tap, ["text"] for text). Optional if elementId/elementText provided.'),
1446
+ elementId: z.string().optional().describe('Find element by resource-id and tap its center (e.g. "com.example:id/button")'),
1447
+ elementText: z.string().optional().describe('Find element by text content and tap its center (e.g. "Login")'),
1448
+ timeoutMs: z.number().int().min(1e3).max(2e4).default(1e4).describe("Timeout in milliseconds")
1449
+ });
1450
+ function getCenterFromBounds(bounds) {
1451
+ const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
1452
+ if (!match) return null;
1453
+ const x1 = parseInt(match[1], 10);
1454
+ const y1 = parseInt(match[2], 10);
1455
+ const x2 = parseInt(match[3], 10);
1456
+ const y2 = parseInt(match[4], 10);
1457
+ return {
1458
+ x: Math.round((x1 + x2) / 2),
1459
+ y: Math.round((y1 + y2) / 2)
1460
+ };
1461
+ }
1462
+ async function runAdbCommand(args, timeoutMs, options = {}) {
1463
+ try {
1464
+ const { stdout } = await execFileAsync("adb", args, {
1465
+ timeout: timeoutMs,
1466
+ maxBuffer: 10 * 1024 * 1024,
1467
+ ...options
1468
+ });
1469
+ return stdout;
1470
+ } catch (error) {
1471
+ const stderr = error && typeof error.stderr === "string" ? error.stderr.trim() : "";
1472
+ const message = [`adb ${args.join(" ")} failed`, error.message].filter(Boolean).join(": ");
1473
+ if (stderr) {
1474
+ throw new Error(`${message} | stderr: ${stderr}`);
1475
+ }
1476
+ throw new Error(message);
1477
+ }
1478
+ }
1479
+ async function runAdbCommandBinary(args, timeoutMs) {
1480
+ try {
1481
+ const { stdout } = await execFileAsync("adb", args, {
1482
+ timeout: timeoutMs,
1483
+ encoding: "buffer",
1484
+ maxBuffer: 20 * 1024 * 1024
1485
+ });
1486
+ return stdout;
1487
+ } catch (error) {
1488
+ throw new Error(`adb ${args.join(" ")} failed: ${error.message}`);
1489
+ }
1490
+ }
1491
+ function registerDeviceTool2(server2) {
1379
1492
  server2.registerTool(
1380
- "fetch-crash-stacktrace",
1493
+ "dump-ui-hierarchy",
1381
1494
  {
1382
- title: "Fetch crash stacktrace (crash buffer)",
1383
- description: "Pull recent crash buffer (-b crash -d -t) optionally filtered by pid resolved from package.",
1384
- inputSchema: crashStackInputSchema
1495
+ title: "Dump UI Hierarchy (XML)",
1496
+ description: "Capture the current UI hierarchy as XML using uiautomator.",
1497
+ inputSchema: dumpUiSchema
1385
1498
  },
1386
1499
  async (params) => {
1387
- const pid = params.packageName ? await resolvePid(params.packageName, params.timeoutMs) : null;
1388
- const args = ["logcat", "-b", "crash", "-d", "-t", String(params.maxLines)];
1389
- if (pid) {
1390
- args.push(`--pid=${pid}`);
1391
- }
1392
- const output = await runAdbCommand(args, params.timeoutMs);
1393
- if (!output) {
1394
- return { content: [{ type: "text", text: "No crash entries found." }] };
1395
- }
1396
- return { content: [{ type: "text", text: output }] };
1500
+ const devicePath = "/data/local/tmp/mcp_window_dump.xml";
1501
+ await runAdbCommand(["shell", "uiautomator", "dump", devicePath], params.timeoutMs);
1502
+ const content = await runAdbCommand(["shell", "cat", devicePath], params.timeoutMs);
1503
+ return { content: [{ type: "text", text: content.trim() }] };
1397
1504
  }
1398
1505
  );
1399
1506
  server2.registerTool(
1400
- "check-anr-state",
1507
+ "take-screenshot",
1401
1508
  {
1402
- title: "Check ANR state (ActivityManager + traces)",
1403
- description: "Check recent ActivityManager ANR logs and tail /data/anr/traces.txt when accessible (best-effort, may require root/debuggable).",
1404
- inputSchema: anrStateInputSchema
1509
+ title: "Take User Screenshot",
1510
+ description: "Capture device screenshot and save to a local file.",
1511
+ inputSchema: screenshotSchema
1405
1512
  },
1406
1513
  async (params) => {
1407
- const sections = [];
1408
- try {
1409
- const amLogs = await runAdbCommand(
1410
- ["logcat", "-d", "-t", String(params.maxLines), "ActivityManager:E", "*:S"],
1411
- params.timeoutMs
1412
- );
1413
- if (amLogs) {
1414
- sections.push("ActivityManager (recent):\n" + amLogs);
1415
- } else {
1416
- sections.push("ActivityManager (recent): no entries.");
1417
- }
1418
- } catch (error) {
1419
- sections.push(`ActivityManager: ${error.message}`);
1420
- }
1421
- try {
1422
- const stat = await runAdbCommand(["shell", "ls", "-l", "/data/anr/traces.txt"], params.timeoutMs);
1423
- sections.push("traces.txt stat:\n" + stat);
1424
- } catch (error) {
1425
- sections.push(`traces.txt stat: ${error.message}`);
1426
- }
1427
- try {
1428
- const tail = await runAdbCommand(
1429
- ["shell", "tail", "-n", "200", "/data/anr/traces.txt"],
1430
- params.timeoutMs
1431
- );
1432
- if (tail) {
1433
- sections.push("traces.txt tail (200 lines):\n" + tail);
1434
- } else {
1435
- sections.push("traces.txt tail: empty.");
1436
- }
1437
- } catch (error) {
1438
- sections.push(`traces.txt tail: ${error.message}`);
1439
- }
1440
- return { content: [{ type: "text", text: sections.join("\n\n") }] };
1514
+ const buffer = await runAdbCommandBinary(["exec-out", "screencap", "-p"], params.timeoutMs);
1515
+ const absPath = path.resolve(params.outputPath);
1516
+ fs.writeFileSync(absPath, buffer);
1517
+ return { content: [{ type: "text", text: `Screenshot saved to ${absPath}` }] };
1441
1518
  }
1442
1519
  );
1443
1520
  server2.registerTool(
1444
- "clear-logcat-buffer",
1521
+ "inject-input",
1445
1522
  {
1446
- title: "Clear logcat buffer",
1447
- description: "Run adb logcat -c to clear buffers before a new scenario.",
1448
- inputSchema: clearLogcatInputSchema
1523
+ title: "Inject Input Events",
1524
+ description: "Simulate user input interactions (tap, text, swipe, keyevents) or click by UI element.",
1525
+ inputSchema: injectInputSchema
1449
1526
  },
1450
1527
  async (params) => {
1451
- await runAdbCommand(["logcat", "-c"], params.timeoutMs);
1452
- return { content: [{ type: "text", text: "Cleared logcat buffers." }] };
1528
+ let { command, args } = params;
1529
+ const { elementId, elementText, timeoutMs } = params;
1530
+ args = args || [];
1531
+ if (elementId || elementText) {
1532
+ if (command !== "tap") {
1533
+ throw new Error('elementId/elementText can only be used with command="tap".');
1534
+ }
1535
+ const devicePath = "/data/local/tmp/mcp_input_dump.xml";
1536
+ await runAdbCommand(["shell", "uiautomator", "dump", devicePath], timeoutMs);
1537
+ const xmlContent = await runAdbCommand(["shell", "cat", devicePath], timeoutMs);
1538
+ let targetBounds = null;
1539
+ const nodes = xmlContent.split("<node ");
1540
+ for (const nodeStr of nodes) {
1541
+ let matches = false;
1542
+ if (elementId && nodeStr.includes(`resource-id="${elementId}"`)) matches = true;
1543
+ if (elementText && nodeStr.includes(`text="${elementText}"`)) matches = true;
1544
+ if (matches) {
1545
+ const boundsMatch = nodeStr.match(/bounds="(\[\d+,\d+\]\[\d+,\d+\])"/);
1546
+ if (boundsMatch) {
1547
+ targetBounds = boundsMatch[1];
1548
+ break;
1549
+ }
1550
+ }
1551
+ }
1552
+ if (!targetBounds) {
1553
+ throw new Error(`Could not find element with id="${elementId}" or text="${elementText}" in current UI.`);
1554
+ }
1555
+ const center = getCenterFromBounds(targetBounds);
1556
+ if (!center) {
1557
+ throw new Error(`Invalid bounds found: ${targetBounds}`);
1558
+ }
1559
+ args = [String(center.x), String(center.y)];
1560
+ }
1561
+ let adbArgs = ["shell", "input"];
1562
+ switch (command) {
1563
+ case "tap":
1564
+ if (args.length !== 2) throw new Error("tap requires x and y coordinates (or use elementId/elementText)");
1565
+ adbArgs.push("tap", args[0], args[1]);
1566
+ break;
1567
+ case "text":
1568
+ if (args.length !== 1) throw new Error("text requires a single string argument");
1569
+ let safeText = String(args[0]).replace(/\s/g, "%s");
1570
+ adbArgs.push("text", safeText);
1571
+ break;
1572
+ case "swipe":
1573
+ if (args.length < 4) throw new Error("swipe requires at least x1, y1, x2, y2");
1574
+ adbArgs.push("swipe", ...args);
1575
+ break;
1576
+ case "keyevent":
1577
+ case "back":
1578
+ case "home":
1579
+ if (command === "back") {
1580
+ adbArgs.push("keyevent", "4");
1581
+ } else if (command === "home") {
1582
+ adbArgs.push("keyevent", "3");
1583
+ } else {
1584
+ if (args.length < 1) throw new Error("keyevent requires keycode");
1585
+ adbArgs.push("keyevent", ...args);
1586
+ }
1587
+ break;
1588
+ default:
1589
+ throw new Error(`Unknown command: ${command}`);
1590
+ }
1591
+ await runAdbCommand(adbArgs, timeoutMs);
1592
+ return { content: [{ type: "text", text: `Executed input ${command} ${JSON.stringify(args)}` }] };
1453
1593
  }
1454
1594
  );
1455
1595
  }
1456
1596
  module2.exports = {
1457
- registerLogcatTool: registerLogcatTool2,
1458
- logcatToolInstructions: logcatToolInstructions2
1597
+ registerDeviceTool: registerDeviceTool2,
1598
+ deviceToolInstructions: deviceToolInstructions2
1459
1599
  };
1460
1600
  }
1461
1601
  });
@@ -1465,11 +1605,18 @@ var { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
1465
1605
  var { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
1466
1606
  var { registerSvgTool, svgToolInstructions } = require_svgTool();
1467
1607
  var { registerLogcatTool, logcatToolInstructions } = require_logcatTool();
1468
- var serverInstructions = [svgToolInstructions, logcatToolInstructions].join("\n");
1608
+ var { registerTextLengthTool, textLengthToolInstructions } = require_textLengthTool();
1609
+ var { registerDeviceTool, deviceToolInstructions } = require_deviceTool();
1610
+ var serverInstructions = [
1611
+ svgToolInstructions,
1612
+ logcatToolInstructions,
1613
+ textLengthToolInstructions,
1614
+ deviceToolInstructions
1615
+ ].join("\n");
1469
1616
  var server = new McpServer(
1470
1617
  {
1471
- name: "svg-to-android-drawable",
1472
- version: "1.1.0"
1618
+ name: "android-mcp-toolkit",
1619
+ version: "1.3.0"
1473
1620
  },
1474
1621
  {
1475
1622
  capabilities: { logging: {} },
@@ -1478,6 +1625,8 @@ var server = new McpServer(
1478
1625
  );
1479
1626
  registerSvgTool(server);
1480
1627
  registerLogcatTool(server);
1628
+ registerTextLengthTool(server);
1629
+ registerDeviceTool(server);
1481
1630
  async function main() {
1482
1631
  const transport = new StdioServerTransport();
1483
1632
  await server.connect(transport);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "android-mcp-toolkit",
3
- "version": "1.1.0",
4
- "description": "MCP server that converts SVG into Android VectorDrawable XML with a fast path and caching.",
3
+ "version": "1.3.0",
4
+ "description": "MCP server with useful Android development tools: SVG conversion, Logcat management, and Device automation (dump UI, screenshot, input).",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
7
  "start": "node dist/index.js",
@@ -24,11 +24,11 @@
24
24
  "license": "MIT",
25
25
  "type": "commonjs",
26
26
  "dependencies": {
27
- "@modelcontextprotocol/sdk": "^1.24.3",
27
+ "@modelcontextprotocol/sdk": "^1.25.1",
28
28
  "svg-path-bounds": "^1.0.1",
29
- "svgo": "^2.8.0",
29
+ "svgo": "^4.0.0",
30
30
  "svgpath": "^2.5.0",
31
- "zod": "^4.1.13"
31
+ "zod": "^4.2.1"
32
32
  },
33
33
  "devDependencies": {
34
34
  "tsup": "^8.3.0",