Skip to content

Commit 964136c

Browse files
mdrokon-devclaude
andcommitted
feat(ads): restore upload_video tool dropped by upstream
Upstream never included upload_video in their codebase; our fork had it. Restoring to preserve the MCP tool surface for video asset uploads (both URL-based and base64 multipart paths). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 767d11a commit 964136c

1 file changed

Lines changed: 203 additions & 0 deletions

File tree

‎meta_ads_mcp/core/ads.py‎

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
15921795
async def create_ad_creative(

0 commit comments

Comments
 (0)