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.
Files changed (57) hide show
  1. MCP/server.py +770 -0
  2. polyscript/__init__.py +12 -0
  3. polyscript/brics/BRICSMod.py +1164 -0
  4. polyscript/brics/BRICS_utils.py +189 -0
  5. polyscript/brics/__init__.py +48 -0
  6. polyscript/classifier/__init__.py +270 -0
  7. polyscript/classifier/class_reference.py +951 -0
  8. polyscript/depolymerizer/__init__.py +576 -0
  9. polyscript/depolymerizer/mpi.py +390 -0
  10. polyscript/depolymerizer/reaction_reference.py +447 -0
  11. polyscript/polymerizer/__init__.py +22 -0
  12. polyscript/polymerizer/core.py +648 -0
  13. polyscript/polymerizer/mpi.py +860 -0
  14. polyscript/polymerizer/mpi_df.py +378 -0
  15. polyscript/polymerizer/utils.py +795 -0
  16. polyscript/utils/__init__.py +80 -0
  17. polyscript/utils/adapters.py +357 -0
  18. polyscript/utils/canon_analyze.py +193 -0
  19. polyscript/utils/executor.py +66 -0
  20. polyscript/utils/logger.py +331 -0
  21. polyscript/utils/mpi/__init__.py +33 -0
  22. polyscript/utils/mpi/checkpoint.py +263 -0
  23. polyscript/utils/mpi/logger.py +138 -0
  24. polyscript/utils/mpi/progress.py +134 -0
  25. polyscript/utils/mpi/protocol.py +113 -0
  26. polyscript/utils/mpi/resource.py +94 -0
  27. polyscript/utils/mpi/splitter.py +42 -0
  28. polyscript/utils/mpi_executor.py +167 -0
  29. polyscript/utils/mpi_validator.py +940 -0
  30. polyscript/utils/parsers.py +177 -0
  31. polyscript/utils/validators.py +201 -0
  32. polyscript_pypi-0.1.0.dist-info/METADATA +82 -0
  33. polyscript_pypi-0.1.0.dist-info/RECORD +57 -0
  34. polyscript_pypi-0.1.0.dist-info/WHEEL +5 -0
  35. polyscript_pypi-0.1.0.dist-info/licenses/LICENSE +21 -0
  36. polyscript_pypi-0.1.0.dist-info/top_level.txt +4 -0
  37. runners/adapt_dataframe.py +105 -0
  38. runners/brics_decompose.py +28 -0
  39. runners/canonicalization.py +46 -0
  40. runners/filter_candidates.py +54 -0
  41. runners/mpi_depolymerize.py +51 -0
  42. runners/mpi_runner.py +66 -0
  43. runners/mpi_runner_df.py +49 -0
  44. runners/mpi_validator.py +45 -0
  45. tests/conftest.py +10 -0
  46. tests/sample_gen.py +10 -0
  47. tests/test_brics.py +275 -0
  48. tests/test_classifier.py +201 -0
  49. tests/test_mpi_polymerize.py +170 -0
  50. tests/test_polymerize.py +206 -0
  51. tests/utils/test_adaptors.py +182 -0
  52. tests/utils/test_canonicalization.py +62 -0
  53. tests/utils/test_executor.py +101 -0
  54. tests/utils/test_logger.py +426 -0
  55. tests/utils/test_mpi_validator.py +159 -0
  56. tests/utils/test_parsers.py +150 -0
  57. 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()