Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions hindsight-api-slim/hindsight_api/api/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions hindsight-api-slim/hindsight_api/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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] = {
Expand Down Expand Up @@ -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] = {
Expand Down
Loading