Skip to content
76 changes: 76 additions & 0 deletions backend/routers/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
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
import utils.mcp_action_items as mcp_action_items
from utils.mcp_memories import (
collect_filtered_memories,
parse_mcp_bool,
Expand Down Expand Up @@ -447,6 +448,81 @@ def get_action_items(
return [clean_action_item(i) for i in items if not i.get("deleted", False)]


class McpCreateActionItem(BaseModel):
description: str
due_at: Optional[datetime] = None
completed: bool = False


class McpUpdateActionItem(BaseModel):
description: Optional[str] = None
due_at: Optional[datetime] = None


def _action_item_write_error(exc: Exception) -> HTTPException:
"""Map a shared action-item write error to the REST status the memory writes use."""
if isinstance(exc, mcp_action_items.ActionItemNotFound):
return HTTPException(status_code=404, detail="Action item not found")
if isinstance(exc, mcp_action_items.ActionItemLocked):
return HTTPException(status_code=402, detail="A paid plan is required to modify this action item.")
return HTTPException(status_code=500, detail="Action item write failed")


@router.get("/v1/mcp/action-items/search", response_model=List[SimpleActionItem], tags=["mcp"])
def search_action_items(query: str, limit: int = 10, uid: str = Depends(get_uid_from_mcp_api_key)):
logger.info(f"search_action_items {uid} limit={limit}")
try:
return mcp_action_items.search_action_items(uid, query, limit=limit)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))


@router.post("/v1/mcp/action-items", response_model=SimpleActionItem, tags=["mcp"])
def create_action_item(
body: McpCreateActionItem,
uid: str = Depends(with_rate_limit(get_uid_from_mcp_api_key, "action_items:write")),
):
logger.info(f"create_action_item {uid} completed={body.completed} has_due={body.due_at is not None}")
try:
return mcp_action_items.create_action_item(uid, body.description, due_at=body.due_at, completed=body.completed)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except mcp_action_items.ActionItemError as e:
raise _action_item_write_error(e)


@router.post("/v1/mcp/action-items/{action_item_id}/complete", response_model=SimpleActionItem, tags=["mcp"])
def complete_action_item(action_item_id: str, completed: bool = True, uid: str = Depends(get_uid_from_mcp_api_key)):
logger.info(f"complete_action_item {uid} id={action_item_id} completed={completed}")
try:
return mcp_action_items.set_completed(uid, action_item_id, completed=completed)
except mcp_action_items.ActionItemError as e:
raise _action_item_write_error(e)


