#!/usr/bin/env python3
"""Dependency-free stdio MCP connector. Runs beside the calling coding agent.

SIM_URL=https://sim.davidhe.de SIM_API_KEY=... python3 mcp.py
Only explicitly selected APK/source files are read. No repository scanning or builds.
"""
import base64
import json
import os
from pathlib import Path
import sys
import urllib.error
import urllib.request
import uuid

BASE=os.environ.get('SIM_URL','https://sim.davidhe.de').rstrip('/')
KEY=os.environ.get('SIM_API_KEY','')


def call(method,path,body=None,binary=False):
    if not KEY: raise ValueError('Set SIM_API_KEY to an agent key from the workspace.')
    data=body if isinstance(body,bytes) else json.dumps(body).encode() if body is not None else None
    request=urllib.request.Request(BASE+path,data=data,method=method,headers={
        'Authorization':'Bearer '+KEY,'Content-Type':'application/octet-stream' if isinstance(body,bytes) else 'application/json'})
    # Never forward an agent credential through HTTP redirects.
    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self,*args,**kwargs): return None
    opener=urllib.request.build_opener(NoRedirect)
    with opener.open(request,timeout=240 if isinstance(body,bytes) else 30) as response:
        result=response.read()
        return result if binary else json.loads(result)


def schema(properties=None,required=None):
    return {'type':'object','properties':properties or {},'required':required or [],'additionalProperties':False}
S={'type':'string'}
TOOLS=[
 ('android_pool','See available Android capacity and current sessions.',schema()),
 ('android_start_session','Resume an Android workspace (default: default). App data and login survive stopping. Set fresh=true only for an explicitly requested empty Android. Use workspace for the app and environment for an isolated agent/worktree (default main). New environments copy the app baseline unless fresh=true. snapshot_id restores a named snapshot into a new/empty environment; no app build is needed. persistence=discard makes release discard current local changes; the default is save. Poll android_pool until ready. Keep the session to update APKs without losing app data. Use the same request_id when retrying.',schema({'name':S,'request_id':S,'workspace':S,'environment':S,'fresh':{'type':'boolean'}})),
 ('android_environments','List an app’s isolated test environments and its saved baseline.',schema({'workspace':S},['workspace'])),
 ('android_publish_baseline','Publish a saved environment as the independent starting point for future environments. Release its session first and wait until its data is saved; active or saving environments return HTTP 409. Existing environments remain unchanged.',schema({'workspace':S,'environment':S},['workspace'])),
 ('android_install_apk','Upload a local APK from the caller machine. Reinstall with -r preserves app data. Runs on ARM64 Android; APK must support arm64-v8a or be architecture-independent. Automatically opens an unambiguous app, or supply package.',schema({'session_id':S,'apk_path':S,'package':S},['session_id','apk_path'])),
 ('android_test','Delegate a task to the device agent. Returns immediately with a job id and observer link. Optional source_paths are explicit local source files to help the model understand routes and UI labels; source is context, runtime screenshots remain the evidence. Never include secrets. No task time/step limit.',schema({'session_id':S,'task':S,'model':{'type':'string','enum':['robot','qwen','astra','opus']},'context':S,'source_paths':{'type':'array','items':S,'maxItems':20},'request_id':S},['session_id','task'])),
 ('android_test_result','Read job status, observed results, timing, token usage and screenshot artifact URLs. Results stay available after releasing the session.',schema({'session_id':S,'job_id':S},['session_id','job_id'])),
 ('android_screenshot','Return a live screenshot, or a recorded job screenshot, as an image the calling agent can inspect.',schema({'session_id':S,'job_id':S,'filename':S},['session_id'])),
 ('android_stop_test','Stop the current test while retaining the device and app data.',schema({'session_id':S},['session_id'])),
 ('android_keep_session','Renew an otherwise idle session. Active tests renew automatically; unused sessions are removed after 30 minutes.',schema({'session_id':S},['session_id'])),
 ('android_release_session','Release a session using its default persistence policy (save by default), or explicitly choose disposition=save|discard. Discard preserves the last saved environment and throws away current session changes. Compute deletion follows billing-window retention. Resume later with the same workspace and environment to keep the login and local data. Reports and screenshots also remain available.',schema({'session_id':S},['session_id'])),
]


CRITERION=schema({'id':S,'description':S,'kind':{'type':'string','enum':['visual','text_visible','text_absent']},'value':S},['id','description','kind'])
ACTION=schema({'action':{'type':'string','enum':['tap','swipe','text','key','keycombo','wait','launch']},
    'x':{'type':'number','minimum':0,'maximum':1},'y':{'type':'number','minimum':0,'maximum':1},
    'x2':{'type':'number','minimum':0,'maximum':1},'y2':{'type':'number','minimum':0,'maximum':1},
    'duration':{'type':'integer'},'text':S,'key':S,'keys':{'type':'array','items':S},'package':S},['action'])
