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.
- molcompose_mcp-0.1.1/LICENSE +27 -0
- molcompose_mcp-0.1.1/PKG-INFO +74 -0
- molcompose_mcp-0.1.1/README.md +63 -0
- molcompose_mcp-0.1.1/molcompose_mcp/__init__.py +3 -0
- molcompose_mcp-0.1.1/molcompose_mcp/chimerax.py +125 -0
- molcompose_mcp-0.1.1/molcompose_mcp/console.py +206 -0
- molcompose_mcp-0.1.1/molcompose_mcp/pythiastudio.py +141 -0
- molcompose_mcp-0.1.1/molcompose_mcp/recipe.py +54 -0
- molcompose_mcp-0.1.1/molcompose_mcp/server.py +1221 -0
- molcompose_mcp-0.1.1/molcompose_mcp/validate.py +111 -0
- molcompose_mcp-0.1.1/molcompose_mcp.egg-info/PKG-INFO +74 -0
- molcompose_mcp-0.1.1/molcompose_mcp.egg-info/SOURCES.txt +21 -0
- molcompose_mcp-0.1.1/molcompose_mcp.egg-info/dependency_links.txt +1 -0
- molcompose_mcp-0.1.1/molcompose_mcp.egg-info/entry_points.txt +3 -0
- molcompose_mcp-0.1.1/molcompose_mcp.egg-info/requires.txt +1 -0
- molcompose_mcp-0.1.1/molcompose_mcp.egg-info/top_level.txt +1 -0
- molcompose_mcp-0.1.1/pyproject.toml +20 -0
- molcompose_mcp-0.1.1/setup.cfg +4 -0
- molcompose_mcp-0.1.1/tests/test_console.py +16 -0
- molcompose_mcp-0.1.1/tests/test_pythiastudio.py +77 -0
- molcompose_mcp-0.1.1/tests/test_server_pure.py +553 -0
- molcompose_mcp-0.1.1/tests/test_server_wiring.py +117 -0
- molcompose_mcp-0.1.1/tests/test_validate.py +99 -0
|
@@ -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,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()
|