"""oGMemory MCP resource URI parsing and rendering."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote, unquote, urlparse
from core.models import ContextNode, RequestContext
from server.mcp_security import (
UNTRUSTED_DATA_NOTICE,
is_not_found_or_not_visible_error,
raise_not_found_or_not_visible,
)
@dataclass(frozen=True)
class MemoryTypeSpec:
public_type: str
internal_category: str
directory: str
owner: str
MEMORY_TYPES: dict[str, MemoryTypeSpec] = {
"profile": MemoryTypeSpec("profile", "profile", "profile", "user"),
"preference": MemoryTypeSpec("preference", "preference", "preferences", "user"),
"entity": MemoryTypeSpec("entity", "entity", "entities", "user"),
"event": MemoryTypeSpec("event", "event", "events", "user"),
"case": MemoryTypeSpec("case", "case", "cases", "user"),
"pattern": MemoryTypeSpec("pattern", "pattern", "patterns", "user"),
"skill": MemoryTypeSpec("skill", "skill", "skills", "agent"),
"tool_memory": MemoryTypeSpec("tool_memory", "tool", "tools", "agent"),
"session_summary": MemoryTypeSpec(
"session_summary", "session_summary", "session_summaries", "user"
),
"session_archive": MemoryTypeSpec(
"session_archive", "session_archive", "session_archives", "user"
),
}
_INTERNAL_TO_PUBLIC = {
spec.internal_category: spec.public_type for spec in MEMORY_TYPES.values()
}
_DIRECTORY_TO_PUBLIC = {
spec.directory: spec.public_type for spec in MEMORY_TYPES.values()
}
_HIDDEN_RESOURCE_TYPES = {"session_summary", "session_archive"}
_WRITE_TOOLSETS = {"read-write", "read_write", "readwrite", "all", "full"}
_DELETE_TOOLSETS = {"all", "full"}
_READ_ONLY_TOOLSETS = {"read-only", "read_only", "readonly"}
@dataclass(frozen=True)
class ParsedOgMemoryUri:
kind: str
memory_type: str | None = None
memory_id: str | None = None
owner_scope: str | None = None
def parse_ogmemory_uri(uri: str) -> ParsedOgMemoryUri:
_validate_public_uri_text(uri)
parsed = urlparse(uri)
if parsed.scheme != "ogmemory":
raise ValueError("Only ogmemory:// URIs are accepted")
parts = [parsed.netloc, *[p for p in parsed.path.split("/") if p]]
if parts == ["status"]:
return ParsedOgMemoryUri(kind="status")
if parts == ["types"]:
return ParsedOgMemoryUri(kind="types")
if len(parts) in (2, 3, 4) and parts[0] == "memories":
memory_type = parts[1]
if memory_type not in MEMORY_TYPES:
raise ValueError(f"Unknown memory type: {memory_type}")
if len(parts) == 2:
return ParsedOgMemoryUri(kind="collection", memory_type=memory_type)
if len(parts) == 4:
owner_scope = parts[2]
if memory_type != "pattern" or owner_scope != "agent":
raise ValueError(f"Unsupported ogmemory URI: {uri}")
memory_id = unquote(parts[3])
if not memory_id:
raise ValueError("Memory id must not be empty")
return ParsedOgMemoryUri(
kind="item",
memory_type=memory_type,
memory_id=memory_id,
owner_scope=owner_scope,
)
memory_id = unquote(parts[2])
if not memory_id:
raise ValueError("Memory id must not be empty")
return ParsedOgMemoryUri(kind="item", memory_type=memory_type, memory_id=memory_id)
raise ValueError(f"Unsupported ogmemory URI: {uri}")
def external_to_internal_uri(uri: str, ctx: RequestContext) -> str:
parsed = parse_ogmemory_uri(uri)
if parsed.kind != "item" or not parsed.memory_type or not parsed.memory_id:
raise ValueError(f"URI does not reference a single memory: {uri}")
spec = MEMORY_TYPES[parsed.memory_type]
memory_id = quote(parsed.memory_id, safe="")
if parsed.memory_type == "profile":
if parsed.memory_id != "self":
raise ValueError("profile memory id must be 'self'")
return f"ctx://{ctx.account_id}/users/{ctx.user_id}/memories/profile"
if parsed.memory_type == "skill":
if not ctx.agent_id:
raise ValueError("agent_id is required for skill memories")
return f"ctx://{ctx.account_id}/agents/{ctx.agent_id}/skills/{memory_id}"
if parsed.memory_type == "pattern" and parsed.owner_scope == "agent":
if not ctx.agent_id:
raise ValueError("agent_id is required for agent pattern memories")
return f"ctx://{ctx.account_id}/agents/{ctx.agent_id}/memories/{spec.directory}/{memory_id}"
if spec.owner == "agent":
if not ctx.agent_id:
raise ValueError(f"agent_id is required for {parsed.memory_type} memories")
return f"ctx://{ctx.account_id}/agents/{ctx.agent_id}/memories/{spec.directory}/{memory_id}"
return f"ctx://{ctx.account_id}/users/{ctx.user_id}/memories/{spec.directory}/{memory_id}"
def external_to_internal_uri_candidates(uri: str, ctx: RequestContext) -> list[str]:
primary = external_to_internal_uri(uri, ctx)
parsed = parse_ogmemory_uri(uri)
candidates = [primary]
if (
parsed.kind == "item"
and parsed.memory_type == "pattern"
and parsed.owner_scope is None
and ctx.agent_id
and parsed.memory_id
):
memory_id = quote(parsed.memory_id, safe="")
legacy_agent_uri = f"ctx://{ctx.account_id}/agents/{ctx.agent_id}/memories/patterns/{memory_id}"
if legacy_agent_uri not in candidates:
candidates.append(legacy_agent_uri)
return candidates
def internal_to_external_uri(uri: str, *, category: str = "") -> str:
clean_uri = uri.removesuffix("/content.md").rstrip("/")
if not clean_uri.startswith("ctx://"):
raise ValueError("Internal URI must use ctx://")
parts = clean_uri.split("/")
if len(parts) < 6:
raise ValueError(f"Unsupported internal URI: {uri}")
if "/skills/" in clean_uri:
memory_id = unquote(parts[-1])
return f"ogmemory://memories/skill/{quote(memory_id, safe='')}"
if "/memories/profile" in clean_uri:
return "ogmemory://memories/profile/self"
if "/memories/" not in clean_uri:
raise ValueError(f"Unsupported internal URI: {uri}")
memory_index = parts.index("memories")
if len(parts) <= memory_index + 2:
raise ValueError(f"Internal URI does not reference a memory item: {uri}")
directory = parts[memory_index + 1]
memory_id = unquote(parts[memory_index + 2])
public_type = _DIRECTORY_TO_PUBLIC.get(directory)
if not public_type and category:
public_type = _INTERNAL_TO_PUBLIC.get(category, category)
if not public_type:
raise ValueError(f"Unknown internal memory directory: {directory}")
owner_scope = parts[memory_index - 2] if memory_index >= 2 else ""
if public_type == "pattern" and owner_scope == "agents":
return f"ogmemory://memories/pattern/agent/{quote(memory_id, safe='')}"
return f"ogmemory://memories/{public_type}/{quote(memory_id, safe='')}"
def external_id_from_uri(uri: str) -> str:
parsed = parse_ogmemory_uri(uri)
return parsed.memory_id or ""
def public_type_from_internal(category: str, uri: str = "") -> str:
if category in _INTERNAL_TO_PUBLIC:
return _INTERNAL_TO_PUBLIC[category]
if uri:
try:
parsed = parse_ogmemory_uri(internal_to_external_uri(uri, category=category))
return parsed.memory_type or category
except Exception:
pass
return category
def render_status_resource(service: Any) -> dict[str, Any]:
cfg = service._cfg
write_enabled = mcp_write_tools_enabled(cfg)
delete_enabled = mcp_delete_tools_enabled(cfg)
return {
"status": "ok",
"transport": "mcp",
"toolsets": {
"read_only": True,
"write": write_enabled,
"delete": delete_enabled,
"runtime": False,
"diagnostic": False,
},
}
def render_types_resource(service: Any) -> dict[str, Any]:
cfg = service._cfg
write_enabled = mcp_write_tools_enabled(cfg)
delete_enabled = mcp_delete_tools_enabled(cfg)
return {
"types": [
{
"type": spec.public_type,
"owner": spec.owner,
"writable_type": spec.public_type not in {"profile"},
"write_tool_enabled": write_enabled,
"deletable_type": spec.public_type not in {"profile"},
"delete_tool_enabled": delete_enabled,
}
for spec in MEMORY_TYPES.values()
if spec.public_type not in _HIDDEN_RESOURCE_TYPES
]
}
def render_resource(service: Any, uri: str, ctx: RequestContext) -> dict[str, Any]:
parsed = parse_ogmemory_uri(uri)
if parsed.kind == "status":
return render_status_resource(service)
if parsed.kind == "types":
return render_types_resource(service)
if parsed.kind == "collection" and parsed.memory_type:
return render_collection_resource(service, parsed.memory_type, ctx)
if parsed.kind == "item":
return render_item_resource(service, uri, ctx)
raise ValueError(f"Unsupported resource URI: {uri}")
def render_collection_resource(service: Any, memory_type: str, ctx: RequestContext) -> dict[str, Any]:
spec = MEMORY_TYPES[memory_type]
limit = int(getattr(service._cfg, "mcp_collection_limit", 50))
memories: list[dict[str, Any]] = []
try:
context_fs = service._get_context_fs()
if memory_type == "profile":
internal_uri = f"ctx://{ctx.account_id}/users/{ctx.user_id}/memories/profile"
if context_fs.exists(internal_uri, ctx):
memories.append(_node_summary(context_fs.read_node(internal_uri, ctx)))
elif memory_type == "skill":
if ctx.agent_id:
base_uri = f"ctx://{ctx.account_id}/agents/{ctx.agent_id}/skills"
memories.extend(_list_node_summaries(context_fs, base_uri, ctx, limit))
elif memory_type == "pattern":
user_base = f"ctx://{ctx.account_id}/users/{ctx.user_id}/memories/patterns"
memories.extend(_list_node_summaries(context_fs, user_base, ctx, limit))
if ctx.agent_id and len(memories) < limit:
agent_base = f"ctx://{ctx.account_id}/agents/{ctx.agent_id}/memories/patterns"
memories.extend(
_list_node_summaries(context_fs, agent_base, ctx, limit - len(memories))
)
else:
owner_path = "agents" if spec.owner == "agent" else "users"
owner_id = ctx.agent_id if spec.owner == "agent" else ctx.user_id
if owner_id:
base_uri = f"ctx://{ctx.account_id}/{owner_path}/{owner_id}/memories/{spec.directory}"
memories.extend(_list_node_summaries(context_fs, base_uri, ctx, limit))
except Exception as exc:
raise_not_found_or_not_visible(exc)
payload = {
"uri": f"ogmemory://memories/{memory_type}",
"type": memory_type,
"memories": memories,
"untrusted_data_notice": UNTRUSTED_DATA_NOTICE,
}
return _truncate_collection_payload(payload, _configured_max_response_chars(service))
def render_item_resource(service: Any, uri: str, ctx: RequestContext) -> dict[str, Any]:
last_exc: Exception | None = None
try:
context_fs = service._get_context_fs()
for internal_uri in external_to_internal_uri_candidates(uri, ctx):
try:
node = context_fs.read_node(internal_uri, ctx)
return _truncate_payload(
_node_detail(node),
_configured_max_response_chars(service),
)
except Exception as exc:
if not is_not_found_or_not_visible_error(exc):
raise
last_exc = exc
continue
except Exception as exc:
raise_not_found_or_not_visible(exc)
raise
if last_exc is not None:
raise_not_found_or_not_visible(last_exc)
raise ValueError(f"Unsupported resource URI: {uri}")
def _node_summary(node: ContextNode) -> dict[str, Any]:
external_uri = internal_to_external_uri(node.uri, category=node.category)
return {
"uri": external_uri,
"type": public_type_from_internal(node.category, node.uri),
"id": external_id_from_uri(external_uri),
"abstract": node.abstract,
"updated_at": node.metadata.get("updated_at", ""),
"has_content": bool(node.content) or bool(node.metadata.get("has_content")),
}
def _list_node_summaries(context_fs: Any, base_uri: str, ctx: RequestContext, limit: int) -> list[dict[str, Any]]:
if hasattr(context_fs, "list_node_summaries"):
return [
_node_summary(node)
for node in context_fs.list_node_summaries(base_uri, ctx, limit)
]
return [
_node_summary(context_fs.read_node(child_uri, ctx))
for child_uri in context_fs.list_children(base_uri, ctx)[:limit]
]
def _node_detail(node: ContextNode) -> dict[str, Any]:
external_uri = internal_to_external_uri(node.uri, category=node.category)
return {
"uri": external_uri,
"type": public_type_from_internal(node.category, node.uri),
"id": external_id_from_uri(external_uri),
"abstract": node.abstract,
"overview": node.overview,
"content": node.content,
"metadata": {
"created_at": node.metadata.get("created_at", ""),
"updated_at": node.metadata.get("updated_at", ""),
"version": node.metadata.get("version", 1),
},
"untrusted_data_notice": UNTRUSTED_DATA_NOTICE,
}
def _validate_public_uri_text(uri: str) -> None:
if not uri or not isinstance(uri, str):
raise ValueError("URI must be a non-empty string")
if uri.startswith("ctx://"):
raise ValueError("Internal ctx:// URIs are not accepted")
if "\\" in uri or ".." in uri or ".." in unquote(uri):
raise ValueError("Invalid URI")
lowered = uri.lower()
if "%2f" in lowered or "%5c" in lowered:
raise ValueError("Encoded path separators are not accepted")
def mcp_write_tools_enabled(cfg: Any) -> bool:
return _normalized_toolset(cfg) in _WRITE_TOOLSETS or bool(
getattr(cfg, "mcp_enable_write_tools", False)
)
def mcp_delete_tools_enabled(cfg: Any) -> bool:
return _normalized_toolset(cfg) in _DELETE_TOOLSETS or bool(
getattr(cfg, "mcp_enable_delete_tools", False)
)
def _normalized_toolset(cfg: Any) -> str:
toolset = str(getattr(cfg, "mcp_default_toolset", "read-only") or "read-only").strip().lower()
if toolset in _READ_ONLY_TOOLSETS | _WRITE_TOOLSETS | _DELETE_TOOLSETS:
return toolset
raise ValueError("mcp_default_toolset must be read-only, read-write, all, or full")
def _configured_max_response_chars(service: Any) -> int:
value = int(getattr(service._cfg, "mcp_max_response_chars", 50000) or 50000)
return max(1, min(value, 50000))
def _truncate_payload(payload: dict[str, Any], max_chars: int) -> dict[str, Any]:
remaining = max_chars
truncated = bool(payload.get("truncated", False))
result: dict[str, Any] = {}
for key, value in payload.items():
if isinstance(value, str) and key in {"abstract", "overview", "content"}:
if len(value) > remaining:
result[key] = value[: max(0, remaining)]
truncated = True
remaining = 0
else:
result[key] = value
remaining -= len(value)
else:
result[key] = value
result["truncated"] = truncated
return result
def _truncate_collection_payload(payload: dict[str, Any], max_chars: int) -> dict[str, Any]:
remaining = max_chars
truncated = False
result = dict(payload)
truncated_memories: list[dict[str, Any]] = []
for memory in payload.get("memories", []):
if remaining <= 0:
truncated = True
break
row = dict(memory)
abstract = row.get("abstract")
if isinstance(abstract, str):
if len(abstract) > remaining:
row["abstract"] = abstract[: max(0, remaining)]
truncated = True
remaining = 0
else:
remaining -= len(abstract)
truncated_memories.append(row)
result["memories"] = truncated_memories
result["truncated"] = truncated
return result