appium-mcp 1.86.0 → 1.86.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [1.86.2](https://github.com/appium/appium-mcp/compare/v1.86.1...v1.86.2) (2026-06-20)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **docs:** support global resolution of doc package ([#417](https://github.com/appium/appium-mcp/issues/417)) ([dfeea2f](https://github.com/appium/appium-mcp/commit/dfeea2ffe16389d57b1cec0c95ddfed30c3c6dfa))
6
+
7
+ ## [1.86.1](https://github.com/appium/appium-mcp/compare/v1.86.0...v1.86.1) (2026-06-19)
8
+
9
+ ### Miscellaneous Chores
10
+
11
+ * **deps-dev:** bump @types/node from 25.9.4 to 26.0.0 ([#416](https://github.com/appium/appium-mcp/issues/416)) ([00bcac5](https://github.com/appium/appium-mcp/commit/00bcac51f50025f1bc4fa71d80c6915eb11cdf13))
12
+
1
13
  ## [1.86.0](https://github.com/appium/appium-mcp/compare/v1.85.10...v1.86.0) (2026-06-19)
2
14
 
3
15
  ### Features
@@ -11,8 +11,9 @@
11
11
  * - `APPIUM_MCP_DOCS_ENABLED` unset / not truthy → docs tools are NOT registered.
12
12
  * This is the default; nothing extra is loaded.
13
13
  * - `APPIUM_MCP_DOCS_ENABLED` truthy → the plugin is loaded if
14
- * `@appium/mcp-documentation` is installed. If it is not installed, the server
15
- * logs an actionable install hint and starts normally without the docs tools.
14
+ * `@appium/mcp-documentation` is installed (locally next to appium-mcp OR in
15
+ * the global npm root). If it cannot be found, the server logs an actionable
16
+ * install hint and starts normally without the docs tools.
16
17
  *
17
18
  * Installation is intentionally left to the user's package manager (run at install
18
19
  * time, where it belongs) rather than shelled out from the running server: that
@@ -25,9 +26,8 @@ export declare function isDocumentationEnabled(): boolean;
25
26
  /**
26
27
  * Load the documentation plugin when the user has opted in.
27
28
  *
28
- * @returns the plugin instance, or `null` if the optional package is not
29
- * installed or fails to load (in which case the server runs without the
30
- * documentation tools).
29
+ * @returns the plugin instance, or `null` if the optional package cannot be
30
+ * found or fails to load (in which case the server runs without the docs tools).
31
31
  */
32
32
  export declare function loadDocumentationPlugin(): Promise<AppiumMcpPlugin | null>;
33
33
  //# sourceMappingURL=documentation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"documentation.d.ts","sourceRoot":"","sources":["../src/documentation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAOjD,iEAAiE;AACjE,wBAAgB,sBAAsB,IAAI,OAAO,CAEhD;AAED;;;;;;GAMG;AACH,wBAAsB,uBAAuB,IAAI,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAyB/E"}
1
+ {"version":3,"file":"documentation.d.ts","sourceRoot":"","sources":["../src/documentation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAMH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAWjD,iEAAiE;AACjE,wBAAgB,sBAAsB,IAAI,OAAO,CAEhD;AAED;;;;;GAKG;AACH,wBAAsB,uBAAuB,IAAI,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CA0B/E"}
@@ -11,14 +11,19 @@
11
11
  * - `APPIUM_MCP_DOCS_ENABLED` unset / not truthy → docs tools are NOT registered.
12
12
  * This is the default; nothing extra is loaded.
13
13
  * - `APPIUM_MCP_DOCS_ENABLED` truthy → the plugin is loaded if
14
- * `@appium/mcp-documentation` is installed. If it is not installed, the server
15
- * logs an actionable install hint and starts normally without the docs tools.
14
+ * `@appium/mcp-documentation` is installed (locally next to appium-mcp OR in
15
+ * the global npm root). If it cannot be found, the server logs an actionable
16
+ * install hint and starts normally without the docs tools.
16
17
  *
17
18
  * Installation is intentionally left to the user's package manager (run at install
18
19
  * time, where it belongs) rather than shelled out from the running server: that
19
20
  * dedupes against appium-mcp's existing dependencies, works across npm/pnpm/yarn,
20
21
  * and never blocks server startup.
21
22
  */
23
+ import { readFileSync } from 'node:fs';
24
+ import { createRequire } from 'node:module';
25
+ import path from 'node:path';
26
+ import { pathToFileURL } from 'node:url';
22
27
  import log from './logger.js';
23
28
  import { isTruthyEnvValue } from './utils/env.js';
24
29
  const ENABLED_FLAG = 'APPIUM_MCP_DOCS_ENABLED';
@@ -30,31 +35,95 @@ export function isDocumentationEnabled() {
30
35
  /**
31
36
  * Load the documentation plugin when the user has opted in.
32
37
  *
33
- * @returns the plugin instance, or `null` if the optional package is not
34
- * installed or fails to load (in which case the server runs without the
35
- * documentation tools).
38
+ * @returns the plugin instance, or `null` if the optional package cannot be
39
+ * found or fails to load (in which case the server runs without the docs tools).
36
40
  */
37
41
  export async function loadDocumentationPlugin() {
42
+ let mod;
43
+ try {
44
+ mod = await importDocumentationModule();
45
+ }
46
+ catch (err) {
47
+ // Found, but threw while evaluating (broken/partial install, bad version).
48
+ log.error(`${PACKAGE_NAME} is installed but failed to load:`, err);
49
+ return null;
50
+ }
51
+ if (!mod) {
52
+ log.warn(`${ENABLED_FLAG} is set but ${PACKAGE_NAME} could not be found. ` +
53
+ 'The documentation tools (appium_documentation_query, appium_skills) ' +
54
+ 'will be unavailable. Install it where appium-mcp can resolve it:\n' +
55
+ ' - globally (recommended; works with npx / global / standalone use):\n' +
56
+ ` npm install -g ${PACKAGE_NAME}\n` +
57
+ ' - or, only if appium-mcp is a dependency of your project, in that project root:\n' +
58
+ ` npm install ${PACKAGE_NAME}`);
59
+ return null;
60
+ }
61
+ const plugin = new mod.AppiumDocumentation();
62
+ log.info(`Documentation tools enabled (${PACKAGE_NAME} loaded).`);
63
+ return plugin;
64
+ }
65
+ /**
66
+ * Import the documentation module.
67
+ *
68
+ * @returns the module, or `null` when the package is not installed anywhere
69
+ * resolvable. Throws only when the package WAS found but failed to evaluate.
70
+ *
71
+ * Resolution order:
72
+ * 1. Standard ESM resolution from appium-mcp's location (local / co-located /
73
+ * hoisted installs, including pnpm/yarn).
74
+ * 2. The global npm root — so `npm install -g @appium/mcp-documentation` works
75
+ * even when appium-mcp itself runs from a different location (a local
76
+ * checkout, a global bin, or `npx`), where the global modules are not on
77
+ * the default resolution path.
78
+ */
79
+ async function importDocumentationModule() {
38
80
  try {
39
81
  // Widen to `string` so TypeScript treats this as a fully dynamic import and
40
82
  // does not require @appium/mcp-documentation to be resolvable at build time
41
83
  // (it is an optional, opt-in dependency, not installed by default).
42
84
  const specifier = PACKAGE_NAME;
43
- const mod = (await import(specifier));
44
- const plugin = new mod.AppiumDocumentation();
45
- log.info(`Documentation tools enabled (${PACKAGE_NAME} loaded).`);
46
- return plugin;
85
+ return (await import(specifier));
47
86
  }
48
87
  catch (err) {
49
- if (isModuleNotFound(err)) {
50
- log.warn(`${ENABLED_FLAG} is set but ${PACKAGE_NAME} is not installed. ` +
51
- 'The documentation tools (appium_documentation_query, appium_skills) ' +
52
- 'will be unavailable. Install the package to enable them:\n' +
53
- ` npm install ${PACKAGE_NAME}`);
54
- }
55
- else {
56
- log.error(`${PACKAGE_NAME} is installed but failed to load:`, err);
88
+ if (!isModuleNotFound(err)) {
89
+ throw err;
57
90
  }
91
+ }
92
+ const globalEntry = resolveGlobalEntryUrl();
93
+ if (!globalEntry) {
94
+ return null;
95
+ }
96
+ return (await import(globalEntry));
97
+ }
98
+ /**
99
+ * Locate the package in the global npm root and return a file URL to its entry
100
+ * point, or `null` if it is not installed globally.
101
+ *
102
+ * We resolve the package's `package.json` (always exported) and read its entry
103
+ * rather than resolving the package directly: the package's "." export is
104
+ * ESM-only, so `require.resolve(PACKAGE_NAME)` fails with ERR_PACKAGE_PATH_NOT_EXPORTED.
105
+ */
106
+ function resolveGlobalEntryUrl() {
107
+ try {
108
+ const require = createRequire(import.meta.url);
109
+ const execDir = path.dirname(process.execPath);
110
+ // npm global roots by platform (each entry is a resolution starting point,
111
+ // so `<entry>/node_modules` is checked):
112
+ // - POSIX (nvm, system, Homebrew): <prefix>/lib/node_modules
113
+ // - Windows (node.exe in <prefix>): <prefix>/node_modules
114
+ const paths = [path.join(execDir, '..', 'lib'), execDir];
115
+ const pkgJsonPath = require.resolve(`${PACKAGE_NAME}/package.json`, {
116
+ paths,
117
+ });
118
+ const pkgDir = path.dirname(pkgJsonPath);
119
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
120
+ const entryRelative = pkg.exports?.['.']?.import ??
121
+ pkg.exports?.['.']?.default ??
122
+ pkg.main ??
123
+ 'index.js';
124
+ return pathToFileURL(path.join(pkgDir, entryRelative)).href;
125
+ }
126
+ catch {
58
127
  return null;
59
128
  }
60
129
  }
@@ -1 +1 @@
1
- {"version":3,"file":"documentation.js","sourceRoot":"","sources":["../src/documentation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAGH,OAAO,GAAG,MAAM,aAAa,CAAC;AAC9B,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,MAAM,YAAY,GAAG,yBAAyB,CAAC;AAC/C,MAAM,YAAY,GAAG,2BAA2B,CAAC;AAEjD,iEAAiE;AACjE,MAAM,UAAU,sBAAsB;IACpC,OAAO,gBAAgB,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC;AACrD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB;IAC3C,IAAI,CAAC;QACH,4EAA4E;QAC5E,4EAA4E;QAC5E,oEAAoE;QACpE,MAAM,SAAS,GAAW,YAAY,CAAC;QACvC,MAAM,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,SAAS,CAAC,CAEnC,CAAC;QACF,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,mBAAmB,EAAE,CAAC;QAC7C,GAAG,CAAC,IAAI,CAAC,gCAAgC,YAAY,WAAW,CAAC,CAAC;QAClE,OAAO,MAAM,CAAC;IAChB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,gBAAgB,CAAC,GAAG,CAAC,EAAE,CAAC;YAC1B,GAAG,CAAC,IAAI,CACN,GAAG,YAAY,eAAe,YAAY,qBAAqB;gBAC7D,sEAAsE;gBACtE,4DAA4D;gBAC5D,iBAAiB,YAAY,EAAE,CAClC,CAAC;QACJ,CAAC;aAAM,CAAC;YACN,GAAG,CAAC,KAAK,CAAC,GAAG,YAAY,mCAAmC,EAAE,GAAG,CAAC,CAAC;QACrE,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,SAAS,gBAAgB,CAAC,GAAY;IACpC,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,IAAI,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,EAAE,CAAC;QAChE,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,IAAI,GAAI,GAA0B,CAAC,IAAI,CAAC;IAC9C,OAAO,IAAI,KAAK,sBAAsB,IAAI,IAAI,KAAK,kBAAkB,CAAC;AACxE,CAAC"}
1
+ {"version":3,"file":"documentation.js","sourceRoot":"","sources":["../src/documentation.ts"],"names":[],"mappings":"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"}
@@ -8,10 +8,10 @@ export type ScrollDistancePreset = (typeof SCROLL_DISTANCE_PRESETS)[number];
8
8
  export declare const LOCATOR_STRATEGIES: readonly ["accessibility id", "id", "-ios predicate string", "-ios class chain", "-android uiautomator", "xpath", "name", "class name", "css selector"];
9
9
  export declare const gestureSchema: z.ZodObject<{
10
10
  action: z.ZodEnum<{
11
- scroll: "scroll";
12
11
  tap: "tap";
13
12
  double_tap: "double_tap";
14
13
  long_press: "long_press";
14
+ scroll: "scroll";
15
15
  swipe: "swipe";
16
16
  pinch_zoom: "pinch_zoom";
17
17
  scroll_to_element: "scroll_to_element";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "appium-mcp",
3
3
  "mcpName": "io.github.appium/appium-mcp",
4
- "version": "1.86.0",
4
+ "version": "1.86.2",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -94,7 +94,7 @@
94
94
  "@semantic-release/changelog": "^6.0.3",
95
95
  "@semantic-release/git": "^10.0.1",
96
96
  "@types/jest": "^30.0.0",
97
- "@types/node": "^25.0.9",
97
+ "@types/node": "^26.0.0",
98
98
  "conventional-changelog-conventionalcommits": "^9.1.0",
99
99
  "husky": "^9.1.7",
100
100
  "jest": "^30.2.0",
package/server.json CHANGED
@@ -3,12 +3,12 @@
3
3
  "name": "io.github.appium/appium-mcp",
4
4
  "title": "MCP Appium - Mobile Development and Automation Server",
5
5
  "description": "MCP server for Appium mobile automation on iOS and Android devices with test creation tools.",
6
- "version": "1.86.0",
6
+ "version": "1.86.2",
7
7
  "packages": [
8
8
  {
9
9
  "registryType": "npm",
10
10
  "identifier": "appium-mcp",
11
- "version": "1.86.0",
11
+ "version": "1.86.2",
12
12
  "transport": {
13
13
  "type": "stdio"
14
14
  }
@@ -11,8 +11,9 @@
11
11
  * - `APPIUM_MCP_DOCS_ENABLED` unset / not truthy → docs tools are NOT registered.
12
12
  * This is the default; nothing extra is loaded.
13
13
  * - `APPIUM_MCP_DOCS_ENABLED` truthy → the plugin is loaded if
14
- * `@appium/mcp-documentation` is installed. If it is not installed, the server
15
- * logs an actionable install hint and starts normally without the docs tools.
14
+ * `@appium/mcp-documentation` is installed (locally next to appium-mcp OR in
15
+ * the global npm root). If it cannot be found, the server logs an actionable
16
+ * install hint and starts normally without the docs tools.
16
17
  *
17
18
  * Installation is intentionally left to the user's package manager (run at install
18
19
  * time, where it belongs) rather than shelled out from the running server: that
@@ -20,6 +21,10 @@
20
21
  * and never blocks server startup.
21
22
  */
22
23
 
24
+ import { readFileSync } from 'node:fs';
25
+ import { createRequire } from 'node:module';
26
+ import path from 'node:path';
27
+ import { pathToFileURL } from 'node:url';
23
28
  import type { AppiumMcpPlugin } from './core.js';
24
29
  import log from './logger.js';
25
30
  import { isTruthyEnvValue } from './utils/env.js';
@@ -27,6 +32,10 @@ import { isTruthyEnvValue } from './utils/env.js';
27
32
  const ENABLED_FLAG = 'APPIUM_MCP_DOCS_ENABLED';
28
33
  const PACKAGE_NAME = '@appium/mcp-documentation';
29
34
 
35
+ interface DocumentationModule {
36
+ AppiumDocumentation: new () => AppiumMcpPlugin;
37
+ }
38
+
30
39
  /** True when the user has opted into the documentation tools. */
31
40
  export function isDocumentationEnabled(): boolean {
32
41
  return isTruthyEnvValue(process.env[ENABLED_FLAG]);
@@ -35,33 +44,103 @@ export function isDocumentationEnabled(): boolean {
35
44
  /**
36
45
  * Load the documentation plugin when the user has opted in.
37
46
  *
38
- * @returns the plugin instance, or `null` if the optional package is not
39
- * installed or fails to load (in which case the server runs without the
40
- * documentation tools).
47
+ * @returns the plugin instance, or `null` if the optional package cannot be
48
+ * found or fails to load (in which case the server runs without the docs tools).
41
49
  */
42
50
  export async function loadDocumentationPlugin(): Promise<AppiumMcpPlugin | null> {
51
+ let mod: DocumentationModule | null;
52
+ try {
53
+ mod = await importDocumentationModule();
54
+ } catch (err) {
55
+ // Found, but threw while evaluating (broken/partial install, bad version).
56
+ log.error(`${PACKAGE_NAME} is installed but failed to load:`, err);
57
+ return null;
58
+ }
59
+
60
+ if (!mod) {
61
+ log.warn(
62
+ `${ENABLED_FLAG} is set but ${PACKAGE_NAME} could not be found. ` +
63
+ 'The documentation tools (appium_documentation_query, appium_skills) ' +
64
+ 'will be unavailable. Install it where appium-mcp can resolve it:\n' +
65
+ ' - globally (recommended; works with npx / global / standalone use):\n' +
66
+ ` npm install -g ${PACKAGE_NAME}\n` +
67
+ ' - or, only if appium-mcp is a dependency of your project, in that project root:\n' +
68
+ ` npm install ${PACKAGE_NAME}`
69
+ );
70
+ return null;
71
+ }
72
+
73
+ const plugin = new mod.AppiumDocumentation();
74
+ log.info(`Documentation tools enabled (${PACKAGE_NAME} loaded).`);
75
+ return plugin;
76
+ }
77
+
78
+ /**
79
+ * Import the documentation module.
80
+ *
81
+ * @returns the module, or `null` when the package is not installed anywhere
82
+ * resolvable. Throws only when the package WAS found but failed to evaluate.
83
+ *
84
+ * Resolution order:
85
+ * 1. Standard ESM resolution from appium-mcp's location (local / co-located /
86
+ * hoisted installs, including pnpm/yarn).
87
+ * 2. The global npm root — so `npm install -g @appium/mcp-documentation` works
88
+ * even when appium-mcp itself runs from a different location (a local
89
+ * checkout, a global bin, or `npx`), where the global modules are not on
90
+ * the default resolution path.
91
+ */
92
+ async function importDocumentationModule(): Promise<DocumentationModule | null> {
43
93
  try {
44
94
  // Widen to `string` so TypeScript treats this as a fully dynamic import and
45
95
  // does not require @appium/mcp-documentation to be resolvable at build time
46
96
  // (it is an optional, opt-in dependency, not installed by default).
47
97
  const specifier: string = PACKAGE_NAME;
48
- const mod = (await import(specifier)) as {
49
- AppiumDocumentation: new () => AppiumMcpPlugin;
50
- };
51
- const plugin = new mod.AppiumDocumentation();
52
- log.info(`Documentation tools enabled (${PACKAGE_NAME} loaded).`);
53
- return plugin;
98
+ return (await import(specifier)) as DocumentationModule;
54
99
  } catch (err) {
55
- if (isModuleNotFound(err)) {
56
- log.warn(
57
- `${ENABLED_FLAG} is set but ${PACKAGE_NAME} is not installed. ` +
58
- 'The documentation tools (appium_documentation_query, appium_skills) ' +
59
- 'will be unavailable. Install the package to enable them:\n' +
60
- ` npm install ${PACKAGE_NAME}`
61
- );
62
- } else {
63
- log.error(`${PACKAGE_NAME} is installed but failed to load:`, err);
100
+ if (!isModuleNotFound(err)) {
101
+ throw err;
64
102
  }
103
+ }
104
+
105
+ const globalEntry = resolveGlobalEntryUrl();
106
+ if (!globalEntry) {
107
+ return null;
108
+ }
109
+ return (await import(globalEntry)) as DocumentationModule;
110
+ }
111
+
112
+ /**
113
+ * Locate the package in the global npm root and return a file URL to its entry
114
+ * point, or `null` if it is not installed globally.
115
+ *
116
+ * We resolve the package's `package.json` (always exported) and read its entry
117
+ * rather than resolving the package directly: the package's "." export is
118
+ * ESM-only, so `require.resolve(PACKAGE_NAME)` fails with ERR_PACKAGE_PATH_NOT_EXPORTED.
119
+ */
120
+ function resolveGlobalEntryUrl(): string | null {
121
+ try {
122
+ const require = createRequire(import.meta.url);
123
+ const execDir = path.dirname(process.execPath);
124
+ // npm global roots by platform (each entry is a resolution starting point,
125
+ // so `<entry>/node_modules` is checked):
126
+ // - POSIX (nvm, system, Homebrew): <prefix>/lib/node_modules
127
+ // - Windows (node.exe in <prefix>): <prefix>/node_modules
128
+ const paths = [path.join(execDir, '..', 'lib'), execDir];
129
+ const pkgJsonPath = require.resolve(`${PACKAGE_NAME}/package.json`, {
130
+ paths,
131
+ });
132
+ const pkgDir = path.dirname(pkgJsonPath);
133
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as {
134
+ main?: string;
135
+ exports?: { ['.']?: { import?: string; default?: string } };
136
+ };
137
+ const entryRelative =
138
+ pkg.exports?.['.']?.import ??
139
+ pkg.exports?.['.']?.default ??
140
+ pkg.main ??
141
+ 'index.js';
142
+ return pathToFileURL(path.join(pkgDir, entryRelative)).href;
143
+ } catch {
65
144
  return null;
66
145
  }
67
146
  }