ghh1125 commited on
Commit
c5fe37e
·
verified ·
1 Parent(s): d69c3a4

Upload 14 files

Browse files
Dockerfile CHANGED
@@ -1,17 +1,21 @@
1
- FROM python:3.10
2
 
3
- RUN useradd -m -u 1000 user && python -m pip install --upgrade pip
4
- USER user
5
- ENV PATH="/home/user/.local/bin:$PATH"
 
6
 
7
  WORKDIR /app
8
 
9
- COPY --chown=user ./requirements.txt requirements.txt
10
- RUN pip install --no-cache-dir --upgrade -r requirements.txt
11
 
12
- COPY --chown=user . /app
13
- ENV MCP_TRANSPORT=http
14
- ENV MCP_PORT=7860
 
 
 
 
15
 
16
  EXPOSE 7860
17
 
 
1
+ FROM python:3.11-slim
2
 
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ MCP_TRANSPORT=http \
6
+ MCP_PORT=7860
7
 
8
  WORKDIR /app
9
 
10
+ RUN useradd -m -u 1000 appuser
 
11
 
12
+ COPY requirements.txt /app/requirements.txt
13
+ RUN pip install --no-cache-dir -r /app/requirements.txt
14
+
15
+ COPY molmass /app/molmass
16
+ COPY app.py /app/app.py
17
+
18
+ USER appuser
19
 
20
  EXPOSE 7860
21
 
README.md CHANGED
@@ -1,10 +1,54 @@
1
  ---
2
- title: Molmass
3
- emoji: 🏆
4
- colorFrom: indigo
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: molmass MCP Service
3
+ emoji: 🔧
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
+ license: mit
9
  ---
10
 
11
+ # molmass MCP Service
12
+
13
+ This deployment package exposes selected `molmass` capabilities as MCP tools using FastMCP.
14
+
15
+ ## Available tools
16
+
17
+ - `health_check`
18
+ - `analyze_formula`
19
+ - `formula_summary`
20
+ - `composition_table`
21
+ - `isotopic_spectrum`
22
+ - `element_lookup`
23
+ - `formula_from_fractions`
24
+ - `list_modules`
25
+
26
+ Detailed tool parameter documentation and examples are in `molmass/mcp_output/README_MCP.md`.
27
+
28
+ ## Local stdio usage
29
+
30
+ Use this for Claude Desktop / CLI style local MCP connections:
31
+
32
+ ```bash
33
+ cd molmass/mcp_output
34
+ python -m mcp_plugin.main
35
+ ```
36
+
37
+ ## HTTP usage (Docker / HF Spaces)
38
+
39
+ The Docker entry point is `python molmass/mcp_output/start_mcp.py`.
40
+
41
+ - Transport is controlled by `MCP_TRANSPORT` (`http` in Docker).
42
+ - Port is controlled by `MCP_PORT` (`7860` for HF Spaces).
43
+ - MCP HTTP endpoint is served at `/mcp`.
44
+
45
+ Example local HTTP run:
46
+
47
+ ```bash
48
+ cd molmass/mcp_output
49
+ MCP_TRANSPORT=http MCP_PORT=7860 python start_mcp.py
50
+ ```
51
+
52
+ Client connection URL pattern:
53
+
54
+ - `https://<host>/mcp`
app.py CHANGED
@@ -1,45 +1,61 @@
1
- from fastapi import FastAPI
 
2
  import os
3
  import sys
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
- mcp_plugin_path = os.path.join(os.path.dirname(__file__), "molmass", "mcp_output", "mcp_plugin")
6
- sys.path.insert(0, mcp_plugin_path)
 
 
7
 
8
- app = FastAPI(
9
- title="Molmass MCP Service",
10
- description="Auto-generated MCP service for molmass",
11
- version="1.0.0"
12
- )
13
 
14
  @app.get("/")
15
- def root():
16
  return {
17
- "service": "Molmass MCP Service",
18
- "version": "1.0.0",
19
- "status": "running",
20
- "transport": os.environ.get("MCP_TRANSPORT", "http")
21
  }
22
 
 
23
  @app.get("/health")
24
- def health_check():
25
- return {"status": "healthy", "service": "molmass MCP"}
 
26
 
27
  @app.get("/tools")
28
- def list_tools():
29
- try:
30
- from mcp_service import create_app
31
- mcp_app = create_app()
32
- tools = []
33
- for tool_name, tool_func in mcp_app.tools.items():
34
- tools.append({
35
- "name": tool_name,
36
- "description": tool_func.__doc__ or "No description available"
37
- })
38
- return {"tools": tools}
39
- except Exception as e:
40
- return {"error": f"Failed to load tools: {str(e)}"}
 
41
 
42
  if __name__ == "__main__":
43
  import uvicorn
44
- port = int(os.environ.get("PORT", 7860))
 
45
  uvicorn.run(app, host="0.0.0.0", port=port)
 
1
+ from __future__ import annotations
2
+
3
  import os
4
  import sys
5
+ import importlib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ try:
10
+ _fastapi = importlib.import_module("fastapi")
11
+ FastAPI = getattr(_fastapi, "FastAPI", None)
12
+ except Exception:
13
+ FastAPI = None
14
+
15
+ CURRENT_DIR = Path(__file__).resolve().parent
16
+ PLUGIN_DIR = CURRENT_DIR / "molmass" / "mcp_output" / "mcp_plugin"
17
+ if str(PLUGIN_DIR) not in sys.path:
18
+ sys.path.insert(0, str(PLUGIN_DIR))
19
 
20
+ if FastAPI is None:
21
+ raise RuntimeError("fastapi is required to run app.py")
22
+
23
+ app = FastAPI(title="molmass MCP Info API", version="1.0.0")
24
 
 
 
 
 
 
25
 
26
  @app.get("/")
27
+ def root() -> dict[str, Any]:
28
  return {
29
+ "service": "molmass-mcp",
30
+ "description": "Supplementary info API for local development.",
31
+ "mcp_entrypoint": "molmass/mcp_output/start_mcp.py",
32
+ "mcp_http_path": "/mcp",
33
  }
34
 
35
+
36
  @app.get("/health")
37
+ def health() -> dict[str, str]:
38
+ return {"status": "healthy"}
39
+
40
 
41
  @app.get("/tools")
42
+ def tools() -> dict[str, Any]:
43
+ create_app = importlib.import_module("mcp_service").create_app
44
+
45
+ mcp = create_app()
46
+ tool_items = []
47
+ for tool in getattr(mcp, "tools", []):
48
+ tool_items.append(
49
+ {
50
+ "name": getattr(tool, "name", getattr(tool, "__name__", "unknown")),
51
+ "description": getattr(tool, "description", ""),
52
+ }
53
+ )
54
+ return {"count": len(tool_items), "tools": tool_items}
55
+
56
 
57
  if __name__ == "__main__":
58
  import uvicorn
59
+
60
+ port = int(os.getenv("PORT", "7860"))
61
  uvicorn.run(app, host="0.0.0.0", port=port)
