android-jev MCP server
2026년 11월 14일 커밋AI 에이전트가 화면을 읽고 자연어 작업을 실행하여 ADB를 통해 Android 폰을 제어할 수 있게 합니다.
다른 이름 FZ2000/android-jev
- 스타
- 2
- Tools
- —
- 다운로드, 30일
- —
- 최신 버전
- —
- 마지막 커밋
- 2026년 11월 14일
- 라이선스
- MIT
android-jev MCP란?
android-jev은(는) MCP server입니다. AI 에이전트가 화면을 읽고 자연어 작업을 실행하여 ADB를 통해 Android 폰을 제어할 수 있게 합니다.
프로젝트 README에서 생성된 요약
android-jev MCP server 정보
개발자는 AI 에이전트를 이 MCP server에 연결하여 자연어 명령으로 Android 폰을 제어할 수 있습니다. run_task 도구를 사용하면 에이전트가 Play Store 열기, 비행기 모드 켜기, YouTube 검색 등의 작업을 수행할 수 있습니다. 이 서버는 ADB를 통해 작동하므로 모바일 기기에 아무것도 설치할 필요가 없지만, 디버깅이 활성화된 상태로 USB를 통해 폰이 연결되어 있어야 합니다.
이 시스템은 폰 화면을 읽고 의사 결정 모델을 사용하여 다음 단계를 결정하는 방식으로 작동합니다. 환경 변수나 secrets 디렉토리에 제공된 API 키를 통해 Jev 또는 OpenRouter를 지원합니다. 다양한 환경에서 작동하지만, Android 17이 실행되는 Pixel 8a에서 구체적으로 검증되었습니다. macOS에서는 이미지 처리를 위해 macOS Vision을 활용하며, Linux에서는 인터페이스 이해를 위해 accessibility tree에 의존합니다. 단순한 좌표 기반 래퍼와 달리, 이 서버는 에이전트가 보고 있는 맥락을 이해할 수 있도록 레이블과 카테고리를 포함한 UI 요소의 구조화된 설명을 제공합니다.
활용 사례
- 1UI 요소 존재 여부 확인이 서버를 사용하면 에이전트가 단순 좌표가 아닌 레이블을 통해 특정 버튼이나 텍스트 필드를 식별할 수 있습니다.
프로젝트의 README를 바탕으로 작성되었습니다. 모든 활용 사례는 해당 내용의 인용문과 대조하여 확인합니다.
구성
PHONE_CONTROL_RUNS
| 이름 | 설명 | 유형 |
|---|---|---|
| PHONE_CONTROL_RUNS | — |
자동 검사
2026년 11월 14일에 실행됨전체 보고서 →아직 인증되지 않았습니다. 서버는 80점 이상의 점수, 심각한 결함 없음, 그리고 검사에 충분한 데이터를 갖추어야 합니다. 검사 방식 · 점수를 높이기 위해 수정할 항목
- 소스 저장소 연결 및 접근 가능fz2000/android-jev is public and reachablegithub.com/fz2000/android-jev ↗통과
- 패키지가 이 저장소를 소스로 명시검사되지 않음
- 빌드 출처(Sigstore 증명)검사되지 않음
- 오픈 소스 라이선스 (OSI)MITspdx.org/licenses/MIT.html ↗통과
- 최근 1년 내 커밋 또는 릴리스Last commit or release 2026-11-14github.com/fz2000/android-jev ↗통과
- OpenSSF Scorecard검사되지 않음
- 패키지 이름이 인기 패키지와 유사하지 않음검사되지 않음
- 패키지 및 해당 종속성에 중요한 보안 권고 없음검사되지 않음
- 모델에 대한 숨겨진 지침 없음0 tool descriptions and the README read; no override phrases, credential paths or hidden characters통과
- 파괴적인 도구에 라벨 표시됨검사되지 않음
- 원격 엔드포인트가 MCP 핸드셰이크에 응답검사되지 않음
README
GitHub에서 보기android-jev
Control your Android phone through Jev.
You say what you want done — open the Play Store, turn on aeroplane mode, take a
photo — and this does it. It plugs into any MCP client as a tool called run_task;
inside, it reads the phone's own screen, asks
Jev what to do next,
does it, checks the result, and tells you honestly whether it worked.
Everything runs over adb. Nothing is installed on the phone.
run_task("open the Play Store") → achieved: true done, 3 steps
run_task("search for YouTube") → achieved: true done, 5 steps
run_task("open YouTube") → achieved: true done, 3 steps
Quick start
What you need
- A phone attached by USB, with a data-capable cable. It stays plugged in while it works.
- USB debugging on — one setting, below.
- macOS with Python 3.11+, and
uv. The picture route uses macOS Vision, which installs on macOS as a dependency; on Linux the loop works and reads the accessibility tree, and leaves the fallback out. - Verified on a Pixel 8a running Android 17. The project has only been measured on that one, so treat other phones as untested rather than unsupported.
1. Connect the phone
Plug it in, and on the phone:
- Settings → About phone → tap "Build number" seven times. Developer options appear.
- Settings → System → Developer options → USB debugging → on.
- Replug the cable. The phone shows Allow USB debugging? — tick Always allow and tap Allow. Nothing works until that tap happens, and it is deliberate: a computer cannot authorise itself.
Check the connection:
adb devices # the phone should say "device", not "unauthorized"
2. Install the server
git clone https://github.com/FZ2000/android-jev ~/src/android-jev
cd ~/src/android-jev
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python -e .
The distribution is android-jev, the import is phone_control and the command is
phone-control: three names, each read somewhere different. The command also answers
to android-jev, so that uvx android-jev — the spelling a registry client derives
from the distribution name — starts the server.
No adb? PHONE_CONTROL_ADB points at one, and the usual SDK locations are
searched:
curl -sSL -o /tmp/pt.zip \
https://dl.google.com/android/repository/platform-tools-latest-darwin.zip
unzip -q /tmp/pt.zip -d ~/.local/opt
3. Give it a decision model
run_task works without one, using a keyword fallback, and is markedly better with
Jev: goals that do not name a control — turn off Bluetooth, open the settings
app — need it.
Either of two keys works, and the key's own shape says which gateway serves it: an
OpenRouter key beginning sk-or-, or a TypeSafe key beginning apikey_. Nothing in
the configuration says which you are using, because sending one gateway's model to the
other is a 400.
The server reads it from JEV_API_KEY — or OPENROUTER_API_KEY, if that is the name
already in use here — or from a file named jev-api-key or openrouter-api-key in the
secrets directory of whichever harness starts it. In DeepSeek Harness that is
$DSH_HOME/secrets/:
printf %s "$JEV_API_KEY" > "$DSH_HOME/secrets/jev-api-key"
chmod 600 "$DSH_HOME/secrets/jev-api-key"
Whichever way it reaches the server, the key never appears in a state, a log, a run folder or a transcript.
4. Mount it in your agent
In Claude Code, this repository is also a plugin, which mounts the server and installs the skill in one step — steps 4 and 5 both:
claude plugin marketplace add FZ2000/android-jev
claude plugin install android-jev@android-jev
It asks for the Jev key once and keeps it in the system's credential store; leave it
empty to use JEV_API_KEY or OPENROUTER_API_KEY from your environment instead. The
server runs from the plugin's own copy of this repository through uv, so what it
does always matches the skill that describes it. It still needs uv and adb, and
the phone set up as in step 1.
For any other client, or to run from your own clone:
Any MCP client can mount this server; it speaks MCP over stdio. Send the agent this, with the path changed to wherever you cloned the repository — it says what the repository is and where its own documentation lives, because an agent that has to guess either of those will guess wrong. A prompt rather than a snippet per client: each client keeps its MCP configuration somewhere different, and changes the format between versions.
The repository at
~/src/android-jevis an MCP server that drives an Android phone attached by USB, together with the instructions for using it. Mount it for me, and tell me when you have.
- Start it as a local stdio server: the absolute path
~/src/android-jev/.venv/bin/python, the single argument-m phone_control, run from that directory.- Call it
android.- It needs the Jev key in its environment, under
JEV_API_KEY— orOPENROUTER_API_KEYif that is the name already in use here. Either an OpenRoutersk-or-…key or a TypeSafeapikey_…one: the shape of the key decides which gateway serves it. If your MCP bridge scrubsKEY,PASSWORD,SECRETorTOKENout of a child process's environment, name the variable in the server's ownenvrather than expecting it to be inherited.- Give it a generous per-call timeout, a couple of minutes: one of its tools waits for the phone by design.
- Read
README.mdat the repository root first. Its quick start covers connecting the phone, enabling USB debugging, installing the server and supplying the key;docs/has the longer explanations. If the phone is not attached or the server will not start, read that rather than guessing.- The instructions for using it are
skills/android-phone-control/SKILL.md, with every tool and argument inreferences/tools-reference.mdbeside it.When it is mounted, list its tools and tell me whether I need to start a new session before you can see them.
Three things catch people out, and the prompt is written the way it is to survive all three:
- The interpreter path has to be absolute. A client starts the server itself and
does not inherit this project's virtualenv, so a bare
python -m phone_controlresolves to the wrong interpreter or to none at all. - The key cannot always be inherited. DeepSeek Harness, for one, strips every
variable whose name matches
/KEY|PASSWORD|SECRET|TOKEN/ifrom a stdio child, so the key has to be named in the server's ownenvor it arrives empty. - A mounted server is not a visible one. Most clients need a new session, or a restart, before a model sees the tools at all — which is why the prompt asks.
The tools surface under whatever namespace your client uses — mcp__android__run_task
in DeepSeek Harness, something else elsewhere. Nothing in these instructions depends on
the exact spelling, and neither should you.
5. Teach your agent how to use it
The skill is one body of text, skills/android-phone-control/SKILL.md, and this
repository already ships it rendered for every harness that reads instructions from the
working directory — which file each one reads is in
The skill, for any agent below. If you work in this
repository, your agent has it already and there is nothing to install.
For any other harness, send it this:
Install the phone-control skill in
~/src/android-jevfor yourself, so that you can drive my phone in any session. The instructions areskills/android-phone-control/SKILL.md, with one reference file beside it atreferences/tools-reference.md. Put both wherever you read skills from, and tell me whether you need to be restarted before it takes effect.
If your harness has nowhere to keep a skill — a hosted assistant with an instructions box and nothing else — paste this instead, which is the part that matters:
The phone is driven by an MCP server mounted as
android. To do anything on it, callrun_taskwith the outcome you want in plain language — "open the Play Store", "turn on aeroplane mode". Ask for one thing at a time and wait for each result. Readachievedandreasonfrom the reply, and relay them rather than retrying.
6. Sanity test: take a photograph
With the phone unlocked and the screen on, ask your agent:
run_task("open the camera app and take a photo")
Then check the phone's own evidence rather than the report:
adb shell ls -t /sdcard/DCIM/Camera | head -3 # a new file at the top
A new file and a report that it could not confirm the goal are both correct here, and this is the most useful thing the quick start can show you. A photograph leaves no trace on the screen — measured, the tree is byte-identical before and after the shutter — so the run can see that the camera opened and cannot see the picture. It says so instead of claiming a success it did not verify.
Run against the phone this was written on, through a fresh clone of this repository, that is what it looks like: five steps, three of them pressing the shutter, and then
"achieved": false, "outcome": "stalled",
"reason": "3 actions in a row left the screen exactly as it was, so the run was
going round rather than on"
and three new files in DCIM/Camera. It took the photograph and could not know it
had, so it stopped rather than pressing the shutter forever. That is the honest
answer to a goal whose result is off-screen, and it is why the last line of this
test is one whose result is on the screen:
run_task("open the calculator app") # achieved: true, done, 3 steps, ~13s
Where to go next
docs/engineering-decisions.md— why the code is the way it is, with the measurement that forced each decision.docs/code-map.md— every module, what it is for, and how they depend on each other.docs/android-apis.md— what this phone will and will not tell you, measured, including the reliable ways to prove an end state.docs/debugging.md— one run failing, and what to read.docs/tool-contract.md— the tool surface as a contract, including what is deliberately not built.docs/what-we-got-wrong.md— what was tried and did not work.CONTRIBUTING.md— the rules the tests live by.
Why it is built this way
Most adb wrappers expose tap(x, y), which is useless to an agent that cannot
see. This one exposes what is actually on screen:
#5 ImageButton labeled "Send message" at (970,2170) [tappable]
#4 EditText labeled "Type a message" at (450,2170) [text field, focused]
Four decisions follow from that, and each cost something to learn.
The reading is structured, not a screenshot. read_screen reports every
window with its type, whether the keyboard is up, and a typed description of every
control — its category, label, bounds and a point to tap. An image is the fallback
for genuinely visual screens, not the default. See
docs/android-apis.md for what is extractable and what is
not.
It reports what has focus, not what is merely running. When a system window
such as the lock screen or the notification shade is in front, status says so
and reports no foreground app — rather than naming the app behind it, which is what
mFocusedApp would have said.
A control is addressed by name, then by number, and only then by coordinate. Every action tool re-reads the screen before it acts, so a name or a number is resolved against the screen as it is now; a coordinate is not. Read the reply — it names exactly what was acted on.
The agent states the goal; the server owns the loop. run_task("open the email app") is one call. The server reads its own screen, decides each step,
checks its own work, and repeats until the goal is reached or cannot be — then
reports what it did and why it stopped. An agent needs to know nothing about
accessibility trees, windows or coordinates to use it.
Each step is several typed questions, asked together. Inside that loop, Jev, TypeSafe's System One decision model, answers four questions in one request and only the answer belonging to the chosen operation is used:
| question | what it settles |
|---|---|
operation |
which kind of action, from what this screen can actually do |
target |
which control, if the operation taps one |
app |
which installed app, if the operation opens one |
completion |
whether a fresh reading shows the goal met |
Splitting them is the part that mattered. One flat list holding operations, app
names and screen controls made the decider unsure on a launcher screen — it
answered give_up at 0.63 — and the reason is mechanical: confidence measures
how concentrated a choice is, so overlapping options always read as doubt,
whichever of them is right. Once the operations had a question of their own,
open_app won the same screen at 0.74.
Only what can be carried out is offered. Typing needs a focused field, scrolling needs something that scrolls, a website needs a value the device can resolve, a tap needs a control to tap. An option the loop cannot execute is a guaranteed dead step and worse than that — it reads as the model being unsure when the model was never at fault. A web address with no scheme was offered once; Android could not resolve it, the action did nothing, and the 0.41 confidence on that step was read as doubt for days.
The state is facts computed from the phone, not a dump to interpret. What the focused field holds, where each control sits in coarse terms, what tells two identical labels apart, what has already been tried on this screen. This is what makes a threshold unnecessary rather than merely wrong: asked whether a goal was complete, Jev scored 0.42 to 0.47 on a screen where it genuinely was and 0.51 on one where it was not, because in neither case did the state say whether the query had been sent or was still in the box. No number separates those two; a state that says what is on the screen does.
A low number is not a reason to stop, and the loop does not decide. Every rule that used to refuse an answer for scoring low is gone, because each one was measured ending a run that was working. What is left is a stop rule about the phone — three actions in a row that left the screen as it was, or two already taken on the same screen, means the run is going round rather than on — and both err toward running on, because a futile run costs a minute and a working run cut short costs the task.
One exception, and it is about safety rather than sureness. An action that would have a material effect — sending, deleting, paying, granting a permission — is not carried out unless the goal explicitly asks for it. That gate exists because a real run tapped Allow Chrome to record audio while pursuing an unrelated goal.
When the loop stops short, a reader takes over. With no reader configured a stop ends the run as it always did. With one, it reads the screen and says whether the goal is met, and may set one next move for the decider to aim at — never choosing the action itself. The reference implementation measured 0.39 becoming 0.92 on the same capture once a focus was set, which is the argument in one number: the fix belongs in what the decider is told, not in a threshold.
The phone is read once per step. Reading it was 84% of a step, half of that a second read whose only job was to see what the action had done. The post-action observation is now the next step's reading:
before step 7.29s reading 2.91s watching_the_result 3.18s deciding 0.26s
after step 3.75s reading 2.18s watching_the_result 0.00s deciding 0.24s
What is sent is the source, not a summary of it. Jev's state carries the
accessibility tree's own field names and values, and each option is a word
(open_app) rather than our numbering. The
measured prompting evidence
is blunt: the same programs scored 0.894 as source, 0.598 as a syntax tree
and 0.530 as a control-flow graph, even though the graph carried more
information for 2.5× the tokens.
Running it directly
.venv/bin/python -m phone_control
It speaks MCP over stdio and waits. Nothing to configure; the tools appear as
mcp__android__* once a client mounts it, as described in the quick start.
The tools
| Seeing | status, read_screen, take_screenshot |
| Acting | tap, type_text, scroll, swipe, press_key, open_app, open_url |
| System | list_apps, wait_for, read_notifications, clipboard, run_shell, transfer_file |
| Doing the whole job | run_task — say the goal, get a report |
| Stepping by hand | decide_next_action, ask_jev |
Full argument reference:
skills/android-phone-control/references/tools-reference.md.
The skill, for any agent
skills/android-phone-control/ teaches the
loop: start with the goal rather than the taps, when to read the tree rather than
take a screenshot, and how to read Jev's confidence. The body names no agent, so
the same instructions work anywhere.
Each tool reads a different file, so the skill is rendered into all of them from
one canonical source — skills/android-phone-control/SKILL.md:
| File | Read by |
|---|---|
AGENTS.md |
Codex, Cursor, Copilot agent mode, and the wider AGENTS.md convention |
GEMINI.md |
Gemini CLI |
.github/copilot-instructions.md |
GitHub Copilot |
.cursor/rules/android-phone-control.mdc |
Cursor |
.claude/skills/android-phone-control/ |
Claude Code |
Edit the canonical skill and re-render; a test fails if any copy has drifted:
.venv/bin/python scripts/render_skill_for_agents.py
For DeepSeek Harness, symlink or copy skills/android-phone-control/ into a
scanned skill root — $DSH_HOME/skills/ for every session, or .dsh/skills/ in a
project. Discovery is live; no restart.
Environment
| Variable | Effect |
|---|---|
DSH_PHONE_SERIAL |
Which device to drive when several are attached. |
JEV_API_KEY |
The Jev key, enabling the two decision tools. An OpenRouter sk-or-… key or a TypeSafe apikey_… one — its own shape routes it. |
OPENROUTER_API_KEY |
The same key under its older name, still read. |
PHONE_CONTROL_ADB |
Path to adb when it is not on PATH. |
Known limits
- Whether a screen can be read at all varies moment to moment. This is the limit to know about, because it produces failures that look like the model's fault and are not. Measured on one page, read twice in a row: 2.9 seconds and 29 controls, then 73.8 seconds and a failure. The page cannot be dumped while it is doing something, and a page carrying a live figure is doing something most of the time and not all of it. So the same goal can succeed on one attempt and fail on the next, with nothing changed but the timing. Everything else below is a consequence or a neighbour of this one.
- A screen hosting embedded Android Views inside Compose can read as almost
empty. The interop wrapper is marked
NO_HIDE_DESCENDANTS, which hides its whole subtree from every accessibility client — no flag or setting overrides it, and Appium's workaround exists only because it ships its own dumper inside an installed APK. Measured, this is the common case on this device's Settings pages rather than the exception. - When the tree will not describe a page, a photograph of it is read instead. That is the second route, and it reaches the tools as well as the loop: a page that cannot be dumped still comes back with its words. What it cannot do is give controls — the reading is lines of text with no tappable targets on them — so on such a page a run can conclude the goal and leave, and cannot act further. A page whose dump fails and whose photograph says nothing either is reported as a failure, not as an empty screen.
dumpsys activity topis not a foreground oracle. It reads as though it should be, and a probe built on it will silently read another app's state: asked while the Settings Battery page was in front, it reported fragments belonging to Photos.dumpsys windownames the page that is actually in front, and for a Settings page reached by tapping that is the genericSubSettings— the page's own title, read from the tree, is the reliable identity.- A sleeping screen reads as empty. The display going off leaves one bare node.
read_screensays which of asleep, locked or canvas-only it is. - Non-ASCII typing.
input textgoes through the virtual keyboard's character map, which returns null for anything it cannot generate — AOSP's own comment says "For robust text entry, do not use this function." Non-ASCII is routed through the clipboard and a paste instead. The proper fix isACTION_SET_TEXT. dumpsysoutput is internal and drifts between releases. Parsing is tested against dump shapes captured from real reports and exercised on a real device;docs/android-apis.mdseparates what was confirmed from what the device corrected.
Documentation
docs/android-apis.md |
What each route to an Android phone offers and costs, the parsing traps, and what a real device confirmed |
docs/tool-contract.md |
Where the tool surface is going, with the measurements — failures now raise, so is_error is true |
docs/what-we-got-wrong.md |
A diagnosis against a reference implementation of the same loop: the state and the questions are structurally wrong, and tuning will not fix them |
docs/debugging.md |
Start here when a run goes wrong — what to read, in what order, and how to re-decide a recorded step without a phone |
tests/test_what_the_phone_actually_does.py |
Every belief this code holds about Android, proven against the phone. A fake can only repeat what its author believed; these cannot |
docs/repository-settings.md |
What protects main, what GitHub refuses until the repository is public, and why a contribution is safe here already |
android-jev MCP 질문
폰에 별도로 설치해야 하는 것이 있나요?
아니요, 모든 작업은 ADB를 통해 실행되므로 폰 자체에 설치할 것은 없습니다.
어떤 Android 기기를 지원하나요?
Android 17이 실행되는 Pixel 8a에서 검증되었으므로, other 기기는 테스트되지 않은 것으로 간주해야 합니다.
의사 결정 모델을 위한 API 키는 어떻게 제공하나요?
JEV_API_KEY 또는 OPENROUTER_API_KEY 환경 변수를 사용하거나, secrets 디렉토리에 키 파일을 배치하면 됩니다.