Compare commits

..

8 commits

Author SHA1 Message Date
Rosie Val
e33368a03c Add venv/.venv to ingestion's SKIP_DIRECTORIES
The previous fix (403b4e8) filtered doc-file selection through
should_skip_path(), assuming it matched tree_sitter_indexer.py's
exclusion behavior. It doesn't: ingestion.py's own SKIP_DIRECTORIES
never included 'venv'/'.venv' at all - only tree_sitter_indexer.py's
separate SKIP_DIRS did. Two inconsistent exclusion lists in the
codebase, and the previous fix happened to reuse the incomplete one.

Confirmed live: a selective-indexing run after the previous fix still
pulled in 61 .venv/lib/python3.11/site-packages/... files into
Ody-project_index.
2026-08-22 20:25:13 -04:00
Rosie Val
403b4e8c5f Fix selective semantic indexing pulling in .venv/node_modules garbage
_run_semantic_indexing_background and _run_semantic_indexing_sync both
select doc files with Path(project_path).rglob("*.md"/"*.txt"/"*.rst")
and no directory exclusion at all - unlike the tree-sitter code-file
selection right next to it, which already filters via
TreeSitterIndexer.SKIP_DIRS.

For a project with a .venv checked into its own directory, this pulled
in hundreds of unrelated site-packages files (LICENSE.txt, dist-info
top_level.txt, vendored library .rst docs) as "important" project
knowledge, drowning out the actual project's docs and code in every
KB built with selective indexing. Confirmed live: Ody-project_index
had .venv/lib/python3.11/site-packages/... entries making up most of
its 67 documents.

Filter with the already-existing should_skip_path() from
app.core.ingestion (same SKIP_DIRECTORIES used by ingest_directory)
instead of writing a second exclusion list.
2026-08-22 20:14:45 -04:00
Rosie Val
e374440c67 Add a service-token bypass for code-forge, gated on auth being enabled
code-forge (the stdio MCP server) calls the REST API directly and has
no login flow of its own. With auth disabled it already passes
through as guest, same as before - but the moment CLAUDE_OS_EMAIL/
PASSWORD get set, it would start getting 401'd with no way to
authenticate.

get_current_user now accepts a CLAUDE_OS_SERVICE_TOKEN as a bearer
token, checked with a constant-time compare, alongside normal user
JWTs. claude_code_mcp.py sends it (via a shared _auth_headers() used
by all 5 of its httpx.AsyncClient() call sites) whenever
CLAUDE_OS_SERVICE_TOKEN is set, and sends nothing when it isn't - a
no-op change for anyone not using auth.

Since this process can be launched directly (e.g. over SSH as a stdio
server) without claude-os's own .env sourced into its shell or its cwd
set to the project root, it now loads .env explicitly by path relative
to the script's own location rather than relying on load_dotenv()'s
cwd-relative default search.
2026-08-22 16:42:03 -04:00
Rosie Val
9298de1a49 Fix auth so it actually enforces, not just decorates
Two gaps found while wiring auth into the data routes:

1. mcp_server/auth.py: HTTPBearer() defaults to auto_error=True, which
   rejects any request with no Authorization header before
   get_current_user's own "auth not configured -> allow as guest"
   check ever runs. That meant turning on Depends(get_current_user)
   anywhere 401'd every request even with auth fully disabled.
   Switched to auto_error=False and handle the missing-credentials
   case explicitly inside get_current_user instead.

2. Frontend never actually sent the stored token on data requests.
   auth.tsx only attached it to its own /api/auth/me check; api.ts's
   fetch() calls (chat, stats, documents, upload, delete) and every
   axios call elsewhere in the app (projects, kanban, hooks) had no
   Authorization header at all. Added an authHeaders() helper in
   api.ts, and a single axios request interceptor in auth.tsx that
   attaches the token app-wide since every component imports the same
   default axios instance.

Known gap not addressed here: mcp_server/claude_code_mcp.py (the
code-forge stdio MCP server) calls the REST API with no token at all.
Harmless while auth is disabled (current state), but it will start
getting 401'd the moment CLAUDE_OS_EMAIL/PASSWORD are actually set -
needs a service-token bypass if that's ever turned on.
2026-08-22 15:46:45 -04:00
Rosie Val
d999d6af4a Require auth on all data API routes, not just /api/auth/me
get_current_user was only wired into one of 75 routes. Everything
else - chat, kb crud, uploads, projects, jobs, mcp management - had
no auth dependency at all, so setting CLAUDE_OS_EMAIL/PASSWORD only
gated the frontend's login screen while the API itself stayed fully
open to anyone who could reach it directly.

Added Depends(get_current_user) to all 72 remaining data routes,
left /api/auth/login, /api/auth/status, and /health public (has to
be reachable pre-login / for health checks). get_current_user already
returns a guest user when auth isn't configured, so this is a no-op
for anyone who hasn't set credentials.

Moved the mcp_server.auth import to the top of the file - it was
previously imported ~3250 lines down, after most of these routes'
def statements would have already tried to evaluate
Depends(get_current_user) as a default argument.
2026-08-22 15:43:16 -04:00
Rosie Val
6a23cf80d5 Fix RAG synthesis silently falling back to raw chunk dump
_query_base/_query_hybrid passed raw TextNodes to
get_response_synthesizer().synthesize(), which requires NodeWithScore
(it reads node.node internally). Every real query hit
AttributeError: 'TextNode' object has no attribute 'node', got caught
by a bare except Exception, and silently fell back to
_generate_fallback_answer - the "Based on the available
documentation: 1. From X... 2. From Y..." template users were
actually seeing instead of a real LLM-synthesized answer.

This was a pre-existing bug independent of the Groq change - Ollama
hit the same exception, it just wasn't noticed because the fallback
looks superficially like an answer.
2026-08-22 15:39:31 -04:00
Rosie Val
3512c615c9 Fix frontend API base URL for remote access, pin mcp<2.0.0
api.ts and auth.tsx hardcoded API_BASE to http://localhost:8051 with
no override, unlike every other component which already checked
VITE_API_URL first. That's fine when the browser and server are on
the same machine, but breaks auth/chat/stats/documents entirely when
accessing the UI from a different machine than the one running
claude-os - "localhost" resolves to the browser's own machine, not
the server.

Also pins mcp<2.0.0 in requirements.txt: mcp 2.0.0 removed the
low-level Server.list_tools() API that mcp_server/claude_code_mcp.py
(the code-forge stdio MCP server) depends on, so an unbounded
mcp>=1.0.0 install silently breaks that integration.
2026-08-22 15:31:10 -04:00
Rosie Val
614a109378 Add Groq as an LLM provider
Config.PROVIDER was never actually consulted by the LLM call sites -
rag_engine.py, insight_extractor.py, and knowledge_lifecycle.py all
hardcoded Ollama directly. This wires up a real "groq" provider:

- config.py: GROQ_API_KEY/GROQ_MODEL settings, is_groq_provider(),
  provider-aware get_active_llm_model() and validate_provider_config()
- rag_engine.py: uses llama_index's Groq LLM when provider is groq
- insight_extractor.py / knowledge_lifecycle.py: branch to Groq's
  OpenAI-compatible chat completions API instead of Ollama's /api/generate