molmass/mcp_output/README_MCP.md CHANGED
@@ -1,128 +1,133 @@
1
- # molmass MCP (Model Context Protocol) Service README
2
-
3
- ## 1) Project Introduction
4
-
5
- This service wraps the `molmass` chemistry engine to provide mass/formula analysis through MCP (Model Context Protocol).
6
- It is designed for developer use cases such as:
7
-
8
- - Molecular formula parsing and validation
9
- - Monoisotopic, average, and nominal mass calculation
10
- - Elemental composition reporting
11
- - Isotopic spectrum generation
12
- - Sequence/oligo/peptide-derived formula conversion
13
-
14
- Primary integration target: `source.molmass.molmass` (or `deployment.molmass.source.molmass` in alternate runtime layouts).
15
-
16
- ---
17
-
18
- ## 2) Installation
19
-
20
- ### Requirements
21
- - Python 3.x
22
- - Standard library only for core functionality
23
- - Optional:
24
- - `tkinter` for GUI module (`elements_gui.py`)
25
- - CGI/web runtime for `web.py`
26
-
27
- ### Install from PyPI
28
- pip install molmass
29
-
30
- ### Install from source
31
- git clone https://github.com/cgohlke/molmass.git
32
- cd molmass
33
- pip install .
34
-
35
- ---
36
-
37
- ## 3) Quick Start
38
-
39
- ### Recommended imports
40
- from source.molmass.molmass import analyze, Formula, from_string, from_sequence, from_peptide, from_oligo
41
-
42
- ### Typical usage patterns
43
- - Parse and analyze a formula string:
44
- - `analyze("C8H10N4O2")`
45
- - Create a formula object and compute masses/composition:
46
- - `Formula("H2O")`
47
- - Build formulas from biological sequences:
48
- - `from_sequence(...)`, `from_peptide(...)`, `from_oligo(...)`
49
- - Build from generic text input:
50
- - `from_string(...)`
51
-
52
- ### CLI execution
53
- python -m source.molmass
54
-
55
- (Depending on packaging/runtime, module path may be `molmass` or `deployment.molmass.source.molmass`.)
56
-
57
- ---
58
-
59
- ## 4) Available Tools and Endpoints
60
-
61
- For MCP (Model Context Protocol) service design, expose these practical endpoints:
62
-
63
- - `analyze_formula`
64
- - Backed by: `analyze`, `Formula`
65
- - Input: formula string
66
- - Output: parsed formula details, masses, composition, optional spectrum summary
67
-
68
- - `parse_formula`
69
- - Backed by: `read_formula`, `split_formula`, `split_parts`, `split_charge`
70
- - Input: formula string
71
- - Output: normalized/parsed structure, charge handling, validation errors
72
-
73
- - `calculate_mass`
74
- - Backed by: `Formula` properties/methods
75
- - Input: formula string
76
- - Output: monoisotopic/average/nominal masses
77
-
78
- - `composition_from_formula`
79
- - Backed by: `Formula`, `Composition`
80
- - Input: formula string
81
- - Output: element counts and relative contributions
82
-
83
- - `spectrum_from_formula`
84
- - Backed by: `Spectrum`, `SpectrumEntry`
85
- - Input: formula string, optional limits/precision
86
- - Output: isotope peaks and intensities
87
-
88
- - `formula_from_sequence`
89
- - Backed by: `from_sequence`, `from_peptide`, `from_oligo`
90
- - Input: sequence + mode/options
91
- - Output: derived molecular formula and optional mass results
92
-
93
- - `formula_from_elements_or_fractions`
94
- - Backed by: `from_elements`, `from_fractions`
95
- - Input: element map or mass fractions
96
- - Output: reconstructed/estimated formula
97
-
98
- ---
99
-
100
- ## 5) Common Issues and Notes
101
-
102
- - Import path differences:
103
- - Prefer `source.molmass.*`
104
- - Fallback to `deployment.molmass.source.*` if required by environment
105
-
106
- - Error handling:
107
- - Catch `FormulaError` for invalid syntax or unsupported input
108
-
109
- - Precision/output formatting:
110
- - Use helper behavior such as `precision_digits`, `join_charge`, `hill_sorted` where needed for consistent output
111
-
112
- - Optional modules:
113
- - `elements_gui.py` requires GUI support (`tkinter`)
114
- - `web.py` is useful as interface reference but not the preferred core MCP (Model Context Protocol) hook
115
-
116
- - Performance:
117
- - Core operations are lightweight for typical formula queries
118
- - Isotopic spectrum generation can be heavier for large molecules
119
-
120
- ---
121
-
122
- ## 6) Reference Links
123
-
124
- - Repository: https://github.com/cgohlke/molmass
125
- - Core engine module: `molmass/molmass.py`
126
- - Element data: `molmass/elements.py`
127
- - Web adapter reference: `molmass/web.py`
128
- - Tests/examples: `tests/test_molmass.py`
 
 
 
 
 
 
1
+ # molmass MCP Plugin
2
+
3
+ This directory contains an MCP wrapper around the `molmass` Python package.
4
+
5
+ ## Exposed Tools
6
+
7
+ 1. **health_check**
8
+ - Parameters: none
9
+ - Returns dependency and adapter health status.
10
+ - Example:
11
+ ```json
12
+ {"name": "health_check", "arguments": {}}
13
+ ```
14
+
15
+ 2. **analyze_formula**
16
+ - Parameters:
17
+ - `formula: str`
18
+ - `maxatoms: int = 512`
19
+ - `min_intensity: float = 0.0001`
20
+ - `debug: bool = false`
21
+ - Returns text report (formula, masses, composition, spectrum summary).
22
+ - Example:
23
+ ```json
24
+ {
25
+ "name": "analyze_formula",
26
+ "arguments": {"formula": "C8H10N4O2", "min_intensity": 0.01}
27
+ }
28
+ ```
29
+
30
+ 3. **formula_summary**
31
+ - Parameters:
32
+ - `formula: str`
33
+ - `allow_empty: bool = false`
34
+ - Returns key notation and mass fields.
35
+ - Example:
36
+ ```json
37
+ {
38
+ "name": "formula_summary",
39
+ "arguments": {"formula": "H2O"}
40
+ }
41
+ ```
42
+
43
+ 4. **composition_table**
44
+ - Parameters:
45
+ - `formula: str`
46
+ - `isotopic: bool = true`
47
+ - Returns composition rows (`element`, `count`, `relative_mass`, `fraction`).
48
+ - Example:
49
+ ```json
50
+ {
51
+ "name": "composition_table",
52
+ "arguments": {"formula": "C2H6O"}
53
+ }
54
+ ```
55
+
56
+ 5. **isotopic_spectrum**
57
+ - Parameters:
58
+ - `formula: str`
59
+ - `min_fraction: float = 1e-9`
60
+ - `min_intensity: float = 1e-9`
61
+ - `max_peaks: int = 30`
62
+ - Returns isotopic peak list.
63
+ - Example:
64
+ ```json
65
+ {
66
+ "name": "isotopic_spectrum",
67
+ "arguments": {"formula": "C2H6O", "max_peaks": 6}
68
+ }
69
+ ```
70
+
71
+ 6. **element_lookup**
72
+ - Parameters:
73
+ - `identifier: str` (symbol, name, or atomic number)
74
+ - Returns periodic element properties.
75
+ - Example:
76
+ ```json
77
+ {
78
+ "name": "element_lookup",
79
+ "arguments": {"identifier": "C"}
80
+ }
81
+ ```
82
+
83
+ 7. **formula_from_fractions**
84
+ - Parameters:
85
+ - `fractions: dict[str, float]`
86
+ - `maxcount: int = 10`
87
+ - `precision: float = 0.0001`
88
+ - Returns inferred formula from elemental mass fractions.
89
+ - Example:
90
+ ```json
91
+ {
92
+ "name": "formula_from_fractions",
93
+ "arguments": {"fractions": {"H": 0.112, "O": 0.888}}
94
+ }
95
+ ```
96
+
97
+ 8. **list_modules**
98
+ - Parameters:
99
+ - `include_failed: bool = true`
100
+ - Returns modules discovered by adapter.
101
+ - Example:
102
+ ```json
103
+ {
104
+ "name": "list_modules",
105
+ "arguments": {"include_failed": true}
106
+ }
107
+ ```
108
+
109
+ All tools return this shape:
110
+
111
+ ```json
112
+ {
113
+ "success": true,
114
+ "result": {},
115
+ "error": null
116
+ }
117
+ ```
118
+
119
+ ## Local stdio run
120
+
121
+ ```bash
122
+ cd molmass/mcp_output
123
+ python -m mcp_plugin.main
124
+ ```
125
+
126
+ ## HTTP transport run
127
+
128
+ ```bash
129
+ cd molmass/mcp_output
130
+ MCP_TRANSPORT=http MCP_PORT=7860 python start_mcp.py
131
+ ```
132
+
133
+ The MCP HTTP endpoint is served at `/mcp`.
molmass/mcp_output/mcp_plugin/__init__.py CHANGED
@@ -0,0 +1 @@
 
 
1
+
molmass/mcp_output/mcp_plugin/adapter.py CHANGED
@@ -1,467 +1,179 @@
1
- import os
 
 
 
2
  import sys
3
- import inspect
4
- import traceback
5
- from typing import Any, Dict, Optional, List
6
 
7
- source_path = os.path.join(
8
- os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
9
- "source",
10
- )
11
- sys.path.insert(0, source_path)
 
12
 
13
 
14
  class Adapter:
15
- """
16
- MCP Import Mode Adapter for molmass repository.
17
-
18
- This adapter prioritizes direct module import and provides graceful CLI fallback.
19
- It exposes dedicated methods for discovered entry points:
20
- - source.molmass.__main__ (module execution dispatcher)
21
- - source.molmass.molmass.main (core CLI handler)
22
- - source.molmass.web.main (web/CGI-style entrypoint)
23
-
24
- All public methods return a unified dictionary format:
25
- {
26
- "status": "success" | "error" | "fallback",
27
- ...
28
- }
29
- """
30
-
31
- # -------------------------------------------------------------------------
32
- # Initialization and module management
33
- # -------------------------------------------------------------------------
34
-
35
- def __init__(self) -> None:
36
- """
37
- Initialize adapter state, import mode, and lazy-load targets.
38
 
39
- Attributes:
40
- mode (str): Adapter operation mode, fixed to "import".
41
- modules (dict): Loaded module references.
42
- functions (dict): Loaded callable references.
43
- import_errors (list): Detailed import errors.
44
- """
45
- self.mode: str = "import"
46
- self.modules: Dict[str, Any] = {}
47
- self.functions: Dict[str, Any] = {}
48
- self.import_errors: List[Dict[str, str]] = []
49
- self._initialize_imports()
50
 
