{"title":"Use Appium MCP Without Confusing Exploration and Tests","excerpt":"An agent that walks a mobile flow through Appium MCP has proved that one path existed once, which is not a regression test. This guide lists what appium-mcp 1.97.1 exposes (32 tools, with annotations that are per tool and only hints), builds a least-privilege exploration server with createAppiumMcpServer, a tool allowlist and a beforeCall guard that blocks uninstalls, data clears and model-supplied capabilities, and records every call to a JSONL transcript. It then turns an onboarding exploration into a review record and a WebdriverIO spec with explicit state setup, including why iOS needs launchApp rather than activateApp after clearApp.","canonicalUrl":"https://automationtester.in/blog/ai-in-testing/appium-mcp-exploration-vs-test-suite","category":{"name":"AI in Testing","slug":"ai-in-testing"},"tags":["appium","appium-mcp","mobile-testing","ai-agents","model-context-protocol","webdriverio","test-design"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-19T13:30:00.000Z","dateModified":"2026-10-19T13:30:00.000Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/appium-mcp-exploration-vs-test-suite.webp","alt":"Dark technical illustration. On the left, inside a large dashed green rounded boundary, a green phone panel sends several dashed grey paths meandering through six small grey screen panels scattered across the enclosure. Two dashed orange paths head for the top and bottom edges of the boundary and stop at crossed-out orange rings sitting on the edge. Thin grey lines run from the screens to a dotted vertical column in the middle, where three ringed green dots alternate with two small crossed-out orange rings. The three green dots send curves that converge on a single straight solid green line, which passes through three evenly spaced green screen panels and ends at an arrowhead at the right edge."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Use Appium MCP to explore a flow and to find out which identifiers the app exposes, never as the regression test itself. An agent picks its next tool call from what it sees, so two runs of the same prompt can take different paths, and a successful run proves only that one path existed once. Scope the server before the agent touches a device: hide the tools exploration does not need, block destructive actions per argument in a plugin hook (the protocol's tool annotations are per tool and are only hints), and write every call to a transcript. Then review the transcript, keep the identifiers that are stable, and write the test by hand with explicit state setup, explicit waits and assertions on outcomes. Link the transcript, the review decision and the test in one record.</div>\n\n<p>An agent connected to an Android emulator through Appium MCP walks a new onboarding flow in four minutes: it finds the start button, types an email, accepts a notification prompt and lands on the home screen. Someone asks it to turn the run into a test, commits the result, and the test passes three times in CI. On the fourth run it fails at the email field. The emulator image had been reset, so the app opened on a language picker the agent never saw, and the \"test\" was a replay of the screens one device happened to show on one afternoon.</p>\n\n<p>Nothing in that story is a bug in Appium MCP. The agent did what it was asked. The mistake was treating the record of an exploration as if it were a specification of behaviour. This last part of the series is about keeping the two apart: what the agent is good at, how to stop it doing damage while it works, and how its findings become a test that runs the same way every time.</p>\n\n<div class=\"callout callout-warning\"><strong>Versions checked on 2026-10-14.</strong> <code class=\"language-text\">appium-mcp</code> 1.97.1 (npm <code class=\"language-text\">latest</code>, published 2026-10-12, requires Node.js 22 or newer), which bundles UiAutomator2 driver 8.7.0 and XCUITest driver 12.18.1. Appium 3.8.0, WebdriverIO 10.0.2 (released 2026-10-05) with expect-webdriverio 8.0.0, and the MCP specification revision 2026-07-28 with <code class=\"language-text\">@modelcontextprotocol/sdk</code> 1.32.1. The tool inventory and the guard probe below were run with Node.js 26.0.0 on macOS against the installed package, with no device attached. The WebdriverIO spec was syntax-checked but not run on a device for this article. Appium MCP releases often, so re-run the inventory script when you upgrade.</div>\n\n<h2 id=\"a-successful-agent-run-proves-one-path\">A successful agent run proves one path, once</h2>\n\n<p>Appium MCP is listed on Appium's own tools page as an official tool maintained by the Appium team: a Model Context Protocol server that gives an AI client tools to start sessions, find elements, tap, type, read the page source and generate locators. It does not even need an Appium server for local work. When a session is created for <code class=\"language-text\">android</code> or <code class=\"language-text\">ios</code> without a <code class=\"language-text\">remoteServerUrl</code>, it drives its bundled UiAutomator2 or XCUITest driver directly, so you only need the platform toolchain.</p>\n\n<p>What it cannot give you is repeatability. The model chooses each call from the latest screenshot or page source, the prompt, the tool descriptions and its own weights. Change any of those, including a silent model update on the client side, and the path changes. A test that is allowed to choose its own path is not checking anything you wrote down.</p>\n\n<p>The package is candid about this. Its <code class=\"language-text\">appium_generate_tests</code> tool does not generate code. In 1.97.1 it returns a block of instructions telling the model to execute every step on a live session first and to \"never generate from the scenario alone\", then to write the test file itself. The code you get is whatever the model writes from its own transcript, and its quality depends on the model, not on Appium.</p>\n\n<h2 id=\"what-the-server-exposes-by-default\">What the server exposes by default</h2>\n\n<p>Before deciding what an exploration agent may do, list what it can do. This script connects to the server over stdio with the official MCP SDK and prints every tool with its annotations:</p>\n\n<pre class=\"language-javascript\"><code class=\"language-javascript\">// list-tools.mjs: node list-tools.mjs npx appium-mcp@1.97.1\nimport { Client } from '@modelcontextprotocol/sdk/client/index.js';\nimport { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';\n\nconst [cmd, ...args] = process.argv.slice(2);\nconst transport = new StdioClientTransport({\n  command: cmd,\n  args,\n  env: { ...process.env, NO_UI: 'true' },\n  stderr: 'ignore',\n});\nconst client = new Client({ name: 'tool-audit', version: '1.0.0' });\nawait client.connect(transport);\nconst { tools } = await client.listTools();\nfor (const t of tools) {\n  const a = t.annotations ?? {};\n  console.log(\n    t.name.padEnd(30),\n    `readOnly=${a.readOnlyHint ?? '-'}`,\n    `destructive=${a.destructiveHint ?? '-'}`,\n    `openWorld=${a.openWorldHint ?? '-'}`,\n  );\n}\nconsole.log('total', tools.length);\nawait client.close();</code></pre>\n\n<p>Against 1.97.1 with no optional features enabled it reports 32 tools. An excerpt:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">appium_session_management      readOnly=false destructive=true openWorld=false\nappium_find_element            readOnly=true destructive=- openWorld=false\nappium_get_page_source         readOnly=false destructive=- openWorld=false\nappium_app_lifecycle           readOnly=false destructive=- openWorld=true\nappium_mobile_permissions      readOnly=false destructive=- openWorld=false\nappium_mobile_file             readOnly=false destructive=- openWorld=false\ngenerate_locators              readOnly=true destructive=- openWorld=false\ntotal 32</code></pre>\n\n<p>Two details matter. First, the specification says clients must treat annotations as untrusted unless they come from trusted servers, and the schema calls every annotation a hint. Second, annotations describe a tool, not a call. <code class=\"language-text\">appium_app_lifecycle</code> is one tool with ten actions: <code class=\"language-text\">query_state</code> and <code class=\"language-text\">is_installed</code> sit beside <code class=\"language-text\">uninstall</code>, <code class=\"language-text\">install</code> and <code class=\"language-text\">clear</code>. Its unset <code class=\"language-text\">destructiveHint</code> defaults to <code class=\"language-text\">true</code> under the schema, which is honest but blunt: a client that asks \"allow this tool?\" can only approve or refuse all ten actions together. In a client that remembers approvals per tool, allowing the agent once to check whether the app is running also lets it wipe the app's data.</p>\n\n<h2 id=\"a-least-privilege-exploration-server\">A least-privilege exploration server</h2>\n\n<p>Appium MCP exports a composition API, <code class=\"language-text\">createAppiumMcpServer</code> from <code class=\"language-text\">appium-mcp/core</code>, with two controls you need. A <code class=\"language-text\">policy</code> with <code class=\"language-text\">allowTools</code> hides every tool whose name does not match, at registration time, so the model never sees it. A plugin's <code class=\"language-text\">beforeCall</code> hook runs before each tool and can return a result to short-circuit it, which is where per-argument rules go. The same hook pair is a natural place to write the transcript.</p>\n\n<pre class=\"language-javascript\"><code class=\"language-javascript\">// explore-server.mjs: an Appium MCP server scoped for exploration sessions\nimport { appendFileSync } from 'node:fs';\nimport { createAppiumMcpServer } from 'appium-mcp/core';\n\nconst TRANSCRIPT = process.env.EXPLORE_TRANSCRIPT ?? 'exploration.jsonl';\nconst SAFE_APP_ACTIONS = new Set(['activate', 'terminate', 'query_state', 'is_installed', 'deep_link']);\n\nfunction record(entry) {\n  appendFileSync(TRANSCRIPT, JSON.stringify({ at: new Date().toISOString(), ...entry }) + '\\n');\n}\n\nfunction denyReason(toolName, args) {\n  if (toolName === 'appium_app_lifecycle' &amp;&amp; !SAFE_APP_ACTIONS.has(args.action)) {\n    return `appium_app_lifecycle action=${args.action} needs a human`;\n  }\n  if (toolName === 'appium_session_management' &amp;&amp; args.action === 'create' &amp;&amp; args.capabilities) {\n    return 'capabilities come from CAPABILITIES_CONFIG, not from the model';\n  }\n  return null;\n}\n\nclass ExplorationGuard {\n  name = 'acme-exploration-guard';\n  version = '1.0.0';\n\n  register() {}\n\n  async beforeCall({ toolName, args }) {\n    const reason = denyReason(toolName, args);\n    record({ tool: toolName, args, decision: reason ? 'blocked' : 'allowed' });\n    if (reason) {\n      return { isError: true, content: [{ type: 'text', text: `Blocked: ${reason}. Ask the operator.` }] };\n    }\n  }\n\n  async afterCall({ toolName }, result) {\n    const text = result.content.find((c) =&gt; c.type === 'text')?.text ?? '';\n    const evidence = result.content\n      .filter((c) =&gt; c.type === 'resource' &amp;&amp; c.resource.mimeType === 'application/vnd.appium.evidence+json')\n      .map((c) =&gt; JSON.parse(c.resource.text));\n    record({ tool: toolName, isError: Boolean(result.isError), result: text.slice(0, 300), evidence });\n  }\n}\n\nconst server = await createAppiumMcpServer({\n  plugins: [new ExplorationGuard()],\n  policy: {\n    allowTools: [\n      /^select_device$/,\n      /^appium_session_management$/,\n      /^appium_app_lifecycle$/,\n      /^appium_(find_element|find_elements|get_text|get_element_attribute|get_page_source|screenshot|get_window_size)$/,\n      /^appium_(gesture|set_value|mobile_keyboard|alert)$/,\n      /^generate_locators$/,\n    ],\n  },\n});\n\nawait server.start({ transportType: 'stdio' });</code></pre>\n\n<p>The capabilities rule is there because of how session creation merges its inputs. In 1.97.1, capabilities passed in the tool call are spread over the ones loaded from <code class=\"language-text\">CAPABILITIES_CONFIG</code>, so a model that decides a clean start would help can send <code class=\"language-text\">appium:fullReset</code>, which the UiAutomator2 driver documents as uninstalling the app before the session. Blocking the lifecycle tool's <code class=\"language-text\">uninstall</code> action is pointless if the same effect is one capability away.</p>\n\n<p>A probe client that lists the tools and then tries four calls against this server, still with no device attached, prints:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">15 tools: select_device, appium_session_management, appium_gesture, appium_find_element, appium_find_elements, appium_set_value, appium_mobile_keyboard, appium_get_text, appium_get_element_attribute, appium_get_page_source, appium_alert, appium_screenshot, appium_get_window_size, appium_app_lifecycle, generate_locators\nuninstall -&gt; true Blocked: appium_app_lifecycle action=uninstall needs a human. Ask the operator.\npermissions -&gt; MCP error -32602: MCP error -32602: Unknown tool: appium_mobile_permissions\ncreate -&gt; true Blocked: capabilities come from CAPABILITIES_CONFIG, not from the model. Ask the operator.\nfind -&gt; true No active driver session. Use appium_session_management (action=create or action=attach), or pass a valid sessionId.</code></pre>\n\n<p>Permission changes are not hidden because they are dangerous on their own. They are hidden because an exploration that grants itself the camera has explored a state real users do not start in; the <a href=\"/blog/automation-tutorials/mobile-permission-testing-system-ui\">permissions part</a> of this series seeds those states deliberately instead. One behaviour observed in the probe: when <code class=\"language-text\">beforeCall</code> short-circuits a call, <code class=\"language-text\">afterCall</code> does not run, which is why the guard records the decision before returning.</p>\n\n<p>The client configuration then points at this file instead of <code class=\"language-text\">npx appium-mcp@latest</code>, the line Appium's tools page and the README both show. <code class=\"language-text\">@latest</code> resolves again on every start, which is the same drift the <a href=\"/blog/automation-tutorials/appium-drivers-plugins-test-boundaries\">drivers and plugins part</a> pinned away for the Appium server. Put <code class=\"language-text\">\"appium-mcp\": \"1.97.1\"</code> in the package next to the script, with a lockfile, and set the remaining controls as environment variables:</p>\n\n<pre class=\"language-json\"><code class=\"language-json\">{\n  \"mcpServers\": {\n    \"appium-explore\": {\n      \"type\": \"stdio\",\n      \"command\": \"node\",\n      \"args\": [\"/path/to/repo/tools/appium-explore/explore-server.mjs\"],\n      \"env\": {\n        \"ANDROID_HOME\": \"/path/to/android/sdk\",\n        \"CAPABILITIES_CONFIG\": \"/path/to/repo/tools/appium-explore/capabilities.json\",\n        \"EXPLORE_TRANSCRIPT\": \"/path/to/repo/exploration/onboarding.jsonl\",\n        \"APPIUM_MCP_EVIDENCE\": \"true\",\n        \"ALLOW_REMOTE_APP_URLS\": \"false\",\n        \"REMOTE_SERVER_URL_ALLOW_REGEX\": \"^http://127\\\\.0\\\\.0\\\\.1:4723/?$\"\n      }\n    }\n  }\n}</code></pre>\n\n<p><code class=\"language-text\">APPIUM_MCP_EVIDENCE</code> attaches a structured record (locator, resolved element id, context, timing and a normalized error code such as <code class=\"language-text\">ELEMENT_NOT_FOUND</code> or <code class=\"language-text\">STALE_ELEMENT</code>) to find and gesture responses, and the guard copies it into the transcript. <code class=\"language-text\">ALLOW_REMOTE_APP_URLS=false</code> stops the agent installing a build from an HTTP URL, and the allow regex limits which Appium server it may connect to. The README is explicit that the server is meant to run for a single local user or inside a trusted CI job and must not be exposed to untrusted clients.</p>\n\n<p>The policy cannot fix the device itself. Embedded drivers act with your user's <code class=\"language-text\">adb</code> and Xcode access, so explore on a dedicated emulator or simulator, never on a phone signed in to anyone's real accounts.</p>\n\n<h2 id=\"write-the-stopping-point-into-the-prompt\">Write the stopping point into the prompt</h2>\n\n<p>An unbounded prompt (\"explore the app\") produces a long transcript and no decision. Give the agent one flow, the data it may type, a place to stop and the output you want. For onboarding:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">Goal: walk onboarding on the running Android emulator, from the first\nscreen after a fresh install to the home screen, once with a malformed\nemail (\"ana@example\") and once with the address in ONBOARDING_TEST_EMAIL.\n\nRules:\n- Use appium_find_element with accessibility id or id first. Note every\n  element you could only reach by xpath, text or coordinates.\n- Do not sign in to any other account and do not type anything that is\n  not given above.\n- Stop at the home screen, or after 40 tool calls, whichever comes first.\n\nReport: for each screen, the identifiers you used, the ones missing,\nand anything that appeared on one run but not the other.</code></pre>\n\n<p>The last line is the useful part. You are not asking for a test. You are asking what the app exposes, and where it differs between runs, because those differences are exactly what a deterministic test has to control.</p>\n\n<h2 id=\"from-transcript-to-reviewed-decision\">From transcript to reviewed decision</h2>\n\n<p>Read the JSONL with the report beside it. For each element the agent touched, decide whether the locator can go into a test. The rules are the ones the Appium MCP README gives for <code class=\"language-text\">appium_find_element</code>: accessibility id first, then id, then platform-native queries, and XPath \"last resort\". Coordinates and vision results from the optional <code class=\"language-text\">appium_ai</code> tool never go into a test. That tool is not registered unless <code class=\"language-text\">AI_VISION_ENABLED=true</code> is set, and the README says it is gated so the model does not reach for a slow, paid vision call when a stable locator would do.</p>\n\n<p>Write the outcome down, next to the transcript, in a form a reviewer can disagree with:</p>\n\n<pre class=\"language-json\"><code class=\"language-json\">{\n  \"flow\": \"onboarding-email\",\n  \"transcript\": \"exploration/onboarding.jsonl\",\n  \"server\": \"appium-mcp 1.97.1 via tools/appium-explore\",\n  \"accepted\": {\n    \"onboarding-start\": \"resource-id and accessibilityIdentifier on both platforms\",\n    \"onboarding-email\": \"same on both platforms\",\n    \"home-root\": \"same on both platforms\"\n  },\n  \"rejected\": {\n    \"//*[@text='Continue']\": \"visible text; changes with locale\",\n    \"tap at 540,1680\": \"coordinates from one screen size\"\n  },\n  \"appRequests\": [\"add testTag/accessibilityIdentifier onboarding-email-continue and onboarding-email-error\"],\n  \"notInTest\": [\"language picker appeared on run 2 only: preset locale in capabilities instead\"],\n  \"test\": \"test/specs/onboarding.e2e.js\",\n  \"reviewedBy\": \"maintainer handle\"\n}</code></pre>\n\n<p>The <code class=\"language-text\">appRequests</code> line is often the most valuable output of the whole exercise. An agent that had to fall back to text or coordinates has found a gap in the app's test hooks, and the fix belongs in the app code. If <code class=\"language-text\">id</code> lookups miss on a Compose screen, the <a href=\"/blog/automation-tutorials/debug-appium-selectors-android-ios\">selector debugging part</a> covers how test tags reach the hierarchy.</p>\n\n<h2 id=\"replaying-the-transcript-versus-writing-the-test\">Replaying the transcript versus writing the test</h2>\n\n<p>A test written by copying the agent's calls in order looks like this:</p>\n\n<pre class=\"language-javascript\"><code class=\"language-javascript\">it('onboarding works', async () =&gt; {\n  await $('//*[@text=\"Get started\"]').click();\n  await driver.pause(2000);\n  await $('android=new UiSelector().className(\"android.widget.EditText\")').setValue('qa.agent+0412@example.com');\n  await driver.action('pointer').move({ x: 540, y: 1680 }).down().up().perform();\n  await driver.pause(3000);\n  await expect($('//*[@text=\"Welcome, qa\"]')).toBeDisplayed();\n});</code></pre>\n\n<p>Every line carries something from the afternoon it was recorded. It assumes the device was already past the language picker. It relies on English button text and on a screen where the continue button sat at that exact pixel. The address worked because no account existed for it yet, so the second run hits \"already registered\". The fixed pauses were how long things took that day, and the assertion checks the greeting the agent happened to see rather than the thing onboarding is for. The malformed-email case, which the agent did explore, is missing entirely.</p>\n\n<p>The authored version owns its starting state and asserts outcomes through identifiers the review accepted:</p>\n\n<pre class=\"language-javascript\"><code class=\"language-javascript\">import { expect } from '@wdio/globals';\n\nconst APP_ID = 'com.example.onboarding';\nconst byTestId = (id) =&gt; (driver.isAndroid ? `id=${id}` : `~${id}`);\n\nasync function startFromFreshInstallState() {\n  if (driver.isAndroid) {\n    // pm clear expects the app to be stopped first\n    await driver.execute('mobile: terminateApp', { appId: APP_ID });\n    await driver.execute('mobile: clearApp', { appId: APP_ID });\n    await driver.execute('mobile: activateApp', { appId: APP_ID });\n  } else {\n    // simulator only; clearApp terminates the app, and activateApp would throw on a stopped app\n    await driver.execute('mobile: clearApp', { bundleId: APP_ID });\n    await driver.execute('mobile: launchApp', { bundleId: APP_ID });\n  }\n}\n\nasync function submitEmail(address) {\n  await $(byTestId('onboarding-start')).click();\n  const email = $(byTestId('onboarding-email'));\n  await email.waitForDisplayed();\n  await email.setValue(address);\n  await $(byTestId('onboarding-email-continue')).click();\n}\n\ndescribe('onboarding email step', () =&gt; {\n  beforeEach(startFromFreshInstallState);\n\n  it('keeps the user on the email step when the address is malformed', async () =&gt; {\n    await submitEmail('ana@example');\n\n    await expect($(byTestId('onboarding-email-error'))).toBeDisplayed();\n    await expect($(byTestId('onboarding-email'))).toBeDisplayed();\n    await expect($(byTestId('home-root'))).not.toBeExisting();\n  });\n\n  it('reaches home with a valid address', async () =&gt; {\n    await submitEmail(process.env.ONBOARDING_TEST_EMAIL);\n\n    await expect($(byTestId('home-root'))).toBeDisplayed();\n  });\n});</code></pre>\n\n<p>The platform branches are not decoration. The UiAutomator2 driver says <code class=\"language-text\">mobile: clearApp</code> runs <code class=\"language-text\">pm clear</code> and that the app \"should not be running\", hence the terminate first. The XCUITest driver's <code class=\"language-text\">mobile: clearApp</code> terminates the app itself, works only on simulators and throws on real devices, where Apple's only fully supported reset is uninstall and reinstall. Its <code class=\"language-text\">mobile: activateApp</code> throws if the app is not running, so the iOS branch uses <code class=\"language-text\">mobile: launchApp</code>. An agent replaying its own calls would never discover that, because it never started from a stopped app.</p>\n\n<p>The valid-address case reads its address from the environment because a reused address fails as \"already registered\" on the second run. Give each run a fresh account from your backend's test API, or reset the account between runs. Either way that is setup code you own, not something the agent improvises.</p>\n\n<h3 id=\"which-assertions-stay-on-the-device\">Which assertions stay on the device</h3>\n\n<p>The error test checks that an error is shown and that the user cannot move on. It does not check the error's wording or the rules for a valid address. Those belong in a unit test on the validator, where a table of fifty addresses runs in milliseconds; Android's testing guidance puts unit tests on the host and keeps the slow application-level tests for behaviour that needs the whole binary. The <a href=\"/blog/automation-tutorials/mobile-test-strategy-fidelity-cost\">first part</a> of this series makes that placement argument in full. The agent found the error state. Deciding which layer proves it is the maintainer's job.</p>\n\n<h2 id=\"price-of-keeping-exploration-separate\">The price of keeping exploration separate</h2>\n\n<p>You now maintain three things where the demo promised one: the guarded server, the review records and the suite. Review takes a person's time on every flow, and a transcript from a model you no longer use is only useful for the decisions it led to, so keep the review record and treat the JSONL as disposable. Exploration also costs tokens, and full page-source responses are large; the README's own measurement for a synthetic 95,000-character page source is a result of about 95 KB even in its leaner viewer mode.</p>\n\n<p>Skip the agent for screens that already have identifiers and a page object. Exploring them again tells you nothing a page-source dump would not. The agent earns its cost on a new or redesigned flow, on a screen nobody on the team has automated, or when you need a quick inventory of missing test hooks before a release.</p>\n\n<h2 id=\"explore-one-flow-under-guard-this-week\">Explore one flow under guard this week</h2>\n\n<p>Pick the newest flow in your app that has no automated test. Install <code class=\"language-text\">appium-mcp@1.97.1</code> into a small tools package, run the inventory script against it and save the output, so the next upgrade shows you which tools appeared. Start the guarded server against a dedicated emulator, give the agent a bounded prompt with a stop condition, and let it run. Then write the review record and one authored test from it, and open both in the same pull request so the reviewer sees the transcript, the decisions and the test together.</p>\n\n<p>The evidence to capture is that pull request: a JSONL transcript with every blocked call visible, a review record that names each accepted and rejected locator and each request to the app team, and a spec that passes twice in a row on a freshly reset emulator.</p>\n\n<h2 id=\"appium-mcp-questions\">Questions about Appium MCP and test suites</h2>\n\n<h3 id=\"can-appium-mcp-run-as-the-ci-gate\">Can the agent itself run in CI as the regression check?</h3>\n\n<p>It can run there; the README describes a trusted CI job as a supported setting and offers <code class=\"language-text\">NO_UI</code> for headless use. As a gate, it cannot be trusted. A pass means the model found some route to the goal on that run, possibly by a route your users cannot take, and a failure might be the model, not the app. Use agent runs in CI for triage or for inventorying a new build, and keep the merge decision on the authored suite.</p>\n\n<h3 id=\"does-appium-mcp-need-an-appium-server\">Does Appium MCP need a running Appium server?</h3>\n\n<p>Not for local Android and iOS sessions. Without <code class=\"language-text\">remoteServerUrl</code>, it uses its bundled UiAutomator2 and XCUITest drivers, and you only need <code class=\"language-text\">ANDROID_HOME</code>, the Android SDK tools or Xcode. With <code class=\"language-text\">remoteServerUrl</code>, the remote server's installed drivers and plugins apply, which is the mode to use for a device farm.</p>\n\n<h3 id=\"should-the-exploration-server-allow-the-vision-tool\">Should the exploration server enable the vision tool?</h3>\n\n<p>Only to answer \"is there any way to reach this element at all\", and only on a flow you are about to give proper identifiers. <code class=\"language-text\">appium_ai</code> returns coordinates encoded in an id, not a WebDriver element, and it sends screenshots to whichever vision API you configure. Neither belongs in a test, and the screenshots may contain data you would not send to a third party.</p>\n\n<h2 id=\"appium-mcp-primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://appium.io/docs/en/latest/ecosystem/tools/\">Appium ecosystem tools page</a>: Appium MCP listed under tools maintained by the Appium team, with the <code class=\"language-text\">npx appium-mcp@latest</code> client configuration.</li>\n<li><a href=\"https://github.com/appium/appium-mcp/blob/v1.97.1/README.md\">appium-mcp README at v1.97.1</a>: embedded versus remote driver modes, the environment variables (<code class=\"language-text\">APPIUM_MCP_EVIDENCE</code>, <code class=\"language-text\">ALLOW_REMOTE_APP_URLS</code>, <code class=\"language-text\">REMOTE_SERVER_URL_ALLOW_REGEX</code>, <code class=\"language-text\">AI_VISION_ENABLED</code>, <code class=\"language-text\">NO_UI</code>), the trust model, <code class=\"language-text\">createAppiumMcpServer</code> with <code class=\"language-text\">policy.allowTools</code>, plugin hooks, the locator priority and the page-source size measurement.</li>\n<li><a href=\"https://github.com/appium/appium-mcp/blob/v1.97.1/src/tools/test-generation/generate-tests.ts\">appium-mcp source: generate-tests.ts at v1.97.1</a>: that <code class=\"language-text\">appium_generate_tests</code> returns instructions for the model rather than generated code.</li>\n<li><a href=\"https://github.com/appium/appium-mcp/blob/v1.97.1/src/tools/app-management/app.ts\">appium-mcp source: app.ts at v1.97.1</a>: the ten actions of <code class=\"language-text\">appium_app_lifecycle</code> under one tool and its annotations.</li>\n<li><a href=\"https://github.com/appium/appium-mcp/blob/v1.97.1/src/tools/session/create-session.ts\">appium-mcp source: create-session.ts at v1.97.1</a>: capabilities from the tool call spread over those loaded from <code class=\"language-text\">CAPABILITIES_CONFIG</code>.</li>\n<li><a href=\"https://github.com/appium/appium-mcp/blob/v1.97.1/src/tools/evidence.ts\">appium-mcp source: evidence.ts at v1.97.1</a>: the evidence record fields and the normalized error codes.</li>\n<li><a href=\"https://www.npmjs.com/package/appium-mcp\">npm registry entry for appium-mcp</a>: version 1.97.1 as <code class=\"language-text\">latest</code> and the Node.js 22 engine requirement.</li>\n<li><a href=\"https://modelcontextprotocol.io/specification/2026-07-28/server/tools\">MCP specification 2026-07-28, Tools</a>: annotations must be treated as untrusted unless from trusted servers, and a human should be able to deny tool invocations.</li>\n<li><a href=\"https://modelcontextprotocol.io/specification/2026-07-28/schema\">MCP specification 2026-07-28, Schema reference</a>: <code class=\"language-text\">ToolAnnotations</code> are hints, with <code class=\"language-text\">readOnlyHint</code> defaulting to false and <code class=\"language-text\">destructiveHint</code> to true.</li>\n<li><a href=\"https://github.com/appium/appium-uiautomator2-driver/blob/v8.7.0/README.md\">The v8.7.0 UiAutomator2 driver README</a>: <code class=\"language-text\">mobile: clearApp</code> calling <code class=\"language-text\">pm clear</code> on a stopped app, <code class=\"language-text\">mobile: activateApp</code>, and <code class=\"language-text\">appium:fullReset</code> uninstalling before a session.</li>\n<li><a href=\"https://appium.github.io/appium-xcuitest-driver/latest/reference/execute-methods/\">XCUITest driver execute methods</a>: <code class=\"language-text\">mobile: clearApp</code> being simulator-only, <code class=\"language-text\">mobile: activateApp</code> throwing on an app that is not running, and <code class=\"language-text\">mobile: launchApp</code>.</li>\n<li><a href=\"https://webdriver.io/docs/selectors/\">WebdriverIO selector guide</a>: the <code class=\"language-text\">~</code> accessibility id selector and its platform mapping.</li>\n<li><a href=\"https://github.com/webdriverio/webdriverio/releases/tag/v10.0.0\">WebdriverIO v10.0.0 release notes</a>: the 2026-10-05 major release and its breaking changes, including that WebdriverIO 10 requires Appium 3.</li>\n<li><a href=\"https://developer.android.com/training/testing/fundamentals/strategies\">Android Developers: testing strategies</a>: unit tests on the host versus application tests on a debuggable binary.</li>\n</ul>\n"}