vivado_mcp/server.py
cah bfe6ce2a9f Add get_host_status tool for memory-based server selection
Reports hostname, free memory, and Vivado session state.
Suggests using vivado-snoke when free memory is below 64GB.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:12:06 -07:00

2275 lines
88 KiB
Python

#!/usr/bin/env python3
"""
Vivado MCP Server - Direct integration with AMD/Xilinx Vivado.
This module implements a Model Context Protocol (MCP) server that provides
AI assistants (like Claude) with direct access to AMD/Xilinx Vivado FPGA
development tools. It enables:
- Session management: Start/stop persistent Vivado TCL sessions
- Project management: Open/close Vivado projects (.xpr files)
- Design flow: Run synthesis, implementation, and bitstream generation
- Reports: Get timing, utilization, and other analysis reports
- Design queries: Explore hierarchy, ports, nets, and cells
- Simulation: Control Vivado's behavioral simulator (xsim)
- Raw TCL: Execute arbitrary TCL commands for advanced operations
Architecture:
The server maintains a singleton VivadoSession that keeps Vivado running
in TCL mode. Commands are sent via pexpect and results are parsed and
returned as structured JSON. This avoids the ~30 second startup time
for each Vivado command.
MCP Protocol:
The server uses the MCP stdio transport, communicating via stdin/stdout
with JSON-RPC messages. Tools are exposed via the @server.list_tools()
and @server.call_tool() decorators.
Usage:
# Start the server (typically done by Claude Code or another MCP client)
python -m vivado_mcp
# Or via the console script (after pip install)
vivado-mcp
Example workflow (from an AI assistant):
1. start_session - Start Vivado
2. open_project - Open your .xpr file
3. run_synthesis - Synthesize the design
4. get_timing_summary - Check timing results
5. get_utilization - Check resource usage
6. stop_session - Clean up when done
Author: Created with Claude (Anthropic)
License: MIT
"""
import json
import os
import re
import uuid
from datetime import datetime
from pathlib import Path
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
from .vivado_session import get_session, VivadoSession
# =============================================================================
# CONFIGURATION CONSTANTS
# =============================================================================
# Feature requests are stored persistently so users can track requested features
FEATURE_REQUESTS_FILE = Path(__file__).parent / "data" / "feature_requests.json"
# Report management configuration
# Reports are written to temp files when they exceed inline size limits
REPORTS_DIR = Path("/tmp/vivado_mcp")
# Maximum characters to return inline in a response
# Larger reports should use generate_full_report + read_report_section
MAX_RESPONSE_CHARS = 8000 # ~8KB limit for inline responses
# How long to keep cached report files before cleanup (in hours)
REPORT_CACHE_HOURS = 1
# In-memory cache mapping report_id -> metadata (file path, type, etc.)
# This allows quick lookup of previously generated reports
_report_cache: dict[str, dict] = {}
# =============================================================================
# FEATURE REQUEST MANAGEMENT
# =============================================================================
def load_feature_requests() -> list[dict]:
"""
Load feature requests from the persistent JSON file.
Feature requests allow the AI assistant to record when it encounters
limitations or wishes it had a tool that doesn't exist. This helps
guide future development of the MCP server.
Returns:
List of feature request dictionaries, or empty list if file
doesn't exist or can't be parsed.
"""
if FEATURE_REQUESTS_FILE.exists():
try:
return json.loads(FEATURE_REQUESTS_FILE.read_text())
except (json.JSONDecodeError, IOError):
return []
return []
def save_feature_request(request: dict) -> None:
"""
Save a feature request to the persistent JSON file.
Args:
request: Dictionary containing the feature request with fields:
- id: Auto-assigned sequential ID
- title: Short description of the feature
- description: Detailed explanation of what's needed
- use_case: The specific task that prompted this request
- priority: low/medium/high
- timestamp: ISO format timestamp
- status: "pending" (could be updated to "implemented" later)
"""
requests = load_feature_requests()
requests.append(request)
# Ensure the data directory exists
FEATURE_REQUESTS_FILE.parent.mkdir(parents=True, exist_ok=True)
FEATURE_REQUESTS_FILE.write_text(json.dumps(requests, indent=2))
# =============================================================================
# RESPONSE TRUNCATION
# =============================================================================
def truncate_response(content: str, max_chars: int = MAX_RESPONSE_CHARS) -> dict:
"""
Truncate response content if it exceeds max_chars.
Large Vivado reports can be tens of thousands of lines. Rather than
overwhelming the AI context window, we truncate and provide metadata
about what was cut. The user can then use generate_full_report to
get the complete output to a file.
Args:
content: The full content string to potentially truncate
max_chars: Maximum characters to return (default: MAX_RESPONSE_CHARS)
Returns:
Dictionary with:
- content: The (possibly truncated) content
- truncated: Boolean indicating if truncation occurred
- total_chars: Original content length
- total_lines: Original line count
- returned_chars: Characters in truncated content (if truncated)
- returned_lines: Lines in truncated content (if truncated)
- truncation_message: Human-readable message about truncation
"""
total_chars = len(content)
total_lines = content.count('\n') + 1
# If content fits, return it unchanged
if total_chars <= max_chars:
return {
"content": content,
"truncated": False,
"total_chars": total_chars,
"total_lines": total_lines
}
# Truncate to max_chars, but try to end at a line boundary
# This makes the output more readable and avoids cutting mid-line
truncated_content = content[:max_chars]
last_newline = truncated_content.rfind('\n')
# Only use the newline boundary if we keep >80% of the allowed content
# Otherwise we might lose too much useful data
if last_newline > max_chars * 0.8:
truncated_content = truncated_content[:last_newline]
truncated_lines = truncated_content.count('\n') + 1
return {
"content": truncated_content,
"truncated": True,
"total_chars": total_chars,
"total_lines": total_lines,
"returned_chars": len(truncated_content),
"returned_lines": truncated_lines,
"truncation_message": f"Output truncated ({total_chars:,} chars -> {len(truncated_content):,} chars). Use generate_full_report for complete output."
}
def verify_run_status(session, run_name: str) -> dict:
"""
Verify actual Vivado run status instead of relying on output parsing.
Vivado run status is stored as properties on the run object. This function
queries those properties directly, which is more reliable than parsing
text output that may contain misleading strings.
Args:
session: VivadoSession instance
run_name: Name of the run to check (e.g., "synth_1", "impl_1")
Returns:
Dictionary with:
- run_name: The run that was checked
- status: Vivado's STATUS property (e.g., "synth_design Complete!")
- progress: Vivado's PROGRESS property (e.g., "100%")
- actually_succeeded: True if run completed successfully
- actually_failed: True if run failed
"""
status_result = session.run_tcl(f"get_property STATUS [get_runs {run_name}]")
progress_result = session.run_tcl(f"get_property PROGRESS [get_runs {run_name}]")
status = status_result.output.strip() if status_result.success else "unknown"
progress = progress_result.output.strip() if progress_result.success else "unknown"
# Determine actual success/failure from status string
# Successful runs have "Complete!" in status
# Failed runs have "ERROR" in status
status_lower = status.lower()
return {
"run_name": run_name,
"status": status,
"progress": progress,
"actually_succeeded": "complete" in status_lower,
"actually_failed": "error" in status_lower,
}
# =============================================================================
# REPORT FILE MANAGEMENT
# =============================================================================
def ensure_reports_dir() -> Path:
"""
Ensure the reports directory exists and clean up old reports.
This function is called before generating new reports. It:
1. Creates the reports directory if it doesn't exist
2. Removes any report files older than REPORT_CACHE_HOURS
3. Cleans up the in-memory cache for deleted files
Returns:
Path to the reports directory
"""
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
# Calculate cutoff timestamp for old reports
cutoff = datetime.now().timestamp() - (REPORT_CACHE_HOURS * 3600)
# Scan for and remove old report files
for report_file in REPORTS_DIR.glob("*.txt"):
try:
if report_file.stat().st_mtime < cutoff:
report_file.unlink()
# Also remove from in-memory cache if present
report_id = report_file.stem
_report_cache.pop(report_id, None)
except OSError:
pass # Ignore errors during cleanup
return REPORTS_DIR
def generate_report_id() -> str:
"""
Generate a unique 8-character report ID.
Uses UUID4 for uniqueness, truncated to 8 chars for readability.
The ID is used to reference reports across tool calls.
Returns:
8-character hexadecimal string (e.g., "a1b2c3d4")
"""
return str(uuid.uuid4())[:8]
def get_hierarchy_depth(path: str) -> int:
"""
Get the depth of a hierarchical path.
Vivado uses "/" to separate hierarchy levels (e.g., "cpu/alu/adder").
This function counts the depth to help filter hierarchy queries.
Args:
path: Hierarchical path string
Returns:
Depth as integer (0 for top level, 1 for first level children, etc.)
"""
return path.count('/')
# =============================================================================
# MCP SERVER INSTANCE
# =============================================================================
# Create the MCP server instance
# The name "vivado" is used as the server identifier in MCP communications
server = Server("vivado")
# =============================================================================
# VIVADO OUTPUT PARSERS
# =============================================================================
# These functions parse Vivado's text-based reports into structured data
# that's easier for AI assistants to work with.
def parse_timing_summary(output: str) -> dict:
"""
Parse a Vivado timing summary report into structured data.
Timing summary reports contain critical information about whether
the design meets timing requirements. Key metrics:
- WNS (Worst Negative Slack): Most critical setup timing margin
Positive = timing met, Negative = timing violation
- TNS (Total Negative Slack): Sum of all negative setup slacks
- WHS (Worst Hold Slack): Most critical hold timing margin
- THS (Total Hold Slack): Sum of all negative hold slacks
- WPWS (Worst Pulse Width Slack): For pulse width requirements
- TPWS (Total Pulse Width Slack): Sum of pulse width violations
Args:
output: Raw text output from report_timing_summary
Returns:
Dictionary with parsed metrics and "met" boolean indicating
if all timing is met (WNS >= 0 and WHS >= 0)
"""
result = {
"wns": None, # Worst Negative Slack (setup)
"tns": None, # Total Negative Slack (setup)
"whs": None, # Worst Hold Slack
"ths": None, # Total Hold Slack
"wpws": None, # Worst Pulse Width Slack
"tpws": None, # Total Pulse Width Slack
"failing_endpoints": 0,
"met": False,
"raw": output # Keep raw output for detailed analysis
}
# Parse WNS/TNS (setup timing) using regex
# Format: "WNS(ns) : 1.234" or similar
wns_match = re.search(r"WNS\(ns\)\s*:\s*([-\d.]+)", output)
tns_match = re.search(r"TNS\(ns\)\s*:\s*([-\d.]+)", output)
if wns_match:
result["wns"] = float(wns_match.group(1))
if tns_match:
result["tns"] = float(tns_match.group(1))
# Parse WHS/THS (hold timing)
whs_match = re.search(r"WHS\(ns\)\s*:\s*([-\d.]+)", output)
ths_match = re.search(r"THS\(ns\)\s*:\s*([-\d.]+)", output)
if whs_match:
result["whs"] = float(whs_match.group(1))
if ths_match:
result["ths"] = float(ths_match.group(1))
# Parse count of failing endpoints
fail_match = re.search(r"(\d+)\s+failing\s+endpoint", output, re.IGNORECASE)
if fail_match:
result["failing_endpoints"] = int(fail_match.group(1))
# Determine if timing is met: both setup and hold must have non-negative slack
if result["wns"] is not None and result["whs"] is not None:
result["met"] = result["wns"] >= 0 and result["whs"] >= 0
return result
def parse_utilization(output: str) -> dict:
"""
Parse a Vivado utilization report into structured data.
Utilization reports show how much of each FPGA resource type is used.
This is critical for understanding if a design will fit and for
optimization decisions.
Resource types tracked:
- LUT: Look-Up Tables (combinational logic)
- FF: Flip-Flops (registers/sequential logic)
- BRAM: Block RAM (on-chip memory)
- DSP: DSP slices (multipliers, MACs)
- IO: Input/Output pins
Args:
output: Raw text output from report_utilization
Returns:
Dictionary with each resource type containing:
- used: Number of resources used
- available: Total resources on the device
- percent: Utilization percentage
"""
result = {
"lut": {"used": 0, "available": 0, "percent": 0},
"ff": {"used": 0, "available": 0, "percent": 0},
"bram": {"used": 0, "available": 0, "percent": 0},
"dsp": {"used": 0, "available": 0, "percent": 0},
"io": {"used": 0, "available": 0, "percent": 0},
"raw": output # Keep raw output for detailed analysis
}
# Regex patterns for each resource type
# Vivado's table format: "Resource | Used | Fixed | Available | Util%"
# Different device families use slightly different names
patterns = {
"lut": r"(?:Slice LUTs|CLB LUTs)\s*\|\s*(\d+)\s*\|\s*\d+\s*\|\s*(\d+)\s*\|\s*([\d.]+)",
"ff": r"(?:Slice Registers|CLB Registers)\s*\|\s*(\d+)\s*\|\s*\d+\s*\|\s*(\d+)\s*\|\s*([\d.]+)",
"bram": r"Block RAM Tile\s*\|\s*(\d+\.?\d*)\s*\|\s*\d+\s*\|\s*(\d+\.?\d*)\s*\|\s*([\d.]+)",
"dsp": r"DSPs?\s*\|\s*(\d+)\s*\|\s*\d+\s*\|\s*(\d+)\s*\|\s*([\d.]+)",
"io": r"(?:Bonded IOB|Bonded User I/O)\s*\|\s*(\d+)\s*\|\s*\d+\s*\|\s*(\d+)\s*\|\s*([\d.]+)"
}
# Apply each pattern and extract values
for resource, pattern in patterns.items():
match = re.search(pattern, output, re.IGNORECASE)
if match:
result[resource]["used"] = float(match.group(1))
result[resource]["available"] = float(match.group(2))
result[resource]["percent"] = float(match.group(3))
return result
def parse_messages(output: str) -> dict:
"""
Parse Vivado messages into categorized lists.
Vivado outputs messages with severity prefixes:
- ERROR: Design or tool errors that must be fixed
- CRITICAL WARNING: Serious issues that may cause problems
- WARNING: Potential issues to review
- INFO: Informational messages
Args:
output: Raw text output containing Vivado messages
Returns:
Dictionary with lists of messages by category
"""
result = {
"errors": [],
"critical_warnings": [],
"warnings": [],
"info": [],
"raw": output
}
# Categorize each line by its severity prefix
for line in output.split("\n"):
line = line.strip()
if re.match(r"ERROR:", line):
result["errors"].append(line)
elif re.match(r"CRITICAL WARNING:", line):
result["critical_warnings"].append(line)
elif re.match(r"WARNING:", line):
result["warnings"].append(line)
elif re.match(r"INFO:", line):
result["info"].append(line)
return result
def parse_timing_paths_summary(output: str, max_paths: int = 5) -> list[dict]:
"""
Extract structured summary of timing paths from report_timing output.
Parses Vivado's timing path reports to extract key information about
each path without the verbose detailed breakdown.
Args:
output: Raw text output from report_timing command
max_paths: Maximum number of paths to return (default: 5)
Returns:
List of dictionaries, each containing:
- slack: Path slack in ns (negative = failing)
- source: Source register/port name
- destination: Destination register/port name
- source_clock: Source clock domain (if applicable)
- dest_clock: Destination clock domain (if applicable)
- requirement: Timing requirement in ns
- data_path_delay: Data path delay in ns
- logic_levels: Number of logic levels
"""
paths = []
# Split output into individual path blocks
# Each path starts with "Slack" line
path_blocks = re.split(r'\n(?=Slack\s*(?:\([A-Z]+\))?\s*:)', output)
for block in path_blocks:
if not block.strip() or 'Slack' not in block:
continue
path_info = {}
# Extract slack value
slack_match = re.search(r'Slack\s*(?:\([A-Z]+\))?\s*:\s*([-\d.]+)\s*ns', block)
if slack_match:
path_info['slack'] = float(slack_match.group(1))
# Extract source (startpoint)
source_match = re.search(r'Source:\s*(\S+)', block)
if source_match:
path_info['source'] = source_match.group(1)
# Extract destination (endpoint)
dest_match = re.search(r'Destination:\s*(\S+)', block)
if dest_match:
path_info['destination'] = dest_match.group(1)
# Extract source clock
src_clk_match = re.search(r'Source Clock:\s*(\S+)', block)
if src_clk_match:
path_info['source_clock'] = src_clk_match.group(1)
# Extract destination clock
dst_clk_match = re.search(r'Destination Clock:\s*(\S+)', block)
if dst_clk_match:
path_info['dest_clock'] = dst_clk_match.group(1)
# Extract requirement
req_match = re.search(r'Requirement:\s*([-\d.]+)\s*ns', block)
if req_match:
path_info['requirement'] = float(req_match.group(1))
# Extract data path delay
data_delay_match = re.search(r'Data Path Delay:\s*([-\d.]+)\s*ns', block)
if data_delay_match:
path_info['data_path_delay'] = float(data_delay_match.group(1))
# Extract logic levels
levels_match = re.search(r'Logic Levels:\s*(\d+)', block)
if levels_match:
path_info['logic_levels'] = int(levels_match.group(1))
# Only add if we got meaningful data
if 'slack' in path_info:
paths.append(path_info)
if len(paths) >= max_paths:
break
return paths
# =============================================================================
# TOOL DEFINITIONS
# =============================================================================
# MCP tools are the interface exposed to AI assistants. Each tool has:
# - name: Unique identifier for the tool
# - description: What the tool does (shown to the AI)
# - inputSchema: JSON Schema defining the parameters
@server.list_tools()
async def list_tools() -> list[Tool]:
"""
List all available Vivado tools.
This function is called by MCP clients to discover available tools.
Tools are organized into categories:
1. Session Management: start_session, stop_session, session_status
2. Project Management: open_project, close_project, get_project_info
3. Design Flow: run_synthesis, run_implementation, generate_bitstream
4. Reports/Analysis: get_timing_summary, get_timing_paths, get_utilization, etc.
5. Design Queries: get_design_hierarchy, get_ports, get_nets, get_cells
6. Raw TCL: run_tcl for advanced operations
7. Simulation: launch_simulation, run_simulation, get_signal_value, etc.
8. Feature Requests: request_feature, list_feature_requests
9. Report Management: generate_full_report, read_report_section
Returns:
List of Tool objects with name, description, and inputSchema
"""
return [
# =====================================================================
# SESSION MANAGEMENT TOOLS
# =====================================================================
# These tools control the Vivado process lifecycle
Tool(
name="start_session",
description="Start a persistent Vivado TCL session. Must be called before other commands.",
inputSchema={
"type": "object",
"properties": {
"vivado_path": {
"type": "string",
"description": "Path to Vivado executable (default: 'vivado' from PATH)"
}
},
"required": []
}
),
Tool(
name="stop_session",
description="Stop the Vivado TCL session and free resources",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
Tool(
name="session_status",
description="Get status and statistics of the current Vivado session",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
Tool(
name="check_session_health",
description="Check if Vivado session is responsive and recover if needed. Use this if commands are timing out or behaving unexpectedly.",
inputSchema={
"type": "object",
"properties": {
"auto_recover": {
"type": "boolean",
"description": "Restart session if unhealthy (default: true)"
}
},
"required": []
}
),
Tool(
name="get_host_status",
description="Get status of this Vivado MCP server host including hostname, free memory, and session state. If free memory is below 64GB, use vivado-snoke instead.",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
# =====================================================================
# PROJECT MANAGEMENT TOOLS
# =====================================================================
# These tools work with Vivado project files (.xpr)
Tool(
name="open_project",
description="Open a Vivado project (.xpr file)",
inputSchema={
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to .xpr project file"
}
},
"required": ["project_path"]
}
),
Tool(
name="close_project",
description="Close the current project",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
Tool(
name="get_project_info",
description="Get information about the currently open project",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
# =====================================================================
# DESIGN FLOW TOOLS
# =====================================================================
# These tools run the major FPGA design flow steps
Tool(
name="run_synthesis",
description="Run synthesis on the current project",
inputSchema={
"type": "object",
"properties": {
"jobs": {
"type": "integer",
"description": "Number of parallel jobs (default: 4)"
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default: 1800 = 30 minutes). Increase for large designs."
}
},
"required": []
}
),
Tool(
name="run_implementation",
description="Run implementation (place and route) on the current project",
inputSchema={
"type": "object",
"properties": {
"jobs": {
"type": "integer",
"description": "Number of parallel jobs (default: 4)"
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default: 3600 = 60 minutes). Increase for large designs."
}
},
"required": []
}
),
Tool(
name="generate_bitstream",
description="Generate bitstream for the implemented design",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
# =====================================================================
# REPORTS AND ANALYSIS TOOLS
# =====================================================================
# These tools generate and parse Vivado's analysis reports
Tool(
name="get_timing_summary",
description="Get timing summary (WNS, TNS, WHS, THS). Returns parsed metrics only by default. Use generate_full_report for raw output.",
inputSchema={
"type": "object",
"properties": {
"report_type": {
"type": "string",
"description": "Type: 'summary' (default), 'setup', 'hold', or 'all'"
},
"detail_level": {
"type": "string",
"enum": ["summary", "standard", "full"],
"description": "Detail level: 'summary' (default, parsed metrics only), 'standard' (+ truncated raw), 'full' (+ complete raw)"
}
},
"required": []
}
),
Tool(
name="get_timing_paths",
description="Get timing paths for failing or critical paths. Returns structured summary (slack, source, dest, clocks) by default. Use generate_full_report for verbose path details.",
inputSchema={
"type": "object",
"properties": {
"num_paths": {
"type": "integer",
"description": "Number of paths to report (default: 10)"
},
"slack_threshold": {
"type": "number",
"description": "Only show paths with slack less than this (default: 0 for failing paths)"
},
"path_type": {
"type": "string",
"description": "Type: 'setup' (default) or 'hold'"
},
"from_pin": {
"type": "string",
"description": "Filter paths starting from this pin/cell pattern (Vivado -from option)"
},
"to_pin": {
"type": "string",
"description": "Filter paths ending at this pin/cell pattern (Vivado -to option)"
},
"through": {
"type": "string",
"description": "Filter paths going through this pin/cell pattern (Vivado -through option)"
},
"clock": {
"type": "string",
"description": "Filter paths by clock domain name"
},
"detail_level": {
"type": "string",
"enum": ["summary", "standard", "full"],
"description": "Detail level: 'summary' (default, structured only), 'standard' (+ truncated raw), 'full' (+ complete raw)"
}
},
"required": []
}
),
Tool(
name="get_utilization",
description="Get resource utilization (LUT, FF, BRAM, DSP, IO). Returns parsed metrics only by default. Use generate_full_report for hierarchical details.",
inputSchema={
"type": "object",
"properties": {
"hierarchical": {
"type": "boolean",
"description": "Include hierarchical breakdown (default: false)"
},
"detail_level": {
"type": "string",
"enum": ["summary", "standard", "full"],
"description": "Detail level: 'summary' (default, parsed only), 'standard' (+ truncated raw), 'full' (+ complete raw)"
},
"module_filter": {
"type": "string",
"description": "Wildcard pattern to filter modules in hierarchical report"
},
"threshold_percent": {
"type": "number",
"description": "Only show resources above this utilization percentage (0-100)"
}
},
"required": []
}
),
Tool(
name="get_clocks",
description="Get clock information and constraints",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
Tool(
name="get_messages",
description="Get synthesis/implementation messages (errors, warnings)",
inputSchema={
"type": "object",
"properties": {
"severity": {
"type": "string",
"description": "Filter by severity: 'all' (default), 'error', 'critical', 'warning'"
}
},
"required": []
}
),
# =====================================================================
# DESIGN QUERY TOOLS
# =====================================================================
# These tools explore the elaborated/synthesized design structure
Tool(
name="get_design_hierarchy",
description="Get the design hierarchy (modules and instances)",
inputSchema={
"type": "object",
"properties": {
"max_depth": {
"type": "integer",
"description": "Maximum hierarchy depth to return (default: 3)"
},
"instance_pattern": {
"type": "string",
"description": "Wildcard pattern to filter instances (e.g., '*cpu*', 'core/alu/*')"
}
},
"required": []
}
),
Tool(
name="get_ports",
description="Get top-level ports of the design",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
Tool(
name="get_nets",
description="Search for nets in the design",
inputSchema={
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Wildcard pattern to match net names (default: '*')"
},
"limit": {
"type": "integer",
"description": "Maximum number of results (default: 100)"
}
},
"required": []
}
),
Tool(
name="get_cells",
description="Search for cells (instances) in the design",
inputSchema={
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Wildcard pattern to match cell names (default: '*')"
},
"limit": {
"type": "integer",
"description": "Maximum number of results (default: 100)"
}
},
"required": []
}
),
# =====================================================================
# RAW TCL TOOL
# =====================================================================
# Escape hatch for advanced operations not covered by specific tools
Tool(
name="run_tcl",
description="Execute a raw TCL command in Vivado. Use for advanced operations not covered by other tools.",
inputSchema={
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "TCL command to execute"
}
},
"required": ["command"]
}
),
# =====================================================================
# SIMULATION TOOLS
# =====================================================================
# These tools control Vivado's integrated simulator (xsim)
Tool(
name="launch_simulation",
description="Launch behavioral simulation (xsim). Opens the simulator and loads the design.",
inputSchema={
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["behavioral", "post_synth_func", "post_synth_timing", "post_impl_func", "post_impl_timing"],
"description": "Simulation mode (default: behavioral)"
}
},
"required": []
}
),
Tool(
name="run_simulation",
description="Run the simulation for a specified time",
inputSchema={
"type": "object",
"properties": {
"time": {
"type": "string",
"description": "Time to run (e.g., '100ns', '1us', '10ms', 'all')"
}
},
"required": ["time"]
}
),
Tool(
name="restart_simulation",
description="Restart the simulation from time 0",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
Tool(
name="close_simulation",
description="Close the current simulation",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
Tool(
name="get_simulation_time",
description="Get the current simulation time",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
Tool(
name="get_signal_value",
description="Get the current value of a signal in simulation",
inputSchema={
"type": "object",
"properties": {
"signal": {
"type": "string",
"description": "Full hierarchical signal path (e.g., '/tb/dut/clk', '/tb/dut/data_out')"
},
"radix": {
"type": "string",
"enum": ["bin", "hex", "dec", "unsigned", "ascii"],
"description": "Display radix (default: hex)"
}
},
"required": ["signal"]
}
),
Tool(
name="get_signal_values",
description="Get current values of multiple signals matching a pattern",
inputSchema={
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Signal pattern with wildcards (e.g., '/tb/dut/*', '/tb/dut/data*')"
},
"radix": {
"type": "string",
"enum": ["bin", "hex", "dec", "unsigned", "ascii"],
"description": "Display radix (default: hex)"
}
},
"required": ["pattern"]
}
),
Tool(
name="add_signals_to_wave",
description="Add signals to the waveform viewer",
inputSchema={
"type": "object",
"properties": {
"signals": {
"type": "array",
"items": {"type": "string"},
"description": "List of signal paths to add (e.g., ['/tb/dut/clk', '/tb/dut/rst'])"
}
},
"required": ["signals"]
}
),
Tool(
name="set_simulation_top",
description="Set the top module for simulation",
inputSchema={
"type": "object",
"properties": {
"top_module": {
"type": "string",
"description": "Name of the testbench module"
},
"fileset": {
"type": "string",
"description": "Simulation fileset (default: sim_1)"
}
},
"required": ["top_module"]
}
),
Tool(
name="get_simulation_objects",
description="List simulation objects (signals, variables) in a scope",
inputSchema={
"type": "object",
"properties": {
"scope": {
"type": "string",
"description": "Hierarchical scope (e.g., '/tb', '/tb/dut'). Default is root."
},
"filter": {
"type": "string",
"enum": ["all", "signals", "ports", "internal"],
"description": "Filter by object type (default: all)"
}
},
"required": []
}
),
Tool(
name="get_scopes",
description="List available scopes (hierarchy) in the simulation",
inputSchema={
"type": "object",
"properties": {
"parent": {
"type": "string",
"description": "Parent scope to list children of (default: root)"
}
},
"required": []
}
),
Tool(
name="step_simulation",
description="Step the simulation by a delta cycle or time step",
inputSchema={
"type": "object",
"properties": {
"count": {
"type": "integer",
"description": "Number of steps (default: 1)"
}
},
"required": []
}
),
Tool(
name="add_breakpoint",
description="Add a simulation breakpoint on a signal condition",
inputSchema={
"type": "object",
"properties": {
"signal": {
"type": "string",
"description": "Signal to monitor"
},
"condition": {
"type": "string",
"enum": ["posedge", "negedge", "change"],
"description": "Trigger condition (default: change)"
}
},
"required": ["signal"]
}
),
Tool(
name="remove_breakpoints",
description="Remove all breakpoints",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
Tool(
name="get_simulation_messages",
description="Get simulation log messages (errors, warnings, info)",
inputSchema={
"type": "object",
"properties": {
"severity": {
"type": "string",
"enum": ["all", "error", "warning", "info"],
"description": "Filter by severity (default: all)"
}
},
"required": []
}
),
# =====================================================================
# FEATURE REQUEST TOOLS
# =====================================================================
# Allow AI assistants to request new features
Tool(
name="request_feature",
description="Request a new feature or capability for the Vivado MCP server. Use this when you encounter a limitation or wish you had a tool that doesn't exist.",
inputSchema={
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Short title for the feature request"
},
"description": {
"type": "string",
"description": "Detailed description of what you need and why"
},
"use_case": {
"type": "string",
"description": "The specific use case or task you were trying to accomplish"
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high"],
"description": "How important is this feature? (default: medium)"
}
},
"required": ["title", "description"]
}
),
Tool(
name="list_feature_requests",
description="List all feature requests that have been submitted",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
# =====================================================================
# REPORT FILE MANAGEMENT TOOLS
# =====================================================================
# Handle large reports that exceed inline response limits
Tool(
name="generate_full_report",
description="Generate a full Vivado report to a file. Use when inline reports are truncated or you need the complete output.",
inputSchema={
"type": "object",
"properties": {
"report_type": {
"type": "string",
"enum": ["timing", "timing_summary", "utilization", "hierarchy", "clocks", "power", "drc"],
"description": "Type of report to generate"
},
"options": {
"type": "object",
"description": "Report-specific options (e.g., {'hierarchical': true} for utilization)"
},
"output_file": {
"type": "string",
"description": "Optional custom output path. Default: /tmp/vivado_mcp/<type>_<id>.txt"
}
},
"required": ["report_type"]
}
),
Tool(
name="read_report_section",
description="Read a section of a previously generated report file",
inputSchema={
"type": "object",
"properties": {
"report_id": {
"type": "string",
"description": "Report ID returned by generate_full_report"
},
"file_path": {
"type": "string",
"description": "Alternative: direct file path to read"
},
"start_line": {
"type": "integer",
"description": "Line number to start reading from (1-indexed, default: 1)"
},
"num_lines": {
"type": "integer",
"description": "Number of lines to read (default: 100)"
},
"search_pattern": {
"type": "string",
"description": "Regex pattern to find a section (returns lines around first match)"
}
},
"required": []
}
)
]
# =============================================================================
# TOOL IMPLEMENTATION
# =============================================================================
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
"""
Handle tool calls from MCP clients.
This is the main dispatcher that routes tool calls to their implementations.
Each tool returns a list containing a single TextContent with JSON-formatted
results.
Args:
name: The tool name being called
arguments: Dictionary of arguments passed to the tool
Returns:
List containing one TextContent with JSON response
Response format:
All tools return JSON with at minimum:
- success: Boolean indicating if the operation succeeded
- Additional fields specific to each tool
On error:
- error: Error message string
- success: False
"""
# Get the singleton Vivado session
session = get_session()
# =========================================================================
# SESSION MANAGEMENT
# =========================================================================
if name == "start_session":
# Start Vivado TCL session
# This spawns a persistent Vivado process that stays running
vivado_path = arguments.get("vivado_path", "vivado")
session.vivado_path = vivado_path
result = session.start()
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"message": result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "stop_session":
# Stop Vivado session gracefully
result = session.stop()
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"message": result.output
}, indent=2))]
elif name == "session_status":
# Get session statistics (commands run, errors, timing, etc.)
stats = session.get_stats()
return [TextContent(type="text", text=json.dumps(stats, indent=2))]
elif name == "check_session_health":
# Check if session is responsive and optionally recover
auto_recover = arguments.get("auto_recover", True)
if not session.is_running:
if auto_recover:
result = session.start()
return [TextContent(type="text", text=json.dumps({
"healthy": result.success,
"action": "started",
"message": "Session was not running, started new session",
"elapsed_ms": result.elapsed_ms
}, indent=2))]
else:
return [TextContent(type="text", text=json.dumps({
"healthy": False,
"action": "none",
"message": "Session not running (auto_recover=false)"
}, indent=2))]
# Session thinks it's running, check if actually responsive
is_healthy = session.is_healthy()
if is_healthy:
return [TextContent(type="text", text=json.dumps({
"healthy": True,
"action": "none",
"message": "Session is healthy and responsive"
}, indent=2))]
# Session is unresponsive
if auto_recover:
result = session.ensure_healthy()
return [TextContent(type="text", text=json.dumps({
"healthy": result.success,
"action": "restarted",
"message": "Session was unresponsive, restarted",
"elapsed_ms": result.elapsed_ms
}, indent=2))]
else:
return [TextContent(type="text", text=json.dumps({
"healthy": False,
"action": "none",
"message": "Session is unresponsive (auto_recover=false)"
}, indent=2))]
elif name == "get_host_status":
# Get host system status for memory-based server selection
import socket
import psutil
hostname = socket.gethostname()
mem = psutil.virtual_memory()
mem_free_gb = mem.available / (1024 ** 3)
mem_total_gb = mem.total / (1024 ** 3)
# Build suggestion based on free memory (64GB threshold)
suggestion = None
if mem_free_gb < 64:
suggestion = f"Low memory ({mem_free_gb:.1f}GB free). Use vivado-snoke instead."
return [TextContent(type="text", text=json.dumps({
"hostname": hostname,
"memory_free_gb": round(mem_free_gb, 1),
"memory_total_gb": round(mem_total_gb, 1),
"memory_percent_used": mem.percent,
"vivado_session_active": session.is_running,
"suggestion": suggestion
}, indent=2))]
# =========================================================================
# SESSION CHECK
# =========================================================================
# All remaining commands require an active Vivado session
if not session.is_running:
return [TextContent(type="text", text=json.dumps({
"error": "Vivado session not running. Call start_session first.",
"success": False
}, indent=2))]
# =========================================================================
# PROJECT MANAGEMENT
# =========================================================================
if name == "open_project":
# Open a Vivado project file (.xpr)
project_path = arguments.get("project_path", "")
# Use braces to handle paths with spaces
result = session.run_tcl(f"open_project {{{project_path}}}")
if result.success:
session.current_project = project_path
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"output": result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "close_project":
# Close the current project
result = session.run_tcl("close_project")
session.current_project = None
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"output": result.output
}, indent=2))]
elif name == "get_project_info":
# Get various project properties
commands = [
"current_project", # Project name
"get_property PART [current_project]", # Target FPGA part
"get_property TARGET_LANGUAGE [current_project]", # Verilog/VHDL
"get_property DIRECTORY [current_project]" # Project directory
]
results = {}
for cmd in commands:
r = session.run_tcl(cmd)
results[cmd] = r.output
return [TextContent(type="text", text=json.dumps(results, indent=2))]
# =========================================================================
# DESIGN FLOW
# =========================================================================
elif name == "run_synthesis":
# Run synthesis with optional parallel jobs
# reset_run clears previous results, launch_runs starts synthesis,
# wait_on_run blocks until complete
jobs = arguments.get("jobs", 4)
timeout = arguments.get("timeout", 1800) # 30 min default
result = session.run_tcl(
f"reset_run synth_1; launch_runs synth_1 -jobs {jobs}; wait_on_run synth_1",
timeout_override=timeout
)
# Verify actual run status (more reliable than output parsing)
verification = verify_run_status(session, "synth_1")
actual_success = verification["actually_succeeded"]
response = {
"success": actual_success,
"output": result.output,
"elapsed_ms": result.elapsed_ms,
"run_status": verification["status"],
"run_progress": verification["progress"],
}
# Note if there was a mismatch between output parsing and actual status
if not result.success and actual_success:
response["note"] = "Output contained error-like strings but run completed successfully"
return [TextContent(type="text", text=json.dumps(response, indent=2))]
elif name == "run_implementation":
# Run place and route
jobs = arguments.get("jobs", 4)
timeout = arguments.get("timeout", 3600) # 60 min default
result = session.run_tcl(
f"launch_runs impl_1 -jobs {jobs}; wait_on_run impl_1",
timeout_override=timeout
)
# Verify actual run status (more reliable than output parsing)
verification = verify_run_status(session, "impl_1")
actual_success = verification["actually_succeeded"]
response = {
"success": actual_success,
"output": result.output,
"elapsed_ms": result.elapsed_ms,
"run_status": verification["status"],
"run_progress": verification["progress"],
}
# Note if there was a mismatch between output parsing and actual status
if not result.success and actual_success:
response["note"] = "Output contained error-like strings but run completed successfully"
return [TextContent(type="text", text=json.dumps(response, indent=2))]
elif name == "generate_bitstream":
# Generate bitstream (programming file)
result = session.run_tcl("launch_runs impl_1 -to_step write_bitstream; wait_on_run impl_1")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"output": result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
# =========================================================================
# REPORTS AND ANALYSIS
# =========================================================================
elif name == "get_timing_summary":
# Get timing summary with parsed metrics
report_type = arguments.get("report_type", "summary")
detail_level = arguments.get("detail_level", "summary")
# Run Vivado timing summary report
result = session.run_tcl("report_timing_summary -no_header -return_string")
# Parse the raw output into structured data
parsed = parse_timing_summary(result.output)
parsed["success"] = result.success
parsed["elapsed_ms"] = result.elapsed_ms
# Control output verbosity based on detail_level
if detail_level == "summary":
# Only return parsed metrics, no raw output
parsed.pop("raw", None)
elif detail_level == "standard":
# Truncate raw output if too large (half of max to leave room for other data)
if "raw" in parsed and len(parsed["raw"]) > MAX_RESPONSE_CHARS // 2:
truncated = truncate_response(parsed["raw"], MAX_RESPONSE_CHARS // 2)
parsed["raw"] = truncated["content"]
if truncated["truncated"]:
parsed["raw_truncated"] = True
parsed["raw_total_chars"] = truncated["total_chars"]
elif detail_level == "full":
# Keep complete raw output but apply safety truncation
if "raw" in parsed:
truncated = truncate_response(parsed["raw"], MAX_RESPONSE_CHARS)
parsed["raw"] = truncated["content"]
if truncated["truncated"]:
parsed["raw_truncated"] = True
parsed["raw_total_chars"] = truncated["total_chars"]
parsed["truncation_message"] = truncated["truncation_message"]
return [TextContent(type="text", text=json.dumps(parsed, indent=2))]
elif name == "get_timing_paths":
# Get detailed timing path information
# Useful for debugging timing violations
num_paths = arguments.get("num_paths", 10)
slack_threshold = arguments.get("slack_threshold", 0) # 0 = failing paths only
path_type = arguments.get("path_type", "setup")
from_pin = arguments.get("from_pin")
to_pin = arguments.get("to_pin")
through = arguments.get("through")
clock = arguments.get("clock")
detail_level = arguments.get("detail_level", "summary")
# Build the report_timing command
delay_type = "max" if path_type == "setup" else "min"
cmd = f"report_timing -delay_type {delay_type} -max_paths {num_paths} -slack_lesser_than {slack_threshold}"
# Add optional path filters
if from_pin:
cmd += f" -from {{{from_pin}}}"
if to_pin:
cmd += f" -to {{{to_pin}}}"
if through:
cmd += f" -through {{{through}}}"
if clock:
cmd += f" -filter {{CLOCK == {clock}}}"
cmd += " -return_string"
result = session.run_tcl(cmd)
# Build response with filter information
response = {
"success": result.success,
"elapsed_ms": result.elapsed_ms,
"filters_applied": {
"path_type": path_type,
"num_paths": num_paths,
"slack_threshold": slack_threshold
}
}
# Include any filters that were used
if from_pin:
response["filters_applied"]["from_pin"] = from_pin
if to_pin:
response["filters_applied"]["to_pin"] = to_pin
if through:
response["filters_applied"]["through"] = through
if clock:
response["filters_applied"]["clock"] = clock
# Handle output based on detail level
if result.success:
# Always parse paths into structured format
parsed_paths = parse_timing_paths_summary(result.output, max_paths=num_paths)
response["paths"] = parsed_paths
response["path_count"] = len(parsed_paths)
if detail_level == "summary":
# Only return structured data, no raw output
pass
elif detail_level == "standard":
# Include truncated raw for reference
truncated = truncate_response(result.output, MAX_RESPONSE_CHARS // 2)
response["raw"] = truncated["content"]
if truncated["truncated"]:
response["raw_truncated"] = True
response["raw_total_chars"] = truncated["total_chars"]
elif detail_level == "full":
# Include complete raw output
truncated = truncate_response(result.output, MAX_RESPONSE_CHARS)
response["raw"] = truncated["content"]
if truncated["truncated"]:
response["raw_truncated"] = True
response["raw_total_chars"] = truncated["total_chars"]
response["truncation_message"] = truncated["truncation_message"]
else:
response["error"] = result.output
return [TextContent(type="text", text=json.dumps(response, indent=2))]
elif name == "get_utilization":
# Get resource utilization with parsed metrics
hierarchical = arguments.get("hierarchical", False)
detail_level = arguments.get("detail_level", "summary")
module_filter = arguments.get("module_filter")
threshold_percent = arguments.get("threshold_percent")
# Build utilization report command
cmd = "report_utilization -return_string"
if hierarchical:
cmd += " -hierarchical"
if module_filter:
cmd += f" -hierarchical_pattern {{{module_filter}}}"
result = session.run_tcl(cmd)
# Parse into structured data
parsed = parse_utilization(result.output)
parsed["success"] = result.success
parsed["elapsed_ms"] = result.elapsed_ms
# Apply threshold filter if specified
if threshold_percent is not None:
for resource in ["lut", "ff", "bram", "dsp", "io"]:
if resource in parsed and parsed[resource]["percent"] < threshold_percent:
parsed[resource]["below_threshold"] = True
# Control output verbosity
if detail_level == "summary":
parsed.pop("raw", None)
elif detail_level == "standard":
if "raw" in parsed and len(parsed["raw"]) > MAX_RESPONSE_CHARS // 2:
truncated = truncate_response(parsed["raw"], MAX_RESPONSE_CHARS // 2)
parsed["raw"] = truncated["content"]
if truncated["truncated"]:
parsed["raw_truncated"] = True
parsed["raw_total_chars"] = truncated["total_chars"]
elif detail_level == "full":
if "raw" in parsed:
truncated = truncate_response(parsed["raw"], MAX_RESPONSE_CHARS)
parsed["raw"] = truncated["content"]
if truncated["truncated"]:
parsed["raw_truncated"] = True
parsed["raw_total_chars"] = truncated["total_chars"]
parsed["truncation_message"] = truncated["truncation_message"]
return [TextContent(type="text", text=json.dumps(parsed, indent=2))]
elif name == "get_clocks":
# Get clock information from the design
result = session.run_tcl("report_clocks -return_string")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"clocks": result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "get_messages":
# Get Vivado messages filtered by severity
severity = arguments.get("severity", "all")
result = session.run_tcl("get_msg_config -rules")
parsed = parse_messages(result.output)
# Apply severity filter
if severity != "all":
filtered = {
"error": parsed["errors"],
"critical": parsed["critical_warnings"],
"warning": parsed["warnings"]
}.get(severity, [])
parsed = {severity: filtered, "raw": parsed["raw"]}
parsed["success"] = result.success
return [TextContent(type="text", text=json.dumps(parsed, indent=2))]
# =========================================================================
# DESIGN QUERIES
# =========================================================================
elif name == "get_design_hierarchy":
# Get the design hierarchy (instances and modules)
max_depth = arguments.get("max_depth", 3)
instance_pattern = arguments.get("instance_pattern", "*")
# Get all hierarchical cells matching the pattern
cmd = f"get_cells -hierarchical {{{instance_pattern}}}"
result = session.run_tcl(cmd)
if result.success and result.output.strip():
cells = result.output.strip().split()
# Filter by hierarchy depth (count '/' separators)
filtered_cells = []
for cell in cells:
depth = get_hierarchy_depth(cell)
if depth <= max_depth:
filtered_cells.append(cell)
# Build a hierarchical structure for easier visualization
hierarchy = {}
for cell in sorted(filtered_cells):
parts = cell.split('/')
current = hierarchy
for i, part in enumerate(parts):
if part not in current:
current[part] = {"_children": {}}
current = current[part]["_children"]
# Get module reference for each cell (limited for performance)
cell_refs = {}
sample_cells = filtered_cells[:100]
for cell in sample_cells:
ref_result = session.run_tcl(f"get_property REF_NAME [get_cells {{{cell}}}]")
if ref_result.success and ref_result.output.strip():
cell_refs[cell] = ref_result.output.strip()
response = {
"success": True,
"cells": filtered_cells[:500], # Limit for response size
"cell_count": len(filtered_cells),
"cell_modules": cell_refs,
"max_depth": max_depth,
"elapsed_ms": result.elapsed_ms
}
if len(filtered_cells) > 500:
response["truncated"] = True
response["total_cells"] = len(filtered_cells)
response["message"] = "Cell list truncated. Use instance_pattern to filter or generate_full_report for complete hierarchy."
else:
response = {
"success": result.success,
"cells": [],
"cell_count": 0,
"error": result.output if not result.success else "No cells found",
"elapsed_ms": result.elapsed_ms
}
return [TextContent(type="text", text=json.dumps(response, indent=2))]
elif name == "get_ports":
# Get top-level I/O ports
result = session.run_tcl("get_ports *")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"ports": result.output.split() if result.success else [],
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "get_nets":
# Search for nets by pattern
pattern = arguments.get("pattern", "*")
limit = arguments.get("limit", 100)
# Use lrange to limit results
result = session.run_tcl(f"lrange [get_nets {{{pattern}}}] 0 {limit-1}")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"nets": result.output.split() if result.success else [],
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "get_cells":
# Search for cells/instances by pattern
pattern = arguments.get("pattern", "*")
limit = arguments.get("limit", 100)
result = session.run_tcl(f"lrange [get_cells {{{pattern}}}] 0 {limit-1}")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"cells": result.output.split() if result.success else [],
"elapsed_ms": result.elapsed_ms
}, indent=2))]
# =========================================================================
# RAW TCL
# =========================================================================
elif name == "run_tcl":
# Execute arbitrary TCL command (escape hatch for advanced users)
command = arguments.get("command", "")
result = session.run_tcl(command)
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"output": result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
# =========================================================================
# SIMULATION TOOLS
# =========================================================================
elif name == "launch_simulation":
# Launch Vivado's integrated simulator (xsim)
mode = arguments.get("mode", "behavioral")
# Map friendly names to Vivado's mode strings
mode_map = {
"behavioral": "behav", # RTL simulation
"post_synth_func": "synth -type func", # Post-synthesis functional
"post_synth_timing": "synth -type timing", # Post-synthesis with timing
"post_impl_func": "impl -type func", # Post-implementation functional
"post_impl_timing": "impl -type timing" # Post-implementation with timing
}
sim_mode = mode_map.get(mode, "behav")
result = session.run_tcl(f"launch_simulation -mode {sim_mode}")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"message": result.output if result.output else f"Simulation launched in {mode} mode",
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "run_simulation":
# Advance simulation time
time_val = arguments.get("time", "100ns")
if time_val.lower() == "all":
# Run until all events processed (testbench completes)
result = session.run_tcl("run -all")
else:
result = session.run_tcl(f"run {time_val}")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"output": result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "restart_simulation":
# Reset simulation to time 0
result = session.run_tcl("restart")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"message": "Simulation restarted" if result.success else result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "close_simulation":
# Close the simulator
result = session.run_tcl("close_sim")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"message": "Simulation closed" if result.success else result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "get_simulation_time":
# Get current simulation time
result = session.run_tcl("current_time")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"time": result.output.strip() if result.success else None,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "get_signal_value":
# Get current value of a single signal
signal = arguments.get("signal", "")
radix = arguments.get("radix", "hex")
result = session.run_tcl(f"get_value -radix {radix} {{{signal}}}")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"signal": signal,
"value": result.output.strip() if result.success else None,
"radix": radix,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "get_signal_values":
# Get values of multiple signals matching a pattern
pattern = arguments.get("pattern", "/*")
radix = arguments.get("radix", "hex")
# First get list of signals matching pattern
signals_result = session.run_tcl(f"get_objects -filter {{TYPE == signal || TYPE == port}} {{{pattern}}}")
if signals_result.success and signals_result.output.strip():
signals = signals_result.output.strip().split()
values = {}
# Limit to 50 signals to avoid overwhelming response
for sig in signals[:50]:
val_result = session.run_tcl(f"get_value -radix {radix} {{{sig}}}")
if val_result.success:
values[sig] = val_result.output.strip()
return [TextContent(type="text", text=json.dumps({
"success": True,
"values": values,
"radix": radix,
"elapsed_ms": signals_result.elapsed_ms
}, indent=2))]
return [TextContent(type="text", text=json.dumps({
"success": False,
"error": "No signals found matching pattern",
"elapsed_ms": signals_result.elapsed_ms
}, indent=2))]
elif name == "add_signals_to_wave":
# Add signals to waveform viewer
signals = arguments.get("signals", [])
if isinstance(signals, str):
signals = [signals]
results = []
for sig in signals:
result = session.run_tcl(f"add_wave {{{sig}}}")
results.append({"signal": sig, "success": result.success})
return [TextContent(type="text", text=json.dumps({
"success": all(r["success"] for r in results),
"results": results
}, indent=2))]
elif name == "set_simulation_top":
# Set the top-level testbench module
top_module = arguments.get("top_module", "")
fileset = arguments.get("fileset", "sim_1")
result = session.run_tcl(f"set_property top {top_module} [get_filesets {fileset}]")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"message": f"Set simulation top to {top_module}" if result.success else result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "get_simulation_objects":
# List simulation objects (signals, ports, variables) in a scope
scope = arguments.get("scope", "/")
obj_filter = arguments.get("filter", "all")
# Map filter names to Vivado filter expressions
filter_map = {
"all": "",
"signals": "-filter {TYPE == signal}",
"ports": "-filter {TYPE == port}",
"internal": "-filter {TYPE == signal && IS_PORT == false}"
}
filter_str = filter_map.get(obj_filter, "")
result = session.run_tcl(f"get_objects {filter_str} {{{scope}/*}}")
objects = result.output.strip().split() if result.success and result.output.strip() else []
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"scope": scope,
"objects": objects,
"count": len(objects),
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "get_scopes":
# List child scopes (hierarchy levels) in simulation
parent = arguments.get("parent", "/")
result = session.run_tcl(f"get_scopes {{{parent}/*}}")
scopes = result.output.strip().split() if result.success and result.output.strip() else []
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"parent": parent,
"scopes": scopes,
"count": len(scopes),
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "step_simulation":
# Step simulation by delta cycles
count = arguments.get("count", 1)
result = session.run_tcl(f"step {count}")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"output": result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "add_breakpoint":
# Add a breakpoint on signal edge or change
signal = arguments.get("signal", "")
condition = arguments.get("condition", "change")
# Map condition names to Vivado flags
cond_map = {
"posedge": "-posedge", # Rising edge
"negedge": "-negedge", # Falling edge
"change": "" # Any change
}
cond_str = cond_map.get(condition, "")
result = session.run_tcl(f"add_bp {cond_str} {{{signal}}}")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"signal": signal,
"condition": condition,
"message": result.output if result.output else f"Breakpoint added on {signal}",
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "remove_breakpoints":
# Remove all breakpoints
result = session.run_tcl("remove_bps -all")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"message": "All breakpoints removed" if result.success else result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "get_simulation_messages":
# Get simulation log messages
severity = arguments.get("severity", "all")
if severity == "all":
result = session.run_tcl("get_msg_config -count")
else:
result = session.run_tcl(f"get_msg_config -count -severity {{{severity}}}")
return [TextContent(type="text", text=json.dumps({
"success": result.success,
"messages": result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
# =========================================================================
# FEATURE REQUESTS
# =========================================================================
elif name == "request_feature":
# Submit a feature request for future development
title = arguments.get("title", "")
description = arguments.get("description", "")
use_case = arguments.get("use_case", "")
priority = arguments.get("priority", "medium")
request = {
"id": len(load_feature_requests()) + 1,
"title": title,
"description": description,
"use_case": use_case,
"priority": priority,
"timestamp": datetime.now().isoformat(),
"status": "pending"
}
save_feature_request(request)
return [TextContent(type="text", text=json.dumps({
"success": True,
"message": f"Feature request #{request['id']} submitted: {title}",
"request": request
}, indent=2))]
elif name == "list_feature_requests":
# List all submitted feature requests
requests = load_feature_requests()
return [TextContent(type="text", text=json.dumps({
"success": True,
"total": len(requests),
"requests": requests
}, indent=2))]
# =========================================================================
# REPORT FILE MANAGEMENT
# =========================================================================
elif name == "generate_full_report":
# Generate a complete report to a file (for large reports)
report_type = arguments.get("report_type", "timing")
options = arguments.get("options", {})
output_file = arguments.get("output_file")
# Ensure reports directory exists and clean up old files
ensure_reports_dir()
# Generate unique report ID and file path
report_id = generate_report_id()
if output_file:
file_path = Path(output_file)
else:
file_path = REPORTS_DIR / f"{report_type}_{report_id}.txt"
# Map report types to Vivado commands
report_commands = {
"timing": "report_timing -max_paths 100",
"timing_summary": "report_timing_summary",
"utilization": "report_utilization",
"hierarchy": "report_hierarchy",
"clocks": "report_clocks",
"power": "report_power",
"drc": "report_drc" # Design Rule Check
}
base_cmd = report_commands.get(report_type, f"report_{report_type}")
# Apply report-specific options
if report_type == "utilization" and options.get("hierarchical"):
base_cmd += " -hierarchical"
if report_type == "timing" and options.get("num_paths"):
base_cmd = base_cmd.replace("-max_paths 100", f"-max_paths {options['num_paths']}")
# Write directly to file using Vivado's -file option
cmd = f"{base_cmd} -file {{{file_path}}}"
result = session.run_tcl(cmd)
if result.success:
try:
# Get file statistics
file_stat = file_path.stat()
line_count = sum(1 for _ in open(file_path))
# Cache report metadata for later lookup
_report_cache[report_id] = {
"file_path": str(file_path),
"report_type": report_type,
"created": datetime.now().isoformat(),
"size_bytes": file_stat.st_size,
"line_count": line_count
}
return [TextContent(type="text", text=json.dumps({
"success": True,
"report_id": report_id,
"file_path": str(file_path),
"report_type": report_type,
"size_bytes": file_stat.st_size,
"line_count": line_count,
"message": f"Report written to {file_path}. Use read_report_section to read portions.",
"elapsed_ms": result.elapsed_ms
}, indent=2))]
except (OSError, IOError) as e:
return [TextContent(type="text", text=json.dumps({
"success": False,
"error": f"Report generated but could not read file info: {e}",
"file_path": str(file_path),
"elapsed_ms": result.elapsed_ms
}, indent=2))]
else:
return [TextContent(type="text", text=json.dumps({
"success": False,
"error": result.output,
"elapsed_ms": result.elapsed_ms
}, indent=2))]
elif name == "read_report_section":
# Read a portion of a previously generated report
report_id = arguments.get("report_id")
file_path = arguments.get("file_path")
start_line = arguments.get("start_line", 1)
num_lines = arguments.get("num_lines", 100)
search_pattern = arguments.get("search_pattern")
# Resolve file path from report_id if provided
if report_id:
if report_id in _report_cache:
file_path = _report_cache[report_id]["file_path"]
else:
# Try to find file in reports directory by ID
possible_files = list(REPORTS_DIR.glob(f"*_{report_id}.txt"))
if possible_files:
file_path = str(possible_files[0])
else:
return [TextContent(type="text", text=json.dumps({
"success": False,
"error": f"Report ID '{report_id}' not found in cache or reports directory"
}, indent=2))]
if not file_path:
return [TextContent(type="text", text=json.dumps({
"success": False,
"error": "Either report_id or file_path must be provided"
}, indent=2))]
try:
file_path = Path(file_path)
if not file_path.exists():
return [TextContent(type="text", text=json.dumps({
"success": False,
"error": f"File not found: {file_path}"
}, indent=2))]
# Read all lines from file
with open(file_path, 'r') as f:
all_lines = f.readlines()
total_lines = len(all_lines)
# Handle search pattern - find and return context around match
if search_pattern:
pattern = re.compile(search_pattern, re.IGNORECASE)
for i, line in enumerate(all_lines):
if pattern.search(line):
# Found match, return context around it
context_before = num_lines // 4
context_after = num_lines - context_before
start_line = max(1, i + 1 - context_before)
break
else:
return [TextContent(type="text", text=json.dumps({
"success": True,
"warning": f"Pattern '{search_pattern}' not found in file",
"total_lines": total_lines,
"file_path": str(file_path)
}, indent=2))]
# Extract requested line range (1-indexed to 0-indexed)
start_idx = max(0, start_line - 1)
end_idx = min(total_lines, start_idx + num_lines)
selected_lines = all_lines[start_idx:end_idx]
content = ''.join(selected_lines)
return [TextContent(type="text", text=json.dumps({
"success": True,
"file_path": str(file_path),
"start_line": start_idx + 1,
"end_line": end_idx,
"total_lines": total_lines,
"returned_lines": len(selected_lines),
"content": content
}, indent=2))]
except (OSError, IOError) as e:
return [TextContent(type="text", text=json.dumps({
"success": False,
"error": f"Error reading file: {e}"
}, indent=2))]
# =========================================================================
# UNKNOWN TOOL
# =========================================================================
return [TextContent(type="text", text=json.dumps({"error": f"Unknown tool: {name}"}, indent=2))]
# =============================================================================
# SERVER ENTRY POINT
# =============================================================================
async def main():
"""
Run the MCP server.
This function starts the MCP server using stdio transport (stdin/stdout).
It's designed to be launched by an MCP client like Claude Code.
The server runs until the client closes the connection or sends an
exit signal.
"""
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
# Allow running directly with: python server.py
if __name__ == "__main__":
import asyncio
asyncio.run(main())