openplot 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openplot/__init__.py +3 -0
- openplot/api/__init__.py +1 -0
- openplot/api/schemas.py +132 -0
- openplot/cli.py +139 -0
- openplot/desktop.py +437 -0
- openplot/domain/__init__.py +1 -0
- openplot/domain/annotations.py +45 -0
- openplot/domain/regions.py +52 -0
- openplot/executor.py +726 -0
- openplot/feedback.py +141 -0
- openplot/mcp_server.py +353 -0
- openplot/models.py +408 -0
- openplot/release_versioning.py +305 -0
- openplot/server.py +11120 -0
- openplot/services/__init__.py +1 -0
- openplot/services/naming.py +75 -0
- openplot/static/assets/MarkdownRenderer-DqMDEmDw.js +2 -0
- openplot/static/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- openplot/static/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- openplot/static/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- openplot/static/assets/iconify-xdk4cov8.js +1 -0
- openplot/static/assets/index-9qrwxLoh.css +1 -0
- openplot/static/assets/index-DhwX5yIi.js +19 -0
- openplot/static/assets/lucide-DCaNFHhh.js +1 -0
- openplot/static/assets/markdown-BgYhP8km.js +29 -0
- openplot/static/assets/openplot-DYqSIMi_.png +0 -0
- openplot/static/assets/react-vendor-C4hNv3Lv.js +9 -0
- openplot/static/assets/ui-DjPaK12C.js +12 -0
- openplot/static/index.html +17 -0
- openplot/static/vite.svg +1 -0
- openplot-1.0.0.dist-info/METADATA +332 -0
- openplot-1.0.0.dist-info/RECORD +34 -0
- openplot-1.0.0.dist-info/WHEEL +4 -0
- openplot-1.0.0.dist-info/entry_points.txt +4 -0
openplot/feedback.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Compile annotations into a structured feedback prompt for LLM agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .domain.regions import region_bounds_from_points, region_zone_hint_from_points
|
|
6
|
+
from .models import Annotation, AnnotationStatus, PlotSession, RegionInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _region_bounds(region: RegionInfo) -> tuple[float, float, float, float] | None:
|
|
10
|
+
return region_bounds_from_points(region.points)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _region_zone_hint(region: RegionInfo) -> str:
|
|
14
|
+
return region_zone_hint_from_points(region.points)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _describe_element(ann: Annotation) -> str:
|
|
18
|
+
"""Human-readable description of the annotated element or region."""
|
|
19
|
+
if ann.element_info is not None:
|
|
20
|
+
ei = ann.element_info
|
|
21
|
+
attrs_str = ", ".join(f"{k}: {v}" for k, v in ei.attributes.items())
|
|
22
|
+
text_part = f' "{ei.text_content}"' if ei.text_content else ""
|
|
23
|
+
attrs_part = f" ({attrs_str})" if attrs_str else ""
|
|
24
|
+
return f"<{ei.tag}>{text_part}{attrs_part}"
|
|
25
|
+
|
|
26
|
+
if ann.region is not None:
|
|
27
|
+
ri = ann.region
|
|
28
|
+
bounds = _region_bounds(ri)
|
|
29
|
+
if bounds is not None:
|
|
30
|
+
x0, y0, x1, y1 = bounds
|
|
31
|
+
coords = f"({x0:.2f}, {y0:.2f}) -> ({x1:.2f}, {y1:.2f})"
|
|
32
|
+
else:
|
|
33
|
+
coords = "unknown region"
|
|
34
|
+
has_crop = " [cropped image attached]" if ri.crop_base64 else ""
|
|
35
|
+
return f"{ri.type.value.capitalize()} region at {coords}{has_crop}"
|
|
36
|
+
|
|
37
|
+
return "Unknown element"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def compile_feedback(session: PlotSession, *, include_addressed: bool = False) -> str:
|
|
41
|
+
"""Build a Markdown prompt containing all pending annotations.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
session:
|
|
46
|
+
The current plot session with annotations.
|
|
47
|
+
include_addressed:
|
|
48
|
+
If True, also include annotations already marked as addressed.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
str
|
|
53
|
+
A Markdown-formatted feedback prompt ready to send to an LLM.
|
|
54
|
+
"""
|
|
55
|
+
annotations = [
|
|
56
|
+
a
|
|
57
|
+
for a in session.annotations
|
|
58
|
+
if include_addressed or a.status == AnnotationStatus.pending
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
if not annotations:
|
|
62
|
+
return "No pending feedback annotations."
|
|
63
|
+
|
|
64
|
+
lines: list[str] = []
|
|
65
|
+
lines.append(
|
|
66
|
+
f"## Plot Feedback ({len(annotations)} annotation{'s' if len(annotations) != 1 else ''})\n"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
has_region_annotations = any(a.region is not None for a in annotations)
|
|
70
|
+
has_element_annotations = any(a.element_info is not None for a in annotations)
|
|
71
|
+
|
|
72
|
+
lines.append("### Scope Rules (must follow)\n")
|
|
73
|
+
|
|
74
|
+
if has_region_annotations:
|
|
75
|
+
lines.append(
|
|
76
|
+
"- For **raster-region** annotations, scope is **LOCAL_REGION**. "
|
|
77
|
+
"Treat the attached crop image as authoritative grounding."
|
|
78
|
+
)
|
|
79
|
+
lines.append(
|
|
80
|
+
'- Ambiguous references ("this", "these", "each line", '
|
|
81
|
+
'"the lines") apply only to elements visible inside the selected '
|
|
82
|
+
"region/crop."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if has_element_annotations:
|
|
86
|
+
lines.append(
|
|
87
|
+
"- For **svg-element** annotations, scope is **LOCAL_ELEMENT** by default."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
lines.append(
|
|
91
|
+
"- Do not modify outside local scope unless feedback explicitly asks for "
|
|
92
|
+
'global edits (for example: "all charts", "entire figure", '
|
|
93
|
+
'"across all subplots").'
|
|
94
|
+
)
|
|
95
|
+
lines.append(
|
|
96
|
+
"- Prefer the minimal set of localized changes that satisfies each annotation.\n"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Include the script source if available.
|
|
100
|
+
if session.source_script:
|
|
101
|
+
lines.append("### Current Script\n")
|
|
102
|
+
lines.append("```python")
|
|
103
|
+
lines.append(session.source_script.rstrip())
|
|
104
|
+
lines.append("```\n")
|
|
105
|
+
|
|
106
|
+
lines.append("### Annotations\n")
|
|
107
|
+
for i, ann in enumerate(annotations, 1):
|
|
108
|
+
desc = _describe_element(ann)
|
|
109
|
+
lines.append(f"{i}. **Element**: {desc}")
|
|
110
|
+
if ann.region is not None:
|
|
111
|
+
lines.append(" **Mode**: raster-region")
|
|
112
|
+
lines.append(" **Scope**: LOCAL_REGION")
|
|
113
|
+
bounds = _region_bounds(ann.region)
|
|
114
|
+
if bounds is not None:
|
|
115
|
+
x0, y0, x1, y1 = bounds
|
|
116
|
+
lines.append(
|
|
117
|
+
" **Region (normalized)**: "
|
|
118
|
+
f"({x0:.3f}, {y0:.3f}) -> ({x1:.3f}, {y1:.3f})"
|
|
119
|
+
)
|
|
120
|
+
lines.append(f" **Zone hint**: {_region_zone_hint(ann.region)}")
|
|
121
|
+
lines.append(
|
|
122
|
+
" **Disambiguation**: Ambiguous references resolve only to "
|
|
123
|
+
"elements visible in this region crop."
|
|
124
|
+
)
|
|
125
|
+
elif ann.element_info is not None:
|
|
126
|
+
lines.append(" **Mode**: svg-element")
|
|
127
|
+
lines.append(" **Scope**: LOCAL_ELEMENT")
|
|
128
|
+
lines.append(
|
|
129
|
+
" **Disambiguation**: Ambiguous references resolve to the "
|
|
130
|
+
"selected SVG element."
|
|
131
|
+
)
|
|
132
|
+
lines.append(f' **Feedback**: "{ann.feedback}"\n')
|
|
133
|
+
|
|
134
|
+
lines.append("---")
|
|
135
|
+
lines.append(
|
|
136
|
+
"Please update the script to address all the feedback above. "
|
|
137
|
+
"Return the complete updated script. Apply scope rules first when "
|
|
138
|
+
"feedback wording is ambiguous."
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return "\n".join(lines)
|
openplot/mcp_server.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""MCP stdio server for OpenPlot agent integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
17
|
+
from mcp.server.fastmcp import Image
|
|
18
|
+
|
|
19
|
+
from .domain.annotations import pending_annotation_dicts_for_context
|
|
20
|
+
from .domain.regions import region_bounds_from_points, region_zone_hint_from_bounds
|
|
21
|
+
|
|
22
|
+
PORT_FILE = Path.home() / ".openplot" / "port"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BackendError(RuntimeError):
|
|
26
|
+
"""Raised when the OpenPlot backend request fails."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class BackendClient:
|
|
31
|
+
"""Small HTTP client for talking to the OpenPlot FastAPI backend."""
|
|
32
|
+
|
|
33
|
+
base_url: str
|
|
34
|
+
timeout_s: float = 20.0
|
|
35
|
+
session_id: str | None = None
|
|
36
|
+
|
|
37
|
+
def _with_session(self, path: str) -> str:
|
|
38
|
+
normalized = path.strip()
|
|
39
|
+
if not self.session_id:
|
|
40
|
+
return normalized
|
|
41
|
+
|
|
42
|
+
separator = "&" if "?" in normalized else "?"
|
|
43
|
+
encoded_session_id = quote(self.session_id, safe="")
|
|
44
|
+
return f"{normalized}{separator}session_id={encoded_session_id}"
|
|
45
|
+
|
|
46
|
+
def get(self, path: str) -> dict:
|
|
47
|
+
req = Request(f"{self.base_url}{self._with_session(path)}", method="GET")
|
|
48
|
+
return _request_json(req, timeout_s=self.timeout_s)
|
|
49
|
+
|
|
50
|
+
def post(self, path: str, payload: dict) -> dict:
|
|
51
|
+
data = json.dumps(payload).encode("utf-8")
|
|
52
|
+
req = Request(
|
|
53
|
+
f"{self.base_url}{self._with_session(path)}",
|
|
54
|
+
method="POST",
|
|
55
|
+
data=data,
|
|
56
|
+
headers={"Content-Type": "application/json"},
|
|
57
|
+
)
|
|
58
|
+
return _request_json(req, timeout_s=self.timeout_s)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _decode_data_url(data_url_or_base64: str) -> tuple[str, bytes]:
|
|
62
|
+
"""Decode a data URL (or raw base64 string) into MIME type + bytes."""
|
|
63
|
+
payload = data_url_or_base64.strip()
|
|
64
|
+
mime_type = "image/png"
|
|
65
|
+
|
|
66
|
+
if payload.startswith("data:"):
|
|
67
|
+
try:
|
|
68
|
+
header, payload = payload.split(",", 1)
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise ValueError("Invalid data URL payload") from exc
|
|
71
|
+
|
|
72
|
+
# Example: data:image/png;base64
|
|
73
|
+
meta = header[5:]
|
|
74
|
+
first = meta.split(";", 1)[0].strip().lower()
|
|
75
|
+
if first:
|
|
76
|
+
mime_type = first
|
|
77
|
+
|
|
78
|
+
payload = "".join(payload.split())
|
|
79
|
+
if not payload:
|
|
80
|
+
raise ValueError("Empty base64 payload")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
image_bytes = base64.b64decode(payload, validate=True)
|
|
84
|
+
except binascii.Error:
|
|
85
|
+
# Some encoders omit padding; fallback to permissive decode.
|
|
86
|
+
try:
|
|
87
|
+
image_bytes = base64.b64decode(payload, validate=False)
|
|
88
|
+
except binascii.Error as exc:
|
|
89
|
+
raise ValueError("Invalid base64 image payload") from exc
|
|
90
|
+
|
|
91
|
+
if not image_bytes:
|
|
92
|
+
raise ValueError("Decoded image payload is empty")
|
|
93
|
+
|
|
94
|
+
return mime_type, image_bytes
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _image_format_from_mime(mime_type: str) -> str:
|
|
98
|
+
"""Map MIME type to FastMCP Image format token."""
|
|
99
|
+
normalized = mime_type.strip().lower()
|
|
100
|
+
if "/" in normalized:
|
|
101
|
+
normalized = normalized.split("/", 1)[1]
|
|
102
|
+
normalized = normalized.split(";", 1)[0]
|
|
103
|
+
|
|
104
|
+
if normalized in {"jpg", "jpeg"}:
|
|
105
|
+
return "jpeg"
|
|
106
|
+
if normalized in {"png", "gif", "webp"}:
|
|
107
|
+
return normalized
|
|
108
|
+
return "png"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _request_json(req: Request, *, timeout_s: float) -> dict:
|
|
112
|
+
try:
|
|
113
|
+
with urlopen(req, timeout=timeout_s) as resp:
|
|
114
|
+
body = resp.read().decode("utf-8")
|
|
115
|
+
return json.loads(body) if body else {}
|
|
116
|
+
except HTTPError as exc:
|
|
117
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
118
|
+
raise BackendError(f"HTTP {exc.code} for {req.full_url}: {body}") from exc
|
|
119
|
+
except URLError as exc:
|
|
120
|
+
raise BackendError(
|
|
121
|
+
f"Could not connect to backend at {req.full_url}: {exc}"
|
|
122
|
+
) from exc
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _normalize_optional_env(key: str) -> str | None:
|
|
126
|
+
raw_value = os.getenv(key)
|
|
127
|
+
if raw_value is None:
|
|
128
|
+
return None
|
|
129
|
+
normalized = raw_value.strip()
|
|
130
|
+
return normalized or None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def discover_server_url(explicit_url: str | None = None) -> str:
|
|
134
|
+
"""Resolve the OpenPlot backend URL from explicit arg, env, or port file."""
|
|
135
|
+
if explicit_url:
|
|
136
|
+
return explicit_url.rstrip("/")
|
|
137
|
+
|
|
138
|
+
env_url = os.getenv("OPENPLOT_SERVER_URL")
|
|
139
|
+
if env_url:
|
|
140
|
+
return env_url.rstrip("/")
|
|
141
|
+
|
|
142
|
+
if PORT_FILE.exists():
|
|
143
|
+
raw = PORT_FILE.read_text().strip()
|
|
144
|
+
try:
|
|
145
|
+
port = int(raw)
|
|
146
|
+
except ValueError as exc:
|
|
147
|
+
raise BackendError(f"Invalid port in {PORT_FILE}: {raw!r}") from exc
|
|
148
|
+
return f"http://127.0.0.1:{port}"
|
|
149
|
+
|
|
150
|
+
raise BackendError(
|
|
151
|
+
"Could not find a running OpenPlot server. Start one with "
|
|
152
|
+
"`openplot serve` or set OPENPLOT_SERVER_URL."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def create_mcp_server(server_url: str) -> FastMCP:
|
|
157
|
+
"""Create an MCP server bound to a specific OpenPlot backend URL."""
|
|
158
|
+
client = BackendClient(
|
|
159
|
+
base_url=server_url,
|
|
160
|
+
session_id=_normalize_optional_env("OPENPLOT_SESSION_ID"),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
mcp = FastMCP(
|
|
164
|
+
name="openplot",
|
|
165
|
+
instructions=(
|
|
166
|
+
"Use OpenPlot tools to read pending visual feedback, inspect plot context, "
|
|
167
|
+
"and submit updated plotting scripts. Treat "
|
|
168
|
+
"python_interpreter.available_packages from plot context as a strict "
|
|
169
|
+
"allowlist for third-party imports. Never import a third-party package "
|
|
170
|
+
"that is not listed there. "
|
|
171
|
+
"For raster-region annotations, treat "
|
|
172
|
+
"attached crop images as authoritative local scope and resolve ambiguous "
|
|
173
|
+
"phrases to crop-visible content unless the user explicitly asks for global edits."
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
@mcp.tool(
|
|
178
|
+
name="get_pending_feedback",
|
|
179
|
+
description=(
|
|
180
|
+
"Return the compiled visual feedback prompt for all pending annotations "
|
|
181
|
+
"from the current OpenPlot session."
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
def get_pending_feedback() -> dict:
|
|
185
|
+
return client.get("/api/feedback")
|
|
186
|
+
|
|
187
|
+
@mcp.tool(
|
|
188
|
+
name="get_pending_feedback_with_images",
|
|
189
|
+
description=(
|
|
190
|
+
"Return pending visual feedback with image crops (when available) for "
|
|
191
|
+
"raster region annotations."
|
|
192
|
+
),
|
|
193
|
+
structured_output=False,
|
|
194
|
+
)
|
|
195
|
+
def get_pending_feedback_with_images(max_images: int = 8) -> Any:
|
|
196
|
+
session = client.get("/api/session")
|
|
197
|
+
feedback = client.get("/api/feedback")
|
|
198
|
+
|
|
199
|
+
pending = pending_annotation_dicts_for_context(session)
|
|
200
|
+
script_path = session.get("source_script_path") or "<unknown>"
|
|
201
|
+
plot_type = session.get("plot_type") or "unknown"
|
|
202
|
+
active_branch_id = session.get("active_branch_id") or "<none>"
|
|
203
|
+
checked_out_version_id = session.get("checked_out_version_id") or "<none>"
|
|
204
|
+
target_annotation_id = feedback.get("target_annotation_id") or "<none>"
|
|
205
|
+
|
|
206
|
+
content: list[Any] = [
|
|
207
|
+
"\n".join(
|
|
208
|
+
[
|
|
209
|
+
"## Pending Plot Feedback (Multimodal)",
|
|
210
|
+
f"- Pending annotations: {len(pending)}",
|
|
211
|
+
f"- Plot type: {plot_type}",
|
|
212
|
+
f"- Script path: {script_path}",
|
|
213
|
+
f"- Active branch: {active_branch_id}",
|
|
214
|
+
f"- Checked out version: {checked_out_version_id}",
|
|
215
|
+
f"- FIFO target annotation: {target_annotation_id}",
|
|
216
|
+
"",
|
|
217
|
+
"### Scope Rules (must follow)",
|
|
218
|
+
"- For raster-region annotations, treat attached crop images as authoritative grounding.",
|
|
219
|
+
"- Scope is LOCAL_REGION: ambiguous references apply only to content visible in the selected region/crop.",
|
|
220
|
+
'- Do not modify outside-region content unless feedback explicitly requests global scope ("all charts", "entire figure", "across all subplots").',
|
|
221
|
+
"- Prefer minimal localized edits.",
|
|
222
|
+
"",
|
|
223
|
+
"### Compiled Feedback",
|
|
224
|
+
feedback.get("prompt", "No compiled feedback available."),
|
|
225
|
+
]
|
|
226
|
+
)
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
images_added = 0
|
|
230
|
+
for index, ann in enumerate(pending, start=1):
|
|
231
|
+
ann_id = ann.get("id", "unknown")
|
|
232
|
+
ann_feedback = ann.get("feedback", "")
|
|
233
|
+
region = ann.get("region")
|
|
234
|
+
element = ann.get("element_info")
|
|
235
|
+
|
|
236
|
+
lines = [
|
|
237
|
+
f"### Annotation {index}",
|
|
238
|
+
f"- id: {ann_id}",
|
|
239
|
+
f"- feedback: {ann_feedback}",
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
if element:
|
|
243
|
+
lines.append(f"- mode: svg-element ({element.get('tag', 'unknown')})")
|
|
244
|
+
lines.append("- scope: LOCAL_ELEMENT")
|
|
245
|
+
lines.append(
|
|
246
|
+
"- disambiguation: ambiguous references resolve to the selected element"
|
|
247
|
+
)
|
|
248
|
+
text = element.get("text_content")
|
|
249
|
+
if text:
|
|
250
|
+
lines.append(f"- text: {text}")
|
|
251
|
+
xpath = element.get("xpath")
|
|
252
|
+
if xpath:
|
|
253
|
+
lines.append(f"- xpath: {xpath}")
|
|
254
|
+
|
|
255
|
+
if region:
|
|
256
|
+
lines.append(f"- mode: raster-region ({region.get('type', 'unknown')})")
|
|
257
|
+
lines.append("- scope: LOCAL_REGION")
|
|
258
|
+
lines.append("- grounding: use the attached crop image")
|
|
259
|
+
lines.append(
|
|
260
|
+
"- disambiguation: ambiguous references resolve to crop-visible elements only"
|
|
261
|
+
)
|
|
262
|
+
points = region.get("points")
|
|
263
|
+
bounds = region_bounds_from_points(points)
|
|
264
|
+
if bounds is not None:
|
|
265
|
+
x0, y0, x1, y1 = bounds
|
|
266
|
+
lines.append(
|
|
267
|
+
f"- region(norm): ({x0:.3f}, {y0:.3f}) -> ({x1:.3f}, {y1:.3f})"
|
|
268
|
+
)
|
|
269
|
+
lines.append(f"- zone-hint: {region_zone_hint_from_bounds(bounds)}")
|
|
270
|
+
|
|
271
|
+
content.append("\n".join(lines))
|
|
272
|
+
|
|
273
|
+
crop_data = region.get("crop_base64") if isinstance(region, dict) else None
|
|
274
|
+
if crop_data and images_added < max_images:
|
|
275
|
+
try:
|
|
276
|
+
mime_type, image_bytes = _decode_data_url(crop_data)
|
|
277
|
+
image_format = _image_format_from_mime(mime_type)
|
|
278
|
+
content.append(
|
|
279
|
+
f"Annotation {index} crop image (mime={mime_type}, id={ann_id})"
|
|
280
|
+
)
|
|
281
|
+
content.append(Image(data=image_bytes, format=image_format))
|
|
282
|
+
images_added += 1
|
|
283
|
+
except ValueError as exc:
|
|
284
|
+
content.append(
|
|
285
|
+
f"Annotation {index} crop decode failed: {exc} (id={ann_id})"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
if len(pending) > 0 and images_added == 0:
|
|
289
|
+
content.append(
|
|
290
|
+
"No decodable raster crop images were attached to pending annotations."
|
|
291
|
+
)
|
|
292
|
+
if len(pending) > max_images:
|
|
293
|
+
content.append(
|
|
294
|
+
f"Only the first {max_images} crop images were attached (limit reached)."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
return content
|
|
298
|
+
|
|
299
|
+
@mcp.tool(
|
|
300
|
+
name="get_plot_context",
|
|
301
|
+
description=(
|
|
302
|
+
"Return current session context: script source, plot path/type, annotations, "
|
|
303
|
+
"and revision history."
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
def get_plot_context() -> dict:
|
|
307
|
+
session = client.get("/api/session")
|
|
308
|
+
python_interpreter: dict[str, Any]
|
|
309
|
+
try:
|
|
310
|
+
python_interpreter = client.get("/api/python/interpreter")
|
|
311
|
+
except BackendError as exc:
|
|
312
|
+
python_interpreter = {"error": str(exc)}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
"session_id": session.get("id"),
|
|
316
|
+
"source_script": session.get("source_script"),
|
|
317
|
+
"source_script_path": session.get("source_script_path"),
|
|
318
|
+
"current_plot": session.get("current_plot"),
|
|
319
|
+
"plot_type": session.get("plot_type"),
|
|
320
|
+
"annotations": session.get("annotations", []),
|
|
321
|
+
"versions": session.get("versions", []),
|
|
322
|
+
"branches": session.get("branches", []),
|
|
323
|
+
"root_version_id": session.get("root_version_id"),
|
|
324
|
+
"active_branch_id": session.get("active_branch_id"),
|
|
325
|
+
"checked_out_version_id": session.get("checked_out_version_id"),
|
|
326
|
+
"artifacts_root": session.get("artifacts_root"),
|
|
327
|
+
"revision_history": session.get("revision_history", []),
|
|
328
|
+
"python_interpreter": python_interpreter,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
@mcp.tool(
|
|
332
|
+
name="submit_updated_script",
|
|
333
|
+
description=(
|
|
334
|
+
"Submit an updated Python plotting script to OpenPlot. "
|
|
335
|
+
"OpenPlot executes it, updates the rendered plot, and marks one feedback annotation addressed. "
|
|
336
|
+
"Use annotation_id to target a specific pending annotation when needed."
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
def submit_updated_script(code: str, annotation_id: str | None = None) -> dict:
|
|
340
|
+
if not code.strip():
|
|
341
|
+
raise ValueError("`code` must not be empty.")
|
|
342
|
+
payload: dict[str, Any] = {"code": code}
|
|
343
|
+
if annotation_id:
|
|
344
|
+
payload["annotation_id"] = annotation_id
|
|
345
|
+
return client.post("/api/script", payload)
|
|
346
|
+
|
|
347
|
+
return mcp
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def run_mcp_stdio(server_url: str) -> None:
|
|
351
|
+
"""Run the MCP server over stdio transport."""
|
|
352
|
+
mcp = create_mcp_server(server_url)
|
|
353
|
+
mcp.run(transport="stdio")
|