JOB_OPTIONS={'criteria':{'type':'array','items':CRITERION},'repeat_count':{'type':'integer','minimum':1},
    'reset_between':{'type':'string','enum':['none','restart_app','clear_data']},'package':S,
    'recovery_after':{'type':'integer','minimum':1},'capture_video':{'type':'boolean'},
    'revision_probe':schema({'uri':S},['uri']),'webhook_id':S,
    'store_artifacts':{'type':'boolean'},'artifact_retention_days':{'type':'integer','minimum':1,'maximum':365},
    'needs_input_timeout_seconds':{'type':['integer','null'],'minimum':60,'maximum':86400},'external_id':S,
    'tags':{'type':'object','additionalProperties':S},'test_key':{'type':'string','minLength':1,'maxLength':200,'pattern':'^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,199}$'}}
for tool_name,description,input_schema in TOOLS:
    if tool_name=='android_test': input_schema['properties'].update(JOB_OPTIONS)
SAVED_OPTIONS=schema({k:v for k,v in JOB_OPTIONS.items() if k not in ('criteria','package','test_key')})
TEST_DEFINITION=schema({'id':{'type':'string','pattern':'^[A-Za-z0-9_-]{1,80}$'},'name':S,'task':S,'model':{'type':'string','enum':['robot','qwen','astra','opus']},'criteria':{'type':'array','items':CRITERION},'package':{'type':['string','null']},'variables':{'type':'array','items':schema({'name':{'type':'string','pattern':'^[A-Za-z][A-Za-z0-9_]{0,63}$'},'secret':{'type':'boolean'}},['name'])},'options':SAVED_OPTIONS,'expected_version':{'type':'integer','minimum':1}})
TOOLS.extend([
 ('android_saved_tests','List reusable model tests for an app, or inspect a test_id and its versioned learned plan.',schema({'workspace':S,'test_id':S},['workspace'])),
 ('android_save_test','Create a reusable test definition, or PATCH test_id. Provide definition OR an explicitly selected JSON file. Creation requires id/name/task; templates use {{variable}} and declare variables. Never save secret values as defaults. Updating invalidates the learned plan; expected_version prevents stale edits.',schema({'workspace':S,'test_id':S,'definition':TEST_DEFINITION,'file':S},['workspace'])),
 ('android_delete_saved_test','Delete a reusable test and its learned plan. An active invocation must finish first. Historical job results remain.',schema({'workspace':S,'test_id':S},['workspace','test_id'])),
 ('android_run_saved_test','Run a saved test on the selected session. auto reuses a compatible learned path or learns; learn uses the model; replay requires a plan. Pointer targets and UI guards are checked, with model fallback on mismatch. Final criteria always apply. AI intent/efficiency review is required for first learning and changed/repaired paths, not unchanged approved replay. Failed/unavailable review prevents promotion; repaired, verified paths update future plans, with plan_history retained. Pass bindings or a private bindings_file; values are not definition defaults. Returns an ordinary job; follow android_watch/android_test_result.',schema({'workspace':S,'test_id':S,'session_id':S,'mode':{'type':'string','enum':['auto','learn','replay']},'bindings':{'type':'object','additionalProperties':S},'bindings_file':S,'options':SAVED_OPTIONS,'request_id':S},['workspace','test_id','session_id'])),
])
JOB={'session_id':S,'job_id':S}
TOOLS.extend([
 ('android_capacity','See available capacity, current usage and queue without starting a device.',schema()),
 ('android_reserve','Queue a simulator for this app/environment. Retry with the same request_id. Poll android_reservation; when allocated use session_id. ttl_seconds bounds the allocated lease (including active jobs); wait_timeout_seconds bounds waiting in the queue.',schema({'workspace':S,'environment':S,'name':S,'request_id':S,'ttl_seconds':{'type':'integer','minimum':60,'maximum':86400},'wait_timeout_seconds':{'type':'integer','minimum':60,'maximum':86400},'fresh':{'type':'boolean'}},['workspace'])),
 ('android_reservations','List queued and allocated reservations.',schema()),
 ('android_reservation','Inspect or cancel your reservation.',schema({'reservation_id':S,'cancel':{'type':'boolean'}},['reservation_id'])),
 ('android_briefing','Read, replace or delete persistent private app briefing. operation put requires briefing or a selected local JSON file. Credentials are supplied to the device model; never put them into source control.',schema({'workspace':S,'operation':{'type':'string','enum':['get','put','delete']},'briefing':schema({'context':S,'credentials':{'type':'object'},'known_pitfalls':{'type':'array','items':S}}),'file':S},['workspace'])),
 ('android_configure','Set permissions, open a deep link, toggle offline or change the Android clock. Clock changes do not change backend time. reset_clock restores automatic time.',schema({'session_id':S,'permissions':{'type':'array','items':schema({'package':S,'permission':S,'grant':{'type':'boolean'}},['package','permission','grant'])},'deep_link':S,'package':S,'offline':{'type':'boolean'},'time':S,'timezone':S,'reset_clock':{'type':'boolean'}},['session_id'])),
 ('android_revision','Inspect installed APK version and hash. APK revision alone cannot prove the loaded OTA JavaScript bundle.',schema({'session_id':S,'package':S,'probe_uri':S},['session_id','package'])),
 ('android_flows','List uploaded Maestro bundles, or delete one with flow_id. Running jobs should finish before deleting their input.',schema({'flow_id':S})),
 ('android_upload_flows','Upload an explicitly selected Maestro flow directory and referenced assets. Does not execute anything on this machine. Excludes hidden credentials, symlinks and sensitive file types.',schema({'directory':S},['directory'])),
 ('android_maestro','Run uploaded Maestro flows, optionally repeated with device reset between repetitions. Inspect per-flow results and evidence; backend data is never reset.',schema({**{k:v for k,v in JOB_OPTIONS.items() if k!='criteria'},'session_id':S,'flow_id':S,'flow_paths':{'type':'array','items':S,'minItems':1,'maxItems':256},'task':S,'request_id':S},['session_id','flow_id'])),
 ('android_answer','Answer the current blocking question using its exact question_id. The job can then continue. Stale answers return conflict.',schema({**JOB,'question_id':S,'answer':S},['session_id','job_id','question_id','answer'])),
 ('android_takeover','Pause the device model and acquire device control at a safe action boundary. Observe, send actions, then resume. Also useful after repeated navigation failure.',schema(JOB,['session_id','job_id'])),
 ('android_actions','Execute actions during caller takeover. Coordinates 0..1 relative to the native screen; durations milliseconds; keys Android names (BACK, ENTER). Never run arbitrary shell commands.',schema({**JOB,'actions':{'type':'array','items':ACTION,'minItems':1,'maxItems':10}},['session_id','job_id','actions'])),
 ('android_resume','Return device control to the testing model, optionally supplying updated instructions.',schema({**JOB,'message':S},['session_id','job_id'])),
 ('android_observe','Capture current screenshot and UI tree as job artifacts for the calling agent.',schema(JOB,['session_id','job_id'])),
 ('android_artifacts','List evidence manifest or download a named artifact to an explicitly selected local output path. Includes screenshots, UI XML, logs and video when available. Evidence failures are explicit.',schema({**JOB,'filename':S,'output':S},['session_id','job_id'])),
 ('android_webhooks','List, register or delete signed job completion callbacks. Secret is returned only on creation; store privately. Supply configuration through file or url/secret.',schema({'operation':{'type':'string','enum':['list','create','delete','deliveries']},'url':S,'secret':S,'file':S,'webhook_id':S})),
])