51
- def _initialize_imports(self) -> None:
52
- """
53
- Attempt to import all identified modules and function entrypoints.
54
-
55
- Uses full package paths from analysis and stores detailed errors for
56
- graceful fallback behavior.
57
- """
58
- import_targets = [
59
- ("source.molmass.__main__", None),
60
- ("source.molmass.molmass", "main"),
61
- ("source.molmass.web", "main"),
62
- ]
63
-
64
- for module_path, function_name in import_targets:
 
 
 
 
 
65
  try:
66
- module = __import__(module_path, fromlist=["*"])
67
- self.modules[module_path] = module
68
- if function_name:
69
- func = getattr(module, function_name, None)
70
- if callable(func):
71
- self.functions[f"{module_path}.{function_name}"] = func
72
- else:
73
- self.import_errors.append(
74
- {
75
- "module": module_path,
76
- "error": f"Function '{function_name}' not found or not callable.",
77
- }
78
- )
79
  except Exception as exc:
80
- self.import_errors.append(
81
- {
82
- "module": module_path,
83
- "error": f"{type(exc).__name__}: {exc}",
84
- }
85
- )
86
 
87
- def health_check(self) -> Dict[str, Any]:
88
- """
89
- Report adapter health and import readiness.
90
 
91
- Returns:
92
- dict: Unified status dictionary with loaded modules/functions and import diagnostics.
93
- """
94
- try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  return {
96
- "status": "success",
97
- "mode": self.mode,
98
- "loaded_modules": sorted(self.modules.keys()),
99
- "loaded_functions": sorted(self.functions.keys()),
100
- "import_errors": self.import_errors,
101
- "ready": len(self.functions) > 0 or len(self.modules) > 0,
102
  }
103
- except Exception as exc:
104
- return self._error_response(
105
- message="Failed to run health check.",
106
- error=exc,
107
- guidance="Inspect adapter initialization and module import paths.",
108
- )
109
-
110
- # -------------------------------------------------------------------------
111
- # Internal helpers
112
- # -------------------------------------------------------------------------
113
-
114
- def _success_response(self, **payload: Any) -> Dict[str, Any]:
115
- """
116
- Build a standardized success response.
117
-
118
- Args:
119
- **payload: Additional response fields.
120
-
121
- Returns:
122
- dict: Success response with status field.
123
- """
124
- data = {"status": "success"}
125
- data.update(payload)
126
- return data
127
-
128
- def _fallback_response(self, **payload: Any) -> Dict[str, Any]:
129
- """
130
- Build a standardized fallback response.
131
 
132
- Args:
133
- **payload: Additional response fields.
 
134
 
135
- Returns:
136
- dict: Fallback response with status field.
137
- """
138
- data = {"status": "fallback"}
139
- data.update(payload)
140
- return data
141
-
142
- def _error_response(self, message: str, error: Exception, guidance: str) -> Dict[str, Any]:
143
- """
144
- Build a standardized error response.
145
-
146
- Args:
147
- message: Human-readable high-level error.
148
- error: Caught exception instance.
149
- guidance: Actionable next step in English.
150
-
151
- Returns:
152
- dict: Error response with detailed diagnostics.
153
- """
154
  return {
155
- "status": "error",
156
- "message": message,
157
- "error_type": type(error).__name__,
158
- "error": str(error),
159
- "guidance": guidance,
160
- "traceback": traceback.format_exc(),
161
  }
162
 
163
- def _invoke_callable(self, func: Any, *args: Any, **kwargs: Any) -> Dict[str, Any]:
164
- """
165
- Safely invoke a callable with flexible argument support.
166
-
167
- If invocation fails because of incompatible signature, a secondary attempt
168
- is made without arguments for zero-arg CLI-style entrypoints.
169
-
170
- Args:
171
- func: Callable target.
172
- *args: Positional arguments.
173
- **kwargs: Keyword arguments.
174
-
175
- Returns:
176
- dict: Unified result dictionary.
177
- """
178
- try:
179
- result = func(*args, **kwargs)
180
- return self._success_response(result=result, callable_name=getattr(func, "__name__", str(func)))
181
- except TypeError:
182
- try:
183
- result = func()
184
- return self._success_response(
185
- result=result,
186
- callable_name=getattr(func, "__name__", str(func)),
187
- note="Called without arguments due to signature mismatch.",
188
- )
189
- except Exception as exc:
190
- return self._error_response(
191
- message="Callable invocation failed after retry.",
192
- error=exc,
193
- guidance="Check function signature and provide compatible arguments.",
194
- )
195
- except Exception as exc:
196
- return self._error_response(
197
- message="Callable invocation failed.",
198
- error=exc,
199
- guidance="Verify input values and runtime context required by the target function.",
200
- )
201
-
202
- def _run_cli_fallback(self, module_name: str, argv: Optional[List[str]] = None) -> Dict[str, Any]:
203
- """
204
- Execute module through subprocess fallback mode.
205
-
206
- Args:
207
- module_name: Python module name for `python -m`.
208
- argv: Optional CLI arguments list.
209
-
210
- Returns:
211
- dict: Unified fallback/success/error response.
212
- """
213
- try:
214
- import subprocess
215
-
216
- cmd = [sys.executable, "-m", module_name]
217
- if argv:
218
- cmd.extend(argv)
219
-
220
- proc = subprocess.run(cmd, capture_output=True, text=True)
221
- status = "success" if proc.returncode == 0 else "fallback"
222
  return {
223
- "status": status,
224
- "mode": "cli_fallback",
225
- "command": cmd,
226
- "returncode": proc.returncode,
227
- "stdout": proc.stdout,
228
- "stderr": proc.stderr,
229
- "guidance": (
230
- "Import mode failed or was unavailable; CLI fallback was used."
231
- if status == "fallback"
232
- else "CLI fallback executed successfully."
233
- ),
234
  }
235
- except Exception as exc:
236
- return self._error_response(
237
- message="CLI fallback execution failed.",
238
- error=exc,
239
- guidance="Ensure Python runtime can execute the target module and verify module name.",
240
- )
241
-
242
- # -------------------------------------------------------------------------
243
- # Entry point wrappers: source.molmass.__main__
244
- # -------------------------------------------------------------------------
245
 
