iccdev-mcp 0.1.0__py3-none-any.whl
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.
- iccdev_mcp/__init__.py +27 -0
- iccdev_mcp/_native.py +372 -0
- iccdev_mcp/cli_tools.py +457 -0
- iccdev_mcp/profiles.py +358 -0
- iccdev_mcp/rest_api.py +1772 -0
- iccdev_mcp/server.py +936 -0
- iccdev_mcp-0.1.0.dist-info/METADATA +382 -0
- iccdev_mcp-0.1.0.dist-info/RECORD +11 -0
- iccdev_mcp-0.1.0.dist-info/WHEEL +4 -0
- iccdev_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- iccdev_mcp-0.1.0.dist-info/licenses/LICENSE.md +26 -0
iccdev_mcp/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# iccdev-mcp -- MCP server for ICC color profile tools
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) International Color Consortium.
|
|
4
|
+
# BSD 3-Clause License. See LICENSE.md for details.
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
MCP server exposing iccDEV ICC color profile tools to AI assistants.
|
|
8
|
+
|
|
9
|
+
Provides profile tooling through:
|
|
10
|
+
- Python-native (header inspection, validation, color transforms, enums)
|
|
11
|
+
- subprocess-backed (dump, XML/JSON conversion, image I/O)
|
|
12
|
+
- service tools (health and profile discovery)
|
|
13
|
+
|
|
14
|
+
Quick start::
|
|
15
|
+
|
|
16
|
+
# Install
|
|
17
|
+
pip install "git+https://github.com/InternationalColorConsortium/iccDEV.git#subdirectory=iccdev-mcp"
|
|
18
|
+
# Or install the universal wheel URL from a successful GitHub release.
|
|
19
|
+
|
|
20
|
+
# Run MCP server (stdio transport for Claude Desktop / VS Code)
|
|
21
|
+
iccdev-mcp
|
|
22
|
+
|
|
23
|
+
# Run with SSE transport for remote access
|
|
24
|
+
iccdev-mcp --transport sse --port 8080
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
iccdev_mcp/_native.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# iccdev-mcp -- Python-native ICC helpers
|
|
2
|
+
#
|
|
3
|
+
# Copyright (c) International Color Consortium.
|
|
4
|
+
# BSD 3-Clause License. See LICENSE.md for details.
|
|
5
|
+
|
|
6
|
+
"""Small Python-native ICC profile interface used by iccdev-mcp.
|
|
7
|
+
|
|
8
|
+
This module parses ICC headers and loads the public C validation ABI when
|
|
9
|
+
available. Color transforms require the separate native iccdev bindings.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ctypes
|
|
15
|
+
from dataclasses import asdict, dataclass
|
|
16
|
+
from enum import IntEnum
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
import struct
|
|
21
|
+
from typing import NamedTuple
|
|
22
|
+
|
|
23
|
+
_HEADER_SIZE = 128
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sig(value: str) -> int:
|
|
27
|
+
return int.from_bytes(value.encode("ascii"), "big")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def sig_to_str(signature: int) -> str:
|
|
31
|
+
"""Convert a 32-bit ICC signature integer to a 4-character string."""
|
|
32
|
+
if not isinstance(signature, int):
|
|
33
|
+
raise TypeError("signature must be an integer")
|
|
34
|
+
if signature < 0 or signature > 0xFFFFFFFF:
|
|
35
|
+
raise ValueError("signature must fit in 32 bits")
|
|
36
|
+
return signature.to_bytes(4, "big").decode("latin-1")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RenderingIntent(IntEnum):
|
|
40
|
+
Perceptual = 0
|
|
41
|
+
RelativeColorimetric = 1
|
|
42
|
+
Saturation = 2
|
|
43
|
+
AbsoluteColorimetric = 3
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Interpolation(IntEnum):
|
|
47
|
+
Linear = 0
|
|
48
|
+
Tetrahedral = 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ValidationStatus(IntEnum):
|
|
52
|
+
OK = 0
|
|
53
|
+
WARNING = 1
|
|
54
|
+
NON_COMPLIANT = 2
|
|
55
|
+
CRITICAL_ERROR = 3
|
|
56
|
+
INVALID_ARGUMENT = 4
|
|
57
|
+
INTERNAL_ERROR = 5
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ValidationResult(NamedTuple):
|
|
61
|
+
status: ValidationStatus
|
|
62
|
+
report: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _validation_library_path() -> Path:
|
|
66
|
+
"""Find a shared IccProfLib containing the public validation ABI."""
|
|
67
|
+
configured = os.environ.get("ICCDEV_VALIDATION_LIBRARY")
|
|
68
|
+
if configured is not None:
|
|
69
|
+
path = Path(configured)
|
|
70
|
+
if path.is_file():
|
|
71
|
+
return path
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
"ICCDEV_VALIDATION_LIBRARY does not name a shared IccProfLib"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
build_dir = os.environ.get("ICCDEV_BUILD_DIR")
|
|
77
|
+
if build_dir:
|
|
78
|
+
library_dir = Path(build_dir) / "IccProfLib"
|
|
79
|
+
for pattern in ("libIccProfLib2*.so*", "libIccProfLib2*.dylib", "IccProfLib2*.dll"):
|
|
80
|
+
matches = sorted(path for path in library_dir.glob(pattern) if path.is_file())
|
|
81
|
+
if matches:
|
|
82
|
+
return matches[0]
|
|
83
|
+
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
"Native validation requires ICCDEV_VALIDATION_LIBRARY or "
|
|
86
|
+
"ICCDEV_BUILD_DIR pointing to a shared IccProfLib build"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@lru_cache(maxsize=None)
|
|
91
|
+
def _validation_function(library_path: str):
|
|
92
|
+
"""Load the C ABI once per resolved shared-library path."""
|
|
93
|
+
try:
|
|
94
|
+
function = ctypes.CDLL(library_path).icc_validate_profile
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
raise RuntimeError(f"Unable to load IccProfLib: {exc}") from exc
|
|
97
|
+
except AttributeError as exc:
|
|
98
|
+
raise RuntimeError(
|
|
99
|
+
"IccProfLib does not export icc_validate_profile"
|
|
100
|
+
) from exc
|
|
101
|
+
|
|
102
|
+
function.argtypes = (
|
|
103
|
+
ctypes.c_void_p,
|
|
104
|
+
ctypes.c_size_t,
|
|
105
|
+
ctypes.c_void_p,
|
|
106
|
+
ctypes.c_size_t,
|
|
107
|
+
)
|
|
108
|
+
function.restype = ctypes.c_int
|
|
109
|
+
return function
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def native_validation_available() -> bool:
|
|
113
|
+
"""Return whether the public C validation ABI can be loaded."""
|
|
114
|
+
try:
|
|
115
|
+
_validation_function(str(_validation_library_path()))
|
|
116
|
+
except RuntimeError:
|
|
117
|
+
return False
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def validate_profile(profile: bytes | bytearray | memoryview) -> ValidationResult:
|
|
122
|
+
"""Validate bytes through the public in-process C validation ABI."""
|
|
123
|
+
if not isinstance(profile, (bytes, bytearray, memoryview)):
|
|
124
|
+
raise TypeError("profile must be bytes, bytearray, or memoryview")
|
|
125
|
+
|
|
126
|
+
profile_data = bytes(profile)
|
|
127
|
+
data_buffer = ctypes.create_string_buffer(profile_data) if profile_data else None
|
|
128
|
+
report_buffer = ctypes.create_string_buffer(8192)
|
|
129
|
+
status = _validation_function(str(_validation_library_path()))(
|
|
130
|
+
ctypes.cast(data_buffer, ctypes.c_void_p) if data_buffer else None,
|
|
131
|
+
len(profile_data),
|
|
132
|
+
report_buffer,
|
|
133
|
+
len(report_buffer),
|
|
134
|
+
)
|
|
135
|
+
try:
|
|
136
|
+
validation_status = ValidationStatus(status)
|
|
137
|
+
except ValueError:
|
|
138
|
+
validation_status = ValidationStatus.INTERNAL_ERROR
|
|
139
|
+
return ValidationResult(
|
|
140
|
+
validation_status,
|
|
141
|
+
report_buffer.value.decode("utf-8", errors="replace"),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def validate_profile_file(path: str | Path) -> ValidationResult:
|
|
146
|
+
"""Read a profile and validate it through :func:`validate_profile`."""
|
|
147
|
+
return validate_profile(Path(path).read_bytes())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ColorSpace(IntEnum):
|
|
151
|
+
XYZ = _sig("XYZ ")
|
|
152
|
+
Lab = _sig("Lab ")
|
|
153
|
+
Luv = _sig("Luv ")
|
|
154
|
+
YCbCr = _sig("YCbr")
|
|
155
|
+
Yxy = _sig("Yxy ")
|
|
156
|
+
RGB = _sig("RGB ")
|
|
157
|
+
GRAY = _sig("GRAY")
|
|
158
|
+
HSV = _sig("HSV ")
|
|
159
|
+
HLS = _sig("HLS ")
|
|
160
|
+
CMYK = _sig("CMYK")
|
|
161
|
+
CMY = _sig("CMY ")
|
|
162
|
+
MCH1 = _sig("MCH1")
|
|
163
|
+
MCH2 = _sig("MCH2")
|
|
164
|
+
MCH3 = _sig("MCH3")
|
|
165
|
+
MCH4 = _sig("MCH4")
|
|
166
|
+
MCH5 = _sig("MCH5")
|
|
167
|
+
MCH6 = _sig("MCH6")
|
|
168
|
+
MCH7 = _sig("MCH7")
|
|
169
|
+
MCH8 = _sig("MCH8")
|
|
170
|
+
MCH9 = _sig("MCH9")
|
|
171
|
+
MCHA = _sig("MCHA")
|
|
172
|
+
MCHB = _sig("MCHB")
|
|
173
|
+
MCHC = _sig("MCHC")
|
|
174
|
+
MCHD = _sig("MCHD")
|
|
175
|
+
MCHE = _sig("MCHE")
|
|
176
|
+
MCHF = _sig("MCHF")
|
|
177
|
+
Named = _sig("nmcl")
|
|
178
|
+
One = _sig("1CLR")
|
|
179
|
+
Two = _sig("2CLR")
|
|
180
|
+
Three = _sig("3CLR")
|
|
181
|
+
Four = _sig("4CLR")
|
|
182
|
+
Five = _sig("5CLR")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_COLOR_SPACE_NAMES = {
|
|
186
|
+
"XYZ ": "XYZ",
|
|
187
|
+
"Lab ": "Lab",
|
|
188
|
+
"Luv ": "Luv",
|
|
189
|
+
"YCbr": "YCbCr",
|
|
190
|
+
"Yxy ": "Yxy",
|
|
191
|
+
"RGB ": "RGB",
|
|
192
|
+
"GRAY": "Gray",
|
|
193
|
+
"HSV ": "HSV",
|
|
194
|
+
"HLS ": "HLS",
|
|
195
|
+
"CMYK": "CMYK",
|
|
196
|
+
"CMY ": "CMY",
|
|
197
|
+
"nmcl": "Named",
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
_DEVICE_CLASS_NAMES = {
|
|
201
|
+
"scnr": "Input",
|
|
202
|
+
"mntr": "Display",
|
|
203
|
+
"prtr": "Output",
|
|
204
|
+
"link": "DeviceLink",
|
|
205
|
+
"spac": "ColorSpace",
|
|
206
|
+
"abst": "Abstract",
|
|
207
|
+
"nmcl": "NamedColor",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_PLATFORM_NAMES = {
|
|
211
|
+
"APPL": "Apple",
|
|
212
|
+
"MSFT": "Microsoft",
|
|
213
|
+
"SGI ": "Silicon Graphics",
|
|
214
|
+
"SUNW": "Sun",
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_RENDERING_INTENT_NAMES = {
|
|
218
|
+
0: "Perceptual",
|
|
219
|
+
1: "Media-Relative Colorimetric",
|
|
220
|
+
2: "Saturation",
|
|
221
|
+
3: "ICC-Absolute Colorimetric",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_CHANNEL_COUNTS = {
|
|
225
|
+
"XYZ ": 3,
|
|
226
|
+
"Lab ": 3,
|
|
227
|
+
"Luv ": 3,
|
|
228
|
+
"YCbr": 3,
|
|
229
|
+
"Yxy ": 3,
|
|
230
|
+
"RGB ": 3,
|
|
231
|
+
"GRAY": 1,
|
|
232
|
+
"HSV ": 3,
|
|
233
|
+
"HLS ": 3,
|
|
234
|
+
"CMYK": 4,
|
|
235
|
+
"CMY ": 3,
|
|
236
|
+
"nmcl": 1,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class IccHeader:
|
|
242
|
+
size: int
|
|
243
|
+
cmm_type: str
|
|
244
|
+
version: int
|
|
245
|
+
device_class: str
|
|
246
|
+
color_space: str
|
|
247
|
+
pcs: str
|
|
248
|
+
date_time: tuple[int, int, int, int, int, int]
|
|
249
|
+
magic: str
|
|
250
|
+
platform: str
|
|
251
|
+
flags: int
|
|
252
|
+
manufacturer: str
|
|
253
|
+
model: str
|
|
254
|
+
attributes: int
|
|
255
|
+
rendering_intent: int
|
|
256
|
+
illuminant: tuple[float, float, float]
|
|
257
|
+
creator: str
|
|
258
|
+
profile_id: bytes
|
|
259
|
+
color_space_name: str
|
|
260
|
+
pcs_name: str
|
|
261
|
+
device_class_name: str
|
|
262
|
+
version_string: str
|
|
263
|
+
rendering_intent_name: str
|
|
264
|
+
platform_name: str
|
|
265
|
+
|
|
266
|
+
def _asdict(self) -> dict:
|
|
267
|
+
return asdict(self)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class IccProfile:
|
|
271
|
+
"""Parsed ICC profile with header metadata."""
|
|
272
|
+
|
|
273
|
+
def __init__(self, path: str | Path):
|
|
274
|
+
self.path = Path(path)
|
|
275
|
+
data = self.path.read_bytes()
|
|
276
|
+
if len(data) < _HEADER_SIZE:
|
|
277
|
+
raise ValueError("ICC profile is shorter than the 128-byte header")
|
|
278
|
+
self.header = _parse_header(data[:_HEADER_SIZE])
|
|
279
|
+
self.is_v5 = ((self.header.version >> 24) & 0xFF) >= 5
|
|
280
|
+
|
|
281
|
+
def close(self) -> None:
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def open_profile(path: str | Path) -> IccProfile:
|
|
286
|
+
"""Open and parse an ICC profile header."""
|
|
287
|
+
return IccProfile(path)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _parse_header(data: bytes) -> IccHeader:
|
|
291
|
+
size = _u32(data, 0)
|
|
292
|
+
cmm_type = _ascii_sig(data, 4)
|
|
293
|
+
version = _u32(data, 8)
|
|
294
|
+
device_class = _ascii_sig(data, 12)
|
|
295
|
+
color_space = _ascii_sig(data, 16)
|
|
296
|
+
pcs = _ascii_sig(data, 20)
|
|
297
|
+
date_time = struct.unpack(">6H", data[24:36])
|
|
298
|
+
magic = _ascii_sig(data, 36)
|
|
299
|
+
if magic != "acsp":
|
|
300
|
+
raise ValueError("ICC header magic is not acsp")
|
|
301
|
+
platform = _ascii_sig(data, 40)
|
|
302
|
+
flags = _u32(data, 44)
|
|
303
|
+
manufacturer = _ascii_sig(data, 48)
|
|
304
|
+
model = _ascii_sig(data, 52)
|
|
305
|
+
attributes = struct.unpack(">Q", data[56:64])[0]
|
|
306
|
+
rendering_intent = _u32(data, 64)
|
|
307
|
+
illuminant = (
|
|
308
|
+
_s15_fixed16(data, 68),
|
|
309
|
+
_s15_fixed16(data, 72),
|
|
310
|
+
_s15_fixed16(data, 76),
|
|
311
|
+
)
|
|
312
|
+
creator = _ascii_sig(data, 80)
|
|
313
|
+
profile_id = data[84:100]
|
|
314
|
+
return IccHeader(
|
|
315
|
+
size=size,
|
|
316
|
+
cmm_type=cmm_type,
|
|
317
|
+
version=version,
|
|
318
|
+
device_class=device_class,
|
|
319
|
+
color_space=color_space,
|
|
320
|
+
pcs=pcs,
|
|
321
|
+
date_time=date_time,
|
|
322
|
+
magic=magic,
|
|
323
|
+
platform=platform,
|
|
324
|
+
flags=flags,
|
|
325
|
+
manufacturer=manufacturer,
|
|
326
|
+
model=model,
|
|
327
|
+
attributes=attributes,
|
|
328
|
+
rendering_intent=rendering_intent,
|
|
329
|
+
illuminant=illuminant,
|
|
330
|
+
creator=creator,
|
|
331
|
+
profile_id=profile_id,
|
|
332
|
+
color_space_name=_COLOR_SPACE_NAMES.get(color_space, color_space.strip()),
|
|
333
|
+
pcs_name=_COLOR_SPACE_NAMES.get(pcs, pcs.strip()),
|
|
334
|
+
device_class_name=_DEVICE_CLASS_NAMES.get(device_class, device_class.strip()),
|
|
335
|
+
version_string=_version_string(version),
|
|
336
|
+
rendering_intent_name=_RENDERING_INTENT_NAMES.get(
|
|
337
|
+
rendering_intent, str(rendering_intent)
|
|
338
|
+
),
|
|
339
|
+
platform_name=_PLATFORM_NAMES.get(platform, platform.strip()),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _ascii_sig(data: bytes, offset: int) -> str:
|
|
344
|
+
return data[offset:offset + 4].decode("latin-1")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _u32(data: bytes, offset: int) -> int:
|
|
348
|
+
return struct.unpack(">I", data[offset:offset + 4])[0]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _s15_fixed16(data: bytes, offset: int) -> float:
|
|
352
|
+
return struct.unpack(">i", data[offset:offset + 4])[0] / 65536.0
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _version_string(version: int) -> str:
|
|
356
|
+
major = (version >> 24) & 0xFF
|
|
357
|
+
minor = (version >> 20) & 0x0F
|
|
358
|
+
bugfix = (version >> 16) & 0x0F
|
|
359
|
+
return f"{major}.{minor}.{bugfix}"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _channel_count(color_space: str) -> int:
|
|
363
|
+
if color_space in _CHANNEL_COUNTS:
|
|
364
|
+
return _CHANNEL_COUNTS[color_space]
|
|
365
|
+
if len(color_space) == 4 and color_space[1:] == "CLR" and color_space[0].isdigit():
|
|
366
|
+
return int(color_space[0])
|
|
367
|
+
if len(color_space) == 4 and color_space.startswith("MCH"):
|
|
368
|
+
suffix = color_space[3]
|
|
369
|
+
if suffix.isdigit():
|
|
370
|
+
return int(suffix)
|
|
371
|
+
return 10 + ord(suffix.upper()) - ord("A")
|
|
372
|
+
return 3
|