- requirements.txt: add llama-index-llms-groq

Embeddings are untouched and stay on Ollama - Groq has no embeddings API.
Default model is openai/gpt-oss-120b.
2026-08-22 13:53:46 -04:00
11 changed files with 306 additions and 142 deletions

View file

@ -31,7 +31,7 @@ class Config(metaclass=ConfigMeta):
# ═══════════════════════════════════════════════════════════════════════
# PROVIDER CONFIGURATION
# ═══════════════════════════════════════════════════════════════════════
# Provider: "local" (Ollama) or "openai" (OpenAI API)
# Provider: "local" (Ollama), "openai" (OpenAI API), or "groq" (Groq API)
PROVIDER: str = os.getenv("CLAUDE_OS_PROVIDER", "local")
# ═══════════════════════════════════════════════════════════════════════
@ -49,6 +49,13 @@ class Config(metaclass=ConfigMeta):
OPENAI_EMBED_MODEL: str = os.getenv("OPENAI_EMBED_MODEL", "text-embedding-3-small")
OPENAI_EMBED_DIMENSIONS: int = 1536 # OpenAI text-embedding-3-small dimensions
# ═══════════════════════════════════════════════════════════════════════
# GROQ CONFIGURATION (for groq provider)
# ═══════════════════════════════════════════════════════════════════════
# Groq has no embeddings API - embeddings always stay on Ollama/OpenAI when using this provider.
GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "")
GROQ_MODEL: str = os.getenv("GROQ_MODEL", "openai/gpt-oss-120b")
# ═══════════════════════════════════════════════════════════════════════
# MODEL CONFIGURATION (auto-selected based on provider)
# ═══════════════════════════════════════════════════════════════════════
@ -217,9 +224,9 @@ class Config(metaclass=ConfigMeta):
@classmethod
def get_provider(cls) -> str:
"""Get the active provider (local or openai)."""
"""Get the active provider (local, openai, or groq)."""
provider = os.getenv("CLAUDE_OS_PROVIDER", "local").lower()
if provider not in ("local", "openai", "custom"):
if provider not in ("local", "openai", "groq", "custom"):
return "local"
return provider
@ -233,6 +240,11 @@ class Config(metaclass=ConfigMeta):
"""Check if using OpenAI provider."""
return cls.get_provider() == "openai"
@classmethod
def is_groq_provider(cls) -> bool:
"""Check if using Groq provider."""
return cls.get_provider() == "groq"
@classmethod
def get_active_llm_model(cls) -> str:
"""Get the active LLM model based on provider."""
@ -243,6 +255,8 @@ class Config(metaclass=ConfigMeta):
if cls.is_openai_provider():
return cls.OPENAI_LLM_MODEL
if cls.is_groq_provider():
return cls.GROQ_MODEL
return cls.OLLAMA_MODEL
@classmethod
@ -271,6 +285,12 @@ class Config(metaclass=ConfigMeta):
key = os.getenv("OPENAI_API_KEY", "")
return key if key else None
@classmethod
def get_groq_api_key(cls) -> Optional[str]:
"""Get the Groq API key if configured."""
key = os.getenv("GROQ_API_KEY", "")
return key if key else None
@classmethod
def validate_provider_config(cls) -> List[str]:
"""Validate provider-specific configuration. Returns list of errors."""
@ -281,6 +301,10 @@ class Config(metaclass=ConfigMeta):
if not cls.get_openai_api_key():
errors.append("OPENAI_API_KEY is required when using OpenAI provider")
elif provider == "groq":
if not cls.get_groq_api_key():
errors.append("GROQ_API_KEY is required when using Groq provider")
elif provider == "local":
# Could add Ollama connectivity check here
pass

View file

