Skip to content
Draft
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
12 changes: 9 additions & 3 deletions revit_mcp/code_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@


def register_code_execution_routes(api):
"""Register code execution routes with the API."""
"""Register code execution routes with the API.

** WARNING ** : this can run 'ANY' code you give it, this can be a major security risk, only send it code that you trust.

"""

@api.route("/execute_code/", methods=["POST"])
def execute_code(doc, uidoc, request):
Expand All @@ -26,10 +30,10 @@ def execute_code(doc, uidoc, request):
{
"code": "python code as string",
"description": "optional description of what the code does",
"use_transaction": true # set false for UI ops like switching the active view

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed this because it didn't seem to be used

}
"""
try:

# Parse the request data
data = (
json.loads(request.data)
Expand Down Expand Up @@ -84,7 +88,6 @@ def execute_code(doc, uidoc, request):
except Exception as exec_error:
sys.stdout = old_stdout
partial_output = captured_output.getvalue()
captured_output.close()

error_traceback = traceback.format_exc()
error_type = type(exec_error).__name__
Expand Down Expand Up @@ -136,5 +139,8 @@ def execute_code(doc, uidoc, request):
except Exception as e:
logger.error("Execute code request failed: {}".format(str(e)))
return routes.make_response(data={"error": str(e)}, status=500)
finally:
sys.stdout = old_stdout
captured_output.close()

Comment on lines 139 to 145

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new finally block always references old_stdout and captured_output, but those variables are only assigned after request parsing. If JSON parsing (or any earlier step) raises, the finally will raise UnboundLocalError and mask the real error. Initialize old_stdout/captured_output to safe defaults before the outer try, and only restore/close them in finally if they were successfully set.

Copilot uses AI. Check for mistakes.
logger.info("Code execution routes registered successfully.")
163 changes: 163 additions & 0 deletions revit_mcp/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# -*- coding: UTF-8 -*-
"""
Commands Module for Revit MCP
Handles Command execution
"""
from pyrevit import HOST_APP
from pyrevit.coreutils.logger import get_logger
from pyrevit.loader import sessioninfo

import glob
import os

from pyrevit import routes
from pyrevit.routes.server import serverinfo

from pyrevit import routes
Comment on lines +6 to +16

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module imports several names that are never used (HOST_APP, get_logger, sessioninfo, routes, serverinfo), and also duplicates from pyrevit import routes. Please remove unused/duplicate imports to reduce confusion and avoid implying dependencies that aren't required.

Suggested change
from pyrevit import HOST_APP
from pyrevit.coreutils.logger import get_logger
from pyrevit.loader import sessioninfo
import glob
import os
from pyrevit import routes
from pyrevit.routes.server import serverinfo
from pyrevit import routes
import glob
import os

Copilot uses AI. Check for mistakes.
import logging

logger = logging.getLogger(__name__)


# Helper methods
def _find_active_log():
# type: ()->(str, int)
"""Return (log_path, current_byte_offset) for the most recent PyRevit log.
returned offset can be used to capture a range of text inside said log.

