morphiaone-mcp 0.1.0__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.
- morphiaone_mcp-0.1.0/LICENSE +21 -0
- morphiaone_mcp-0.1.0/PKG-INFO +64 -0
- morphiaone_mcp-0.1.0/README.md +39 -0
- morphiaone_mcp-0.1.0/morphiaone_mcp/__init__.py +1 -0
- morphiaone_mcp-0.1.0/morphiaone_mcp/server.py +703 -0
- morphiaone_mcp-0.1.0/morphiaone_mcp.egg-info/PKG-INFO +64 -0
- morphiaone_mcp-0.1.0/morphiaone_mcp.egg-info/SOURCES.txt +11 -0
- morphiaone_mcp-0.1.0/morphiaone_mcp.egg-info/dependency_links.txt +1 -0
- morphiaone_mcp-0.1.0/morphiaone_mcp.egg-info/entry_points.txt +2 -0
- morphiaone_mcp-0.1.0/morphiaone_mcp.egg-info/requires.txt +2 -0
- morphiaone_mcp-0.1.0/morphiaone_mcp.egg-info/top_level.txt +1 -0
- morphiaone_mcp-0.1.0/pyproject.toml +39 -0
- morphiaone_mcp-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MorphiaOne
|
|
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,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: morphiaone-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for MorphiaOne shade digitisation, a thin client of the public V1 API.
|
|
5
|
+
Author: MorphiaOne
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Documentation, https://docs.morphiaone.ai/guides/ai-agents
|
|
8
|
+
Project-URL: Repository, https://github.com/MorphiaOne/vision-api
|
|
9
|
+
Keywords: morphiaone,mcp,model-context-protocol,ai-agents,digitisation
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Requires-Python: <3.15,>=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: morphiaone<0.12,>=0.11
|
|
23
|
+
Requires-Dist: mcp<2.4,>=2.3.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# morphiaone-mcp
|
|
27
|
+
|
|
28
|
+
An [MCP](https://modelcontextprotocol.io) server that lets Claude, Cursor or any MCP
|
|
29
|
+
client digitise product shade colours through the MorphiaOne V1 API: retain a photo,
|
|
30
|
+
measure regions, propose a candidate, review it and stage it as an unpublished
|
|
31
|
+
catalogue draft. It is a thin client of the public API, so your key's scopes, rate
|
|
32
|
+
limits, credits and test mode apply as they do anywhere else.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install morphiaone # the CLI that writes the client configuration
|
|
38
|
+
morphiaone mcp install --client claude-code # or cursor, claude-desktop
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`morphiaone mcp install` adds one `morphiaone` server to the client's configuration
|
|
42
|
+
and leaves the others as they are. It runs `morphiaone-mcp` if it is on your `PATH`
|
|
43
|
+
(`pipx install morphiaone-mcp`), otherwise `uvx morphiaone-mcp`. `--print` shows the
|
|
44
|
+
entry without writing it.
|
|
45
|
+
|
|
46
|
+
The server reads the key from `MORPHIAONE_API_KEY`. Claude Code and Cursor expand that
|
|
47
|
+
reference when they start the server, so no key is written to their files. Claude
|
|
48
|
+
Desktop expands nothing, so its file would hold the key: the command writes it only
|
|
49
|
+
with `--store-key`. Use a test-mode key, or one with only `digitisation.read` and
|
|
50
|
+
`digitisation.write`, and revoke it when you stop.
|
|
51
|
+
|
|
52
|
+
`MORPHIAONE_BASE_URL` defaults to `https://api.morphiaone.ai`.
|
|
53
|
+
|
|
54
|
+
## What the agent can do
|
|
55
|
+
|
|
56
|
+
The tools it sees are the ones your key holds every scope for. Give an agent
|
|
57
|
+
`digitisation.read` and `digitisation.write` and it proposes; a person reviews and
|
|
58
|
+
stages in the Platform. Staging is not publishing: a draft renders nothing until
|
|
59
|
+
someone publishes it.
|
|
60
|
+
|
|
61
|
+
Guide, tools and scopes: https://docs.morphiaone.ai/guides/ai-agents
|
|
62
|
+
|
|
63
|
+
`morphiaone-mcp --version` prints the installed version; include it when you report a
|
|
64
|
+
problem, with the `request_id` of any refused call.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# morphiaone-mcp
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server that lets Claude, Cursor or any MCP
|
|
4
|
+
client digitise product shade colours through the MorphiaOne V1 API: retain a photo,
|
|
5
|
+
measure regions, propose a candidate, review it and stage it as an unpublished
|
|
6
|
+
catalogue draft. It is a thin client of the public API, so your key's scopes, rate
|
|
7
|
+
limits, credits and test mode apply as they do anywhere else.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install morphiaone # the CLI that writes the client configuration
|
|
13
|
+
morphiaone mcp install --client claude-code # or cursor, claude-desktop
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`morphiaone mcp install` adds one `morphiaone` server to the client's configuration
|
|
17
|
+
and leaves the others as they are. It runs `morphiaone-mcp` if it is on your `PATH`
|
|
18
|
+
(`pipx install morphiaone-mcp`), otherwise `uvx morphiaone-mcp`. `--print` shows the
|
|
19
|
+
entry without writing it.
|
|
20
|
+
|
|
21
|
+
The server reads the key from `MORPHIAONE_API_KEY`. Claude Code and Cursor expand that
|
|
22
|
+
reference when they start the server, so no key is written to their files. Claude
|
|
23
|
+
Desktop expands nothing, so its file would hold the key: the command writes it only
|
|
24
|
+
with `--store-key`. Use a test-mode key, or one with only `digitisation.read` and
|
|
25
|
+
`digitisation.write`, and revoke it when you stop.
|
|
26
|
+
|
|
27
|
+
`MORPHIAONE_BASE_URL` defaults to `https://api.morphiaone.ai`.
|
|
28
|
+
|
|
29
|
+
## What the agent can do
|
|
30
|
+
|
|
31
|
+
The tools it sees are the ones your key holds every scope for. Give an agent
|
|
32
|
+
`digitisation.read` and `digitisation.write` and it proposes; a person reviews and
|
|
33
|
+
stages in the Platform. Staging is not publishing: a draft renders nothing until
|
|
34
|
+
someone publishes it.
|
|
35
|
+
|
|
36
|
+
Guide, tools and scopes: https://docs.morphiaone.ai/guides/ai-agents
|
|
37
|
+
|
|
38
|
+
`morphiaone-mcp --version` prints the installed version; include it when you report a
|
|
39
|
+
problem, with the `request_id` of any refused call.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MorphiaOne MCP server."""
|
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
"""The MorphiaOne MCP server: shade digitisation tools over the public V1 API.
|
|
2
|
+
|
|
3
|
+
A thin client of /v1 through the Python SDK, so rate limits, quotas, credits, test
|
|
4
|
+
mode, audit and scopes apply exactly as to any API caller. It keeps no state and no
|
|
5
|
+
secret: every request carries the caller's own credential as ``Authorization: Bearer``,
|
|
6
|
+
an API key or a Platform member's OAuth access token, which it forwards to the gateway
|
|
7
|
+
unread; the gateway is the authority on both.
|
|
8
|
+
|
|
9
|
+
MORPHIAONE_BASE_URL=https://api.morphiaone.ai \\
|
|
10
|
+
MORPHIAONE_MCP_ALLOWED_HOSTS=mcp.example.com \\
|
|
11
|
+
uvicorn morphiaone_mcp.server:app --host 0.0.0.0 --port 8091 --no-access-log
|
|
12
|
+
|
|
13
|
+
To let OAuth clients (a claude.ai connector) sign members in, also name the public MCP
|
|
14
|
+
URL and the AuthKit issuer: ``MORPHIAONE_MCP_RESOURCE_URL=https://mcp.example.com/mcp``
|
|
15
|
+
and ``MORPHIAONE_MCP_AUTHORIZATION_SERVERS=https://auth.example.com``. The server then
|
|
16
|
+
serves ``/.well-known/oauth-protected-resource`` and names it in each ``401``.
|
|
17
|
+
|
|
18
|
+
Run on the user's own machine instead, ``morphiaone-mcp`` serves the same tools over
|
|
19
|
+
stdio with the key from ``MORPHIAONE_API_KEY``; only then may ``retain_evidence`` read a
|
|
20
|
+
local photo by ``path``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import asyncio
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
import os
|
|
31
|
+
import time
|
|
32
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
33
|
+
from importlib import metadata
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Literal, TypeVar
|
|
36
|
+
from urllib.parse import urlsplit
|
|
37
|
+
|
|
38
|
+
from mcp.server.mcpserver import Context, MCPServer
|
|
39
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
40
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
41
|
+
from mcp.shared.exceptions import MCPError
|
|
42
|
+
from mcp_types import INTERNAL_ERROR, ListToolsResult, ToolAnnotations
|
|
43
|
+
from morphiaone import AsyncMorphiaOne, MorphiaOneError, views
|
|
44
|
+
from morphiaone.errors import DeadlineExceeded
|
|
45
|
+
from morphiaone.v1 import _sniff
|
|
46
|
+
from starlette.requests import Request
|
|
47
|
+
from starlette.responses import JSONResponse, Response
|
|
48
|
+
|
|
49
|
+
BASE_URL_ENV = "MORPHIAONE_BASE_URL"
|
|
50
|
+
API_KEY_ENV = "MORPHIAONE_API_KEY"
|
|
51
|
+
#: True only under ``morphiaone-mcp`` (stdio, on the user's machine): the key comes
|
|
52
|
+
#: from the environment and a tool may read a local photo. The HTTP app never sets it,
|
|
53
|
+
#: so a hosted process never acts with its own environment's key.
|
|
54
|
+
LOCAL = False
|
|
55
|
+
#: Local photos the server will upload, by extension; the bytes must match it too.
|
|
56
|
+
PHOTO_TYPES = {
|
|
57
|
+
".jpg": "image/jpeg",
|
|
58
|
+
".jpeg": "image/jpeg",
|
|
59
|
+
".png": "image/png",
|
|
60
|
+
".webp": "image/webp",
|
|
61
|
+
}
|
|
62
|
+
#: The SDK's default upload cap; one byte more is read so it can refuse a larger file.
|
|
63
|
+
MAX_PHOTO_BYTES = 10 * 1024 * 1024
|
|
64
|
+
DEFAULT_BASE_URL = "https://api.morphiaone.ai"
|
|
65
|
+
#: Comma-separated Host and Origin allowlists (``name:*`` matches any port). The
|
|
66
|
+
#: defaults admit only a local run; a deployment names its public host.
|
|
67
|
+
ALLOWED_HOSTS_ENV = "MORPHIAONE_MCP_ALLOWED_HOSTS"
|
|
68
|
+
ALLOWED_ORIGINS_ENV = "MORPHIAONE_MCP_ALLOWED_ORIGINS"
|
|
69
|
+
DEFAULT_ALLOWED_HOSTS = "localhost:*,127.0.0.1:*"
|
|
70
|
+
DEFAULT_ALLOWED_ORIGINS = "http://localhost:*,http://127.0.0.1:*"
|
|
71
|
+
#: OAuth protected-resource metadata (RFC 9728), served only when both are set: this
|
|
72
|
+
#: server's public MCP URL, and the authorization servers (AuthKit) that issue its
|
|
73
|
+
#: tokens, comma-separated.
|
|
74
|
+
RESOURCE_URL_ENV = "MORPHIAONE_MCP_RESOURCE_URL"
|
|
75
|
+
AUTHORIZATION_SERVERS_ENV = "MORPHIAONE_MCP_AUTHORIZATION_SERVERS"
|
|
76
|
+
METADATA_PATH = "/.well-known/oauth-protected-resource"
|
|
77
|
+
#: Seconds measure_regions waits before answering the assessment as it stands.
|
|
78
|
+
MEASURE_WAIT_S = 60.0
|
|
79
|
+
#: Seconds between the progress notifications measure_regions sends while it waits.
|
|
80
|
+
PROGRESS_EVERY_S = 5.0
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
VERSION = metadata.version("morphiaone-mcp")
|
|
84
|
+
except metadata.PackageNotFoundError: # a source tree imported without installing
|
|
85
|
+
VERSION = "0+unknown"
|
|
86
|
+
#: Tool ``_meta`` key naming every API scope a tool's calls need.
|
|
87
|
+
SCOPE_META = "ai.morphiaone/scopes"
|
|
88
|
+
log = logging.getLogger("morphiaone.mcp")
|
|
89
|
+
|
|
90
|
+
REVIEW_RULE = (
|
|
91
|
+
"A person reviews before anything is accepted, rejected or staged: show the user "
|
|
92
|
+
"the colour (srgb_hex, lab_d65, clipped_fraction, region) and act only on their "
|
|
93
|
+
"explicit yes. A clipped_fraction above 0 means the colour may be off; say so. "
|
|
94
|
+
"On HUMAN_REVIEW_REQUIRED this account lets only a person decide: give the user "
|
|
95
|
+
"the candidate's review_url from get_candidate, wait for them, and never say it "
|
|
96
|
+
"was approved until get_candidate shows it."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
READ = ToolAnnotations(read_only_hint=True, destructive_hint=False)
|
|
100
|
+
# Retried writes replay the first answer (derived Idempotency-Key), and they add a
|
|
101
|
+
# pending item without changing anything a user already has.
|
|
102
|
+
WRITE = ToolAnnotations(
|
|
103
|
+
read_only_hint=False, destructive_hint=False, idempotent_hint=True
|
|
104
|
+
)
|
|
105
|
+
# Decisions and catalogue writes: a client should ask the user before each one.
|
|
106
|
+
DECIDE = ToolAnnotations(
|
|
107
|
+
read_only_hint=False, destructive_hint=True, idempotent_hint=True
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class Server(MCPServer):
|
|
112
|
+
"""Lists only the tools the caller's key can use; logs one line per tool call."""
|
|
113
|
+
|
|
114
|
+
# The private hook is the one that sees the request's headers; list_tools() does not.
|
|
115
|
+
async def _handle_list_tools(self, ctx, params) -> ListToolsResult:
|
|
116
|
+
tools = await self.list_tools()
|
|
117
|
+
try:
|
|
118
|
+
scopes = await key_scopes(Context(request_context=ctx, mcp_server=self))
|
|
119
|
+
except ToolError as exc:
|
|
120
|
+
# Discovery has no tool-result error channel. Preserve the safe API
|
|
121
|
+
# code and request id in a protocol error so clients can act on it.
|
|
122
|
+
raise MCPError(code=INTERNAL_ERROR, message=str(exc)) from None
|
|
123
|
+
tools = [t for t in tools if set(t.meta[SCOPE_META]).issubset(scopes)]
|
|
124
|
+
return ListToolsResult(tools=tools)
|
|
125
|
+
|
|
126
|
+
async def call_tool(self, name, arguments, context=None):
|
|
127
|
+
started, outcome, request_id = time.perf_counter(), "ok", None
|
|
128
|
+
try:
|
|
129
|
+
# Discovery is advisory: clients can call a hidden tool directly.
|
|
130
|
+
tools = await self.list_tools()
|
|
131
|
+
selected = next((t for t in tools if t.name == name), None)
|
|
132
|
+
if selected is not None:
|
|
133
|
+
ctx = context if context is not None else Context(mcp_server=self)
|
|
134
|
+
scopes = await key_scopes(ctx)
|
|
135
|
+
missing = sorted(set(selected.meta[SCOPE_META]) - scopes)
|
|
136
|
+
if missing:
|
|
137
|
+
error = ToolError(
|
|
138
|
+
json.dumps(
|
|
139
|
+
{
|
|
140
|
+
"code": "INSUFFICIENT_SCOPE",
|
|
141
|
+
"message": "The key lacks required tool scopes.",
|
|
142
|
+
"missing_scopes": missing,
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
error.code = "INSUFFICIENT_SCOPE"
|
|
147
|
+
raise error
|
|
148
|
+
return await super().call_tool(name, arguments, context)
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
cause = exc.__cause__ or exc
|
|
151
|
+
outcome = getattr(cause, "code", None) or type(cause).__name__
|
|
152
|
+
request_id = getattr(cause, "request_id", None)
|
|
153
|
+
if (
|
|
154
|
+
cause is not exc
|
|
155
|
+
and isinstance(cause, ToolError)
|
|
156
|
+
and hasattr(cause, "code")
|
|
157
|
+
):
|
|
158
|
+
# Ours is already the JSON refusal; drop the library's "Error executing
|
|
159
|
+
# tool <name>: " prefix so every refusal reaches the agent as bare JSON.
|
|
160
|
+
raise cause from None
|
|
161
|
+
raise
|
|
162
|
+
finally:
|
|
163
|
+
# Never the key or the arguments: they are the caller's.
|
|
164
|
+
log.info(
|
|
165
|
+
json.dumps(
|
|
166
|
+
{
|
|
167
|
+
"message": "tool call",
|
|
168
|
+
"tool": name,
|
|
169
|
+
"outcome": outcome,
|
|
170
|
+
"request_id": request_id,
|
|
171
|
+
"duration_ms": round((time.perf_counter() - started) * 1000, 2),
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def key_scopes(ctx: Context) -> frozenset[str]:
|
|
178
|
+
"""Read the key's current scopes; unreadable credentials never grant tools.
|
|
179
|
+
|
|
180
|
+
No cache: revocation and scope changes take effect on the next list or call.
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
async with client_for(ctx) as client:
|
|
184
|
+
return frozenset((await client.whoami())["scopes"])
|
|
185
|
+
except MorphiaOneError as exc:
|
|
186
|
+
log.info(json.dumps({"message": "scopes unread", "outcome": exc.code}))
|
|
187
|
+
raise tool_error(exc) from None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
server = Server(
|
|
191
|
+
"morphiaone",
|
|
192
|
+
title="MorphiaOne",
|
|
193
|
+
version=VERSION,
|
|
194
|
+
instructions=(
|
|
195
|
+
"Digitise product shade colours: retain a photo by URL, measure regions, "
|
|
196
|
+
"freeze one as a candidate, review it, then stage it into the catalogue as an "
|
|
197
|
+
"unpublished draft. " + REVIEW_RULE
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
T = TypeVar("T")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def tool(annotations: ToolAnnotations, *scopes: str):
|
|
205
|
+
"""Register a tool and every API scope its calls need."""
|
|
206
|
+
return server.tool(annotations=annotations, meta={SCOPE_META: list(scopes)})
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def reviewed(annotations: ToolAnnotations, *scopes: str):
|
|
210
|
+
"""Register a tool whose description carries the review rule: it changes what a
|
|
211
|
+
user reviews or publishes."""
|
|
212
|
+
|
|
213
|
+
def register(fn):
|
|
214
|
+
fn.__doc__ = f"{fn.__doc__}\n\n{REVIEW_RULE}"
|
|
215
|
+
return tool(annotations, *scopes)(fn)
|
|
216
|
+
|
|
217
|
+
return register
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def bearer(headers: Mapping[str, str] | None) -> str | None:
|
|
221
|
+
scheme, _, token = (headers or {}).get("authorization", "").partition(" ")
|
|
222
|
+
token = token.strip()
|
|
223
|
+
return token if scheme.lower() == "bearer" and token else None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def is_access_token(credential: str) -> bool:
|
|
227
|
+
"""A JWT (an OAuth access token) rather than an API key, by its shape alone: the
|
|
228
|
+
server never verifies it, the gateway does."""
|
|
229
|
+
return credential.startswith("eyJ") and credential.count(".") == 2
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def client_for(ctx: Context) -> AsyncMorphiaOne:
|
|
233
|
+
key = os.environ.get(API_KEY_ENV) if LOCAL else bearer(ctx.headers)
|
|
234
|
+
if not key:
|
|
235
|
+
raise ToolError(
|
|
236
|
+
f"set {API_KEY_ENV} in the environment that starts morphiaone-mcp"
|
|
237
|
+
if LOCAL
|
|
238
|
+
else "send your MorphiaOne API key or access token as Authorization: Bearer"
|
|
239
|
+
)
|
|
240
|
+
token = not LOCAL and is_access_token(key)
|
|
241
|
+
return AsyncMorphiaOne(
|
|
242
|
+
api_key=None if token else key,
|
|
243
|
+
access_token=key if token else None,
|
|
244
|
+
base_url=os.environ.get(BASE_URL_ENV, DEFAULT_BASE_URL),
|
|
245
|
+
app_info=f"morphiaone-mcp/{VERSION}",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def idempotency_key(tool: str, **args: Any) -> str:
|
|
250
|
+
"""One key per tool and argument set, so an agent's retry replays the first answer."""
|
|
251
|
+
canonical = json.dumps([tool, args], sort_keys=True, separators=(",", ":"))
|
|
252
|
+
return "mcp-" + hashlib.sha256(canonical.encode()).hexdigest()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def tool_error(exc: Exception) -> ToolError:
|
|
256
|
+
if isinstance(exc, MorphiaOneError):
|
|
257
|
+
body = {"code": exc.code, "message": exc.message, "request_id": exc.request_id}
|
|
258
|
+
if exc.details:
|
|
259
|
+
body["details"] = exc.details
|
|
260
|
+
else:
|
|
261
|
+
body = {"code": "INVALID_ARGUMENT", "message": str(exc)}
|
|
262
|
+
error = ToolError(json.dumps(body))
|
|
263
|
+
error.code, error.request_id = (
|
|
264
|
+
body["code"],
|
|
265
|
+
body.get("request_id"),
|
|
266
|
+
) # for the log line
|
|
267
|
+
return error
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
async def call(ctx: Context, action: Callable[[AsyncMorphiaOne], Awaitable[T]]) -> T:
|
|
271
|
+
# shortcut: one HTTP client per tool call; pool per key if call volume makes it matter.
|
|
272
|
+
try:
|
|
273
|
+
async with client_for(ctx) as client:
|
|
274
|
+
return await action(client)
|
|
275
|
+
except (MorphiaOneError, TypeError, ValueError) as exc:
|
|
276
|
+
raise tool_error(exc) from None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def shown(raw: Mapping[str, Any], view: Callable[[Any], dict], full: bool) -> dict:
|
|
280
|
+
return dict(raw) if full else view(raw)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@tool(READ, "digitisation.read")
|
|
284
|
+
async def list_review_queue(
|
|
285
|
+
ctx: Context, limit: int = 20, cursor: str | None = None, raw: bool = False
|
|
286
|
+
) -> dict[str, Any]:
|
|
287
|
+
"""The pending candidates waiting for review, newest first. Pass `next_cursor`
|
|
288
|
+
back as `cursor` to read on."""
|
|
289
|
+
page = await call(
|
|
290
|
+
ctx,
|
|
291
|
+
lambda c: c.digitisation.list_candidates(
|
|
292
|
+
review_status="pending", limit=limit, cursor=cursor
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
return shown(page.raw, views.candidate_page, raw)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@tool(READ, "digitisation.read")
|
|
299
|
+
async def get_assessment(
|
|
300
|
+
ctx: Context, assessment_id: str, raw: bool = False
|
|
301
|
+
) -> dict[str, Any]:
|
|
302
|
+
"""One assessment: its status and, once succeeded, each measured region."""
|
|
303
|
+
found = await call(ctx, lambda c: c.digitisation.get_assessment(assessment_id))
|
|
304
|
+
return shown(found.raw, views.assessment, raw)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@tool(READ, "digitisation.read")
|
|
308
|
+
async def get_candidate(
|
|
309
|
+
ctx: Context, candidate_id: str, raw: bool = False
|
|
310
|
+
) -> dict[str, Any]:
|
|
311
|
+
"""One candidate with its measured colour and the `candidate_sha256` a review sends."""
|
|
312
|
+
found = await call(ctx, lambda c: c.digitisation.get_candidate(candidate_id))
|
|
313
|
+
return shown(found.raw, views.candidate, raw)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@tool(READ, "catalogue.read")
|
|
317
|
+
async def find_variant(
|
|
318
|
+
ctx: Context, external_ref: str, raw: bool = False
|
|
319
|
+
) -> dict[str, Any]:
|
|
320
|
+
"""The catalogue variants carrying this SKU (`external_ref`). Read this before
|
|
321
|
+
staging: a taken reference cannot be created again, only revised."""
|
|
322
|
+
page = await call(
|
|
323
|
+
ctx, lambda c: c.catalogue.list_variants(external_ref=external_ref)
|
|
324
|
+
)
|
|
325
|
+
if raw:
|
|
326
|
+
return dict(page.raw)
|
|
327
|
+
return {"data": [views.variant(v) for v in page.raw["data"]]}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def local_photo(path: str) -> tuple[bytes, str]:
|
|
331
|
+
"""A local swatch photo's bytes and media type, refusing anything else before it
|
|
332
|
+
leaves the machine: the agent picks the path, so an injected one must not upload a
|
|
333
|
+
private file."""
|
|
334
|
+
if not LOCAL:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
"a hosted server can't read your files: call start_upload and send "
|
|
337
|
+
"the user its upload_url, or upload the photo with the MorphiaOne SDK "
|
|
338
|
+
"or CLI and pass its asset_id"
|
|
339
|
+
)
|
|
340
|
+
file = Path(path).expanduser().resolve()
|
|
341
|
+
media_type = PHOTO_TYPES.get(file.suffix.lower())
|
|
342
|
+
if media_type is None:
|
|
343
|
+
raise ValueError("path must name a .jpg, .jpeg, .png or .webp photo")
|
|
344
|
+
if not file.is_file():
|
|
345
|
+
raise ValueError("path names no file")
|
|
346
|
+
try:
|
|
347
|
+
with file.open("rb") as stream:
|
|
348
|
+
content = stream.read(MAX_PHOTO_BYTES + 1)
|
|
349
|
+
except OSError:
|
|
350
|
+
raise ValueError("path can't be read") from None
|
|
351
|
+
if len(content) > MAX_PHOTO_BYTES:
|
|
352
|
+
raise ValueError(f"photo is larger than {MAX_PHOTO_BYTES} bytes")
|
|
353
|
+
if _sniff(content) != media_type:
|
|
354
|
+
raise ValueError("file content is not the image its extension names")
|
|
355
|
+
return content, media_type
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@tool(WRITE, "digitisation.write")
|
|
359
|
+
async def retain_evidence(
|
|
360
|
+
ctx: Context,
|
|
361
|
+
path: str | None = None,
|
|
362
|
+
asset_id: str | None = None,
|
|
363
|
+
url: str | None = None,
|
|
364
|
+
raw: bool = False,
|
|
365
|
+
) -> dict[str, Any]:
|
|
366
|
+
"""Keep a photo of the product swatch as evidence. Pass exactly one of: `path`, a
|
|
367
|
+
local .jpg, .png or .webp file (only when this server runs on the user's machine);
|
|
368
|
+
`asset_id`, an image uploaded with the MorphiaOne SDK or CLI; or `url`, a public
|
|
369
|
+
HTTPS image. The same image already retained is answered as it is. A deployment
|
|
370
|
+
that fetches no links refuses `url` with `remote_input_disabled`: use `path`, or
|
|
371
|
+
`start_upload` for a link the user sends the photo to."""
|
|
372
|
+
if sum(one is not None for one in (path, asset_id, url)) != 1:
|
|
373
|
+
raise tool_error(ValueError("pass exactly one of path, asset_id or url"))
|
|
374
|
+
|
|
375
|
+
async def keep(c: AsyncMorphiaOne):
|
|
376
|
+
if path is not None:
|
|
377
|
+
content, media_type = local_photo(path)
|
|
378
|
+
asset = await c.assets.upload(content, media_type=media_type)
|
|
379
|
+
return await c.digitisation.retain_evidence(asset.id)
|
|
380
|
+
if asset_id is not None:
|
|
381
|
+
return await c.digitisation.retain_evidence(asset_id)
|
|
382
|
+
return await c.digitisation.retain_evidence(url=url)
|
|
383
|
+
|
|
384
|
+
kept = await call(ctx, keep)
|
|
385
|
+
return shown(kept.raw, views.evidence, raw)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
UPLOAD_HINT = (
|
|
389
|
+
"Send the user this upload_url: they open it to choose the photo (or run "
|
|
390
|
+
"`curl -T photo.jpg <upload_url>`). It works once and expires at expires_at. "
|
|
391
|
+
"Then call get_upload until status is stored and pass its asset_id to "
|
|
392
|
+
"retain_evidence."
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@tool(WRITE, "digitisation.write")
|
|
397
|
+
async def start_upload(ctx: Context) -> dict[str, Any]:
|
|
398
|
+
"""A single-use link the user sends one photo through, for when you don't hold the
|
|
399
|
+
photo (a hosted server can't read the user's files). The link is in this answer
|
|
400
|
+
only: call again for a new one, not to re-read it."""
|
|
401
|
+
# No derived Idempotency-Key: a replay answers no link, so each call is a new session.
|
|
402
|
+
started = await call(ctx, lambda c: c.uploads.start())
|
|
403
|
+
return {**started.raw, "hint": UPLOAD_HINT}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@tool(READ, "digitisation.read")
|
|
407
|
+
async def get_upload(ctx: Context, upload_id: str) -> dict[str, Any]:
|
|
408
|
+
"""An upload session: `pending` until the user sends the photo, then `stored` with
|
|
409
|
+
the `asset_id` to pass to retain_evidence, or `expired`."""
|
|
410
|
+
found = await call(ctx, lambda c: c.uploads.get(upload_id))
|
|
411
|
+
return dict(found.raw)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@tool(WRITE, "digitisation.write", "digitisation.read")
|
|
415
|
+
async def measure_regions(
|
|
416
|
+
ctx: Context,
|
|
417
|
+
evidence_id: str,
|
|
418
|
+
regions: list[list[int]],
|
|
419
|
+
wait: bool = True,
|
|
420
|
+
raw: bool = False,
|
|
421
|
+
attempt: int = 0,
|
|
422
|
+
) -> dict[str, Any]:
|
|
423
|
+
"""Measure the mean colour of each region of one evidence image. A region is
|
|
424
|
+
[LEFT, TOP, RIGHT, BOTTOM] in pixels, half-open. With `wait`, answers once the
|
|
425
|
+
assessment settles (up to a minute), sending progress while it waits; otherwise
|
|
426
|
+
read it later with get_assessment.
|
|
427
|
+
The same arguments answer the same assessment, failed or not; to measure again on
|
|
428
|
+
purpose after a failure, pass the next `attempt` (1, 2, ...)."""
|
|
429
|
+
if attempt < 0:
|
|
430
|
+
raise tool_error(ValueError("attempt must be 0 or more"))
|
|
431
|
+
# attempt 0 keeps the key a plain retry derives, so only a deliberate rerun differs.
|
|
432
|
+
extra = {"attempt": attempt} if attempt else {}
|
|
433
|
+
key = idempotency_key(
|
|
434
|
+
"measure_regions", evidence_id=evidence_id, regions=regions, **extra
|
|
435
|
+
)
|
|
436
|
+
images = [{"evidence_id": evidence_id, "regions": regions}]
|
|
437
|
+
|
|
438
|
+
async def measure(c: AsyncMorphiaOne):
|
|
439
|
+
made = await c.digitisation.create_assessment(images, idempotency_key=key)
|
|
440
|
+
if not wait:
|
|
441
|
+
return made
|
|
442
|
+
waiting = asyncio.create_task(
|
|
443
|
+
c.digitisation.wait_assessment(made, timeout=MEASURE_WAIT_S)
|
|
444
|
+
)
|
|
445
|
+
started = time.monotonic()
|
|
446
|
+
try:
|
|
447
|
+
while not (await asyncio.wait({waiting}, timeout=PROGRESS_EVERY_S))[0]:
|
|
448
|
+
# A heartbeat (a no-op unless the client sent a progress token): the
|
|
449
|
+
# SDK's wait does the polling, so this adds no request to the API.
|
|
450
|
+
await ctx.report_progress(
|
|
451
|
+
time.monotonic() - started,
|
|
452
|
+
MEASURE_WAIT_S,
|
|
453
|
+
f"waiting for assessment {made.id}",
|
|
454
|
+
)
|
|
455
|
+
return waiting.result()
|
|
456
|
+
except DeadlineExceeded:
|
|
457
|
+
return await c.digitisation.get_assessment(made.id)
|
|
458
|
+
finally:
|
|
459
|
+
waiting.cancel()
|
|
460
|
+
|
|
461
|
+
return shown((await call(ctx, measure)).raw, views.assessment, raw)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
@tool(WRITE, "digitisation.write")
|
|
465
|
+
async def make_candidate(
|
|
466
|
+
ctx: Context, assessment_id: str, image: int, region: int, raw: bool = False
|
|
467
|
+
) -> dict[str, Any]:
|
|
468
|
+
"""Freeze one measured region of a succeeded assessment as a pending candidate.
|
|
469
|
+
`image` and `region` are positions in the assessment's output, from 0."""
|
|
470
|
+
key = idempotency_key(
|
|
471
|
+
"make_candidate", assessment_id=assessment_id, image=image, region=region
|
|
472
|
+
)
|
|
473
|
+
made = await call(
|
|
474
|
+
ctx,
|
|
475
|
+
lambda c: c.digitisation.create_candidate(
|
|
476
|
+
assessment_id, image=image, region=region, idempotency_key=key
|
|
477
|
+
),
|
|
478
|
+
)
|
|
479
|
+
return shown(made.raw, views.candidate, raw)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@reviewed(WRITE, "digitisation.write")
|
|
483
|
+
async def edit_candidate(
|
|
484
|
+
ctx: Context,
|
|
485
|
+
candidate_id: str,
|
|
486
|
+
candidate_sha256: str,
|
|
487
|
+
display_color: str | None = None,
|
|
488
|
+
finish: str | None = None,
|
|
489
|
+
raw: bool = False,
|
|
490
|
+
) -> dict[str, Any]:
|
|
491
|
+
"""Correct a candidate's colour (`#RRGGBB`) or finish. This derives a NEW pending
|
|
492
|
+
candidate with a new digest; review that one."""
|
|
493
|
+
key = idempotency_key(
|
|
494
|
+
"edit_candidate",
|
|
495
|
+
candidate_id=candidate_id,
|
|
496
|
+
candidate_sha256=candidate_sha256,
|
|
497
|
+
display_color=display_color,
|
|
498
|
+
finish=finish,
|
|
499
|
+
)
|
|
500
|
+
edited = await call(
|
|
501
|
+
ctx,
|
|
502
|
+
lambda c: c.digitisation.edit_candidate(
|
|
503
|
+
candidate_id,
|
|
504
|
+
candidate_sha256=candidate_sha256,
|
|
505
|
+
display_color=display_color,
|
|
506
|
+
finish=finish,
|
|
507
|
+
idempotency_key=key,
|
|
508
|
+
),
|
|
509
|
+
)
|
|
510
|
+
return shown(edited.raw, views.candidate, raw)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@reviewed(DECIDE, "digitisation.review")
|
|
514
|
+
async def review_candidate(
|
|
515
|
+
ctx: Context,
|
|
516
|
+
candidate_id: str,
|
|
517
|
+
candidate_sha256: str,
|
|
518
|
+
decision: Literal["accept", "reject"],
|
|
519
|
+
raw: bool = False,
|
|
520
|
+
) -> dict[str, Any]:
|
|
521
|
+
"""Accept or reject a pending candidate, bound to the `candidate_sha256` the user
|
|
522
|
+
reviewed; a candidate that changed since is refused."""
|
|
523
|
+
key = idempotency_key(
|
|
524
|
+
"review_candidate",
|
|
525
|
+
candidate_id=candidate_id,
|
|
526
|
+
candidate_sha256=candidate_sha256,
|
|
527
|
+
decision=decision,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def decide(c: AsyncMorphiaOne):
|
|
531
|
+
verb = c.digitisation.accept_candidate
|
|
532
|
+
if decision == "reject":
|
|
533
|
+
verb = c.digitisation.reject_candidate
|
|
534
|
+
return verb(
|
|
535
|
+
candidate_id, candidate_sha256=candidate_sha256, idempotency_key=key
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
return shown((await call(ctx, decide)).raw, views.candidate, raw)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@reviewed(DECIDE, "catalogue.write")
|
|
542
|
+
async def stage_candidate(
|
|
543
|
+
ctx: Context,
|
|
544
|
+
candidate_id: str,
|
|
545
|
+
candidate_sha256: str,
|
|
546
|
+
applications: list[dict[str, Any]],
|
|
547
|
+
external_ref: str | None = None,
|
|
548
|
+
variant_id: str | None = None,
|
|
549
|
+
expected_generation: int | None = None,
|
|
550
|
+
raw: bool = False,
|
|
551
|
+
) -> dict[str, Any]:
|
|
552
|
+
"""Write an accepted candidate into the catalogue as an unpublished draft: a new
|
|
553
|
+
variant (`external_ref`, its SKU) or a new revision of `variant_id` at the
|
|
554
|
+
`expected_generation` you read. Nothing renders until someone publishes it in the
|
|
555
|
+
Platform. Example applications: [{"application_id": "lips", "category": "lipstick"}]."""
|
|
556
|
+
key = idempotency_key(
|
|
557
|
+
"stage_candidate",
|
|
558
|
+
candidate_id=candidate_id,
|
|
559
|
+
candidate_sha256=candidate_sha256,
|
|
560
|
+
applications=applications,
|
|
561
|
+
external_ref=external_ref,
|
|
562
|
+
variant_id=variant_id,
|
|
563
|
+
expected_generation=expected_generation,
|
|
564
|
+
)
|
|
565
|
+
staged = await call(
|
|
566
|
+
ctx,
|
|
567
|
+
lambda c: c.digitisation.stage_candidate(
|
|
568
|
+
candidate_id,
|
|
569
|
+
candidate_sha256=candidate_sha256,
|
|
570
|
+
applications=applications,
|
|
571
|
+
external_ref=external_ref,
|
|
572
|
+
variant_id=variant_id,
|
|
573
|
+
expected_generation=expected_generation,
|
|
574
|
+
idempotency_key=key,
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
return shown(staged.raw, views.stage, raw)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
@reviewed(DECIDE, "catalogue.write", "catalogue.read")
|
|
581
|
+
async def import_shades(ctx: Context, rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
582
|
+
"""Add or revise many shades whose colours are already known, one result per row;
|
|
583
|
+
a bad row never stops the rest. A row: {"display_color": "#C27461",
|
|
584
|
+
"external_ref": "SKU-1", "applications": [...]}, plus optional shade_name,
|
|
585
|
+
product_name, brand, finish, gtin. A taken external_ref is revised. Drafts only."""
|
|
586
|
+
done = await call(ctx, lambda c: c.catalogue.import_shades(rows))
|
|
587
|
+
return {
|
|
588
|
+
"results": [
|
|
589
|
+
{
|
|
590
|
+
"external_ref": r.row.get("external_ref"),
|
|
591
|
+
"outcome": r.outcome,
|
|
592
|
+
"variant_id": r.variant_id,
|
|
593
|
+
"error": None if r.error is None else str(r.error),
|
|
594
|
+
}
|
|
595
|
+
for r in done
|
|
596
|
+
]
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@server.custom_route("/healthz", methods=["GET"], include_in_schema=False)
|
|
601
|
+
async def healthz(_: Request) -> Response:
|
|
602
|
+
return JSONResponse({"status": "ok"})
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def protected_resource() -> dict[str, Any] | None:
|
|
606
|
+
"""This server's RFC 9728 metadata, or ``None`` when the deployment names no
|
|
607
|
+
authorization server (a key-only deployment, as before)."""
|
|
608
|
+
resource = os.environ.get(RESOURCE_URL_ENV, "").strip()
|
|
609
|
+
servers = _allowlist(AUTHORIZATION_SERVERS_ENV, "")
|
|
610
|
+
if not resource or not servers:
|
|
611
|
+
return None
|
|
612
|
+
return {
|
|
613
|
+
"resource": resource,
|
|
614
|
+
"authorization_servers": servers,
|
|
615
|
+
"bearer_methods_supported": ["header"],
|
|
616
|
+
"resource_name": "MorphiaOne",
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def metadata_url(resource: str) -> str:
|
|
621
|
+
"""Where a client reads the metadata for ``resource``: the well-known path with
|
|
622
|
+
the resource's own path appended (RFC 9728 section 3.1)."""
|
|
623
|
+
parts = urlsplit(resource)
|
|
624
|
+
return f"{parts.scheme}://{parts.netloc}{METADATA_PATH}{parts.path.rstrip('/')}"
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@server.custom_route(METADATA_PATH, methods=["GET"], include_in_schema=False)
|
|
628
|
+
@server.custom_route(f"{METADATA_PATH}/mcp", methods=["GET"], include_in_schema=False)
|
|
629
|
+
async def oauth_protected_resource(_: Request) -> Response:
|
|
630
|
+
document = protected_resource()
|
|
631
|
+
if document is None:
|
|
632
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
633
|
+
return JSONResponse(document, headers={"Cache-Control": "public, max-age=3600"})
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def challenge() -> str:
|
|
637
|
+
document = protected_resource()
|
|
638
|
+
if document is None:
|
|
639
|
+
return "Bearer"
|
|
640
|
+
return f'Bearer resource_metadata="{metadata_url(document["resource"])}"'
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
class RequireBearer:
|
|
644
|
+
"""A request to /mcp without a bearer is an HTTP 401 before any MCP handling. With
|
|
645
|
+
OAuth configured, the challenge names the metadata, so a connector can sign in."""
|
|
646
|
+
|
|
647
|
+
def __init__(self, app: Any) -> None:
|
|
648
|
+
self.app = app
|
|
649
|
+
|
|
650
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
651
|
+
if scope["type"] == "http" and scope["path"].startswith("/mcp"):
|
|
652
|
+
raw = dict(scope["headers"]).get(b"authorization", b"").decode("latin-1")
|
|
653
|
+
if bearer({"authorization": raw}) is None:
|
|
654
|
+
refusal = JSONResponse(
|
|
655
|
+
{
|
|
656
|
+
"error": "send your MorphiaOne API key or access token "
|
|
657
|
+
"as Authorization: Bearer"
|
|
658
|
+
},
|
|
659
|
+
status_code=401,
|
|
660
|
+
headers={"WWW-Authenticate": challenge()},
|
|
661
|
+
)
|
|
662
|
+
await refusal(scope, receive, send)
|
|
663
|
+
return
|
|
664
|
+
await self.app(scope, receive, send)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _allowlist(env: str, default: str) -> list[str]:
|
|
668
|
+
return [
|
|
669
|
+
one.strip() for one in os.environ.get(env, default).split(",") if one.strip()
|
|
670
|
+
]
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def transport_security() -> TransportSecuritySettings:
|
|
674
|
+
"""DNS-rebinding protection, always on: an unlisted Host is 421, an unlisted
|
|
675
|
+
Origin 403. A request without an Origin (every native MCP client) passes."""
|
|
676
|
+
return TransportSecuritySettings(
|
|
677
|
+
enable_dns_rebinding_protection=True,
|
|
678
|
+
allowed_hosts=_allowlist(ALLOWED_HOSTS_ENV, DEFAULT_ALLOWED_HOSTS),
|
|
679
|
+
allowed_origins=_allowlist(ALLOWED_ORIGINS_ENV, DEFAULT_ALLOWED_ORIGINS),
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
app = RequireBearer(
|
|
684
|
+
server.streamable_http_app(
|
|
685
|
+
stateless_http=True, json_response=True, transport_security=transport_security()
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def main(argv: list[str] | None = None) -> None:
|
|
691
|
+
"""``morphiaone-mcp``: the same tools over stdio, on the user's machine."""
|
|
692
|
+
global LOCAL
|
|
693
|
+
parser = argparse.ArgumentParser(
|
|
694
|
+
prog="morphiaone-mcp",
|
|
695
|
+
description="MorphiaOne MCP server over stdio. Reads MORPHIAONE_API_KEY; "
|
|
696
|
+
"an MCP client starts it (see `morphiaone mcp install`).",
|
|
697
|
+
)
|
|
698
|
+
parser.add_argument(
|
|
699
|
+
"--version", action="version", version=f"morphiaone-mcp {VERSION}"
|
|
700
|
+
)
|
|
701
|
+
parser.parse_args(argv)
|
|
702
|
+
LOCAL = True
|
|
703
|
+
server.run(transport="stdio")
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: morphiaone-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for MorphiaOne shade digitisation, a thin client of the public V1 API.
|
|
5
|
+
Author: MorphiaOne
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Documentation, https://docs.morphiaone.ai/guides/ai-agents
|
|
8
|
+
Project-URL: Repository, https://github.com/MorphiaOne/vision-api
|
|
9
|
+
Keywords: morphiaone,mcp,model-context-protocol,ai-agents,digitisation
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Requires-Python: <3.15,>=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: morphiaone<0.12,>=0.11
|
|
23
|
+
Requires-Dist: mcp<2.4,>=2.3.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# morphiaone-mcp
|
|
27
|
+
|
|
28
|
+
An [MCP](https://modelcontextprotocol.io) server that lets Claude, Cursor or any MCP
|
|
29
|
+
client digitise product shade colours through the MorphiaOne V1 API: retain a photo,
|
|
30
|
+
measure regions, propose a candidate, review it and stage it as an unpublished
|
|
31
|
+
catalogue draft. It is a thin client of the public API, so your key's scopes, rate
|
|
32
|
+
limits, credits and test mode apply as they do anywhere else.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
pip install morphiaone # the CLI that writes the client configuration
|
|
38
|
+
morphiaone mcp install --client claude-code # or cursor, claude-desktop
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`morphiaone mcp install` adds one `morphiaone` server to the client's configuration
|
|
42
|
+
and leaves the others as they are. It runs `morphiaone-mcp` if it is on your `PATH`
|
|
43
|
+
(`pipx install morphiaone-mcp`), otherwise `uvx morphiaone-mcp`. `--print` shows the
|
|
44
|
+
entry without writing it.
|
|
45
|
+
|
|
46
|
+
The server reads the key from `MORPHIAONE_API_KEY`. Claude Code and Cursor expand that
|
|
47
|
+
reference when they start the server, so no key is written to their files. Claude
|
|
48
|
+
Desktop expands nothing, so its file would hold the key: the command writes it only
|
|
49
|
+
with `--store-key`. Use a test-mode key, or one with only `digitisation.read` and
|
|
50
|
+
`digitisation.write`, and revoke it when you stop.
|
|
51
|
+
|
|
52
|
+
`MORPHIAONE_BASE_URL` defaults to `https://api.morphiaone.ai`.
|
|
53
|
+
|
|
54
|
+
## What the agent can do
|
|
55
|
+
|
|
56
|
+
The tools it sees are the ones your key holds every scope for. Give an agent
|
|
57
|
+
`digitisation.read` and `digitisation.write` and it proposes; a person reviews and
|
|
58
|
+
stages in the Platform. Staging is not publishing: a draft renders nothing until
|
|
59
|
+
someone publishes it.
|
|
60
|
+
|
|
61
|
+
Guide, tools and scopes: https://docs.morphiaone.ai/guides/ai-agents
|
|
62
|
+
|
|
63
|
+
`morphiaone-mcp --version` prints the installed version; include it when you report a
|
|
64
|
+
problem, with the `request_id` of any refused call.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
morphiaone_mcp/__init__.py
|
|
5
|
+
morphiaone_mcp/server.py
|
|
6
|
+
morphiaone_mcp.egg-info/PKG-INFO
|
|
7
|
+
morphiaone_mcp.egg-info/SOURCES.txt
|
|
8
|
+
morphiaone_mcp.egg-info/dependency_links.txt
|
|
9
|
+
morphiaone_mcp.egg-info/entry_points.txt
|
|
10
|
+
morphiaone_mcp.egg-info/requires.txt
|
|
11
|
+
morphiaone_mcp.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
morphiaone_mcp
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "morphiaone-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for MorphiaOne shade digitisation, a thin client of the public V1 API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11,<3.15"
|
|
11
|
+
# The server overrides mcp's tool-call path, so it takes patch releases only. The image
|
|
12
|
+
# lock (requirements-mcp.txt) pins both exactly; a checkout installs ../sdk/python.
|
|
13
|
+
dependencies = ["morphiaone>=0.11,<0.12", "mcp>=2.3.0,<2.4"]
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICENSE"]
|
|
16
|
+
authors = [{ name = "MorphiaOne" }]
|
|
17
|
+
keywords = ["morphiaone", "mcp", "model-context-protocol", "ai-agents", "digitisation"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
morphiaone-mcp = "morphiaone_mcp.server:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Documentation = "https://docs.morphiaone.ai/guides/ai-agents"
|
|
35
|
+
Repository = "https://github.com/MorphiaOne/vision-api"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools]
|
|
38
|
+
packages = ["morphiaone_mcp"]
|
|
39
|
+
include-package-data = false
|