DISPLAY=schema({'width':{'type':'integer','minimum':320,'maximum':3200},'height':{'type':'integer','minimum':320,'maximum':3200},'density':{'type':'integer','minimum':120,'maximum':640}},['width','height','density'])
DEVICE_OPTIONS={'device_profile':{'type':'string','enum':['compact','large','tablet']},'display':DISPLAY,'locale':S}
REGION=schema({k:{'type':'number','minimum':0,'maximum':1} for k in ('x','y','width','height')},['x','y','width','height'])
for tool_name,description,input_schema in TOOLS:
    if tool_name in ('android_start_session','android_configure'): input_schema['properties'].update(DEVICE_OPTIONS)
    if tool_name in ('android_start_session','android_reserve'): input_schema['properties'].update({'snapshot_id':{'type':'string','pattern':'^n_[a-f0-9]{32}$'},'persistence':{'type':'string','enum':['save','discard']}})
    if tool_name=='android_release_session': input_schema['properties']['disposition']={'type':'string','enum':['save','discard']}
TOOLS.extend([
 ('android_snapshots','List immutable named Android snapshots for one app, or inspect snapshot_id until status=ready. A checkpoint captures local Android/app data, not remote backend state.',schema({'workspace':S,'snapshot_id':{'type':'string','pattern':'^n_[a-f0-9]{32}$'}},['workspace'])),
 ('android_create_snapshot','Create a named Android checkpoint. Supply session_id for an active device (pauses Android then resumes the same session; no active test allowed), or workspace/environment for a stopped saved environment. Returns a descriptor; poll android_snapshots until ready before restoring.',schema({'workspace':S,'environment':S,'session_id':S,'name':{'type':'string','minLength':1,'maxLength':100}},['name'])),
 ('android_delete_snapshot','Delete an explicitly selected immutable snapshot. Existing independent environments are not rolled back. This does not delete remote application backend records.',schema({'workspace':S,'snapshot_id':{'type':'string','pattern':'^n_[a-f0-9]{32}$'}},['workspace','snapshot_id'])),
 ('android_flakes','Read the last 20 completed jobs per stable flow (window 1..200) in one app/environment, or inspect flow_key from the list. Reports attempt failures, within-job mixed outcomes and cross-job binary flips separately. Infra/navigation outcomes are excluded from pass/fail rates. Use revision filtering before attributing a version change to flakiness.',schema({'workspace':S,'environment':S,'flow_key':{'type':'string','pattern':'^[a-f0-9]{64}$'},'window':{'type':'integer','minimum':1,'maximum':200},'kind':{'type':'string','enum':['maestro','model']},'model':S,'since':{'type':'number','minimum':0},'until':{'type':'number','minimum':0},'revision':S},['workspace'])),
 ('android_quarantine','Set or clear a manual quarantine annotation for one app/environment flow. It does not skip tests or change historical outcomes. Only pipelines explicitly choosing the monitoring gate may treat active quarantine as nonblocking. expires_at is future Unix seconds or null for no expiry.',schema({'workspace':S,'environment':S,'flow_key':{'type':'string','pattern':'^[a-f0-9]{64}$'},'quarantined':{'type':'boolean'},'reason':{'type':'string','maxLength':2000},'expires_at':{'type':['number','null'],'minimum':0}},['workspace','flow_key','quarantined'])),
 ('android_dev_server','Start, inspect, open the development app or stop a remote dev server isolated to this Android session. The device must already have a compatible development APK. After start, upload a source ZIP through android_dev_sync. Native or dependency changes may require rebuilding the APK/restarting the server; a release APK cannot use Fast Refresh.',schema({'session_id':S,'operation':{'type':'string','enum':['start','status','stop','open']},'framework':{'type':'string','enum':['expo','react-native','custom']},'command':S,'install_command':S,'package':S,'scheme':S},['session_id'])),
 ('android_dev_sync','Upload an explicitly selected project ZIP to this session dev server. full=true initially, then incremental ZIPs (100 MiB ZIP / 500 MiB expanded) containing changed files and optional __sim_sync__.json with delete_paths. Pass the latest revision as base_revision; conflicts must be resolved, never silently overwritten. Filter secrets and dependencies before making the archive; sim dev does this automatically.',schema({'session_id':S,'archive_path':S,'full':{'type':'boolean'},'base_revision':S},['session_id','archive_path'])),
 ('android_updates','Request an app-supported OTA check or reload for a channel. Exactly one of deep_link or provider_uri is required. This delivers the request; inspect before/after revision, channel_verified and update_changed rather than assuming the requested bundle is loaded. provider_uri calls the app provider method sim_update.',schema({'session_id':S,'package':S,'channel':S,'action':{'type':'string','enum':['check','reload']},'deep_link':S,'provider_uri':S,'revision_probe':schema({'uri':S},['uri'])},['session_id','package','channel'])),
 ('android_files','List, upload or download shared Android files. path is under Download, Pictures, DCIM or Movies. Upload reads only local_path (max 50 MiB); download writes only output. Media uploads trigger Android indexing for photo pickers.',schema({'session_id':S,'operation':{'type':'string','enum':['list','upload','download']},'path':S,'local_path':S,'output':S},['session_id'])),
 ('android_clipboard','Read or set the Android clipboard. Omit text to read; text including an empty string sets it. Maximum 16384 characters and 65536 UTF-8 bytes.',schema({'session_id':S,'text':{'type':'string','maxLength':16384}},['session_id'])),
 ('android_logcat','Read Android logs incrementally without clearing the buffer. Reuse next_cursor as after; cursor is scoped to session and package. Omit after to start with the latest lines. Use package to restrict to the running app process.',schema({'session_id':S,'after':S,'limit':{'type':'integer','minimum':1,'maximum':1000},'package':S},['session_id'])),
 ('android_device_profiles','List display presets and their size/density. They emulate screen geometry, not manufacturer hardware or firmware identity.',schema()),
 ('android_limits','Read actual concurrency, upload and API rate-limit policy before scheduling parallel work.',schema()),
 ('android_jobs','Search your account job history across released sessions. external_id can be a ticket ID; q searches text; tag is key:value. Pagination uses limit and offset.',schema({'q':S,'external_id':S,'request_id':S,'workspace':S,'environment':S,'outcome':S,'tag':S,'limit':{'type':'integer','minimum':1,'maximum':200},'offset':{'type':'integer','minimum':0}})),
 ('android_watch','Read-only live observation: returns new action events since after, cursor, status, question and (by default) the current screenshot as an image. Call again with the returned cursor while terminal=false or has_more=true. Does not pause, control or renew the device lease. include_screenshot=false returns only JSON. Can observe jobs with store_artifacts=false transiently.',schema({**JOB,'after':{'type':'integer','minimum':0},'include_screenshot':{'type':'boolean'}},['session_id','job_id'])),
 ('android_artifact_retention','Change evidence retention in days, pin against automatic expiry, or unpin. Cannot recover already deleted evidence. An explicit deletion also deletes pinned evidence.',schema({**JOB,'artifact_retention_days':{'type':'integer','minimum':1,'maximum':365},'pinned':{'type':'boolean'}},['session_id','job_id'])),
 ('android_delete_artifacts','Delete screenshots, videos, UI trees and logs for a finished job. Active jobs return conflict. The job result metadata remains searchable.',schema(JOB,['session_id','job_id'])),
 ('android_divergence','Find the first visibly different retained screen across repeated runs. Inspect returned pairs: rendering differences alone do not prove an app bug.',schema({**JOB,'threshold':{'type':'number','minimum':0,'maximum':1}},['session_id','job_id'])),
 ('android_compare','Compare a normalized screen region against an explicit baseline job or the latest passed job in the same environment. Returns difference metrics and visual evidence; it does not change the test outcome. Select matching checkpoints for meaningful comparisons.',schema({**JOB,'baseline_job_id':S,'baseline_session_id':S,'current_filename':S,'baseline_filename':S,'region':REGION,'threshold':{'type':'number','minimum':0,'maximum':1}},['session_id','job_id','region'])),
])


