#!/usr/bin/env python3
"""Dependency-free MCP stdio adapter. Credentials stay in this local process."""
import os,sys,json,urllib.request,urllib.error,base64
from urllib.parse import urlsplit
BASE=os.environ.get('BLENDOJO_URL','https://blendojo.com').rstrip('/')
JOB=os.environ.get('BLENDOJO_JOB','');TOKEN=os.environ.get('BLENDOJO_EDITOR_TOKEN','')
PROTOCOLS=['2025-06-18','2024-11-05']
def schema(properties,required=()):return dict(type='object',properties=properties,required=list(required),additionalProperties=False)
integer={'type':'integer','minimum':0};vector={'type':'array','items':{'type':'number'},'minItems':3,'maxItems':3}
TOOLS=[
 dict(name='get_rig',description='Read bone names, hierarchy, frame times, source mapping and rest joints. Optionally include mesh vertices, faces, weights and motion arrays.',inputSchema=schema({'include_geometry':{'type':'boolean'}})),
 dict(name='get_draft',description='Read current versioned draft before changing anything.',inputSchema=schema({})),
 dict(name='set_joint',description='Set a rest joint in Y-up model coordinates. Requires the current draft version; preserves other edits.',inputSchema=schema({'version':integer,'bone':integer,'position':vector},['version','bone','position'])),
 dict(name='set_pose',description='Set model-space XYZ Euler degree rotation and translation at a zero-based frame. Radius blends into neighboring frames. Descendants follow.',inputSchema=schema({'version':integer,'bone':integer,'frame':integer,'rotation':vector,'translation':vector,'radius':integer},['version','bone','frame','rotation','translation','radius'])),
 dict(name='paint_weights',description='Replace selected vertices’ full weight vectors. Values must be nonnegative and sum to one; use indices from get_rig.',inputSchema=schema({'version':integer,'weights':{'type':'array','maxItems':5000,'items':schema({'vertex':integer,'values':{'type':'array','items':{'type':'number','minimum':0,'maximum':1}}},['vertex','values'])}},['version','weights'])),
 dict(name='replace_draft',description='Replace the draft, including removing edits. Supply its current version; empty arrays reset that edit category.',inputSchema=schema({'draft':{'type':'object'}},['draft'])),
 dict(name='source_frame',description='Get the source image corresponding to an animation frame, including loop time mapping.',inputSchema=schema({'frame':integer},['frame'])),
 dict(name='preview_frame',description='Get a CPU-rendered draft mesh and skeleton PNG; does not run inference or queue a rebake.',inputSchema=schema({'frame':integer,'view':{'type':'string','enum':['front','side','back']}},['frame'])),
 dict(name='schedule_rebake',description='Queue an FBX/GLB export of the saved draft for 30 blenDojo credits charged to the project owner. Joint and weight edits are free. Failed or cancelled rebakes refund the charge. Creates a separate result. One active rebake per project; repeated version returns the same job without another charge.',inputSchema=schema({'version':integer},['version'])),
 dict(name='list_rebakes',description='Read queued and completed rebake status.',inputSchema=schema({}))]
def api(path,method='GET',body=None,binary=False):
 if not TOKEN or len(JOB)!=32 or any(x not in '0123456789abcdef' for x in JOB):raise ValueError('Set BLENDOJO_JOB and BLENDOJO_EDITOR_TOKEN.')
 parsed=urlsplit(BASE)
 if parsed.scheme!='https' and not (parsed.scheme=='http' and parsed.hostname in ('127.0.0.1','localhost')):raise ValueError('Use HTTPS for remote connections.')
 req=urllib.request.Request(BASE+'/api/jobs/'+JOB+'/editor/'+path,data=None if body is None else json.dumps(body).encode(),method=method,headers={'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'})
 with urllib.request.urlopen(req,timeout=90) as response:raw=response.read()
 return raw if binary else json.loads(raw)
def call(name,args):
 if name=='get_rig':
  value=api('rig')
  if not args.get('include_geometry'):value={k:v for k,v in value.items() if k not in ('rest','faces','weights','rotations','heads')}
 elif name=='get_draft':value=api('draft')
 elif name in ('set_joint','set_pose','paint_weights'):
  value=api('draft')
  if value['version']!=args['version']:raise ValueError('Draft changed; call get_draft again.')
  if name=='set_joint':
   value['rest']=[x for x in value['rest'] if x['bone']!=args['bone']]+[{k:args[k] for k in ('bone','position')}]
  elif name=='set_pose':
   value['poses']=[x for x in value['poses'] if (x['bone'],x['frame'])!=(args['bone'],args['frame'])]+[{k:args[k] for k in ('bone','frame','rotation','translation','radius')}]
  else:
   ids={x['vertex'] for x in args['weights']};value['weights']=[x for x in value['weights'] if x['vertex'] not in ids]+args['weights']
  value=api('draft','PUT',value)
 elif name=='replace_draft':value=api('draft','PUT',args['draft'])
 elif name in ('source_frame','preview_frame'):
  frame=args['frame']
  if type(frame)!=int or frame<0:raise ValueError('Invalid frame.')
  view=args.get('view','front')
  if view not in ('front','side','back'):raise ValueError('Invalid view.')
  path='source/'+str(frame) if name=='source_frame' else 'preview/'+str(frame)+'?view='+view
  return {'content':[{'type':'image','mimeType':'image/png','data':base64.b64encode(api(path,binary=True)).decode()}]}
 elif name=='schedule_rebake':value=api('rebake','POST',{'version':args['version']})
 elif name=='list_rebakes':value=api('rebakes')
 else:raise ValueError('Unknown tool.')
 return {'content':[{'type':'text','text':json.dumps(value)}]}
def handle(message):
 method=message.get('method');params=message.get('params',{})
 if method=='initialize':return dict(protocolVersion=params.get('protocolVersion') if params.get('protocolVersion') in PROTOCOLS else PROTOCOLS[0],capabilities={'tools':{}},serverInfo={'name':'blendojo-final-editor','version':'1.0.0'})
 if method=='ping':return {}
 if method=='tools/list':return {'tools':TOOLS}
 if method=='tools/call':
  try:return call(params['name'],params.get('arguments',{}))
  except urllib.error.HTTPError as e:
   try:detail=json.loads(e.read()).get('detail','Request failed')
   except Exception:detail='Request failed'
   return {'isError':True,'content':[{'type':'text','text':str(detail)}]}
  except Exception as e:return {'isError':True,'content':[{'type':'text','text':str(e)}]}
 raise ValueError('Method not found')
if __name__=='__main__':
 for line in sys.stdin:
  try:
   message=json.loads(line)
   if not isinstance(message,dict) or 'id' not in message:continue
   try:reply={'jsonrpc':'2.0','id':message['id'],'result':handle(message)}
   except ValueError:reply={'jsonrpc':'2.0','id':message['id'],'error':{'code':-32601,'message':'Method not found'}}
  except ValueError:reply={'jsonrpc':'2.0','id':None,'error':{'code':-32700,'message':'Invalid JSON'}}
  print(json.dumps(reply),flush=True)
