diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index 006988d58..132b33385 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -172,6 +172,23 @@ class RecallRequest(BaseModel): "Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.", ) + @field_validator("types", "tags", mode="before") + @classmethod + def coerce_string_to_list(cls, v: Any) -> Any: + """Coerce JSON-string arrays to list. + + MCP tool bridges sometimes serialize JSON arrays as strings during + transport, e.g. '["world"]' instead of ["world"]. Parse them back. + """ + if isinstance(v, str): + try: + parsed = json.loads(v) + if isinstance(parsed, list): + return parsed + except (json.JSONDecodeError, TypeError): + pass + return v + @field_validator("query") @classmethod def validate_query_not_empty(cls, v: str) -> str: @@ -448,6 +465,23 @@ def coerce_tags(cls, v): return [v] return v + @field_validator("metadata", mode="before") + @classmethod + def coerce_metadata(cls, v: Any) -> Any: + """Coerce JSON-string metadata to dict. + + MCP tool bridges sometimes serialize JSON objects as strings during + transport, e.g. '{"key": "value"}' instead of {"key": "value"}. + """ + if isinstance(v, str): + try: + parsed = json.loads(v) + if isinstance(parsed, dict): + return parsed + except (json.JSONDecodeError, TypeError): + pass + return v + observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = Field( default=None, title="ObservationScopes", diff --git a/hindsight-api-slim/hindsight_api/mcp_tools.py b/hindsight-api-slim/hindsight_api/mcp_tools.py index 3ccec38d8..fe491b0b8 100644 --- a/hindsight-api-slim/hindsight_api/mcp_tools.py +++ b/hindsight-api-slim/hindsight_api/mcp_tools.py @@ -167,6 +167,15 @@ def build_content_dict( if isinstance(tags, str): tags = [tags] + # Coerce metadata from JSON string to dict if needed. + if isinstance(metadata, str): + try: + parsed = json.loads(metadata) + if isinstance(parsed, dict): + metadata = parsed + except (json.JSONDecodeError, TypeError): + metadata = None + content_dict: dict[str, Any] = {"content": content, "context": context} if timestamp: @@ -665,6 +674,18 @@ async def recall( budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH} budget_enum = budget_map.get(budget.lower(), Budget.HIGH) + # Coerce JSON-string arrays to list (MCP bridges sometimes serialize them as strings) + if isinstance(types, str): + try: + types = json.loads(types) + except (json.JSONDecodeError, TypeError): + types = None + if isinstance(tags, str): + try: + parsed = json.loads(tags) + tags = parsed if isinstance(parsed, list) else [tags] + except (json.JSONDecodeError, TypeError): + tags = [tags] fact_types = types if types is not None else list(VALID_RECALL_FACT_TYPES) recall_kwargs: dict[str, Any] = { @@ -722,6 +743,18 @@ async def recall( budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH} budget_enum = budget_map.get(budget.lower(), Budget.HIGH) + # Coerce JSON-string arrays to list (MCP bridges sometimes serialize them as strings) + if isinstance(types, str): + try: + types = json.loads(types) + except (json.JSONDecodeError, TypeError): + types = None + if isinstance(tags, str): + try: + parsed = json.loads(tags) + tags = parsed if isinstance(parsed, list) else [tags] + except (json.JSONDecodeError, TypeError): + tags = [tags] fact_types = types if types is not None else list(VALID_RECALL_FACT_TYPES) recall_kwargs: dict[str, Any] = {