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.
@@ -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,2 @@
1
+ [console_scripts]
2
+ morphiaone-mcp = morphiaone_mcp.server:main
@@ -0,0 +1,2 @@
1
+ morphiaone<0.12,>=0.11
2
+ mcp<2.4,>=2.3.0
@@ -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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+