def flow_archive(directory):
    import io,zipfile
    root=Path(directory).expanduser()
    if root.is_symlink() or not root.is_dir(): raise ValueError('Select a real flow directory.')
    root=root.resolve();output=io.BytesIO();total=0;count=0;flows=0
    excluded={'.git','.sim','.gradle','node_modules','build','dist','.venv','venv','.ssh','.aws'}
    with zipfile.ZipFile(output,'w',compression=zipfile.ZIP_DEFLATED) as archive:
        for parent,dirs,files in os.walk(root,followlinks=False):
            dirs[:]=[d for d in dirs if d not in excluded and not (Path(parent)/d).is_symlink()]
            for name in sorted(files):
                path=Path(parent)/name
                if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root): continue
                if name.startswith('.env') or name in {'google-services.json','GoogleService-Info.plist','key.properties','local.properties'}: continue
                if path.suffix.lower() not in ('.yaml','.yml','.js','.json','.png','.jpg','.jpeg','.webp','.txt'): continue
                data=path.read_bytes();total+=len(data);count+=1
                if total>32*1024*1024 or count>256: raise ValueError('Flow archive exceeds 32 MiB / 256 files.')
                flows+=path.suffix.lower() in ('.yaml','.yml')
                archive.writestr(path.relative_to(root).as_posix(),data)
    if not flows: raise ValueError('No Maestro YAML flows in selected directory.')
    if len(output.getvalue())>8*1024*1024: raise ValueError('Compressed flow archive exceeds 8 MiB.')
    return output.getvalue()


