91e75e620b
CI: cua-driver distro-compat matrix / Resolve release version (push) Waiting to run
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / Distro compat summary (push) Blocked by required conditions
CI: Nix Linux Rust source / Nix / compositor build (push) Waiting to run
CI: Nix Linux Rust source / Nix / driver package (push) Waiting to run
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Waiting to run
CI: Rust Linux unit / Rust Linux unit and compile (push) Waiting to run
CI: Rust Windows unit / Rust Windows unit and compile (push) Waiting to run
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Waiting to run
CD: Docs MCP Server / build (linux/amd64) (push) Waiting to run
CD: Docs MCP Server / build (linux/arm64) (push) Waiting to run
CD: Docs MCP Server / merge (push) Blocked by required conditions
522 lines
17 KiB
Python
522 lines
17 KiB
Python
"""
|
|
CUA Documentation and Code MCP Server
|
|
|
|
A standalone MCP server that provides read-only query access to:
|
|
1. CUA documentation (crawled from cua.ai/docs)
|
|
2. Versioned source code indexed across git tags
|
|
|
|
This server is designed to run as a containerized service, with databases
|
|
mounted from external volumes or cloud storage.
|
|
|
|
Usage:
|
|
# Run the server
|
|
python main.py
|
|
|
|
# Or with uvicorn
|
|
uvicorn main:app --host 0.0.0.0 --port 8000
|
|
"""
|
|
|
|
import os
|
|
import sqlite3
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import lancedb
|
|
from fastmcp import FastMCP
|
|
from lancedb.embeddings import get_registry
|
|
from starlette.middleware import Middleware
|
|
from starlette.middleware.cors import CORSMiddleware
|
|
|
|
# Configuration from environment variables
|
|
OTEL_ENDPOINT = os.environ.get("OTEL_ENDPOINT", "https://otel.cua.ai")
|
|
OTEL_SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME", "cua-docs-mcp")
|
|
|
|
# Database paths (configurable via environment)
|
|
DOCS_DB_PATH = os.environ.get("DOCS_DB_PATH", "/data/docs_db")
|
|
CODE_DB_PATH = os.environ.get("CODE_DB_PATH", "/data/code_db")
|
|
|
|
# Initialize OpenTelemetry for metrics and tracing
|
|
_tracer = None
|
|
_meter = None
|
|
_request_counter = None
|
|
_request_duration = None
|
|
|
|
|
|
def init_telemetry():
|
|
"""Initialize OpenTelemetry for metrics and tracing."""
|
|
global _tracer, _meter, _request_counter, _request_duration
|
|
|
|
try:
|
|
from opentelemetry import metrics, trace
|
|
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
|
|
OTLPMetricExporter,
|
|
)
|
|
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
OTLPSpanExporter,
|
|
)
|
|
from opentelemetry.sdk.metrics import MeterProvider
|
|
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
|
from opentelemetry.sdk.resources import Resource
|
|
from opentelemetry.sdk.trace import TracerProvider
|
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
|
|
resource = Resource.create(
|
|
{
|
|
"service.name": OTEL_SERVICE_NAME,
|
|
"service.version": "1.0.0",
|
|
}
|
|
)
|
|
|
|
# Set up tracing
|
|
trace_exporter = OTLPSpanExporter(endpoint=f"{OTEL_ENDPOINT}/v1/traces")
|
|
tracer_provider = TracerProvider(resource=resource)
|
|
tracer_provider.add_span_processor(BatchSpanProcessor(trace_exporter))
|
|
trace.set_tracer_provider(tracer_provider)
|
|
_tracer = trace.get_tracer(OTEL_SERVICE_NAME)
|
|
|
|
# Set up metrics
|
|
metric_exporter = OTLPMetricExporter(endpoint=f"{OTEL_ENDPOINT}/v1/metrics")
|
|
metric_reader = PeriodicExportingMetricReader(metric_exporter, export_interval_millis=60000)
|
|
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
|
metrics.set_meter_provider(meter_provider)
|
|
_meter = metrics.get_meter(OTEL_SERVICE_NAME)
|
|
|
|
# Create metrics instruments
|
|
_request_counter = _meter.create_counter(
|
|
name="mcp_requests_total",
|
|
description="Total number of MCP tool requests",
|
|
unit="1",
|
|
)
|
|
_request_duration = _meter.create_histogram(
|
|
name="mcp_request_duration_seconds",
|
|
description="Duration of MCP tool requests in seconds",
|
|
unit="s",
|
|
)
|
|
|
|
print(f"OpenTelemetry initialized with endpoint: {OTEL_ENDPOINT}")
|
|
except ImportError as e:
|
|
print(f"OpenTelemetry packages not available: {e}")
|
|
except Exception as e:
|
|
print(f"Failed to initialize OpenTelemetry: {e}")
|
|
|
|
|
|
def record_request(tool_name: str, duration: float, status: str = "success"):
|
|
"""Record metrics for a tool request."""
|
|
if _request_counter is not None:
|
|
_request_counter.add(1, {"tool": tool_name, "status": status})
|
|
if _request_duration is not None:
|
|
_request_duration.record(duration, {"tool": tool_name, "status": status})
|
|
|
|
|
|
# Initialize telemetry
|
|
init_telemetry()
|
|
|
|
# Initialize the MCP server
|
|
mcp = FastMCP(
|
|
name="CUA Docs & Code",
|
|
instructions="""CUA Documentation and Code Server - provides direct read-only query access to Computer Use Agent (CUA) documentation and versioned source code.
|
|
|
|
=== AVAILABLE TOOLS ===
|
|
|
|
Documentation:
|
|
- query_docs_db: Execute SQL queries against the documentation SQLite database
|
|
- query_docs_vectors: Execute vector similarity searches against the documentation LanceDB
|
|
|
|
Code:
|
|
- query_code_db: Execute SQL queries against the code search SQLite database
|
|
- query_code_vectors: Execute vector similarity searches against the code LanceDB
|
|
|
|
All tools are READ-ONLY. Only SELECT queries are allowed for SQL databases.
|
|
|
|
=== DOCUMENTATION DATABASE ===
|
|
|
|
The documentation database contains crawled pages from cua.ai/docs covering:
|
|
- CUA SDK: Python library for building computer-use agents
|
|
- CUA Bench: Benchmarking framework for evaluating computer-use agents
|
|
- Agent Loop: Core execution loop for autonomous agent operation
|
|
- Sandboxes: Docker and cloud VM environments for safe agent execution
|
|
- Computer interfaces: Screen, mouse, keyboard, and bash interaction APIs
|
|
|
|
=== CODE DATABASE ===
|
|
|
|
The code database contains versioned source code indexed across all git tags.
|
|
Components include: agent, computer, mcp-server, som, etc.
|
|
|
|
=== WORKFLOW EXAMPLES ===
|
|
|
|
1. Find documentation about a topic:
|
|
- Use query_docs_vectors with a natural language query for semantic search
|
|
- Use query_docs_db with FTS5 MATCH for keyword search
|
|
|
|
2. Explore code across versions:
|
|
- List components: SELECT component, COUNT(DISTINCT version) FROM code_files GROUP BY component
|
|
- Search code: Use query_code_db with FTS5 on code_files_fts
|
|
- Get file content: SELECT content FROM code_files WHERE component='agent' AND version='0.7.3' AND file_path='...'
|
|
|
|
3. Semantic code search:
|
|
- Use query_code_vectors with natural language queries like "screenshot capture implementation"
|
|
|
|
IMPORTANT: Always cite sources - URLs for docs, component@version:path for code.""",
|
|
)
|
|
|
|
# Initialize embedding model - load eagerly to avoid cold start on first search
|
|
print("Initializing embedding model...")
|
|
model = get_registry().get("sentence-transformers").create(name="all-MiniLM-L6-v2")
|
|
|
|
# Eagerly initialize database connections at startup to reduce first-request latency
|
|
print("Initializing database connections...")
|
|
|
|
# Docs LanceDB
|
|
_docs_lance_db = None
|
|
_docs_lance_table = None
|
|
docs_db_path = Path(DOCS_DB_PATH)
|
|
if docs_db_path.exists():
|
|
try:
|
|
_docs_lance_db = lancedb.connect(docs_db_path)
|
|
_docs_lance_table = _docs_lance_db.open_table("docs")
|
|
print(f" Docs LanceDB loaded from {docs_db_path}")
|
|
except Exception as e:
|
|
print(f" Warning: Could not load docs LanceDB: {e}")
|
|
|
|
# Docs SQLite
|
|
_docs_sqlite_conn = None
|
|
sqlite_path = Path(DOCS_DB_PATH) / "docs.sqlite"
|
|
if sqlite_path.exists():
|
|
try:
|
|
_docs_sqlite_conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
|
|
_docs_sqlite_conn.row_factory = sqlite3.Row
|
|
print(f" Docs SQLite loaded from {sqlite_path}")
|
|
except Exception as e:
|
|
print(f" Warning: Could not load docs SQLite: {e}")
|
|
|
|
# Code LanceDB
|
|
_code_lance_db = None
|
|
_code_lance_table = None
|
|
code_lance_path = Path(CODE_DB_PATH) / "code_index.lancedb"
|
|
if code_lance_path.exists():
|
|
try:
|
|
_code_lance_db = lancedb.connect(code_lance_path)
|
|
_code_lance_table = _code_lance_db.open_table("code")
|
|
print(f" Code LanceDB loaded from {code_lance_path}")
|
|
except Exception as e:
|
|
print(f" Warning: Could not load code LanceDB: {e}")
|
|
|
|
# Code SQLite
|
|
_code_sqlite_conn = None
|
|
code_sqlite_path = Path(CODE_DB_PATH) / "code_index.sqlite"
|
|
if code_sqlite_path.exists():
|
|
try:
|
|
_code_sqlite_conn = sqlite3.connect(f"file:{code_sqlite_path}?mode=ro", uri=True)
|
|
_code_sqlite_conn.row_factory = sqlite3.Row
|
|
print(f" Code SQLite loaded from {code_sqlite_path}")
|
|
except Exception as e:
|
|
print(f" Warning: Could not load code SQLite: {e}")
|
|
|
|
print("Database initialization complete.")
|
|
|
|
|
|
def get_lance_table():
|
|
"""Get LanceDB connection for docs (eagerly loaded)"""
|
|
if _docs_lance_table is None:
|
|
raise RuntimeError(
|
|
"Database not found. Ensure the docs database is mounted at DOCS_DB_PATH."
|
|
)
|
|
return _docs_lance_table
|
|
|
|
|
|
def get_sqlite_conn():
|
|
"""Get read-only SQLite connection for docs (eagerly loaded)"""
|
|
if _docs_sqlite_conn is None:
|
|
raise RuntimeError(
|
|
"SQLite database not found. Ensure docs.sqlite is present in DOCS_DB_PATH."
|
|
)
|
|
return _docs_sqlite_conn
|
|
|
|
|
|
def get_code_lance_table():
|
|
"""Get LanceDB connection for the aggregated code database (eagerly loaded)."""
|
|
if _code_lance_table is None:
|
|
raise RuntimeError(
|
|
"Code LanceDB not found. Ensure code_index.lancedb is present in CODE_DB_PATH."
|
|
)
|
|
return _code_lance_table
|
|
|
|
|
|
def get_code_sqlite_conn():
|
|
"""Get read-only SQLite connection for the aggregated code database (eagerly loaded)."""
|
|
if _code_sqlite_conn is None:
|
|
raise RuntimeError(
|
|
"Code SQLite database not found. Ensure code_index.sqlite is present in CODE_DB_PATH."
|
|
)
|
|
return _code_sqlite_conn
|
|
|
|
|
|
# =================== DOCUMENTATION QUERY TOOLS (READ-ONLY) ===================
|
|
|
|
|
|
@mcp.tool()
|
|
def query_docs_db(sql: str) -> list[dict]:
|
|
"""
|
|
Execute a SQL query against the documentation database.
|
|
The database is READ-ONLY.
|
|
|
|
Database Schema:
|
|
|
|
Table: pages
|
|
- id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
- url TEXT NOT NULL UNIQUE -- Full URL of the documentation page
|
|
- title TEXT NOT NULL -- Page title
|
|
- category TEXT NOT NULL -- Category (e.g., 'cua', 'cuabench', 'llms.txt')
|
|
- content TEXT NOT NULL -- Plain text content (markdown stripped)
|
|
|
|
Virtual Table: pages_fts (FTS5 full-text search)
|
|
- content TEXT -- Full-text indexed content
|
|
- url TEXT UNINDEXED
|
|
- title TEXT UNINDEXED
|
|
- category TEXT UNINDEXED
|
|
|
|
Example queries:
|
|
|
|
1. List all pages: SELECT url, title, category FROM pages ORDER BY category, title
|
|
|
|
2. Full-text search with snippets:
|
|
SELECT p.url, p.title, snippet(pages_fts, 0, '>>>', '<<<', '...', 64) as snippet
|
|
FROM pages_fts JOIN pages p ON pages_fts.rowid = p.id
|
|
WHERE pages_fts MATCH 'agent loop' ORDER BY rank LIMIT 10
|
|
|
|
3. Get page content: SELECT url, title, content FROM pages WHERE url LIKE '%quickstart%'
|
|
|
|
Args:
|
|
sql: SQL query to execute
|
|
|
|
Returns:
|
|
List of dictionaries, one per row, with column names as keys
|
|
"""
|
|
start_time = time.perf_counter()
|
|
status = "success"
|
|
try:
|
|
conn = get_sqlite_conn()
|
|
cursor = conn.cursor()
|
|
cursor.execute(sql)
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
except Exception:
|
|
status = "error"
|
|
raise
|
|
finally:
|
|
record_request("query_docs_db", time.perf_counter() - start_time, status)
|
|
|
|
|
|
@mcp.tool()
|
|
def query_docs_vectors(
|
|
query: str,
|
|
limit: int = 10,
|
|
where: Optional[str] = None,
|
|
select: Optional[list[str]] = None,
|
|
) -> list[dict]:
|
|
"""
|
|
Execute a vector similarity search against the documentation LanceDB (read-only).
|
|
|
|
Schema:
|
|
- text TEXT -- The document chunk text
|
|
- vector VECTOR -- Embedding vector (all-MiniLM-L6-v2, 384 dimensions)
|
|
- url TEXT -- Source URL
|
|
- title TEXT -- Document title
|
|
- category TEXT -- Category (e.g., 'cua', 'cuabench')
|
|
- chunk_index INT -- Index of chunk within document
|
|
|
|
Args:
|
|
query: Natural language query to embed and search for
|
|
limit: Maximum number of results (default: 10, max: 100)
|
|
where: Optional SQL-like filter (e.g., "category = 'cua'")
|
|
select: Optional list of columns to return (default: all except vector)
|
|
|
|
Returns:
|
|
List of matching documents with similarity scores (_distance field)
|
|
"""
|
|
start_time = time.perf_counter()
|
|
status = "success"
|
|
try:
|
|
limit = min(max(1, limit), 100)
|
|
table = get_lance_table()
|
|
|
|
search = table.search(query).limit(limit)
|
|
|
|
if where:
|
|
search = search.where(where)
|
|
if select:
|
|
search = search.select(select)
|
|
|
|
results = search.to_list()
|
|
|
|
formatted = []
|
|
for r in results:
|
|
result = {}
|
|
for key, value in r.items():
|
|
if key == "vector":
|
|
continue
|
|
result[key] = value
|
|
formatted.append(result)
|
|
|
|
return formatted
|
|
except Exception:
|
|
status = "error"
|
|
raise
|
|
finally:
|
|
record_request("query_docs_vectors", time.perf_counter() - start_time, status)
|
|
|
|
|
|
# =================== CODE QUERY TOOLS (READ-ONLY) ===================
|
|
|
|
|
|
@mcp.tool()
|
|
def query_code_db(sql: str) -> list[dict]:
|
|
"""
|
|
Execute a SQL query against the code search database.
|
|
The database is READ-ONLY.
|
|
|
|
Database Schema:
|
|
|
|
Table: code_files
|
|
- id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
- component TEXT NOT NULL -- Component name (e.g., "agent", "computer")
|
|
- version TEXT NOT NULL -- Version string (e.g., "0.7.3")
|
|
- file_path TEXT NOT NULL -- Path to file
|
|
- content TEXT NOT NULL -- Full source code content
|
|
- language TEXT NOT NULL -- Programming language
|
|
- UNIQUE(component, version, file_path)
|
|
|
|
Virtual Table: code_files_fts (FTS5 full-text search)
|
|
- content TEXT -- Full-text indexed content
|
|
- component TEXT UNINDEXED
|
|
- version TEXT UNINDEXED
|
|
- file_path TEXT UNINDEXED
|
|
|
|
Example queries:
|
|
|
|
1. List components: SELECT component, COUNT(DISTINCT version) as version_count
|
|
FROM code_files GROUP BY component ORDER BY component
|
|
|
|
2. List versions: SELECT DISTINCT version FROM code_files
|
|
WHERE component = 'agent' ORDER BY version DESC
|
|
|
|
3. Full-text search:
|
|
SELECT f.component, f.version, f.file_path,
|
|
snippet(code_files_fts, 0, '>>>', '<<<', '...', 64) as snippet
|
|
FROM code_files_fts JOIN code_files f ON code_files_fts.rowid = f.id
|
|
WHERE code_files_fts MATCH 'ComputerAgent' ORDER BY rank LIMIT 10
|
|
|
|
4. Get file content: SELECT content, language FROM code_files
|
|
WHERE component = 'agent' AND version = '0.7.3' AND file_path = 'agent/core.py'
|
|
|
|
Args:
|
|
sql: SQL query to execute
|
|
|
|
Returns:
|
|
List of dictionaries, one per row, with column names as keys
|
|
"""
|
|
start_time = time.perf_counter()
|
|
status = "success"
|
|
try:
|
|
conn = get_code_sqlite_conn()
|
|
cursor = conn.cursor()
|
|
cursor.execute(sql)
|
|
return [dict(row) for row in cursor.fetchall()]
|
|
except Exception:
|
|
status = "error"
|
|
raise
|
|
finally:
|
|
record_request("query_code_db", time.perf_counter() - start_time, status)
|
|
|
|
|
|
@mcp.tool()
|
|
def query_code_vectors(
|
|
query: str,
|
|
limit: int = 10,
|
|
where: Optional[str] = None,
|
|
select: Optional[list[str]] = None,
|
|
component: Optional[str] = None,
|
|
) -> list[dict]:
|
|
"""
|
|
Execute a vector similarity search against the code LanceDB (read-only).
|
|
|
|
Schema:
|
|
- text TEXT -- The source code content
|
|
- vector VECTOR -- Embedding vector (all-MiniLM-L6-v2, 384 dimensions)
|
|
- component TEXT -- Component name (e.g., "agent", "computer")
|
|
- version TEXT -- Version string (e.g., "0.7.3")
|
|
- file_path TEXT -- Path to file within the component
|
|
- language TEXT -- Programming language
|
|
|
|
Args:
|
|
query: Natural language query to embed and search for
|
|
limit: Maximum number of results (default: 10, max: 100)
|
|
where: Optional SQL-like filter (e.g., "version = '0.7.3'")
|
|
select: Optional list of columns to return (default: all except vector)
|
|
component: Optional component to filter by (if not specified, searches all)
|
|
|
|
Returns:
|
|
List of matching code files with similarity scores (_distance field)
|
|
"""
|
|
start_time = time.perf_counter()
|
|
status = "success"
|
|
try:
|
|
limit = min(max(1, limit), 100)
|
|
table = get_code_lance_table()
|
|
|
|
search = table.search(query).limit(limit)
|
|
|
|
# Build where clause, adding component filter if specified
|
|
where_clauses = []
|
|
if component:
|
|
where_clauses.append(f"component = '{component}'")
|
|
if where:
|
|
where_clauses.append(where)
|
|
|
|
if where_clauses:
|
|
search = search.where(" AND ".join(where_clauses))
|
|
if select:
|
|
search = search.select(select)
|
|
|
|
results = search.to_list()
|
|
|
|
formatted = []
|
|
for r in results:
|
|
result = {}
|
|
for key, value in r.items():
|
|
if key == "vector":
|
|
continue
|
|
result[key] = value
|
|
formatted.append(result)
|
|
|
|
return formatted
|
|
except Exception:
|
|
status = "error"
|
|
raise
|
|
finally:
|
|
record_request("query_code_vectors", time.perf_counter() - start_time, status)
|
|
|
|
|
|
# Create the ASGI app
|
|
app = mcp.http_app(
|
|
transport="streamable-http",
|
|
middleware=[
|
|
Middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
],
|
|
)
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
port = int(os.environ.get("PORT", "8000"))
|
|
host = os.environ.get("HOST", "0.0.0.0")
|
|
|
|
print(f"Starting MCP server on {host}:{port}")
|
|
uvicorn.run(app, host=host, port=port)
|