decimer-mcp-server 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DocMinus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: decimer-mcp-server
3
+ Version: 0.1.2
4
+ Summary: MCP adapter server for DECIMER FastAPI image-to-SMILES endpoint
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: mcp>=1.2.0
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: pydantic>=2.8.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
13
+ Dynamic: license-file
14
+
15
+ # DecimerMCPServer
16
+
17
+ MCP server that exposes DECIMER image-to-SMILES functionality as tool calls.
18
+
19
+ This project is a thin adapter over the existing FastAPI service in `DecimerServerAPI`.
20
+ It does not run DECIMER models directly.
21
+ The adapter sends JSON requests by default, with automatic fallback to form payloads for compatibility.
22
+
23
+ ## Tools
24
+
25
+ - `server_health`: Checks whether the DECIMER FastAPI server is reachable.
26
+ - `analyze_chemical_image`: Sends a base64-encoded image to `/image2smiles/` and returns structured output.
27
+
28
+ ## Requirements
29
+
30
+ - Python 3.10+
31
+ - Running DECIMER API server (default: `http://localhost:8099`)
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ cd /Users/a/dev/DecimerMCPServer
37
+ uv venv
38
+ uv sync
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ Copy `.env.example` values into your environment:
44
+
45
+ - `DECIMER_API_BASE_URL` (default `http://localhost:8099`)
46
+ - `DECIMER_API_TIMEOUT_SECONDS` (default `60`)
47
+ - `DECIMER_MAX_IMAGE_BYTES` (default `10000000`)
48
+ - `DECIMER_MCP_LOG_LEVEL` (default `INFO`)
49
+
50
+ ## Run (stdio transport)
51
+
52
+ ```bash
53
+ uv run decimer-mcp-server
54
+ ```
55
+
56
+ or
57
+
58
+ ```bash
59
+ uv run python -m decimer_mcp_server
60
+ ```
61
+
62
+ ## Example MCP client config
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "decimer": {
68
+ "command": "uv",
69
+ "args": ["run", "python", "-m", "decimer_mcp_server"],
70
+ "env": {
71
+ "DECIMER_API_BASE_URL": "http://localhost:8099"
72
+ }
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ ## Output shape
79
+
80
+ `analyze_chemical_image` returns:
81
+
82
+ ```json
83
+ {
84
+ "ok": true,
85
+ "smiles": "CCO",
86
+ "reason": null,
87
+ "api_status_code": 200,
88
+ "api_message": null,
89
+ "classifier_score": 0.0000012,
90
+ "classifier_threshold": 0.3,
91
+ "classifier_decision": "structure_like"
92
+ }
93
+ ```
94
+
95
+ When no SMILES is returned by API classifier behavior:
96
+
97
+ ```json
98
+ {
99
+ "ok": true,
100
+ "smiles": null,
101
+ "reason": "not_chemical_structure",
102
+ "api_status_code": 200,
103
+ "api_message": "No SMILES returned by API",
104
+ "classifier_score": 0.99999,
105
+ "classifier_threshold": 0.3,
106
+ "classifier_decision": "not_structure_like"
107
+ }
108
+ ```
109
+
110
+ ## Development tests
111
+
112
+ ```bash
113
+ uv sync --extra dev
114
+ uv run pytest
115
+ ```
116
+
117
+ Make targets:
118
+
119
+ ```bash
120
+ make sync
121
+ make test
122
+ ```
123
+
124
+ ## Smoke test helper
125
+
126
+ Run one health check + one inference call against your DECIMER API:
127
+
128
+ ```bash
129
+ cd /Users/a/dev/DecimerMCPServer
130
+ DECIMER_API_BASE_URL=http://chitchat:8099 uv run decimer-mcp-smoke-test --image /Users/a/dev/DecimerServerAPI/example_usage/structure.png
131
+ ```
132
+
133
+ If you keep settings in `.env`, load it with:
134
+
135
+ ```bash
136
+ uv run --env-file .env decimer-mcp-smoke-test --image /Users/a/dev/DecimerServerAPI/example_usage/structure.png
137
+ ```
138
+
139
+ or use make:
140
+
141
+ ```bash
142
+ make smoke
143
+ ```
144
+
145
+ Override the image path if needed:
146
+
147
+ ```bash
148
+ make smoke SMOKE_IMAGE=/absolute/path/to/image.png
149
+
150
+ ## MCP Registry publishing
151
+
152
+ Tags matching `v*` trigger `.github/workflows/publish-mcp.yml`.
153
+
154
+ Workflow steps:
155
+ - installs `mcp-publisher`
156
+ - validates `server.json`
157
+ - calls registry publish using secret `MCP_REGISTRY_TOKEN`
158
+ - publishes slug `io.github.DocMinus/decimer-mcp-server` (case sensitive; must match registry grant)
159
+
160
+ Before tagging:
161
+ 1. Update `pyproject.toml` + `server.json` versions
162
+ 2. Ensure `server.json` stays valid (`uv pip install jsonschema && python validate snippet from AGENTS.md`)
163
+ 3. Add GitHub repo secret `MCP_REGISTRY_TOKEN` (GitHub PAT with `repo`, `workflow` scopes)
164
+
165
+ Release flow:
166
+ ```bash
167
+ git tag v0.1.1
168
+ git push origin v0.1.1
169
+ ```
170
+
171
+ Monitor Actions tab. If publish fails, rerun using workflow dispatch after fixing issues.
172
+ ```
173
+
174
+ ## Contribution
175
+ This project was built by DocMinus with AI-assisted coding support (OpenCode/Copilot-style tooling), then reviewed and tested by the author.
176
+
177
+ ## AI usage policy
178
+
179
+ - AI assistance was used for scaffolding, implementation drafts, and documentation edits.
180
+ - Final technical decisions, validation runs, and acceptance were performed by the maintainer.
181
+ - Runtime behavior should be validated with local tests (`make test`) and smoke tests (`make smoke`) before release.
@@ -0,0 +1,167 @@
1
+ # DecimerMCPServer
2
+
3
+ MCP server that exposes DECIMER image-to-SMILES functionality as tool calls.
4
+
5
+ This project is a thin adapter over the existing FastAPI service in `DecimerServerAPI`.
6
+ It does not run DECIMER models directly.
7
+ The adapter sends JSON requests by default, with automatic fallback to form payloads for compatibility.
8
+
9
+ ## Tools
10
+
11
+ - `server_health`: Checks whether the DECIMER FastAPI server is reachable.
12
+ - `analyze_chemical_image`: Sends a base64-encoded image to `/image2smiles/` and returns structured output.
13
+
14
+ ## Requirements
15
+
16
+ - Python 3.10+
17
+ - Running DECIMER API server (default: `http://localhost:8099`)
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ cd /Users/a/dev/DecimerMCPServer
23
+ uv venv
24
+ uv sync
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ Copy `.env.example` values into your environment:
30
+
31
+ - `DECIMER_API_BASE_URL` (default `http://localhost:8099`)
32
+ - `DECIMER_API_TIMEOUT_SECONDS` (default `60`)
33
+ - `DECIMER_MAX_IMAGE_BYTES` (default `10000000`)
34
+ - `DECIMER_MCP_LOG_LEVEL` (default `INFO`)
35
+
36
+ ## Run (stdio transport)
37
+
38
+ ```bash
39
+ uv run decimer-mcp-server
40
+ ```
41
+
42
+ or
43
+
44
+ ```bash
45
+ uv run python -m decimer_mcp_server
46
+ ```
47
+
48
+ ## Example MCP client config
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "decimer": {
54
+ "command": "uv",
55
+ "args": ["run", "python", "-m", "decimer_mcp_server"],
56
+ "env": {
57
+ "DECIMER_API_BASE_URL": "http://localhost:8099"
58
+ }
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ## Output shape
65
+
66
+ `analyze_chemical_image` returns:
67
+
68
+ ```json
69
+ {
70
+ "ok": true,
71
+ "smiles": "CCO",
72
+ "reason": null,
73
+ "api_status_code": 200,
74
+ "api_message": null,
75
+ "classifier_score": 0.0000012,
76
+ "classifier_threshold": 0.3,
77
+ "classifier_decision": "structure_like"
78
+ }
79
+ ```
80
+
81
+ When no SMILES is returned by API classifier behavior:
82
+
83
+ ```json
84
+ {
85
+ "ok": true,
86
+ "smiles": null,
87
+ "reason": "not_chemical_structure",
88
+ "api_status_code": 200,
89
+ "api_message": "No SMILES returned by API",
90
+ "classifier_score": 0.99999,
91
+ "classifier_threshold": 0.3,
92
+ "classifier_decision": "not_structure_like"
93
+ }
94
+ ```
95
+
96
+ ## Development tests
97
+
98
+ ```bash
99
+ uv sync --extra dev
100
+ uv run pytest
101
+ ```
102
+
103
+ Make targets:
104
+
105
+ ```bash
106
+ make sync
107
+ make test
108
+ ```
109
+
110
+ ## Smoke test helper
111
+
112
+ Run one health check + one inference call against your DECIMER API:
113
+
114
+ ```bash
115
+ cd /Users/a/dev/DecimerMCPServer
116
+ DECIMER_API_BASE_URL=http://chitchat:8099 uv run decimer-mcp-smoke-test --image /Users/a/dev/DecimerServerAPI/example_usage/structure.png
117
+ ```
118
+
119
+ If you keep settings in `.env`, load it with:
120
+
121
+ ```bash
122
+ uv run --env-file .env decimer-mcp-smoke-test --image /Users/a/dev/DecimerServerAPI/example_usage/structure.png
123
+ ```
124
+
125
+ or use make:
126
+
127
+ ```bash
128
+ make smoke
129
+ ```
130
+
131
+ Override the image path if needed:
132
+
133
+ ```bash
134
+ make smoke SMOKE_IMAGE=/absolute/path/to/image.png
135
+
136
+ ## MCP Registry publishing
137
+
138
+ Tags matching `v*` trigger `.github/workflows/publish-mcp.yml`.
139
+
140
+ Workflow steps:
141
+ - installs `mcp-publisher`
142
+ - validates `server.json`
143
+ - calls registry publish using secret `MCP_REGISTRY_TOKEN`
144
+ - publishes slug `io.github.DocMinus/decimer-mcp-server` (case sensitive; must match registry grant)
145
+
146
+ Before tagging:
147
+ 1. Update `pyproject.toml` + `server.json` versions
148
+ 2. Ensure `server.json` stays valid (`uv pip install jsonschema && python validate snippet from AGENTS.md`)
149
+ 3. Add GitHub repo secret `MCP_REGISTRY_TOKEN` (GitHub PAT with `repo`, `workflow` scopes)
150
+
151
+ Release flow:
152
+ ```bash
153
+ git tag v0.1.1
154
+ git push origin v0.1.1
155
+ ```
156
+
157
+ Monitor Actions tab. If publish fails, rerun using workflow dispatch after fixing issues.
158
+ ```
159
+
160
+ ## Contribution
161
+ This project was built by DocMinus with AI-assisted coding support (OpenCode/Copilot-style tooling), then reviewed and tested by the author.
162
+
163
+ ## AI usage policy
164
+
165
+ - AI assistance was used for scaffolding, implementation drafts, and documentation edits.
166
+ - Final technical decisions, validation runs, and acceptance were performed by the maintainer.
167
+ - Runtime behavior should be validated with local tests (`make test`) and smoke tests (`make smoke`) before release.
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "decimer-mcp-server"
3
+ version = "0.1.2"
4
+ description = "MCP adapter server for DECIMER FastAPI image-to-SMILES endpoint"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "mcp>=1.2.0",
9
+ "httpx>=0.27.0",
10
+ "pydantic>=2.8.0",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ dev = [
15
+ "pytest>=8.0.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ decimer-mcp-server = "decimer_mcp_server.__main__:main"
20
+ decimer-mcp-smoke-test = "decimer_mcp_server.smoke_test:main"
21
+
22
+ [build-system]
23
+ requires = ["setuptools>=68", "wheel"]
24
+ build-backend = "setuptools.build_meta"
25
+
26
+ [tool.setuptools]
27
+ package-dir = {"" = "src"}
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from .server import mcp
4
+
5
+
6
+ def main() -> None:
7
+ mcp.run(transport="stdio")
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ def _get_env_str(name: str, default: str) -> str:
7
+ value = os.getenv(name)
8
+ if value is None or value.strip() == "":
9
+ return default
10
+ return value.strip()
11
+
12
+
13
+ def _get_env_int(name: str, default: int) -> int:
14
+ value = os.getenv(name)
15
+ if value is None or value.strip() == "":
16
+ return default
17
+ try:
18
+ parsed = int(value)
19
+ except ValueError as exc:
20
+ raise ValueError(f"Environment variable {name} must be an integer") from exc
21
+ if parsed <= 0:
22
+ raise ValueError(f"Environment variable {name} must be greater than 0")
23
+ return parsed
24
+
25
+
26
+ def _get_env_float(name: str, default: float) -> float:
27
+ value = os.getenv(name)
28
+ if value is None or value.strip() == "":
29
+ return default
30
+ try:
31
+ parsed = float(value)
32
+ except ValueError as exc:
33
+ raise ValueError(f"Environment variable {name} must be a float") from exc
34
+ if parsed <= 0:
35
+ raise ValueError(f"Environment variable {name} must be greater than 0")
36
+ return parsed
37
+
38
+
39
+ class Settings:
40
+ def __init__(self) -> None:
41
+ self.decimer_api_base_url = _get_env_str(
42
+ "DECIMER_API_BASE_URL", "http://localhost:8099"
43
+ )
44
+ self.decimer_api_timeout_seconds = _get_env_float(
45
+ "DECIMER_API_TIMEOUT_SECONDS", 60.0
46
+ )
47
+ self.decimer_max_image_bytes = _get_env_int(
48
+ "DECIMER_MAX_IMAGE_BYTES", 10_000_000
49
+ )
50
+ self.decimer_mcp_log_level = _get_env_str("DECIMER_MCP_LOG_LEVEL", "INFO")
51
+
52
+
53
+ settings = Settings()
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from dataclasses import dataclass
5
+
6
+ import httpx
7
+
8
+
9
+ @dataclass
10
+ class APIResult:
11
+ ok: bool
12
+ status_code: int
13
+ smiles: str | None = None
14
+ message: str | None = None
15
+ reason: str | None = None
16
+ classifier_score: float | None = None
17
+ classifier_threshold: float | None = None
18
+ classifier_decision: str | None = None
19
+
20
+
21
+ class DecimerAPIClient:
22
+ def __init__(
23
+ self, base_url: str, timeout_seconds: float, max_image_bytes: int
24
+ ) -> None:
25
+ self.base_url = base_url.rstrip("/")
26
+ self.timeout_seconds = timeout_seconds
27
+ self.max_image_bytes = max_image_bytes
28
+
29
+ def _validate_image_size(self, encoded_image: str) -> None:
30
+ decoded = base64.b64decode(encoded_image, validate=True)
31
+ if len(decoded) > self.max_image_bytes:
32
+ raise ValueError(
33
+ f"Decoded image size exceeds limit of {self.max_image_bytes} bytes"
34
+ )
35
+
36
+ async def health(self) -> APIResult:
37
+ url = f"{self.base_url}/"
38
+ try:
39
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
40
+ response = await client.get(url)
41
+ except httpx.TimeoutException:
42
+ return APIResult(
43
+ ok=False,
44
+ status_code=504,
45
+ message="DECIMER API request timed out",
46
+ reason="timeout",
47
+ )
48
+ except httpx.HTTPError as exc:
49
+ return APIResult(
50
+ ok=False,
51
+ status_code=503,
52
+ message=f"DECIMER API unreachable: {exc}",
53
+ reason="unreachable",
54
+ )
55
+
56
+ message = None
57
+ try:
58
+ payload = response.json()
59
+ message = payload.get("Message") or payload.get("message")
60
+ except ValueError:
61
+ message = response.text
62
+
63
+ return APIResult(
64
+ ok=response.status_code == 200,
65
+ status_code=response.status_code,
66
+ message=message,
67
+ reason=None if response.status_code == 200 else "http_error",
68
+ )
69
+
70
+ async def image_to_smiles(
71
+ self,
72
+ encoded_image: str,
73
+ is_hand_drawn: bool,
74
+ classify_image: bool,
75
+ ) -> APIResult:
76
+ self._validate_image_size(encoded_image)
77
+ url = f"{self.base_url}/image2smiles/"
78
+ json_payload = {
79
+ "encoded_image": encoded_image,
80
+ "is_hand_drawn": is_hand_drawn,
81
+ "classify_image": classify_image,
82
+ }
83
+ form_data = {
84
+ "encoded_image": encoded_image,
85
+ "is_hand_drawn": str(is_hand_drawn).lower(),
86
+ "classify_image": str(classify_image).lower(),
87
+ }
88
+
89
+ try:
90
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
91
+ response = await client.post(url, json=json_payload)
92
+ if response.status_code in {400, 415, 422}:
93
+ response = await client.post(url, data=form_data)
94
+ except httpx.TimeoutException:
95
+ return APIResult(
96
+ ok=False,
97
+ status_code=504,
98
+ message="DECIMER API request timed out",
99
+ reason="timeout",
100
+ )
101
+ except httpx.HTTPError as exc:
102
+ return APIResult(
103
+ ok=False,
104
+ status_code=503,
105
+ message=f"DECIMER API unreachable: {exc}",
106
+ reason="unreachable",
107
+ )
108
+
109
+ try:
110
+ payload = response.json()
111
+ except ValueError:
112
+ payload = {"message": response.text}
113
+
114
+ if response.status_code != 200:
115
+ return APIResult(
116
+ ok=False,
117
+ status_code=response.status_code,
118
+ message=payload.get("message", "Unknown API error"),
119
+ reason="http_error",
120
+ )
121
+
122
+ smiles = payload.get("smiles")
123
+ if smiles is None:
124
+ return APIResult(
125
+ ok=True,
126
+ status_code=200,
127
+ smiles=None,
128
+ message=payload.get("message", "No SMILES returned by API"),
129
+ reason=payload.get("reason", "no_smiles_returned"),
130
+ classifier_score=payload.get("classifier_score"),
131
+ classifier_threshold=payload.get("classifier_threshold"),
132
+ classifier_decision=payload.get("classifier_decision"),
133
+ )
134
+
135
+ return APIResult(
136
+ ok=True,
137
+ status_code=200,
138
+ smiles=smiles,
139
+ classifier_score=payload.get("classifier_score"),
140
+ classifier_threshold=payload.get("classifier_threshold"),
141
+ classifier_decision=payload.get("classifier_decision"),
142
+ )
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+
9
+ class AnalyzeChemicalImageInput(BaseModel):
10
+ encoded_image: str = Field(
11
+ ..., description="Base64-encoded image content (PNG/JPEG recommended)."
12
+ )
13
+ is_hand_drawn: bool = Field(
14
+ default=False,
15
+ description="Whether the depicted chemical structure is hand-drawn.",
16
+ )
17
+ classify_image: bool = Field(
18
+ default=True,
19
+ description="Whether to run image classification before DECIMER OCR.",
20
+ )
21
+
22
+ @field_validator("encoded_image")
23
+ @classmethod
24
+ def validate_base64(cls, value: str) -> str:
25
+ stripped = value.strip()
26
+ if not stripped:
27
+ raise ValueError("encoded_image must not be empty")
28
+ try:
29
+ base64.b64decode(stripped, validate=True)
30
+ except (binascii.Error, ValueError) as exc:
31
+ raise ValueError("encoded_image is not valid base64") from exc
32
+ return stripped
33
+
34
+
35
+ class AnalyzeChemicalImageOutput(BaseModel):
36
+ ok: bool
37
+ smiles: str | None
38
+ reason: str | None = None
39
+ api_status_code: int
40
+ api_message: str | None = None
41
+ classifier_score: float | None = None
42
+ classifier_threshold: float | None = None
43
+ classifier_decision: str | None = None
44
+
45
+
46
+ class ServerHealthOutput(BaseModel):
47
+ ok: bool
48
+ api_status_code: int
49
+ message: str
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ from .config import settings
6
+ from .decimer_api_client import DecimerAPIClient
7
+ from .schemas import (
8
+ AnalyzeChemicalImageInput,
9
+ AnalyzeChemicalImageOutput,
10
+ ServerHealthOutput,
11
+ )
12
+
13
+
14
+ mcp = FastMCP("decimer-mcp-server")
15
+ api_client = DecimerAPIClient(
16
+ base_url=settings.decimer_api_base_url,
17
+ timeout_seconds=settings.decimer_api_timeout_seconds,
18
+ max_image_bytes=settings.decimer_max_image_bytes,
19
+ )
20
+
21
+
22
+ @mcp.tool()
23
+ async def server_health() -> dict:
24
+ """Check DECIMER FastAPI server availability."""
25
+ result = await api_client.health()
26
+ output = ServerHealthOutput(
27
+ ok=result.ok,
28
+ api_status_code=result.status_code,
29
+ message=result.message or "No response message",
30
+ )
31
+ return output.model_dump()
32
+
33
+
34
+ @mcp.tool()
35
+ async def analyze_chemical_image(
36
+ encoded_image: str,
37
+ is_hand_drawn: bool = False,
38
+ classify_image: bool = True,
39
+ ) -> dict:
40
+ """Analyze a base64 image and return a predicted SMILES string when available."""
41
+ parsed = AnalyzeChemicalImageInput(
42
+ encoded_image=encoded_image,
43
+ is_hand_drawn=is_hand_drawn,
44
+ classify_image=classify_image,
45
+ )
46
+
47
+ try:
48
+ result = await api_client.image_to_smiles(
49
+ encoded_image=parsed.encoded_image,
50
+ is_hand_drawn=parsed.is_hand_drawn,
51
+ classify_image=parsed.classify_image,
52
+ )
53
+ except ValueError as exc:
54
+ output = AnalyzeChemicalImageOutput(
55
+ ok=False,
56
+ smiles=None,
57
+ reason="image_too_large",
58
+ api_status_code=413,
59
+ api_message=str(exc),
60
+ )
61
+ return output.model_dump()
62
+
63
+ output = AnalyzeChemicalImageOutput(
64
+ ok=result.ok,
65
+ smiles=result.smiles,
66
+ reason=result.reason,
67
+ api_status_code=result.status_code,
68
+ api_message=result.message,
69
+ classifier_score=result.classifier_score,
70
+ classifier_threshold=result.classifier_threshold,
71
+ classifier_decision=result.classifier_decision,
72
+ )
73
+ return output.model_dump()
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import base64
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from .decimer_api_client import DecimerAPIClient
10
+
11
+
12
+ def parse_args() -> argparse.Namespace:
13
+ parser = argparse.ArgumentParser(
14
+ description="Run a DECIMER API smoke test with one image."
15
+ )
16
+ parser.add_argument(
17
+ "--image",
18
+ required=True,
19
+ help="Path to an image file to send to /image2smiles/",
20
+ )
21
+ parser.add_argument(
22
+ "--base-url",
23
+ default=os.getenv("DECIMER_API_BASE_URL", "http://localhost:8099"),
24
+ help="DECIMER API base URL (default: DECIMER_API_BASE_URL or http://localhost:8099)",
25
+ )
26
+ parser.add_argument(
27
+ "--timeout",
28
+ type=float,
29
+ default=60.0,
30
+ help="HTTP timeout in seconds (default: 60)",
31
+ )
32
+ parser.add_argument(
33
+ "--max-image-bytes",
34
+ type=int,
35
+ default=10_000_000,
36
+ help="Maximum decoded image bytes (default: 10000000)",
37
+ )
38
+ parser.add_argument(
39
+ "--is-hand-drawn",
40
+ action="store_true",
41
+ help="Set hand-drawn mode for DECIMER",
42
+ )
43
+ parser.add_argument(
44
+ "--no-classify",
45
+ action="store_true",
46
+ help="Disable classifier pre-check",
47
+ )
48
+ return parser.parse_args()
49
+
50
+
51
+ async def run() -> int:
52
+ args = parse_args()
53
+ image_path = Path(args.image)
54
+ if not image_path.exists() or not image_path.is_file():
55
+ print(f"ERROR: Image path not found or not a file: {image_path}")
56
+ return 2
57
+
58
+ encoded_image = base64.b64encode(image_path.read_bytes()).decode("utf-8")
59
+ client = DecimerAPIClient(
60
+ base_url=args.base_url,
61
+ timeout_seconds=args.timeout,
62
+ max_image_bytes=args.max_image_bytes,
63
+ )
64
+
65
+ health = await client.health()
66
+ print(f"health.ok={health.ok} status={health.status_code} message={health.message}")
67
+ if not health.ok:
68
+ return 1
69
+
70
+ result = await client.image_to_smiles(
71
+ encoded_image=encoded_image,
72
+ is_hand_drawn=args.is_hand_drawn,
73
+ classify_image=not args.no_classify,
74
+ )
75
+ print(
76
+ "inference."
77
+ f"ok={result.ok} status={result.status_code} smiles={result.smiles} "
78
+ f"reason={result.reason} message={result.message}"
79
+ )
80
+
81
+ if not result.ok:
82
+ return 1
83
+ return 0
84
+
85
+
86
+ def main() -> None:
87
+ exit_code = asyncio.run(run())
88
+ raise SystemExit(exit_code)
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: decimer-mcp-server
3
+ Version: 0.1.2
4
+ Summary: MCP adapter server for DECIMER FastAPI image-to-SMILES endpoint
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: mcp>=1.2.0
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: pydantic>=2.8.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
13
+ Dynamic: license-file
14
+
15
+ # DecimerMCPServer
16
+
17
+ MCP server that exposes DECIMER image-to-SMILES functionality as tool calls.
18
+
19
+ This project is a thin adapter over the existing FastAPI service in `DecimerServerAPI`.
20
+ It does not run DECIMER models directly.
21
+ The adapter sends JSON requests by default, with automatic fallback to form payloads for compatibility.
22
+
23
+ ## Tools
24
+
25
+ - `server_health`: Checks whether the DECIMER FastAPI server is reachable.
26
+ - `analyze_chemical_image`: Sends a base64-encoded image to `/image2smiles/` and returns structured output.
27
+
28
+ ## Requirements
29
+
30
+ - Python 3.10+
31
+ - Running DECIMER API server (default: `http://localhost:8099`)
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ cd /Users/a/dev/DecimerMCPServer
37
+ uv venv
38
+ uv sync
39
+ ```
40
+
41
+ ## Configuration
42
+
43
+ Copy `.env.example` values into your environment:
44
+
45
+ - `DECIMER_API_BASE_URL` (default `http://localhost:8099`)
46
+ - `DECIMER_API_TIMEOUT_SECONDS` (default `60`)
47
+ - `DECIMER_MAX_IMAGE_BYTES` (default `10000000`)
48
+ - `DECIMER_MCP_LOG_LEVEL` (default `INFO`)
49
+
50
+ ## Run (stdio transport)
51
+
52
+ ```bash
53
+ uv run decimer-mcp-server
54
+ ```
55
+
56
+ or
57
+
58
+ ```bash
59
+ uv run python -m decimer_mcp_server
60
+ ```
61
+
62
+ ## Example MCP client config
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "decimer": {
68
+ "command": "uv",
69
+ "args": ["run", "python", "-m", "decimer_mcp_server"],
70
+ "env": {
71
+ "DECIMER_API_BASE_URL": "http://localhost:8099"
72
+ }
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ ## Output shape
79
+
80
+ `analyze_chemical_image` returns:
81
+
82
+ ```json
83
+ {
84
+ "ok": true,
85
+ "smiles": "CCO",
86
+ "reason": null,
87
+ "api_status_code": 200,
88
+ "api_message": null,
89
+ "classifier_score": 0.0000012,
90
+ "classifier_threshold": 0.3,
91
+ "classifier_decision": "structure_like"
92
+ }
93
+ ```
94
+
95
+ When no SMILES is returned by API classifier behavior:
96
+
97
+ ```json
98
+ {
99
+ "ok": true,
100
+ "smiles": null,
101
+ "reason": "not_chemical_structure",
102
+ "api_status_code": 200,
103
+ "api_message": "No SMILES returned by API",
104
+ "classifier_score": 0.99999,
105
+ "classifier_threshold": 0.3,
106
+ "classifier_decision": "not_structure_like"
107
+ }
108
+ ```
109
+
110
+ ## Development tests
111
+
112
+ ```bash
113
+ uv sync --extra dev
114
+ uv run pytest
115
+ ```
116
+
117
+ Make targets:
118
+
119
+ ```bash
120
+ make sync
121
+ make test
122
+ ```
123
+
124
+ ## Smoke test helper
125
+
126
+ Run one health check + one inference call against your DECIMER API:
127
+
128
+ ```bash
129
+ cd /Users/a/dev/DecimerMCPServer
130
+ DECIMER_API_BASE_URL=http://chitchat:8099 uv run decimer-mcp-smoke-test --image /Users/a/dev/DecimerServerAPI/example_usage/structure.png
131
+ ```
132
+
133
+ If you keep settings in `.env`, load it with:
134
+
135
+ ```bash
136
+ uv run --env-file .env decimer-mcp-smoke-test --image /Users/a/dev/DecimerServerAPI/example_usage/structure.png
137
+ ```
138
+
139
+ or use make:
140
+
141
+ ```bash
142
+ make smoke
143
+ ```
144
+
145
+ Override the image path if needed:
146
+
147
+ ```bash
148
+ make smoke SMOKE_IMAGE=/absolute/path/to/image.png
149
+
150
+ ## MCP Registry publishing
151
+
152
+ Tags matching `v*` trigger `.github/workflows/publish-mcp.yml`.
153
+
154
+ Workflow steps:
155
+ - installs `mcp-publisher`
156
+ - validates `server.json`
157
+ - calls registry publish using secret `MCP_REGISTRY_TOKEN`
158
+ - publishes slug `io.github.DocMinus/decimer-mcp-server` (case sensitive; must match registry grant)
159
+
160
+ Before tagging:
161
+ 1. Update `pyproject.toml` + `server.json` versions
162
+ 2. Ensure `server.json` stays valid (`uv pip install jsonschema && python validate snippet from AGENTS.md`)
163
+ 3. Add GitHub repo secret `MCP_REGISTRY_TOKEN` (GitHub PAT with `repo`, `workflow` scopes)
164
+
165
+ Release flow:
166
+ ```bash
167
+ git tag v0.1.1
168
+ git push origin v0.1.1
169
+ ```
170
+
171
+ Monitor Actions tab. If publish fails, rerun using workflow dispatch after fixing issues.
172
+ ```
173
+
174
+ ## Contribution
175
+ This project was built by DocMinus with AI-assisted coding support (OpenCode/Copilot-style tooling), then reviewed and tested by the author.
176
+
177
+ ## AI usage policy
178
+
179
+ - AI assistance was used for scaffolding, implementation drafts, and documentation edits.
180
+ - Final technical decisions, validation runs, and acceptance were performed by the maintainer.
181
+ - Runtime behavior should be validated with local tests (`make test`) and smoke tests (`make smoke`) before release.
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/decimer_mcp_server/__init__.py
5
+ src/decimer_mcp_server/__main__.py
6
+ src/decimer_mcp_server/config.py
7
+ src/decimer_mcp_server/decimer_api_client.py
8
+ src/decimer_mcp_server/schemas.py
9
+ src/decimer_mcp_server/server.py
10
+ src/decimer_mcp_server/smoke_test.py
11
+ src/decimer_mcp_server.egg-info/PKG-INFO
12
+ src/decimer_mcp_server.egg-info/SOURCES.txt
13
+ src/decimer_mcp_server.egg-info/dependency_links.txt
14
+ src/decimer_mcp_server.egg-info/entry_points.txt
15
+ src/decimer_mcp_server.egg-info/requires.txt
16
+ src/decimer_mcp_server.egg-info/top_level.txt
17
+ tests/test_api_client_unit.py
18
+ tests/test_integration_optional.py
19
+ tests/test_tools_unit.py
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ decimer-mcp-server = decimer_mcp_server.__main__:main
3
+ decimer-mcp-smoke-test = decimer_mcp_server.smoke_test:main
@@ -0,0 +1,6 @@
1
+ mcp>=1.2.0
2
+ httpx>=0.27.0
3
+ pydantic>=2.8.0
4
+
5
+ [dev]
6
+ pytest>=8.0.0
@@ -0,0 +1 @@
1
+ decimer_mcp_server
@@ -0,0 +1,28 @@
1
+ import base64
2
+
3
+ from decimer_mcp_server.decimer_api_client import DecimerAPIClient
4
+
5
+
6
+ def test_validate_image_size_allows_small_payload() -> None:
7
+ client = DecimerAPIClient(
8
+ base_url="http://localhost:8099",
9
+ timeout_seconds=1.0,
10
+ max_image_bytes=20,
11
+ )
12
+ encoded = base64.b64encode(b"12345").decode("utf-8")
13
+ client._validate_image_size(encoded)
14
+
15
+
16
+ def test_validate_image_size_rejects_large_payload() -> None:
17
+ client = DecimerAPIClient(
18
+ base_url="http://localhost:8099",
19
+ timeout_seconds=1.0,
20
+ max_image_bytes=4,
21
+ )
22
+ encoded = base64.b64encode(b"12345").decode("utf-8")
23
+
24
+ try:
25
+ client._validate_image_size(encoded)
26
+ assert False, "Expected ValueError for oversized image payload"
27
+ except ValueError as exc:
28
+ assert "exceeds limit" in str(exc)
@@ -0,0 +1,23 @@
1
+ """Optional integration tests.
2
+
3
+ Run with a live DecimerServerAPI instance listening on DECIMER_API_BASE_URL.
4
+ """
5
+
6
+ import os
7
+
8
+ import pytest
9
+
10
+ from decimer_mcp_server.decimer_api_client import DecimerAPIClient
11
+
12
+
13
+ @pytest.mark.anyio
14
+ async def test_health_live() -> None:
15
+ base_url = os.getenv("DECIMER_API_BASE_URL")
16
+ if not base_url:
17
+ pytest.skip("DECIMER_API_BASE_URL not set")
18
+
19
+ client = DecimerAPIClient(
20
+ base_url=base_url, timeout_seconds=5.0, max_image_bytes=1_000_000
21
+ )
22
+ result = await client.health()
23
+ assert result.status_code in (200, 503, 504)
@@ -0,0 +1,15 @@
1
+ from decimer_mcp_server.schemas import AnalyzeChemicalImageInput
2
+
3
+
4
+ def test_input_schema_accepts_valid_base64() -> None:
5
+ valid = "aGVsbG8="
6
+ parsed = AnalyzeChemicalImageInput(encoded_image=valid)
7
+ assert parsed.encoded_image == valid
8
+
9
+
10
+ def test_input_schema_rejects_invalid_base64() -> None:
11
+ try:
12
+ AnalyzeChemicalImageInput(encoded_image="not_base64!!!")
13
+ assert False, "Expected base64 validation failure"
14
+ except ValueError as exc:
15
+ assert "valid base64" in str(exc)