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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@
## Features

- **Projects & Workspaces** — Persistent workspaces with knowledge graphs, file trees, and cross-conversation memory. Research accumulates across chats.
- **Interactive terminal** — Built-in terminal connected to the project workspace. Run commands directly alongside AI-driven research.
- **Interactive terminal** — Built-in terminal tab connected to the project workspace. Run commands directly alongside AI-driven research.
- **Plan + Execute modes** — Plan mode gathers context; Execute mode does the work. Toggle with `Cmd+M`.
- **Paper research** — OpenAlex, Semantic Scholar, arXiv, CrossRef, Papers With Code. Reads full papers, crawls citation graphs.
- **Paper writing** — Section-by-section drafting with auto-save. Export to Markdown/LaTeX.
- **Compute environments** — Execute code on local Docker, SSH remotes, or Modal cloud. Workspace persists independently of compute.
- **Background jobs** — Celery + Redis. Close the browser, come back later.
- **Multi-provider LLMs** — OpenAI, Anthropic, OpenRouter, plus local models (Ollama, LM Studio). Add custom providers with OpenAI SDK, Anthropic SDK, OpenRouter, or LiteLLM compatibility.
- **Model picker** — Browse models grouped by provider with logos, sorted by release date. Recently used models at the top. Fetches live from [models.dev](https://models.dev).
- **MCP servers** — Connect external tools via the Model Context Protocol.
- **MCP servers** — Connect remote HTTP/HTTPS MCP servers with custom authentication (Bearer, API key, headers).
- **Onboarding flow** — Guided setup when no LLM provider is configured.

## Quick Start
Expand Down
2 changes: 2 additions & 0 deletions backend/openmlr/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ async def lifespan(app: FastAPI):
from .routes.compute import router as compute_router
from .routes.health import router as health_router
from .routes.keys import router as keys_router
from .routes.mcp import router as mcp_router
from .routes.projects import router as projects_router
from .routes.settings import router as settings_router
from .routes.terminal import router as terminal_router
Expand All @@ -94,6 +95,7 @@ async def lifespan(app: FastAPI):
app.include_router(health_router)
app.include_router(keys_router)
app.include_router(compute_router)
app.include_router(mcp_router)
app.include_router(projects_router)
app.include_router(terminal_router)

Expand Down
16 changes: 16 additions & 0 deletions backend/openmlr/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,19 @@ async def get_current_user(
)

return user


async def get_current_user_optional(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User | None:
"""Like get_current_user but returns None instead of raising."""
if credentials is None:
return None
payload = decode_access_token(credentials.credentials)
if payload is None:
return None
result = await db.execute(
select(User).where(User.id == int(payload["sub"]), User.is_active == True)
)
return result.scalar_one_or_none()
1 change: 1 addition & 0 deletions backend/openmlr/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class MessageSend(BaseModel):
mode: Literal["plan", "execute"] | None = (
None # per-message mode; only plan or execute accepted
)
request_id: str | None = None # client-generated idempotency key


class ApprovalRequest(BaseModel):
Expand Down
30 changes: 30 additions & 0 deletions backend/openmlr/routes/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,32 @@ async def clear_conversation_compute(
# ── Messaging ────────────────────────────────────────────


# In-memory set of recently seen request IDs (with TTL-based eviction)
_recent_request_ids: dict[str, float] = {}
_REQUEST_ID_TTL = 30.0 # seconds


def _check_and_record_request_id(request_id: str | None) -> bool:
"""Return True if this request_id is a duplicate. Records new IDs."""
import time

if not request_id:
return False # No idempotency key — allow through

now = time.monotonic()

# Evict expired entries (keep the dict small)
expired = [k for k, t in _recent_request_ids.items() if now - t > _REQUEST_ID_TTL]
for k in expired:
_recent_request_ids.pop(k, None)

if request_id in _recent_request_ids:
return True # Duplicate

_recent_request_ids[request_id] = now
return False


@router.post("/message")
async def send_message(
body: MessageSend,
Expand All @@ -369,6 +395,10 @@ async def send_message(
):
from ..services.job_manager import USE_BACKGROUND_JOBS, get_job_manager

# Reject duplicate requests (idempotency guard)
if _check_and_record_request_id(body.request_id):
return {"ok": True, "duplicate": True}

sm = _sm(request)
event_bus = _bus(request)
job_manager = get_job_manager()
Expand Down
64 changes: 64 additions & 0 deletions backend/openmlr/routes/mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""MCP server management routes — test connections and get status."""

import logging
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession

from ..db import operations as ops
from ..db.engine import get_db
from ..db.models import User
from ..dependencies import get_current_user
from ..tools.mcp import test_mcp_connection

log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/mcp", tags=["mcp"])


class TestRequest(BaseModel):
url: str
headers: dict[str, str] | None = None
params: dict[str, str] | None = None


@router.post("/test", responses={400: {"description": "Invalid URL scheme"}})
async def test_connection(
body: TestRequest,
user: Annotated[User, Depends(get_current_user)],
):
"""Test an MCP server connection without saving it."""
if not body.url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="Only http/https URLs are supported")

result = await test_mcp_connection(
url=body.url,
headers=body.headers,
params=body.params,
)
return result


@router.get("/status")
async def get_status(
user: Annotated[User, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get configured MCP servers with their enabled/disabled state."""
user_settings = await ops.get_all_settings(db, user.id, category="mcp")
mcp_settings = user_settings.get("mcp", {})
servers_config = mcp_settings.get("servers", {})

servers = []
for name, config in servers_config.items():
servers.append(
{
"name": name,
"url": config.get("url", ""),
"enabled": config.get("enabled", True),
"connected": False, # Will be updated via SSE in real-time
}
)

return {"servers": servers}
106 changes: 72 additions & 34 deletions backend/openmlr/routes/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@
import shutil
import uuid as uuid_mod
from pathlib import Path
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from ..auth.security import decode_access_token
from ..db import operations as ops
from ..db.engine import get_db
from ..db.models import User
from ..dependencies import get_current_user
from ..dependencies import get_current_user, get_current_user_optional

router = APIRouter(prefix="/api/projects", tags=["projects"])

Expand Down Expand Up @@ -434,51 +437,86 @@
return {"path": path, "entries": entries}


@router.get("/{project_uuid}/files/{file_path:path}")
async def _resolve_user(
token: str | None,
user: User | None,
db: AsyncSession,
) -> User:
"""Resolve authenticated user from Bearer header or query-string token."""
if user is not None:
return user
if token:
payload = decode_access_token(token)
if payload:
result = await db.execute(
select(User).where(User.id == int(payload["sub"]), User.is_active == True)
)
found = result.scalar_one_or_none()
if found:
return found
raise HTTPException(status_code=401, detail="Not authenticated")


def _validate_symlink(target: Path, workspace_path: str) -> None:
"""Reject symlinks that escape the workspace."""
if not target.is_symlink():
return
try:
target.resolve().relative_to(Path(workspace_path).resolve())
except ValueError:
raise HTTPException(status_code=400, detail="Symlink points outside workspace")


def _try_read_text(target: Path, file_path: str) -> dict | None:
"""Try to read a file as text. Returns JSON dict or None for binary."""
mime, _ = mimetypes.guess_type(str(target))
is_text = (
mime is None
or mime.startswith("text/")
or mime in ("application/json", "application/xml", "application/x-yaml")
)
if not is_text:
return None
try:
content = target.read_text(encoding="utf-8", errors="replace")
if len(content) > 500_000:
content = content[:500_000] + "\n\n[... truncated at 500KB ...]"
return {"path": file_path, "content": content, "size": target.stat().st_size}
except Exception:
return None


@router.get(
"/{project_uuid}/files/{file_path:path}",
responses={401: {"description": "Not authenticated"}},
)
async def read_file(
project_uuid: str,
file_path: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
token: str | None = Query(None),

Check failure on line 496 in backend/openmlr/routes/projects.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "Annotated" type hints for FastAPI dependency injection

See more on https://sonarcloud.io/project/issues?id=xprilion_OpenMLR&issues=AZ3atPcpDPf-qUGQPvmN&open=AZ3atPcpDPf-qUGQPvmN&pullRequest=34
user: Annotated[User | None, Depends(get_current_user_optional)] = None,
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
"""Read a file from the project workspace."""
project = await ops.get_project_by_uuid(db, project_uuid, user.id)
"""Read a file from the project workspace.

Supports auth via Bearer header or ?token= query param (for <img> tags).
"""
authed_user = await _resolve_user(token, user, db)
project = await ops.get_project_by_uuid(db, project_uuid, authed_user.id)
if not project or not project.workspace_path:
raise HTTPException(status_code=404, detail="Project not found")

target = _safe_resolve(project.workspace_path, file_path)
if not target.exists():
raise HTTPException(status_code=404, detail="File not found")
if target.is_dir():
return await list_files(project_uuid, file_path, user, db)
return await list_files(project_uuid, file_path, authed_user, db)

# Reject symlinks that point outside workspace
if target.is_symlink():
try:
target.resolve().relative_to(Path(project.workspace_path).resolve())
except ValueError:
raise HTTPException(status_code=400, detail="Symlink points outside workspace")
_validate_symlink(target, project.workspace_path)

# For text files, return content as JSON
mime, _ = mimetypes.guess_type(str(target))
is_text = (
mime is None
or mime.startswith("text/")
or mime in ("application/json", "application/xml", "application/x-yaml")
)

if is_text:
try:
content = target.read_text(encoding="utf-8", errors="replace")
if len(content) > 500_000:
content = content[:500_000] + "\n\n[... truncated at 500KB ...]"
return {
"path": file_path,
"content": content,
"size": target.stat().st_size,
}
except Exception:
pass
text_response = _try_read_text(target, file_path)
if text_response is not None:
return text_response

return FileResponse(str(target), filename=target.name)

Expand Down
11 changes: 6 additions & 5 deletions backend/openmlr/services/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def __init__(self, event_bus: EventBus, default_config: AgentConfig):
self.event_bus = event_bus
self.default_config = default_config
self.current_conversation_id: int | None = None
self._is_processing: bool = False
self._processing: set[int] = set() # per-conversation processing locks
self._message_queues: dict[int, list[str]] = {}

def get_session(self, conversation_id: int) -> ActiveSession | None:
Expand Down Expand Up @@ -314,14 +314,15 @@ async def process_message(
message: str,
mode: str = None,
) -> None:
"""Queue and process a user message."""
"""Queue and process a user message (per-conversation locking)."""
queue = self._message_queues.setdefault(conversation_id, [])
queue.append((message, mode))

if self._is_processing:
# Per-conversation lock: if this conversation is already processing, just queue
if conversation_id in self._processing:
return

self._is_processing = True
self._processing.add(conversation_id)
await self.event_bus.broadcast(
AgentEvent(event_type="status", data={"status": "thinking..."})
)
Expand All @@ -340,7 +341,7 @@ async def process_message(
AgentEvent(event_type="error", data={"error": str(e)})
)
finally:
self._is_processing = False
self._processing.discard(conversation_id)
await self.event_bus.broadcast(
AgentEvent(event_type="status", data={"status": "ready"})
)
Expand Down
Loading
Loading