@router.patch("/v1/mcp/action-items/{action_item_id}", response_model=SimpleActionItem, tags=["mcp"])
def update_action_item(action_item_id: str, body: McpUpdateActionItem, uid: str = Depends(get_uid_from_mcp_api_key)):
logger.info(f"update_action_item {uid} id={action_item_id}")
try:
return mcp_action_items.update_action_item(
uid, action_item_id, description=body.description, due_at=body.due_at
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except mcp_action_items.ActionItemError as e:
raise _action_item_write_error(e)


@router.delete("/v1/mcp/action-items/{action_item_id}", tags=["mcp"])
def delete_action_item(action_item_id: str, uid: str = Depends(get_uid_from_mcp_api_key)):
logger.info(f"delete_action_item {uid} id={action_item_id}")
try:
mcp_action_items.delete_action_item(uid, action_item_id)
except mcp_action_items.ActionItemError as e:
raise _action_item_write_error(e)
return {"status": "ok"}


# ---------------------------------------------------------------------------
# Goals — the user's stated objectives
# ---------------------------------------------------------------------------
Expand Down
165 changes: 165 additions & 0 deletions backend/routers/mcp_sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
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
import utils.mcp_action_items as mcp_action_items
from utils.mcp_memories import (
collect_filtered_memories,
parse_mcp_bool,
Expand All @@ -60,6 +61,7 @@
"memories.write",
"conversations.read",
"action_items.read",
"action_items.write",
"goals.read",
"chat.read",
"screen_activity.read",
Expand Down Expand Up @@ -88,6 +90,7 @@
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"]}]
ACTION_ITEMS_WRITE_SECURITY = [{"type": "oauth2", "scopes": ["action_items.write"]}]
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"]}]
Expand Down Expand Up @@ -360,6 +363,100 @@ def invalid_mcp_auth_exception(
},
},
},
{
"name": "search_action_items",
"description": (
"Semantic search across the user's action items (tasks/to-dos). Returns tasks ranked by relevance to "
"the query — use this to find a specific task by what it is about before completing or updating it."
),
"annotations": READ_ONLY_ANNOTATIONS,
"securitySchemes": ACTION_ITEMS_READ_SECURITY,
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "What to search the user's tasks for"},
"limit": {"type": "integer", "description": "Max number of tasks to return (1-50)", "default": 10},
},
"required": ["query"],
},
},
{
"name": "create_action_item",
"description": (
"Create a new action item (task/to-do) for the user — for example a follow-up you identified while "
"helping them. Retries with the same description return the existing task instead of duplicating it."
),
"annotations": WRITE_ANNOTATIONS,
"securitySchemes": ACTION_ITEMS_WRITE_SECURITY,
"inputSchema": {
"type": "object",
"properties": {
"description": {"type": "string", "description": "What the user needs to do"},
"due_at": {
"type": "string",
"description": "Optional due date/time, ISO 8601 (2026-07-01T17:00:00Z) or YYYY-MM-DD",
},
"completed": {
"type": "boolean",
"description": "Create it already completed (default false)",
"default": False,
},
},
"required": ["description"],
},
},
{
"name": "complete_action_item",
"description": "Mark an action item complete, or reopen it by passing completed=false.",
"annotations": WRITE_ANNOTATIONS,
"securitySchemes": ACTION_ITEMS_WRITE_SECURITY,
"inputSchema": {
"type": "object",
"properties": {
"action_item_id": {"type": "string", "description": "The ID of the action item"},
"completed": {
"type": "boolean",
"description": "True to complete (default), false to reopen",
"default": True,
},
},
"required": ["action_item_id"],
},
},
{
"name": "update_action_item",
"description": (
"Update an action item's description and/or due date. Only the fields you pass are changed; an omitted "
"due date is left unchanged."
),
"annotations": WRITE_ANNOTATIONS,
"securitySchemes": ACTION_ITEMS_WRITE_SECURITY,
"inputSchema": {
"type": "object",
"properties": {
"action_item_id": {"type": "string", "description": "The ID of the action item"},
"description": {"type": "string", "description": "New description for the task"},
"due_at": {
"type": "string",
"description": "New due date/time, ISO 8601 (2026-07-01T17:00:00Z) or YYYY-MM-DD",
},
},
"required": ["action_item_id"],
},
},
{
"name": "delete_action_item",
"description": "Delete an action item by ID. Use this to clean up a task that is no longer relevant.",
"annotations": DESTRUCTIVE_WRITE_ANNOTATIONS,
"securitySchemes": ACTION_ITEMS_WRITE_SECURITY,
"inputSchema": {
"type": "object",
"properties": {
"action_item_id": {"type": "string", "description": "The ID of the action item to delete"},
},
"required": ["action_item_id"],
},
},
{
"name": "get_goals",
"description": (
Expand Down Expand Up @@ -820,6 +917,74 @@ def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict:
)
return {"action_items": [clean_action_item(i) for i in items if not i.get("deleted", False)]}

elif tool_name == "search_action_items":
try:
items = mcp_action_items.search_action_items(
user_id, arguments.get("query"), limit=arguments.get("limit", 10)
)
except ValueError as e:
raise ToolExecutionError(str(e), code=-32602)
return {"action_items": items}

elif tool_name == "create_action_item":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: New write tools rely on metadata-only securitySchemes declarations (ACTION_ITEMS_WRITE_SECURITY) without enforcing action_items.write scope during SSE tool execution. authenticate_api_key returns only a user_id; the handle_mcp_messageexecute_tool path never validates scopes before allowing creates, updates, completions, or deletes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/routers/mcp_sse.py, line 929:

<comment>New write tools rely on metadata-only `securitySchemes` declarations (`ACTION_ITEMS_WRITE_SECURITY`) without enforcing `action_items.write` scope during SSE tool execution. `authenticate_api_key` returns only a `user_id`; the `handle_mcp_message` → `execute_tool` path never validates scopes before allowing creates, updates, completions, or deletes.</comment>

<file context>
@@ -820,6 +917,74 @@ def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict:
+            raise ToolExecutionError(str(e), code=-32602)
+        return {"action_items": items}
+
+    elif tool_name == "create_action_item":
+        try:
+            completed = parse_mcp_bool(arguments.get("completed"), "completed", default=False)
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: create_action_item in the SSE tool executor does not enforce the action_items:write rate limit, allowing clients to bypass the intended 120/hr creation throttle.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/routers/mcp_sse.py, line 929:

<comment>`create_action_item` in the SSE tool executor does not enforce the `action_items:write` rate limit, allowing clients to bypass the intended 120/hr creation throttle.</comment>

<file context>
@@ -820,6 +917,74 @@ def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict:
+            raise ToolExecutionError(str(e), code=-32602)
+        return {"action_items": items}
+
+    elif tool_name == "create_action_item":
+        try:
+            completed = parse_mcp_bool(arguments.get("completed"), "completed", default=False)
</file context>

try:
completed = parse_mcp_bool(arguments.get("completed"), "completed", default=False)
item = mcp_action_items.create_action_item(
user_id,
arguments.get("description"),
due_at=arguments.get("due_at"),
completed=completed,
)
except ValueError as e:
raise ToolExecutionError(str(e), code=-32602)
return {"success": True, "action_item": item}

elif tool_name == "complete_action_item":
action_item_id = arguments.get("action_item_id")
if not action_item_id:
raise ToolExecutionError("action_item_id is required", code=-32602)
try:
completed = parse_mcp_bool(arguments.get("completed"), "completed", default=True)
item = mcp_action_items.set_completed(user_id, action_item_id, completed=completed)
except ValueError as e:
raise ToolExecutionError(str(e), code=-32602)
except mcp_action_items.ActionItemNotFound:
raise ToolExecutionError("Action item not found", code=-32001)
except mcp_action_items.ActionItemLocked:
raise ToolExecutionError("A paid plan is required to modify this action item.", code=-32002)
return {"success": True, "action_item": item}

elif tool_name == "update_action_item":
action_item_id = arguments.get("action_item_id")
if not action_item_id:
raise ToolExecutionError("action_item_id is required", code=-32602)
try:
item = mcp_action_items.update_action_item(
user_id,
action_item_id,
description=arguments.get("description"),
due_at=arguments.get("due_at"),
)
except ValueError as e:
raise ToolExecutionError(str(e), code=-32602)
except mcp_action_items.ActionItemNotFound:
raise ToolExecutionError("Action item not found", code=-32001)
except mcp_action_items.ActionItemLocked:
raise ToolExecutionError("A paid plan is required to modify this action item.", code=-32002)
return {"success": True, "action_item": item}

elif tool_name == "delete_action_item":
action_item_id = arguments.get("action_item_id")
if not action_item_id:
raise ToolExecutionError("action_item_id is required", code=-32602)
try:
mcp_action_items.delete_action_item(user_id, action_item_id)
except mcp_action_items.ActionItemNotFound:
raise ToolExecutionError("Action item not found", code=-32001)
except mcp_action_items.ActionItemLocked:
raise ToolExecutionError("A paid plan is required to modify this action item.", code=-32002)
return {"success": True}

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)}
Expand Down
1 change: 1 addition & 0 deletions backend/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pytest tests/unit/test_mcp_search_conversations_poison.py -v
pytest tests/unit/test_mcp_memory_filters.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_mcp_action_item_writes.py -v
pytest tests/unit/test_mcp_conversations_poison.py -v
pytest tests/unit/test_mcp_profile_contact.py -v
pytest tests/unit/test_memory_temporal_brain.py -v
Expand Down
Loading
Loading