1515
1616import json
1717import logging
18+ import re
1819import sys
1920import urllib .error
2021import urllib .request
2122
23+ from opencut import __version__
24+
2225logger = logging .getLogger ("opencut.mcp" )
2326
2427BACKEND_URL = "http://127.0.0.1:5679"
2528_csrf_token = ""
2629
2730
31+ def _refresh_csrf ():
32+ """Fetch fresh CSRF token from backend."""
33+ global _csrf_token
34+ try :
35+ req = urllib .request .Request (f"{ BACKEND_URL } /health" )
36+ with urllib .request .urlopen (req , timeout = 5 ) as resp :
37+ body = json .loads (resp .read ())
38+ _csrf_token = body .get ("csrf_token" , "" )
39+ except Exception :
40+ pass
41+
42+
2843def _api (method , path , data = None ):
2944 """Call the OpenCut Flask backend."""
3045 global _csrf_token
3146
32- # Get CSRF token if we don't have one
3347 if not _csrf_token :
34- try :
35- req = urllib .request .Request (f"{ BACKEND_URL } /health" )
36- with urllib .request .urlopen (req , timeout = 5 ) as resp :
37- body = json .loads (resp .read ())
38- _csrf_token = body .get ("csrf_token" , "" )
39- except Exception :
40- pass
48+ _refresh_csrf ()
4149
4250 url = f"{ BACKEND_URL } { path } "
4351 headers = {"Content-Type" : "application/json" }
@@ -51,6 +59,16 @@ def _api(method, path, data=None):
5159 with urllib .request .urlopen (req , timeout = 120 ) as resp :
5260 return json .loads (resp .read ())
5361 except urllib .error .HTTPError as e :
62+ # Retry once on 403 (stale CSRF token after backend restart)
63+ if e .code == 403 and _csrf_token :
64+ _refresh_csrf ()
65+ headers ["X-OpenCut-Token" ] = _csrf_token
66+ req2 = urllib .request .Request (url , data = body , headers = headers , method = method )
67+ try :
68+ with urllib .request .urlopen (req2 , timeout = 120 ) as resp2 :
69+ return json .loads (resp2 .read ())
70+ except Exception :
71+ pass
5472 error_body = e .read ().decode (errors = "replace" )
5573 try :
5674 return json .loads (error_body )
@@ -214,16 +232,34 @@ def _api(method, path, data=None):
214232}
215233
216234
235+ def _validate_mcp_filepath (args , key = "filepath" ):
236+ """Validate filepath arguments at MCP layer (defense-in-depth)."""
237+ path = args .get (key , "" )
238+ if not isinstance (path , str ):
239+ return False
240+ if ".." in path or "\x00 " in path :
241+ return False
242+ return True
243+
244+
217245def handle_tool_call (tool_name , arguments ):
218246 """Execute an MCP tool call by proxying to the Flask backend."""
219247 if tool_name not in _TOOL_ROUTES :
220248 return {"error" : f"Unknown tool: { tool_name } " }
221249
250+ # Validate filepath arguments at MCP layer
251+ for key in ("filepath" , "style_image" , "voice_ref" ):
252+ if key in arguments and not _validate_mcp_filepath (arguments , key ):
253+ return {"error" : f"Invalid { key } : path traversal or null bytes detected" }
254+
222255 method , path = _TOOL_ROUTES [tool_name ]
223256
224257 # Handle special routing
225258 if tool_name == "opencut_job_status" :
226259 job_id = arguments .get ("job_id" , "" )
260+ # Validate job_id format (UUID hex + hyphens only)
261+ if not re .match (r'^[a-f0-9-]+$' , job_id ):
262+ return {"error" : "Invalid job_id format" }
227263 path = f"/status/{ job_id } "
228264 return _api ("GET" , path )
229265
@@ -255,6 +291,9 @@ def run_mcp_stdio():
255291 try :
256292 msg = json .loads (line )
257293 except json .JSONDecodeError :
294+ err = {"jsonrpc" : "2.0" , "id" : None , "error" : {"code" : - 32700 , "message" : "Parse error" }}
295+ sys .stdout .write (json .dumps (err ) + "\n " )
296+ sys .stdout .flush ()
258297 continue
259298
260299 msg_id = msg .get ("id" )
@@ -270,7 +309,7 @@ def run_mcp_stdio():
270309 "capabilities" : {"tools" : {}},
271310 "serverInfo" : {
272311 "name" : "opencut" ,
273- "version" : "1.3.1" ,
312+ "version" : __version__ ,
274313 },
275314 },
276315 }
0 commit comments