polyscript-pypi 0.1.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.
- MCP/server.py +770 -0
- polyscript/__init__.py +12 -0
- polyscript/brics/BRICSMod.py +1164 -0
- polyscript/brics/BRICS_utils.py +189 -0
- polyscript/brics/__init__.py +48 -0
- polyscript/classifier/__init__.py +270 -0
- polyscript/classifier/class_reference.py +951 -0
- polyscript/depolymerizer/__init__.py +576 -0
- polyscript/depolymerizer/mpi.py +390 -0
- polyscript/depolymerizer/reaction_reference.py +447 -0
- polyscript/polymerizer/__init__.py +22 -0
- polyscript/polymerizer/core.py +648 -0
- polyscript/polymerizer/mpi.py +860 -0
- polyscript/polymerizer/mpi_df.py +378 -0
- polyscript/polymerizer/utils.py +795 -0
- polyscript/utils/__init__.py +80 -0
- polyscript/utils/adapters.py +357 -0
- polyscript/utils/canon_analyze.py +193 -0
- polyscript/utils/executor.py +66 -0
- polyscript/utils/logger.py +331 -0
- polyscript/utils/mpi/__init__.py +33 -0
- polyscript/utils/mpi/checkpoint.py +263 -0
- polyscript/utils/mpi/logger.py +138 -0
- polyscript/utils/mpi/progress.py +134 -0
- polyscript/utils/mpi/protocol.py +113 -0
- polyscript/utils/mpi/resource.py +94 -0
- polyscript/utils/mpi/splitter.py +42 -0
- polyscript/utils/mpi_executor.py +167 -0
- polyscript/utils/mpi_validator.py +940 -0
- polyscript/utils/parsers.py +177 -0
- polyscript/utils/validators.py +201 -0
- polyscript_pypi-0.1.0.dist-info/METADATA +82 -0
- polyscript_pypi-0.1.0.dist-info/RECORD +57 -0
- polyscript_pypi-0.1.0.dist-info/WHEEL +5 -0
- polyscript_pypi-0.1.0.dist-info/licenses/LICENSE +21 -0
- polyscript_pypi-0.1.0.dist-info/top_level.txt +4 -0
- runners/adapt_dataframe.py +105 -0
- runners/brics_decompose.py +28 -0
- runners/canonicalization.py +46 -0
- runners/filter_candidates.py +54 -0
- runners/mpi_depolymerize.py +51 -0
- runners/mpi_runner.py +66 -0
- runners/mpi_runner_df.py +49 -0
- runners/mpi_validator.py +45 -0
- tests/conftest.py +10 -0
- tests/sample_gen.py +10 -0
- tests/test_brics.py +275 -0
- tests/test_classifier.py +201 -0
- tests/test_mpi_polymerize.py +170 -0
- tests/test_polymerize.py +206 -0
- tests/utils/test_adaptors.py +182 -0
- tests/utils/test_canonicalization.py +62 -0
- tests/utils/test_executor.py +101 -0
- tests/utils/test_logger.py +426 -0
- tests/utils/test_mpi_validator.py +159 -0
- tests/utils/test_parsers.py +150 -0
- tests/utils/test_validator.py +105 -0
MCP/server.py
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MCP Server for PolyScript — polymer chemistry sequence generation, parsing,
|
|
4
|
+
classification, validation, and depolymerization.
|
|
5
|
+
|
|
6
|
+
Exposes tools via FastMCP for AI agents to work with PolyScript sequences:
|
|
7
|
+
- Parse PolyScript into monomers/reaction/polymer
|
|
8
|
+
- Validate chemical correctness of a PolyScript sequence
|
|
9
|
+
|
|
10
|
+
Note: WE DO NOT PROVIDE EXACT EXAMPLES IN THE DOCUMENTATION, BECAUSE THE LLM REPLICATES THE EXAMPLES
|
|
11
|
+
OR HALLUCINATES BASED ON THE PROPER POLYSCRIPT EXAMPLE RATHER GENERATING NOVEL SEQUENCES.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# - Classify monomers by functional-group type
|
|
16
|
+
# - Polymerize: generate polymer sequences from monomer pairs
|
|
17
|
+
# - Depolymerize: recover monomers from polymer SMILES via reverse reaction
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import tempfile
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
import pandas as pd
|
|
27
|
+
from mcp.server.fastmcp import FastMCP
|
|
28
|
+
|
|
29
|
+
# ── PolyScript imports ───────────────────────────────────────────────────────
|
|
30
|
+
from polyscript.classifier import PolyScriptClassifier
|
|
31
|
+
from polyscript.depolymerizer import PolyScriptDepolymerizer, DepolymerizeResult
|
|
32
|
+
from polyscript.polymerizer import Polymerizer
|
|
33
|
+
from polyscript.utils.parsers import PolyScriptParser, NestedPolyScriptParser
|
|
34
|
+
from polyscript.utils.validators import SeqValidator
|
|
35
|
+
from polyscript.utils.executor import BaseExecutor
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Global server
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
mcp = FastMCP("polyscript")
|
|
41
|
+
|
|
42
|
+
# ── Lazy / cached singletons ────────────────────────────────────────────────
|
|
43
|
+
_classifier: PolyScriptClassifier | None = None
|
|
44
|
+
_depolymerizer: PolyScriptDepolymerizer | None = None
|
|
45
|
+
_parser: PolyScriptParser | None = None
|
|
46
|
+
_nested_parser: NestedPolyScriptParser | None = None
|
|
47
|
+
_validator: SeqValidator | None = None
|
|
48
|
+
_polymerizer: Polymerizer | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _quiet() -> None:
|
|
52
|
+
"""Mute all constructors so they don't print to stderr by default."""
|
|
53
|
+
BaseExecutor.set_log_level("SILENT")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_parser() -> PolyScriptParser:
|
|
57
|
+
global _parser
|
|
58
|
+
if _parser is None:
|
|
59
|
+
_quiet()
|
|
60
|
+
_parser = PolyScriptParser()
|
|
61
|
+
return _parser
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_nested_parser() -> NestedPolyScriptParser:
|
|
65
|
+
global _nested_parser
|
|
66
|
+
if _nested_parser is None:
|
|
67
|
+
_quiet()
|
|
68
|
+
_nested_parser = NestedPolyScriptParser()
|
|
69
|
+
return _nested_parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _get_validator() -> SeqValidator:
|
|
73
|
+
global _validator
|
|
74
|
+
if _validator is None:
|
|
75
|
+
_quiet()
|
|
76
|
+
_validator = SeqValidator()
|
|
77
|
+
return _validator
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_classifier() -> PolyScriptClassifier:
|
|
81
|
+
global _classifier
|
|
82
|
+
if _classifier is None:
|
|
83
|
+
_quiet()
|
|
84
|
+
_classifier = PolyScriptClassifier(include_carbonate=False)
|
|
85
|
+
return _classifier
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_depolymerizer() -> PolyScriptDepolymerizer:
|
|
89
|
+
global _depolymerizer
|
|
90
|
+
if _depolymerizer is None:
|
|
91
|
+
_quiet()
|
|
92
|
+
_depolymerizer = PolyScriptDepolymerizer()
|
|
93
|
+
return _depolymerizer
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _get_polymerizer() -> Polymerizer:
|
|
97
|
+
global _polymerizer
|
|
98
|
+
if _polymerizer is None:
|
|
99
|
+
_quiet()
|
|
100
|
+
_polymerizer = Polymerizer()
|
|
101
|
+
return _polymerizer
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _depol_result_to_dict(r: DepolymerizeResult) -> dict:
|
|
105
|
+
return {
|
|
106
|
+
"polymer": r.polymer,
|
|
107
|
+
"reaction": r.reaction,
|
|
108
|
+
"monomers": r.monomers,
|
|
109
|
+
"input_monomers": r.input_monomers,
|
|
110
|
+
"polymer_type": r.polymer_type,
|
|
111
|
+
"polymer_pattern": r.polymer_pattern,
|
|
112
|
+
"seq_index": r.seq_index,
|
|
113
|
+
"is_exact_match": r.is_exact_match,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
118
|
+
# Tools
|
|
119
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Parse ────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@mcp.tool()
|
|
126
|
+
def parse_single_sequence(sequence: str) -> dict:
|
|
127
|
+
"""
|
|
128
|
+
PolyScript is a library that handles a specific form of polymer
|
|
129
|
+
representation expressed in a character sequence.
|
|
130
|
+
|
|
131
|
+
This function parses a **single** PolyScript sequence into its
|
|
132
|
+
components. It does **not** handle multiple sequences (use
|
|
133
|
+
``parse_nested_sequence`` or ``batch_validate_sequences`` for
|
|
134
|
+
``|||``-separated inputs).
|
|
135
|
+
|
|
136
|
+
.. note::
|
|
137
|
+
|
|
138
|
+
``{...}`` is literal PolyScript syntax (required). ``<...>``
|
|
139
|
+
tags are placeholders — replace them with actual SMILES/SMARTS.
|
|
140
|
+
|
|
141
|
+
.. important::
|
|
142
|
+
|
|
143
|
+
The polymer **must** be a PSMILES containing ``*`` as wildcard
|
|
144
|
+
atoms (polymer attachment points). The ``*`` positions must be
|
|
145
|
+
consistent with the atom mappings in the reaction SMARTS.
|
|
146
|
+
|
|
147
|
+
Format
|
|
148
|
+
------
|
|
149
|
+
``{<monomer1>}+{<monomer2>}=>{<reaction>}=>{<polymer>}``
|
|
150
|
+
|
|
151
|
+
Example
|
|
152
|
+
-------
|
|
153
|
+
Valid
|
|
154
|
+
input: "{<monomer1>}+{<monomer2>}=>{<reaction>}=>{<polymer>}"
|
|
155
|
+
output: {"monomers": ["<monomer1>", "<monomer2>"], "reaction": "<reaction>", "polymer": "<polymer>", "errors": [], "valid": true}
|
|
156
|
+
|
|
157
|
+
Invalid
|
|
158
|
+
input: "{<monomer1>}+{<monomer2>}<bad_delim>{<reaction>}=>{<polymer>}"
|
|
159
|
+
output: {"monomers": null, "reaction": null, "polymer": null, "errors": ["<E-parse-|invalid-number-of-parts|>"], "valid": false}
|
|
160
|
+
"""
|
|
161
|
+
parser = _get_parser()
|
|
162
|
+
errors = parser.parse(sequence)
|
|
163
|
+
return {
|
|
164
|
+
"monomers": parser.monomers,
|
|
165
|
+
"reaction": parser.reaction,
|
|
166
|
+
"polymer": parser.polymer,
|
|
167
|
+
"errors": errors,
|
|
168
|
+
"valid": len(errors) == 0,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@mcp.tool()
|
|
173
|
+
def parse_nested_sequence(sequence: str) -> dict:
|
|
174
|
+
"""Parse multiple PolyScript sub-sequences separated by '|||'.
|
|
175
|
+
|
|
176
|
+
Each sub-sequence is parsed independently. Returns a list of
|
|
177
|
+
(monomers, reaction, polymer) tuples and per-sub-sequence errors.
|
|
178
|
+
|
|
179
|
+
.. note::
|
|
180
|
+
|
|
181
|
+
In the examples below ``{...}`` is literal PolyScript syntax
|
|
182
|
+
(required). ``<...>`` tags are placeholders — they are **not**
|
|
183
|
+
part of the format. Replace them with actual SMILES/SMARTS.
|
|
184
|
+
|
|
185
|
+
.. important::
|
|
186
|
+
|
|
187
|
+
The polymer **must** be a PSMILES containing ``*`` wildcards
|
|
188
|
+
consistent with the reaction SMARTS atom mappings.
|
|
189
|
+
|
|
190
|
+
Format
|
|
191
|
+
------
|
|
192
|
+
``{<monomer1>}+{<monomer2>}=>{<reaction>}=>{<polymer>}|||...``
|
|
193
|
+
|
|
194
|
+
Example
|
|
195
|
+
-------
|
|
196
|
+
Valid
|
|
197
|
+
input: "{<monomer1>}+{<monomer2>}=>{<rxn1>}=>{<poly1>}|||{<poly1>}+{none}=>{<rxn2>}=>{<poly2>}"
|
|
198
|
+
output: {"sequences": [{"monomers": ["<monomer1>", "<monomer2>"], "reaction": "<rxn1>", "polymer": "<poly1>"}, {"monomers": ["<poly1>", "none"], "reaction": "<rxn2>", "polymer": "<poly2>"}], "errors": [[], []], "valid": true}
|
|
199
|
+
|
|
200
|
+
Invalid
|
|
201
|
+
input: "{<monomer1>}+{<monomer2>}=>{<rxn1>}=>{<poly1>}|||{<poly1>}+{none}=>{<rxn2>}=>{<poly2>}"
|
|
202
|
+
output: {"sequences": [{"monomers": null, "reaction": null, "polymer": null}], "errors": [["<E-parse-|too-many-parts|>"]], "valid": false}
|
|
203
|
+
"""
|
|
204
|
+
parser = _get_nested_parser()
|
|
205
|
+
errors = parser.parse(sequence)
|
|
206
|
+
return {
|
|
207
|
+
"sequences": [
|
|
208
|
+
{
|
|
209
|
+
"monomers": mons,
|
|
210
|
+
"reaction": rxn,
|
|
211
|
+
"polymer": poly,
|
|
212
|
+
}
|
|
213
|
+
for mons, rxn, poly in parser.sequences
|
|
214
|
+
],
|
|
215
|
+
"errors": errors,
|
|
216
|
+
"valid": all(len(e) == 0 for e in errors) if errors else False,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── Classify ─────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# @mcp.tool()
|
|
224
|
+
# def classify_monomers(smiles_list: list[str]) -> dict:
|
|
225
|
+
# """Classify each monomer SMILES by its functional-group type(s).
|
|
226
|
+
|
|
227
|
+
# Returns a table (JSON-serializable) with boolean columns for each
|
|
228
|
+
# monomer functional-group class.
|
|
229
|
+
|
|
230
|
+
# Example
|
|
231
|
+
# -------
|
|
232
|
+
# input: ["<monomer_smiles_1>", "<monomer_smiles_2>"]
|
|
233
|
+
# output: {"columns": ["smiles", "<type1>", "<type2>", ...], "rows": [{"smiles": "<monomer_smiles_1>", "<type1>": true, "<type2>": false, ...}, {"smiles": "<monomer_smiles_2>", "<type1>": false, "<type2>": true, ...}], "num_rows": 2}
|
|
234
|
+
# """
|
|
235
|
+
# classifier = _get_classifier()
|
|
236
|
+
# df = pd.DataFrame({"smiles": smiles_list})
|
|
237
|
+
# result = classifier.classify(df, col_name="smiles")
|
|
238
|
+
|
|
239
|
+
# # Drop ROMol if present (non-serializable)
|
|
240
|
+
# if "ROMol" in result.columns:
|
|
241
|
+
# result = result.drop(columns=["ROMol"])
|
|
242
|
+
|
|
243
|
+
# return {
|
|
244
|
+
# "columns": result.columns.tolist(),
|
|
245
|
+
# "rows": result.to_dict(orient="records"),
|
|
246
|
+
# "num_rows": len(result),
|
|
247
|
+
# }
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ── Validate ─────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@mcp.tool()
|
|
254
|
+
def validate_sequence(sequence: str) -> dict:
|
|
255
|
+
"""Chemically validate a PolyScript sequence.
|
|
256
|
+
|
|
257
|
+
Checks:
|
|
258
|
+
- Monomer SMILES validity
|
|
259
|
+
- Reaction SMARTS validity
|
|
260
|
+
- Reaction execution (whether the reaction runs on the monomers)
|
|
261
|
+
- Polymer product match
|
|
262
|
+
|
|
263
|
+
Handles multi-sequences (``|||``). Returns errors (empty = valid).
|
|
264
|
+
|
|
265
|
+
.. note::
|
|
266
|
+
|
|
267
|
+
In the examples below ``{...}`` is literal PolyScript syntax
|
|
268
|
+
(required). ``<...>`` tags are placeholders — they are **not**
|
|
269
|
+
part of the format. Replace them with actual SMILES/SMARTS.
|
|
270
|
+
|
|
271
|
+
.. important::
|
|
272
|
+
|
|
273
|
+
The polymer **must** be a PSMILES containing ``*`` wildcards
|
|
274
|
+
consistent with the reaction SMARTS atom mappings.
|
|
275
|
+
|
|
276
|
+
Format
|
|
277
|
+
------
|
|
278
|
+
``{<monomer1>}+{<monomer2>}=>{<reaction>}=>{<polymer>}``
|
|
279
|
+
|
|
280
|
+
Example
|
|
281
|
+
-------
|
|
282
|
+
Valid
|
|
283
|
+
input: "{<monomer1>}+{<monomer2>}=>{<reaction>}=>{<polymer>}"
|
|
284
|
+
output: {"errors": [], "valid": true}
|
|
285
|
+
|
|
286
|
+
Invalid
|
|
287
|
+
input: "{<bad_monomer>}+{<monomer2>}=>{<reaction>}=>{<polymer>}"
|
|
288
|
+
output: {"errors": ["<E-chem-|invalid-monomer-smiles|>", "<E-chem-|reaction-execution-error|>"], "valid": false}
|
|
289
|
+
|
|
290
|
+
"""
|
|
291
|
+
validator = _get_validator()
|
|
292
|
+
errors = validator.validate(sequence)
|
|
293
|
+
return {
|
|
294
|
+
"errors": errors,
|
|
295
|
+
"valid": len(errors) == 0,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@mcp.tool()
|
|
300
|
+
def is_valid_sequence(sequence: str) -> bool:
|
|
301
|
+
"""
|
|
302
|
+
Return True when every sub-sequence passes all chemical validation.
|
|
303
|
+
|
|
304
|
+
.. note::
|
|
305
|
+
|
|
306
|
+
In the examples below ``{...}`` is literal PolyScript syntax
|
|
307
|
+
(required). ``<...>`` tags are placeholders — they are **not**
|
|
308
|
+
part of the format. Replace them with actual SMILES/SMARTS.
|
|
309
|
+
|
|
310
|
+
.. important::
|
|
311
|
+
|
|
312
|
+
The polymer **must** be a PSMILES containing ``*`` wildcards
|
|
313
|
+
consistent with the reaction SMARTS atom mappings.
|
|
314
|
+
|
|
315
|
+
Format
|
|
316
|
+
------
|
|
317
|
+
``{<monomer1>}+{<monomer2>}=>{<reaction>}=>{<polymer>}``
|
|
318
|
+
|
|
319
|
+
Example
|
|
320
|
+
-------
|
|
321
|
+
Valid
|
|
322
|
+
input: "{<monomer1>}+{<monomer2>}=>{<reaction>}=>{<polymer>}"
|
|
323
|
+
output: true
|
|
324
|
+
|
|
325
|
+
Invalid
|
|
326
|
+
input: "{<bad_monomer>}+{<monomer2>}=>{<reaction>}=>{<polymer>}"
|
|
327
|
+
output: false
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
return _get_validator().is_valid(sequence)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ── Batch validate (includes parse) ──────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@mcp.tool()
|
|
337
|
+
def batch_validate_sequences(sequences: list[str]) -> dict:
|
|
338
|
+
"""Parse and chemically validate multiple PolyScript sequences.
|
|
339
|
+
|
|
340
|
+
Accepts a list of PolyScript sequences and returns both parse *and*
|
|
341
|
+
chemical validation results for every entry in a single round-trip.
|
|
342
|
+
Handles single and nested (``|||``-separated) sequences automatically.
|
|
343
|
+
|
|
344
|
+
Much more efficient than calling the single-sequence tools separately.
|
|
345
|
+
|
|
346
|
+
.. note::
|
|
347
|
+
|
|
348
|
+
``{...}`` is literal PolyScript syntax (required). ``<...>``
|
|
349
|
+
tags are placeholders — replace them with actual SMILES/SMARTS.
|
|
350
|
+
|
|
351
|
+
.. important::
|
|
352
|
+
|
|
353
|
+
The polymer **must** be a PSMILES containing ``*`` wildcards
|
|
354
|
+
consistent with the reaction SMARTS atom mappings.
|
|
355
|
+
|
|
356
|
+
Format
|
|
357
|
+
------
|
|
358
|
+
``{<monomer1>}+{<monomer2>}=>{<reaction>}=>{<polymer>}``
|
|
359
|
+
(or ``|||``-separated chains of the above)
|
|
360
|
+
|
|
361
|
+
Each result entry contains:
|
|
362
|
+
- ``index`` — position in the input list
|
|
363
|
+
- ``monomers``, ``reaction``, ``polymer`` — top-level parsed
|
|
364
|
+
components (first valid sub-sequence for nested inputs)
|
|
365
|
+
- ``sub_sequences`` — for nested inputs, each ``|||``-separated
|
|
366
|
+
sub-sequence as ``{sub_index, monomers, reaction, polymer}``
|
|
367
|
+
- ``parse_errors`` — list of parse error tags
|
|
368
|
+
- ``validation_errors`` — list of chemical validation error tags
|
|
369
|
+
- ``valid`` — true when both parse and validation pass
|
|
370
|
+
|
|
371
|
+
Example
|
|
372
|
+
-------
|
|
373
|
+
Input: [
|
|
374
|
+
"{<monomer1>}+{<monomer2>}=>{<reaction>}=>{<polymer>}",
|
|
375
|
+
"not-a-valid-sequence"
|
|
376
|
+
]
|
|
377
|
+
output: {"results": [{"index": 0, "monomers": ["<monomer1>", "<monomer2>"], "reaction": "<reaction>", "polymer": "<polymer>", "sub_sequences": [], "parse_errors": [], "validation_errors": [], "valid": true}, {"index": 1, "monomers": null, "reaction": null, "polymer": null, "sub_sequences": [], "parse_errors": ["<E-parse-|invalid-number-of-parts|>"], "validation_errors": ["<E-parse-|invalid-number-of-parts|>"], "valid": false}], "total": 2, "valid_count": 1}
|
|
378
|
+
"""
|
|
379
|
+
parser = _get_parser()
|
|
380
|
+
nested_parser = _get_nested_parser()
|
|
381
|
+
validator = _get_validator()
|
|
382
|
+
|
|
383
|
+
results = []
|
|
384
|
+
for i, seq in enumerate(sequences):
|
|
385
|
+
entry: dict = {
|
|
386
|
+
"index": i,
|
|
387
|
+
"monomers": None,
|
|
388
|
+
"reaction": None,
|
|
389
|
+
"polymer": None,
|
|
390
|
+
"sub_sequences": [],
|
|
391
|
+
"parse_errors": [],
|
|
392
|
+
"validation_errors": [],
|
|
393
|
+
"valid": False,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
# ── Parse ─────────────────────────────────────────────────────
|
|
397
|
+
if "|||" in seq:
|
|
398
|
+
sub_errors = nested_parser.parse(seq)
|
|
399
|
+
entry["parse_errors"] = [e for sub in sub_errors for e in sub]
|
|
400
|
+
first_valid = None
|
|
401
|
+
for j, (mons, rxn, poly) in enumerate(nested_parser.sequences):
|
|
402
|
+
entry["sub_sequences"].append({
|
|
403
|
+
"sub_index": j,
|
|
404
|
+
"monomers": mons,
|
|
405
|
+
"reaction": rxn,
|
|
406
|
+
"polymer": poly,
|
|
407
|
+
})
|
|
408
|
+
if first_valid is None and mons is not None:
|
|
409
|
+
first_valid = (mons, rxn, poly)
|
|
410
|
+
if first_valid is not None:
|
|
411
|
+
entry["monomers"] = first_valid[0]
|
|
412
|
+
entry["reaction"] = first_valid[1]
|
|
413
|
+
entry["polymer"] = first_valid[2]
|
|
414
|
+
else:
|
|
415
|
+
entry["parse_errors"] = parser.parse(seq)
|
|
416
|
+
entry["monomers"] = parser.monomers
|
|
417
|
+
entry["reaction"] = parser.reaction
|
|
418
|
+
entry["polymer"] = parser.polymer
|
|
419
|
+
|
|
420
|
+
# ── Validate ───────────────────────────────────────────────────
|
|
421
|
+
try:
|
|
422
|
+
entry["validation_errors"] = validator.validate(seq)
|
|
423
|
+
except Exception:
|
|
424
|
+
entry["validation_errors"] = ["<E-batch-|validation-crash|>"]
|
|
425
|
+
|
|
426
|
+
entry["valid"] = (
|
|
427
|
+
len(entry["parse_errors"]) == 0
|
|
428
|
+
and len(entry["validation_errors"]) == 0
|
|
429
|
+
)
|
|
430
|
+
results.append(entry)
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
"results": results,
|
|
434
|
+
"total": len(results),
|
|
435
|
+
"valid_count": sum(1 for r in results if r["valid"]),
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ── Post-process candidates ───────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@mcp.tool()
|
|
443
|
+
def post_process_oneshot(candidates: list[dict]) -> dict:
|
|
444
|
+
"""
|
|
445
|
+
Parse and chemically validate a batch of polymer candidates
|
|
446
|
+
in a single call. Accepts a list of candidate objects each
|
|
447
|
+
with a ``polyscript_repr`` field.
|
|
448
|
+
|
|
449
|
+
**This is for one-shot post-processing, not iterative calling.**
|
|
450
|
+
Use ``validate_sequence`` or ``batch_validate_sequences`` for
|
|
451
|
+
single or iterative validation during generation.
|
|
452
|
+
|
|
453
|
+
.. note::
|
|
454
|
+
|
|
455
|
+
``{...}`` is literal PolyScript syntax (required). ``<...>``
|
|
456
|
+
tags are placeholders — replace them with actual SMILES/SMARTS.
|
|
457
|
+
|
|
458
|
+
.. important::
|
|
459
|
+
|
|
460
|
+
The polymer **must** be a PSMILES containing ``*`` wildcards
|
|
461
|
+
consistent with the reaction SMARTS atom mappings.
|
|
462
|
+
|
|
463
|
+
Format
|
|
464
|
+
------
|
|
465
|
+
``{<monomer1>}+{<monomer2>}=>{<reaction>}=>{<polymer>}``
|
|
466
|
+
(or ``|||``-separated chains of the above)
|
|
467
|
+
|
|
468
|
+
Each result entry:
|
|
469
|
+
- ``index`` — position in the input list
|
|
470
|
+
- ``monomers``, ``reaction``, ``polymer`` — parsed components
|
|
471
|
+
- ``sub_sequences`` — nested sub-sequence details
|
|
472
|
+
- ``parse_errors`` — parse error tags
|
|
473
|
+
- ``validation_errors`` — chemical validation error tags
|
|
474
|
+
- ``valid`` — true when both parse and validation pass
|
|
475
|
+
|
|
476
|
+
Example
|
|
477
|
+
-------
|
|
478
|
+
Input: {"candidates": [
|
|
479
|
+
{"polyscript_repr": "{<monomer1>}+{<monomer2>}=>{<reaction>}=>{<polymer>}"}
|
|
480
|
+
]}
|
|
481
|
+
output: {"results": [{"index": 0, "monomers": ["<monomer1>", "<monomer2>"], "reaction": "<reaction>", "polymer": "<polymer>", "sub_sequences": [], "parse_errors": [], "validation_errors": [], "valid": true}], "total": 1, "valid_count": 1}
|
|
482
|
+
"""
|
|
483
|
+
parser = _get_parser()
|
|
484
|
+
nested_parser = _get_nested_parser()
|
|
485
|
+
validator = _get_validator()
|
|
486
|
+
|
|
487
|
+
results = []
|
|
488
|
+
for i, cand in enumerate(candidates):
|
|
489
|
+
seq = cand.get("polyscript_repr", "")
|
|
490
|
+
|
|
491
|
+
entry: dict = {
|
|
492
|
+
"index": i,
|
|
493
|
+
"monomers": None,
|
|
494
|
+
"reaction": None,
|
|
495
|
+
"polymer": None,
|
|
496
|
+
"sub_sequences": [],
|
|
497
|
+
"parse_errors": [],
|
|
498
|
+
"validation_errors": [],
|
|
499
|
+
"valid": False,
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
# ── Parse ─────────────────────────────────────────────────────
|
|
503
|
+
if "|||" in seq:
|
|
504
|
+
sub_errors = nested_parser.parse(seq)
|
|
505
|
+
entry["parse_errors"] = [e for sub in sub_errors for e in sub]
|
|
506
|
+
first_valid = None
|
|
507
|
+
for j, (mons, rxn, poly) in enumerate(nested_parser.sequences):
|
|
508
|
+
entry["sub_sequences"].append({
|
|
509
|
+
"sub_index": j,
|
|
510
|
+
"monomers": mons,
|
|
511
|
+
"reaction": rxn,
|
|
512
|
+
"polymer": poly,
|
|
513
|
+
})
|
|
514
|
+
if first_valid is None and mons is not None:
|
|
515
|
+
first_valid = (mons, rxn, poly)
|
|
516
|
+
if first_valid is not None:
|
|
517
|
+
entry["monomers"] = first_valid[0]
|
|
518
|
+
entry["reaction"] = first_valid[1]
|
|
519
|
+
entry["polymer"] = first_valid[2]
|
|
520
|
+
else:
|
|
521
|
+
entry["parse_errors"] = parser.parse(seq)
|
|
522
|
+
entry["monomers"] = parser.monomers
|
|
523
|
+
entry["reaction"] = parser.reaction
|
|
524
|
+
entry["polymer"] = parser.polymer
|
|
525
|
+
|
|
526
|
+
# ── Validate ───────────────────────────────────────────────────
|
|
527
|
+
try:
|
|
528
|
+
entry["validation_errors"] = validator.validate(seq)
|
|
529
|
+
except Exception:
|
|
530
|
+
entry["validation_errors"] = ["<E-batch-|validation-crash|>"]
|
|
531
|
+
|
|
532
|
+
entry["valid"] = (
|
|
533
|
+
len(entry["parse_errors"]) == 0
|
|
534
|
+
and len(entry["validation_errors"]) == 0
|
|
535
|
+
)
|
|
536
|
+
results.append(entry)
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
"results": results,
|
|
540
|
+
"total": len(results),
|
|
541
|
+
"valid_count": sum(1 for r in results if r["valid"]),
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# # ── Polymerize ───────────────────────────────────────────────────────────────
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# @mcp.tool()
|
|
549
|
+
# def polymerize(
|
|
550
|
+
# monomer_smiles_1: list[str],
|
|
551
|
+
# monomer_type_1: str,
|
|
552
|
+
# polymer_class: str,
|
|
553
|
+
# monomer_smiles_2: Optional[list[str]] = None,
|
|
554
|
+
# monomer_type_2: Optional[str] = None,
|
|
555
|
+
# ) -> dict:
|
|
556
|
+
# """Generate polymer sequences from one or two monomer lists.
|
|
557
|
+
|
|
558
|
+
# When only one monomer list is provided, homopolymerization is performed.
|
|
559
|
+
# When two monomer lists are provided, bipolymerization (copolymer) is
|
|
560
|
+
# performed using the matching reaction rule.
|
|
561
|
+
|
|
562
|
+
# Args:
|
|
563
|
+
# monomer_smiles_1: SMILES strings for the first set of monomers.
|
|
564
|
+
# monomer_type_1: Functional-group type tag (e.g. "vinyl", "acid").
|
|
565
|
+
# polymer_class: Polymer class (e.g. "polyolefin", "polyester").
|
|
566
|
+
# monomer_smiles_2: Optional second monomer list for copolymerization.
|
|
567
|
+
# monomer_type_2: Functional-group type for second monomers (required
|
|
568
|
+
# when monomer_smiles_2 is provided).
|
|
569
|
+
# """
|
|
570
|
+
# poly = _get_polymerizer()
|
|
571
|
+
|
|
572
|
+
# mon_df1 = pd.DataFrame({"smiles": monomer_smiles_1})
|
|
573
|
+
|
|
574
|
+
# mon_df2 = None
|
|
575
|
+
# if monomer_smiles_2 is not None:
|
|
576
|
+
# mon_df2 = pd.DataFrame({"smiles": monomer_smiles_2})
|
|
577
|
+
|
|
578
|
+
# result = poly.bipolymerize(
|
|
579
|
+
# mon_df1=mon_df1,
|
|
580
|
+
# mon_type1=monomer_type_1,
|
|
581
|
+
# P_class=polymer_class,
|
|
582
|
+
# mon_df2=mon_df2,
|
|
583
|
+
# mon_type2=monomer_type_2,
|
|
584
|
+
# candidate_col_name="smiles",
|
|
585
|
+
# )
|
|
586
|
+
|
|
587
|
+
# if result is None or result.empty:
|
|
588
|
+
# return {
|
|
589
|
+
# "num_results": 0,
|
|
590
|
+
# "results": [],
|
|
591
|
+
# "message": "No polymer sequences were generated for the given inputs.",
|
|
592
|
+
# }
|
|
593
|
+
|
|
594
|
+
# return {
|
|
595
|
+
# "num_results": len(result),
|
|
596
|
+
# "columns": result.columns.tolist(),
|
|
597
|
+
# "results": result.to_dict(orient="records"),
|
|
598
|
+
# }
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# @mcp.tool()
|
|
602
|
+
# def execute_polymerization(
|
|
603
|
+
# mon1_path: str,
|
|
604
|
+
# mon1_type: str,
|
|
605
|
+
# polymer_class: str,
|
|
606
|
+
# col_name: str = "psmiles",
|
|
607
|
+
# mon2_path: Optional[str] = None,
|
|
608
|
+
# mon2_type: Optional[str] = None,
|
|
609
|
+
# output_path: Optional[str] = None,
|
|
610
|
+
# ) -> dict:
|
|
611
|
+
# """Run polymerizer.execute() with a path-based config.
|
|
612
|
+
|
|
613
|
+
# Reads monomer parquet files from disk and writes results.
|
|
614
|
+
|
|
615
|
+
# Args:
|
|
616
|
+
# mon1_path: Path to first monomer parquet file.
|
|
617
|
+
# mon1_type: Functional-group type for first monomers.
|
|
618
|
+
# polymer_class: Polymer class (e.g. "polyolefin").
|
|
619
|
+
# col_name: Column name containing SMILES in the parquet files.
|
|
620
|
+
# mon2_path: Optional second monomer parquet file.
|
|
621
|
+
# mon2_type: Functional-group type for second monomers.
|
|
622
|
+
# output_path: Where to write the output parquet. If omitted, a
|
|
623
|
+
# temporary file is used.
|
|
624
|
+
# """
|
|
625
|
+
# if output_path is None:
|
|
626
|
+
# with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as f:
|
|
627
|
+
# output_path = f.name
|
|
628
|
+
|
|
629
|
+
# config = {
|
|
630
|
+
# "mon1_path": mon1_path,
|
|
631
|
+
# "mon2_path": mon2_path,
|
|
632
|
+
# "output_path": output_path,
|
|
633
|
+
# "col_name": col_name,
|
|
634
|
+
# "P_class": polymer_class,
|
|
635
|
+
# "mon_type1": mon1_type,
|
|
636
|
+
# "mon_type2": mon2_type,
|
|
637
|
+
# }
|
|
638
|
+
|
|
639
|
+
# poly = _get_polymerizer()
|
|
640
|
+
# success, error = poly.execute(config)
|
|
641
|
+
|
|
642
|
+
# return {
|
|
643
|
+
# "success": success,
|
|
644
|
+
# "error": error,
|
|
645
|
+
# "output_path": output_path if success else None,
|
|
646
|
+
# }
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
# # ── Depolymerize ─────────────────────────────────────────────────────────────
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# @mcp.tool()
|
|
653
|
+
# def depolymerize(polymer_smiles: str) -> dict:
|
|
654
|
+
# """Recover monomers from a polymer SMILES via reverse reaction.
|
|
655
|
+
|
|
656
|
+
# Finds all reaction pathways that can produce the given polymer,
|
|
657
|
+
# then reverses them to recover candidate monomers. Returns the
|
|
658
|
+
# list of successful recovery results.
|
|
659
|
+
# """
|
|
660
|
+
# depol = _get_depolymerizer()
|
|
661
|
+
# results = depol.convert_psmiles(polymer_smiles)
|
|
662
|
+
# return {
|
|
663
|
+
# "polymer": polymer_smiles,
|
|
664
|
+
# "num_pathways": len(results),
|
|
665
|
+
# "pathways": [_depol_result_to_dict(r) for r in results],
|
|
666
|
+
# }
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# @mcp.tool()
|
|
670
|
+
# def depolymerize_batch(polymer_smiles_list: list[str]) -> dict:
|
|
671
|
+
# """Batch depolymerization of multiple polymer SMILES.
|
|
672
|
+
|
|
673
|
+
# Finds reverse -> forward reaction round-trips for each polymer.
|
|
674
|
+
# """
|
|
675
|
+
# depol = _get_depolymerizer()
|
|
676
|
+
# results, stats = depol.convert_all(polymer_smiles_list)
|
|
677
|
+
# return {
|
|
678
|
+
# "stats": stats,
|
|
679
|
+
# "num_pathways": len(results),
|
|
680
|
+
# "pathways": [
|
|
681
|
+
# _depol_result_to_dict(r) for r in results
|
|
682
|
+
# ],
|
|
683
|
+
# }
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
# @mcp.tool()
|
|
687
|
+
# def validate_depolymerization(polym_sequence: str) -> dict:
|
|
688
|
+
# """Validate monomer recovery via depolymerization for a PolyScript sequence.
|
|
689
|
+
|
|
690
|
+
# Parses the PolyScript sequence, then attempts a reverse -> forward
|
|
691
|
+
# reaction round-trip to check whether the input monomers can be
|
|
692
|
+
# recovered from the polymer.
|
|
693
|
+
# """
|
|
694
|
+
# depol = _get_depolymerizer()
|
|
695
|
+
# results = depol.validate(polym_sequence)
|
|
696
|
+
# return {
|
|
697
|
+
# "sequence": polym_sequence,
|
|
698
|
+
# "num_matches": len(results),
|
|
699
|
+
# "matches": [
|
|
700
|
+
# _depol_result_to_dict(r) for r in results
|
|
701
|
+
# ],
|
|
702
|
+
# }
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
# @mcp.tool()
|
|
706
|
+
# def validate_depolymerization_batch(polym_sequences: list[str]) -> dict:
|
|
707
|
+
# """Batch depolymerization validation for multiple PolyScript sequences."""
|
|
708
|
+
# depol = _get_depolymerizer()
|
|
709
|
+
# results, stats = depol.validate_all(polym_sequences)
|
|
710
|
+
# return {
|
|
711
|
+
# "stats": stats,
|
|
712
|
+
# "num_matches": len(results),
|
|
713
|
+
# "matches": [
|
|
714
|
+
# _depol_result_to_dict(r) for r in results
|
|
715
|
+
# ],
|
|
716
|
+
# }
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
720
|
+
# Entry point
|
|
721
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def main():
|
|
725
|
+
"""Run the MCP server.
|
|
726
|
+
|
|
727
|
+
Transport is chosen via CLI arguments or the ``MCP_TRANSPORT`` env var:
|
|
728
|
+
|
|
729
|
+
- ``stdio`` (default) — standard input/output, no port needed.
|
|
730
|
+
- ``sse`` — Server-Sent Events over HTTP (default port 8000).
|
|
731
|
+
- ``streamable-http`` — Streamable HTTP (default port 8000).
|
|
732
|
+
|
|
733
|
+
Examples::
|
|
734
|
+
|
|
735
|
+
python server.py # stdio
|
|
736
|
+
python server.py --transport sse --port 9000
|
|
737
|
+
MCP_TRANSPORT=sse python server.py # port 8000
|
|
738
|
+
"""
|
|
739
|
+
import argparse
|
|
740
|
+
|
|
741
|
+
parser = argparse.ArgumentParser(description="PolyScript MCP Server")
|
|
742
|
+
parser.add_argument(
|
|
743
|
+
"--transport",
|
|
744
|
+
choices=["stdio", "sse", "streamable-http"],
|
|
745
|
+
default=os.environ.get("MCP_TRANSPORT", "stdio"),
|
|
746
|
+
help="Transport protocol (default: stdio, or $MCP_TRANSPORT)",
|
|
747
|
+
)
|
|
748
|
+
parser.add_argument(
|
|
749
|
+
"--host",
|
|
750
|
+
default=os.environ.get("MCP_HOST", "127.0.0.1"),
|
|
751
|
+
help="Host to bind when using sse / streamable-http (default: 127.0.0.1)",
|
|
752
|
+
)
|
|
753
|
+
parser.add_argument(
|
|
754
|
+
"--port",
|
|
755
|
+
type=int,
|
|
756
|
+
default=int(os.environ.get("MCP_PORT", "8000")),
|
|
757
|
+
help="Port to bind when using sse / streamable-http (default: 8000)",
|
|
758
|
+
)
|
|
759
|
+
args = parser.parse_args()
|
|
760
|
+
|
|
761
|
+
if args.transport == "stdio":
|
|
762
|
+
mcp.run(transport="stdio")
|
|
763
|
+
elif args.transport == "sse":
|
|
764
|
+
mcp.run(transport="sse", host=args.host, port=args.port)
|
|
765
|
+
elif args.transport == "streamable-http":
|
|
766
|
+
mcp.run(transport="streamable-http", host=args.host, port=args.port)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
if __name__ == "__main__":
|
|
770
|
+
main()
|