@@ -1587,6 +1587,209 @@ async def _fetch_video_thumbnail(vid_id: str, access_token: str) -> Optional[str
15871587 return None
15881588
15891589
1590+ @mcp_server .tool ()
1591+ @meta_api_tool
1592+ async def upload_video (
1593+ account_id : str ,
1594+ access_token : Optional [str ] = None ,
1595+ video_url : Optional [str ] = None ,
1596+ file : Optional [str ] = None ,
1597+ name : Optional [str ] = None ,
1598+ title : Optional [str ] = None ,
1599+ description : Optional [str ] = None ,
1600+ ) -> str :
1601+ """
1602+ Upload a video to an ad account's video library for use in Meta Ads creatives.
1603+
1604+ Wraps POST /act_{account_id}/advideos. Returns the video_id, which can be
1605+ passed to create_ad_creative (video_id field) or embedded in object_story_spec.video_data.
1606+
1607+ Provide EITHER:
1608+ - video_url: a publicly reachable URL (Meta fetches it server-side via the
1609+ `file_url` param — simplest and works for any file size Meta can download)
1610+ - file: a data URL ("data:video/mp4;base64,AAAA...") or raw base64 string.
1611+ This is uploaded as multipart form-data via the `source` field.
1612+ Practical limit ~100MB due to base64 payload size — use video_url for larger files.
1613+
1614+ Args:
1615+ account_id: Meta Ads account ID (format: act_XXXXXXXXX or XXXXXXXXX)
1616+ access_token: Meta API access token (optional — will use cached token if not provided)
1617+ video_url: Public URL of the video file (preferred for large files)
1618+ file: Data URL or raw base64 string of the video (for direct upload)
1619+ name: Optional internal name for the video asset
1620+ title: Optional video title (shown in Ads Manager)
1621+ description: Optional video description
1622+
1623+ Returns:
1624+ JSON with success flag, video_id, and (when available) the raw Meta response.
1625+ On success the typical shape is:
1626+ { "success": true, "account_id": "act_...", "video_id": "<id>", "name": "..." }
1627+ """
1628+ if not account_id :
1629+ return json .dumps ({"error" : "No account ID provided" }, indent = 2 )
1630+
1631+ if not video_url and not file :
1632+ return json .dumps ({
1633+ "error" : "Provide either 'video_url' (public URL) or 'file' (data URL / base64)"
1634+ }, indent = 2 )
1635+
1636+ account_id = ensure_act_prefix (account_id )
1637+ endpoint = f"{ account_id } /advideos"
1638+
1639+ try :
1640+ # --- Path 1: URL upload via make_api_request (Meta fetches server-side) ---
1641+ if video_url :
1642+ params : Dict [str , Any ] = {"file_url" : video_url }
1643+ if name :
1644+ params ["name" ] = name
1645+ if title :
1646+ params ["title" ] = title
1647+ if description :
1648+ params ["description" ] = description
1649+
1650+ logger .info (f"Uploading video to { account_id } via file_url" )
1651+ data = await make_api_request (endpoint , access_token , params , method = "POST" )
1652+
1653+ if isinstance (data , dict ) and "error" in data :
1654+ return json .dumps ({
1655+ "error" : "Failed to upload video" ,
1656+ "details" : data .get ("error" ),
1657+ "account_id" : account_id ,
1658+ "video_url" : video_url ,
1659+ }, indent = 2 )
1660+
1661+ video_id = None
1662+ if isinstance (data , dict ):
1663+ video_id = data .get ("id" ) or data .get ("video_id" )
1664+
1665+ return json .dumps ({
1666+ "success" : True ,
1667+ "account_id" : account_id ,
1668+ "video_id" : video_id ,
1669+ "name" : name ,
1670+ "title" : title ,
1671+ "raw_response" : data ,
1672+ }, indent = 2 )
1673+
1674+ # --- Path 2: direct base64 upload via multipart form-data ---
1675+ import base64 # Local import
1676+ import hmac
1677+ import hashlib
1678+ import httpx
1679+ from .api import META_GRAPH_API_BASE , USER_AGENT
1680+
1681+ data_url_prefix = "data:"
1682+ base64_marker = "base64,"
1683+ inferred_ext = ".mp4"
1684+ inferred_name = name or ""
1685+
1686+ if file .startswith (data_url_prefix ) and base64_marker in file :
1687+ header , base64_payload = file .split (base64_marker , 1 )
1688+ base64_payload = base64_payload .strip ()
1689+ mime_type = header [len (data_url_prefix ):].split (";" )[0 ].strip ()
1690+ ext_map = {
1691+ "video/mp4" : ".mp4" ,
1692+ "video/quicktime" : ".mov" ,
1693+ "video/webm" : ".webm" ,
1694+ "video/x-msvideo" : ".avi" ,
1695+ "video/x-matroska" : ".mkv" ,
1696+ }
1697+ inferred_ext = ext_map .get (mime_type , ".mp4" )
1698+ else :
1699+ base64_payload = file .strip ()
1700+
1701+ if not inferred_name :
1702+ inferred_name = f"upload{ inferred_ext } "
1703+
1704+ try :
1705+ video_bytes = base64 .b64decode (base64_payload )
1706+ except Exception as decode_error :
1707+ return json .dumps ({
1708+ "error" : "Failed to decode base64 video payload" ,
1709+ "details" : str (decode_error ),
1710+ }, indent = 2 )
1711+
1712+ if not access_token :
1713+ return json .dumps ({
1714+ "error" : {
1715+ "message" : "Authentication Required" ,
1716+ "details" : "A valid access token is required to upload video" ,
1717+ }
1718+ }, indent = 2 )
1719+
1720+ url = f"{ META_GRAPH_API_BASE } /{ endpoint } "
1721+
1722+ query_params : Dict [str , Any ] = {"access_token" : access_token }
1723+ app_secret = os .environ .get ("META_APP_SECRET" , "" )
1724+ if app_secret :
1725+ query_params ["appsecret_proof" ] = hmac .new (
1726+ app_secret .encode ("utf-8" ),
1727+ access_token .encode ("utf-8" ),
1728+ hashlib .sha256 ,
1729+ ).hexdigest ()
1730+
1731+ form_data : Dict [str , Any ] = {}
1732+ if name :
1733+ form_data ["name" ] = name
1734+ if title :
1735+ form_data ["title" ] = title
1736+ if description :
1737+ form_data ["description" ] = description
1738+
1739+ files = {
1740+ "source" : (inferred_name , video_bytes , "application/octet-stream" ),
1741+ }
1742+
1743+ logger .info (
1744+ f"Uploading video to { account_id } via multipart "
1745+ f"({ len (video_bytes )} bytes, name={ inferred_name } )"
1746+ )
1747+
1748+ async with httpx .AsyncClient () as client :
1749+ response = await client .post (
1750+ url ,
1751+ params = query_params ,
1752+ data = form_data ,
1753+ files = files ,
1754+ headers = {"User-Agent" : USER_AGENT },
1755+ timeout = 300.0 ,
1756+ )
1757+
1758+ try :
1759+ data = response .json ()
1760+ except json .JSONDecodeError :
1761+ data = {"text_response" : response .text , "status_code" : response .status_code }
1762+
1763+ if response .status_code >= 400 or (isinstance (data , dict ) and "error" in data ):
1764+ return json .dumps ({
1765+ "error" : "Failed to upload video" ,
1766+ "status_code" : response .status_code ,
1767+ "details" : data .get ("error" ) if isinstance (data , dict ) else data ,
1768+ "account_id" : account_id ,
1769+ "name" : inferred_name ,
1770+ }, indent = 2 )
1771+
1772+ video_id = None
1773+ if isinstance (data , dict ):
1774+ video_id = data .get ("id" ) or data .get ("video_id" )
1775+
1776+ return json .dumps ({
1777+ "success" : True ,
1778+ "account_id" : account_id ,
1779+ "video_id" : video_id ,
1780+ "name" : inferred_name ,
1781+ "title" : title ,
1782+ "bytes_uploaded" : len (video_bytes ),
1783+ "raw_response" : data ,
1784+ }, indent = 2 )
1785+
1786+ except Exception as e :
1787+ return json .dumps ({
1788+ "error" : "Failed to upload video" ,
1789+ "details" : str (e ),
1790+ }, indent = 2 )
1791+
1792+
15901793@mcp_server .tool ()
15911794@meta_api_tool
15921795async def create_ad_creative (
0 commit comments