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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
run: |
uv pip install huggingface_hub[cli]
huggingface-cli download --repo-type dataset The-OpenROAD-Project/ORAssistant_RAG_Dataset --include source_list.json --local-dir data/
export GOOGLE_API_KEY="dummy-unit-test-key"
cp .env.test .env
make test

- name: Build Docker images
Expand Down
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,16 @@ FAST_MODE=false
# Debug mode for development
DEBUG=false

# Enable MCP (Model Context Protocol) tools
ENABLE_MCP=false

# MCP Server Configuration
# Path to OpenROAD Flow Scripts directory
ORFS_DIR={{PATH_TO_ORFS_DIR}}

# Disable GUI commands (set to true for headless environments)
DISABLE_GUI=true

# Repository commit hashes for documentation building
OR_REPO_COMMIT=ffc5760f2df639cd184c40ceba253c7e02a006d5
ORFS_REPO_COMMIT={{ORFS_REPO_COMMIT}}
Expand Down
32 changes: 32 additions & 0 deletions backend/.env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Test Environment Variables for CI
# This file contains dummy values for running unit tests

# Google API (dummy key for tests)
GOOGLE_API_KEY=dummy-unit-test-key

# Embedding Configuration
EMBEDDINGS_TYPE=HF
HF_EMBEDDINGS=thenlper/gte-large
HF_RERANKER=BAAI/bge-reranker-base
GOOGLE_EMBEDDINGS=text-embedding-004

# LLM Configuration
LLM_MODEL=gemini
LLM_TEMP=1
GOOGLE_GEMINI=2.0_flash
OLLAMA_MODEL=

# System Configuration
USE_CUDA=false
SEARCH_K=5
CHUNK_SIZE=2000
CHUNK_OVERLAP=200

# Optional settings
FAISS_DB_PATH=./.faissdb/faiss_index
TOKENIZERS_PARALLELISM=false
LOGLEVEL=INFO
FAST_MODE=false
DEBUG=false
ENABLE_MCP=false
DISABLE_GUI=true
12 changes: 12 additions & 0 deletions backend/mcp_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import os
import logging

from src.openroad_mcp.server.orfs.orfs_server import ORFSServer

logging.basicConfig(
level=os.environ.get("LOGLEVEL", "INFO").upper(),
format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s\n",
)

