mcpinkscape 0.0.2__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.
@@ -0,0 +1,3 @@
1
+ """MCP server for Inkscape documents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Run mcpInkscape with ``python -m mcpinkscape``."""
2
+
3
+ from mcpinkscape.app.server import run_mcp_server
4
+
5
+
6
+ if __name__ == "__main__":
7
+ run_mcp_server()
@@ -0,0 +1 @@
1
+ """MCP application entrypoint."""
@@ -0,0 +1,514 @@
1
+ """FastMCP server exposing the typed mcpInkscape service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ import logging
7
+ from contextlib import asynccontextmanager
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from fastmcp import Context, FastMCP
13
+ from fastmcp.server.transforms import Transform
14
+
15
+ from mcpinkscape.config.api_keys import match_api_key
16
+ from mcpinkscape.config.config_manager import generate_and_store_api_key, load_config, parse_arguments, setup_logging
17
+ from mcpinkscape.config.schema import APIKeyConfig, AccessConfig, Config
18
+ from mcpinkscape.service import InkscapeService, NATIVE_TOOL_METHODS
19
+
20
+
21
+ logger = logging.getLogger(__name__)
22
+ ACTIVE_ACCESS: contextvars.ContextVar[AccessConfig | None] = contextvars.ContextVar("mcpinkscape_access", default = None)
23
+
24
+
25
+ class _ToolAccessTransform(Transform):
26
+ """Apply the active access policy to FastMCP tool discovery and calls."""
27
+
28
+ def __init__(self, tool_allowed):
29
+ self._tool_allowed = tool_allowed
30
+
31
+ async def list_tools(self, tools):
32
+ return [tool for tool in tools if self._tool_allowed(tool.name)]
33
+
34
+ async def get_tool(self, name, call_next, *, version = None):
35
+ if not self._tool_allowed(name):
36
+ return None
37
+ return await call_next(name, version = version)
38
+
39
+
40
+ DOCUMENT_WRITE_TOOLS = frozenset({
41
+ "create_document", "open_document", "save_document", "close_document",
42
+ "create_layer", "select_objects", "clear_selection", "create_shape",
43
+ "create_rectangle", "create_ellipse", "create_circle", "create_line",
44
+ "create_polyline", "create_polygon", "create_path", "create_text",
45
+ "set_text", "set_style", "set_fill", "set_stroke", "set_opacity",
46
+ "set_object_attribute", "set_page_background", "move_objects",
47
+ "create_gradient", "set_gradient_stops", "apply_gradient",
48
+ "import_image",
49
+ "rotate_objects", "scale_objects", "set_object_transform",
50
+ "duplicate_objects", "delete_objects", "group_objects", "ungroup_objects",
51
+ "raise_objects", "lower_objects", "export_document",
52
+ })
53
+ SNAPSHOT_TOOLS = frozenset({"render_snapshot", "list_snapshots", "get_snapshot_base64"})
54
+ NATIVE_BRIDGE_TOOLS = frozenset(NATIVE_TOOL_METHODS)
55
+
56
+
57
+ def _tool_allowed_for_access(name: str, access: AccessConfig, native_bridge_available: bool = False) -> bool:
58
+ """Return whether a policy permits advertising and calling *name*."""
59
+ if not name:
60
+ return False
61
+ if name in DOCUMENT_WRITE_TOOLS and not access.allow_document_write:
62
+ return False
63
+ if name in SNAPSHOT_TOOLS and not access.allow_snapshots:
64
+ return False
65
+ if name.startswith("live_") and not access.allow_live_control:
66
+ return False
67
+ if name in NATIVE_BRIDGE_TOOLS and not native_bridge_available:
68
+ return False
69
+ return True
70
+
71
+
72
+ @dataclass
73
+ class ServerState:
74
+ config: Config
75
+ stdio_service: InkscapeService
76
+ remote_services: dict[str, InkscapeService] = field(default_factory = dict)
77
+
78
+ def close(self) -> None:
79
+ self.stdio_service.close()
80
+ for service in self.remote_services.values():
81
+ service.close()
82
+ self.remote_services.clear()
83
+
84
+ def service_for_access(self, access: AccessConfig) -> InkscapeService:
85
+ if access is self.config.stdio:
86
+ return self.stdio_service
87
+ if not isinstance(access, APIKeyConfig):
88
+ return self.stdio_service
89
+ service = self.remote_services.get(access.id)
90
+ if service is None:
91
+ service = InkscapeService(access, self.config.bridge)
92
+ self.remote_services[access.id] = service
93
+ return service
94
+
95
+
96
+ def _access_from_context(ctx: Context | None, state: ServerState) -> AccessConfig:
97
+ request = getattr(getattr(ctx, "request_context", None), "request", None)
98
+ if request is not None:
99
+ access = getattr(getattr(request, "state", object()), "mcpinkscape_access", None)
100
+ if isinstance(access, AccessConfig):
101
+ return access
102
+ return ACTIVE_ACCESS.get() or state.config.stdio
103
+
104
+
105
+ def build_server(config: Config) -> tuple[FastMCP, ServerState]:
106
+ state = ServerState(config, InkscapeService(config.stdio, config.bridge))
107
+
108
+ @asynccontextmanager
109
+ async def lifespan(_: FastMCP):
110
+ try:
111
+ yield state
112
+ finally:
113
+ state.close()
114
+
115
+ server = FastMCP("mcpinkscape", lifespan = lifespan)
116
+ server.add_transform(
117
+ _ToolAccessTransform(
118
+ lambda name: _tool_allowed_for_access(
119
+ name,
120
+ ACTIVE_ACCESS.get() or state.config.stdio,
121
+ state.service_for_access(ACTIVE_ACCESS.get() or state.config.stdio).native_supports_tool(name),
122
+ )
123
+ )
124
+ )
125
+ _register_tools(server, state)
126
+ return server, state
127
+
128
+
129
+ def _register_tools(server: FastMCP, state: ServerState) -> None:
130
+ def service(ctx: Context | None) -> InkscapeService:
131
+ return state.service_for_access(_access_from_context(ctx, state))
132
+
133
+ @server.tool(name = "server_status", description = "Return offline, active-window, native-bridge, and policy status.")
134
+ def server_status(ctx: Context = None) -> dict[str, Any]:
135
+ return service(ctx).server_status()
136
+
137
+ @server.tool(name = "list_documents", description = "List documents currently held by this MCP session.")
138
+ def list_documents(ctx: Context = None) -> list[dict[str, Any]]:
139
+ return service(ctx).list_documents()
140
+
141
+ @server.tool(name = "create_document", description = "Create a new SVG document below the configured document root.")
142
+ def create_document(name: str, width: float = 800, height: float = 600, ctx: Context = None) -> dict[str, Any]:
143
+ return service(ctx).create_document(name, width, height)
144
+
145
+ @server.tool(name = "open_document", description = "Open an SVG below the configured document root.")
146
+ def open_document(path: str, ctx: Context = None) -> dict[str, Any]:
147
+ return service(ctx).open_document(path)
148
+
149
+ @server.tool(name = "save_document", description = "Save an opened SVG document, optionally under a new relative SVG path.")
150
+ def save_document(document_id: str, path: str | None = None, ctx: Context = None) -> dict[str, Any]:
151
+ return service(ctx).save_document(document_id, path)
152
+
153
+ @server.tool(name = "close_document", description = "Forget an opened offline document from this MCP session.")
154
+ def close_document(document_id: str, ctx: Context = None) -> dict[str, Any]:
155
+ return service(ctx).close_document(document_id)
156
+
157
+ @server.tool(name = "get_document_info", description = "Inspect one opened document, including revision and selection.")
158
+ def get_document_info(document_id: str, ctx: Context = None) -> dict[str, Any]:
159
+ return service(ctx).get_document_info(document_id)
160
+
161
+ @server.tool(name = "list_layers", description = "List top-level SVG groups/layers in a document.")
162
+ def list_layers(document_id: str, ctx: Context = None) -> list[dict[str, Any]]:
163
+ return service(ctx).list_layers(document_id)
164
+
165
+ @server.tool(name = "create_layer", description = "Create an Inkscape layer with a stable SVG ID.")
166
+ def create_layer(document_id: str, label: str, layer_id: str | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
167
+ return service(ctx).create_layer(document_id, label, layer_id, expected_revision)
168
+
169
+ @server.tool(name = "list_objects", description = "List direct child objects of an SVG root, group, or layer.")
170
+ def list_objects(document_id: str, parent_id: str | None = None, ctx: Context = None) -> list[dict[str, Any]]:
171
+ return service(ctx).list_objects(document_id, parent_id)
172
+
173
+ @server.tool(name = "get_object", description = "Inspect an SVG object by its stable ID.")
174
+ def get_object(document_id: str, object_id: str, ctx: Context = None) -> dict[str, Any]:
175
+ return service(ctx).get_object(document_id, object_id)
176
+
177
+ @server.tool(name = "get_object_bounds", description = "Return bounds for a basic SVG object by stable ID.")
178
+ def get_object_bounds(document_id: str, object_id: str, ctx: Context = None) -> dict[str, Any]:
179
+ return service(ctx).get_object_bounds(document_id, object_id)
180
+
181
+ @server.tool(name = "get_selection", description = "Return the explicit offline-document selection.")
182
+ def get_selection(document_id: str, ctx: Context = None) -> dict[str, Any]:
183
+ return service(ctx).get_selection(document_id)
184
+
185
+ @server.tool(name = "select_objects", description = "Set the explicit offline-document selection by object ID.")
186
+ def select_objects(document_id: str, object_ids: list[str], ctx: Context = None) -> dict[str, Any]:
187
+ return service(ctx).select_objects(document_id, object_ids)
188
+
189
+ @server.tool(name = "clear_selection", description = "Clear the explicit offline-document selection.")
190
+ def clear_selection(document_id: str, ctx: Context = None) -> dict[str, Any]:
191
+ return service(ctx).clear_selection(document_id)
192
+
193
+ @server.tool(name = "create_shape", description = "Create a typed rectangle, ellipse, circle, line, polyline, polygon, or SVG path.")
194
+ def create_shape(document_id: str, kind: str, values: dict[str, Any], object_id: str | None = None, layer_id: str | None = None, style: dict[str, Any] | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
195
+ return service(ctx).create_shape(document_id, kind, values, object_id, layer_id, style, expected_revision)
196
+
197
+ def _create_basic_shape(kind: str, document_id: str, values: dict[str, Any], object_id: str | None, layer_id: str | None, style: dict[str, Any] | None, expected_revision: int | None, ctx: Context | None) -> dict[str, Any]:
198
+ return service(ctx).create_shape(document_id, kind, values, object_id, layer_id, style, expected_revision)
199
+
200
+ @server.tool(name = "create_rectangle", description = "Create a rectangle; values requires x, y, width, and height.")
201
+ def create_rectangle(document_id: str, values: dict[str, Any], object_id: str | None = None, layer_id: str | None = None, style: dict[str, Any] | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
202
+ return _create_basic_shape("rectangle", document_id, values, object_id, layer_id, style, expected_revision, ctx)
203
+
204
+ @server.tool(name = "create_ellipse", description = "Create an ellipse; values requires cx, cy, rx, and ry.")
205
+ def create_ellipse(document_id: str, values: dict[str, Any], object_id: str | None = None, layer_id: str | None = None, style: dict[str, Any] | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
206
+ return _create_basic_shape("ellipse", document_id, values, object_id, layer_id, style, expected_revision, ctx)
207
+
208
+ @server.tool(name = "create_circle", description = "Create a circle; values requires cx, cy, and r.")
209
+ def create_circle(document_id: str, values: dict[str, Any], object_id: str | None = None, layer_id: str | None = None, style: dict[str, Any] | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
210
+ return _create_basic_shape("circle", document_id, values, object_id, layer_id, style, expected_revision, ctx)
211
+
212
+ @server.tool(name = "create_line", description = "Create a line; values requires x1, y1, x2, and y2.")
213
+ def create_line(document_id: str, values: dict[str, Any], object_id: str | None = None, layer_id: str | None = None, style: dict[str, Any] | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
214
+ return _create_basic_shape("line", document_id, values, object_id, layer_id, style, expected_revision, ctx)
215
+
216
+ @server.tool(name = "create_polyline", description = "Create a polyline; values requires points as [[x,y], ...].")
217
+ def create_polyline(document_id: str, values: dict[str, Any], object_id: str | None = None, layer_id: str | None = None, style: dict[str, Any] | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
218
+ return _create_basic_shape("polyline", document_id, values, object_id, layer_id, style, expected_revision, ctx)
219
+
220
+ @server.tool(name = "create_polygon", description = "Create a polygon; values requires points as [[x,y], ...].")
221
+ def create_polygon(document_id: str, values: dict[str, Any], object_id: str | None = None, layer_id: str | None = None, style: dict[str, Any] | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
222
+ return _create_basic_shape("polygon", document_id, values, object_id, layer_id, style, expected_revision, ctx)
223
+
224
+ @server.tool(name = "create_path", description = "Create an SVG path; values requires d.")
225
+ def create_path(document_id: str, values: dict[str, Any], object_id: str | None = None, layer_id: str | None = None, style: dict[str, Any] | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
226
+ return _create_basic_shape("path", document_id, values, object_id, layer_id, style, expected_revision, ctx)
227
+
228
+ @server.tool(name = "create_text", description = "Create a basic SVG text object at x/y with optional typed style.")
229
+ def create_text(document_id: str, text: str, x: float, y: float, object_id: str | None = None, layer_id: str | None = None, style: dict[str, Any] | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
230
+ return service(ctx).create_text(document_id, text, x, y, object_id, layer_id, style, expected_revision)
231
+
232
+ @server.tool(name = "set_text", description = "Replace text content in a basic SVG text object.")
233
+ def set_text(document_id: str, object_id: str, text: str, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
234
+ return service(ctx).set_text(document_id, object_id, text, expected_revision)
235
+
236
+ @server.tool(name = "set_style", description = "Atomically set typed fill, stroke, opacity, and text style fields on objects.")
237
+ def set_style(document_id: str, object_ids: list[str], style: dict[str, Any], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
238
+ return service(ctx).set_style(document_id, object_ids, style, expected_revision)
239
+
240
+ @server.tool(name = "set_fill", description = "Set object foreground/fill colour and optional opacity.")
241
+ def set_fill(document_id: str, object_ids: list[str], colour: str, opacity: float | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
242
+ style: dict[str, Any] = {"fill": colour}
243
+ if opacity is not None:
244
+ style["fill_opacity"] = opacity
245
+ return service(ctx).set_style(document_id, object_ids, style, expected_revision)
246
+
247
+ @server.tool(name = "set_stroke", description = "Set object outline/stroke colour, width, and optional opacity.")
248
+ def set_stroke(document_id: str, object_ids: list[str], colour: str, width: float | str | None = None, opacity: float | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
249
+ style: dict[str, Any] = {"stroke": colour}
250
+ if width is not None:
251
+ style["stroke_width"] = width
252
+ if opacity is not None:
253
+ style["stroke_opacity"] = opacity
254
+ return service(ctx).set_style(document_id, object_ids, style, expected_revision)
255
+
256
+ @server.tool(name = "set_opacity", description = "Set overall object opacity from 0 to 1.")
257
+ def set_opacity(document_id: str, object_ids: list[str], opacity: float, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
258
+ return service(ctx).set_style(document_id, object_ids, {"opacity": opacity}, expected_revision)
259
+
260
+ @server.tool(name = "set_object_attribute", description = "Set an allowlisted non-structural SVG presentation attribute.")
261
+ def set_object_attribute(document_id: str, object_ids: list[str], attribute: str, value: str, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
262
+ return service(ctx).set_attribute(document_id, object_ids, attribute, value, expected_revision)
263
+
264
+ @server.tool(name = "set_page_background", description = "Set Inkscape page background colour and opacity.")
265
+ def set_page_background(document_id: str, colour: str, opacity: float = 1.0, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
266
+ return service(ctx).set_page_background(document_id, colour, opacity, expected_revision)
267
+
268
+ @server.tool(name = "create_gradient", description = "Create a typed linear or radial SVG gradient with ordered colour/opacity stops.")
269
+ def create_gradient(document_id: str, kind: str, stops: list[dict[str, Any]], geometry: dict[str, Any] | None = None, gradient_id: str | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
270
+ return service(ctx).create_gradient(document_id, kind, stops, geometry, gradient_id, expected_revision)
271
+
272
+ @server.tool(name = "set_gradient_stops", description = "Replace a typed SVG gradient's ordered stops atomically.")
273
+ def set_gradient_stops(document_id: str, gradient_id: str, stops: list[dict[str, Any]], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
274
+ return service(ctx).set_gradient_stops(document_id, gradient_id, stops, expected_revision)
275
+
276
+ @server.tool(name = "apply_gradient", description = "Apply an existing typed gradient to object fills or strokes.")
277
+ def apply_gradient(document_id: str, object_ids: list[str], gradient_id: str, target: str = "fill", expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
278
+ return service(ctx).apply_gradient(document_id, object_ids, gradient_id, target, expected_revision)
279
+
280
+ @server.tool(name = "import_image", description = "Embed a configured-root PNG or JPEG as a typed positioned SVG image.")
281
+ def import_image(document_id: str, path: str, x: float, y: float, width: float | None = None, height: float | None = None, object_id: str | None = None, layer_id: str | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
282
+ return service(ctx).import_image(document_id, path, x, y, width, height, object_id, layer_id, expected_revision)
283
+
284
+ @server.tool(name = "move_objects", description = "Translate objects by dx/dy SVG user units.")
285
+ def move_objects(document_id: str, object_ids: list[str], dx: float, dy: float, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
286
+ return service(ctx).transform(document_id, "move", object_ids, {"dx": dx, "dy": dy}, expected_revision)
287
+
288
+ @server.tool(name = "rotate_objects", description = "Rotate objects by degrees, optionally around cx/cy.")
289
+ def rotate_objects(document_id: str, object_ids: list[str], degrees: float, cx: float | None = None, cy: float | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
290
+ return service(ctx).transform(document_id, "rotate", object_ids, {"degrees": degrees, "cx": cx, "cy": cy}, expected_revision)
291
+
292
+ @server.tool(name = "scale_objects", description = "Scale objects by non-zero scale_x and optional scale_y.")
293
+ def scale_objects(document_id: str, object_ids: list[str], scale_x: float, scale_y: float | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
294
+ return service(ctx).transform(document_id, "scale", object_ids, {"scale_x": scale_x, "scale_y": scale_y}, expected_revision)
295
+
296
+ @server.tool(name = "set_object_transform", description = "Replace an object's SVG transform with a validated transform string.")
297
+ def set_object_transform(document_id: str, object_ids: list[str], transform: str, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
298
+ return service(ctx).transform(document_id, "set_transform", object_ids, {"transform": transform}, expected_revision)
299
+
300
+ @server.tool(name = "duplicate_objects", description = "Duplicate objects with new stable IDs.")
301
+ def duplicate_objects(document_id: str, object_ids: list[str], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
302
+ return service(ctx).duplicate_objects(document_id, object_ids, expected_revision)
303
+
304
+ @server.tool(name = "delete_objects", description = "Delete objects by ID.")
305
+ def delete_objects(document_id: str, object_ids: list[str], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
306
+ return service(ctx).delete_objects(document_id, object_ids, expected_revision)
307
+
308
+ @server.tool(name = "group_objects", description = "Group sibling SVG objects under a new group.")
309
+ def group_objects(document_id: str, object_ids: list[str], group_id: str | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
310
+ return service(ctx).group_objects(document_id, object_ids, group_id, expected_revision)
311
+
312
+ @server.tool(name = "ungroup_objects", description = "Ungroup a direct SVG group.")
313
+ def ungroup_objects(document_id: str, group_id: str, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
314
+ return service(ctx).ungroup_objects(document_id, group_id, expected_revision)
315
+
316
+ @server.tool(name = "raise_objects", description = "Raise sibling objects to the top of their parent stacking order.")
317
+ def raise_objects(document_id: str, object_ids: list[str], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
318
+ return service(ctx).reorder_objects(document_id, object_ids, "top", expected_revision)
319
+
320
+ @server.tool(name = "lower_objects", description = "Lower sibling objects to the bottom of their parent stacking order.")
321
+ def lower_objects(document_id: str, object_ids: list[str], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
322
+ return service(ctx).reorder_objects(document_id, object_ids, "bottom", expected_revision)
323
+
324
+ @server.tool(name = "render_snapshot", description = "Render an offline document page or drawing through Inkscape and register a PNG snapshot.")
325
+ def render_snapshot(document_id: str, area: str = "page", width_px: int | None = None, height_px: int | None = None, dpi: float | None = None, background: str = "document", ctx: Context = None) -> dict[str, Any]:
326
+ return service(ctx).render_snapshot(document_id, area, width_px, height_px, dpi, background)
327
+
328
+ @server.tool(name = "list_snapshots", description = "List server-registered PNG snapshots.")
329
+ def list_snapshots(ctx: Context = None) -> list[dict[str, object]]:
330
+ return service(ctx).list_snapshots()
331
+
332
+ @server.tool(name = "export_document", description = "Export an offline document as svg, plain-svg, png, or pdf below the document root.")
333
+ def export_document(document_id: str, path: str, export_type: str = "svg", ctx: Context = None) -> dict[str, Any]:
334
+ return service(ctx).export_document(document_id, path, export_type)
335
+
336
+ @server.tool(name = "get_snapshot_base64", description = "Retrieve a registered PNG snapshot as base64.")
337
+ def get_snapshot_base64(snapshot_id: str, ctx: Context = None) -> dict[str, Any]:
338
+ return service(ctx).get_snapshot_base64(snapshot_id)
339
+
340
+ @server.tool(name = "live_status", description = "Inspect the version-probed active-window CLI backend.")
341
+ def live_status(ctx: Context = None) -> dict[str, Any]:
342
+ return service(ctx).live_status()
343
+
344
+ @server.tool(name = "live_select", description = "Select objects in the active Inkscape window by ID.")
345
+ def live_select(object_ids: list[str], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
346
+ return service(ctx).live_select(object_ids, expected_revision)
347
+
348
+ @server.tool(name = "live_list_selection", description = "Inspect the focused live Inkscape selection through the native bridge or supported active-window CLI.")
349
+ def live_list_selection(ctx: Context = None) -> dict[str, Any]:
350
+ return service(ctx).live_list_selection()
351
+
352
+ @server.tool(name = "live_list_objects", description = "List objects in the focused live Inkscape document through the native bridge.")
353
+ def live_list_objects(parent_id: str | None = None, offset: int = 0, limit: int = 100, ctx: Context = None) -> dict[str, Any]:
354
+ return service(ctx).live_list_objects(parent_id, offset, limit)
355
+
356
+ @server.tool(name = "live_poll_changes", description = "Poll native live document and selection revisions for intervening collaborative edits.")
357
+ def live_poll_changes(after_revision: int, after_selection_generation: int, ctx: Context = None) -> dict[str, Any]:
358
+ return service(ctx).live_poll_changes(after_revision, after_selection_generation)
359
+
360
+ @server.tool(name = "live_create_shape", description = "Create a typed shape in the focused live document through the native bridge.")
361
+ def live_create_shape(kind: str, values: dict[str, Any], object_id: str | None = None, layer_id: str | None = None, style: dict[str, Any] | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
362
+ return service(ctx).live_create_shape(kind, values, object_id, layer_id, style, expected_revision)
363
+
364
+ @server.tool(name = "live_create_text", description = "Create typed text in the focused live document through the native bridge.")
365
+ def live_create_text(text: str, x: float, y: float, object_id: str | None = None, layer_id: str | None = None, style: dict[str, Any] | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
366
+ return service(ctx).live_create_text(text, x, y, object_id, layer_id, style, expected_revision)
367
+
368
+ @server.tool(name = "live_set_text", description = "Replace a basic text object's content through the native live bridge.")
369
+ def live_set_text(object_id: str, text: str, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
370
+ return service(ctx).live_set_text(object_id, text, expected_revision)
371
+
372
+ @server.tool(name = "live_set_page_background", description = "Set page background colour and opacity through the native live bridge.")
373
+ def live_set_page_background(colour: str, opacity: float = 1.0, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
374
+ return service(ctx).live_set_page_background(colour, opacity, expected_revision)
375
+
376
+ @server.tool(name = "live_create_gradient", description = "Create a typed linear or radial SVG gradient through the native live bridge.")
377
+ def live_create_gradient(kind: str, stops: list[dict[str, Any]], geometry: dict[str, Any] | None = None, gradient_id: str | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
378
+ return service(ctx).live_create_gradient(kind, stops, geometry, gradient_id, expected_revision)
379
+
380
+ @server.tool(name = "live_set_gradient_stops", description = "Replace typed gradient stops through the native live bridge.")
381
+ def live_set_gradient_stops(gradient_id: str, stops: list[dict[str, Any]], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
382
+ return service(ctx).live_set_gradient_stops(gradient_id, stops, expected_revision)
383
+
384
+ @server.tool(name = "live_apply_gradient", description = "Apply an existing native gradient to object fills or strokes.")
385
+ def live_apply_gradient(object_ids: list[str], gradient_id: str, target: str = "fill", expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
386
+ return service(ctx).live_apply_gradient(object_ids, gradient_id, target, expected_revision)
387
+
388
+ @server.tool(name = "live_import_image", description = "Embed a configured-root PNG or JPEG through the native live bridge.")
389
+ def live_import_image(path: str, x: float, y: float, width: float | None = None, height: float | None = None, object_id: str | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
390
+ return service(ctx).live_import_image(path, x, y, width, height, object_id, expected_revision)
391
+
392
+ @server.tool(name = "live_delete_objects", description = "Atomically delete explicit object IDs through the native live bridge.")
393
+ def live_delete_objects(object_ids: list[str], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
394
+ return service(ctx).live_delete_objects(object_ids, expected_revision)
395
+
396
+ @server.tool(name = "live_duplicate_objects", description = "Duplicate explicit live objects with returned stable IDs through the native bridge.")
397
+ def live_duplicate_objects(object_ids: list[str], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
398
+ return service(ctx).live_duplicate_objects(object_ids, expected_revision)
399
+
400
+ @server.tool(name = "live_group_objects", description = "Group sibling live objects under an explicit stable group ID through the native bridge.")
401
+ def live_group_objects(object_ids: list[str], group_id: str, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
402
+ return service(ctx).live_group_objects(object_ids, group_id, expected_revision)
403
+
404
+ @server.tool(name = "live_ungroup_objects", description = "Ungroup a live SVG group through the native bridge.")
405
+ def live_ungroup_objects(group_id: str, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
406
+ return service(ctx).live_ungroup_objects(group_id, expected_revision)
407
+
408
+ @server.tool(name = "live_raise_objects", description = "Raise sibling live objects to their parent's top stacking position.")
409
+ def live_raise_objects(object_ids: list[str], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
410
+ return service(ctx).live_reorder_objects(object_ids, "top", expected_revision)
411
+
412
+ @server.tool(name = "live_lower_objects", description = "Lower sibling live objects to their parent's bottom stacking position.")
413
+ def live_lower_objects(object_ids: list[str], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
414
+ return service(ctx).live_reorder_objects(object_ids, "bottom", expected_revision)
415
+
416
+ @server.tool(name = "live_set_style", description = "Set supported fill/stroke/opacity fields on selected live objects.")
417
+ def live_set_style(object_ids: list[str], style: dict[str, Any], expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
418
+ return service(ctx).live_set_style(object_ids, style, expected_revision)
419
+
420
+ @server.tool(name = "live_move", description = "Translate selected live objects in the active Inkscape window.")
421
+ def live_move(object_ids: list[str], dx: float, dy: float, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
422
+ return service(ctx).live_transform("move", object_ids, [dx, dy], expected_revision)
423
+
424
+ @server.tool(name = "live_rotate", description = "Rotate selected live objects in the active Inkscape window.")
425
+ def live_rotate(object_ids: list[str], degrees: float, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
426
+ return service(ctx).live_transform("rotate", object_ids, [degrees], expected_revision)
427
+
428
+ @server.tool(name = "live_scale", description = "Scale selected live objects in the active Inkscape window.")
429
+ def live_scale(object_ids: list[str], scale_x: float, scale_y: float | None = None, expected_revision: int | None = None, ctx: Context = None) -> dict[str, Any]:
430
+ return service(ctx).live_transform("scale", object_ids, [scale_x, scale_x if scale_y is None else scale_y], expected_revision)
431
+
432
+ @server.tool(name = "live_render_snapshot", description = "Render the active Inkscape page or drawing into a registered PNG snapshot.")
433
+ def live_render_snapshot(area: str = "page", ctx: Context = None) -> dict[str, Any]:
434
+ return service(ctx).live_render_snapshot(area)
435
+
436
+
437
+ def build_remote_app(server: FastMCP, state: ServerState):
438
+ """Build authenticated Streamable HTTP transport using FastMCP's HTTP app."""
439
+ if state.config.remote_server is None:
440
+ raise RuntimeError("remotehttp transport requires a remote_server configuration block")
441
+ if not state.config.api_keys:
442
+ raise RuntimeError("remotehttp transport requires at least one api_keys entry")
443
+ try:
444
+ from fastapi import FastAPI, Request
445
+ from fastapi.responses import JSONResponse
446
+ except ImportError as exc:
447
+ raise RuntimeError("remotehttp transport requires FastAPI") from exc
448
+
449
+ mcp_app = server.http_app(path = "/", transport = "streamable-http", host_origin_protection = False)
450
+ app = FastAPI(lifespan = mcp_app.lifespan)
451
+
452
+ @app.middleware("http")
453
+ async def authenticate(request: Request, call_next):
454
+ if request.url.path == "/status":
455
+ return await call_next(request)
456
+ token = _request_token(request)
457
+ access = match_api_key(token, state.config.api_keys)
458
+ if access is None:
459
+ return JSONResponse({"detail": "valid API key required"}, status_code = 401)
460
+ request.state.mcpinkscape_access = access
461
+ context_token = ACTIVE_ACCESS.set(access)
462
+ try:
463
+ return await call_next(request)
464
+ finally:
465
+ ACTIVE_ACCESS.reset(context_token)
466
+
467
+ @app.get("/status")
468
+ async def status() -> dict[str, Any]:
469
+ return state.stdio_service.server_status()
470
+
471
+ app.mount("/mcp", mcp_app)
472
+ return app
473
+
474
+
475
+ def _request_token(request: Any) -> str:
476
+ authorization = request.headers.get("authorization", "")
477
+ if authorization.lower().startswith("bearer "):
478
+ return authorization[7:].strip()
479
+ return request.headers.get("x-api-key", "") or request.query_params.get("api_key", "") or request.query_params.get("mcp", "")
480
+
481
+
482
+ def run_mcp_server() -> None:
483
+ args = parse_arguments()
484
+ if args.genkey:
485
+ print(generate_and_store_api_key(args.config, args.genkey))
486
+ return
487
+ config = load_config(args.config)
488
+ setup_logging(config, args.log_level, args.logfile)
489
+ transport = args.transport or config.mode
490
+ server, state = build_server(config)
491
+ if transport == "stdio":
492
+ logger.info("starting mcpInkscape stdio server")
493
+ server.run("stdio")
494
+ return
495
+ app = build_remote_app(server, state)
496
+ endpoint = config.remote_server.resolved_endpoint() if config.remote_server else {}
497
+ try:
498
+ import uvicorn
499
+ except ImportError as exc:
500
+ raise RuntimeError("remotehttp transport requires uvicorn") from exc
501
+ if endpoint.get("uds"):
502
+ uds = Path(str(endpoint["uds"])).expanduser()
503
+ uds.parent.mkdir(parents = True, exist_ok = True)
504
+ if uds.exists():
505
+ uds.unlink()
506
+ uvicorn.run(app, uds = str(uds), log_config = None)
507
+ elif endpoint.get("port") is not None:
508
+ uvicorn.run(app, host = str(endpoint.get("host") or "127.0.0.1"), port = int(endpoint["port"]), log_config = None)
509
+ else:
510
+ raise RuntimeError("remote_server must provide transport.uds or transport.host/port")
511
+
512
+
513
+ if __name__ == "__main__":
514
+ run_mcp_server()
@@ -0,0 +1 @@
1
+ """Configuration and API-key helpers."""
@@ -0,0 +1,65 @@
1
+ """Argon2id API-key generation and verification for remote MCP transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hmac
7
+ import os
8
+ from typing import Iterable
9
+
10
+ from argon2.low_level import Type, hash_secret_raw
11
+
12
+ from mcpinkscape.config.schema import APIKeyConfig
13
+
14
+
15
+ DEFAULT_KDF = {
16
+ "algorithm": "argon2id",
17
+ "salt": None,
18
+ "time_cost": 3,
19
+ "memory_cost": 65536,
20
+ "parallelism": 1,
21
+ "hash_len": 32,
22
+ }
23
+
24
+
25
+ def ensure_kdf_defaults(kdf: dict | None) -> dict:
26
+ result = DEFAULT_KDF.copy()
27
+ result.update(kdf or {})
28
+ if result["algorithm"] != "argon2id":
29
+ raise ValueError("only argon2id API key derivation is supported")
30
+ if result.get("salt") is None:
31
+ result["salt"] = base64.b64encode(os.urandom(16)).decode("ascii")
32
+ return result
33
+
34
+
35
+ def generate_random_api_key(length: int = 48) -> str:
36
+ return base64.urlsafe_b64encode(os.urandom(length)).decode("ascii").rstrip("=")
37
+
38
+
39
+ def derive_argon2id_hash(token: str, kdf: dict) -> str:
40
+ raw = hash_secret_raw(
41
+ secret = token.encode("utf-8"),
42
+ salt = base64.b64decode(kdf["salt"]),
43
+ time_cost = int(kdf["time_cost"]),
44
+ memory_cost = int(kdf["memory_cost"]),
45
+ parallelism = int(kdf["parallelism"]),
46
+ hash_len = int(kdf["hash_len"]),
47
+ type = Type.ID,
48
+ )
49
+ return base64.b64encode(raw).decode("ascii")
50
+
51
+
52
+ def match_api_key(token: str, entries: Iterable[APIKeyConfig]) -> APIKeyConfig | None:
53
+ if not token:
54
+ return None
55
+ for entry in entries:
56
+ kdf = entry.kdf or {}
57
+ if (kdf.get("algorithm") != "argon2id") or (not kdf.get("hash")):
58
+ continue
59
+ try:
60
+ calculated = derive_argon2id_hash(token, kdf)
61
+ except (KeyError, ValueError, TypeError):
62
+ continue
63
+ if hmac.compare_digest(calculated, str(kdf["hash"])):
64
+ return entry
65
+ return None