From 534bdee80991646e6350c003f35270c3743ad846 Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Thu, 11 Jun 2026 03:07:56 -0400 Subject: [PATCH 1/5] feat(backend): add shared MCP data response shaping helpers Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/utils/mcp_data.py | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 backend/utils/mcp_data.py diff --git a/backend/utils/mcp_data.py b/backend/utils/mcp_data.py new file mode 100644 index 0000000000..1172dfa48b --- /dev/null +++ b/backend/utils/mcp_data.py @@ -0,0 +1,64 @@ +"""Shared response shaping for the MCP data surface. + +These helpers normalize Firestore documents into the lean shapes returned by +both the REST endpoints (``routers/mcp.py``) and the MCP tools +(``routers/mcp_sse.py``). They live in ``utils`` so both routers reuse the +exact same shapes without cross-importing each other (routers must never +import from other routers). +""" + +from typing import List + + +def clean_action_item(item: dict) -> dict: + """Shape an action_item doc for MCP output (locked descriptions truncated).""" + description = item.get("description", "") or "" + if item.get("is_locked", False) and len(description) > 70: + description = description[:70] + "..." + return { + "id": item.get("id", ""), + "description": description, + "completed": bool(item.get("completed", False)), + "created_at": item.get("created_at"), + "due_at": item.get("due_at"), + "completed_at": item.get("completed_at"), + "conversation_id": item.get("conversation_id"), + } + + +def clean_chat_message(message: dict) -> dict: + """Shape a chat message doc (drops file/conversation join noise).""" + return { + "id": message.get("id", ""), + "text": message.get("text", "") or "", + "sender": message.get("sender", ""), + "type": message.get("type"), + "created_at": message.get("created_at"), + } + + +def clean_person(person: dict) -> dict: + """Shape a person/contact doc. + + Drops raw speech-sample audio URLs and speaker embeddings (not useful to an + AI and high-sensitivity); keeps a capped sample of transcripts so the model + can recognize how the person speaks. + """ + transcripts: List[str] = person.get("speech_sample_transcripts") or [] + return { + "id": person.get("id", ""), + "name": person.get("name", ""), + "created_at": person.get("created_at"), + "speech_sample_transcripts": transcripts[:5], + } + + +def clean_screen_activity_row(row: dict) -> dict: + """Shape a screen_activity doc into snake_case fields.""" + return { + "id": row.get("id"), + "timestamp": row.get("timestamp"), + "app_name": row.get("appName"), + "window_title": row.get("windowTitle"), + "ocr_text": row.get("ocrText"), + } From afa7de012ff12789c5e006905c2cba6108e7e65c Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Thu, 11 Jun 2026 03:07:56 -0400 Subject: [PATCH 2/5] feat(backend): expose action items, goals, chat, people, screen activity, daily summaries via MCP REST Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/mcp.py | 140 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/backend/routers/mcp.py b/backend/routers/mcp.py index 7aa2acd646..be36f5f0a7 100644 --- a/backend/routers/mcp.py +++ b/backend/routers/mcp.py @@ -9,6 +9,11 @@ import database.memories as memories_db import database.conversations as conversations_db import database.users as users_db +import database.action_items as action_items_db +import database.goals as goals_db +import database.chat as chat_db +import database.screen_activity as screen_activity_db +import database.daily_summaries as daily_summaries_db # from database.redis_db import get_filter_category_items # from database.vector_db import query_vectors_by_metadata @@ -23,6 +28,7 @@ from dependencies import get_uid_from_mcp_api_key, get_current_user_id from utils.other.endpoints import with_rate_limit from utils.log_sanitizer import sanitize_pii +from utils.mcp_data import clean_action_item, clean_chat_message, clean_person, clean_screen_activity_row from utils.mcp_memories import ( collect_filtered_memories, parse_mcp_bool, @@ -357,3 +363,137 @@ def get_conversation_by_id(conversation_id: str, uid: str = Depends(get_uid_from populate_speaker_names(uid, [conversation]) return conversation + + +# --------------------------------------------------------------------------- +# Action items — the user's actionable task layer (to-dos with due dates) +# --------------------------------------------------------------------------- + + +class SimpleActionItem(BaseModel): + id: str + description: str + completed: bool = False + created_at: Optional[datetime] = None + due_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + conversation_id: Optional[str] = None + + +@router.get("/v1/mcp/action-items", response_model=List[SimpleActionItem], tags=["mcp"]) +def get_action_items( + completed: Optional[bool] = None, + due_start_date: Optional[datetime] = None, + due_end_date: Optional[datetime] = None, + limit: int = 100, + offset: int = 0, + uid: str = Depends(get_uid_from_mcp_api_key), +): + logger.info(f"get_action_items {uid} completed={completed} limit={limit} offset={offset}") + limit = max(1, min(limit, 500)) + offset = max(0, offset) + items = action_items_db.get_action_items( + uid, + completed=completed, + due_start_date=due_start_date, + due_end_date=due_end_date, + limit=limit, + offset=offset, + ) + return [clean_action_item(i) for i in items if not i.get("deleted", False)] + + +# --------------------------------------------------------------------------- +# Goals — the user's stated objectives +# --------------------------------------------------------------------------- + + +@router.get("/v1/mcp/goals", tags=["mcp"]) +def get_goals(include_inactive: bool = False, uid: str = Depends(get_uid_from_mcp_api_key)): + logger.info(f"get_goals {uid} include_inactive={include_inactive}") + return goals_db.get_all_goals(uid, include_inactive=include_inactive) + + +# --------------------------------------------------------------------------- +# Chat — the user's prior conversations with Omi (intent / preferences signal) +# --------------------------------------------------------------------------- + + +class SimpleChatMessage(BaseModel): + id: str + text: str + sender: str + type: Optional[str] = None + created_at: Optional[datetime] = None + + +@router.get("/v1/mcp/chat", response_model=List[SimpleChatMessage], tags=["mcp"]) +def get_chat_messages(limit: int = 50, offset: int = 0, uid: str = Depends(get_uid_from_mcp_api_key)): + logger.info(f"get_chat_messages {uid} limit={limit} offset={offset}") + limit = max(1, min(limit, 200)) + offset = max(0, offset) + messages = chat_db.get_messages(uid, limit=limit, offset=offset) + return [clean_chat_message(m) for m in messages] + + +# --------------------------------------------------------------------------- +# People — the contacts/speakers the user interacts with +# --------------------------------------------------------------------------- + + +class SimplePerson(BaseModel): + id: str + name: str + created_at: Optional[datetime] = None + speech_sample_transcripts: List[str] = [] + + +@router.get("/v1/mcp/people", response_model=List[SimplePerson], tags=["mcp"]) +def get_people(uid: str = Depends(get_uid_from_mcp_api_key)): + logger.info(f"get_people {uid}") + return [clean_person(p) for p in users_db.get_people(uid)] + + +# --------------------------------------------------------------------------- +# Screen activity — desktop Rewind (app, window title, OCR text) +# --------------------------------------------------------------------------- + + +@router.get("/v1/mcp/screen-activity", tags=["mcp"]) +def get_screen_activity( + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + app: Optional[str] = None, + summary: bool = False, + limit: int = 200, + uid: str = Depends(get_uid_from_mcp_api_key), +): + logger.info(f"get_screen_activity {uid} summary={summary} app={app} limit={limit}") + if summary: + return screen_activity_db.get_screen_activity_summary(uid, start_date=start_date, end_date=end_date) + limit = max(1, min(limit, 1000)) + rows = screen_activity_db.get_screen_activity( + uid, start_date=start_date, end_date=end_date, app_filter=app, limit=limit + ) + return [clean_screen_activity_row(r) for r in rows] + + +# --------------------------------------------------------------------------- +# Daily summaries — Omi's per-day digest of the user's life +# --------------------------------------------------------------------------- + + +@router.get("/v1/mcp/daily-summaries", tags=["mcp"]) +def get_daily_summaries( + limit: int = 30, + offset: int = 0, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + uid: str = Depends(get_uid_from_mcp_api_key), +): + logger.info(f"get_daily_summaries {uid} limit={limit} offset={offset}") + limit = max(1, min(limit, 100)) + offset = max(0, offset) + return daily_summaries_db.get_daily_summaries( + uid, limit=limit, offset=offset, start_date=start_date, end_date=end_date + ) From 2f0feeff2df8fe107696691f5425f641118f7bfe Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Thu, 11 Jun 2026 03:07:57 -0400 Subject: [PATCH 3/5] feat(backend): add MCP tools + scopes for action items, goals, chat, people, screen activity, daily summaries Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/routers/mcp_sse.py | 204 +++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index 7734095a18..a93dd7bac3 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -25,10 +25,16 @@ import database.vector_db as vector_db import database.x_posts as x_posts_db import database.users as users_db +import database.action_items as action_items_db +import database.goals as goals_db +import database.chat as chat_db +import database.screen_activity as screen_activity_db +import database.daily_summaries as daily_summaries_db from models.memories import MemoryDB, Memory, MemoryCategory from utils.conversations.render import redact_conversation_for_list from models.conversation_enums import CategoryEnum from utils.llm.memories import identify_category_for_memory +from utils.mcp_data import clean_action_item, clean_chat_message, clean_person, clean_screen_activity_row from utils.mcp_memories import ( collect_filtered_memories, parse_mcp_bool, @@ -53,6 +59,11 @@ "memories.read", "memories.write", "conversations.read", + "action_items.read", + "goals.read", + "chat.read", + "screen_activity.read", + "people.read", ] READ_ONLY_ANNOTATIONS = { @@ -76,6 +87,11 @@ MEMORIES_READ_SECURITY = [{"type": "oauth2", "scopes": ["memories.read"]}] MEMORIES_WRITE_SECURITY = [{"type": "oauth2", "scopes": ["memories.write"]}] CONVERSATIONS_READ_SECURITY = [{"type": "oauth2", "scopes": ["conversations.read"]}] +ACTION_ITEMS_READ_SECURITY = [{"type": "oauth2", "scopes": ["action_items.read"]}] +GOALS_READ_SECURITY = [{"type": "oauth2", "scopes": ["goals.read"]}] +CHAT_READ_SECURITY = [{"type": "oauth2", "scopes": ["chat.read"]}] +SCREEN_ACTIVITY_READ_SECURITY = [{"type": "oauth2", "scopes": ["screen_activity.read"]}] +PEOPLE_READ_SECURITY = [{"type": "oauth2", "scopes": ["people.read"]}] class MCPSession: @@ -318,6 +334,118 @@ def invalid_mcp_auth_exception( }, }, }, + { + "name": "get_action_items", + "description": ( + "Retrieve the user's action items (tasks/to-dos extracted from conversations), newest due first. " + "Each item has a description, completion status, and optional due date. Use this to know what the " + "user needs to do or has committed to." + ), + "annotations": READ_ONLY_ANNOTATIONS, + "securitySchemes": ACTION_ITEMS_READ_SECURITY, + "inputSchema": { + "type": "object", + "properties": { + "completed": {"type": "boolean", "description": "Filter by completion status (omit for all)"}, + "due_start_date": {"type": "string", "description": "Only items due on/after this date (yyyy-mm-dd)"}, + "due_end_date": {"type": "string", "description": "Only items due on/before this date (yyyy-mm-dd)"}, + "limit": {"type": "integer", "description": "Number of action items to retrieve", "default": 100}, + "offset": {"type": "integer", "description": "Offset for pagination", "default": 0}, + }, + }, + }, + { + "name": "get_goals", + "description": ( + "Retrieve the user's goals — their stated objectives and what they are working toward. Use this to " + "ground long-horizon advice and prioritization in what actually matters to the user." + ), + "annotations": READ_ONLY_ANNOTATIONS, + "securitySchemes": GOALS_READ_SECURITY, + "inputSchema": { + "type": "object", + "properties": { + "include_inactive": { + "type": "boolean", + "description": "Include ended/inactive goals (default only active goals)", + "default": False, + }, + }, + }, + }, + { + "name": "get_chat_messages", + "description": ( + "Retrieve the user's recent chat history with Omi, newest first. Reveals what the user has previously " + "asked, their intent, and stated preferences. Returns message text, sender (human/ai), and timestamp." + ), + "annotations": READ_ONLY_ANNOTATIONS, + "securitySchemes": CHAT_READ_SECURITY, + "inputSchema": { + "type": "object", + "properties": { + "limit": {"type": "integer", "description": "Number of messages to retrieve", "default": 50}, + "offset": {"type": "integer", "description": "Offset for pagination", "default": 0}, + }, + }, + }, + { + "name": "get_people", + "description": ( + "Retrieve the people/contacts the user interacts with (recurring speakers Omi has identified). " + "Returns each person's name, id, and a few transcript samples of how they speak. Use this to reason " + "about the user's relationships, not just raw text." + ), + "annotations": READ_ONLY_ANNOTATIONS, + "securitySchemes": PEOPLE_READ_SECURITY, + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "get_screen_activity", + "description": ( + "Retrieve the user's desktop screen activity (Rewind) — what apps and windows they used and the OCR'd " + "on-screen text, ordered by time. Pass summary=true for an aggregated per-app usage breakdown instead " + "of raw rows. High-signal context on what the user actually does day to day." + ), + "annotations": READ_ONLY_ANNOTATIONS, + "securitySchemes": SCREEN_ACTIVITY_READ_SECURITY, + "inputSchema": { + "type": "object", + "properties": { + "start_date": {"type": "string", "description": "Filter on/after this date (yyyy-mm-dd)"}, + "end_date": {"type": "string", "description": "Filter on/before this date (yyyy-mm-dd)"}, + "app": {"type": "string", "description": "Filter to a single app name"}, + "summary": { + "type": "boolean", + "description": "Return an aggregated per-app usage summary instead of raw rows", + "default": False, + }, + "limit": { + "type": "integer", + "description": "Max raw rows to return (ignored when summary=true)", + "default": 200, + }, + }, + }, + }, + { + "name": "get_daily_summaries", + "description": ( + "Retrieve Omi's per-day summaries of the user's life, newest first. A concise digest of what happened " + "each day. Use for temporal context — 'what has the user been up to lately'." + ), + "annotations": READ_ONLY_ANNOTATIONS, + "securitySchemes": CONVERSATIONS_READ_SECURITY, + "inputSchema": { + "type": "object", + "properties": { + "start_date": {"type": "string", "description": "Filter on/after this date (yyyy-mm-dd)"}, + "end_date": {"type": "string", "description": "Filter on/before this date (yyyy-mm-dd)"}, + "limit": {"type": "integer", "description": "Number of summaries to retrieve", "default": 30}, + "offset": {"type": "integer", "description": "Offset for pagination", "default": 0}, + }, + }, + }, ] @@ -360,6 +488,16 @@ def __init__(self, message: str, code: int = -32000): super().__init__(self.message) +def _parse_mcp_date(value: Optional[str], field: str) -> Optional[datetime]: + """Parse a yyyy-mm-dd MCP argument into a datetime, or None when absent.""" + if not value: + return None + try: + return datetime.strptime(value, "%Y-%m-%d") + except ValueError: + raise ToolExecutionError(f"Invalid {field} format: '{value}'. Expected YYYY-MM-DD.", code=-32602) + + def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict: """Execute an MCP tool and return the result. Raises ToolExecutionError on failure.""" @@ -655,6 +793,72 @@ def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict: ] return {"posts": results} + elif tool_name == "get_action_items": + try: + limit = parse_mcp_int(arguments.get("limit"), "limit", default=100, minimum=1, maximum=500) + offset = parse_mcp_int(arguments.get("offset"), "offset", default=0, minimum=0, maximum=100000) + completed = parse_optional_mcp_bool(arguments.get("completed"), "completed") + except ValueError as e: + raise ToolExecutionError(str(e), code=-32602) + due_start = _parse_mcp_date(arguments.get("due_start_date"), "due_start_date") + due_end = _parse_mcp_date(arguments.get("due_end_date"), "due_end_date") + items = action_items_db.get_action_items( + user_id, + completed=completed, + due_start_date=due_start, + due_end_date=due_end, + limit=limit, + offset=offset, + ) + return {"action_items": [clean_action_item(i) for i in items if not i.get("deleted", False)]} + + elif tool_name == "get_goals": + include_inactive = parse_mcp_bool(arguments.get("include_inactive"), "include_inactive", default=False) + return {"goals": goals_db.get_all_goals(user_id, include_inactive=include_inactive)} + + elif tool_name == "get_chat_messages": + try: + limit = parse_mcp_int(arguments.get("limit"), "limit", default=50, minimum=1, maximum=200) + offset = parse_mcp_int(arguments.get("offset"), "offset", default=0, minimum=0, maximum=100000) + except ValueError as e: + raise ToolExecutionError(str(e), code=-32602) + messages = chat_db.get_messages(user_id, limit=limit, offset=offset) + return {"messages": [clean_chat_message(m) for m in messages]} + + elif tool_name == "get_people": + return {"people": [clean_person(p) for p in users_db.get_people(user_id)]} + + elif tool_name == "get_screen_activity": + start = _parse_mcp_date(arguments.get("start_date"), "start_date") + end = _parse_mcp_date(arguments.get("end_date"), "end_date") + app = arguments.get("app") + summary = parse_mcp_bool(arguments.get("summary"), "summary", default=False) + if summary: + return screen_activity_db.get_screen_activity_summary(user_id, start_date=start, end_date=end) + try: + limit = parse_mcp_int(arguments.get("limit"), "limit", default=200, minimum=1, maximum=1000) + except ValueError as e: + raise ToolExecutionError(str(e), code=-32602) + rows = screen_activity_db.get_screen_activity( + user_id, start_date=start, end_date=end, app_filter=app, limit=limit + ) + return {"screen_activity": [clean_screen_activity_row(r) for r in rows]} + + elif tool_name == "get_daily_summaries": + try: + limit = parse_mcp_int(arguments.get("limit"), "limit", default=30, minimum=1, maximum=100) + offset = parse_mcp_int(arguments.get("offset"), "offset", default=0, minimum=0, maximum=100000) + except ValueError as e: + raise ToolExecutionError(str(e), code=-32602) + summaries = daily_summaries_db.get_daily_summaries( + user_id, + limit=limit, + offset=offset, + start_date=arguments.get("start_date"), + end_date=arguments.get("end_date"), + ) + return {"daily_summaries": summaries} + else: raise ToolExecutionError(f"Unknown tool: {tool_name}", code=-32601) From 1b1331daf23efd381a4753a73a3343b8e4d0ab28 Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Thu, 11 Jun 2026 03:07:57 -0400 Subject: [PATCH 4/5] test(backend): unit tests for new MCP data endpoints and tools Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/tests/unit/test_mcp_data_endpoints.py | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 backend/tests/unit/test_mcp_data_endpoints.py diff --git a/backend/tests/unit/test_mcp_data_endpoints.py b/backend/tests/unit/test_mcp_data_endpoints.py new file mode 100644 index 0000000000..b7d1fb1a3d --- /dev/null +++ b/backend/tests/unit/test_mcp_data_endpoints.py @@ -0,0 +1,287 @@ +"""Unit tests for the new MCP data endpoints/tools: action items, goals, chat, +people, screen activity, and daily summaries. + +Tests both the REST handlers (routers/mcp.py) and the MCP tool dispatch +(routers/mcp_sse.py) with mocked database calls, following the heavy-dep +stubbing pattern in test_mcp_search_memories.py. +""" + +from datetime import datetime, timezone +from unittest.mock import patch, MagicMock +import os +import sys +from types import ModuleType + +import pytest + +os.environ.setdefault('OPENAI_API_KEY', 'sk-test-not-real') +os.environ.setdefault('ENCRYPTION_SECRET', 'omi_ZwB2ZNqB2HHpMK6wStk7sTpavJiPTFg7gXUHnc4tFABPU6pZ2c2DKgehtfgi4RZv') + + +class _AutoMockModule(ModuleType): + def __getattr__(self, name): + if name.startswith('__') and name.endswith('__'): + raise AttributeError(name) + mock = MagicMock() + setattr(self, name, mock) + return mock + + +_stubs = [ + 'database._client', + 'database.redis_db', + 'database.conversations', + 'database.memories', + 'database.action_items', + 'database.folders', + 'database.users', + 'database.user_usage', + 'database.vector_db', + 'database.chat', + 'database.apps', + 'database.goals', + 'database.notifications', + 'database.mem_db', + 'database.mcp_api_key', + 'database.daily_summaries', + 'database.screen_activity', + 'database.x_posts', + 'database.fair_use', + 'database.auth', + 'database.dev_api_key', + 'firebase_admin', + 'firebase_admin.messaging', + 'firebase_admin.auth', + 'google.cloud.firestore', + 'google.cloud.firestore_v1', + 'google.cloud.firestore_v1.FieldFilter', + 'google', + 'google.cloud', + 'pinecone', + 'typesense', + 'opuslib', + 'pydub', + 'pusher', + 'modal', + 'utils.other.storage', + 'utils.other.endpoints', + 'utils.stt.pre_recorded', + 'utils.stt.vad', + 'utils.fair_use', + 'utils.subscription', + 'utils.conversations.process_conversation', + 'utils.conversations.render', + 'utils.notifications', + 'utils.apps', + 'utils.llm.memories', + 'utils.llm.chat', + 'utils.log_sanitizer', + 'utils.executors', +] +for mod_name in _stubs: + if mod_name not in sys.modules: + sys.modules[mod_name] = _AutoMockModule(mod_name) + +sys.modules['firebase_admin.auth'].InvalidIdTokenError = type('InvalidIdTokenError', (Exception,), {}) +sys.modules['firebase_admin.auth'].ExpiredIdTokenError = type('ExpiredIdTokenError', (Exception,), {}) +sys.modules['firebase_admin.auth'].RevokedIdTokenError = type('RevokedIdTokenError', (Exception,), {}) +sys.modules['firebase_admin.auth'].CertificateFetchError = type('CertificateFetchError', (Exception,), {}) +sys.modules['firebase_admin.auth'].UserNotFoundError = type('UserNotFoundError', (Exception,), {}) + +from routers import mcp as rest # noqa: E402 +from routers import mcp_sse as sse # noqa: E402 + +NOW = datetime(2026, 6, 11, tzinfo=timezone.utc) +UID = "user-1" + + +def _action_item(item_id='a1', desc='Email Bob', completed=False, deleted=False, locked=False): + return { + 'id': item_id, + 'description': desc, + 'completed': completed, + 'created_at': NOW, + 'due_at': NOW, + 'completed_at': None, + 'conversation_id': 'conv-1', + 'deleted': deleted, + 'is_locked': locked, + } + + +class TestActionItems: + @patch('routers.mcp.action_items_db') + def test_rest_returns_items_and_drops_deleted(self, mock_db): + mock_db.get_action_items.return_value = [_action_item('a1'), _action_item('a2', deleted=True)] + result = rest.get_action_items(uid=UID) + assert [i['id'] for i in result] == ['a1'] + assert result[0]['description'] == 'Email Bob' + + @patch('routers.mcp.action_items_db') + def test_rest_limit_clamped(self, mock_db): + mock_db.get_action_items.return_value = [] + rest.get_action_items(limit=99999, uid=UID) + _, kwargs = mock_db.get_action_items.call_args + assert kwargs['limit'] == 500 + + @patch('routers.mcp_sse.action_items_db') + def test_tool_dispatch(self, mock_db): + mock_db.get_action_items.return_value = [_action_item('a1'), _action_item('a2', deleted=True)] + result = sse.execute_tool(UID, 'get_action_items', {'completed': False}) + assert [i['id'] for i in result['action_items']] == ['a1'] + + @patch('routers.mcp_sse.action_items_db') + def test_tool_rejects_bad_date(self, mock_db): + with pytest.raises(sse.ToolExecutionError): + sse.execute_tool(UID, 'get_action_items', {'due_start_date': 'not-a-date'}) + + @patch('routers.mcp_sse.action_items_db') + def test_locked_description_truncated(self, mock_db): + long_desc = 'x' * 200 + mock_db.get_action_items.return_value = [_action_item('a1', desc=long_desc, locked=True)] + result = sse.execute_tool(UID, 'get_action_items', {}) + assert result['action_items'][0]['description'].endswith('...') + assert len(result['action_items'][0]['description']) == 73 + + +class TestGoals: + @patch('routers.mcp.goals_db') + def test_rest(self, mock_db): + mock_db.get_all_goals.return_value = [{'id': 'g1', 'title': 'Ship MCP', 'is_active': True}] + result = rest.get_goals(uid=UID) + assert result[0]['title'] == 'Ship MCP' + mock_db.get_all_goals.assert_called_once_with(UID, include_inactive=False) + + @patch('routers.mcp_sse.goals_db') + def test_tool(self, mock_db): + mock_db.get_all_goals.return_value = [{'id': 'g1', 'title': 'Ship MCP'}] + result = sse.execute_tool(UID, 'get_goals', {'include_inactive': True}) + assert result['goals'][0]['id'] == 'g1' + mock_db.get_all_goals.assert_called_once_with(UID, include_inactive=True) + + +class TestChat: + @patch('routers.mcp.chat_db') + def test_rest_shapes_message(self, mock_db): + mock_db.get_messages.return_value = [ + {'id': 'm1', 'text': 'hi', 'sender': 'human', 'type': 'text', 'created_at': NOW, 'files_id': []} + ] + result = rest.get_chat_messages(uid=UID) + assert result == [{'id': 'm1', 'text': 'hi', 'sender': 'human', 'type': 'text', 'created_at': NOW}] + + @patch('routers.mcp_sse.chat_db') + def test_tool(self, mock_db): + mock_db.get_messages.return_value = [{'id': 'm1', 'text': 'hi', 'sender': 'ai', 'type': 'text'}] + result = sse.execute_tool(UID, 'get_chat_messages', {'limit': 10}) + assert result['messages'][0]['sender'] == 'ai' + + +class TestPeople: + def _person(self): + return { + 'id': 'p1', + 'name': 'Bob', + 'created_at': NOW, + 'speech_sample_transcripts': ['hello there', 'how are you'], + 'speech_samples': ['gs://bucket/secret.wav'], + 'speaker_embedding': [0.1, 0.2, 0.3], + } + + @patch('routers.mcp.users_db') + def test_rest_drops_audio_and_embeddings(self, mock_db): + mock_db.get_people.return_value = [self._person()] + result = rest.get_people(uid=UID) + assert result[0]['name'] == 'Bob' + assert 'speech_samples' not in result[0] + assert 'speaker_embedding' not in result[0] + assert result[0]['speech_sample_transcripts'] == ['hello there', 'how are you'] + + @patch('routers.mcp_sse.users_db') + def test_tool(self, mock_db): + mock_db.get_people.return_value = [self._person()] + result = sse.execute_tool(UID, 'get_people', {}) + assert result['people'][0]['id'] == 'p1' + assert 'speech_samples' not in result['people'][0] + + +class TestScreenActivity: + def _row(self): + return { + 'id': 's1', + 'timestamp': '2026-06-11 10:00:00.000', + 'appName': 'Cursor', + 'windowTitle': 'mcp.py', + 'ocrText': 'def foo', + } + + @patch('routers.mcp.screen_activity_db') + def test_rest_rows(self, mock_db): + mock_db.get_screen_activity.return_value = [self._row()] + result = rest.get_screen_activity(uid=UID) + assert result == [ + { + 'id': 's1', + 'timestamp': '2026-06-11 10:00:00.000', + 'app_name': 'Cursor', + 'window_title': 'mcp.py', + 'ocr_text': 'def foo', + } + ] + + @patch('routers.mcp.screen_activity_db') + def test_rest_summary_mode(self, mock_db): + mock_db.get_screen_activity_summary.return_value = {'apps': {'Cursor': {'count': 1}}, 'total_screenshots': 1} + result = rest.get_screen_activity(summary=True, uid=UID) + assert result['total_screenshots'] == 1 + mock_db.get_screen_activity.assert_not_called() + + @patch('routers.mcp_sse.screen_activity_db') + def test_tool_rows(self, mock_db): + mock_db.get_screen_activity.return_value = [self._row()] + result = sse.execute_tool(UID, 'get_screen_activity', {'limit': 5}) + assert result['screen_activity'][0]['app_name'] == 'Cursor' + + @patch('routers.mcp_sse.screen_activity_db') + def test_tool_summary(self, mock_db): + mock_db.get_screen_activity_summary.return_value = {'apps': {}, 'total_screenshots': 0} + result = sse.execute_tool(UID, 'get_screen_activity', {'summary': True}) + assert result['total_screenshots'] == 0 + + +class TestDailySummaries: + @patch('routers.mcp.daily_summaries_db') + def test_rest(self, mock_db): + mock_db.get_daily_summaries.return_value = [{'date': '2026-06-11', 'content': 'Worked on MCP'}] + result = rest.get_daily_summaries(uid=UID) + assert result[0]['date'] == '2026-06-11' + + @patch('routers.mcp_sse.daily_summaries_db') + def test_tool(self, mock_db): + mock_db.get_daily_summaries.return_value = [{'date': '2026-06-11', 'content': 'x'}] + result = sse.execute_tool(UID, 'get_daily_summaries', {'limit': 5}) + assert result['daily_summaries'][0]['date'] == '2026-06-11' + + +class TestToolRegistry: + def test_new_tools_registered(self): + names = {t['name'] for t in sse.MCP_TOOLS} + for expected in [ + 'get_action_items', + 'get_goals', + 'get_chat_messages', + 'get_people', + 'get_screen_activity', + 'get_daily_summaries', + ]: + assert expected in names, f"{expected} missing from MCP_TOOLS" + + def test_every_tool_has_a_dispatch_branch(self): + # Each declared read-only data tool must dispatch (not fall through to "Unknown tool"). + for name in ['get_action_items', 'get_goals', 'get_chat_messages', 'get_people', 'get_daily_summaries']: + with patch.object(sse, 'action_items_db'), patch.object(sse, 'goals_db'), patch.object( + sse, 'chat_db' + ), patch.object(sse, 'users_db'), patch.object(sse, 'daily_summaries_db'): + try: + sse.execute_tool(UID, name, {}) + except sse.ToolExecutionError as e: + assert 'Unknown tool' not in e.message From edb224c1b8fb3bafc76b2659cffb595f3f0f2518 Mon Sep 17 00:00:00 2001 From: Nik Shevchenko Date: Thu, 11 Jun 2026 03:07:58 -0400 Subject: [PATCH 5/5] test(backend): run test_mcp_data_endpoints in CI Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/test.sh b/backend/test.sh index 64a9846d0d..efadf7c508 100755 --- a/backend/test.sh +++ b/backend/test.sh @@ -24,6 +24,7 @@ pytest tests/unit/test_parakeet_stream_session.py -v pytest tests/unit/test_memory_leak_buffers.py -v pytest tests/unit/test_mcp_search_memories.py -v pytest tests/unit/test_mcp_client_tool_result.py -v +pytest tests/unit/test_mcp_data_endpoints.py -v pytest tests/unit/test_memory_temporal_brain.py -v pytest tests/unit/test_llm_usage_tracker.py -v pytest tests/unit/test_process_conversation_usage_context.py -v