Skip to content

Commit e81a1d9

Browse files
committed
feat: F146 UXP MCP bridge (close RESEARCH_FEATURE_PLAN_2026-05-25 F146 carry-forward)
Every competing PPro MCP server today is CEP-bound and will break at Adobe's ~Sept-2026 CEP EOL. UXP can't speak JSON-RPC to a sidecar process directly, but it can hit the main Flask app on :5679 like any other route. F146 bridges the MCP catalogue + dispatcher onto :5679 so UXP keeps the 39 curated + 1,325 opt-in tools usable post-EOL — no transport surgery required. Implementation: - opencut/routes/mcp_bridge_routes.py exposes 3 routes: * GET /mcp/tools?include_extended=true|false — full catalogue * POST /mcp/call {tool, arguments} — invokes opencut.mcp_server.handle_tool_call. CSRF-protected. Tool-name allowlist guard before dispatch. Per-tool rate-limit key "mcp_bridge::<tool>" released in finally. * GET /mcp/info — version + curated_count + extended_count + extended_enabled_by_default + transport tag + endpoints. - In-process: no extra socket hop. handle_tool_call still uses the sidecar's existing _api() to hit :5679 for REST proxying, same as the JSON-RPC stdio transport path. - Manifest regenerated: 1517 routes / 107 blueprints. - tests/test_mcp_bridge.py: 10 cases (info shape, curated vs extended counts, unknown-tool reject, missing-tool reject, non-dict-arguments reject, dispatch via handle_tool_call mock, CSRF guard, rate-limit slot released after exception, tool index correctness). Verify: python -m pytest tests/test_mcp_bridge.py -q # 10 passed curl -s http://localhost:5679/mcp/info
1 parent 3424e19 commit e81a1d9

6 files changed

Lines changed: 375 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
## [Unreleased]
44

5+
### Added — F146 UXP MCP Bridge
6+
7+
- `opencut/routes/mcp_bridge_routes.py` exposes the MCP sidecar's catalogue + dispatcher onto the main Flask app on `:5679` so UXP panels keep MCP functionality after Adobe's ~Sept-2026 CEP EOL. Three routes:
8+
- `GET /mcp/tools?include_extended=true|false` — full catalogue (39 curated + 1,325 opt-in extended).
9+
- `POST /mcp/call``{tool, arguments}` → invokes `opencut.mcp_server.handle_tool_call`. CSRF-protected. Tool-name allowlist enforced before dispatch. Per-tool rate-limit via the existing `rate_limit(key="mcp_bridge::<tool>")` machinery; slot released in `finally`.
10+
- `GET /mcp/info` — capability report (version, curated_count, extended_count, extended_enabled_by_default, transport tag, endpoints).
11+
- In-process — no extra socket hop. Bridge dispatches directly through `handle_tool_call`; the sidecar's own `_api()` still hits `:5679` for REST proxying, same as the JSON-RPC stdio path.
12+
- Manifest now reports **1,517 routes / 107 blueprints**.
13+
- `tests/test_mcp_bridge.py` — 10 cases: info shape, curated vs extended catalogue counts, unknown-tool rejection, missing-tool / non-dict-arguments rejection, dispatch routes through `handle_tool_call` (mocked), CSRF guard, rate-limit slot released after exception, tool index keyed by name.
14+
515
### Changed — F144 Self-Review Polish (structured JSON + drift score + suggested-retry append)
616

717
- `opencut/core/agent_chat.review()` now defaults to the structured-JSON LLM critique path. Schema: `{matched, drift_score: 0..100, drift_notes: [str], suggested_retry: null | {label, endpoint, payload, rationale}}`. Markdown fences and leading prose are tolerated; structured parse failure transparently falls back to the legacy free-text prompt; both fall back to the heuristic when no LLM is configured.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-0078D4)
66
![Python](https://img.shields.io/badge/Python-3.11+-3776AB?logo=python&logoColor=white)
77
![Premiere Pro](https://img.shields.io/badge/Premiere%20Pro-2019+-9999FF?logo=adobepremierepro&logoColor=white)
8-
![Routes](https://img.shields.io/badge/API%20Routes-1514-orange)
8+
![Routes](https://img.shields.io/badge/API%20Routes-1517-orange)
99
![Tests](https://img.shields.io/badge/Tests-8200+-brightgreen)
1010

1111
> Route count is generated from `opencut/_generated/route_manifest.json`; run

opencut/_generated/route_manifest.json

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
22
"version": 1,
3-
"generated_at": "2026-05-26T02:16:26Z",
4-
"total_routes": 1514,
5-
"blueprint_count": 106,
3+
"generated_at": "2026-05-26T02:26:13Z",
4+
"total_routes": 1517,
5+
"blueprint_count": 107,
66
"method_counts": {
77
"DELETE": 14,
8-
"GET": 375,
8+
"GET": 377,
99
"PATCH": 1,
10-
"POST": 1126,
10+
"POST": 1127,
1111
"PUT": 2
1212
},
1313
"blueprints": {
@@ -516,6 +516,18 @@
516516
"/journal/record"
517517
]
518518
},
519+
"mcp_bridge": {
520+
"route_count": 3,
521+
"method_counts": {
522+
"GET": 2,
523+
"POST": 1
524+
},
525+
"sample_rules": [
526+
"/mcp/call",
527+
"/mcp/info",
528+
"/mcp/tools"
529+
]
530+
},
519531
"motion_design": {
520532
"route_count": 12,
521533
"method_counts": {
@@ -7724,6 +7736,30 @@
77247736
"endpoint": "timeline.markers_import",
77257737
"blueprint": "timeline"
77267738
},
7739+
{
7740+
"rule": "/mcp/call",
7741+
"methods": [
7742+
"POST"
7743+
],
7744+
"endpoint": "mcp_bridge.route_mcp_call",
7745+
"blueprint": "mcp_bridge"
7746+
},
7747+
{
7748+
"rule": "/mcp/info",
7749+
"methods": [
7750+
"GET"
7751+
],
7752+
"endpoint": "mcp_bridge.route_mcp_info",
7753+
"blueprint": "mcp_bridge"
7754+
},
7755+
{
7756+
"rule": "/mcp/tools",
7757+
"methods": [
7758+
"GET"
7759+
],
7760+
"endpoint": "mcp_bridge.route_mcp_tools",
7761+
"blueprint": "mcp_bridge"
7762+
},
77277763
{
77287764
"rule": "/media/find-duplicates",
77297765
"methods": [

opencut/routes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ def get_core_blueprints():
139139
from .wave_k_routes import wave_k_bp
140140
from .agent_chat_routes import agent_chat_bp
141141
from .enhance_routes import enhance_bp
142+
from .mcp_bridge_routes import mcp_bridge_bp
142143
from .sequence_index_routes import sequence_index_bp
143144
from .shorts_variants_routes import shorts_variants_bp
144145
from .wave_l_routes import wave_l_bp
@@ -245,6 +246,7 @@ def get_core_blueprints():
245246
wave_k_bp,
246247
agent_chat_bp,
247248
enhance_bp,
249+
mcp_bridge_bp,
248250
sequence_index_bp,
249251
shorts_variants_bp,
250252
wave_l_bp,
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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

Comments
 (0)