Not 100% reliable when multiple revits are in play, and file logging must be enabled for this to work.
"""
appdata = os.environ.get('APPDATA', '')
if not appdata:
return None, 0
pattern = os.path.join(appdata, 'pyRevit', '*', 'pyRevit_*_runtime.log')
matches = glob.glob(pattern)
if not matches:
return None, 0
log_path = max(matches, key=os.path.getmtime)
try:
offset = os.path.getsize(log_path)
except OSError:
offset = 0
return log_path, offset


def _read_log_since(log_path, offset):
# type: (str, int)->(str|None)
"""reads log data from a file FROM an offset position"""
try:
with open(log_path, 'rb') as f:
f.seek(offset)
raw = f.read()
return raw.decode('utf-8', errors='replace')
except Exception:
return None

def register_commands_routes(api):
"""Register all commands-related routes with the API"""

@api.route('/commands_list', methods=['GET'])
def get_commands(uiapp):
"""List all loaded pyRevit commands with their control IDs."""
from pyrevit.loader import sessionmgr
commands = sessionmgr.find_all_commands(cache=True)
return [
{
"name": cmd.name,
"control_id": cmd.control_id,
"bundle": cmd.bundle,
"extension": cmd.extension,
"unique_id": cmd.unique_id,
}
for cmd in commands
]
Comment on lines +59 to +73

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_commands returns a raw Python list instead of using routes.make_response(...). Other route modules consistently wrap responses with routes.make_response for a stable JSON shape and status handling (e.g. revit_mcp/status.py, revit_mcp/placement.py). Please wrap this list in routes.make_response(data={...}) (or at least data=commands).

Copilot uses AI. Check for mistakes.

@api.route('/commands_run', methods=['POST'])
def run_command(request, uiapp):
"""Run a pyRevit command by control ID.

Has 2 modes depending on provided body:
wait = true => runs execution immediatly and returns script logs (where possible)
Wait = false => posts command to revit ui thread, to be executed after this api call.
Comment on lines +80 to +81

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spelling in the docstring: immediatly should be immediately. Also Wait = false is inconsistently capitalized compared to wait elsewhere in the same docstring.

Suggested change
wait = true => runs execution immediatly and returns script logs (where possible)
Wait = false => posts command to revit ui thread, to be executed after this api call.
wait = true => runs execution immediately and returns script logs (where possible)
wait = false => posts command to revit ui thread, to be executed after this api call.

Copilot uses AI. Check for mistakes.