server = ORFSServer()
server.mcp.run(transport="http", host="127.0.0.1", port=3001)
12 changes: 12 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,15 @@ dev = [

[tool.pytest.ini_options]
pythonpath = ["."]
markers = [
"unit: marks tests as unit tests (fast, isolated)",
"integration: marks tests as integration tests (slower, may require external resources)",
]
asyncio_mode = "auto"

[tool.mypy]
exclude = [
"^tests/",
]
python_version = "3.13"
disallow_untyped_defs = true
2 changes: 1 addition & 1 deletion backend/src/agents/retriever_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def fork_route(self, state: AgentState) -> str:
if not self.enable_mcp:
tmp = "rag_agent"
else:
tmp = state["agent_type"][0]
tmp = "mcp_agent"
return tmp

def initialize(self) -> None:
Expand Down
11 changes: 5 additions & 6 deletions backend/src/agents/retriever_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,16 @@ def mcp_tool_node(self, state: AgentState) -> dict[str, list[Any]]:
logging.info(tool_call["args"])
try:
observation = asyncio.run(tool.ainvoke(tool_call["args"]))
result.append(observation)
except ToolException as e:
error_msg = f"Tool '{tool_call['name']}' failed: {str(e)}"
logging.error(f"ToolException during {tool_call['name']}: {e}")
observation = None
result.append(error_msg)
except Exception as e:
error_msg = f"Tool '{tool_call['name']}' encountered an error: {str(e)}"
logging.error(f"Unexpected error during {tool_call['name']}: {e}")
observation = None
result.append(error_msg)

if observation:
result.append(observation)
else:
result.append("no return")
logging.info("DONE")
logging.info(result)
return {"messages": result}
Expand Down
4 changes: 2 additions & 2 deletions backend/src/api/routers/graphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ async def get_agent_response(user_input: UserInput) -> ChatResponse:
return ChatResponse(**response)


async def get_response_stream(user_input: UserInput):
async def get_response_stream(user_input: UserInput): # type: ignore[no-untyped-def]
user_question = user_input.query

inputs = {
Expand Down Expand Up @@ -316,7 +316,7 @@ async def get_response_stream(user_input: UserInput):


@router.post("/agent-retriever/stream", response_class=StreamingResponse)
async def get_agent_response_streaming(user_input: UserInput):
async def get_agent_response_streaming(user_input: UserInput): # type: ignore[no-untyped-def]
return StreamingResponse(
get_response_stream(user_input), media_type="text/event-stream"
)
13 changes: 10 additions & 3 deletions backend/src/openroad_mcp/client/client.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import asyncio
import logging
from typing import Any
from datetime import timedelta
from langchain_mcp_adapters.client import MultiServerMCPClient # type: ignore

MCP_SERVER_URL = "http://localhost:3001/mcp/"

_tools_cache = None
_tools_cache: Any = None


async def get_tools_async():
async def get_tools_async() -> Any:
"""Get MCP tools asynchronously"""
global _tools_cache
if _tools_cache is None:
Expand All @@ -17,6 +19,11 @@ async def get_tools_async():
"orfs_cmd": {
"transport": "streamable_http",
"url": MCP_SERVER_URL,
# TODO: remove this once tools are async!
# HTTP request timeout - increase for long make commands
"timeout": timedelta(hours=2),
# SSE read timeout - how long to wait for events
"sse_read_timeout": timedelta(hours=2),
},
}
)
Expand All @@ -28,7 +35,7 @@ async def get_tools_async():
return _tools_cache


def get_tools():
def get_tools() -> Any:
"""Get MCP tools synchronously"""
try:
return asyncio.run(get_tools_async())
Expand Down
195 changes: 195 additions & 0 deletions backend/src/openroad_mcp/server/orfs/orfs_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import os
import subprocess
import logging
import shlex
from src.openroad_mcp.server.orfs.orfs_tools import ORFS


def _should_skip_gui() -> bool:
"""Check if GUI commands should be skipped based on environment variable."""
return os.getenv("DISABLE_GUI", "false").lower() in ("true", "1", "yes")


class ORFSBase(ORFS):
def _get_platforms_impl(self) -> str:
"""Internal implementation of get_platforms"""
# TODO: scrape platforms instead of serving only default sky130
assert ORFS.server is not None
ORFS.server.platform = "sky130hd"
return ORFS.server.platform

def _get_designs_impl(self) -> str:
"""Internal implementation of get_designs"""
# TODO: scrape designs instead of default riscv
assert ORFS.server is not None
ORFS.server.design = "riscv32i"
return ORFS.server.design

def _check_configuration(self) -> None:
assert ORFS.server is not None
if not ORFS.server.platform:
ORFS.server._get_platforms_impl()
logging.info(ORFS.server.platform)

if not ORFS.server.design:
ORFS.server._get_designs_impl()
logging.info(ORFS.server.design)

def _command(self, cmd: str) -> None:
assert ORFS.server is not None
working = os.getcwd()
os.chdir(ORFS.server.flow_dir)

make = f"make DESIGN_CONFIG={ORFS.server.flow_dir}/designs/{ORFS.server.platform}/{ORFS.server.design}/config.mk"
logging.info(cmd)
build_command = f"{make} {cmd}"
ORFS.server._run_command(build_command)

os.chdir(working)

def _run_command(self, cmd: str) -> None:
assert ORFS.server is not None
logging.info("start command")

process = subprocess.Popen(
shlex.split(cmd),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1, # Line-buffered
universal_newlines=True, # Text mode
env=ORFS.server.env,
)

