molcompose-mcp 0.1.1__tar.gz

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.
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2026, MolCompose contributors
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ 3. Neither the name of the copyright holder nor the names of its contributors
15
+ may be used to endorse or promote products derived from this software
16
+ without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: molcompose-mcp
3
+ Version: 0.1.1
4
+ Summary: Agent-safe MCP server exposing MolCompose figure commands in UCSF ChimeraX
5
+ License-Expression: BSD-3-Clause
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: mcp<3,>=2
10
+ Dynamic: license-file
11
+
12
+ # molcompose-mcp
13
+
14
+ An agent-safe [MCP](https://modelcontextprotocol.io) server that exposes the
15
+ MolCompose figure workflow in UCSF ChimeraX as typed tools. Agents call the
16
+ same canonical `molcompose` commands as the GUI panel — versioned presets,
17
+ validated inputs, and a complete provenance record for every exported figure.
18
+
19
+ ## Requirements
20
+
21
+ 1. UCSF ChimeraX 1.12 with the ChimeraX-MolCompose bundle installed.
22
+ 2. Start the REST bridge inside ChimeraX:
23
+
24
+ ```
25
+ remotecontrol rest start port 3000 json true
26
+ ```
27
+
28
+ (Image export requires a windowed ChimeraX on macOS; analysis tools also work
29
+ with `--nogui`.)
30
+
31
+ ## Run
32
+
33
+ ```bash
34
+ pip install molcompose-mcp
35
+ molcompose-mcp --chimerax-url http://127.0.0.1:3000
36
+ ```
37
+
38
+ Register the command above as an MCP stdio server in whichever client you use.
39
+ The protocol is open and this server is not written against any one client; a
40
+ command-line client is enough.
41
+
42
+ Most clients take the same shape of config:
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "molcompose": {
48
+ "command": "molcompose-mcp",
49
+ "args": ["--chimerax-url", "http://127.0.0.1:3000"]
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Check your client's documentation for its configuration-file location rather
56
+ than trusting a path written here, since client conventions change.
57
+
58
+ ## Optional: PythiaStudio ΔΔG
59
+
60
+ `predict_ddg_pythiastudio` fetches binding/stability ΔΔG from the
61
+ [PythiaStudio](https://pythiastudio.wulab.xyz) REST API and writes a tabular
62
+ file that `load_ddg` then loads into ChimeraX. This is the **only networked
63
+ tool** — the bundle itself never calls out, which keeps its zero-service
64
+ dependency profile intact while the agent acts as the integration layer.
65
+ Set `PYTHIASTUDIO_API_KEY` or pass a key explicitly.
66
+
67
+ ## Safety model
68
+
69
+ - Figure work goes through the typed `molcompose` tools only.
70
+ - Native passthrough is limited to an enumerated display-only whitelist
71
+ (`open`, `close`, `view`, `select`, `color`, `show`, `hide`, ...).
72
+ - Coordinate- or file-modifying commands are rejected with a structured error.
73
+ - Every export writes `<figure>.provenance.json` with the client identity,
74
+ timestamps, and the full command recipe.
@@ -0,0 +1,63 @@
1
+ # molcompose-mcp
2
+
3
+ An agent-safe [MCP](https://modelcontextprotocol.io) server that exposes the
4
+ MolCompose figure workflow in UCSF ChimeraX as typed tools. Agents call the
5
+ same canonical `molcompose` commands as the GUI panel — versioned presets,
6
+ validated inputs, and a complete provenance record for every exported figure.
7
+
8
+ ## Requirements
9
+
10
+ 1. UCSF ChimeraX 1.12 with the ChimeraX-MolCompose bundle installed.
11
+ 2. Start the REST bridge inside ChimeraX:
12
+
13
+ ```
14
+ remotecontrol rest start port 3000 json true
15
+ ```
16
+
17
+ (Image export requires a windowed ChimeraX on macOS; analysis tools also work
18
+ with `--nogui`.)
19
+
20
+ ## Run
21
+
22
+ ```bash
23
+ pip install molcompose-mcp
24
+ molcompose-mcp --chimerax-url http://127.0.0.1:3000
25
+ ```
26
+
27
+ Register the command above as an MCP stdio server in whichever client you use.
28
+ The protocol is open and this server is not written against any one client; a
29
+ command-line client is enough.
30
+
31
+ Most clients take the same shape of config:
32
+
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "molcompose": {
37
+ "command": "molcompose-mcp",
38
+ "args": ["--chimerax-url", "http://127.0.0.1:3000"]
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ Check your client's documentation for its configuration-file location rather
45
+ than trusting a path written here, since client conventions change.
46
+
47
+ ## Optional: PythiaStudio ΔΔG
48
+
49
+ `predict_ddg_pythiastudio` fetches binding/stability ΔΔG from the
50
+ [PythiaStudio](https://pythiastudio.wulab.xyz) REST API and writes a tabular
51
+ file that `load_ddg` then loads into ChimeraX. This is the **only networked
52
+ tool** — the bundle itself never calls out, which keeps its zero-service
53
+ dependency profile intact while the agent acts as the integration layer.
54
+ Set `PYTHIASTUDIO_API_KEY` or pass a key explicitly.
55
+
56
+ ## Safety model
57
+
58
+ - Figure work goes through the typed `molcompose` tools only.
59
+ - Native passthrough is limited to an enumerated display-only whitelist
60
+ (`open`, `close`, `view`, `select`, `color`, `show`, `hide`, ...).
61
+ - Coordinate- or file-modifying commands are rejected with a structured error.
62
+ - Every export writes `<figure>.provenance.json` with the client identity,
63
+ timestamps, and the full command recipe.
@@ -0,0 +1,3 @@
1
+ """Agent-safe MCP server for MolCompose in UCSF ChimeraX."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,125 @@
1
+ """Minimal HTTP client for the ChimeraX ``remotecontrol rest`` bridge."""
2
+
3
+ import json
4
+ import re
5
+ from urllib.error import URLError
6
+ from urllib.parse import urlencode
7
+ from urllib.request import urlopen
8
+
9
+ # ChimeraX writes its log for a GUI panel: every command it echoes is wrapped
10
+ # in a clickable link, and the whole command text is repeated inside the href.
11
+ # In a `characterise` on 1BRS that markup is 56% of the reply — 6.2 kB of
12
+ # duplicated pbond syntax an agent must read past to reach the findings.
13
+ _LOG_LINK = re.compile(r"\[(?P<label>[^\]]*)\]\((?:cxcmd|help):[^)]*\)", re.DOTALL)
14
+
15
+
16
+ class ChimeraXUnavailable(RuntimeError):
17
+ """Raised when the ChimeraX REST bridge cannot be reached."""
18
+
19
+
20
+ class ChimeraXClient:
21
+ def __init__(self, base_url: str = "http://127.0.0.1:3000", timeout: float = 120.0):
22
+ self.base_url = base_url.rstrip("/")
23
+ self.timeout = timeout
24
+
25
+ def run(self, command: str) -> dict:
26
+ """Run one ChimeraX command; return parsed JSON or {'raw': text}."""
27
+ url = f"{self.base_url}/run?{urlencode({'command': command})}"
28
+ try:
29
+ with urlopen(url, timeout=self.timeout) as response: # noqa: S310 - localhost bridge
30
+ text = response.read().decode("utf-8", errors="replace")
31
+ except (URLError, OSError) as error:
32
+ raise ChimeraXUnavailable(
33
+ "Cannot reach ChimeraX. Start ChimeraX (windowed for image export), "
34
+ "make sure ChimeraX-MolCompose is installed, and run: "
35
+ "remotecontrol rest start port 3000 json true "
36
+ f"(tried {self.base_url}; error: {error})"
37
+ ) from error
38
+ try:
39
+ return json.loads(text)
40
+ except ValueError:
41
+ return {"raw": text}
42
+
43
+
44
+ #: What `command_value` returns when the bridge cannot supply a return value.
45
+ #: Distinct from ``None``, which is a return value a command may legitimately
46
+ #: have — telling the two apart is the difference between "this command
47
+ #: reported nothing" and "this bridge cannot report anything".
48
+ NO_VALUE = object()
49
+
50
+
51
+ def log_text(payload: dict) -> str:
52
+ """Flatten whatever log structure the REST bridge returned into one string.
53
+
54
+ The command's *return value* is deliberately not folded in here. It used to
55
+ be, as ``str(payload["python values"])``, which put a Python repr of the
56
+ result on the end of the log — duplicating findings the command had already
57
+ logged, and leaving the structured value reachable only by parsing it back
58
+ out again. `command_value` returns it as data instead.
59
+ """
60
+ if "raw" in payload:
61
+ return str(payload["raw"])
62
+ parts = []
63
+ messages = payload.get("log messages")
64
+ if isinstance(messages, dict):
65
+ for level_messages in messages.values():
66
+ if isinstance(level_messages, list):
67
+ parts.extend(str(message) for message in level_messages)
68
+ elif isinstance(messages, list):
69
+ parts.extend(str(message) for message in messages)
70
+ error = payload.get("error")
71
+ if error:
72
+ parts.append(str(error))
73
+ return unlink("\n".join(parts))
74
+
75
+
76
+ def command_value(payload: dict):
77
+ """The command's return value, as data, or `NO_VALUE` if none is available.
78
+
79
+ ChimeraX's REST bridge serialises each command's return value when it is
80
+ started with ``json true`` — ``remotecontrol rest start port 3000 json
81
+ true``, which is what the docked panel and every documented launch line
82
+ use. A bridge started without it replies in plain text, there is no return
83
+ value to be had, and callers get `NO_VALUE` rather than a fabricated one.
84
+
85
+ Two keys can carry it. ``json values`` holds the JSON form of commands that
86
+ opt into ChimeraX's ``JSONResult`` protocol, and is preferred; ``python
87
+ values`` holds everything else, run through the bridge's own
88
+ ``make_json_friendly``, which recurses into dicts, lists and tuples. Both
89
+ are lists, one entry per command in the request, and MolCompose sends one
90
+ command per request.
91
+ """
92
+ if "raw" in payload:
93
+ return NO_VALUE
94
+ for key in ("json values", "python values"):
95
+ values = payload.get(key)
96
+ if isinstance(values, list) and len(values) == 1 and values[0] is not None:
97
+ return values[0]
98
+ return NO_VALUE
99
+
100
+
101
+ def unlink(text: str) -> str:
102
+ """Strip the ChimeraX log's link markup and the repetition it creates.
103
+
104
+ Every echoed command appears twice: once as ``[pbond](help:...)`` followed
105
+ by its arguments, then again whole inside a ``cxcmd:`` href. Unwrapping the
106
+ links leaves the two forms identical, so the second is dropped. Only exact
107
+ repeats go — the label is always kept, and text that never had a link is
108
+ returned unchanged.
109
+
110
+ The two copies are wrapped independently to the log panel's width, and
111
+ ChimeraX breaks an over-long token by inserting a hyphen, so one copy can
112
+ read ``mc-pi-\\nstacking`` where the other reads ``mc-pi-stacking``.
113
+ Comparison therefore ignores whitespace entirely; the line breaks
114
+ themselves are left as ChimeraX made them, since an inserted hyphen cannot
115
+ be told apart from one belonging to the name.
116
+ """
117
+ blocks, previous = [], None
118
+ for block in _LOG_LINK.sub(r"\g<label>", text).split("\n\n"):
119
+ if not block.strip():
120
+ continue
121
+ collapsed = "".join(block.split())
122
+ if collapsed != previous:
123
+ blocks.append(block.strip())
124
+ previous = collapsed
125
+ return "\n\n".join(blocks)
@@ -0,0 +1,206 @@
1
+ """A styled local web console for ChimeraX, replacing the bare REST test page.
2
+
3
+ Serves a single-page command console on localhost and proxies commands to the
4
+ ChimeraX ``remotecontrol rest`` bridge (same-origin, so no CORS issues). This
5
+ is the *human* command line — unlike the agent tool surface it applies no
6
+ whitelist, exactly like ChimeraX's own command prompt.
7
+ """
8
+
9
+ import argparse
10
+ import webbrowser
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from urllib.parse import parse_qs, urlencode, urlparse
13
+ from urllib.request import urlopen
14
+
15
+ CONSOLE_HTML = """<!DOCTYPE html>
16
+ <html lang="en">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1">
20
+ <title>MolCompose Console</title>
21
+ <style>
22
+ :root {
23
+ --accent: #4F46E5; --accent-dark: #4338CA; --bg: #F1F2F7;
24
+ --card: #ffffff; --border: #E7E9F0; --ink: #2f323a; --hint: #7c7f88;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ body {
28
+ margin: 0; background: var(--bg); color: var(--ink);
29
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
30
+ display: flex; flex-direction: column; align-items: center;
31
+ min-height: 100vh; padding: 24px;
32
+ }
33
+ .shell { width: min(860px, 100%); display: flex; flex-direction: column; gap: 14px; }
34
+ header { display: flex; align-items: center; gap: 10px; }
35
+ .dot { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); }
36
+ h1 { font-size: 18px; margin: 0; font-weight: 700; }
37
+ .sub { color: var(--hint); font-size: 12px; margin-left: auto; }
38
+ #output {
39
+ background: #1F2430; color: #D9DCE3; border-radius: 12px;
40
+ padding: 16px; height: 52vh; overflow-y: auto;
41
+ font-family: Menlo, Consolas, monospace; font-size: 13px; line-height: 1.55;
42
+ white-space: pre-wrap; word-break: break-word;
43
+ }
44
+ #output .cmd { color: #A5B4FC; font-weight: 600; }
45
+ #output .err { color: #FCA5A5; }
46
+ #output .hint { color: #8A8D96; }
47
+ .inputrow {
48
+ display: flex; gap: 8px; background: var(--card);
49
+ border: 1px solid var(--border); border-radius: 12px; padding: 10px;
50
+ }
51
+ #command {
52
+ flex: 1; border: none; outline: none; font-family: Menlo, Consolas, monospace;
53
+ font-size: 14px; color: var(--ink); background: transparent;
54
+ }
55
+ button {
56
+ border: none; border-radius: 9px; padding: 9px 18px; cursor: pointer;
57
+ background: var(--accent); color: white; font-weight: 600; font-size: 13px;
58
+ }
59
+ button:hover { background: var(--accent-dark); }
60
+ .chips { display: flex; gap: 6px; flex-wrap: wrap; }
61
+ .chip {
62
+ background: #EEF2FF; color: var(--accent); border-radius: 14px;
63
+ padding: 5px 12px; font-size: 12px; font-weight: 600; cursor: pointer;
64
+ }
65
+ .chip:hover { background: #E0E7FF; }
66
+ </style>
67
+ </head>
68
+ <body>
69
+ <div class="shell">
70
+ <header>
71
+ <div class="dot"></div>
72
+ <h1>MolCompose Console</h1>
73
+ <div class="sub">ChimeraX command line · Up/Down for history</div>
74
+ </header>
75
+ <div id="output"><span class="hint">Commands run inside ChimeraX.
76
+ Try a chip below, or any ChimeraX / molcompose command.</span>
77
+ </div>
78
+ <div class="chips" id="chips">
79
+ <span class="chip">open 1brs</span>
80
+ <span class="chip">molcompose style complex-by-chain model #1</span>
81
+ <span class="chip">molcompose interface A D model #1 distance 4.5</span>
82
+ <span class="chip">molcompose style interface-focus model #1</span>
83
+ <span class="chip">molcompose confidence model #1</span>
84
+ </div>
85
+ <div class="inputrow">
86
+ <input id="command" placeholder="ChimeraX command…" autocomplete="off" autofocus>
87
+ <button id="run">Run</button>
88
+ </div>
89
+ </div>
90
+ <script>
91
+ const output = document.getElementById("output");
92
+ const input = document.getElementById("command");
93
+ const history = [];
94
+ let cursor = -1;
95
+
96
+ function append(text, cls) {
97
+ const span = document.createElement("span");
98
+ if (cls) span.className = cls;
99
+ span.textContent = text + "\\n";
100
+ output.appendChild(span);
101
+ output.scrollTop = output.scrollHeight;
102
+ }
103
+
104
+ function flatten(data) {
105
+ const parts = [];
106
+ const logs = data["log messages"];
107
+ if (logs) for (const level of Object.values(logs)) {
108
+ if (Array.isArray(level)) parts.push(...level.map(String));
109
+ }
110
+ if (data["error"]) parts.push("ERROR: " + data["error"]);
111
+ return parts.join("\\n").replace(/<[^>]*>/g, "").trim();
112
+ }
113
+
114
+ async function run(command) {
115
+ if (!command.trim()) return;
116
+ append("❯ " + command, "cmd");
117
+ history.unshift(command); cursor = -1;
118
+ try {
119
+ const response = await fetch("/run?" + new URLSearchParams({command}));
120
+ const data = await response.json();
121
+ const text = flatten(data);
122
+ if (text) append(text, data["error"] ? "err" : "");
123
+ } catch (error) {
124
+ append("Cannot reach ChimeraX — is the bridge running? " +
125
+ "(remotecontrol rest start port 3000 json true)", "err");
126
+ }
127
+ input.value = "";
128
+ }
129
+
130
+ document.getElementById("run").onclick = () => run(input.value);
131
+ input.addEventListener("keydown", (event) => {
132
+ if (event.key === "Enter") run(input.value);
133
+ if (event.key === "ArrowUp" && cursor < history.length - 1) {
134
+ cursor += 1; input.value = history[cursor];
135
+ }
136
+ if (event.key === "ArrowDown" && cursor > 0) {
137
+ cursor -= 1; input.value = history[cursor];
138
+ }
139
+ });
140
+ document.getElementById("chips").addEventListener("click", (event) => {
141
+ if (event.target.classList.contains("chip")) run(event.target.textContent);
142
+ });
143
+ </script>
144
+ </body>
145
+ </html>
146
+ """
147
+
148
+
149
+ def build_proxy_url(chimerax_url: str, command: str) -> str:
150
+ return f"{chimerax_url.rstrip('/')}/run?{urlencode({'command': command})}"
151
+
152
+
153
+ class ConsoleHandler(BaseHTTPRequestHandler):
154
+ chimerax_url = "http://127.0.0.1:3000"
155
+
156
+ def _send(self, status: int, content_type: str, body: bytes) -> None:
157
+ self.send_response(status)
158
+ self.send_header("Content-Type", content_type)
159
+ self.send_header("Content-Length", str(len(body)))
160
+ self.end_headers()
161
+ self.wfile.write(body)
162
+
163
+ def do_GET(self): # noqa: N802 - http.server API
164
+ parsed = urlparse(self.path)
165
+ if parsed.path == "/":
166
+ self._send(200, "text/html; charset=utf-8", CONSOLE_HTML.encode())
167
+ return
168
+ if parsed.path == "/run":
169
+ command = parse_qs(parsed.query).get("command", [""])[0]
170
+ try:
171
+ with urlopen( # noqa: S310 - localhost bridge
172
+ build_proxy_url(self.chimerax_url, command), timeout=300
173
+ ) as response:
174
+ body = response.read()
175
+ self._send(200, "application/json", body)
176
+ except OSError as error:
177
+ self._send(502, "application/json", f'{{"error": "{error}"}}'.encode())
178
+ return
179
+ self._send(404, "text/plain", b"not found")
180
+
181
+ def log_message(self, *args): # quiet server
182
+ return
183
+
184
+
185
+ def main() -> None:
186
+ parser = argparse.ArgumentParser(description="MolCompose web console for ChimeraX")
187
+ parser.add_argument("--port", type=int, default=8642)
188
+ parser.add_argument(
189
+ "--chimerax-url",
190
+ default="http://127.0.0.1:3000",
191
+ help="Base URL of the ChimeraX REST bridge (remotecontrol rest)",
192
+ )
193
+ parser.add_argument("--no-browser", action="store_true")
194
+ args = parser.parse_args()
195
+
196
+ ConsoleHandler.chimerax_url = args.chimerax_url
197
+ server = ThreadingHTTPServer(("127.0.0.1", args.port), ConsoleHandler)
198
+ address = f"http://127.0.0.1:{args.port}/"
199
+ print(f"MolCompose Console on {address} (bridge: {args.chimerax_url})")
200
+ if not args.no_browser:
201
+ webbrowser.open(address)
202
+ server.serve_forever()
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
@@ -0,0 +1,141 @@
1
+ """Optional PythiaStudio REST client, kept out of the ChimeraX bundle.
2
+
3
+ The bundle itself never touches the network: it consumes ΔΔG *files*. This
4
+ module lets an agent fetch predictions from PythiaStudio and write them to a
5
+ file that `molcompose ddg` can then load — so the agent, not the bundle,
6
+ becomes the integration layer, and the bundle keeps its zero-service
7
+ dependency profile.
8
+
9
+ API keys are supplied by the user (environment variable `PYTHIASTUDIO_API_KEY`
10
+ or an explicit argument) and are never logged.
11
+ """
12
+
13
+ import csv
14
+ import io
15
+ import json
16
+ import os
17
+ from urllib.error import HTTPError, URLError
18
+ from urllib.request import Request, urlopen
19
+
20
+ DEFAULT_BASE_URL = "https://pythiastudio.wulab.xyz"
21
+ ENDPOINTS = {
22
+ "pythia-ppi": "/api/tools/pythia-ppi/predict",
23
+ "pythia": "/api/tools/pythia/scan",
24
+ }
25
+
26
+
27
+ class PythiaStudioError(RuntimeError):
28
+ pass
29
+
30
+
31
+ def api_key(explicit: str | None = None) -> str:
32
+ key = explicit or os.environ.get("PYTHIASTUDIO_API_KEY", "")
33
+ if not key:
34
+ raise PythiaStudioError(
35
+ "no PythiaStudio API key: set PYTHIASTUDIO_API_KEY or pass one "
36
+ "explicitly (request a key from the PythiaStudio maintainers)"
37
+ )
38
+ return key
39
+
40
+
41
+ def request_prediction(
42
+ structure_path: str,
43
+ tool: str = "pythia-ppi",
44
+ base_url: str = DEFAULT_BASE_URL,
45
+ key: str | None = None,
46
+ timeout: float = 600.0,
47
+ ) -> dict:
48
+ """POST a structure to PythiaStudio and return the decoded JSON response."""
49
+ if tool not in ENDPOINTS:
50
+ raise PythiaStudioError(
51
+ f"tool must be one of {', '.join(sorted(ENDPOINTS))}: {tool}"
52
+ )
53
+ try:
54
+ structure = open(structure_path, "rb").read() # noqa: SIM115
55
+ except OSError as error:
56
+ raise PythiaStudioError(f"cannot read structure file: {error}") from error
57
+
58
+ boundary = "----molcompose-mcp-boundary"
59
+ name = os.path.basename(structure_path)
60
+ body = b"".join(
61
+ [
62
+ f"--{boundary}\r\n".encode(),
63
+ f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'.encode(),
64
+ b"Content-Type: application/octet-stream\r\n\r\n",
65
+ structure,
66
+ f"\r\n--{boundary}--\r\n".encode(),
67
+ ]
68
+ )
69
+ request = Request( # noqa: S310 - fixed https endpoint
70
+ f"{base_url.rstrip('/')}{ENDPOINTS[tool]}",
71
+ data=body,
72
+ headers={
73
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
74
+ "Authorization": f"Bearer {api_key(key)}",
75
+ },
76
+ method="POST",
77
+ )
78
+ try:
79
+ with urlopen(request, timeout=timeout) as response: # noqa: S310
80
+ return json.loads(response.read().decode("utf-8"))
81
+ except HTTPError as error:
82
+ raise PythiaStudioError(
83
+ f"PythiaStudio returned HTTP {error.code}; check the API key and quota"
84
+ ) from error
85
+ except (URLError, OSError, ValueError) as error:
86
+ raise PythiaStudioError(f"PythiaStudio request failed: {error}") from error
87
+
88
+
89
+ def to_tabular(payload: dict) -> str:
90
+ """Flatten a PythiaStudio mutation map into the tabular ΔΔG format.
91
+
92
+ The exact response schema is not published, so several plausible shapes are
93
+ accepted; anything unrecognised raises with the observed keys so the caller
94
+ can report the real format.
95
+ """
96
+ records = None
97
+ for key in ("mutations", "results", "predictions", "data"):
98
+ value = payload.get(key)
99
+ if isinstance(value, list) and value:
100
+ records = value
101
+ break
102
+ if records is None:
103
+ raise PythiaStudioError(
104
+ "could not find a mutation list in the PythiaStudio response; "
105
+ f"top-level keys were: {', '.join(sorted(payload))}"
106
+ )
107
+
108
+ buffer = io.StringIO()
109
+ writer = csv.writer(buffer)
110
+ writer.writerow(["chain", "position", "wt", "mut", "ddG"])
111
+ written = 0
112
+ for record in records:
113
+ if not isinstance(record, dict):
114
+ continue
115
+ lowered = {str(k).lower(): v for k, v in record.items()}
116
+
117
+ def pick(*names, mapping=lowered):
118
+ for name in names:
119
+ if mapping.get(name) not in (None, ""):
120
+ return mapping[name]
121
+ return ""
122
+
123
+ position = pick("position", "pos", "resnum", "residue")
124
+ ddg = pick("ddg", "ddG".lower(), "score", "prediction", "value")
125
+ if position == "" or ddg == "":
126
+ continue
127
+ writer.writerow(
128
+ [
129
+ pick("chain", "chain_id"),
130
+ position,
131
+ str(pick("wt", "wild_type", "from")).upper(),
132
+ str(pick("mut", "mutant", "to")).upper(),
133
+ ddg,
134
+ ]
135
+ )
136
+ written += 1
137
+ if not written:
138
+ raise PythiaStudioError(
139
+ "the PythiaStudio response contained no usable mutation records"
140
+ )
141
+ return buffer.getvalue()