dcc-mcp-krita 0.2.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,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ dist/
6
+ *.egg-info/
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/dcc-mcp/dcc-mcp-krita/compare/v0.1.0...v0.2.0) (2026-07-25)
4
+
5
+
6
+ ### Features
7
+
8
+ * add DCC MCP adapter ([fc7aeac](https://github.com/dcc-mcp/dcc-mcp-krita/commit/fc7aeac768c5aff17c7c553a83449e7c962800c9))
9
+ * add unified menu with Copy Instance ID, Server Info, and About DCC MCP ([#2](https://github.com/dcc-mcp/dcc-mcp-krita/issues/2)) ([e03addf](https://github.com/dcc-mcp/dcc-mcp-krita/commit/e03addf8f2963254b189772fe44757a5f9e20ebf))
10
+
11
+
12
+ ### Documentation
13
+
14
+ * optimize workflow showcase ([7a37b8d](https://github.com/dcc-mcp/dcc-mcp-krita/commit/7a37b8df6adb34f71cb815d2a52ff739d399599d))
15
+ * redesign DCC-MCP brand visuals ([0952d40](https://github.com/dcc-mcp/dcc-mcp-krita/commit/0952d40c4d4e11d719144d8d972281d9d2b4c69a))
16
+
17
+ ## 0.1.0
18
+
19
+ - Initial Krita session bridge and MCP adapter.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 loonghao
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,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: dcc-mcp-krita
3
+ Version: 0.2.0
4
+ Summary: Krita adapter for the DCC Model Context Protocol
5
+ Project-URL: Homepage, https://github.com/dcc-mcp/dcc-mcp-krita
6
+ Project-URL: Repository, https://github.com/dcc-mcp/dcc-mcp-krita
7
+ Project-URL: Issues, https://github.com/dcc-mcp/dcc-mcp-krita/issues
8
+ Author-email: loonghao <hal.long@outlook.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: automation,dcc,image-editing,krita,mcp
12
+ Requires-Python: >=3.9
13
+ Requires-Dist: dcc-mcp-core<1.0.0,>=0.19.38
14
+ Provides-Extra: dev
15
+ Requires-Dist: build>=1.2; extra == 'dev'
16
+ Requires-Dist: pytest>=8; extra == 'dev'
17
+ Requires-Dist: ruff>=0.8; extra == 'dev'
18
+ Requires-Dist: twine>=6; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # dcc-mcp-krita
22
+
23
+ ![DCC-MCP Krita](docs/images/dcc-mcp-krita.svg)
24
+
25
+ Krita adapter for the DCC Model Context Protocol ecosystem.
26
+
27
+ ![Krita game-art workflow](docs/images/dcc-mcp-krita-showcase.webp)
28
+
29
+ The adapter uses a small Krita Python plug-in and a loopback JSON-lines bridge.
30
+ The MCP server runs in the normal Python environment; KRITA API calls stay inside
31
+ the plug-in process. It does not expose arbitrary Python or Script-Fu execution.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install dcc-mcp-krita
37
+ dcc-mcp-krita-install
38
+ ```
39
+
40
+ Restart Krita, enable the bundled Python plug-in, then start:
41
+
42
+ ```bash
43
+ dcc-mcp-krita
44
+ ```
45
+
46
+ The MCP endpoint defaults to `http://127.0.0.1:8767/mcp`; the plug-in bridge uses
47
+ `127.0.0.1:3848`. Override the latter with `DCC_MCP_KRITA_BRIDGE_PORT` before
48
+ starting both processes.
49
+
50
+ ## Current tools
51
+
52
+ - Check KRITA bridge status and version.
53
+ - List open images with dimensions.
54
+ - Inspect the active image.
55
+
56
+ The first release targets safe session discovery. Image mutation and export will
57
+ be added only through typed KRITA procedures, not arbitrary source evaluation.
58
+
59
+ ## Development
60
+
61
+ ```bash
62
+ python -m pip install -e ".[dev]"
63
+ python -m pytest
64
+ python -m ruff check src tests tools
65
+ python tools/lint_skills.py
66
+ python -m build
67
+ python -m twine check dist/*
68
+ ```
69
+
70
+ Krita plug-in API reference: https://developer.krita.org/api/3.0/
@@ -0,0 +1,50 @@
1
+ # dcc-mcp-krita
2
+
3
+ ![DCC-MCP Krita](docs/images/dcc-mcp-krita.svg)
4
+
5
+ Krita adapter for the DCC Model Context Protocol ecosystem.
6
+
7
+ ![Krita game-art workflow](docs/images/dcc-mcp-krita-showcase.webp)
8
+
9
+ The adapter uses a small Krita Python plug-in and a loopback JSON-lines bridge.
10
+ The MCP server runs in the normal Python environment; KRITA API calls stay inside
11
+ the plug-in process. It does not expose arbitrary Python or Script-Fu execution.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install dcc-mcp-krita
17
+ dcc-mcp-krita-install
18
+ ```
19
+
20
+ Restart Krita, enable the bundled Python plug-in, then start:
21
+
22
+ ```bash
23
+ dcc-mcp-krita
24
+ ```
25
+
26
+ The MCP endpoint defaults to `http://127.0.0.1:8767/mcp`; the plug-in bridge uses
27
+ `127.0.0.1:3848`. Override the latter with `DCC_MCP_KRITA_BRIDGE_PORT` before
28
+ starting both processes.
29
+
30
+ ## Current tools
31
+
32
+ - Check KRITA bridge status and version.
33
+ - List open images with dimensions.
34
+ - Inspect the active image.
35
+
36
+ The first release targets safe session discovery. Image mutation and export will
37
+ be added only through typed KRITA procedures, not arbitrary source evaluation.
38
+
39
+ ## Development
40
+
41
+ ```bash
42
+ python -m pip install -e ".[dev]"
43
+ python -m pytest
44
+ python -m ruff check src tests tools
45
+ python tools/lint_skills.py
46
+ python -m build
47
+ python -m twine check dist/*
48
+ ```
49
+
50
+ Krita plug-in API reference: https://developer.krita.org/api/3.0/
@@ -0,0 +1,301 @@
1
+ """DCC MCP Krita — unified menu and JSON-lines bridge server.
2
+
3
+ This pykrita extension provides:
4
+ 1. A background JSON-lines bridge server for external MCP communication.
5
+ 2. Unified menu actions: Copy Instance ID, Server Info, About DCC MCP.
6
+
7
+ The bridge server runs in a daemon thread so Krita's UI remains responsive.
8
+ Menu actions appear under Tools → Scripts (standard Krita extension location).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import socketserver
16
+ import sys
17
+ import threading
18
+
19
+ # The Krita module is only available inside Krita's embedded Python.
20
+ # This typing-only import silences static analysis without affecting runtime.
21
+ if False: # pragma: no cover
22
+ from krita import Extension, Krita # noqa: F401
23
+
24
+ BRIDGE_PORT: int = int(os.environ.get("DCC_MCP_KRITA_BRIDGE_PORT", "3848"))
25
+
26
+ _bridge_thread: threading.Thread | None = None
27
+ _bridge_server: socketserver.ThreadingTCPServer | None = None
28
+
29
+
30
+ # ── Version (hard-coded to avoid import-time side-effects) ───────────────────
31
+
32
+ VERSION: str = "0.2.0"
33
+
34
+ # ── Bridge server ────────────────────────────────────────────────────────────
35
+
36
+
37
+ def _document_info(document):
38
+ """Extract metadata from a Krita Document for the bridge."""
39
+ return {
40
+ "name": document.name(),
41
+ "width": document.width(),
42
+ "height": document.height(),
43
+ }
44
+
45
+
46
+ class _BridgeHandler(socketserver.StreamRequestHandler):
47
+ """Single-request JSON-lines handler for the Krita bridge."""
48
+
49
+ def handle(self) -> None:
50
+ try:
51
+ request = json.loads(self.rfile.readline())
52
+ except (json.JSONDecodeError, ValueError):
53
+ return
54
+ method = request.get("method", "")
55
+ app = Krita.instance()
56
+ documents = app.documents()
57
+ if method in {"krita.get_status", "krita.ping"}:
58
+ result = {"ready": True, "krita_version": app.version(), "bridge_port": BRIDGE_PORT}
59
+ elif method in {"krita.list_documents", "krita.list_images"}:
60
+ result = [_document_info(document) for document in documents]
61
+ elif method in {"krita.get_active_document", "krita.get_active_image"}:
62
+ document = app.activeDocument()
63
+ result = _document_info(document) if document else None
64
+ else:
65
+ result = {"error": f"Unsupported Krita bridge method: {method}"}
66
+ self.wfile.write(
67
+ (
68
+ json.dumps(
69
+ {"jsonrpc": "2.0", "id": request.get("id"), "result": result}
70
+ )
71
+ + "\n"
72
+ ).encode()
73
+ )
74
+
75
+
76
+ def _start_bridge() -> None:
77
+ """Start the TCP bridge server in a background daemon thread.
78
+
79
+ Idempotent — if already running, this is a no-op.
80
+ """
81
+ global _bridge_thread, _bridge_server
82
+ if _bridge_thread is not None and _bridge_thread.is_alive():
83
+ return
84
+
85
+ class _Server(socketserver.ThreadingTCPServer):
86
+ allow_reuse_address = True
87
+ daemon_threads = True
88
+
89
+ _bridge_server = _Server(("127.0.0.1", BRIDGE_PORT), _BridgeHandler)
90
+ _bridge_thread = threading.Thread(target=_bridge_server.serve_forever, daemon=True)
91
+ _bridge_thread.start()
92
+
93
+
94
+ def _stop_bridge() -> None:
95
+ """Shut down the bridge server."""
96
+ global _bridge_thread, _bridge_server
97
+ if _bridge_server is not None:
98
+ _bridge_server.shutdown()
99
+ _bridge_server.server_close()
100
+ _bridge_server = None
101
+ _bridge_thread = None
102
+
103
+
104
+ # ── Clipboard helper ─────────────────────────────────────────────────────────
105
+
106
+
107
+ def _set_clipboard_text(text: str) -> None:
108
+ """Set the system clipboard text.
109
+
110
+ Tries PyQt5 (Krita's bundled binding) first, then PySide2 as fallback.
111
+ Raises RuntimeError if neither binding is available.
112
+ """
113
+ for binding in ("PyQt5", "PySide2"):
114
+ try:
115
+ mod = __import__(binding)
116
+ app = mod.QtWidgets.QApplication.instance()
117
+ if app is not None:
118
+ app.clipboard().setText(text)
119
+ return
120
+ except Exception:
121
+ continue
122
+ raise RuntimeError("Unable to access system clipboard (no Qt binding available)")
123
+
124
+
125
+ # ── Instance ID resolution ───────────────────────────────────────────────────
126
+
127
+
128
+ def _resolve_instance_id() -> str:
129
+ """Resolve the DCC MCP instance UUID.
130
+
131
+ Priority:
132
+ 1. ``DCC_MCP_INSTANCE_ID`` environment variable.
133
+ 2. Running server's ``instance_id`` attribute.
134
+ Falls back to ``"unknown"``.
135
+ """
136
+ instance_id = os.environ.get("DCC_MCP_INSTANCE_ID")
137
+ if instance_id:
138
+ return instance_id
139
+
140
+ try:
141
+ from dcc_mcp_krita.server import _server # type: ignore[import-not-found]
142
+
143
+ if _server is not None and getattr(_server, "is_running", False):
144
+ sid = getattr(_server, "instance_id", None)
145
+ if isinstance(sid, str):
146
+ return sid
147
+ except Exception:
148
+ pass
149
+
150
+ return "unknown"
151
+
152
+
153
+ def _server_url() -> str:
154
+ """Return the MCP server URL or a placeholder."""
155
+ try:
156
+ from dcc_mcp_krita.server import _server # type: ignore[import-not-found]
157
+
158
+ if _server is not None and getattr(_server, "is_running", False):
159
+ return getattr(_server, "mcp_url", "N/A")
160
+ except Exception:
161
+ pass
162
+ return "N/A"
163
+
164
+
165
+ # ── Menu actions ─────────────────────────────────────────────────────────────
166
+
167
+
168
+ def _copy_instance_id() -> None:
169
+ """Copy the DCC MCP instance UUID to the system clipboard.
170
+
171
+ Falls back to ``print()`` when the clipboard is unavailable (headless).
172
+ """
173
+ instance_id = _resolve_instance_id()
174
+ copied = False
175
+ try:
176
+ _set_clipboard_text(instance_id)
177
+ copied = True
178
+ except RuntimeError:
179
+ pass
180
+
181
+ if copied:
182
+ print(f"DCC MCP: Instance ID copied to clipboard: {instance_id}") # noqa: T201
183
+ else:
184
+ print(f"DCC MCP Instance ID: {instance_id}") # noqa: T201
185
+
186
+
187
+ def _show_server_info() -> None:
188
+ """Display DCC MCP server status information in a dialog."""
189
+ instance_id = _resolve_instance_id()
190
+ mcp_url = _server_url()
191
+
192
+ krita_version = "unknown"
193
+ try:
194
+ krita_version = Krita.instance().version()
195
+ except Exception:
196
+ pass
197
+
198
+ core_version = "unknown"
199
+ try:
200
+ from dcc_mcp_core.server_base import _package_version # type: ignore[import-not-found]
201
+
202
+ core_version = _package_version() or "unknown"
203
+ except Exception:
204
+ pass
205
+
206
+ gateway_port_str = os.environ.get("DCC_MCP_GATEWAY_PORT", "0")
207
+ try:
208
+ gp = int(gateway_port_str)
209
+ except ValueError:
210
+ gp = 0
211
+ gateway_display = "disabled" if gp <= 0 else str(gp)
212
+
213
+ lines = [
214
+ f"Instance UUID: {instance_id}",
215
+ f"DCC: Krita {krita_version}",
216
+ f"PID: {os.getpid()}",
217
+ f"MCP URL: {mcp_url}",
218
+ f"Gateway Port: {gateway_display}",
219
+ f"Core Version: {core_version}",
220
+ f"Adapter Version: {VERSION}",
221
+ f"Python: {sys.version.split()[0]}",
222
+ ]
223
+ _show_message_box("DCC MCP — Server Info", "\n".join(lines))
224
+
225
+
226
+ def _show_about() -> None:
227
+ """Show the About DCC MCP dialog with version information."""
228
+ krita_version = "unknown"
229
+ try:
230
+ krita_version = Krita.instance().version()
231
+ except Exception:
232
+ pass
233
+
234
+ text = (
235
+ f"dcc-mcp-krita v{VERSION}\n"
236
+ f"Krita {krita_version}\n"
237
+ f"Python {sys.version.split()[0]}\n\n"
238
+ "DCC MCP — AI-driven DCC automation.\n"
239
+ "https://github.com/dcc-mcp/dcc-mcp-krita"
240
+ )
241
+ _show_message_box("About DCC MCP", text)
242
+
243
+
244
+ def _show_message_box(title: str, text: str) -> None:
245
+ """Show a message box using PyQt5 (Krita's bundled binding)."""
246
+ try:
247
+ from PyQt5.QtWidgets import QMessageBox
248
+ except ImportError:
249
+ try:
250
+ from PySide2.QtWidgets import QMessageBox
251
+ except ImportError:
252
+ print(f"{title}\n{text}") # noqa: T201
253
+ return
254
+
255
+ try:
256
+ window = Krita.instance().activeWindow()
257
+ if window is not None:
258
+ qwin = window.qwindow()
259
+ if qwin is not None:
260
+ QMessageBox.information(qwin, title, text)
261
+ return
262
+ except Exception:
263
+ pass
264
+
265
+ print(f"{title}\n{text}") # noqa: T201
266
+
267
+
268
+ # ── Pykrita Extension ───────────────────────────────────────────────────────
269
+
270
+
271
+ class DccMcpKritaExtension(Extension): # type: ignore[name-defined] # noqa: F821
272
+ """Krita pykrita extension for DCC MCP.
273
+
274
+ Registers unified menu actions and starts the bridge server on load.
275
+ """
276
+
277
+ def __init__(self, parent: object) -> None: # noqa: D107
278
+ super().__init__(parent) # type: ignore[call-arg]
279
+
280
+ def setup(self) -> None: # noqa: D102
281
+ _start_bridge()
282
+
283
+ def createActions(self, window: object) -> None: # noqa: D102, N802
284
+ copy_action = window.createAction(
285
+ "dcc_mcp_copy_instance_id", "Copy Instance ID", "tools/scripts"
286
+ )
287
+ copy_action.triggered.connect(_copy_instance_id)
288
+
289
+ server_action = window.createAction(
290
+ "dcc_mcp_server_info", "Server Info", "tools/scripts"
291
+ )
292
+ server_action.triggered.connect(_show_server_info)
293
+
294
+ about_action = window.createAction(
295
+ "dcc_mcp_about", "About DCC MCP", "tools/scripts"
296
+ )
297
+ about_action.triggered.connect(_show_about)
298
+
299
+
300
+ # Register the extension when Krita loads this module.
301
+ Krita.instance().addExtension(DccMcpKritaExtension(Krita.instance()))
@@ -0,0 +1,7 @@
1
+ [Desktop Entry]
2
+ Type=Service
3
+ X-KDE-ServiceTypes=Krita/PythonPlugin
4
+ X-Python-2-Compatible=false
5
+ X-Krita-Version=28
6
+ Name=DCC MCP Krita
7
+ Comment=DCC MCP integration for Krita — AI-driven automation bridge
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dcc-mcp-krita"
7
+ version = "0.2.0"
8
+ description = "Krita adapter for the DCC Model Context Protocol"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "loonghao", email = "hal.long@outlook.com" }]
13
+ keywords = ["krita", "image-editing", "mcp", "dcc", "automation"]
14
+ dependencies = ["dcc-mcp-core>=0.19.38,<1.0.0"]
15
+
16
+ [project.optional-dependencies]
17
+ dev = ["build>=1.2", "pytest>=8", "ruff>=0.8", "twine>=6"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/dcc-mcp/dcc-mcp-krita"
21
+ Repository = "https://github.com/dcc-mcp/dcc-mcp-krita"
22
+ Issues = "https://github.com/dcc-mcp/dcc-mcp-krita/issues"
23
+
24
+ [project.scripts]
25
+ dcc-mcp-krita = "dcc_mcp_krita.server:main"
26
+ dcc-mcp-krita-install = "dcc_mcp_krita.install:main"
27
+
28
+ [project.entry-points."dcc_mcp.adapters"]
29
+ krita = "dcc_mcp_krita:KritaMcpServer"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/dcc_mcp_krita"]
33
+ artifacts = ["src/dcc_mcp_krita/skills/**"]
34
+
35
+ [tool.hatch.build.targets.wheel.force-include]
36
+ "bridge/krita-plugin/dcc_mcp_krita.desktop" = "dcc_mcp_krita/krita_plugin/dcc_mcp_krita.desktop"
37
+ "bridge/krita-plugin/dcc_mcp_krita/__init__.py" = "dcc_mcp_krita/krita_plugin/dcc_mcp_krita/__init__.py"
38
+
39
+ [tool.hatch.build.targets.sdist]
40
+ include = ["src/dcc_mcp_krita", "bridge", "tests", "README.md", "LICENSE", "CHANGELOG.md", "tools"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
44
+ pythonpath = ["src", "."]
45
+
46
+ [tool.ruff]
47
+ target-version = "py39"
48
+ line-length = 100
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "I", "B"]
@@ -0,0 +1,3 @@
1
+ from .server import KritaMcpServer
2
+
3
+ __all__ = ["KritaMcpServer"]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,51 @@
1
+ """Small JSON-lines client for the Krita plug-in bridge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import socket
8
+ from typing import Any
9
+
10
+
11
+ class KritaBridgeError(RuntimeError):
12
+ """Raised when the KRITA plug-in bridge is unavailable or rejects a call."""
13
+
14
+
15
+ class KritaBridge:
16
+ def __init__(self, host: str = "127.0.0.1", port: int = 3848, timeout: float = 10.0) -> None:
17
+ self.host = host
18
+ self.port = port
19
+ self.timeout = timeout
20
+
21
+ @classmethod
22
+ def from_env(cls) -> "KritaBridge":
23
+ return cls(
24
+ host=os.environ.get("DCC_MCP_KRITA_BRIDGE_HOST", "127.0.0.1"),
25
+ port=int(os.environ.get("DCC_MCP_KRITA_BRIDGE_PORT", "3848")),
26
+ timeout=float(os.environ.get("DCC_MCP_KRITA_BRIDGE_TIMEOUT", "10")),
27
+ )
28
+
29
+ def call(self, method: str, **params: Any) -> Any:
30
+ request = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
31
+ try:
32
+ with socket.create_connection(
33
+ (self.host, self.port), timeout=self.timeout
34
+ ) as connection:
35
+ connection.sendall((request + "\n").encode("utf-8"))
36
+ response = connection.makefile("r", encoding="utf-8").readline()
37
+ except OSError as exc:
38
+ raise KritaBridgeError(
39
+ f"KRITA bridge unavailable at {self.host}:{self.port}; "
40
+ "install and run the Krita plug-in"
41
+ ) from exc
42
+ if not response:
43
+ raise KritaBridgeError("KRITA bridge closed the connection without a response")
44
+ payload = json.loads(response)
45
+ if "error" in payload:
46
+ raise KritaBridgeError(str(payload["error"]))
47
+ return payload.get("result")
48
+
49
+
50
+ def get_bridge() -> KritaBridge:
51
+ return KritaBridge.from_env()
@@ -0,0 +1,82 @@
1
+ """Install the Krita pykrita extension into the user pykrita directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ _PLUGIN_NAME = "dcc_mcp_krita"
11
+
12
+
13
+ def default_pykrita_dir() -> Path:
14
+ """Return the default Krita pykrita directory for the current platform."""
15
+ if os.name == "nt":
16
+ root = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
17
+ return root / "krita" / "pykrita"
18
+ return (
19
+ Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
20
+ / "krita"
21
+ / "pykrita"
22
+ )
23
+
24
+
25
+ def _resolve_source_dir() -> Path:
26
+ """Find the bridge/krita-plugin directory (dev) or installed wheel location."""
27
+ # Wheel layout: dcc_mcp_krita/krita_plugin/...
28
+ candidate = (
29
+ Path(__file__).resolve().parent / "krita_plugin"
30
+ )
31
+ if candidate.is_dir():
32
+ return candidate
33
+
34
+ # Dev layout: ../../bridge/krita-plugin
35
+ candidate = Path(__file__).resolve().parents[2] / "bridge" / "krita-plugin"
36
+ if candidate.is_dir():
37
+ return candidate
38
+
39
+ raise FileNotFoundError(
40
+ "Bundled Krita plugin directory not found (tried wheel and dev layouts)"
41
+ )
42
+
43
+
44
+ def install(destination: Path | None = None) -> Path:
45
+ """Install the pykrita extension into the user's Krita pykrita directory.
46
+
47
+ Copies:
48
+ - ``dcc_mcp_krita.desktop`` → ``<pykrita>/dcc_mcp_krita.desktop``
49
+ - ``dcc_mcp_krita/`` module → ``<pykrita>/dcc_mcp_krita/``
50
+ """
51
+ target = (destination or default_pykrita_dir()).expanduser().resolve()
52
+ target.mkdir(parents=True, exist_ok=True)
53
+
54
+ source_dir = _resolve_source_dir()
55
+
56
+ # Install .desktop file
57
+ desktop_src = source_dir / f"{_PLUGIN_NAME}.desktop"
58
+ if not desktop_src.is_file():
59
+ raise FileNotFoundError(f"Desktop entry not found: {desktop_src}")
60
+ shutil.copy2(desktop_src, target / desktop_src.name)
61
+
62
+ # Install plugin module directory
63
+ module_src = source_dir / _PLUGIN_NAME
64
+ if not module_src.is_dir():
65
+ raise FileNotFoundError(f"Plugin module directory not found: {module_src}")
66
+ module_dst = target / _PLUGIN_NAME
67
+ if module_dst.exists():
68
+ shutil.rmtree(module_dst)
69
+ shutil.copytree(module_src, module_dst)
70
+
71
+ if os.name != "nt":
72
+ for py_file in module_dst.rglob("*.py"):
73
+ py_file.chmod(0o755)
74
+
75
+ return target
76
+
77
+
78
+ def main() -> None:
79
+ parser = argparse.ArgumentParser(description="Install DCC MCP Krita pykrita extension")
80
+ parser.add_argument("--destination", type=Path, help="Override pykrita directory")
81
+ result = install(parser.parse_args().destination)
82
+ print(f"Installed DCC MCP Krita extension to {result}")
@@ -0,0 +1,62 @@
1
+ """Standalone KRITA MCP server lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import sys
8
+ import threading
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from dcc_mcp_core import DccServerOptions
13
+ from dcc_mcp_core.server_base import DccServerBase
14
+
15
+ from .__version__ import __version__
16
+
17
+ DEFAULT_PORT = 8767
18
+ _server: Optional["KritaMcpServer"] = None
19
+
20
+
21
+ class KritaMcpServer(DccServerBase):
22
+ """Krita adapter using its Python plug-in bridge."""
23
+
24
+ def __init__(self, port: int = DEFAULT_PORT) -> None:
25
+ os.environ.setdefault("DCC_MCP_PYTHON_EXECUTABLE", sys.executable)
26
+ options = DccServerOptions.from_env(
27
+ "krita",
28
+ Path(__file__).resolve().parent / "skills",
29
+ port=port,
30
+ server_name="dcc-mcp-krita",
31
+ server_version=__version__,
32
+ )
33
+ super().__init__(options=options)
34
+
35
+
36
+ def start_server(port: Optional[int] = None) -> KritaMcpServer:
37
+ global _server
38
+ if _server is None or not _server.is_running:
39
+ selected_port = port or int(os.environ.get("DCC_MCP_KRITA_PORT", DEFAULT_PORT))
40
+ _server = KritaMcpServer(selected_port)
41
+ _server.register_builtin_actions()
42
+ _server.start()
43
+ return _server
44
+
45
+
46
+ def stop_server() -> None:
47
+ global _server
48
+ if _server is not None:
49
+ _server.stop()
50
+ _server = None
51
+
52
+
53
+ def main() -> None:
54
+ stopped = threading.Event()
55
+ signal.signal(signal.SIGINT, lambda *_: stopped.set())
56
+ if hasattr(signal, "SIGTERM"):
57
+ signal.signal(signal.SIGTERM, lambda *_: stopped.set())
58
+ start_server()
59
+ try:
60
+ stopped.wait()
61
+ finally:
62
+ stop_server()
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: krita-session
3
+ description: >-
4
+ Inspect the connected Krita session through the DCC-MCP Python plug-in
5
+ bridge. Use for session health, open images, and active image metadata.
6
+ license: MIT
7
+ compatibility: "Krita.0+; dcc-mcp-core 0.19+"
8
+ allowed-tools: "python"
9
+ metadata:
10
+ dcc-mcp:
11
+ dcc: krita
12
+ layer: domain
13
+ version: "0.1.0"
14
+ search-hint: "KRITA image editor session document active image layers"
15
+ tags: "krita,image-editing,session"
16
+ tools: tools.yaml
17
+ depends: "dcc-diagnostics"
18
+ ---
19
+
20
+ # KRITA Session
21
+
22
+ Install and run the bundled Krita plug-in before using this skill. Calls use a
23
+ loopback JSON-lines bridge and never execute arbitrary KRITA/Python source.
@@ -0,0 +1,15 @@
1
+ from dcc_mcp_core.skill import run_main, skill_entry, skill_success
2
+
3
+ from dcc_mcp_krita.bridge import get_bridge
4
+
5
+
6
+ @skill_entry
7
+ def main(**_kwargs):
8
+ return skill_success(
9
+ "Active KRITA image inspected.",
10
+ image=get_bridge().call("krita.get_active_image"),
11
+ )
12
+
13
+
14
+ if __name__ == "__main__":
15
+ run_main(main)
@@ -0,0 +1,12 @@
1
+ from dcc_mcp_core.skill import run_main, skill_entry, skill_success
2
+
3
+ from dcc_mcp_krita.bridge import get_bridge
4
+
5
+
6
+ @skill_entry
7
+ def main(**_kwargs):
8
+ return skill_success("KRITA bridge is ready.", **get_bridge().call("krita.get_status"))
9
+
10
+
11
+ if __name__ == "__main__":
12
+ run_main(main)
@@ -0,0 +1,13 @@
1
+ from dcc_mcp_core.skill import run_main, skill_entry, skill_success
2
+
3
+ from dcc_mcp_krita.bridge import get_bridge
4
+
5
+
6
+ @skill_entry
7
+ def main(**_kwargs):
8
+ images = get_bridge().call("krita.list_images")
9
+ return skill_success(f"Found {len(images)} open image(s).", count=len(images), images=images)
10
+
11
+
12
+ if __name__ == "__main__":
13
+ run_main(main)
@@ -0,0 +1,34 @@
1
+ tools:
2
+ - name: get_status
3
+ description: Check the Krita plug-in bridge and return KRITA version and bridge metadata.
4
+ input_schema: {type: object, properties: {}, additionalProperties: false}
5
+ output_schema: {type: object}
6
+ read_only: true
7
+ destructive: false
8
+ idempotent: true
9
+ execution: sync
10
+ affinity: any
11
+ timeout_hint_secs: 30
12
+ source_file: scripts/get_status.py
13
+ - name: list_images
14
+ description: List open KRITA images with dimensions and names. Read-only.
15
+ input_schema: {type: object, properties: {}, additionalProperties: false}
16
+ output_schema: {type: object}
17
+ read_only: true
18
+ destructive: false
19
+ idempotent: true
20
+ execution: sync
21
+ affinity: any
22
+ timeout_hint_secs: 30
23
+ source_file: scripts/list_images.py
24
+ - name: get_active_image
25
+ description: Return metadata for the active KRITA image. Read-only.
26
+ input_schema: {type: object, properties: {}, additionalProperties: false}
27
+ output_schema: {type: object}
28
+ read_only: true
29
+ destructive: false
30
+ idempotent: true
31
+ execution: sync
32
+ affinity: any
33
+ timeout_hint_secs: 30
34
+ source_file: scripts/get_active_image.py
@@ -0,0 +1,67 @@
1
+ """Exercise the real KRITA plug-in bridge and MCP tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.request
8
+
9
+ from dcc_mcp_krita.bridge import KritaBridge
10
+ from dcc_mcp_krita.server import KritaMcpServer
11
+
12
+
13
+ def post(url: str, method: str, params=None):
14
+ request = urllib.request.Request(
15
+ url,
16
+ data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode(),
17
+ headers={
18
+ "Accept": "application/json, text/event-stream",
19
+ "Content-Type": "application/json",
20
+ },
21
+ )
22
+ with urllib.request.urlopen(request, timeout=30) as response:
23
+ return json.loads(response.read())
24
+
25
+
26
+ def call(url: str, name: str, arguments=None):
27
+ response = post(url, "tools/call", {"name": name, "arguments": arguments or {}})
28
+ result = response.get("result", {})
29
+ if response.get("error") or result.get("isError"):
30
+ raise RuntimeError(json.dumps(response))
31
+ structured = result.get("structuredContent")
32
+ if structured is not None:
33
+ return structured
34
+ return json.loads(result["content"][0]["text"])
35
+
36
+
37
+ def main() -> None:
38
+ bridge = KritaBridge.from_env()
39
+ deadline = time.monotonic() + 45
40
+ while time.monotonic() < deadline:
41
+ try:
42
+ status = bridge.call("krita.get_status")
43
+ break
44
+ except Exception:
45
+ time.sleep(0.5)
46
+ else:
47
+ raise RuntimeError("KRITA plug-in bridge did not become ready")
48
+
49
+ server = KritaMcpServer(port=0)
50
+ try:
51
+ server.register_builtin_actions()
52
+ server.start(install_atexit_hook=False)
53
+ url = server.mcp_url
54
+ call(url, "load_skill", {"skill_name": "krita-session"})
55
+ listed = post(url, "tools/list", {})["result"]["tools"]
56
+ names = {item["name"] for item in listed}
57
+ assert any(name.endswith("__get_status") for name in names)
58
+ assert call(url, next(name for name in names if name.endswith("__get_status")))["ready"]
59
+ images = call(url, next(name for name in names if name.endswith("__list_images")))
60
+ assert isinstance(images.get("images"), list)
61
+ print(json.dumps({"status": status, "images": images}))
62
+ finally:
63
+ server.stop()
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
@@ -0,0 +1,24 @@
1
+ import json
2
+ import socket
3
+ import threading
4
+
5
+ from dcc_mcp_krita.bridge import KritaBridge
6
+
7
+
8
+ def test_bridge_sends_json_lines_request():
9
+ received = {}
10
+
11
+ def serve(listener):
12
+ connection, _ = listener.accept()
13
+ with connection:
14
+ received["request"] = json.loads(connection.makefile("r", encoding="utf-8").readline())
15
+ connection.sendall(b'{"jsonrpc":"2.0","id":1,"result":{"ready":true}}\n')
16
+
17
+ listener = socket.socket()
18
+ listener.bind(("127.0.0.1", 0))
19
+ listener.listen(1)
20
+ threading.Thread(target=serve, args=(listener,), daemon=True).start()
21
+ bridge = KritaBridge(port=listener.getsockname()[1])
22
+ assert bridge.call("krita.ping") == {"ready": True}
23
+ assert received["request"]["method"] == "krita.ping"
24
+ listener.close()
@@ -0,0 +1,225 @@
1
+ """Tests for Krita plugin installation and module structure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ # ── Install tests ────────────────────────────────────────────────────────────
12
+
13
+
14
+ class TestDefaultPykritaDir:
15
+ """Platform-specific pykrita directory resolution."""
16
+
17
+ @pytest.mark.skipif(os.name != "nt", reason="WindowsPath not available on Linux")
18
+ def test_windows_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
19
+ monkeypatch.setattr(os, "name", "nt")
20
+ monkeypatch.setenv("APPDATA", "C:\\Users\\test\\AppData\\Roaming")
21
+ from dcc_mcp_krita.install import default_pykrita_dir
22
+
23
+ result = default_pykrita_dir()
24
+ assert result == Path("C:/Users/test/AppData/Roaming/krita/pykrita")
25
+
26
+ @pytest.mark.skipif(os.name != "nt", reason="WindowsPath not available on Linux")
27
+ def test_windows_fallback_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
28
+ monkeypatch.setattr(os, "name", "nt")
29
+ monkeypatch.delenv("APPDATA", raising=False)
30
+ monkeypatch.setattr(Path, "home", lambda: Path("C:/Users/test"))
31
+ from dcc_mcp_krita.install import default_pykrita_dir
32
+
33
+ result = default_pykrita_dir()
34
+ assert result == Path("C:/Users/test/AppData/Roaming/krita/pykrita")
35
+
36
+ @pytest.mark.skipif(os.name == "nt", reason="PosixPath not available on Windows")
37
+ def test_linux_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
38
+ monkeypatch.setattr(os, "name", "posix")
39
+ monkeypatch.setenv("XDG_DATA_HOME", "/home/test/.local/share")
40
+ from dcc_mcp_krita.install import default_pykrita_dir
41
+
42
+ result = default_pykrita_dir()
43
+ assert result == Path("/home/test/.local/share/krita/pykrita")
44
+
45
+ @pytest.mark.skipif(os.name == "nt", reason="PosixPath not available on Windows")
46
+ def test_linux_fallback_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
47
+ monkeypatch.setattr(os, "name", "posix")
48
+ monkeypatch.delenv("XDG_DATA_HOME", raising=False)
49
+ monkeypatch.setattr(Path, "home", lambda: Path("/home/test"))
50
+ from dcc_mcp_krita.install import default_pykrita_dir
51
+
52
+ result = default_pykrita_dir()
53
+ assert result == Path("/home/test/.local/share/krita/pykrita")
54
+
55
+
56
+ class TestInstall:
57
+ """Integration tests for the install process."""
58
+
59
+ def test_install_copies_desktop_and_module(self, tmp_path: Path) -> None:
60
+ """install() copies both the .desktop file and the module directory."""
61
+ from dcc_mcp_krita.install import install
62
+
63
+ install(tmp_path)
64
+ assert (tmp_path / "dcc_mcp_krita.desktop").is_file()
65
+ assert (tmp_path / "dcc_mcp_krita" / "__init__.py").is_file()
66
+
67
+ def test_install_desktop_content(self, tmp_path: Path) -> None:
68
+ """Desktop entry file contains the expected metadata."""
69
+ from dcc_mcp_krita.install import install
70
+
71
+ install(tmp_path)
72
+ content = (tmp_path / "dcc_mcp_krita.desktop").read_text(encoding="utf-8")
73
+ assert "DCC MCP" in content
74
+ assert "Krita/PythonPlugin" in content
75
+ assert "X-Krita-Version" in content
76
+
77
+ def test_install_module_is_valid_python(self, tmp_path: Path) -> None:
78
+ """The installed __init__.py is syntactically valid Python."""
79
+ from dcc_mcp_krita.install import install
80
+
81
+ install(tmp_path)
82
+ source = (tmp_path / "dcc_mcp_krita" / "__init__.py").read_text(encoding="utf-8")
83
+ ast.parse(source) # raises SyntaxError on failure
84
+
85
+ def test_install_creates_target_if_missing(self, tmp_path: Path) -> None:
86
+ """install() creates the pykrita directory if it does not exist."""
87
+ from dcc_mcp_krita.install import install
88
+
89
+ target = tmp_path / "new" / "pykrita"
90
+ assert not target.exists()
91
+ install(target)
92
+ assert target.is_dir()
93
+ assert (target / "dcc_mcp_krita.desktop").is_file()
94
+
95
+ def test_install_overwrites_existing_module(self, tmp_path: Path) -> None:
96
+ """Re-installing replaces the existing module directory."""
97
+ from dcc_mcp_krita.install import install
98
+
99
+ install(tmp_path)
100
+ # Second install — should not raise
101
+ install(tmp_path)
102
+ # Module should still be present after second install
103
+ assert (tmp_path / "dcc_mcp_krita" / "__init__.py").is_file()
104
+
105
+
106
+ # ── Plugin module validation ─────────────────────────────────────────────────
107
+
108
+
109
+ class TestPluginModule:
110
+ """Validate the plugin module structure."""
111
+
112
+ def test_plugin_init_exists(self) -> None:
113
+ """The __init__.py exists in the source tree."""
114
+ repo_root = Path(__file__).resolve().parents[1]
115
+ init_path = repo_root / "bridge" / "krita-plugin" / "dcc_mcp_krita" / "__init__.py"
116
+ assert init_path.is_file(), f"Missing: {init_path}"
117
+
118
+ def test_plugin_desktop_exists(self) -> None:
119
+ """The .desktop file exists in the source tree."""
120
+ repo_root = Path(__file__).resolve().parents[1]
121
+ desktop_path = repo_root / "bridge" / "krita-plugin" / "dcc_mcp_krita.desktop"
122
+ assert desktop_path.is_file(), f"Missing: {desktop_path}"
123
+
124
+ def test_plugin_init_is_valid_python(self) -> None:
125
+ """The __init__.py parses as valid Python 3.9+."""
126
+ repo_root = Path(__file__).resolve().parents[1]
127
+ init_path = repo_root / "bridge" / "krita-plugin" / "dcc_mcp_krita" / "__init__.py"
128
+ source = init_path.read_text(encoding="utf-8")
129
+ ast.parse(source)
130
+
131
+ def test_plugin_init_contains_menu_actions(self) -> None:
132
+ """The plugin module defines the three unified menu action functions."""
133
+ repo_root = Path(__file__).resolve().parents[1]
134
+ init_path = repo_root / "bridge" / "krita-plugin" / "dcc_mcp_krita" / "__init__.py"
135
+ source = init_path.read_text(encoding="utf-8")
136
+ assert "_copy_instance_id" in source
137
+ assert "_show_server_info" in source
138
+ assert "_show_about" in source
139
+
140
+ def test_plugin_init_contains_clipboard_helper(self) -> None:
141
+ """The plugin module defines the clipboard helper."""
142
+ repo_root = Path(__file__).resolve().parents[1]
143
+ init_path = repo_root / "bridge" / "krita-plugin" / "dcc_mcp_krita" / "__init__.py"
144
+ source = init_path.read_text(encoding="utf-8")
145
+ assert "_set_clipboard_text" in source
146
+ assert "QApplication.clipboard" in source or "clipboard()" in source
147
+
148
+ def test_plugin_init_references_project_url(self) -> None:
149
+ """The About dialog references the correct GitHub URL."""
150
+ repo_root = Path(__file__).resolve().parents[1]
151
+ init_path = repo_root / "bridge" / "krita-plugin" / "dcc_mcp_krita" / "__init__.py"
152
+ source = init_path.read_text(encoding="utf-8")
153
+ assert "github.com/dcc-mcp/dcc-mcp-krita" in source
154
+
155
+ def test_old_single_file_removed(self) -> None:
156
+ """The old dcc_mcp_krita.py single-file plugin has been removed."""
157
+ repo_root = Path(__file__).resolve().parents[1]
158
+ old_path = repo_root / "bridge" / "krita-plugin" / "dcc_mcp_krita.py"
159
+ assert not old_path.exists(), (
160
+ "Old single-file bridge plugin still exists — should be replaced by the "
161
+ "dcc_mcp_krita/ directory structure"
162
+ )
163
+
164
+
165
+ # ── Version consistency ──────────────────────────────────────────────────────
166
+
167
+
168
+ class TestVersionConsistency:
169
+ """Ensure version strings stay aligned."""
170
+
171
+ def test_pyproject_version_matches_init(self) -> None:
172
+ """pyproject.toml version matches src/dcc_mcp_krita/__version__.py."""
173
+ repo_root = Path(__file__).resolve().parents[1]
174
+ import tomllib
175
+
176
+ pyproject = tomllib.loads(
177
+ (repo_root / "pyproject.toml").read_text(encoding="utf-8")
178
+ )
179
+ pyproject_version = pyproject["project"]["version"]
180
+
181
+ # Read __version__.py
182
+ version_path = repo_root / "src" / "dcc_mcp_krita" / "__version__.py"
183
+ version_ns: dict = {}
184
+ exec(version_path.read_text(encoding="utf-8"), version_ns)
185
+ assert pyproject_version == version_ns["__version__"]
186
+
187
+ def test_pyproject_version_matches_plugin_init(self) -> None:
188
+ """pyproject.toml version matches the hard-coded VERSION in the plugin."""
189
+ repo_root = Path(__file__).resolve().parents[1]
190
+ import tomllib
191
+
192
+ pyproject = tomllib.loads(
193
+ (repo_root / "pyproject.toml").read_text(encoding="utf-8")
194
+ )
195
+ pyproject_version = pyproject["project"]["version"]
196
+
197
+ init_path = repo_root / "bridge" / "krita-plugin" / "dcc_mcp_krita" / "__init__.py"
198
+ source = init_path.read_text(encoding="utf-8")
199
+ assert f'VERSION: str = "{pyproject_version}"' in source
200
+
201
+
202
+ # ── Python version compliance ────────────────────────────────────────────────
203
+
204
+
205
+ class TestPythonCompliance:
206
+ """Ensure the plugin code is compatible with the project's minimum Python."""
207
+
208
+ def test_union_type_syntax(self) -> None:
209
+ """Verify that `X | Y` union syntax is valid (requires Python 3.10+, but
210
+ we have ``from __future__ import annotations`` so it's parseable on 3.9+).
211
+ """
212
+ # On Python 3.9, `int | None` would be a TypeError at runtime without
213
+ # ``from __future__ import annotations``. Test that the plugin uses the
214
+ # future import and parses cleanly.
215
+ repo_root = Path(__file__).resolve().parents[1]
216
+ init_path = repo_root / "bridge" / "krita-plugin" / "dcc_mcp_krita" / "__init__.py"
217
+ source = init_path.read_text(encoding="utf-8")
218
+ tree = ast.parse(source)
219
+ # The file must have the future import as its first import
220
+ assert any(
221
+ isinstance(node, ast.ImportFrom)
222
+ and node.module == "__future__"
223
+ and any(alias.name == "annotations" for alias in node.names)
224
+ for node in ast.iter_child_nodes(tree)
225
+ ), "Plugin __init__.py must have `from __future__ import annotations`"
@@ -0,0 +1,8 @@
1
+ from pathlib import Path
2
+
3
+ from dcc_mcp_core import validate_skill
4
+
5
+ skills_root = Path(__file__).parents[1] / "src" / "dcc_mcp_krita" / "skills"
6
+ reports = [validate_skill(str(path)) for path in skills_root.iterdir() if path.is_dir()]
7
+ assert all(report.is_clean for report in reports), [report.issues for report in reports]
8
+ print(f"validated {len(reports)} bundled skills")