/* This file is part of darktable, Copyright (C) 2026 darktable developers. darktable is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. darktable is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with darktable. If not, see . */ #include "mcp/mcp_tools.h" #include "mcp/dt_bridge.h" #include #include // --------------------------------------------------------------------------- // result builders // --------------------------------------------------------------------------- // { "content": [ { "type":"text", "text": } ], "isError": } static JsonNode *_text_result(const char *text, gboolean is_error) { JsonObject *block = json_object_new(); json_object_set_string_member(block, "type", "text"); json_object_set_string_member(block, "text", text ? text : ""); JsonArray *content = json_array_new(); json_array_add_object_element(content, block); JsonObject *res = json_object_new(); json_object_set_array_member(res, "content", content); json_object_set_boolean_member(res, "isError", is_error); JsonNode *node = json_node_new(JSON_NODE_OBJECT); json_node_take_object(node, res); return node; } static const char *_arg_string(JsonObject *args, const char *key) { if(!args || !json_object_has_member(args, key)) return NULL; JsonNode *n = json_object_get_member(args, key); if(json_node_get_value_type(n) != G_TYPE_STRING) return NULL; return json_node_get_string(n); } static int _arg_int(JsonObject *args, const char *key, int fallback) { if(!args || !json_object_has_member(args, key)) return fallback; return (int)json_object_get_int_member(args, key); } static JsonArray *_arg_array(JsonObject *args, const char *key) { if(!args || !json_object_has_member(args, key)) return NULL; JsonNode *n = json_object_get_member(args, key); return JSON_NODE_HOLDS_ARRAY(n) ? json_node_get_array(n) : NULL; } static gboolean _arg_bool(JsonObject *args, const char *key, gboolean fallback) { if(!args || !json_object_has_member(args, key)) return fallback; return json_object_get_boolean_member(args, key); } // image result: { content:[{ type:image, mimeType:image/png, data: }] } static JsonNode *_image_result(const uint8_t *png, size_t len) { gchar *b64 = g_base64_encode(png, len); JsonObject *block = json_object_new(); json_object_set_string_member(block, "type", "image"); json_object_set_string_member(block, "mimeType", "image/png"); json_object_set_string_member(block, "data", b64); g_free(b64); JsonArray *content = json_array_new(); json_array_add_object_element(content, block); JsonObject *res = json_object_new(); json_object_set_array_member(res, "content", content); json_object_set_boolean_member(res, "isError", FALSE); JsonNode *node = json_node_new(JSON_NODE_OBJECT); json_node_take_object(node, res); return node; } // input:{path|imgid}, shared by everything that names one image static void _parse_image_input(JsonObject *args, const char **path, int *imgid) { *path = NULL; *imgid = 0; if(!args || !json_object_has_member(args, "input")) return; JsonNode *in = json_object_get_member(args, "input"); if(!JSON_NODE_HOLDS_OBJECT(in)) return; JsonObject *io = json_node_get_object(in); if(json_object_has_member(io, "path")) *path = json_object_get_string_member(io, "path"); if(json_object_has_member(io, "imgid")) *imgid = (int)json_object_get_int_member(io, "imgid"); } // shared parsing of the render/image_stats input arguments static gboolean _parse_render_inputs(JsonObject *args, const char **path, int *imgid, int *w, int *h, int *he, gboolean *dtm, JsonArray **stack) { *stack = NULL; _parse_image_input(args, path, imgid); if(!args) return FALSE; *w = _arg_int(args, "width", 0); *h = _arg_int(args, "height", 0); *he = _arg_int(args, "history_end", -1); *dtm = _arg_bool(args, "disable_tone_mappers", FALSE); if(json_object_has_member(args, "stack")) { JsonNode *s = json_object_get_member(args, "stack"); if(JSON_NODE_HOLDS_ARRAY(s)) *stack = json_node_get_array(s); } return (*path != NULL) || (*imgid > 0); } // --------------------------------------------------------------------------- // tool handlers // --------------------------------------------------------------------------- static JsonNode *_tool_list_modules(JsonObject *args) { (void)args; char *json = dt_bridge_list_modules_json(); JsonNode *r = _text_result(json, FALSE); g_free(json); return r; } static JsonNode *_tool_module_schema(JsonObject *args) { const char *op = _arg_string(args, "operation"); if(!op) return _text_result("missing required string argument 'operation'", TRUE); char *err = NULL; char *json = dt_bridge_module_schema_json(op, &err); if(!json) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } JsonNode *r = _text_result(json, FALSE); g_free(json); return r; } static JsonNode *_tool_decode_params(JsonObject *args) { const char *op = _arg_string(args, "operation"); const char *hex = _arg_string(args, "blob_hex"); if(!op || !hex) return _text_result("missing required arguments 'operation' and 'blob_hex'", TRUE); char *err = NULL; char *json = dt_bridge_decode_params_json(op, hex, &err); if(!json) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } JsonNode *r = _text_result(json, FALSE); g_free(json); return r; } static JsonNode *_tool_encode_params(JsonObject *args) { const char *op = _arg_string(args, "operation"); if(!op) return _text_result("missing required string argument 'operation'", TRUE); JsonObject *fields = NULL; if(args && json_object_has_member(args, "fields")) { JsonNode *fn = json_object_get_member(args, "fields"); if(JSON_NODE_HOLDS_OBJECT(fn)) fields = json_node_get_object(fn); } char *err = NULL; char *hex = dt_bridge_encode_params_hex(op, fields, &err); if(!hex) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } char *out = g_strdup_printf("{\"operation\":\"%s\",\"blob_hex\":\"%s\"}", op, hex); JsonNode *r = _text_result(out, FALSE); g_free(out); g_free(hex); return r; } static JsonNode *_tool_render(JsonObject *args) { const char *path; int imgid, w, h, he; gboolean dtm; JsonArray *stack; if(!_parse_render_inputs(args, &path, &imgid, &w, &h, &he, &dtm, &stack)) return _text_result("render: provide input.path or input.imgid", TRUE); uint8_t *png = NULL; size_t len = 0; char *err = NULL; if(!dt_bridge_render_png(path, imgid, w, h, stack, dtm, he, &png, &len, &err)) { JsonNode *r = _text_result(err ? err : "render failed", TRUE); g_free(err); return r; } JsonNode *r = _image_result(png, len); g_free(png); return r; } static JsonNode *_tool_image_stats(JsonObject *args) { const char *path; int imgid, w, h, he; gboolean dtm; JsonArray *stack; if(!_parse_render_inputs(args, &path, &imgid, &w, &h, &he, &dtm, &stack)) return _text_result("image_stats: provide input.path or input.imgid", TRUE); char *err = NULL; char *json = dt_bridge_image_stats_json(path, imgid, w, h, stack, dtm, he, &err); if(!json) { JsonNode *r = _text_result(err ? err : "image_stats failed", TRUE); g_free(err); return r; } JsonNode *r = _text_result(json, FALSE); g_free(json); return r; } static JsonNode *_json_text_or_err(char *json, char *err) { if(!json) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } JsonNode *r = _text_result(json, FALSE); g_free(json); return r; } static JsonNode *_tool_list_images(JsonObject *args) { char *err = NULL; // sequenced: reading err in the same argument list would be unordered char *json = dt_bridge_list_images_json(_arg_int(args, "limit", 0), _arg_string(args, "folder"), _arg_int(args, "rating", -1), _arg_string(args, "color"), _arg_bool(args, "rejected", FALSE), _arg_int(args, "film_roll", 0), &err); return _json_text_or_err(json, err); } static JsonNode *_tool_get_history(JsonObject *args) { if(!args || !json_object_has_member(args, "imgid")) return _text_result("get_history: requires integer 'imgid'", TRUE); char *err = NULL; char *json = dt_bridge_get_history_json(_arg_int(args, "imgid", 0), &err); return _json_text_or_err(json, err); } static JsonNode *_tool_list_styles(JsonObject *args) { return _json_text_or_err(dt_bridge_list_styles_json(_arg_string(args, "filter"), _arg_int(args, "limit", 0)), NULL); } static JsonNode *_tool_apply_style(JsonObject *args) { const char *name = _arg_string(args, "name"); const int imgid = _arg_int(args, "imgid", 0); char *err = NULL; if(!dt_bridge_apply_style(name, imgid, _arg_bool(args, "overwrite", FALSE), _arg_array(args, "imgids"), &err)) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } return _text_result("{\"ok\":true}", FALSE); } static JsonNode *_tool_save_style(JsonObject *args) { char *err = NULL; if(!dt_bridge_save_style(_arg_string(args, "name"), _arg_string(args, "description"), _arg_int(args, "imgid", 0), &err)) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } return _text_result("{\"ok\":true}", FALSE); } static JsonNode *_tool_set_rating(JsonObject *args) { char *err = NULL; if(!dt_bridge_set_rating(_arg_array(args, "imgids"), _arg_int(args, "rating", -1), _arg_bool(args, "reject", FALSE), &err)) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } return _text_result("{\"ok\":true}", FALSE); } static JsonNode *_tool_set_color_label(JsonObject *args) { char *err = NULL; if(!dt_bridge_set_color_label(_arg_array(args, "imgids"), _arg_string(args, "color"), _arg_bool(args, "toggle", FALSE), &err)) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } return _text_result("{\"ok\":true}", FALSE); } static JsonNode *_tool_import_images(JsonObject *args) { char *err = NULL; char *json = dt_bridge_import_images_json(_arg_array(args, "paths"), _arg_string(args, "folder"), _arg_bool(args, "recursive", FALSE), &err); return _json_text_or_err(json, err); } static JsonNode *_tool_list_film_rolls(JsonObject *args) { return _json_text_or_err(dt_bridge_list_film_rolls_json(), NULL); } static JsonNode *_tool_get_metadata(JsonObject *args) { char *err = NULL; char *json = dt_bridge_get_metadata_json(_arg_int(args, "imgid", 0), &err); if(!json) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } JsonNode *r = _text_result(json, FALSE); g_free(json); return r; } static JsonNode *_tool_get_conf(JsonObject *args) { char *err = NULL; char *json = dt_bridge_get_conf_json(_arg_string(args, "key"), &err); if(!json) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } JsonNode *r = _text_result(json, FALSE); g_free(json); return r; } static JsonNode *_tool_list_conf(JsonObject *args) { char *json = dt_bridge_list_conf_json(_arg_string(args, "prefix")); JsonNode *r = _text_result(json ? json : "[]", FALSE); g_free(json); return r; } static JsonNode *_tool_reset_history(JsonObject *args) { char *err = NULL; if(!dt_bridge_reset_history(_arg_int(args, "imgid", 0), &err)) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } return _text_result("{\"ok\":true}", FALSE); } static JsonNode *_tool_import_style(JsonObject *args) { char *err = NULL; if(!dt_bridge_import_style(_arg_string(args, "path"), &err)) { JsonNode *r = _text_result(err ? err : "error", TRUE); g_free(err); return r; } return _text_result("{\"ok\":true}", FALSE); } static JsonNode *_tool_export_images(JsonObject *args) { // deliberately not _parse_render_inputs: an export must not carry a stack or // the tone-mapper switch, both of which are committed to the image's history const char *path = NULL; int imgid = 0; _parse_image_input(args, &path, &imgid); const int w = _arg_int(args, "width", 0); const int h = _arg_int(args, "height", 0); const int he = _arg_int(args, "history_end", -1); // export never edits, so a client sending these should be told rather than // have them quietly dropped if(args && (json_object_has_member(args, "stack") || json_object_has_member(args, "disable_tone_mappers"))) return _text_result("export_images: 'stack' and 'disable_tone_mappers' are" " edits and belong to render; export writes the image" " as it already is", TRUE); const char *out_path = _arg_string(args, "out_path"); const char *out_dir = _arg_string(args, "out_dir"); JsonArray *ids = _arg_array(args, "imgids"); const gboolean batch = ids && json_array_get_length(ids) > 0; if(!batch && !path && imgid <= 0) return _text_result("export_images: requires input.path/imgid, or 'imgids'", TRUE); char *err = NULL; GPtrArray *written = g_ptr_array_new_with_free_func(g_free); GPtrArray *skipped = g_ptr_array_new_with_free_func(g_free); if(!dt_bridge_export_images(path, imgid, w, h, he, out_path, ids, out_dir, _arg_string(args, "format"), _arg_int(args, "quality", 0), _arg_bool(args, "upscale", FALSE), _arg_bool(args, "high_quality", FALSE), written, skipped, &err)) { // a batch that failed part way still put files on disk; discarding the list // would leave the caller unable to see them, and a retry would duplicate GString *m = g_string_new(err ? err : "error"); if(written->len) { g_string_append_printf(m, " (%u already written:", written->len); for(guint i = 0; i < written->len; i++) g_string_append_printf(m, "%s %s", i ? "," : "", (const char *)g_ptr_array_index(written, i)); g_string_append_c(m, ')'); } // and the ones the policy left alone: without them a retry cannot tell a // file that was deliberately kept from one the batch never reached if(skipped->len) { g_string_append_printf(m, " (%u skipped, already on disk with" " plugins/imageio/storage/disk/overwrite set" " to skip:", skipped->len); for(guint i = 0; i < skipped->len; i++) g_string_append_printf(m, "%s %s", i ? "," : "", (const char *)g_ptr_array_index(skipped, i)); g_string_append_c(m, ')'); } JsonNode *r = _text_result(m->str, TRUE); g_string_free(m, TRUE); g_ptr_array_free(written, TRUE); g_ptr_array_free(skipped, TRUE); g_free(err); return r; } // report where the files actually landed: with no out_path/out_dir the // target comes from darktable's own export setting, which the caller cannot // work out from the request JsonArray *paths = json_array_new(); for(guint i = 0; i < written->len; i++) json_array_add_string_element(paths, g_ptr_array_index(written, i)); JsonObject *o = json_object_new(); json_object_set_boolean_member(o, "ok", TRUE); json_object_set_int_member(o, "exported", written->len); json_object_set_array_member(o, "paths", paths); // an export that wrote nothing because every target was already there looks // exactly like one that succeeded, so name the files and say why json_object_set_int_member(o, "skipped", skipped->len); if(skipped->len) { JsonArray *sp = json_array_new(); for(guint i = 0; i < skipped->len; i++) json_array_add_string_element(sp, g_ptr_array_index(skipped, i)); json_object_set_array_member(o, "skipped_paths", sp); json_object_set_string_member(o, "skipped_reason", "the file already exists and" " plugins/imageio/storage/disk/overwrite is" " set to skip; set it to 0 to pick a free" " name, or 1 to overwrite"); } JsonNode *root = json_node_new(JSON_NODE_OBJECT); json_node_take_object(root, o); JsonGenerator *g = json_generator_new(); json_generator_set_root(g, root); char *msg = json_generator_to_data(g, NULL); g_object_unref(g); json_node_unref(root); g_ptr_array_free(written, TRUE); g_ptr_array_free(skipped, TRUE); JsonNode *r = _text_result(msg, FALSE); g_free(msg); return r; } // --------------------------------------------------------------------------- // registry // --------------------------------------------------------------------------- typedef JsonNode *(*mcp_tool_fn)(JsonObject *args); typedef struct mcp_handler_t { const char *name; mcp_tool_fn fn; } mcp_handler_t; // tool behaviour lives in C; the presentation (name/description/inputSchema) // is loaded from mcp_tools.json in the data folder and matched here by name static const mcp_handler_t _handlers[] = { { "list_modules", _tool_list_modules }, { "module_schema", _tool_module_schema }, { "decode_params", _tool_decode_params }, { "encode_params", _tool_encode_params }, { "render", _tool_render }, { "image_stats", _tool_image_stats }, { "list_images", _tool_list_images }, { "get_history", _tool_get_history }, { "list_styles", _tool_list_styles }, { "apply_style", _tool_apply_style }, { "save_style", _tool_save_style }, { "import_style", _tool_import_style }, { "reset_history", _tool_reset_history }, { "list_film_rolls", _tool_list_film_rolls }, { "import_images", _tool_import_images }, { "set_rating", _tool_set_rating }, { "set_color_label", _tool_set_color_label }, { "get_metadata", _tool_get_metadata }, { "get_conf", _tool_get_conf }, { "list_conf", _tool_list_conf }, { "export_images", _tool_export_images }, }; static const size_t _n_handlers = sizeof(_handlers) / sizeof(_handlers[0]); // tool metadata array (name/description/inputSchema) loaded from mcp_tools.json static JsonNode *_tools_meta = NULL; int mcp_tools_load(const char *path) { JsonParser *parser = json_parser_new(); GError *e = NULL; if(!json_parser_load_from_file(parser, path, &e)) { fprintf(stderr, "darktable-mcp: could not load tools file '%s': %s\n", path, e ? e->message : "?"); if(e) g_error_free(e); g_object_unref(parser); return -1; } JsonNode *root = json_parser_get_root(parser); if(!root || !JSON_NODE_HOLDS_ARRAY(root)) { fprintf(stderr, "darktable-mcp: tools file '%s' is not a JSON array\n", path); g_object_unref(parser); return -1; } if(_tools_meta) json_node_unref(_tools_meta); _tools_meta = json_node_copy(root); g_object_unref(parser); // warn about metadata entries with no C handler (they cannot be called) JsonArray *arr = json_node_get_array(_tools_meta); const guint len = json_array_get_length(arr); for(guint k = 0; k < len; k++) { JsonObject *o = json_array_get_object_element(arr, k); const char *name = o && json_object_has_member(o, "name") ? json_object_get_string_member(o, "name") : NULL; gboolean known = FALSE; for(size_t i = 0; i < _n_handlers; i++) if(!g_strcmp0(name, _handlers[i].name)) { known = TRUE; break; } if(!known) fprintf(stderr, "darktable-mcp: tool '%s' has no handler; not callable\n", name ? name : "(unnamed)"); } // warn about handlers missing from the metadata (callable but not advertised) for(size_t i = 0; i < _n_handlers; i++) { gboolean listed = FALSE; for(guint k = 0; k < len; k++) { JsonObject *o = json_array_get_object_element(arr, k); const char *name = o && json_object_has_member(o, "name") ? json_object_get_string_member(o, "name") : NULL; if(!g_strcmp0(name, _handlers[i].name)) { listed = TRUE; break; } } if(!listed) fprintf(stderr, "darktable-mcp: handler '%s' has no metadata; not listed\n", _handlers[i].name); } return (int)len; } JsonNode *mcp_tools_list_node(void) { if(_tools_meta) return json_node_copy(_tools_meta); JsonNode *node = json_node_new(JSON_NODE_ARRAY); json_node_take_array(node, json_array_new()); return node; } JsonNode *mcp_tools_call_node(const char *name, JsonObject *arguments, gboolean *found) { for(size_t i = 0; i < _n_handlers; i++) { if(!g_strcmp0(name, _handlers[i].name)) { if(found) *found = TRUE; return _handlers[i].fn(arguments); } } if(found) *found = FALSE; return NULL; }