246
- def instance_source_molmass___main__(self) -> Dict[str, Any]:
247
- """
248
- Return imported module instance for source.molmass.__main__.
249
-
250
- Returns:
251
- dict: Contains module reference metadata when available.
252
- """
253
- try:
254
- key = "source.molmass.__main__"
255
- module = self.modules.get(key)
256
- if module is None:
257
- return self._fallback_response(
258
- message="Module source.molmass.__main__ is not available in import mode.",
259
- guidance="Use run_module_execution_fallback() to execute via python -m molmass.__main__.",
260
- import_errors=self.import_errors,
261
- )
262
- return self._success_response(
263
- module_key=key,
264
- module_name=getattr(module, "__name__", key),
265
- attributes=sorted([a for a in dir(module) if not a.startswith("_")]),
266
- )
267
- except Exception as exc:
268
- return self._error_response(
269
- message="Failed to get module instance for source.molmass.__main__.",
270
- error=exc,
271
- guidance="Check import configuration and source path mapping.",
272
- )
273
-
274
- def call_source_molmass___main__(self, args: Optional[List[str]] = None) -> Dict[str, Any]:
275
- """
276
- Execute source.molmass.__main__ behavior.
277
-
278
- Because __main__ modules often dispatch through side effects or imported main(),
279
- this method attempts:
280
- 1) direct call of module.main if available
281
- 2) fallback to subprocess module execution
282
-
283
- Args:
284
- args: Optional CLI-like argument list for fallback execution.
285
-
286
- Returns:
287
- dict: Unified status dictionary with execution details.
288
- """
289
- try:
290
- module = self.modules.get("source.molmass.__main__")
291
- if module is not None and hasattr(module, "main") and callable(getattr(module, "main")):
292
- return self._invoke_callable(getattr(module, "main"), args or [])
293
- return self._run_cli_fallback("molmass.__main__", argv=args or [])
294
- except Exception as exc:
295
- return self._error_response(
296
- message="Failed to execute source.molmass.__main__ entrypoint.",
297
- error=exc,
298
- guidance="Use call_source_molmass_molmass_main() as primary CLI handler when available.",
299
- )
300
-
301
- # -------------------------------------------------------------------------
302
- # Entry point wrappers: source.molmass.molmass.main
303
- # -------------------------------------------------------------------------
304
-
305
- def instance_source_molmass_molmass_main(self) -> Dict[str, Any]:
306
- """
307
- Return function metadata for source.molmass.molmass.main.
308
-
309
- Returns:
310
- dict: Function signature/doc info when available.
311
- """
312
- try:
313
- key = "source.molmass.molmass.main"
314
- func = self.functions.get(key)
315
- if func is None:
316
- return self._fallback_response(
317
- message="Function source.molmass.molmass.main is unavailable in import mode.",
318
- guidance="Use call_source_molmass___main__() or run_module_execution_fallback().",
319
- import_errors=self.import_errors,
320
- )
321
- return self._success_response(
322
- function_key=key,
323
- function_name=func.__name__,
324
- signature=str(inspect.signature(func)),
325
- doc=(inspect.getdoc(func) or ""),
326
- )
327
- except Exception as exc:
328
- return self._error_response(
329
- message="Failed to get function instance for source.molmass.molmass.main.",
330
- error=exc,
331
- guidance="Confirm module source.molmass.molmass is importable and intact.",
332
- )
333
-
334
- def call_source_molmass_molmass_main(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
335
- """
336
- Call core CLI handler: source.molmass.molmass.main.
337
-
338
- Args:
339
- *args: Positional arguments forwarded to molmass.main.
340
- **kwargs: Keyword arguments forwarded to molmass.main.
341
-
342
- Returns:
343
- dict: Unified result dictionary.
344
- """
345
- try:
346
- key = "source.molmass.molmass.main"
347
- func = self.functions.get(key)
348
- if func is None:
349
- return self._run_cli_fallback("molmass", argv=list(args) if args else None)
350
- return self._invoke_callable(func, *args, **kwargs)
351
- except Exception as exc:
352
- return self._error_response(
353
- message="Failed to execute source.molmass.molmass.main.",
354
- error=exc,
355
- guidance="Verify argument compatibility with molmass.main signature.",
356
- )
357
-
358
- # -------------------------------------------------------------------------
359
- # Entry point wrappers: source.molmass.web.main
360
- # -------------------------------------------------------------------------
361
-
362
- def instance_source_molmass_web_main(self) -> Dict[str, Any]:
363
- """
364
- Return function metadata for source.molmass.web.main.
365
-
366
- Returns:
367
- dict: Function signature/doc info when available.
368
- """
369
- try:
370
- key = "source.molmass.web.main"
371
- func = self.functions.get(key)
372
- if func is None:
373
- return self._fallback_response(
374
- message="Function source.molmass.web.main is unavailable in import mode.",
375
- guidance="Use call_source_molmass_molmass_main() for non-web usage or configure web runtime.",
376
- import_errors=self.import_errors,
377
- )
378
- return self._success_response(
379
- function_key=key,
380
- function_name=func.__name__,
381
- signature=str(inspect.signature(func)),
382
- doc=(inspect.getdoc(func) or ""),
383
- )
384
- except Exception as exc:
385
- return self._error_response(
386
- message="Failed to get function instance for source.molmass.web.main.",
387
- error=exc,
388
- guidance="Confirm source.molmass.web imports correctly in current environment.",
389
- )
390
-
391
- def call_source_molmass_web_main(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
392
- """
393
- Call web/CGI-style entrypoint: source.molmass.web.main.
394
 
395
- Args:
396
- *args: Positional arguments forwarded to web.main.
397
- **kwargs: Keyword arguments forwarded to web.main.
398
 
399
- Returns:
400
- dict: Unified result dictionary.
401
- """
402
  try:
403
- key = "source.molmass.web.main"
404
- func = self.functions.get(key)
405
- if func is None:
406
- return self._fallback_response(
407
- message="Web entrypoint unavailable in import mode.",
408
- guidance="Configure web server/CGI context or verify source.molmass.web import path.",
409
- import_errors=self.import_errors,
410
- )
411
- return self._invoke_callable(func, *args, **kwargs)
412
  except Exception as exc:
413
- return self._error_response(
414
- message="Failed to execute source.molmass.web.main.",
415
- error=exc,
416
- guidance="Ensure required web runtime context variables are provided.",
417
- )
418
-
419
- # -------------------------------------------------------------------------
420
- # Generic utility methods for MCP orchestration
421
- # -------------------------------------------------------------------------
422
-
423
- def run_module_execution_fallback(self, module_name: str, argv: Optional[List[str]] = None) -> Dict[str, Any]:
424
- """
425
- Public utility to execute any target module with subprocess fallback.
426
 
427
- Args:
428
- module_name: Module name usable with `python -m`.
429
- argv: Optional command arguments list.
 
 
 
 
 
 
 
 
 
 
430
 
431
- Returns:
432
- dict: Unified execution response.
433
- """
434
- return self._run_cli_fallback(module_name=module_name, argv=argv)
 
 
435
 
436
- def list_capabilities(self) -> Dict[str, Any]:
437
- """
438
- List adapter-exposed capabilities and recommended use order.
439
 
440
- Returns:
441
- dict: Capability inventory and usage hints.
442
- """
443
  try:
444
- return self._success_response(
445
- mode=self.mode,
446
- capabilities=[
447
- "instance_source_molmass___main__",
448
- "call_source_molmass___main__",
449
- "instance_source_molmass_molmass_main",
450
- "call_source_molmass_molmass_main",
451
- "instance_source_molmass_web_main",
452
- "call_source_molmass_web_main",
453
- "run_module_execution_fallback",
454
- "health_check",
455
- ],
456
- recommended_order=[
457
- "call_source_molmass_molmass_main",
458
- "call_source_molmass___main__",
459
- "call_source_molmass_web_main",
460
- ],
461
- )
462
  except Exception as exc:
463
- return self._error_response(
464
- message="Failed to list adapter capabilities.",
465
- error=exc,
466
- guidance="Reinitialize adapter and retry.",
467
- )
 
 
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ import pkgutil
5
  import sys
6
+ from pathlib import Path
7
+ from typing import Any
 
8
 
9
+ CURRENT_DIR = Path(__file__).resolve().parent
10
+ SOURCE_DIR = CURRENT_DIR.parents[1] / "source"
11
+ ALT_SOURCE_DIR = CURRENT_DIR.parents[5] / "workspace" / "molmass" / "source"
12
+ for candidate in (SOURCE_DIR, ALT_SOURCE_DIR):
13
+ if candidate.exists() and str(candidate) not in sys.path:
14
+ sys.path.insert(0, str(candidate))
15
 
16
 
17
  class Adapter:
18
+ """Dynamic module adapter for the target package."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
+ def __init__(self, package_name: str) -> None:
21
+ self.package_name = package_name
22
+ self.mode = "normal"
23
+ self.loaded_modules: dict[str, Any] = {}
24
+ self.failed_modules: dict[str, str] = {}
25
+ self._load_all_modules()
 
 
 
 
 
26
 
27
+ def _load_all_modules(self) -> None:
28
+ try:
29
+ root_module = importlib.import_module(self.package_name)
30
+ self.loaded_modules[self.package_name] = root_module
31
+ except Exception as exc:
32
+ self.failed_modules[self.package_name] = f"{type(exc).__name__}: {exc}"
33
+ self.mode = "blackbox"
34
+ return
35
+
36
+ package = self.loaded_modules[self.package_name]
37
+ package_paths = getattr(package, "__path__", None)
38
+ if not package_paths:
39
+ if len(self.loaded_modules) == 0:
40
+ self.mode = "blackbox"
41
+ return
42
+
43
+ prefix = f"{self.package_name}."
44
+ for module_info in pkgutil.walk_packages(package_paths, prefix=prefix):
45
+ module_name = module_info.name
46
  try:
47
+ self.loaded_modules[module_name] = importlib.import_module(module_name)
 
 
 
 
 
 
 
 
 
 
 
 
48
  except Exception as exc:
49
+ self.failed_modules[module_name] = f"{type(exc).__name__}: {exc}"
 
 
 
 
 
50
 
51
+ if len(self.loaded_modules) == 0:
52
+ self.mode = "blackbox"
 
53
 
54
+ def health(self) -> dict[str, Any]:
55
+ status = "ok" if self.loaded_modules else "fallback"
56
+ return {
57
+ "status": status,
58
+ "mode": self.mode,
59
+ "package": self.package_name,
60
+ "loaded_count": len(self.loaded_modules),
61
+ "failed_count": len(self.failed_modules),
62
+ }
63
+
64
+ def list_modules(self, include_failed: bool = True) -> dict[str, Any]:
65
+ result: dict[str, Any] = {
66
+ "status": "ok" if self.loaded_modules else "fallback",
67
+ "mode": self.mode,
68
+ "loaded": sorted(self.loaded_modules.keys()),
69
+ }
70
+ if include_failed:
71
+ result["failed"] = self.failed_modules
72
+ return result
73
+
74
+ def list_symbols(
75
+ self,
76
+ module_name: str,
77
+ public_only: bool = True,
78
+ limit: int = 100,
79
+ ) -> dict[str, Any]:
80
+ module = self.loaded_modules.get(module_name)
81
+ if module is None:
82
  return {
83
+ "status": "error",
84
+ "message": f"Module not loaded: {module_name}",
85
+ "module": module_name,
 
 
 
86
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
+ symbols = dir(module)
89
+ if public_only:
90
+ symbols = [name for name in symbols if not name.startswith("_")]
91
 
92
+ safe_limit = max(1, limit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  return {
94
+ "status": "ok",
95
+ "module": module_name,
96
+ "count": len(symbols),
97
+ "symbols": sorted(symbols)[:safe_limit],
 
 
98
  }
99
 
100
+ def call_function(
101
+ self,
102
+ module_name: str,
103
+ function_name: str,
104
+ args: list[Any] | None = None,
105
+ kwargs: dict[str, Any] | None = None,
106
+ ) -> dict[str, Any]:
107
+ module = self.loaded_modules.get(module_name)
108
+ if module is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  return {
110
+ "status": "error",
111
+ "message": f"Module not loaded: {module_name}",
 
 
 
 
 
 
 
 
 
112
  }
 
 
 
 
 
 
 
 
 
 
113
 
114
+ func = getattr(module, function_name, None)
115
+ if func is None or not callable(func):
116
+ return {
117
+ "status": "error",
118
+ "message": f"Function not found: {module_name}.{function_name}",
119
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
+ call_args = args if args is not None else []
122
+ call_kwargs = kwargs if kwargs is not None else {}
 
123
 
 
 
 
124
  try:
125
+ value = func(*call_args, **call_kwargs)
126
+ return {
127
+ "status": "ok",
128
+ "module": module_name,
129
+ "function": function_name,
130
+ "result": value,
131
+ }
 
 
132
  except Exception as exc:
133
+ return {
134
+ "status": "error",
135
+ "message": f"{type(exc).__name__}: {exc}",
136
+ "module": module_name,
137
+ "function": function_name,
138
+ }
 
 
 
 
 
 
 
139
 
140
+ def create_instance(
141
+ self,
142
+ module_name: str,
143
+ class_name: str,
144
+ init_args: list[Any] | None = None,
145
+ init_kwargs: dict[str, Any] | None = None,
146
+ ) -> dict[str, Any]:
147
+ module = self.loaded_modules.get(module_name)
148
+ if module is None:
149
+ return {
150
+ "status": "error",
151
+ "message": f"Module not loaded: {module_name}",
152
+ }
153
 
154
+ cls = getattr(module, class_name, None)
155
+ if cls is None:
156
+ return {
157
+ "status": "error",
158
+ "message": f"Class not found: {module_name}.{class_name}",
159
+ }
160
 
161
+ args = init_args if init_args is not None else []
162
+ kwargs = init_kwargs if init_kwargs is not None else {}
 
163
 
 
 
 
164
  try:
165
+ instance = cls(*args, **kwargs)
166
+ return {
167
+ "status": "ok",
168
+ "module": module_name,
169
+ "class": class_name,
170
+ "instance_type": type(instance).__name__,
171
+ "instance_repr": repr(instance),
172
+ }
 
 
 
 
 
 
 
 
 
 
173
  except Exception as exc:
174
+ return {
175
+ "status": "error",
176
+ "message": f"{type(exc).__name__}: {exc}",
177
+ "module": module_name,
178
+ "class": class_name,
179
+ }
molmass/mcp_output/mcp_plugin/main.py CHANGED
@@ -1,13 +1,12 @@
1
- """
2
- MCP Service Auto-Wrapper - Auto-generated
3
- """
4
- from mcp_service import create_app
5
 
6
- def main():
7
- """Main entry point"""
8
- app = create_app()
9
- return app
10
 
 
11
  if __name__ == "__main__":
12
- app = main()
13
- app.run()
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .mcp_service import create_app
 
4
 
 
 
 
 
5
 
6
+ # Local stdio entry point only (Claude Desktop / CLI), not for Docker/web deployment.
7
  if __name__ == "__main__":
8
+ app = create_app()
9
+ try:
10
+ app.run(transport="stdio")
11
+ except TypeError:
12
+ app.run()
molmass/mcp_output/mcp_plugin/mcp_service.py CHANGED
@@ -1,239 +1,331 @@
1
- import os
 
2
  import sys
3
- from typing import Optional, Dict, Any
 
4
 
5
- source_path = os.path.join(
6
- os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
7
- "source",
8
- )
9
- if source_path not in sys.path:
10
- sys.path.insert(0, source_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
- from fastmcp import FastMCP
13
- from molmass.molmass import Formula, from_string, analyze
14
- from molmass.elements import ELEMENTS
 
15
 
16
- mcp = FastMCP("molmass_service")
17
 
 
 
 
 
 
18
 
19
- @mcp.tool(
20
- name="parse_formula",
21
- description="Parse a chemical formula string into a canonical representation.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  )
23
- def parse_formula(formula: str) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  """
25
- Parse a chemical formula string and return normalized/canonical details.
 
 
 
 
 
 
 
 
 
26
 
27
- Parameters:
28
- formula: Chemical formula string, e.g. 'C8H10N4O2'.
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  Returns:
31
- A dictionary with:
32
- - success: bool indicating operation status
33
- - result: parsed formula details when successful
34
- - error: error message when unsuccessful
35
  """
 
 
36
  try:
37
- parsed = from_string(formula)
38
- f = Formula(parsed)
39
- result = {
40
- "input": formula,
41
- "parsed": parsed,
42
- "expanded": f.expanded,
43
- "atoms": int(f.atoms),
44
- "formula": str(f.formula),
45
- }
46
- return {"success": True, "result": result, "error": None}
47
  except Exception as exc:
48
- return {"success": False, "result": None, "error": str(exc)}
49
 
50
 
51
- @mcp.tool(
52
- name="formula_mass",
53
- description="Compute molecular mass properties for a chemical formula.",
54
- )
55
- def formula_mass(formula: str) -> Dict[str, Any]:
56
- """
57
- Compute average and monoisotopic mass information for a chemical formula.
58
 
59
- Parameters:
60
  formula: Chemical formula string.
 
61
 
62
  Returns:
63
- A dictionary with:
64
- - success: bool indicating operation status
65
- - result: mass-related properties
66
- - error: error message when unsuccessful
67
  """
 
 
 
68
  try:
69
- f = Formula(from_string(formula))
70
- result = {
71
- "formula": str(f.formula),
72
- "empirical": str(f.empirical),
73
- "atoms": int(f.atoms),
74
- "mass": float(f.mass),
75
- "isotope_mass": float(f.isotope.mass),
76
- "isotope_abundance": float(f.isotope.abundance),
77
- "nominal_mass": int(f.isotope.massnumber),
 
 
 
 
 
 
 
 
78
  }
79
- return {"success": True, "result": result, "error": None}
80
  except Exception as exc:
81
- return {"success": False, "result": None, "error": str(exc)}
82
 
83
 
84
- @mcp.tool(
85
- name="formula_composition",
86
- description="Get elemental composition for a chemical formula.",
87
- )
88
- def formula_composition(formula: str, isotopic: bool = True) -> Dict[str, Any]:
89
- """
90
- Return elemental composition entries for a formula.
91
 
92
- Parameters:
93
  formula: Chemical formula string.
94
- isotopic: Whether to include isotopic composition detail when available.
95
 
96
  Returns:
97
- A dictionary with:
98
- - success: bool indicating operation status
99
- - result: list of composition rows
100
- - error: error message when unsuccessful
101
  """
 
 
 
102
  try:
103
- f = Formula(from_string(formula))
104
- comp = f.composition(isotopic=isotopic)
105
  rows = []
106
- for item in comp.astuple():
107
- symbol, count, mass, fraction = item
108
  rows.append(
109
  {
110
- "symbol": symbol,
111
- "count": int(count),
112
- "mass": float(mass),
113
- "fraction": float(fraction),
114
  }
115
  )
116
- return {"success": True, "result": rows, "error": None}
117
  except Exception as exc:
118
- return {"success": False, "result": None, "error": str(exc)}
119
 
120
 
121
- @mcp.tool(
122
- name="formula_spectrum",
123
- description="Calculate mass distribution (spectrum) for a chemical formula.",
124
- )
125
- def formula_spectrum(formula: str, min_fraction: float = 1e-12) -> Dict[str, Any]:
126
- """
127
- Calculate isotopic mass spectrum for a chemical formula.
 
128
 
129
- Parameters:
130
  formula: Chemical formula string.
131
- min_fraction: Minimum fraction threshold for peaks included by backend behavior.
 
 
132
 
133
  Returns:
134
- A dictionary with:
135
- - success: bool indicating operation status
136
- - result: peak list and related summary
137
- - error: error message when unsuccessful
138
  """
 
 
 
139
  try:
140
- f = Formula(from_string(formula))
141
- spectrum = f.spectrum(min_fraction=min_fraction)
142
- peaks = []
143
- for mass_number, peak in spectrum.items():
144
  peaks.append(
145
  {
146
- "mass_number": int(mass_number),
147
- "mass": float(peak.mass),
148
- "fraction": float(peak.fraction),
149
- "intensity": float(peak.intensity),
150
- "mz": float(peak.mz),
151
  }
152
  )
153
- result = {
154
- "formula": str(f.formula),
155
- "peak_count": len(peaks),
156
- "peaks": peaks,
157
- }
158
- return {"success": True, "result": result, "error": None}
 
 
159
  except Exception as exc:
160
- return {"success": False, "result": None, "error": str(exc)}
161
 
162
 
163
- @mcp.tool(
164
- name="analyze_formula_text",
165
- description="Run molmass textual analysis report for a formula.",
166
- )
167
- def analyze_formula_text(formula: str, maxatoms: int = 512) -> Dict[str, Any]:
168
- """
169
- Generate the standard textual analysis output produced by molmass.
170
 
171
- Parameters:
172
- formula: Chemical formula string.
173
- maxatoms: Maximum number of atoms allowed during analysis.
174
 
175
  Returns:
176
- A dictionary with:
177
- - success: bool indicating operation status
178
- - result: text report string
179
- - error: error message when unsuccessful
180
  """
 
 
 
181
  try:
182
- report = analyze(formula, maxatoms=maxatoms)
183
- return {"success": True, "result": report, "error": None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  except Exception as exc:
185
- return {"success": False, "result": None, "error": str(exc)}
186
 
187
 
188
- @mcp.tool(
189
- name="get_element",
190
- description="Fetch periodic table metadata for an element by symbol.",
191
- )
192
- def get_element(symbol: str) -> Dict[str, Any]:
193
- """
194
- Retrieve element metadata from molmass periodic table data.
195
 
196
- Parameters:
197
- symbol: Element symbol, e.g. 'C', 'H', 'Na'.
 
 
198
 
199
  Returns:
200
- A dictionary with:
201
- - success: bool indicating operation status
202
- - result: selected element properties
203
- - error: error message when unsuccessful
204
  """
 
 
 
205
  try:
206
- normalized = symbol.strip().capitalize()
207
- element = ELEMENTS[normalized]
208
- isotopes = []
209
- for mass_number, iso in element.isotopes.items():
210
- isotopes.append(
211
- {
212
- "mass_number": int(mass_number),
213
- "mass": float(iso.mass),
214
- "abundance": float(iso.abundance),
215
- "charge": int(iso.charge),
216
- }
217
- )
218
- result = {
219
- "symbol": str(element.symbol),
220
- "name": str(element.name),
221
- "number": int(element.number),
222
- "group": int(element.group),
223
- "period": int(element.period),
224
- "block": str(element.block),
225
- "series": int(element.series),
226
- "mass": float(element.mass),
227
- "isotopes": isotopes,
228
- }
229
- return {"success": True, "result": result, "error": None}
230
  except Exception as exc:
231
- return {"success": False, "result": None, "error": str(exc)}
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
233
 
234
- def create_app() -> FastMCP:
 
235
  return mcp
236
 
237
 
238
  if __name__ == "__main__":
239
- mcp.run()
 
1
+ from __future__ import annotations
2
+
3
  import sys
4
+ from pathlib import Path
5
+ from typing import Any
6
 
7
+ CURRENT_DIR = Path(__file__).resolve().parent
8
+ SOURCE_DIR = CURRENT_DIR.parents[1] / "source"
9
+ ALT_SOURCE_DIR = CURRENT_DIR.parents[5] / "workspace" / "molmass" / "source"
10
+ for candidate in (SOURCE_DIR, ALT_SOURCE_DIR):
11
+ if candidate.exists() and str(candidate) not in sys.path:
12
+ sys.path.insert(0, str(candidate))
13
+
14
+ try:
15
+ from fastmcp import FastMCP
16
+ except Exception:
17
+ FastMCP = None
18
+
19
+ try:
20
+ import molmass as molmass_pkg
21
+ except Exception:
22
+ molmass_pkg = None
23
+
24
+ try:
25
+ from molmass import ELEMENTS
26
+ except Exception:
27
+ ELEMENTS = None
28
+
29
+ try:
30
+ from molmass import Formula
31
+ except Exception:
32
+ Formula = None
33
+
34
+ try:
35
+ from molmass import analyze
36
+ except Exception:
37
+ analyze = None
38
+
39
+ try:
40
+ from molmass import from_fractions
41
+ except Exception:
42
+ from_fractions = None
43
 
44
+ try:
45
+ from .adapter import Adapter
46
+ except Exception:
47
+ from adapter import Adapter
48
 
 
49
 
50
+ class _FallbackMCP:
51
+ def __init__(self, name: str, description: str) -> None:
52
+ self.name = name
53
+ self.description = description
54
+ self.tools: list[Any] = []
55
 
56
+ def tool(self, name: str, description: str):
57
+ def decorator(func):
58
+ setattr(func, "name", name)
59
+ setattr(func, "description", description)
60
+ self.tools.append(func)
61
+ return func
62
+
63
+ return decorator
64
+
65
+ def run(self, transport: str = "stdio", host: str = "127.0.0.1", port: int = 8000) -> None:
66
+ raise RuntimeError("fastmcp is not installed. Install dependencies first.")
67
+
68
+
69
+ mcp = (
70
+ FastMCP(name="molmass-mcp", description="MCP wrapper for molecular mass and element utilities")
71
+ if FastMCP is not None
72
+ else _FallbackMCP(
73
+ name="molmass-mcp", description="MCP wrapper for molecular mass and element utilities"
74
+ )
75
  )
76
+
77
+ adapter = Adapter(package_name="molmass")
78
+
79
+
80
+ def _success(result: Any) -> dict[str, Any]:
81
+ return {"success": True, "result": result, "error": None}
82
+
83
+
84
+ def _failure(error: str) -> dict[str, Any]:
85
+ return {"success": False, "result": None, "error": error}
86
+
87
+
88
+ @mcp.tool(name="health_check", description="Report FastMCP, molmass and adapter health")
89
+ def health_check() -> dict[str, Any]:
90
+ """Check runtime dependency availability and adapter loading state.
91
+
92
+ Returns:
93
+ Standardized health payload with dependency and module loading details.
94
  """
95
+ dependencies = {
96
+ "fastmcp": FastMCP is not None,
97
+ "molmass": molmass_pkg is not None,
98
+ "Formula": Formula is not None,
99
+ "ELEMENTS": ELEMENTS is not None,
100
+ "analyze": analyze is not None,
101
+ "from_fractions": from_fractions is not None,
102
+ }
103
+ return _success({"dependencies": dependencies, "adapter": adapter.health()})
104
+
105
 
106
+ @mcp.tool(name="analyze_formula", description="Analyze a chemical formula as formatted text report")
107
+ def analyze_formula(
108
+ formula: str,
109
+ maxatoms: int = 512,
110
+ min_intensity: float = 0.0001,
111
+ debug: bool = False,
112
+ ) -> dict[str, Any]:
113
+ """Compute a textual analysis report for a chemical formula.
114
+
115
+ Args:
116
+ formula: Input chemical formula string such as ``C8H10N4O2``.
117
+ maxatoms: Atom-count threshold above which spectrum calculation is skipped.
118
+ min_intensity: Minimum peak intensity included in generated spectrum text.
119
+ debug: If true, backend parser errors are raised by molmass internals.
120
 
121
  Returns:
122
+ Standardized response containing the report text.
 
 
 
123
  """
124
+ if analyze is None:
125
+ return _failure("molmass.analyze is unavailable")
126
  try:
127
+ report = analyze(
128
+ formula,
129
+ maxatoms=maxatoms,
130
+ min_intensity=min_intensity,
131
+ debug=debug,
132
+ )
133
+ return _success({"formula": formula, "analysis": report})
 
 
 
134
  except Exception as exc:
135
+ return _failure(f"{type(exc).__name__}: {exc}")
136
 
137
 
138
+ @mcp.tool(name="formula_summary", description="Return key mass and notation fields for a formula")
139
+ def formula_summary(formula: str, allow_empty: bool = False) -> dict[str, Any]:
140
+ """Create a summary of formula notation and masses.
 
 
 
 
141
 
142
+ Args:
143
  formula: Chemical formula string.
144
+ allow_empty: Whether empty formula input is accepted.
145
 
146
  Returns:
147
+ Standardized response with notation, mass, charge and isotope summary values.
 
 
 
148
  """
149
+ if Formula is None:
150
+ return _failure("molmass.Formula is unavailable")
151
+
152
  try:
153
+ f = Formula(formula, allow_empty=allow_empty)
154
+ payload = {
155
+ "input": formula,
156
+ "formula": f.formula,
157
+ "empirical": f.empirical,
158
+ "expanded": f.expanded,
159
+ "atoms": f.atoms,
160
+ "charge": f.charge,
161
+ "average_mass": f.mass,
162
+ "monoisotopic_mass": f.monoisotopic_mass,
163
+ "nominal_mass": f.nominal_mass,
164
+ "mz": f.mz,
165
+ "isotope": {
166
+ "mass": f.isotope.mass,
167
+ "massnumber": f.isotope.massnumber,
168
+ "abundance": f.isotope.abundance,
169
+ },
170
  }
171
+ return _success(payload)
172
  except Exception as exc:
173
+ return _failure(f"{type(exc).__name__}: {exc}")
174
 
175
 
176
+ @mcp.tool(name="composition_table", description="Return elemental composition table for a formula")
177
+ def composition_table(formula: str, isotopic: bool = True) -> dict[str, Any]:
178
+ """Compute the elemental composition rows for a formula.
 
 
 
 
179
 
180
+ Args:
181
  formula: Chemical formula string.
182
+ isotopic: Whether to preserve isotope-specific rows when available.
183
 
184
  Returns:
185
+ Standardized response containing composition tuples.
 
 
 
186
  """
187
+ if Formula is None:
188
+ return _failure("molmass.Formula is unavailable")
189
+
190
  try:
191
+ f = Formula(formula)
192
+ composition = f.composition(isotopic=isotopic)
193
  rows = []
194
+ for item in composition.astuple():
 
195
  rows.append(
196
  {
197
+ "element": item[0],
198
+ "count": item[1],
199
+ "relative_mass": item[2],
200
+ "fraction": item[3],
201
  }
202
  )
203
+ return _success({"formula": f.formula, "isotopic": isotopic, "rows": rows})
204
  except Exception as exc:
205
+ return _failure(f"{type(exc).__name__}: {exc}")
206
 
207
 
208
+ @mcp.tool(name="isotopic_spectrum", description="Return isotopic spectrum peaks for a formula")
209
+ def isotopic_spectrum(
210
+ formula: str,
211
+ min_fraction: float = 1e-09,
212
+ min_intensity: float = 1e-09,
213
+ max_peaks: int = 30,
214
+ ) -> dict[str, Any]:
215
+ """Calculate isotopic mass distribution peaks.
216
 
217
+ Args:
218
  formula: Chemical formula string.
219
+ min_fraction: Minimum fractional abundance for peak generation.
220
+ min_intensity: Minimum relative intensity threshold.
221
+ max_peaks: Maximum number of peaks returned.
222
 
223
  Returns:
224
+ Standardized response containing spectrum rows.
 
 
 
225
  """
226
+ if Formula is None:
227
+ return _failure("molmass.Formula is unavailable")
228
+
229
  try:
230
+ f = Formula(formula)
231
+ spectrum = f.spectrum(min_fraction=min_fraction, min_intensity=min_intensity)
232
+ peaks: list[dict[str, Any]] = []
233
+ for mass_number, entry in sorted(spectrum.items())[: max(1, max_peaks)]:
234
  peaks.append(
235
  {
236
+ "mass_number": mass_number,
237
+ "mass": entry.mass,
238
+ "fraction": entry.fraction,
239
+ "intensity": entry.intensity,
240
+ "mz": entry.mz,
241
  }
242
  )
243
+ return _success(
244
+ {
245
+ "formula": f.formula,
246
+ "peak_count": len(peaks),
247
+ "mean": spectrum.mean,
248
+ "peaks": peaks,
249
+ }
250
+ )
251
  except Exception as exc:
252
+ return _failure(f"{type(exc).__name__}: {exc}")
253
 
254
 
255
+ @mcp.tool(name="element_lookup", description="Lookup periodic element properties by symbol, name or atomic number")
256
+ def element_lookup(identifier: str) -> dict[str, Any]:
257
+ """Retrieve element data for symbol, English name, or atomic number.
 
 
 
 
258
 
259
+ Args:
260
+ identifier: Element symbol (e.g. ``C``), name (e.g. ``Carbon``), or atomic number string.
 
261
 
262
  Returns:
263
+ Standardized response with key physicochemical properties and isotope count.
 
 
 
264
  """
265
+ if ELEMENTS is None:
266
+ return _failure("molmass.ELEMENTS is unavailable")
267
+
268
  try:
269
+ key: str | int
270
+ cleaned = identifier.strip()
271
+ key = int(cleaned) if cleaned.isdigit() else cleaned
272
+ element = ELEMENTS[key]
273
+ result = {
274
+ "number": element.number,
275
+ "symbol": element.symbol,
276
+ "name": element.name,
277
+ "mass": element.mass,
278
+ "group": element.group,
279
+ "period": element.period,
280
+ "block": element.block,
281
+ "series": element.series,
282
+ "description": element.description,
283
+ "isotope_count": len(element.isotopes),
284
+ }
285
+ return _success(result)
286
  except Exception as exc:
287
+ return _failure(f"{type(exc).__name__}: {exc}")
288
 
289
 
290
+ @mcp.tool(name="formula_from_fractions", description="Infer empirical-style formula from elemental mass fractions")
291
+ def formula_from_fractions(fractions: dict[str, float], maxcount: int = 10, precision: float = 0.0001) -> dict[str, Any]:
292
+ """Generate a formula from elemental mass fractions.
 
 
 
 
293
 
294
+ Args:
295
+ fractions: Mapping from element symbol (or isotope notation) to mass fraction.
296
+ maxcount: Maximum count to consider per element while solving the ratio.
297
+ precision: Error tolerance used in ratio approximation.
298
 
299
  Returns:
300
+ Standardized response containing generated formula.
 
 
 
301
  """
302
+ if from_fractions is None:
303
+ return _failure("molmass.from_fractions is unavailable")
304
+
305
  try:
306
+ formula = from_fractions(fractions, maxcount=maxcount, precision=precision)
307
+ return _success({"fractions": fractions, "formula": formula})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  except Exception as exc:
309
+ return _failure(f"{type(exc).__name__}: {exc}")
310
+
311
+
312
+ @mcp.tool(name="list_modules", description="List importable modules discovered by the adapter")
313
+ def list_modules(include_failed: bool = True) -> dict[str, Any]:
314
+ """List modules loaded by the dynamic adapter.
315
+
316
+ Args:
317
+ include_failed: Whether failed imports should be included in the output.
318
+
319
+ Returns:
320
+ Standardized response with loaded and failed module names.
321
+ """
322
+ return _success(adapter.list_modules(include_failed=include_failed))
323
 
324
 
325
+ def create_app() -> Any:
326
+ """Return the module-level MCP application instance."""
327
  return mcp
328
 
329
 
330
  if __name__ == "__main__":
331
+ mcp.run()
molmass/mcp_output/requirements.txt CHANGED
@@ -1,5 +1,2 @@
1
  fastmcp
2
- fastapi
3
- uvicorn[standard]
4
- pydantic>=2.0.0
5
- standard library (dataclasses/typing/re/math/etc.)
 
1
  fastmcp
2
+ molmass
 
 
 
molmass/mcp_output/start_mcp.py CHANGED
@@ -1,30 +1,38 @@
 
1
 
2
- """
3
- MCP Service Startup Entry
4
- """
5
- import sys
6
  import os
 
 
7
 
8
- project_root = os.path.dirname(os.path.abspath(__file__))
9
- mcp_plugin_dir = os.path.join(project_root, "mcp_plugin")
10
- if mcp_plugin_dir not in sys.path:
11
- sys.path.insert(0, mcp_plugin_dir)
12
 
13
  from mcp_service import create_app
14
 
15
- def main():
16
- """Start FastMCP service"""
 
 
17
  app = create_app()
18
- # Use environment variable to configure port, default 8000
19
- port = int(os.environ.get("MCP_PORT", "8000"))
20
-
21
- # Choose transport mode based on environment variable
22
- transport = os.environ.get("MCP_TRANSPORT", "stdio")
 
 
 
23
  if transport == "http":
24
- app.run(transport="http", host="0.0.0.0", port=port)
25
- else:
26
- # Default to STDIO mode
27
- app.run()
 
 
 
 
28
 
29
  if __name__ == "__main__":
30
  main()
 
1
+ from __future__ import annotations
2
 
 
 
 
 
3
  import os
4
+ import sys
5
+ from pathlib import Path
6
 
7
+ CURRENT_DIR = Path(__file__).resolve().parent
8
+ PLUGIN_DIR = CURRENT_DIR / "mcp_plugin"
9
+ if str(PLUGIN_DIR) not in sys.path:
10
+ sys.path.insert(0, str(PLUGIN_DIR))
11
 
12
  from mcp_service import create_app
13
 
14
+
15
+ def main() -> None:
16
+ transport = os.getenv("MCP_TRANSPORT", "stdio").strip().lower()
17
+ port = int(os.getenv("MCP_PORT", "8000"))
18
  app = create_app()
19
+
20
+ if transport == "stdio":
21
+ try:
22
+ app.run(transport="stdio")
23
+ except TypeError:
24
+ app.run()
25
+ return
26
+
27
  if transport == "http":
28
+ try:
29
+ app.run(transport="http", host="0.0.0.0", port=port, path="/mcp")
30
+ except TypeError:
31
+ app.run(transport="http", host="0.0.0.0", port=port)
32
+ return
33
+
34
+ raise ValueError(f"Unsupported MCP_TRANSPORT='{transport}'. Use 'stdio' or 'http'.")
35
+
36
 
37
  if __name__ == "__main__":
38
  main()
port.json CHANGED
@@ -1,5 +1 @@
1
- {
2
- "repo": "molmass",
3
- "port": 7909,
4
- "timestamp": 1773407876
5
- }
 
1
+ {"port": 7860}
 
 
 
 
requirements.txt CHANGED
@@ -1,5 +1,4 @@
1
  fastmcp
 
2
  fastapi
3
- uvicorn[standard]
4
- pydantic>=2.0.0
5
-
 
1
  fastmcp
2
+ molmass
3
  fastapi
4
+ uvicorn
 
 
run_docker.ps1 CHANGED
@@ -1,26 +1,11 @@
1
- cd $PSScriptRoot
2
  $ErrorActionPreference = "Stop"
3
- $entryName = if ($env:MCP_ENTRY_NAME) { $env:MCP_ENTRY_NAME } else { "molmass" }
4
- $entryUrl = if ($env:MCP_ENTRY_URL) { $env:MCP_ENTRY_URL } else { "http://localhost:7909/mcp" }
5
- $imageName = if ($env:MCP_IMAGE_NAME) { $env:MCP_IMAGE_NAME } else { "molmass-mcp" }
6
- $mcpDir = Join-Path $env:USERPROFILE ".cursor"
7
- $mcpPath = Join-Path $mcpDir "mcp.json"
8
- if (!(Test-Path $mcpDir)) { New-Item -ItemType Directory -Path $mcpDir | Out-Null }
9
- $config = @{}
10
- if (Test-Path $mcpPath) {
11
- try { $config = Get-Content $mcpPath -Raw | ConvertFrom-Json } catch { $config = @{} }
12
- }
13
- $serversOrdered = [ordered]@{}
14
- if ($config -and ($config.PSObject.Properties.Name -contains "mcpServers") -and $config.mcpServers) {
15
- $existing = $config.mcpServers
16
- if ($existing -is [pscustomobject]) {
17
- foreach ($p in $existing.PSObject.Properties) { if ($p.Name -ne $entryName) { $serversOrdered[$p.Name] = $p.Value } }
18
- } elseif ($existing -is [System.Collections.IDictionary]) {
19
- foreach ($k in $existing.Keys) { if ($k -ne $entryName) { $serversOrdered[$k] = $existing[$k] } }
20
- }
21
- }
22
- $serversOrdered[$entryName] = @{ url = $entryUrl }
23
- $config = @{ mcpServers = $serversOrdered }
24
- $config | ConvertTo-Json -Depth 10 | Set-Content -Path $mcpPath -Encoding UTF8
25
  docker build -t $imageName .
26
- docker run --rm -p 7909:7860 $imageName
 
 
 
 
1
  $ErrorActionPreference = "Stop"
2
+
3
+ $portConfig = Get-Content -Raw "port.json" | ConvertFrom-Json
4
+ $port = [int]$portConfig.port
5
+ $imageName = "molmass-mcp"
6
+
7
+ Write-Host "Building Docker image: $imageName"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  docker build -t $imageName .
9
+
10
+ Write-Host "Running container on port $port"
11
+ docker run --rm -it -p "${port}:${port}" $imageName
run_docker.sh CHANGED
@@ -1,75 +1,11 @@
1
  #!/usr/bin/env bash
2
  set -euo pipefail
3
- cd "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
4
- mcp_entry_name="${MCP_ENTRY_NAME:-molmass}"
5
- mcp_entry_url="${MCP_ENTRY_URL:-http://localhost:7909/mcp}"
6
- mcp_dir="${HOME}/.cursor"
7
- mcp_path="${mcp_dir}/mcp.json"
8
- mkdir -p "${mcp_dir}"
9
- if command -v python3 >/dev/null 2>&1; then
10
- python3 - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY'
11
- import json, os, sys
12
- path, name, url = sys.argv[1:4]
13
- cfg = {"mcpServers": {}}
14
- if os.path.exists(path):
15
- try:
16
- with open(path, "r", encoding="utf-8") as f:
17
- cfg = json.load(f)
18
- except Exception:
19
- cfg = {"mcpServers": {}}
20
- if not isinstance(cfg, dict):
21
- cfg = {"mcpServers": {}}
22
- servers = cfg.get("mcpServers")
23
- if not isinstance(servers, dict):
24
- servers = {}
25
- ordered = {}
26
- for k, v in servers.items():
27
- if k != name:
28
- ordered[k] = v
29
- ordered[name] = {"url": url}
30
- cfg = {"mcpServers": ordered}
31
- with open(path, "w", encoding="utf-8") as f:
32
- json.dump(cfg, f, indent=2, ensure_ascii=False)
33
- PY
34
- elif command -v python >/dev/null 2>&1; then
35
- python - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY'
36
- import json, os, sys
37
- path, name, url = sys.argv[1:4]
38
- cfg = {"mcpServers": {}}
39
- if os.path.exists(path):
40
- try:
41
- with open(path, "r", encoding="utf-8") as f:
42
- cfg = json.load(f)
43
- except Exception:
44
- cfg = {"mcpServers": {}}
45
- if not isinstance(cfg, dict):
46
- cfg = {"mcpServers": {}}
47
- servers = cfg.get("mcpServers")
48
- if not isinstance(servers, dict):
49
- servers = {}
50
- ordered = {}
51
- for k, v in servers.items():
52
- if k != name:
53
- ordered[k] = v
54
- ordered[name] = {"url": url}
55
- cfg = {"mcpServers": ordered}
56
- with open(path, "w", encoding="utf-8") as f:
57
- json.dump(cfg, f, indent=2, ensure_ascii=False)
58
- PY
59
- elif command -v jq >/dev/null 2>&1; then
60
- name="${mcp_entry_name}"; url="${mcp_entry_url}"
61
- if [ -f "${mcp_path}" ]; then
62
- tmp="$(mktemp)"
63
- jq --arg name "$name" --arg url "$url" '
64
- .mcpServers = (.mcpServers // {})
65
- | .mcpServers as $s
66
- | ($s | with_entries(select(.key != $name))) as $base
67
- | .mcpServers = ($base + {($name): {"url": $url}})
68
- ' "${mcp_path}" > "${tmp}" && mv "${tmp}" "${mcp_path}"
69
- else
70
- printf '{ "mcpServers": { "%s": { "url": "%s" } } }
71
- ' "$name" "$url" > "${mcp_path}"
72
- fi
73
- fi
74
- docker build -t molmass-mcp .
75
- docker run --rm -p 7909:7860 molmass-mcp
 
1
  #!/usr/bin/env bash
2
  set -euo pipefail
3
+
4
+ PORT=$(python3 -c "import json; print(json.load(open('port.json', 'r', encoding='utf-8'))['port'])")
5
+ IMAGE_NAME="molmass-mcp"
6
+
7
+ echo "Building Docker image: ${IMAGE_NAME}"
8
+ docker build -t "${IMAGE_NAME}" .
9
+
10
+ echo "Running container on port ${PORT}"
11
+ docker run --rm -it -p "${PORT}:${PORT}" "${IMAGE_NAME}"