def artifact_name(value):
    import re
    if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,179}',value) or '..' in value: raise ValueError('Use a filename from the artifact manifest.')
    return value


def identifier(value):
    import re
    if not re.fullmatch('[a-f0-9]{32}',str(value)): raise ValueError('Invalid session or job id')
    return value


def slug_identifier(value):
    import re
    if not isinstance(value,str) or not re.fullmatch('[a-zA-Z0-9_-]{1,80}',value): raise ValueError('Invalid app or environment identifier')
    return value


def execute(name,args):
    prefix='/v1/sessions/'+identifier(args['session_id']) if 'session_id' in args else ''
    if name=='android_pool': result={'pool':call('GET','/v1/pool'),**call('GET','/v1/sessions'),**call('GET','/v1/workspaces')}
    elif name=='android_start_session': result=call('POST','/v1/sessions',{'name':args.get('name','Agent session'),'request_id':args.get('request_id',uuid.uuid4().hex),'workspace':slug_identifier(args.get('workspace','default')),'environment':slug_identifier(args.get('environment','main')),'fresh':args.get('fresh',False),**{k:args[k] for k in (*DEVICE_OPTIONS,'snapshot_id','persistence') if k in args}})
    elif name=='android_environments': result=call('GET','/v1/apps/'+slug_identifier(args['workspace'])+'/environments')
    elif name=='android_publish_baseline': result=call('POST','/v1/apps/'+slug_identifier(args['workspace'])+'/baseline',{'environment':slug_identifier(args.get('environment','main'))})
    elif name=='android_install_apk':
        path=Path(args['apk_path']).expanduser()
        if path.stat().st_size>350*1024*1024: raise ValueError('APK exceeds 350 MB')
        from urllib.parse import urlencode
        result=call('POST',prefix+'/apk?'+urlencode({'package':args.get('package','')}),path.read_bytes())
    elif name=='android_test':
        files=[]
        for filename in args.get('source_paths',[]):
            path=Path(filename).expanduser()
            if path.stat().st_size>25000: raise ValueError('Select source files of at most 25 KB each: '+path.name)
            files.append({'path':str(path),'content':path.read_text()})
        if sum(len(f['content']) for f in files)>60000: raise ValueError('Select at most 60,000 characters of relevant source.')
        result=call('POST',prefix+'/jobs',{'task':args['task'],'model':args.get('model','robot'),
                    'context':args.get('context',''),'source_files':files,'request_id':args.get('request_id',uuid.uuid4().hex),**{k:args[k] for k in JOB_OPTIONS if k in args}})
    elif name=='android_test_result': result=call('GET',prefix+'/jobs/'+identifier(args['job_id']))
    elif name=='android_screenshot':
        endpoint=prefix+'/screenshot'
        if args.get('job_id'):
            import re
            filename=args.get('filename','')
            artifact_name(filename)
            if not filename.lower().endswith(('.jpg','.jpeg','.png','.webp')): raise ValueError('Select an image artifact.')
            endpoint=prefix+'/jobs/'+identifier(args['job_id'])+'/artifacts/'+filename
        mime='image/png' if args.get('filename','').lower().endswith('.png') else 'image/webp' if args.get('filename','').lower().endswith('.webp') else 'image/jpeg'
        return {'content':[{'type':'image','mimeType':mime,'data':base64.b64encode(call('GET',endpoint,binary=True)).decode()}]}
    elif name=='android_stop_test': result=call('POST',prefix+'/stop',{})
    elif name=='android_keep_session': result=call('POST',prefix+'/heartbeat',{})
    elif name=='android_release_session':
        from urllib.parse import urlencode
        result=call('DELETE',prefix+('?' + urlencode({'disposition':args['disposition']}) if 'disposition' in args else ''))
    elif name in ('android_capacity','android_device_profiles','android_limits'):
        result=call('GET',{'android_capacity':'/v1/capacity','android_device_profiles':'/v1/device-profiles','android_limits':'/v1/limits'}[name])
    elif name in ('android_saved_tests','android_save_test','android_delete_saved_test','android_run_saved_test'):
        path='/v1/apps/'+slug_identifier(args['workspace'])+'/tests'
        if args.get('test_id'): path+='/'+slug_identifier(args['test_id'])
        if name=='android_saved_tests': result=call('GET',path)
        elif name=='android_delete_saved_test':
            if not args.get('test_id'): raise ValueError('test_id is required for deletion.')
            result=call('DELETE',path)
        elif name=='android_save_test':
            if bool(args.get('file'))==('definition' in args): raise ValueError('Provide exactly one definition or JSON file.')
            body=json.loads(Path(args['file']).expanduser().read_text()) if args.get('file') else args['definition']
            if not isinstance(body,dict): raise ValueError('Definition must be a JSON object.')
            result=call('PATCH' if args.get('test_id') else 'POST',path,body)
        else:
            if not args.get('test_id'): raise ValueError('test_id is required to run a saved test.')
            if args.get('bindings_file') and 'bindings' in args: raise ValueError('Provide bindings or bindings_file, not both.')
            bindings=json.loads(Path(args['bindings_file']).expanduser().read_text()) if args.get('bindings_file') else args.get('bindings',{})
            if not isinstance(bindings,dict) or any(not isinstance(value,str) for value in bindings.values()): raise ValueError('Bindings must be a JSON object with string values.')
            result=call('POST',path+'/run',{'session_id':identifier(args['session_id']),'mode':args.get('mode','auto'),'bindings':bindings,'options':args.get('options',{}),'request_id':args.get('request_id',uuid.uuid4().hex)})
    elif name in ('android_snapshots','android_create_snapshot','android_delete_snapshot'):
        import re
        ident=args.get('snapshot_id')
        if ident is not None and not re.fullmatch(r'n_[a-f0-9]{32}',ident): raise ValueError('Use a snapshot_id from android_snapshots.')
        if name=='android_create_snapshot' and args.get('session_id'):
            result=call('POST',prefix+'/snapshots',{'name':args['name']})
        else:
            if not args.get('workspace'): raise ValueError('Select workspace for a saved environment or snapshot.')
            path='/v1/apps/'+slug_identifier(args['workspace'])+'/snapshots'
            if name=='android_create_snapshot': result=call('POST',path,{'name':args['name'],'environment':slug_identifier(args.get('environment','main'))})
            elif name=='android_delete_snapshot':
                if not ident: raise ValueError('snapshot_id is required for deletion.')
                result=call('DELETE',path+'/'+ident)
            else: result=call('GET',path+('/'+ident if ident else ''))
    elif name in ('android_flakes','android_quarantine'):
        import re
        from urllib.parse import urlencode
        key=args.get('flow_key')
        if key is not None and not re.fullmatch('[a-f0-9]{64}',key): raise ValueError('Use a flow_key from android_flakes (64 lowercase hex characters).')
        path='/v1/apps/'+slug_identifier(args['workspace'])+'/flakes'+('/'+key if key else '')
        query={'environment':slug_identifier(args.get('environment','main'))}
        if name=='android_flakes':
            query['window']=args.get('window',20)
            query.update({k:args[k] for k in ('kind','model','since','until','revision') if k in args})
            result=call('GET',path+'?'+urlencode(query))
        else:
            if not key: raise ValueError('flow_key is required for quarantine.')
            if args.get('quarantined') and not str(args.get('reason','')).strip(): raise ValueError('Provide a reason when enabling quarantine.')
            result=call('PATCH',path+'/quarantine?'+urlencode(query),{k:args[k] for k in ('quarantined','reason','expires_at') if k in args})
    elif name=='android_dev_server':
        operation=args.get('operation','status');path=prefix+'/dev-server'
        if operation=='status': result=call('GET',path)
        elif operation=='stop': result=call('DELETE',path)
        elif operation=='open': result=call('POST',path+'/open',{})
        elif operation=='start':
            if not args.get('framework'): raise ValueError('Starting a dev server requires framework.')
            result=call('POST',path,{k:args[k] for k in ('framework','command','install_command','package','scheme') if k in args})
        else: raise ValueError('Invalid dev server operation.')
    elif name=='android_dev_sync':
        from urllib.parse import urlencode
        source=Path(args['archive_path']).expanduser();full=args.get('full',False)
        if source.stat().st_size>100*1024*1024: raise ValueError('Dev source ZIP exceeds 100 MiB.')
        if not full and not args.get('base_revision'): raise ValueError('Incremental sync requires the latest base_revision.')
        query={'full':'true' if full else 'false'}
        if 'base_revision' in args: query['base_revision']=args['base_revision']
        result=call('PUT',prefix+'/dev-server/source?'+urlencode(query),source.read_bytes())
    elif name=='android_updates':
        if bool(args.get('deep_link'))==bool(args.get('provider_uri')): raise ValueError('Choose exactly one of deep_link and provider_uri.')
        result=call('POST',prefix+'/updates',{k:v for k,v in args.items() if k!='session_id'})
    elif name=='android_files':
        from urllib.parse import urlencode
        operation=args.get('operation','list');path=args.get('path','Download')
        if operation=='upload':
            source=Path(args['local_path']).expanduser()
            if source.stat().st_size>50*1024*1024: raise ValueError('Device files must not exceed 50 MiB.')
            if path in ('Download','Pictures','DCIM','Movies'): path+='/'+source.name
            result=call('PUT',prefix+'/files?'+urlencode({'path':path}),source.read_bytes())
        elif operation=='download':
            if not args.get('output'): raise ValueError('Select an explicit output path for download.')
            output=Path(args['output']).expanduser();data=call('GET',prefix+'/files?'+urlencode({'path':path}),binary=True)
            output.parent.mkdir(parents=True,exist_ok=True);output.write_bytes(data)
            result={'path':path,'output':str(output.resolve()),'bytes':len(data)}
        elif operation=='list': result=call('GET',prefix+'/files/list?'+urlencode({'path':path}))
        else: raise ValueError('Unknown file operation.')
    elif name=='android_clipboard':
        body={'text':args['text']} if 'text' in args else None
        if body and (len(body['text'])>16384 or len(body['text'].encode('utf-8'))>65536): raise ValueError('Clipboard text exceeds 16384 characters / 65536 UTF-8 bytes.')
        result=call('PUT' if body is not None else 'GET',prefix+'/clipboard',body)
    elif name=='android_logcat':
        from urllib.parse import urlencode
        result=call('GET',prefix+'/logcat'+('?' + urlencode({k:args[k] for k in ('after','limit','package') if k in args}) if any(k in args for k in ('after','limit','package')) else ''))
    elif name=='android_jobs':
        from urllib.parse import urlencode
        result=call('GET','/v1/jobs'+('?' + urlencode(args) if args else ''))
    elif name=='android_watch':
        from urllib.parse import urlencode
        path=prefix+'/jobs/'+identifier(args['job_id'])
        result=call('GET',path+'/live?'+urlencode({'after':args.get('after',0)}))
        content=[{'type':'text','text':json.dumps(result,ensure_ascii=False)}]
        if args.get('include_screenshot',True) and result.get('screenshot_url'):
            screenshot=result['screenshot_url']
            allowed=screenshot==path+'/live/screenshot' or (screenshot.startswith(path+'/artifacts/') and artifact_name(screenshot.removeprefix(path+'/artifacts/')))
            if not allowed: raise ValueError('Unexpected live screenshot URL.')
            mime='image/png' if screenshot.lower().endswith('.png') else 'image/webp' if screenshot.lower().endswith('.webp') else 'image/jpeg'
            try: content.append({'type':'image','mimeType':mime,'data':base64.b64encode(call('GET',screenshot,binary=True)).decode()})
            except urllib.error.HTTPError as error:
                if error.code not in (404,409,410): raise
                content.append({'type':'text','text':'The frame changed or expired during observation. Poll again with the returned cursor.'})
        return {'content':content}
    elif name in ('android_artifact_retention','android_delete_artifacts','android_divergence','android_compare'):
        path=prefix+'/jobs/'+identifier(args['job_id'])
        if name=='android_artifact_retention': result=call('PATCH',path+'/retention',{k:args[k] for k in ('artifact_retention_days','pinned') if k in args})
        elif name=='android_delete_artifacts': result=call('DELETE',path+'/artifacts')
        elif name=='android_divergence':
            from urllib.parse import urlencode
            result=call('GET',path+'/divergence'+('?' + urlencode({'threshold':args['threshold']}) if 'threshold' in args else ''))
        else: result=call('POST',path+'/compare',{k:v for k,v in args.items() if k not in ('session_id','job_id')})
    elif name=='android_reserve':
        result=call('POST','/v1/reservations',{'workspace':slug_identifier(args['workspace']),'environment':slug_identifier(args.get('environment','main')),'name':args.get('name','Agent session'),'request_id':args.get('request_id',uuid.uuid4().hex),'ttl_seconds':args.get('ttl_seconds',1800),'wait_timeout_seconds':args.get('wait_timeout_seconds',3600),'fresh':args.get('fresh',False),**{k:args[k] for k in ('snapshot_id','persistence') if k in args}})
    elif name=='android_reservations': result=call('GET','/v1/reservations')
    elif name=='android_reservation': result=call('DELETE' if args.get('cancel') else 'GET','/v1/reservations/'+slug_identifier(args['reservation_id']))
    elif name=='android_briefing':
        operation=args.get('operation','get')
        if operation not in ('get','put','delete'): raise ValueError('Invalid briefing operation')
        if args.get('file') and args.get('briefing'): raise ValueError('Use briefing or file, not both.')
        body=json.loads(Path(args['file']).expanduser().read_text()) if args.get('file') else args.get('briefing')
        if operation=='put' and not isinstance(body,dict): raise ValueError('Provide a briefing JSON object.')
        result=call(operation.upper(),'/v1/apps/'+slug_identifier(args['workspace'])+'/briefing',body if operation=='put' else None)
    elif name=='android_configure': result=call('POST',prefix+'/configure',{k:v for k,v in args.items() if k!='session_id'})
    elif name=='android_revision':
        from urllib.parse import urlencode
        result=call('GET',prefix+'/revision'+'?' + urlencode({'package':args['package'],**({'probe_uri':args['probe_uri']} if args.get('probe_uri') else {})}))
    elif name=='android_flows': result=call('DELETE' if args.get('flow_id') else 'GET','/v1/flows'+('/'+slug_identifier(args['flow_id']) if args.get('flow_id') else ''))
    elif name=='android_upload_flows': result=call('POST','/v1/flows',flow_archive(args['directory']))
    elif name=='android_maestro': result=call('POST',prefix+'/jobs',{'kind':'maestro','flow_id':args['flow_id'],**({'flow_paths':args['flow_paths']} if 'flow_paths' in args else {}),'task':args.get('task','Run the Maestro suite and report each flow.'),'request_id':args.get('request_id',uuid.uuid4().hex),**{k:args[k] for k in JOB_OPTIONS if k in args}})
    elif name in ('android_answer','android_takeover','android_actions','android_resume','android_observe','android_artifacts'):
        action=name.removeprefix('android_');path=prefix+'/jobs/'+identifier(args['job_id'])
        if action=='artifacts':
            if args.get('filename'):
                if not args.get('output'): raise ValueError('Specify output to download this artifact, or use android_screenshot for images.')
                destination=Path(args['output']).expanduser();destination.parent.mkdir(parents=True,exist_ok=True)
                destination.write_bytes(call('GET',path+'/artifacts/'+artifact_name(args['filename']),binary=True))
                result={'path':str(destination.resolve()),'filename':args['filename']}
            else: result=call('GET',path+'/artifacts')
        else:
            body={k:v for k,v in args.items() if k not in ('session_id','job_id')}
            result=call('GET' if action=='observe' else 'POST',path+'/'+action,None if action=='observe' else body)
    elif name=='android_webhooks':
        operation=args.get('operation','list')
        if operation=='list': result=call('GET','/v1/webhooks')
        elif operation=='deliveries': result=call('GET','/v1/webhooks/'+slug_identifier(args['webhook_id'])+'/deliveries')
        elif operation=='delete': result=call('DELETE','/v1/webhooks/'+slug_identifier(args['webhook_id']))
        elif operation=='create':
            body=json.loads(Path(args['file']).expanduser().read_text()) if args.get('file') else {k:args[k] for k in ('url','secret') if k in args}
            if not isinstance(body,dict) or not body.get('url'): raise ValueError('Provide a webhook URL.')
            result=call('POST','/v1/webhooks',body)
        else: raise ValueError('Invalid webhook operation')
    else: raise ValueError('Unknown tool')
    return {'content':[{'type':'text','text':json.dumps(result,ensure_ascii=False)}]}