Request body (JSON): {
"control_id": "CustomCtrl_%CustomCtrl_%...",
"wait": true (optional, default true — set false for fire-and-forget)
}
"""
from pyrevit.loader import sessionmgr
from datetime import datetime
data = request.data or {}
control_id = data.get('control_id', None)
wait = data.get('wait', True)

Comment on lines +90 to +93

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data = request.data or {} assumes request.data is already a dict. In other POST routes, request.data is often a JSON string and is parsed with json.loads(...). As-is, calling data.get(...) will fail when request.data is a string; parse the JSON like the other endpoints before reading control_id/wait.

Copilot uses AI. Check for mistakes.
if not control_id:
return {"error": "control_id is required in request body"}

# fire-and-forget via PostCommand
if not wait:
command_id = UI.RevitCommandId.LookupCommandId(control_id)
if command_id is None:
return {"error": "Command not found: {}".format(control_id)}
uiapp.PostCommand(command_id)
return {"result": "posted", "control_id": control_id}
Comment on lines +98 to +103

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UI.RevitCommandId.LookupCommandId(...) is used but UI is never imported in this module, which will raise NameError on the wait=false path. Import the required Revit UI namespace (or access it via an existing project pattern) before using UI.RevitCommandId.

Copilot uses AI. Check for mistakes.


cmd = next((c for c in sessionmgr.find_all_commands()
if c.control_id == control_id), None)
if cmd is None:
return {"error": "Command not found: {}".format(control_id)}


# PyRevit reload destroys the IronPython engine mid-execution, so the HTTP
# response can never be sent. Waiting on it will always time out so we don't allow this
# command to be run with 'wait'
if cmd.unique_id == 'pyrevitcore_pyrevit_pyrevit_tools_reload':
return {
"error": "Cannot await PyRevit reload: the reload script destroys "
"the runtime engine before a response can be sent. "
"Use wait=false to fire-and-forget instead."
}
Comment on lines +95 to +120

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_command returns bare dicts for both success and error cases, while other endpoints use routes.make_response(...) to control HTTP status codes and keep a consistent response envelope. Consider returning routes.make_response(data=..., status=...) here as well (e.g. 400 for missing control_id, 404 for unknown commands).

Suggested change
return {"error": "control_id is required in request body"}
# fire-and-forget via PostCommand
if not wait:
command_id = UI.RevitCommandId.LookupCommandId(control_id)
if command_id is None:
return {"error": "Command not found: {}".format(control_id)}
uiapp.PostCommand(command_id)
return {"result": "posted", "control_id": control_id}
cmd = next((c for c in sessionmgr.find_all_commands()
if c.control_id == control_id), None)
if cmd is None:
return {"error": "Command not found: {}".format(control_id)}
# PyRevit reload destroys the IronPython engine mid-execution, so the HTTP
# response can never be sent. Waiting on it will always time out so we don't allow this
# command to be run with 'wait'
if cmd.unique_id == 'pyrevitcore_pyrevit_pyrevit_tools_reload':
return {
"error": "Cannot await PyRevit reload: the reload script destroys "
"the runtime engine before a response can be sent. "
"Use wait=false to fire-and-forget instead."
}
return routes.make_response(
data={"error": "control_id is required in request body"},
status=400,
)
# fire-and-forget via PostCommand
if not wait:
command_id = UI.RevitCommandId.LookupCommandId(control_id)
if command_id is None:
return routes.make_response(
data={"error": "Command not found: {}".format(control_id)},
status=404,
)
uiapp.PostCommand(command_id)
return routes.make_response(
data={"result": "posted", "control_id": control_id},
status=200,
)
cmd = next((c for c in sessionmgr.find_all_commands()
if c.control_id == control_id), None)
if cmd is None:
return routes.make_response(
data={"error": "Command not found: {}".format(control_id)},
status=404,
)
# PyRevit reload destroys the IronPython engine mid-execution, so the HTTP
# response can never be sent. Waiting on it will always time out so we don't allow this
# command to be run with 'wait'
if cmd.unique_id == 'pyrevitcore_pyrevit_pyrevit_tools_reload':
return routes.make_response(
data={
"error": "Cannot await PyRevit reload: the reload script destroys "
"the runtime engine before a response can be sent. "
"Use wait=false to fire-and-forget instead."
},
status=400,
)

Copilot uses AI. Check for mistakes.

# Snapshot log position before any execution so we can return only the
# lines produced by this command.
log_path, log_offset = _find_active_log()

now = datetime.now()
envvars.set_pyrevit_env_var('PYREVIT_HEADLESS', '1')
result = None
except_info = None
try:
mlogger.debug('[HEADLESS:START] command=%s controlId=%s', cmd.unique_id, control_id)
result = sessionmgr.execute_command_cls(cmd.extcmd_type)

except Exception as e:
except_info = except_info
logger.exception(e)
Comment on lines +134 to +136

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the exception handler, except_info = except_info is a no-op and drops the real exception details. Assign except_info from the caught exception (and ideally include traceback) so the response can report what failed.

Copilot uses AI. Check for mistakes.
finally:
mlogger.debug('[HEADLESS:END] command=%s controlId=%s result=%s', cmd.unique_id, control_id, result)
envvars.set_pyrevit_env_var('PYREVIT_HEADLESS', '')
Comment on lines +126 to +139

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

envvars and mlogger are referenced but never defined/imported, so the wait=true execution path will raise NameError. Either import/initialize these (e.g. set mlogger = get_logger(__name__) and import envvars from pyRevit) or use the already-defined logger.

Copilot uses AI. Check for mistakes.

# Read log lines produced between [HEADLESS:START] and [HEADLESS:END].
execution_log = _read_log_since(log_path, log_offset) if log_path else None

response = {
"result": str(result) if result else 'error',
"execution_time": str(datetime.now() - now),
"command": {
"name": cmd.name,
"control_id": cmd.control_id,
"bundle": cmd.bundle,
"extension": cmd.extension,
"unique_id": cmd.unique_id,
},
}
if except_info is not None:
response["error"] = except_info

if execution_log is not None:
response["output"] = execution_log
else:
response["output"] = "Logs are disabled or failed to collect. You can turn them on in the PyRevit Settings"

return response
4 changes: 4 additions & 0 deletions startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ def register_routes():

register_code_execution_routes(api)

from revit_mcp.commands import register_commands_routes

register_commands_routes(api)

logger.info("All MCP routes registered successfully")

except Exception as e:
Expand Down
2 changes: 2 additions & 0 deletions tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def register_tools(mcp_server, revit_get_func, revit_post_func, revit_image_func
from .model_tools import register_model_tools
from .colors_tools import register_colors_tools
from .code_execution_tools import register_code_execution_tools
from .commands_tools import register_commands_tools

# Register tools from each module
register_status_tools(mcp_server, revit_get_func)
Expand All @@ -21,3 +22,4 @@ def register_tools(mcp_server, revit_get_func, revit_post_func, revit_image_func
register_code_execution_tools(
mcp_server, revit_get_func, revit_post_func, revit_image_func
)
register_commands_tools(mcp_server, revit_get_func, revit_post_func)
37 changes: 37 additions & 0 deletions tools/commands_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
"""Commands tools for the MCP server."""
from mcp.server.fastmcp import Context
from .utils import format_response


def register_commands_tools(mcp, revit_get, revit_post, revit_image=None):
"""Register your tools with the MCP server."""

# ---- Tool for the GET request ----
@mcp.tool()
async def list_commands(
ctx: Context = None
) -> str:

"""
Return a list of all pyrevit commands, including control & uniqueid
"""
response = await revit_get("/commands_run", ctx)

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_commands is calling revit_get("/commands_run", ...), but the API route for listing commands is GET /commands_list (and /commands_run is only registered as POST). This will 404 / use the wrong method; update the path to /commands_list.

Suggested change
response = await revit_get("/commands_run", ctx)
response = await revit_get("/commands_list", ctx)

Copilot uses AI. Check for mistakes.
return format_response(response)


# ---- Tool for the POST request ----
@mcp.tool()
async def run_command_by_control_id(control_id: str, config:bool = False, wait:bool=False ,ctx: Context) -> str:
"""
Comment on lines +25 to +26

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

