Ctrl K
ComunidadGuías…

src/mcp/mcp_tools.c

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/*
    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 <http://www.gnu.org/licenses/>.
*/

#include "mcp/mcp_tools.h"
#include "mcp/dt_bridge.h"

#include <stdio.h>
#include <string.h>

// ---------------------------------------------------------------------------
// result builders
// ---------------------------------------------------------------------------

// { "content": [ { "type":"text", "text": <text> } ], "isError": <is_error> }
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:<base64> }] }
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;
}