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.
- dcc_mcp_krita-0.2.0/.gitignore +6 -0
- dcc_mcp_krita-0.2.0/CHANGELOG.md +19 -0
- dcc_mcp_krita-0.2.0/LICENSE +21 -0
- dcc_mcp_krita-0.2.0/PKG-INFO +70 -0
- dcc_mcp_krita-0.2.0/README.md +50 -0
- dcc_mcp_krita-0.2.0/bridge/krita-plugin/dcc_mcp_krita/__init__.py +301 -0
- dcc_mcp_krita-0.2.0/bridge/krita-plugin/dcc_mcp_krita.desktop +7 -0
- dcc_mcp_krita-0.2.0/pyproject.toml +51 -0
- dcc_mcp_krita-0.2.0/src/dcc_mcp_krita/__init__.py +3 -0
- dcc_mcp_krita-0.2.0/src/dcc_mcp_krita/__version__.py +1 -0
- dcc_mcp_krita-0.2.0/src/dcc_mcp_krita/bridge.py +51 -0
- dcc_mcp_krita-0.2.0/src/dcc_mcp_krita/install.py +82 -0
- dcc_mcp_krita-0.2.0/src/dcc_mcp_krita/server.py +62 -0
- dcc_mcp_krita-0.2.0/src/dcc_mcp_krita/skills/krita-session/SKILL.md +23 -0
- dcc_mcp_krita-0.2.0/src/dcc_mcp_krita/skills/krita-session/metadata/depends.md +1 -0
- dcc_mcp_krita-0.2.0/src/dcc_mcp_krita/skills/krita-session/scripts/get_active_image.py +15 -0
- dcc_mcp_krita-0.2.0/src/dcc_mcp_krita/skills/krita-session/scripts/get_status.py +12 -0
- dcc_mcp_krita-0.2.0/src/dcc_mcp_krita/skills/krita-session/scripts/list_images.py +13 -0
- dcc_mcp_krita-0.2.0/src/dcc_mcp_krita/skills/krita-session/tools.yaml +34 -0
- dcc_mcp_krita-0.2.0/tests/live_gimp_smoke.py +67 -0
- dcc_mcp_krita-0.2.0/tests/test_bridge.py +24 -0
- dcc_mcp_krita-0.2.0/tests/test_plugin_structure.py +225 -0
- dcc_mcp_krita-0.2.0/tools/lint_skills.py +8 -0
|
@@ -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
|
+

|
|
24
|
+
|
|
25
|
+
Krita adapter for the DCC Model Context Protocol ecosystem.
|
|
26
|
+
|
|
27
|
+

|
|
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
|
+

|
|
4
|
+
|
|
5
|
+
Krita adapter for the DCC Model Context Protocol ecosystem.
|
|
6
|
+
|
|
7
|
+

|
|
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,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 @@
|
|
|
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 @@
|
|
|
1
|
+
- dcc-diagnostics
|
|
@@ -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")
|