run_command_by_control_id has a non-default parameter (ctx) after default parameters, which is a Python syntax error. Make ctx optional with a default (like other tools) and/or reorder parameters so all non-defaults come first.

Copilot uses AI. Check for mistakes.
Runs a command using its control_id

Args:
control_id: The ID of the command, as found in the revit journal or list_commands tool
config: Run tool in config (shift+click) mode.
wait: Wait for tool to finish and return response

"""
payload = {"control_id": control_id, "config": config, "wait" = wait}
Comment on lines +25 to +35

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool sends a config flag in the payload, but /commands_run currently ignores config entirely (it only reads control_id and wait). Either implement config handling server-side or remove it from the tool payload to avoid a misleading API/tool contract.

Suggested change
async def run_command_by_control_id(control_id: str, config:bool = False, wait:bool=False ,ctx: Context) -> str:
"""
Runs a command using its control_id
Args:
control_id: The ID of the command, as found in the revit journal or list_commands tool
config: Run tool in config (shift+click) mode.
wait: Wait for tool to finish and return response
"""
payload = {"control_id": control_id, "config": config, "wait" = wait}
async def run_command_by_control_id(control_id: str, wait: bool = False, ctx: Context = None) -> str:
"""
Runs a command using its control_id
Args:
control_id: The ID of the command, as found in the revit journal or list_commands tool
wait: Wait for tool to finish and return response
"""
payload = {"control_id": control_id, "wait": wait}

Copilot uses AI. Check for mistakes.
response = await revit_post("/commands_run", payload, ctx)
Comment on lines +35 to +36

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The payload dict uses "wait" = wait, which is invalid Python syntax (assignment inside a dict literal). Use a : for the key/value pair, and ensure the payload keys match what /commands_run actually supports.

Copilot uses AI. Check for mistakes.
return format_response(response)