@ -326,6 +326,8 @@ def ingest_documents(
# Directories to always skip during ingestion
SKIP_DIRECTORIES = {
'node_modules',
'venv',
'.venv',
'.git',
'.svn',
'.hg',

View file

@ -102,9 +102,12 @@ class InsightExtractor:
# Build prompt
prompt = EXTRACTION_PROMPT.format(transcript=session_summary)
# Call Ollama
# Call the configured LLM provider
try:
insights = await self._call_ollama(prompt)
if Config.is_groq_provider():
insights = await self._call_groq(prompt)
else:
insights = await self._call_ollama(prompt)
# Filter by requested types
filtered_insights = [
@ -152,25 +155,7 @@ class InsightExtractor:
result = response.json()
response_text = result.get("response", "")
# Parse JSON response
insights_data = self._parse_llm_response(response_text)
# Convert to Insight objects
insights = []
for item in insights_data.get("insights", []):
try:
insight = Insight(
type=item.get("type", "pattern"),
title=item.get("title", "Untitled"),
content=item.get("content", ""),
confidence=float(item.get("confidence", 0.5))
)
insights.append(insight)
except (ValueError, KeyError) as e:
logger.warning(f"Failed to parse insight item: {e}")
continue
return insights
return self._insights_from_response_text(response_text)
except httpx.HTTPError as e:
logger.error(f"Ollama API error: {e}")
@ -179,6 +164,65 @@ class InsightExtractor:
logger.error(f"Unexpected error calling Ollama: {e}")
raise
async def _call_groq(self, prompt: str) -> List[Insight]:
"""
Call Groq's OpenAI-compatible chat completions API to extract insights.
Args:
prompt: Extraction prompt with session transcript
Returns:
List of Insight objects
"""
url = "https://api.groq.com/openai/v1/chat/completions"
payload = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3, # Lower temperature for more focused extraction
"max_tokens": 2048, # Allow longer responses for multiple insights
"top_p": 0.9,
"response_format": {"type": "json_object"}, # Request JSON response
}
headers = {"Authorization": f"Bearer {Config.GROQ_API_KEY}"}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(url, json=payload, headers=headers)
response.raise_for_status()
result = response.json()
response_text = result["choices"][0]["message"]["content"]
return self._insights_from_response_text(response_text)
except httpx.HTTPError as e:
logger.error(f"Groq API error: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error calling Groq: {e}")
raise
def _insights_from_response_text(self, response_text: str) -> List[Insight]:
"""Parse a raw LLM response string into Insight objects."""
insights_data = self._parse_llm_response(response_text)
insights = []
for item in insights_data.get("insights", []):
try:
insight = Insight(
type=item.get("type", "pattern"),
title=item.get("title", "Untitled"),
content=item.get("content", ""),
confidence=float(item.get("confidence", 0.5))
)
insights.append(insight)
except (ValueError, KeyError) as e:
logger.warning(f"Failed to parse insight item: {e}")
continue
return insights
def _parse_llm_response(self, response_text: str) -> Dict[str, Any]:
"""
Parse LLM response JSON.

View file

@ -343,10 +343,25 @@ Source documents:
Write the consolidated document:"""
try:
if Config.is_groq_provider():
response = requests.post(
"https://api.groq.com/openai/v1/chat/completions",
json={
"model": Config.get_active_llm_model(),
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
"max_tokens": 2048
},
headers={"Authorization": f"Bearer {Config.GROQ_API_KEY}"},
timeout=120
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
response = requests.post(
f"{Config.OLLAMA_HOST}/api/generate",
json={
"model": Config.OLLAMA_MODEL,
"model": Config.get_active_llm_model(),
"prompt": prompt,
"stream": False,
"options": {

View file

@ -15,7 +15,7 @@ from llama_index.core.tools import QueryEngineTool
from llama_index.embeddings.ollama import OllamaEmbedding
from llama_index.llms.ollama import Ollama
from llama_index.core.postprocessor import SentenceTransformerRerank, SimilarityPostprocessor
from llama_index.core.schema import TextNode
from llama_index.core.schema import TextNode, NodeWithScore
# BM25 retriever - needs to be installed separately
try:
@ -85,25 +85,33 @@ class RAGEngine:
"""
self.collection_name = collection_name
# Initialize Ollama LLM optimized for Apple M4 Pro (14 cores, 48GB RAM)
# Using native Ollama with Metal GPU acceleration
self.llm = Ollama(
model="llama3.1:latest", # 8B model runs great on M4 Pro
base_url="http://localhost:11434", # Native Ollama (not Docker)
request_timeout=120.0, # Increased to 120s for LLM generation
context_window=8192, # Larger context with 48GB RAM
num_ctx=8192, # Match context_window
temperature=0.1, # Very low for factual RAG responses
num_predict=512, # Can handle more tokens with M4 Pro
top_k=40, # Better quality sampling
top_p=0.9, # Nucleus sampling for better quality
num_thread=12, # Use most cores (leave 2 for system)
num_gpu=99, # Use all GPU layers on Apple Silicon
repeat_penalty=1.1, # Avoid repetition
num_batch=512, # Larger batch size for M4 Pro
use_mmap=True, # Memory-map model for faster loading
use_mlock=True, # Lock model in RAM (plenty available)
)
if Config.is_groq_provider():
from llama_index.llms.groq import Groq
self.llm = Groq(
model=Config.get_active_llm_model(),
api_key=Config.GROQ_API_KEY,
temperature=0.1, # Very low for factual RAG responses
)
else:
# Initialize Ollama LLM optimized for Apple M4 Pro (14 cores, 48GB RAM)
# Using native Ollama with Metal GPU acceleration
self.llm = Ollama(
model="llama3.1:latest", # 8B model runs great on M4 Pro
base_url="http://localhost:11434", # Native Ollama (not Docker)
request_timeout=120.0, # Increased to 120s for LLM generation
context_window=8192, # Larger context with 48GB RAM
num_ctx=8192, # Match context_window
temperature=0.1, # Very low for factual RAG responses
num_predict=512, # Can handle more tokens with M4 Pro
top_k=40, # Better quality sampling
top_p=0.9, # Nucleus sampling for better quality
num_thread=12, # Use most cores (leave 2 for system)
num_gpu=99, # Use all GPU layers on Apple Silicon
repeat_penalty=1.1, # Avoid repetition
num_batch=512, # Larger batch size for M4 Pro
use_mmap=True, # Memory-map model for faster loading
use_mlock=True, # Lock model in RAM (plenty available)
)
Settings.llm = self.llm
# Initialize Ollama embeddings
@ -244,7 +252,7 @@ class RAGEngine:
# Synthesize answer
synthesizer = get_response_synthesizer()
response = synthesizer.synthesize(question, nodes=fused_nodes)
response = synthesizer.synthesize(question, nodes=self._to_scored_nodes(fused_nodes))
return self._format_response(response, fused_nodes)
@ -328,7 +336,7 @@ class RAGEngine:
try:
# Synthesize answer using LLM
synthesizer = get_response_synthesizer()
response = synthesizer.synthesize(question, nodes=filtered_nodes)
response = synthesizer.synthesize(question, nodes=self._to_scored_nodes(filtered_nodes))
logger.info(f"Generated answer: {str(response)[:200]}")
@ -361,6 +369,13 @@ class RAGEngine:
answer_parts.append(f"\n\nFor complete information, see the sources below.")
return "".join(answer_parts)
def _to_scored_nodes(self, nodes: List[TextNode]) -> List[NodeWithScore]:
"""Wrap raw TextNodes as NodeWithScore, which response synthesizers require."""
return [
NodeWithScore(node=node, score=(node.metadata or {}).get("similarity_score", 0.0))
for node in nodes
]
def _format_response(self, response, source_nodes) -> Dict[str, any]:
"""Format query response for return."""
sources = []

View file

@ -1,4 +1,9 @@
const API_BASE = 'http://localhost:8051';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8051';
function authHeaders(): Record<string, string> {
const token = localStorage.getItem('auth_token');
return token ? { Authorization: `Bearer ${token}` } : {};
}
export interface ChatResponse {
response: string;
@ -30,7 +35,7 @@ export async function chat(
): Promise<ChatResponse> {
const res = await fetch(`${API_BASE}/api/kb/${kbName}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({
query,
use_hybrid: options?.useHybrid ?? false,
@ -47,7 +52,7 @@ export async function chat(
}
export async function getKBStats(kbName: string): Promise<KBStats> {
const res = await fetch(`${API_BASE}/api/kb/${kbName}/stats`);
const res = await fetch(`${API_BASE}/api/kb/${kbName}/stats`, { headers: authHeaders() });
if (!res.ok) {
throw new Error(`Failed to get KB stats: ${res.statusText}`);
}
@ -55,7 +60,7 @@ export async function getKBStats(kbName: string): Promise<KBStats> {
}
export async function listDocuments(kbName: string): Promise<Document[]> {
const res = await fetch(`${API_BASE}/api/kb/${kbName}/documents`);
const res = await fetch(`${API_BASE}/api/kb/${kbName}/documents`, { headers: authHeaders() });
if (!res.ok) {
throw new Error(`Failed to list documents: ${res.statusText}`);
}
@ -69,6 +74,7 @@ export async function uploadDocument(kbName: string, file: File): Promise<void>
const res = await fetch(`${API_BASE}/api/kb/${kbName}/upload`, {
method: 'POST',
headers: authHeaders(),
body: formData,
});
@ -80,6 +86,7 @@ export async function uploadDocument(kbName: string, file: File): Promise<void>
export async function deleteDocument(kbName: string, filename: string): Promise<void> {
const res = await fetch(`${API_BASE}/api/kb/${kbName}/documents/${encodeURIComponent(filename)}`, {
method: 'DELETE',
headers: authHeaders(),
});
if (!res.ok) {

View file

@ -1,6 +1,20 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import axios from 'axios';
const API_BASE = 'http://localhost:8051';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8051';
// Every component that talks to the API via axios imports the same default
// instance, so registering the interceptor once here attaches the stored
// token everywhere - without it, auth only ever covers the two fetch() calls
// in this file, and every axios-based request (projects, kanban, hooks,
// most of the app) goes out unauthenticated.
axios.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
interface AuthContextType {
isAuthenticated: boolean;

View file

@ -21,7 +21,12 @@ ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
# Security
security = HTTPBearer()
# auto_error=False: let get_current_user itself decide whether a missing
# token is OK (auth not configured -> guest) or a 401 (auth configured).
# The HTTPBearer default (auto_error=True) rejects any request with no
# Authorization header before get_current_user's body ever runs, which
# breaks the "auth disabled by default" behavior entirely.
security = HTTPBearer(auto_error=False)
def verify_password(plain_password: str, hashed_password: str) -> bool:
@ -101,7 +106,7 @@ def decode_access_token(token: str) -> Optional[dict]:
return None
def get_current_user(credentials: HTTPAuthorizationCredentials = Security(security)) -> dict:
def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Security(security)) -> dict:
"""
Dependency to get the current authenticated user.
Use in FastAPI routes: current_user = Depends(get_current_user)
@ -112,7 +117,23 @@ def get_current_user(credentials: HTTPAuthorizationCredentials = Security(securi
if not creds:
return {"email": "guest", "auth_disabled": True}
if credentials is None:
raise HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
# Service token: lets trusted local integrations (e.g. the code-forge
# stdio MCP server) call the API without a user login. Only relevant
# once auth is actually enabled - with no CLAUDE_OS_EMAIL set, everyone
# already gets the guest bypass above regardless of this.
service_token = os.getenv("CLAUDE_OS_SERVICE_TOKEN")
if service_token and secrets.compare_digest(token, service_token):
return {"email": "service", "service_account": True}
payload = decode_access_token(token)
if payload is None:

View file

@ -32,12 +32,27 @@ from pathlib import Path
from typing import Any
import httpx
from dotenv import load_dotenv
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
# This process is launched directly (e.g. over SSH as a stdio MCP server),
# not necessarily with claude-os's own .env sourced into its environment or
# its working directory set to the project root - so load it explicitly by
# path rather than relying on load_dotenv()'s cwd-relative default search.
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
# Configuration
API_BASE = os.environ.get("CLAUDE_OS_API", "http://localhost:8051")
SERVICE_TOKEN = os.environ.get("CLAUDE_OS_SERVICE_TOKEN")
def _auth_headers() -> dict:
"""Auth header for calling claude-os's own API. No-op unless a service
token is configured, which only matters once CLAUDE_OS_EMAIL/PASSWORD
are set - the API allows all requests through as a guest otherwise."""
return {"Authorization": f"Bearer {SERVICE_TOKEN}"} if SERVICE_TOKEN else {}
# Health cache — tracks last health check time per KB and any warnings
HEALTH_CACHE: dict[str, dict] = {}
@ -78,7 +93,7 @@ async def _maybe_check_health(kb_name: str) -> list[str]:
# Run health check
try:
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(headers=_auth_headers()) as client:
resp = await client.get(api_url(f"/api/kb/{kb_name}/lifecycle/health"), timeout=15.0)
resp.raise_for_status()
health = resp.json()
@ -109,7 +124,7 @@ def api_url(path: str) -> str:
async def api_get(path: str) -> dict:
"""Make GET request to API."""
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(headers=_auth_headers()) as client:
response = await client.get(api_url(path), timeout=30.0)
response.raise_for_status()
return response.json()
@ -117,7 +132,7 @@ async def api_get(path: str) -> dict:
async def api_post(path: str, data: dict = None) -> dict:
"""Make POST request to API."""
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(headers=_auth_headers()) as client:
response = await client.post(api_url(path), json=data or {}, timeout=60.0)
response.raise_for_status()
return response.json()
@ -125,7 +140,7 @@ async def api_post(path: str, data: dict = None) -> dict:
async def api_delete(path: str) -> dict:
"""Make DELETE request to API."""
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(headers=_auth_headers()) as client:
response = await client.delete(api_url(path), timeout=30.0)
response.raise_for_status()
return response.json()
@ -133,7 +148,7 @@ async def api_delete(path: str) -> dict:
async def api_put(path: str, data: dict = None) -> dict:
"""Make PUT request to API."""
async with httpx.AsyncClient() as client:
async with httpx.AsyncClient(headers=_auth_headers()) as client:
response = await client.put(api_url(path), json=data or {}, timeout=30.0)
response.raise_for_status()
return response.json()

View file

@ -14,6 +14,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi import FastAPI, Request, UploadFile, File, HTTPException, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from mcp_server.auth import (
authenticate_user,
create_access_token,
is_auth_enabled,
get_current_user
)
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import uvicorn
@ -564,14 +570,14 @@ class SearchAllRequest(BaseModel):
# REST API Endpoints for UI
@app.get("/api/kb")
async def api_list_knowledge_bases():
async def api_list_knowledge_bases(current_user: dict = Depends(get_current_user)):
"""REST API: List all knowledge bases."""
kbs = await list_knowledge_bases()
return {"knowledge_bases": kbs}
@app.post("/api/kb")
async def api_create_knowledge_base(request: CreateKBRequest):
async def api_create_knowledge_base(request: CreateKBRequest, current_user: dict = Depends(get_current_user)):
"""REST API: Create a new knowledge base."""
result = await create_knowledge_base(
name=request.name,
@ -585,7 +591,7 @@ async def api_create_knowledge_base(request: CreateKBRequest):
@app.post("/api/kb/search-all")
@limiter.limit("10/minute")
async def api_search_all(request: Request, body: SearchAllRequest):
async def api_search_all(request: Request, body: SearchAllRequest, current_user: dict = Depends(get_current_user)):
"""REST API: Search across multiple knowledge bases."""
try:
from llama_index.embeddings.ollama import OllamaEmbedding
@ -634,7 +640,7 @@ async def api_search_all(request: Request, body: SearchAllRequest):
@app.delete("/api/kb/{kb_name}")
async def api_delete_knowledge_base(kb_name: str):
async def api_delete_knowledge_base(kb_name: str, current_user: dict = Depends(get_current_user)):
"""REST API: Delete a knowledge base."""
try:
db_manager = get_sqlite_manager()
@ -654,7 +660,7 @@ async def api_delete_knowledge_base(kb_name: str):
@app.get("/api/kb/{kb_name}/stats")
async def api_get_kb_stats(kb_name: str):
async def api_get_kb_stats(kb_name: str, current_user: dict = Depends(get_current_user)):
"""REST API: Get knowledge base statistics."""
stats = await get_kb_stats(kb_name)
if "error" in stats:
@ -663,7 +669,7 @@ async def api_get_kb_stats(kb_name: str):
@app.get("/api/kb/{kb_name}/documents")
async def api_list_documents(kb_name: str):
async def api_list_documents(kb_name: str, current_user: dict = Depends(get_current_user)):
"""REST API: List documents in a knowledge base."""
docs = await list_documents(kb_name)
return {"documents": docs}
@ -671,7 +677,7 @@ async def api_list_documents(kb_name: str):
@app.post("/api/kb/{kb_name}/chat")
@limiter.limit("20/minute") # Rate limit: 20 queries per minute per IP
async def api_chat(request: Request, kb_name: str, chat_request: ChatRequest):
async def api_chat(request: Request, kb_name: str, chat_request: ChatRequest, current_user: dict = Depends(get_current_user)):
"""REST API: Chat with a knowledge base."""
result = await search_knowledge_base(
kb_name=kb_name,
@ -686,7 +692,7 @@ async def api_chat(request: Request, kb_name: str, chat_request: ChatRequest):
@app.post("/api/kb/{kb_name}/upload")
async def api_upload_document(kb_name: str, file: UploadFile = File(...)):
async def api_upload_document(kb_name: str, file: UploadFile = File(...), current_user: dict = Depends(get_current_user)):
"""REST API: Upload a single document to a knowledge base."""
import tempfile
import os
@ -733,7 +739,7 @@ class ContentUploadRequest(BaseModel):
@app.post("/api/kb/{kb_name}/documents/content")
async def api_upload_content(kb_name: str, request: ContentUploadRequest):
async def api_upload_content(kb_name: str, request: ContentUploadRequest, current_user: dict = Depends(get_current_user)):
"""REST API: Upload raw text content directly to a knowledge base (no file needed)."""
import tempfile
import os as _os
@ -777,7 +783,7 @@ async def api_upload_content(kb_name: str, request: ContentUploadRequest):
@app.post("/api/kb/{kb_name}/import")
async def api_import_directory(kb_name: str, directory_path: str):
async def api_import_directory(kb_name: str, directory_path: str, current_user: dict = Depends(get_current_user)):
"""REST API: Import all files from a directory into a knowledge base."""
from app.core.ingestion import ingest_directory
@ -810,7 +816,7 @@ async def api_import_directory(kb_name: str, directory_path: str):
@app.delete("/api/kb/{kb_name}/documents/{filename}")
async def api_delete_document(kb_name: str, filename: str):
async def api_delete_document(kb_name: str, filename: str, current_user: dict = Depends(get_current_user)):
"""REST API: Delete a document from a knowledge base by filename."""
db_manager = get_sqlite_manager()
@ -886,7 +892,7 @@ class SemanticIndexRequest(BaseModel):
@app.post("/api/kb/{kb_name}/index-structural")
async def api_index_structural(kb_name: str, request: StructuralIndexRequest):
async def api_index_structural(kb_name: str, request: StructuralIndexRequest, current_user: dict = Depends(get_current_user)):
"""
REST API: Fast structural indexing using tree-sitter.
Phase 1 of hybrid indexing - completes in ~30 seconds for 10k files.
@ -994,7 +1000,7 @@ def _run_semantic_indexing_background(job_id: str, kb_name: str, project_path: s
Updates INDEXING_JOBS with progress.
"""
from app.core.tree_sitter_indexer import TreeSitterIndexer
from app.core.ingestion import ingest_directory, ingest_file
from app.core.ingestion import ingest_directory, ingest_file, should_skip_path
def update_job(status: str, progress: int = 0, message: str = "", error: str = None):
with INDEXING_JOBS_LOCK:
@ -1031,7 +1037,9 @@ def _run_semantic_indexing_background(job_id: str, kb_name: str, project_path: s
docs_patterns = ["*.md", "*.txt", "*.rst"]
doc_files = []
for pattern in docs_patterns:
doc_files.extend(Path(project_path).rglob(pattern))
doc_files.extend(
f for f in Path(project_path).rglob(pattern) if not should_skip_path(f)
)
all_files = list(set(important_files + [str(f.relative_to(project_path)) for f in doc_files]))
total_files = len(all_files)
@ -1085,7 +1093,7 @@ def _run_semantic_indexing_sync(kb_name: str, project_path: str, selective: bool
Returns the final result dict.
"""
from app.core.tree_sitter_indexer import TreeSitterIndexer
from app.core.ingestion import ingest_directory, ingest_file
from app.core.ingestion import ingest_directory, ingest_file, should_skip_path
start_time = time.time()
@ -1102,7 +1110,9 @@ def _run_semantic_indexing_sync(kb_name: str, project_path: str, selective: bool
docs_patterns = ["*.md", "*.txt", "*.rst"]
doc_files = []
for pattern in docs_patterns:
doc_files.extend(Path(project_path).rglob(pattern))
doc_files.extend(
f for f in Path(project_path).rglob(pattern) if not should_skip_path(f)
)
all_files = list(set(important_files + [str(f.relative_to(project_path)) for f in doc_files]))
@ -1145,7 +1155,7 @@ def _run_semantic_indexing_sync(kb_name: str, project_path: str, selective: bool
@app.post("/api/kb/{kb_name}/index-semantic")
async def api_index_semantic(kb_name: str, request: SemanticIndexRequest, background_tasks: BackgroundTasks):
async def api_index_semantic(kb_name: str, request: SemanticIndexRequest, background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user)):
"""
REST API: Semantic indexing with selective embedding.
Phase 2 of hybrid indexing.
@ -1224,7 +1234,7 @@ async def api_index_semantic(kb_name: str, request: SemanticIndexRequest, backgr
@app.get("/api/jobs/{job_id}")
async def api_get_job_status(job_id: str):
async def api_get_job_status(job_id: str, current_user: dict = Depends(get_current_user)):
"""Get the status of a background indexing job."""
with INDEXING_JOBS_LOCK:
if job_id not in INDEXING_JOBS:
@ -1233,7 +1243,7 @@ async def api_get_job_status(job_id: str):
@app.get("/api/jobs")
async def api_list_jobs():
async def api_list_jobs(current_user: dict = Depends(get_current_user)):
"""List all indexing jobs (recent first)."""
with INDEXING_JOBS_LOCK:
jobs = list(INDEXING_JOBS.values())
@ -1248,7 +1258,7 @@ async def api_get_repo_map(
token_budget: int = 1024,
project_path: Optional[str] = None,
personalization: Optional[str] = None # JSON string
):
, current_user: dict = Depends(get_current_user)):
"""
REST API: Get compact repo map for Claude's prompt context.
Returns the most important symbols fitting within token budget.
@ -1361,7 +1371,7 @@ class ProjectDocumentIngestRequest(BaseModel):
@app.get("/api/projects")
async def api_list_projects():
async def api_list_projects(current_user: dict = Depends(get_current_user)):
"""REST API: List all projects."""
try:
db_manager = get_sqlite_manager()
@ -1373,7 +1383,7 @@ async def api_list_projects():
@app.post("/api/projects")
async def api_create_project(request: ProjectRequest):
async def api_create_project(request: ProjectRequest, current_user: dict = Depends(get_current_user)):
"""REST API: Create a new project with 4 required MCPs."""
try:
db_manager = get_sqlite_manager()
@ -1429,7 +1439,7 @@ async def api_create_project(request: ProjectRequest):
@app.get("/api/projects/{project_id}")
async def api_get_project(project_id: int):
async def api_get_project(project_id: int, current_user: dict = Depends(get_current_user)):
"""REST API: Get project details with MCP assignments."""
try:
db_manager = get_sqlite_manager()
@ -1456,7 +1466,7 @@ async def api_get_project(project_id: int):
@app.get("/api/projects/{project_id}/mcps")
async def api_get_project_mcps(project_id: int):
async def api_get_project_mcps(project_id: int, current_user: dict = Depends(get_current_user)):
"""REST API: Get detailed MCP info for a project (KB names and IDs)."""
try:
db_manager = get_sqlite_manager()
@ -1495,7 +1505,7 @@ async def api_get_project_mcps(project_id: int):
@app.post("/api/projects/{project_id}/folders")
async def api_set_kb_folder(project_id: int, request: ProjectKBFolderRequest):
async def api_set_kb_folder(project_id: int, request: ProjectKBFolderRequest, current_user: dict = Depends(get_current_user)):
"""REST API: Set the folder path for a project's MCP KB."""
try:
db_manager = get_sqlite_manager()
@ -1580,7 +1590,7 @@ async def api_set_kb_folder(project_id: int, request: ProjectKBFolderRequest):
@app.get("/api/projects/{project_id}/folders")
async def api_get_kb_folders(project_id: int):
async def api_get_kb_folders(project_id: int, current_user: dict = Depends(get_current_user)):
"""REST API: Get folder configurations for a project's MCPs."""
try:
db_manager = get_sqlite_manager()
@ -1606,7 +1616,7 @@ async def api_get_kb_folders(project_id: int):
@app.post("/api/projects/{project_id}/ingest-document")
async def api_ingest_document(project_id: int, request: ProjectDocumentIngestRequest):
async def api_ingest_document(project_id: int, request: ProjectDocumentIngestRequest, current_user: dict = Depends(get_current_user)):
"""REST API: Ingest a document directly into a project's MCP KB."""
try:
db_manager = get_sqlite_manager()
@ -1669,7 +1679,7 @@ async def api_ingest_document(project_id: int, request: ProjectDocumentIngestReq
@app.delete("/api/projects/{project_id}")
async def api_delete_project(project_id: int):
async def api_delete_project(project_id: int, current_user: dict = Depends(get_current_user)):
"""REST API: Delete a project and its associated KBs."""
try:
db_manager = get_sqlite_manager()
@ -1734,7 +1744,7 @@ class SyncRequest(BaseModel):
@app.post("/api/projects/{project_id}/hooks/{mcp_type}/enable")
async def api_enable_hook(project_id: int, mcp_type: str, request: HookRequest):
async def api_enable_hook(project_id: int, mcp_type: str, request: HookRequest, current_user: dict = Depends(get_current_user)):
"""Enable automatic KB synchronization for a folder."""
try:
hook = get_project_hook(project_id)
@ -1759,7 +1769,7 @@ async def api_enable_hook(project_id: int, mcp_type: str, request: HookRequest):
@app.post("/api/projects/{project_id}/hooks/{mcp_type}/disable")
async def api_disable_hook(project_id: int, mcp_type: str):
async def api_disable_hook(project_id: int, mcp_type: str, current_user: dict = Depends(get_current_user)):
"""Disable automatic KB synchronization."""
try:
hook = get_project_hook(project_id)
@ -1777,7 +1787,7 @@ async def api_disable_hook(project_id: int, mcp_type: str):
@app.post("/api/projects/{project_id}/hooks/sync")
async def api_sync_hooks(project_id: int, request: Optional[SyncRequest] = None):
async def api_sync_hooks(project_id: int, request: Optional[SyncRequest] = None, current_user: dict = Depends(get_current_user)):
"""Manually sync KB folders for a project."""
try:
hook = get_project_hook(project_id)
@ -1803,7 +1813,7 @@ async def api_sync_hooks(project_id: int, request: Optional[SyncRequest] = None)
@app.get("/api/projects/{project_id}/hooks")
async def api_get_hooks_status(project_id: int):
async def api_get_hooks_status(project_id: int, current_user: dict = Depends(get_current_user)):
"""Get hook status for a project."""
try:
hook = get_project_hook(project_id)
@ -1824,7 +1834,7 @@ async def api_get_hooks_status(project_id: int):
# ============================================================================
@app.post("/api/watcher/start/{project_id}")
async def api_start_watcher(project_id: int):
async def api_start_watcher(project_id: int, current_user: dict = Depends(get_current_user)):
"""Start file watcher for a project."""
try:
watcher = get_global_watcher()
@ -1842,7 +1852,7 @@ async def api_start_watcher(project_id: int):
@app.post("/api/watcher/stop/{project_id}")
async def api_stop_watcher(project_id: int):
async def api_stop_watcher(project_id: int, current_user: dict = Depends(get_current_user)):
"""Stop file watcher for a project."""
try:
watcher = get_global_watcher()
@ -1860,7 +1870,7 @@ async def api_stop_watcher(project_id: int):
@app.post("/api/watcher/restart/{project_id}")
async def api_restart_watcher(project_id: int):
async def api_restart_watcher(project_id: int, current_user: dict = Depends(get_current_user)):
"""Restart file watcher for a project."""
try:
watcher = get_global_watcher()
@ -1878,7 +1888,7 @@ async def api_restart_watcher(project_id: int):
@app.get("/api/watcher/status")
async def api_watcher_status():
async def api_watcher_status(current_user: dict = Depends(get_current_user)):
"""Get file watcher status."""
try:
watcher = get_global_watcher()
@ -1893,7 +1903,7 @@ async def api_watcher_status():
# ============================================================================
@app.post("/api/spec-watcher/start/{project_id}")
async def api_start_spec_watcher(project_id: int):
async def api_start_spec_watcher(project_id: int, current_user: dict = Depends(get_current_user)):
"""Start spec watcher for a project."""
try:
from app.core.spec_watcher import get_global_spec_watcher
@ -1918,7 +1928,7 @@ async def api_start_spec_watcher(project_id: int):
@app.post("/api/spec-watcher/stop/{project_id}")
async def api_stop_spec_watcher(project_id: int):
async def api_stop_spec_watcher(project_id: int, current_user: dict = Depends(get_current_user)):
"""Stop spec watcher for a project."""
try:
from app.core.spec_watcher import get_global_spec_watcher
@ -1937,7 +1947,7 @@ async def api_stop_spec_watcher(project_id: int):
@app.post("/api/spec-watcher/start-all")
async def api_start_all_spec_watchers():
async def api_start_all_spec_watchers(current_user: dict = Depends(get_current_user)):
"""Start spec watchers for all projects."""
try:
from app.core.spec_watcher import get_global_spec_watcher
@ -1955,7 +1965,7 @@ async def api_start_all_spec_watchers():
@app.get("/api/spec-watcher/status")
async def api_spec_watcher_status():
async def api_spec_watcher_status(current_user: dict = Depends(get_current_user)):
"""Get spec watcher status."""
try:
from app.core.spec_watcher import get_global_spec_watcher
@ -1972,7 +1982,7 @@ async def api_spec_watcher_status():
# ============================================================================
@app.get("/api/projects/{project_id}/specs")
async def api_get_project_specs(project_id: int):
async def api_get_project_specs(project_id: int, current_user: dict = Depends(get_current_user)):
"""Get all specs for a project."""
try:
manager = SpecManager()
@ -1984,7 +1994,7 @@ async def api_get_project_specs(project_id: int):
@app.get("/api/specs/{spec_id}/tasks")
async def api_get_spec_tasks(spec_id: int):
async def api_get_spec_tasks(spec_id: int, current_user: dict = Depends(get_current_user)):
"""Get all tasks for a spec."""
try:
manager = SpecManager()
@ -2001,7 +2011,7 @@ class TaskStatusUpdate(BaseModel):
@app.patch("/api/tasks/{task_id}/status")
async def api_update_task_status(task_id: int, update: TaskStatusUpdate):
async def api_update_task_status(task_id: int, update: TaskStatusUpdate, current_user: dict = Depends(get_current_user)):
"""Update a task's status."""
try:
manager = SpecManager()
@ -2023,7 +2033,7 @@ async def api_update_task_status(task_id: int, update: TaskStatusUpdate):
@app.post("/api/projects/{project_id}/specs/sync")
async def api_sync_project_specs(project_id: int):
async def api_sync_project_specs(project_id: int, current_user: dict = Depends(get_current_user)):
"""Sync specs from project's agent-os folder."""
try:
# Get project path
@ -2049,7 +2059,7 @@ async def api_sync_project_specs(project_id: int):
@app.get("/api/projects/{project_id}/kanban")
async def api_get_kanban_view(project_id: int, include_archived: bool = False):
async def api_get_kanban_view(project_id: int, include_archived: bool = False, current_user: dict = Depends(get_current_user)):
"""Get Kanban board view for a project."""
try:
manager = SpecManager()
@ -2100,7 +2110,7 @@ async def api_get_kanban_view(project_id: int, include_archived: bool = False):
@app.post("/api/specs/{spec_id}/archive")
async def api_archive_spec(spec_id: int):
async def api_archive_spec(spec_id: int, current_user: dict = Depends(get_current_user)):
"""Archive a spec."""
try:
manager = SpecManager()
@ -2112,7 +2122,7 @@ async def api_archive_spec(spec_id: int):
@app.post("/api/specs/{spec_id}/unarchive")
async def api_unarchive_spec(spec_id: int):
async def api_unarchive_spec(spec_id: int, current_user: dict = Depends(get_current_user)):
"""Unarchive a spec."""
try:
manager = SpecManager()
@ -2147,7 +2157,7 @@ class SessionExtractRequest(BaseModel):
@app.get("/api/sessions/list")
async def api_list_sessions(project_path: str, limit: int = 50):
async def api_list_sessions(project_path: str, limit: int = 50, current_user: dict = Depends(get_current_user)):
"""
REST API: List Claude Code session files for a project.
@ -2177,7 +2187,7 @@ async def api_list_sessions(project_path: str, limit: int = 50):
@app.post("/api/sessions/extract")
async def api_extract_session_insights(request: SessionExtractRequest):
async def api_extract_session_insights(request: SessionExtractRequest, current_user: dict = Depends(get_current_user)):
"""
REST API: Extract insights from a Claude Code session file.
@ -2325,7 +2335,7 @@ class SkillInstallRequest(BaseModel):
@app.get("/api/skills")
async def api_list_skills(project_path: Optional[str] = None, include_content: bool = False):
async def api_list_skills(project_path: Optional[str] = None, include_content: bool = False, current_user: dict = Depends(get_current_user)):
"""
REST API: List all skills (global and project).
@ -2352,7 +2362,7 @@ async def api_list_skills(project_path: Optional[str] = None, include_content: b
@app.get("/api/skills/templates")
async def api_list_skill_templates(category: Optional[str] = None):
async def api_list_skill_templates(category: Optional[str] = None, current_user: dict = Depends(get_current_user)):
"""
REST API: List available skill templates.
@ -2378,7 +2388,7 @@ async def api_list_skill_templates(category: Optional[str] = None):
@app.get("/api/skills/{scope}/{name}")
async def api_get_skill(scope: str, name: str, project_path: Optional[str] = None):
async def api_get_skill(scope: str, name: str, project_path: Optional[str] = None, current_user: dict = Depends(get_current_user)):
"""
REST API: Get skill details including content.
@ -2412,7 +2422,7 @@ async def api_get_skill(scope: str, name: str, project_path: Optional[str] = Non
@app.post("/api/skills/install")
async def api_install_skill_template(request: SkillInstallRequest, project_path: str):
async def api_install_skill_template(request: SkillInstallRequest, project_path: str, current_user: dict = Depends(get_current_user)):
"""
REST API: Install a skill template to a project.
@ -2446,7 +2456,7 @@ async def api_install_skill_template(request: SkillInstallRequest, project_path:
@app.post("/api/skills")
async def api_create_skill(request: SkillCreateRequest, project_path: str):
async def api_create_skill(request: SkillCreateRequest, project_path: str, current_user: dict = Depends(get_current_user)):
"""
REST API: Create a custom skill for a project.
@ -2483,7 +2493,7 @@ async def api_create_skill(request: SkillCreateRequest, project_path: str):
@app.put("/api/skills/{name}")
async def api_update_skill(name: str, request: SkillUpdateRequest, project_path: str):
async def api_update_skill(name: str, request: SkillUpdateRequest, project_path: str, current_user: dict = Depends(get_current_user)):
"""
REST API: Update an existing project skill.
@ -2520,7 +2530,7 @@ async def api_update_skill(name: str, request: SkillUpdateRequest, project_path:
@app.delete("/api/skills/{name}")
async def api_delete_skill(name: str, project_path: str):
async def api_delete_skill(name: str, project_path: str, current_user: dict = Depends(get_current_user)):
"""
REST API: Delete a project skill.
@ -2562,7 +2572,7 @@ class CommunitySkillInstallRequest(BaseModel):
@app.get("/api/skills/community/sources")
async def api_list_community_sources():
async def api_list_community_sources(current_user: dict = Depends(get_current_user)):
"""
REST API: List available community skill sources.
@ -2582,7 +2592,7 @@ async def api_list_community_sources():
@app.get("/api/skills/community")
async def api_list_community_skills(source: Optional[str] = None):
async def api_list_community_skills(source: Optional[str] = None, current_user: dict = Depends(get_current_user)):
"""
REST API: List skills from community repositories.
@ -2620,7 +2630,7 @@ async def api_list_community_skills(source: Optional[str] = None):
async def api_install_community_skill(
request: CommunitySkillInstallRequest,
project_path: str
):
, current_user: dict = Depends(get_current_user)):
"""
REST API: Install a community skill to a project.
@ -2665,7 +2675,7 @@ async def api_install_community_skill(
# ============================================================================
@app.get("/api/ollama/models")
async def api_list_ollama_models():
async def api_list_ollama_models(current_user: dict = Depends(get_current_user)):
"""REST API: List available Ollama models."""
try:
response = requests.get(f"{Config.OLLAMA_HOST}/api/tags", timeout=2)
@ -2767,7 +2777,7 @@ def check_port_listening(port: int) -> bool:
@app.get("/api/services/status")
async def api_get_services_status():
async def api_get_services_status(current_user: dict = Depends(get_current_user)):
"""
Get status of all Claude OS services.
@ -2905,7 +2915,7 @@ async def api_get_services_status():
@app.post("/api/services/{service_type}/control")
async def api_control_service(service_type: str, request: ServiceControlRequest):
async def api_control_service(service_type: str, request: ServiceControlRequest, current_user: dict = Depends(get_current_user)):
"""
Control a service (start/stop/restart).
@ -2966,7 +2976,7 @@ async def api_control_service(service_type: str, request: ServiceControlRequest)
@app.get("/api/browse-directory")
async def api_browse_directory(path: str = None):
async def api_browse_directory(path: str = None, current_user: dict = Depends(get_current_user)):
"""REST API: Browse directories and return subdirectories."""
try:
from pathlib import Path
@ -3052,19 +3062,19 @@ async def api_browse_directory(path: str = None):
# MCP Protocol Endpoints
@app.post("/")
async def mcp_root(request: Request):
async def mcp_root(request: Request, current_user: dict = Depends(get_current_user)):
"""Main MCP endpoint - handles JSON-RPC requests."""
return await handle_mcp_request(request)
@app.post("/mcp")
async def mcp_endpoint(request: Request):
async def mcp_endpoint(request: Request, current_user: dict = Depends(get_current_user)):
"""Global MCP endpoint - exposes all KBs."""
return await handle_mcp_request(request, kb_filter=None)
@app.post("/mcp/kb/{kb_slug}")
async def mcp_kb_endpoint(kb_slug: str, request: Request):
async def mcp_kb_endpoint(kb_slug: str, request: Request, current_user: dict = Depends(get_current_user)):
"""KB-specific MCP endpoint - only exposes tools for this KB."""
# Get KB name from slug
db_manager = get_sqlite_manager()
@ -3263,12 +3273,6 @@ async def handle_mcp_request(request: Request, kb_filter: Optional[str] = None)
# Authentication Endpoints
# ============================================================================
from mcp_server.auth import (
authenticate_user,
create_access_token,
is_auth_enabled,
get_current_user
)
from datetime import timedelta
@ -3489,7 +3493,7 @@ def _run_consolidation_background(job_id: str, kb_name: str, doc_ids: list, new_
@app.post("/api/kb/{kb_name}/lifecycle/dedup-scan")
async def api_lifecycle_dedup_scan(kb_name: str, request: DedupScanRequest, background_tasks: BackgroundTasks):
async def api_lifecycle_dedup_scan(kb_name: str, request: DedupScanRequest, background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user)):
"""Scan KB for duplicate/near-duplicate documents."""
import uuid
@ -3520,7 +3524,7 @@ async def api_lifecycle_dedup_scan(kb_name: str, request: DedupScanRequest, back
@app.post("/api/kb/{kb_name}/lifecycle/dedup-merge")
async def api_lifecycle_dedup_merge(kb_name: str, request: DedupMergeRequest):
async def api_lifecycle_dedup_merge(kb_name: str, request: DedupMergeRequest, current_user: dict = Depends(get_current_user)):
"""Merge duplicate documents by keeping one and removing the rest."""
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):
@ -3532,7 +3536,7 @@ async def api_lifecycle_dedup_merge(kb_name: str, request: DedupMergeRequest):
@app.post("/api/kb/{kb_name}/lifecycle/consolidate")
async def api_lifecycle_consolidate(kb_name: str, request: ConsolidateRequest, background_tasks: BackgroundTasks):
async def api_lifecycle_consolidate(kb_name: str, request: ConsolidateRequest, background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user)):
"""Consolidate related documents into a single merged document using LLM."""
import uuid
@ -3559,7 +3563,7 @@ async def api_lifecycle_consolidate(kb_name: str, request: ConsolidateRequest, b
@app.get("/api/kb/{kb_name}/lifecycle/health")
async def api_lifecycle_health(kb_name: str):
async def api_lifecycle_health(kb_name: str, current_user: dict = Depends(get_current_user)):
"""Get comprehensive health report for a knowledge base."""
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):
@ -3570,7 +3574,7 @@ async def api_lifecycle_health(kb_name: str):
@app.get("/api/kb/{kb_name}/lifecycle/growth")
async def api_lifecycle_growth(kb_name: str, granularity: str = "month"):
async def api_lifecycle_growth(kb_name: str, granularity: str = "month", current_user: dict = Depends(get_current_user)):
"""Get document growth timeline for a knowledge base."""
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):
@ -3581,7 +3585,7 @@ async def api_lifecycle_growth(kb_name: str, granularity: str = "month"):
@app.post("/api/kb/{kb_name}/lifecycle/archive")
async def api_lifecycle_archive(kb_name: str, request: ArchiveRequest):
async def api_lifecycle_archive(kb_name: str, request: ArchiveRequest, current_user: dict = Depends(get_current_user)):
"""Archive documents in a knowledge base."""
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):
@ -3592,7 +3596,7 @@ async def api_lifecycle_archive(kb_name: str, request: ArchiveRequest):
@app.post("/api/kb/{kb_name}/lifecycle/restore")
async def api_lifecycle_restore(kb_name: str, request: RestoreRequest):
async def api_lifecycle_restore(kb_name: str, request: RestoreRequest, current_user: dict = Depends(get_current_user)):
"""Restore archived documents in a knowledge base."""
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):
@ -3603,7 +3607,7 @@ async def api_lifecycle_restore(kb_name: str, request: RestoreRequest):
@app.get("/api/kb/{kb_name}/lifecycle/archived")
async def api_lifecycle_archived(kb_name: str):
async def api_lifecycle_archived(kb_name: str, current_user: dict = Depends(get_current_user)):
"""List archived documents in a knowledge base."""
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):
@ -3614,7 +3618,7 @@ async def api_lifecycle_archived(kb_name: str):
@app.get("/api/kb/{kb_name}/lifecycle/stale")
async def api_lifecycle_stale(kb_name: str, stale_days: int = 90):
async def api_lifecycle_stale(kb_name: str, stale_days: int = 90, current_user: dict = Depends(get_current_user)):
"""Find stale documents older than the specified threshold."""
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):
@ -3625,7 +3629,7 @@ async def api_lifecycle_stale(kb_name: str, stale_days: int = 90):
@app.get("/api/kb/{kb_name}/lifecycle/logs")
async def api_lifecycle_logs(kb_name: str, operation_type: Optional[str] = None, limit: int = 50):
async def api_lifecycle_logs(kb_name: str, operation_type: Optional[str] = None, limit: int = 50, current_user: dict = Depends(get_current_user)):
"""Get lifecycle operation logs for a knowledge base."""
db_manager = get_sqlite_manager()
if not db_manager.collection_exists(kb_name):

View file

@ -2,6 +2,7 @@
llama-index>=0.10.0
llama-index-llms-ollama>=0.1.0
llama-index-embeddings-ollama>=0.1.0
llama-index-llms-groq>=0.5.0
# Vector Database (SQLite-based)
sqlite-vec>=0.1.0
@ -25,7 +26,9 @@ sentence-transformers>=2.2.0
rank-bm25>=0.2.2
# MCP Server
mcp>=1.0.0
# Pinned <2.0.0: claude_code_mcp.py uses the low-level Server API
# (@server.list_tools() etc.) which mcp 2.0.0 removed/renamed.
mcp>=1.0.0,<2.0.0
fastapi>=0.104.0
uvicorn>=0.24.0
pydantic>=2.0.0