From 6ea39d365ec5ff57180cc8ea4362edba6cd489f6 Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:17:05 +0100 Subject: [PATCH 01/13] pre-hosted MCP compatible with apps like poke.com --- app/lib/pages/settings/developer.dart | 116 ++++++ backend/main.py | 2 + backend/routers/mcp_sse.py | 535 ++++++++++++++++++++++++++ 3 files changed, 653 insertions(+) create mode 100644 backend/routers/mcp_sse.py diff --git a/app/lib/pages/settings/developer.dart b/app/lib/pages/settings/developer.dart index e5bda7d5209..e7a8fed7b20 100644 --- a/app/lib/pages/settings/developer.dart +++ b/app/lib/pages/settings/developer.dart @@ -7,6 +7,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:omi/backend/http/api/conversations.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/env/env.dart'; import 'package:omi/pages/settings/widgets/create_mcp_api_key_dialog.dart'; import 'package:omi/pages/settings/widgets/mcp_api_key_list_item.dart'; import 'package:omi/pages/settings/widgets/developer_api_keys_section.dart'; @@ -973,6 +974,121 @@ class _DeveloperSettingsPageState extends State { ), ), + const SizedBox(height: 24), + + // Pre-hosted MCP Section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF1C1C1E), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFF2A2A2E), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: FaIcon(FontAwesomeIcons.globe, color: Colors.grey.shade400, size: 16), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Pre-hosted MCP', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Connect without Docker', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 13, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Server URL', + style: TextStyle(color: Colors.grey.shade400, fontSize: 12, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6), + GestureDetector( + onTap: () { + final url = '${Env.apiBaseUrl}v1/mcp/sse'; + Clipboard.setData(ClipboardData(text: url)); + AppSnackbar.showSnackbar('Server URL copied'); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF0D0D0D), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF2A2A2E), width: 1), + ), + child: Row( + children: [ + Expanded( + child: Text( + '${Env.apiBaseUrl}v1/mcp/sse', + style: const TextStyle( + color: Colors.white70, + fontFamily: 'Ubuntu Mono', + fontSize: 13, + ), + ), + ), + FaIcon(FontAwesomeIcons.copy, color: Colors.grey.shade500, size: 14), + ], + ), + ), + ), + const SizedBox(height: 12), + Text( + 'API Key', + style: TextStyle(color: Colors.grey.shade400, fontSize: 12, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF0D0D0D), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF2A2A2E), width: 1), + ), + child: Text( + 'Use your MCP API key (omi_mcp_...)', + style: TextStyle( + color: Colors.grey.shade500, + fontFamily: 'Ubuntu Mono', + fontSize: 13, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 32), // Webhooks Section diff --git a/backend/main.py b/backend/main.py index eb7df0203c8..dc54d7bb12a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -24,6 +24,7 @@ conversations, memories, mcp, + mcp_sse, oauth, auth, action_items, @@ -77,6 +78,7 @@ app.include_router(payment.router) app.include_router(mcp.router) +app.include_router(mcp_sse.router) app.include_router(developer.router) app.include_router(imports.router) diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py new file mode 100644 index 00000000000..c52e2297d07 --- /dev/null +++ b/backend/routers/mcp_sse.py @@ -0,0 +1,535 @@ +""" +Pre-hosted MCP Server via Streamable HTTP Transport + +This module provides a streamable HTTP transport for MCP (Model Context Protocol), +allowing clients to connect without running a local MCP server. + +Implements the MCP 2025-03-26 Streamable HTTP Transport specification. +""" + +import json +import uuid +from datetime import datetime +from typing import Optional, Union, List, Any + +from fastapi import APIRouter, HTTPException, Header, Request, Response +from fastapi.responses import StreamingResponse, JSONResponse +from pydantic import BaseModel + +import database.memories as memories_db +import database.conversations as conversations_db +import database.mcp_api_key as mcp_api_key_db +from models.memories import MemoryDB, Memory, MemoryCategory +from models.conversation import CategoryEnum +from utils.llm.memories import identify_category_for_memory + +router = APIRouter() + +# Store active sessions +active_sessions: dict = {} + + +class MCPSession: + """Represents an active MCP session.""" + + def __init__(self, session_id: str, user_id: str): + self.session_id = session_id + self.user_id = user_id + self.created_at = datetime.utcnow() + self.initialized = False + + +def authenticate_api_key(authorization: Optional[str]) -> Optional[str]: + """Validate API key from Authorization header and return user_id if valid.""" + if not authorization: + return None + + # Support both "Bearer " and just "" formats + token = authorization + if authorization.startswith("Bearer "): + token = authorization[7:] + + if not token.startswith("omi_mcp_"): + return None + + return mcp_api_key_db.get_user_id_by_api_key(token) + + +# MCP Tool Definitions +MCP_TOOLS = [ + { + "name": "get_memories", + "description": "Retrieve a list of memories. A memory is a known fact about the user across multiple domains.", + "inputSchema": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": {"type": "string", "enum": [c.value for c in MemoryCategory]}, + "description": "Categories to filter by", + "default": [] + }, + "limit": {"type": "integer", "description": "Number of memories to retrieve", "default": 100}, + "offset": {"type": "integer", "description": "Offset for pagination", "default": 0} + } + } + }, + { + "name": "create_memory", + "description": "Create a new memory. A memory is a known fact about the user across multiple domains.", + "inputSchema": { + "type": "object", + "properties": { + "content": {"type": "string", "description": "The content of the memory"}, + "category": {"type": "string", "enum": [c.value for c in MemoryCategory], "description": "The category of the memory"} + }, + "required": ["content"] + } + }, + { + "name": "delete_memory", + "description": "Delete a memory by ID.", + "inputSchema": { + "type": "object", + "properties": { + "memory_id": {"type": "string", "description": "The ID of the memory to delete"} + }, + "required": ["memory_id"] + } + }, + { + "name": "edit_memory", + "description": "Edit a memory's content.", + "inputSchema": { + "type": "object", + "properties": { + "memory_id": {"type": "string", "description": "The ID of the memory to edit"}, + "content": {"type": "string", "description": "The new content for the memory"} + }, + "required": ["memory_id", "content"] + } + }, + { + "name": "get_conversations", + "description": "Retrieve a list of conversation metadata. To get full transcripts, use get_conversation_by_id.", + "inputSchema": { + "type": "object", + "properties": { + "start_date": {"type": "string", "description": "Filter after this date (yyyy-mm-dd)"}, + "end_date": {"type": "string", "description": "Filter before this date (yyyy-mm-dd)"}, + "categories": { + "type": "array", + "items": {"type": "string", "enum": [c.value for c in CategoryEnum]}, + "description": "Categories to filter by", + "default": [] + }, + "limit": {"type": "integer", "description": "Number of conversations to retrieve", "default": 20}, + "offset": {"type": "integer", "description": "Offset for pagination", "default": 0} + } + } + }, + { + "name": "get_conversation_by_id", + "description": "Retrieve a conversation by ID including each segment of the transcript.", + "inputSchema": { + "type": "object", + "properties": { + "conversation_id": {"type": "string", "description": "The ID of the conversation to retrieve"} + }, + "required": ["conversation_id"] + } + } +] + + +def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict: + """Execute an MCP tool and return the result.""" + + if tool_name == "get_memories": + categories = arguments.get("categories", []) + limit = arguments.get("limit", 100) + offset = arguments.get("offset", 0) + + # Validate categories + valid_categories = [] + for cat in categories: + try: + valid_categories.append(MemoryCategory(cat).value) + except ValueError: + pass + + memories = memories_db.get_memories(user_id, limit, offset, valid_categories) + # Apply locked content truncation + for memory in memories: + if memory.get('is_locked', False): + content = memory.get('content', '') + memory['content'] = (content[:70] + '...') if len(content) > 70 else content + + return {"memories": memories} + + elif tool_name == "create_memory": + content = arguments.get("content") + if not content: + return {"error": "Content is required"} + + memory = Memory(content=content) + categories = [category for category in MemoryCategory] + memory.category = identify_category_for_memory(memory.content, categories) + memory_db = MemoryDB.from_memory(memory, user_id, None, True) + memories_db.create_memory(user_id, memory_db.model_dump()) + + return {"success": True, "memory": memory_db.model_dump()} + + elif tool_name == "delete_memory": + memory_id = arguments.get("memory_id") + if not memory_id: + return {"error": "memory_id is required"} + + memories_db.delete_memory(user_id, memory_id) + return {"success": True} + + elif tool_name == "edit_memory": + memory_id = arguments.get("memory_id") + content = arguments.get("content") + if not memory_id or not content: + return {"error": "memory_id and content are required"} + + memories_db.edit_memory(user_id, memory_id, content) + return {"success": True} + + elif tool_name == "get_conversations": + start_date = arguments.get("start_date") + end_date = arguments.get("end_date") + categories = arguments.get("categories", []) + limit = arguments.get("limit", 20) + offset = arguments.get("offset", 0) + + # Parse dates + start_dt = None + end_dt = None + if start_date: + try: + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + except ValueError: + pass + if end_date: + try: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + except ValueError: + pass + + # Validate categories + valid_categories = [] + for cat in categories: + try: + valid_categories.append(CategoryEnum(cat).value) + except ValueError: + pass + + conversations = conversations_db.get_conversations( + user_id, limit, offset, + include_discarded=False, + statuses=["completed"], + start_date=start_dt, + end_date=end_dt, + categories=valid_categories + ) + + # Simplify conversation data + simple_conversations = [] + for conv in conversations: + simple_conversations.append({ + "id": conv.get("id"), + "started_at": conv.get("started_at"), + "finished_at": conv.get("finished_at"), + "structured": conv.get("structured"), + "language": conv.get("language") + }) + + return {"conversations": simple_conversations} + + elif tool_name == "get_conversation_by_id": + conversation_id = arguments.get("conversation_id") + if not conversation_id: + return {"error": "conversation_id is required"} + + conversation = conversations_db.get_conversation(user_id, conversation_id) + if not conversation: + return {"error": "Conversation not found"} + + if conversation.get('is_locked', False): + return {"error": "Unlimited Plan Required to access this conversation."} + + return {"conversation": conversation} + + else: + return {"error": f"Unknown tool: {tool_name}"} + + +def create_mcp_response(id: Any, result: dict) -> dict: + """Create a JSON-RPC 2.0 response.""" + return { + "jsonrpc": "2.0", + "id": id, + "result": result + } + + +def create_mcp_error(id: Any, code: int, message: str) -> dict: + """Create a JSON-RPC 2.0 error response.""" + return { + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + } + + +def handle_mcp_message(user_id: str, message: dict, session: Optional[MCPSession] = None) -> tuple[Optional[dict], Optional[str]]: + """ + Process an incoming MCP JSON-RPC message and return a response. + Returns (response, new_session_id) tuple. + """ + msg_id = message.get("id") + method = message.get("method") + params = message.get("params", {}) + new_session_id = None + + if method == "initialize": + # Create a new session + session_id = str(uuid.uuid4()) + new_session = MCPSession(session_id, user_id) + new_session.initialized = True + active_sessions[session_id] = new_session + new_session_id = session_id + + return create_mcp_response(msg_id, { + "protocolVersion": "2025-03-26", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "omi-mcp-server", + "version": "1.0.0" + } + }), new_session_id + + elif method == "notifications/initialized": + # This is a notification, no response needed + return None, None + + elif method == "tools/list": + return create_mcp_response(msg_id, { + "tools": MCP_TOOLS + }), None + + elif method == "tools/call": + tool_name = params.get("name") + arguments = params.get("arguments", {}) + + if not tool_name: + return create_mcp_error(msg_id, -32602, "Tool name is required"), None + + result = execute_tool(user_id, tool_name, arguments) + + return create_mcp_response(msg_id, { + "content": [ + { + "type": "text", + "text": json.dumps(result, indent=2, default=str) + } + ] + }), None + + elif method == "ping": + return create_mcp_response(msg_id, {}), None + + else: + return create_mcp_error(msg_id, -32601, f"Method not found: {method}"), None + + +@router.post("/v1/mcp/sse", tags=["mcp"]) +async def mcp_streamable_http( + request: Request, + authorization: Optional[str] = Header(None, alias="Authorization"), + mcp_session_id: Optional[str] = Header(None, alias="Mcp-Session-Id"), + accept: Optional[str] = Header(None, alias="Accept"), +): + """ + Streamable HTTP Transport endpoint for MCP clients. + + This implements the MCP 2025-03-26 Streamable HTTP Transport specification. + + - POST JSON-RPC messages to this endpoint + - Responses are returned as SSE stream or JSON depending on Accept header + - Session ID is returned in Mcp-Session-Id header after initialization + """ + # Authenticate + user_id = authenticate_api_key(authorization) + if not user_id: + raise HTTPException( + status_code=401, + detail="Invalid or missing API key. Provide via Authorization header." + ) + + # Parse request body + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") + + # Get session if provided + session = None + if mcp_session_id and mcp_session_id in active_sessions: + session = active_sessions[mcp_session_id] + # Verify session belongs to this user + if session.user_id != user_id: + raise HTTPException(status_code=403, detail="Session does not belong to this user") + + # Handle batch requests (array of messages) + messages = body if isinstance(body, list) else [body] + + # Check if all messages are notifications/responses (no id) + all_notifications = all(msg.get("id") is None for msg in messages) + + if all_notifications: + # Process notifications without response + for msg in messages: + handle_mcp_message(user_id, msg, session) + return Response(status_code=202) + + # Process messages and collect responses + responses = [] + new_session_id = None + + for msg in messages: + response, session_id = handle_mcp_message(user_id, msg, session) + if session_id: + new_session_id = session_id + if response: + responses.append(response) + + # Prepare headers + headers = {} + if new_session_id: + headers["Mcp-Session-Id"] = new_session_id + + # Check if client accepts SSE + wants_sse = accept and "text/event-stream" in accept + + if wants_sse: + # Return as SSE stream + async def event_generator(): + for resp in responses: + yield f"event: message\ndata: {json.dumps(resp, default=str)}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + **headers, + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + else: + # Return as JSON + if len(responses) == 1: + return JSONResponse(content=responses[0], headers=headers) + else: + return JSONResponse(content=responses, headers=headers) + + +@router.get("/v1/mcp/sse", tags=["mcp"]) +async def mcp_sse_get( + request: Request, + authorization: Optional[str] = Header(None, alias="Authorization"), + mcp_session_id: Optional[str] = Header(None, alias="Mcp-Session-Id"), +): + """ + SSE endpoint for server-initiated messages (optional). + + Clients can GET this endpoint to listen for server-initiated notifications. + This is optional per the MCP spec and mainly used for long-polling scenarios. + """ + # Authenticate + user_id = authenticate_api_key(authorization) + if not user_id: + raise HTTPException( + status_code=401, + detail="Invalid or missing API key. Provide via Authorization header." + ) + + # For backwards compatibility, also support the old SSE flow + # Return an empty SSE stream that just sends keepalives + async def event_generator(): + import asyncio + try: + while True: + if await request.is_disconnected(): + break + yield f"event: ping\ndata: {{}}\n\n" + await asyncio.sleep(30) + except Exception: + pass + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + + +@router.delete("/v1/mcp/sse", tags=["mcp"]) +async def mcp_delete_session( + mcp_session_id: Optional[str] = Header(None, alias="Mcp-Session-Id"), + authorization: Optional[str] = Header(None, alias="Authorization"), +): + """ + Delete/terminate an MCP session. + """ + if not mcp_session_id: + raise HTTPException(status_code=400, detail="Mcp-Session-Id header required") + + if mcp_session_id in active_sessions: + # Verify ownership + user_id = authenticate_api_key(authorization) + session = active_sessions[mcp_session_id] + if session.user_id == user_id: + del active_sessions[mcp_session_id] + return Response(status_code=204) + + raise HTTPException(status_code=404, detail="Session not found") + + +@router.get("/v1/mcp/sse/info", tags=["mcp"]) +async def mcp_sse_info(): + """ + Get information about the pre-hosted MCP server. + """ + return { + "endpoint": "/v1/mcp/sse", + "transport": "streamable-http", + "protocol_version": "2025-03-26", + "authentication": { + "method": "api_key", + "header": "Authorization", + "format": "Bearer or raw " + }, + "instructions": { + "step1": "Create an MCP API key in the Omi app (Settings > Developer > MCP)", + "step2": "Set Server URL to: https://api.omi.me/v1/mcp/sse", + "step3": "Set API Key to your MCP API key (omi_mcp_...)" + }, + "example": { + "server_url": "https://api.omi.me/v1/mcp/sse", + "api_key": "omi_mcp_your_key_here" + } + } From acb16e126a4c5d9d0099c2aad4a49608e90be46c Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:26:35 +0100 Subject: [PATCH 02/13] clean UI of MCP URL --- app/lib/pages/settings/developer.dart | 30 +-------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/app/lib/pages/settings/developer.dart b/app/lib/pages/settings/developer.dart index e7a8fed7b20..98add72f514 100644 --- a/app/lib/pages/settings/developer.dart +++ b/app/lib/pages/settings/developer.dart @@ -1026,16 +1026,11 @@ class _DeveloperSettingsPageState extends State { ], ), const SizedBox(height: 16), - Text( - 'Server URL', - style: TextStyle(color: Colors.grey.shade400, fontSize: 12, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 6), GestureDetector( onTap: () { final url = '${Env.apiBaseUrl}v1/mcp/sse'; Clipboard.setData(ClipboardData(text: url)); - AppSnackbar.showSnackbar('Server URL copied'); + AppSnackbar.showSnackbar('URL copied'); }, child: Container( width: double.infinity, @@ -1062,29 +1057,6 @@ class _DeveloperSettingsPageState extends State { ), ), ), - const SizedBox(height: 12), - Text( - 'API Key', - style: TextStyle(color: Colors.grey.shade400, fontSize: 12, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFF0D0D0D), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFF2A2A2E), width: 1), - ), - child: Text( - 'Use your MCP API key (omi_mcp_...)', - style: TextStyle( - color: Colors.grey.shade500, - fontFamily: 'Ubuntu Mono', - fontSize: 13, - ), - ), - ), ], ), ), From a4ba0e8c3327f45ef908ef8c6e4f1653b8f7afd4 Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Mon, 8 Dec 2025 12:29:56 +0100 Subject: [PATCH 03/13] code cleanup (thanks gemini) --- app/lib/pages/settings/developer.dart | 60 ++++++++++++++------------- backend/routers/mcp_sse.py | 2 +- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/app/lib/pages/settings/developer.dart b/app/lib/pages/settings/developer.dart index 98add72f514..a842f0fb871 100644 --- a/app/lib/pages/settings/developer.dart +++ b/app/lib/pages/settings/developer.dart @@ -1026,36 +1026,40 @@ class _DeveloperSettingsPageState extends State { ], ), const SizedBox(height: 16), - GestureDetector( - onTap: () { - final url = '${Env.apiBaseUrl}v1/mcp/sse'; - Clipboard.setData(ClipboardData(text: url)); - AppSnackbar.showSnackbar('URL copied'); - }, - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFF0D0D0D), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFF2A2A2E), width: 1), - ), - child: Row( - children: [ - Expanded( - child: Text( - '${Env.apiBaseUrl}v1/mcp/sse', - style: const TextStyle( - color: Colors.white70, - fontFamily: 'Ubuntu Mono', - fontSize: 13, + Builder( + builder: (context) { + final mcpUrl = '${Env.apiBaseUrl}v1/mcp/sse'; + return GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: mcpUrl)); + AppSnackbar.showSnackbar('URL copied'); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF0D0D0D), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF2A2A2E), width: 1), + ), + child: Row( + children: [ + Expanded( + child: Text( + mcpUrl, + style: const TextStyle( + color: Colors.white70, + fontFamily: 'Ubuntu Mono', + fontSize: 13, + ), + ), ), - ), + FaIcon(FontAwesomeIcons.copy, color: Colors.grey.shade500, size: 14), + ], ), - FaIcon(FontAwesomeIcons.copy, color: Colors.grey.shade500, size: 14), - ], - ), - ), + ), + ); + }, ), ], ), diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index c52e2297d07..a89a94d03a4 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -7,6 +7,7 @@ Implements the MCP 2025-03-26 Streamable HTTP Transport specification. """ +import asyncio import json import uuid from datetime import datetime @@ -466,7 +467,6 @@ async def mcp_sse_get( # For backwards compatibility, also support the old SSE flow # Return an empty SSE stream that just sends keepalives async def event_generator(): - import asyncio try: while True: if await request.is_disconnected(): From 250fc512c121d542aa27dc131eea82d24c26299a Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:50:51 +0100 Subject: [PATCH 04/13] add support for OAuth MCPs --- app/lib/pages/settings/developer.dart | 106 ++++++++++++++++++++++++-- backend/routers/mcp_sse.py | 85 +++++++++++++++++++-- 2 files changed, 176 insertions(+), 15 deletions(-) diff --git a/app/lib/pages/settings/developer.dart b/app/lib/pages/settings/developer.dart index a842f0fb871..b900b7785a2 100644 --- a/app/lib/pages/settings/developer.dart +++ b/app/lib/pages/settings/developer.dart @@ -261,6 +261,51 @@ class _DeveloperSettingsPageState extends State { ); } + Widget _buildMcpConfigRow(String label, String value) { + return GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: value)); + AppSnackbar.showSnackbar('$label copied'); + }, + child: Row( + children: [ + Expanded( + flex: 2, + child: Text( + label, + style: TextStyle(color: Colors.grey.shade500, fontSize: 13), + ), + ), + Expanded( + flex: 3, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF0D0D0D), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Expanded( + child: Text( + value, + style: const TextStyle( + color: Colors.white, + fontFamily: 'Ubuntu Mono', + fontSize: 13, + ), + ), + ), + FaIcon(FontAwesomeIcons.copy, color: Colors.grey.shade600, size: 11), + ], + ), + ), + ), + ], + ), + ); + } + Widget _buildApiKeysList(BuildContext context) { return Consumer( builder: (context, provider, child) { @@ -976,7 +1021,7 @@ class _DeveloperSettingsPageState extends State { const SizedBox(height: 24), - // Pre-hosted MCP Section + // MCP Server Section Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -996,7 +1041,7 @@ class _DeveloperSettingsPageState extends State { borderRadius: BorderRadius.circular(10), ), child: Center( - child: FaIcon(FontAwesomeIcons.globe, color: Colors.grey.shade400, size: 16), + child: FaIcon(FontAwesomeIcons.server, color: Colors.grey.shade400, size: 16), ), ), const SizedBox(width: 14), @@ -1005,7 +1050,7 @@ class _DeveloperSettingsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - 'Pre-hosted MCP', + 'MCP Server', style: TextStyle( color: Colors.white, fontSize: 16, @@ -1014,7 +1059,7 @@ class _DeveloperSettingsPageState extends State { ), const SizedBox(height: 2), Text( - 'Connect without Docker', + 'Connect AI assistants to your data', style: TextStyle( color: Colors.grey.shade500, fontSize: 13, @@ -1025,7 +1070,14 @@ class _DeveloperSettingsPageState extends State { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 20), + + // Server URL + Text( + 'Server URL', + style: TextStyle(color: Colors.grey.shade400, fontSize: 12, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), Builder( builder: (context) { final mcpUrl = '${Env.apiBaseUrl}v1/mcp/sse'; @@ -1036,7 +1088,7 @@ class _DeveloperSettingsPageState extends State { }, child: Container( width: double.infinity, - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: const Color(0xFF0D0D0D), borderRadius: BorderRadius.circular(10), @@ -1048,12 +1100,13 @@ class _DeveloperSettingsPageState extends State { child: Text( mcpUrl, style: const TextStyle( - color: Colors.white70, + color: Colors.white, fontFamily: 'Ubuntu Mono', fontSize: 13, ), ), ), + const SizedBox(width: 8), FaIcon(FontAwesomeIcons.copy, color: Colors.grey.shade500, size: 14), ], ), @@ -1061,6 +1114,45 @@ class _DeveloperSettingsPageState extends State { ); }, ), + + const SizedBox(height: 20), + Divider(color: Colors.grey.shade800, height: 1), + const SizedBox(height: 20), + + // OAuth Section for ChatGPT + Text( + 'OAuth (for ChatGPT)', + style: TextStyle(color: Colors.grey.shade400, fontSize: 12, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + + // Client ID + _buildMcpConfigRow('Client ID', 'omi'), + const SizedBox(height: 8), + + // Client Secret hint + Row( + children: [ + Expanded( + flex: 2, + child: Text( + 'Client Secret', + style: TextStyle(color: Colors.grey.shade500, fontSize: 13), + ), + ), + Expanded( + flex: 3, + child: Text( + 'Use your MCP API key', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 13, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), ], ), ), diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index a89a94d03a4..a3cd0299c8d 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -519,17 +519,86 @@ async def mcp_sse_info(): "transport": "streamable-http", "protocol_version": "2025-03-26", "authentication": { - "method": "api_key", - "header": "Authorization", - "format": "Bearer or raw " + "methods": ["api_key", "oauth2"], + "api_key": { + "header": "Authorization", + "format": "Bearer " + }, + "oauth2": { + "token_endpoint": "/v1/mcp/sse/token", + "grant_type": "client_credentials", + "client_secret": "Your MCP API key (omi_mcp_...)" + } }, "instructions": { "step1": "Create an MCP API key in the Omi app (Settings > Developer > MCP)", "step2": "Set Server URL to: https://api.omi.me/v1/mcp/sse", - "step3": "Set API Key to your MCP API key (omi_mcp_...)" - }, - "example": { - "server_url": "https://api.omi.me/v1/mcp/sse", - "api_key": "omi_mcp_your_key_here" + "step3": "For API Key auth: Set Authorization header to your key", + "step4": "For OAuth: Use client_secret = your MCP API key" } } + + +class TokenRequest(BaseModel): + """OAuth2 token request body.""" + grant_type: str = "client_credentials" + client_id: Optional[str] = None + client_secret: Optional[str] = None + + +@router.post("/v1/mcp/sse/token", tags=["mcp"]) +async def mcp_oauth_token( + request: Request, + grant_type: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, +): + """ + OAuth2 Client Credentials token endpoint for MCP. + + Used by ChatGPT and other OAuth-aware MCP clients. + - client_id: Optional (not used) + - client_secret: Your MCP API key (omi_mcp_...) + + Returns an access token that can be used with the /v1/mcp/sse endpoint. + """ + # Try to get credentials from form data or JSON body + secret = client_secret + + if not secret: + # Try form data + try: + form = await request.form() + secret = form.get("client_secret") + except Exception: + pass + + if not secret: + # Try JSON body + try: + body = await request.json() + secret = body.get("client_secret") + except Exception: + pass + + if not secret: + raise HTTPException( + status_code=400, + detail="client_secret is required (use your MCP API key)" + ) + + # Validate the API key + user_id = authenticate_api_key(secret) + if not user_id: + raise HTTPException( + status_code=401, + detail="Invalid client_secret (MCP API key)" + ) + + # Return the API key as the access token + # ChatGPT will use this in the Authorization header + return { + "access_token": secret, + "token_type": "Bearer", + "expires_in": 31536000 # 1 year (keys don't expire) + } From ad56aff1ac0bd6ab41a82b9aaa6954509e1be1cc Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:05:19 +0100 Subject: [PATCH 05/13] generalize app titles --- app/lib/pages/settings/developer.dart | 37 +++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/app/lib/pages/settings/developer.dart b/app/lib/pages/settings/developer.dart index b900b7785a2..c50eed8aa7d 100644 --- a/app/lib/pages/settings/developer.dart +++ b/app/lib/pages/settings/developer.dart @@ -1119,9 +1119,42 @@ class _DeveloperSettingsPageState extends State { Divider(color: Colors.grey.shade800, height: 1), const SizedBox(height: 20), - // OAuth Section for ChatGPT + // API Key Auth Section Text( - 'OAuth (for ChatGPT)', + 'API Key Auth', + style: TextStyle(color: Colors.grey.shade400, fontSize: 12, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + flex: 2, + child: Text( + 'Header', + style: TextStyle(color: Colors.grey.shade500, fontSize: 13), + ), + ), + Expanded( + flex: 3, + child: Text( + 'Authorization: Bearer ', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + fontFamily: 'Ubuntu Mono', + ), + ), + ), + ], + ), + + const SizedBox(height: 20), + Divider(color: Colors.grey.shade800, height: 1), + const SizedBox(height: 20), + + // OAuth Section + Text( + 'OAuth', style: TextStyle(color: Colors.grey.shade400, fontSize: 12, fontWeight: FontWeight.w600), ), const SizedBox(height: 12), From 17868a0eeda98bf15ab6b772893d5532d04e67ea Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:10:50 +0100 Subject: [PATCH 06/13] fix two MCP issues (issues caught by Gemini Code review) --- backend/routers/mcp_sse.py | 54 ++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index a3cd0299c8d..2ccf93d0f5e 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -143,8 +143,16 @@ def authenticate_api_key(authorization: Optional[str]) -> Optional[str]: ] +class ToolExecutionError(Exception): + """Exception raised when a tool execution fails.""" + def __init__(self, message: str, code: int = -32000): + self.message = message + self.code = code + super().__init__(self.message) + + def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict: - """Execute an MCP tool and return the result.""" + """Execute an MCP tool and return the result. Raises ToolExecutionError on failure.""" if tool_name == "get_memories": categories = arguments.get("categories", []) @@ -171,7 +179,7 @@ def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict: elif tool_name == "create_memory": content = arguments.get("content") if not content: - return {"error": "Content is required"} + raise ToolExecutionError("Content is required") memory = Memory(content=content) categories = [category for category in MemoryCategory] @@ -184,7 +192,7 @@ def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict: elif tool_name == "delete_memory": memory_id = arguments.get("memory_id") if not memory_id: - return {"error": "memory_id is required"} + raise ToolExecutionError("memory_id is required") memories_db.delete_memory(user_id, memory_id) return {"success": True} @@ -193,7 +201,7 @@ def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict: memory_id = arguments.get("memory_id") content = arguments.get("content") if not memory_id or not content: - return {"error": "memory_id and content are required"} + raise ToolExecutionError("memory_id and content are required") memories_db.edit_memory(user_id, memory_id, content) return {"success": True} @@ -252,19 +260,19 @@ def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict: elif tool_name == "get_conversation_by_id": conversation_id = arguments.get("conversation_id") if not conversation_id: - return {"error": "conversation_id is required"} + raise ToolExecutionError("conversation_id is required") conversation = conversations_db.get_conversation(user_id, conversation_id) if not conversation: - return {"error": "Conversation not found"} + raise ToolExecutionError("Conversation not found", code=-32001) if conversation.get('is_locked', False): - return {"error": "Unlimited Plan Required to access this conversation."} + raise ToolExecutionError("Unlimited Plan Required to access this conversation.", code=-32002) return {"conversation": conversation} else: - return {"error": f"Unknown tool: {tool_name}"} + raise ToolExecutionError(f"Unknown tool: {tool_name}", code=-32601) def create_mcp_response(id: Any, result: dict) -> dict: @@ -333,7 +341,10 @@ def handle_mcp_message(user_id: str, message: dict, session: Optional[MCPSession if not tool_name: return create_mcp_error(msg_id, -32602, "Tool name is required"), None - result = execute_tool(user_id, tool_name, arguments) + try: + result = execute_tool(user_id, tool_name, arguments) + except ToolExecutionError as e: + return create_mcp_error(msg_id, e.code, e.message), None return create_mcp_response(msg_id, { "content": [ @@ -495,18 +506,27 @@ async def mcp_delete_session( """ Delete/terminate an MCP session. """ + # Step 1: Validate authorization + user_id = authenticate_api_key(authorization) + if not user_id: + raise HTTPException(status_code=401, detail="Invalid or missing API key") + + # Step 2: Validate session ID is provided if not mcp_session_id: raise HTTPException(status_code=400, detail="Mcp-Session-Id header required") - if mcp_session_id in active_sessions: - # Verify ownership - user_id = authenticate_api_key(authorization) - session = active_sessions[mcp_session_id] - if session.user_id == user_id: - del active_sessions[mcp_session_id] - return Response(status_code=204) + # Step 3: Check if session exists + if mcp_session_id not in active_sessions: + raise HTTPException(status_code=404, detail="Session not found") + + # Step 4: Verify ownership + session = active_sessions[mcp_session_id] + if session.user_id != user_id: + raise HTTPException(status_code=403, detail="Not authorized to delete this session") - raise HTTPException(status_code=404, detail="Session not found") + # Delete the session + del active_sessions[mcp_session_id] + return Response(status_code=204) @router.get("/v1/mcp/sse/info", tags=["mcp"]) From 9a92c5e5611d745a2274d333b49885b8eca78749 Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:12:06 +0100 Subject: [PATCH 07/13] fix: mcp error logging --- backend/routers/mcp_sse.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index 2ccf93d0f5e..6a7ee7f69c3 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -9,6 +9,7 @@ import asyncio import json +import logging import uuid from datetime import datetime from typing import Optional, Union, List, Any @@ -484,8 +485,11 @@ async def event_generator(): break yield f"event: ping\ndata: {{}}\n\n" await asyncio.sleep(30) - except Exception: + except asyncio.CancelledError: + # Normal cancellation when client disconnects pass + except Exception as e: + logging.warning(f"MCP SSE event generator error: {e}") return StreamingResponse( event_generator(), From 9800bf3a50ade1b52f97f73ecfa412c9947823db Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:13:11 +0100 Subject: [PATCH 08/13] Update backend/routers/mcp_sse.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- backend/routers/mcp_sse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index 6a7ee7f69c3..d44e2700b5f 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -166,7 +166,7 @@ def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict: try: valid_categories.append(MemoryCategory(cat).value) except ValueError: - pass + raise ToolExecutionError(f"Invalid memory category: '{cat}'", code=-32602) memories = memories_db.get_memories(user_id, limit, offset, valid_categories) # Apply locked content truncation From 2451f080de4a619c1f221702f1b9d8ee128cc30e Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:13:22 +0100 Subject: [PATCH 09/13] Update backend/routers/mcp_sse.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- backend/routers/mcp_sse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index d44e2700b5f..6136c6b065a 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -221,7 +221,7 @@ def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict: try: start_dt = datetime.strptime(start_date, "%Y-%m-%d") except ValueError: - pass + raise ToolExecutionError(f"Invalid start_date format: '{start_date}'. Expected YYYY-MM-DD.", code=-32602) if end_date: try: end_dt = datetime.strptime(end_date, "%Y-%m-%d") From 9d61cff39fc5478c4c24785bf1aecebfa0dabf7c Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Mon, 8 Dec 2025 22:13:31 +0100 Subject: [PATCH 10/13] Update backend/routers/mcp_sse.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- backend/routers/mcp_sse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index 6136c6b065a..17c323b425b 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -594,8 +594,8 @@ async def mcp_oauth_token( try: form = await request.form() secret = form.get("client_secret") - except Exception: - pass + except Exception as e: + logging.warning(f"Could not parse form data in OAuth token endpoint: {e}") if not secret: # Try JSON body From 298bdda944b1f8cee330bf914bf2910dccc58d4c Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:21:33 +0100 Subject: [PATCH 11/13] Update backend/routers/mcp_sse.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- backend/routers/mcp_sse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index 17c323b425b..5fba75d4d9c 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -226,7 +226,7 @@ def execute_tool(user_id: str, tool_name: str, arguments: dict) -> dict: try: end_dt = datetime.strptime(end_date, "%Y-%m-%d") except ValueError: - pass + raise ToolExecutionError(f"Invalid end_date format: '{end_date}'. Expected YYYY-MM-DD.", code=-32602) # Validate categories valid_categories = [] From 83d85cddb65876300810462c91954a23cf1a0bf1 Mon Sep 17 00:00:00 2001 From: Neo <54811660+neooriginal@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:58:38 +0100 Subject: [PATCH 12/13] Update mcp_sse.py --- backend/routers/mcp_sse.py | 48 ++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index 5fba75d4d9c..3cfee195714 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -510,20 +510,16 @@ async def mcp_delete_session( """ Delete/terminate an MCP session. """ - # Step 1: Validate authorization user_id = authenticate_api_key(authorization) if not user_id: raise HTTPException(status_code=401, detail="Invalid or missing API key") - # Step 2: Validate session ID is provided if not mcp_session_id: raise HTTPException(status_code=400, detail="Mcp-Session-Id header required") - # Step 3: Check if session exists if mcp_session_id not in active_sessions: raise HTTPException(status_code=404, detail="Session not found") - # Step 4: Verify ownership session = active_sessions[mcp_session_id] if session.user_id != user_id: raise HTTPException(status_code=403, detail="Not authorized to delete this session") @@ -534,10 +530,11 @@ async def mcp_delete_session( @router.get("/v1/mcp/sse/info", tags=["mcp"]) -async def mcp_sse_info(): +async def mcp_sse_info(request: Request): """ Get information about the pre-hosted MCP server. """ + base_url = str(request.base_url).rstrip("/") return { "endpoint": "/v1/mcp/sse", "transport": "streamable-http", @@ -556,7 +553,7 @@ async def mcp_sse_info(): }, "instructions": { "step1": "Create an MCP API key in the Omi app (Settings > Developer > MCP)", - "step2": "Set Server URL to: https://api.omi.me/v1/mcp/sse", + "step2": f"Set Server URL to: {base_url}/v1/mcp/sse", "step3": "For API Key auth: Set Authorization header to your key", "step4": "For OAuth: Use client_secret = your MCP API key" } @@ -579,18 +576,10 @@ async def mcp_oauth_token( ): """ OAuth2 Client Credentials token endpoint for MCP. - - Used by ChatGPT and other OAuth-aware MCP clients. - - client_id: Optional (not used) - - client_secret: Your MCP API key (omi_mcp_...) - - Returns an access token that can be used with the /v1/mcp/sse endpoint. """ - # Try to get credentials from form data or JSON body secret = client_secret if not secret: - # Try form data try: form = await request.form() secret = form.get("client_secret") @@ -598,7 +587,6 @@ async def mcp_oauth_token( logging.warning(f"Could not parse form data in OAuth token endpoint: {e}") if not secret: - # Try JSON body try: body = await request.json() secret = body.get("client_secret") @@ -611,7 +599,6 @@ async def mcp_oauth_token( detail="client_secret is required (use your MCP API key)" ) - # Validate the API key user_id = authenticate_api_key(secret) if not user_id: raise HTTPException( @@ -620,9 +607,36 @@ async def mcp_oauth_token( ) # Return the API key as the access token - # ChatGPT will use this in the Authorization header return { "access_token": secret, "token_type": "Bearer", "expires_in": 31536000 # 1 year (keys don't expire) } + + +async def get_oauth_metadata(request: Request): + """ + Return OAuth 2.0 / OpenID Connect discovery metadata. + """ + base_url = str(request.base_url).rstrip("/") + + return { + "issuer": base_url, + "authorization_endpoint": f"{base_url}/v1/oauth/authorize", + "token_endpoint": f"{base_url}/v1/mcp/sse/token", + "scopes_supported": [], + "response_types_supported": ["token"], + "grant_types_supported": ["client_credentials"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "service_documentation": "https://docs.omi.me/doc/developer/MCP" + } + + +@router.get("/.well-known/oauth-authorization-server", tags=["mcp"]) +async def oauth_authorization_server(request: Request): + return await get_oauth_metadata(request) + + +@router.get("/.well-known/openid-configuration", tags=["mcp"]) +async def openid_configuration(request: Request): + return await get_oauth_metadata(request) From 64aac6f471d22a647509ca90bff84187e7304baa Mon Sep 17 00:00:00 2001 From: Neotastisch <54811660+Neotastisch@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:14:37 +0100 Subject: [PATCH 13/13] rm: oauth --- backend/routers/mcp_sse.py | 89 +------------------------------------- 1 file changed, 2 insertions(+), 87 deletions(-) diff --git a/backend/routers/mcp_sse.py b/backend/routers/mcp_sse.py index 3cfee195714..a3a2d7248fa 100644 --- a/backend/routers/mcp_sse.py +++ b/backend/routers/mcp_sse.py @@ -540,103 +540,18 @@ async def mcp_sse_info(request: Request): "transport": "streamable-http", "protocol_version": "2025-03-26", "authentication": { - "methods": ["api_key", "oauth2"], + "methods": ["api_key"], "api_key": { "header": "Authorization", "format": "Bearer " - }, - "oauth2": { - "token_endpoint": "/v1/mcp/sse/token", - "grant_type": "client_credentials", - "client_secret": "Your MCP API key (omi_mcp_...)" } }, "instructions": { "step1": "Create an MCP API key in the Omi app (Settings > Developer > MCP)", "step2": f"Set Server URL to: {base_url}/v1/mcp/sse", - "step3": "For API Key auth: Set Authorization header to your key", - "step4": "For OAuth: Use client_secret = your MCP API key" + "step3": "Set Authorization header to your key" } } -class TokenRequest(BaseModel): - """OAuth2 token request body.""" - grant_type: str = "client_credentials" - client_id: Optional[str] = None - client_secret: Optional[str] = None - - -@router.post("/v1/mcp/sse/token", tags=["mcp"]) -async def mcp_oauth_token( - request: Request, - grant_type: Optional[str] = None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, -): - """ - OAuth2 Client Credentials token endpoint for MCP. - """ - secret = client_secret - - if not secret: - try: - form = await request.form() - secret = form.get("client_secret") - except Exception as e: - logging.warning(f"Could not parse form data in OAuth token endpoint: {e}") - - if not secret: - try: - body = await request.json() - secret = body.get("client_secret") - except Exception: - pass - - if not secret: - raise HTTPException( - status_code=400, - detail="client_secret is required (use your MCP API key)" - ) - - user_id = authenticate_api_key(secret) - if not user_id: - raise HTTPException( - status_code=401, - detail="Invalid client_secret (MCP API key)" - ) - - # Return the API key as the access token - return { - "access_token": secret, - "token_type": "Bearer", - "expires_in": 31536000 # 1 year (keys don't expire) - } - - -async def get_oauth_metadata(request: Request): - """ - Return OAuth 2.0 / OpenID Connect discovery metadata. - """ - base_url = str(request.base_url).rstrip("/") - - return { - "issuer": base_url, - "authorization_endpoint": f"{base_url}/v1/oauth/authorize", - "token_endpoint": f"{base_url}/v1/mcp/sse/token", - "scopes_supported": [], - "response_types_supported": ["token"], - "grant_types_supported": ["client_credentials"], - "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], - "service_documentation": "https://docs.omi.me/doc/developer/MCP" - } - - -@router.get("/.well-known/oauth-authorization-server", tags=["mcp"]) -async def oauth_authorization_server(request: Request): - return await get_oauth_metadata(request) - -@router.get("/.well-known/openid-configuration", tags=["mcp"]) -async def openid_configuration(request: Request): - return await get_oauth_metadata(request)