|
| 1 | +""" |
| 2 | +F146 — UXP-native MCP bridge. |
| 3 | +
|
| 4 | +Every competing Premiere-Pro MCP server today is CEP-bound (HTTP + |
| 5 | +ExtendScript) and will break with Adobe's ~Sept-2026 CEP EOL. UXP |
| 6 | +panels can't easily talk JSON-RPC to a sidecar process, but they can |
| 7 | +hit the existing Flask app on :5679 over HTTPS/HTTP just like any |
| 8 | +other route. This module bridges the MCP tool surface onto that same |
| 9 | +HTTP server so UXP keeps the 39 curated tools (and the 1,325 opt-in |
| 10 | +extended tools) usable post-EOL — no transport surgery required. |
| 11 | +
|
| 12 | +Three routes: |
| 13 | +
|
| 14 | + GET /mcp/tools — list the available tools (the same payload |
| 15 | + the sidecar exposes over JSON-RPC). |
| 16 | + POST /mcp/call — invoke a tool: {tool, arguments} → {result}. |
| 17 | + Wraps ``opencut.mcp_server.handle_tool_call``; |
| 18 | + rate-limited per-tool via the existing |
| 19 | + ``rate_limit`` machinery so the bridge can't |
| 20 | + be used to bypass per-key throttles. |
| 21 | + GET /mcp/info — capability report (count, extended-enabled, |
| 22 | + version, base-url). |
| 23 | +
|
| 24 | +Design notes: |
| 25 | + * The bridge stays in-process — no socket round-trip — by calling |
| 26 | + ``handle_tool_call`` directly. ``mcp_server._api`` will still |
| 27 | + re-hit ``:5679`` for the underlying REST calls, but the bridge |
| 28 | + itself adds no extra hop. |
| 29 | + * CSRF is required on ``POST /mcp/call`` (mutations); ``GET /mcp/*`` |
| 30 | + is read-only. |
| 31 | + * Tool-name allowlist is enforced server-side via |
| 32 | + ``mcp_server.get_mcp_tools`` so a malicious UXP panel can't |
| 33 | + invoke arbitrary string names. |
| 34 | +""" |
| 35 | +from __future__ import annotations |
| 36 | + |
| 37 | +import logging |
| 38 | +import time |
| 39 | + |
| 40 | +from flask import Blueprint, jsonify, request |
| 41 | + |
| 42 | +from opencut.errors import safe_error |
| 43 | +from opencut.security import rate_limit, rate_limit_release, require_csrf, safe_bool |
| 44 | + |
| 45 | +logger = logging.getLogger("opencut") |
| 46 | +mcp_bridge_bp = Blueprint("mcp_bridge", __name__) |
| 47 | + |
| 48 | + |
| 49 | +def _tool_index() -> dict: |
| 50 | + """Return ``{tool_name: tool_def}`` for fast allowlist lookups.""" |
| 51 | + from opencut import mcp_server |
| 52 | + out: dict = {} |
| 53 | + for tool in mcp_server.get_mcp_tools(include_extended=True): |
| 54 | + if isinstance(tool, dict) and tool.get("name"): |
| 55 | + out[str(tool["name"])] = tool |
| 56 | + return out |
| 57 | + |
| 58 | + |
| 59 | +@mcp_bridge_bp.route("/mcp/tools", methods=["GET"]) |
| 60 | +def route_mcp_tools(): |
| 61 | + """Return the tool catalogue. |
| 62 | +
|
| 63 | + Query params: |
| 64 | + include_extended bool default true — include the 1,325 opt-in |
| 65 | + auto-generated route tools. |
| 66 | + """ |
| 67 | + try: |
| 68 | + from opencut import mcp_server |
| 69 | + include_extended = safe_bool(request.args.get("include_extended", "true"), True) |
| 70 | + tools = mcp_server.get_mcp_tools(include_extended=include_extended) |
| 71 | + return jsonify({ |
| 72 | + "tools": tools, |
| 73 | + "count": len(tools), |
| 74 | + "include_extended": include_extended, |
| 75 | + }) |
| 76 | + except Exception as exc: # pragma: no cover |
| 77 | + return safe_error(exc, "mcp_bridge_tools") |
| 78 | + |
| 79 | + |
| 80 | +@mcp_bridge_bp.route("/mcp/call", methods=["POST"]) |
| 81 | +@require_csrf |
| 82 | +def route_mcp_call(): |
| 83 | + """Invoke an MCP tool. |
| 84 | +
|
| 85 | + Body params: |
| 86 | + tool str required, must be in the bridge allowlist |
| 87 | + arguments dict required (use ``{}`` for no-arg tools) |
| 88 | + """ |
| 89 | + acquired_key: str | None = None |
| 90 | + try: |
| 91 | + from opencut import mcp_server |
| 92 | + |
| 93 | + data = request.get_json(silent=True) or {} |
| 94 | + tool = str(data.get("tool") or "").strip() |
| 95 | + if not tool: |
| 96 | + raise ValueError("'tool' is required") |
| 97 | + arguments = data.get("arguments") |
| 98 | + if arguments is None: |
| 99 | + arguments = {} |
| 100 | + if not isinstance(arguments, dict): |
| 101 | + raise ValueError("'arguments' must be an object") |
| 102 | + |
| 103 | + # Allowlist guard — refuse unknown tool names BEFORE invoking. |
| 104 | + # Keeps the bridge from being used to probe arbitrary strings. |
| 105 | + idx = _tool_index() |
| 106 | + if tool not in idx: |
| 107 | + return jsonify({"error": f"unknown tool: {tool}"}), 400 |
| 108 | + |
| 109 | + # Per-tool rate limit to keep one UXP panel from starving others. |
| 110 | + # Uses a deterministic key per tool name so concurrent identical |
| 111 | + # calls queue (rather than fan out and overload the backend). |
| 112 | + rl_key = f"mcp_bridge::{tool}" |
| 113 | + if not rate_limit(rl_key): |
| 114 | + return jsonify({ |
| 115 | + "error": "rate limit exceeded for tool", |
| 116 | + "tool": tool, |
| 117 | + "retry_after_seconds": 1, |
| 118 | + }), 429 |
| 119 | + acquired_key = rl_key |
| 120 | + |
| 121 | + start = time.perf_counter() |
| 122 | + result = mcp_server.handle_tool_call(tool, arguments) |
| 123 | + duration_ms = int((time.perf_counter() - start) * 1000) |
| 124 | + |
| 125 | + return jsonify({ |
| 126 | + "tool": tool, |
| 127 | + "result": result, |
| 128 | + "duration_ms": duration_ms, |
| 129 | + }) |
| 130 | + except (ValueError, TypeError) as exc: |
| 131 | + return jsonify({"error": str(exc)}), 400 |
| 132 | + except Exception as exc: # pragma: no cover |
| 133 | + return safe_error(exc, "mcp_bridge_call") |
| 134 | + finally: |
| 135 | + if acquired_key: |
| 136 | + try: |
| 137 | + rate_limit_release(acquired_key) |
| 138 | + except Exception: # pragma: no cover |
| 139 | + pass |
| 140 | + |
| 141 | + |
| 142 | +@mcp_bridge_bp.route("/mcp/info", methods=["GET"]) |
| 143 | +def route_mcp_info(): |
| 144 | + """Capability report — how many tools, extended-mode flag, version.""" |
| 145 | + try: |
| 146 | + from opencut import __version__, mcp_server |
| 147 | + from opencut.mcp_extended_tools import extended_tools_enabled |
| 148 | + curated = mcp_server.get_mcp_tools(include_extended=False) |
| 149 | + extended = mcp_server.get_mcp_tools(include_extended=True) |
| 150 | + return jsonify({ |
| 151 | + "version": __version__, |
| 152 | + "curated_count": len(curated), |
| 153 | + "extended_count": len(extended) - len(curated), |
| 154 | + "extended_enabled_by_default": extended_tools_enabled(), |
| 155 | + "transport": "uxp-bridge", # vs "json-rpc-stdio" or "json-rpc-http" |
| 156 | + "endpoints": ["/mcp/tools", "/mcp/call", "/mcp/info"], |
| 157 | + }) |
| 158 | + except Exception as exc: # pragma: no cover |
| 159 | + return safe_error(exc, "mcp_bridge_info") |
0 commit comments