def respond(request):
    method=request.get('method');args=request.get('params',{})
    if method=='initialize':
        return {'protocolVersion':'2025-06-18','capabilities':{'tools':{}},'serverInfo':{'name':'android-sessions','version':'0.7.0'},
                'instructions':'Start an Android session, poll until ready, install your APK, delegate a test and poll its result. Inspect criteria and artifacts before accepting a pass. Use android_watch to inspect live action events and screenshots without taking control. Remote development is available through sim dev or android_dev_server/android_dev_sync. Answer needs_input questions, or take over, observe, act and resume. Use persistent briefing, Maestro suites and repeat_count for reliable pipelines. Release unused sessions.'}
    if method=='ping': return {}
    if method=='tools/list': return {'tools':[{'name':n,'description':d,'inputSchema':s} for n,d,s in TOOLS]}
    if method=='tools/call':
        try: return execute(args['name'],args.get('arguments',{}))
        except urllib.error.HTTPError as e:
            return {'isError':True,'content':[{'type':'text','text':f'HTTP {e.code}: '+e.read(1500).decode(errors='replace')}]}
        except Exception as e: return {'isError':True,'content':[{'type':'text','text':str(e)[:500]}]}
    raise ValueError('Method not found')


def main():
    for line in sys.stdin:
        request={}
        try:
            request=json.loads(line)
            if 'id' not in request: continue
            result=respond(request)
            response={'jsonrpc':'2.0','id':request['id'],'result':result}
        except Exception as e:
            response={'jsonrpc':'2.0','id':request.get('id'),'error':{'code':-32601,'message':str(e)[:200]}}
        print(json.dumps(response,ensure_ascii=False),flush=True)

if __name__=='__main__': main()