if process.stdout:
for line in process.stdout:
logging.info(line.rstrip())

process.wait()
if process.returncode != 0:
logging.error(f"Command exited with return code {process.returncode}")
raise subprocess.CalledProcessError(process.returncode, cmd)

### mcp tool section ###

@staticmethod
@ORFS.mcp.tool
def get_platforms() -> str:
"""call get platforms to display possible platforms to run through flow"""
assert ORFS.server is not None
return ORFS.server._get_platforms_impl()

@staticmethod
@ORFS.mcp.tool
def get_designs() -> str:
"""call get designs to display possible designs to run through flow"""
assert ORFS.server is not None
return ORFS.server._get_designs_impl()

@staticmethod
@ORFS.mcp.tool
def make(cmd: str) -> str:
"""Execute a makefile target for OpenROAD-flow-scripts.

Common commands:
- "clean" - Remove all build artifacts and start fresh
- "synth" - Run synthesis
- "place" - Run placement
- "route" - Run routing
- "final" - Generate final reports

Use this for any makefile target not covered by step/jump commands.
"""
assert ORFS.server is not None
ORFS.server._check_configuration()
ORFS.server._command(cmd)

return f"finished {cmd}"

@staticmethod
@ORFS.mcp.tool
def get_stage_names() -> str:
"""get stage names for possible states this mcp server can be in the chip design pipeline"""
assert ORFS.server is not None
stage_names = [_.info() for _ in ORFS.server.stages.values()]
logging.info(stage_names) # in server process
# for chatbot output
result = ""
for _ in stage_names:
result += f"{_}\n"
return result

@staticmethod
@ORFS.mcp.tool
def jump(stage: str) -> str:
"""Jump directly to a specific stage in the chip design pipeline.

Valid stage names (MUST use exact names):
- "synth" - Synthesis
- "floorplan" - Floorplan
- "place" - Placement
- "cts" - Clock Tree Synthesis
- "route" - Routing
- "final" - Final Report

Use get_stage_names() to see all available stages.
"""
assert ORFS.server is not None
ORFS.server._check_configuration()

stage_names = [_.info() for _ in ORFS.server.stages.values()]
logging.info(stage_names)
if stage in stage_names:
logging.info(stage)
ORFS.server.cur_stage = ORFS.server.stage_index[stage]

ORFS.server._command(stage)

# Open GUI if not disabled
if not _should_skip_gui():
try:
ORFS.server._command(f"gui_{stage}")
except subprocess.CalledProcessError as e:
logging.warning(f"GUI command failed: {e}")
else:
logging.info("Skipping GUI command (DISABLE_GUI=true)")

return f"finished {stage}"
else:
logging.info("jump unsuccessful..")
return f"aborted {stage}"

@staticmethod
@ORFS.mcp.tool
def step() -> str:
"""Progress to the next stage in the chip design pipeline (synthesis -> floorplan -> placement -> CTS -> routing -> final report)"""
assert ORFS.server is not None

def make_keyword() -> str:
assert ORFS.server is not None
logging.info(ORFS.server.cur_stage)
if ORFS.server.cur_stage <= len(ORFS.server.stages) - 2:
ORFS.server.cur_stage += 1
else:
logging.info("end of pipeline..")
return ORFS.server.stages[ORFS.server.cur_stage].info()

ORFS.server._check_configuration()

command = make_keyword()
ORFS.server._command(command)

# Open GUI if not disabled
if not _should_skip_gui():
try:
ORFS.server._command(f"gui_{command}")
except subprocess.CalledProcessError as e:
logging.warning(f"GUI command failed: {e}")
else:
logging.info("Skipping GUI command (DISABLE_GUI=true)")

return f"finished {command}"

# TODO: scrape all makefile keywords and make into mcp tool
@staticmethod
def get_all_keywords() -> None:
pass
Loading