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.
- decimer_mcp_server-0.1.2/LICENSE +21 -0
- decimer_mcp_server-0.1.2/PKG-INFO +181 -0
- decimer_mcp_server-0.1.2/README.md +167 -0
- decimer_mcp_server-0.1.2/pyproject.toml +33 -0
- decimer_mcp_server-0.1.2/setup.cfg +4 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server/__init__.py +3 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server/__main__.py +11 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server/config.py +53 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server/decimer_api_client.py +142 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server/schemas.py +49 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server/server.py +73 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server/smoke_test.py +88 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server.egg-info/PKG-INFO +181 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server.egg-info/SOURCES.txt +19 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server.egg-info/dependency_links.txt +1 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server.egg-info/entry_points.txt +3 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server.egg-info/requires.txt +6 -0
- decimer_mcp_server-0.1.2/src/decimer_mcp_server.egg-info/top_level.txt +1 -0
- decimer_mcp_server-0.1.2/tests/test_api_client_unit.py +28 -0
- decimer_mcp_server-0.1.2/tests/test_integration_optional.py +23 -0
- decimer_mcp_server-0.1.2/tests/test_tools_unit.py +15 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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)
|