Skip to content

Commit 81249e2

Browse files
cristipufuclaude
andcommitted
feat: add mcp server, cli agent terminal, explorer canvas highlighting
- add stdio mcp server (uipath-dev-mcp) exposing run api as mcp tools so cli agents can execute runs server-side with real-time websocket events and graph visualization - add cli agent terminal with pty support and clipboard handling - add explorer canvas execution highlighting during runs - broadcast run/state events to all ws clients (not just subscribers) - remove in-house coding agent implementation and openai dependency - bump version to 0.0.74 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dac1cd2 commit 81249e2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+2339
-6096
lines changed

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-dev"
3-
version = "0.0.73"
3+
version = "0.0.74"
44
description = "UiPath Developer Console"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
@@ -11,8 +11,9 @@ dependencies = [
1111
"fastapi>=0.128.8",
1212
"uvicorn[standard]>=0.40.0",
1313
"uipath>=2.10.0, <2.11.0",
14-
"openai",
1514
"aiosqlite>=0.20.0",
15+
"pywinpty>=2.0.0; sys_platform == 'win32'",
16+
"mcp[cli]>=1.0.0",
1617
]
1718
classifiers = [
1819
"Intended Audience :: Developers",
@@ -34,6 +35,7 @@ Documentation = "https://uipath.github.io/uipath-python/"
3435
[project.scripts]
3536
uipath-dev = "uipath.dev.__mock__:main"
3637
uipath-dev-server = "uipath.dev.__mock_server__:main"
38+
uipath-dev-mcp = "uipath.dev.mcp:main"
3739

3840
[build-system]
3941
requires = ["hatchling"]

src/uipath/dev/mcp/__init__.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""UiPath Dev MCP Server — exposes dev server run tools via stdio."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
from typing import Any
8+
9+
import httpx
10+
import websockets
11+
from mcp.server.fastmcp import Context, FastMCP
12+
13+
mcp = FastMCP(
14+
"uipath-dev",
15+
instructions=(
16+
"This MCP server is the ONLY way to run, test, or execute agents, "
17+
"functions, and projects. Do NOT use shell commands like "
18+
"`uipath run`, `langgraph dev`, `python -m`, or any subprocess. "
19+
"Always use the tools provided here instead."
20+
),
21+
)
22+
23+
24+
def _base_url() -> str:
25+
port = os.environ.get("UIPATH_DEV_SERVER_PORT", "8080")
26+
host = os.environ.get("UIPATH_DEV_SERVER_HOST", "localhost")
27+
return f"http://{host}:{port}"
28+
29+
30+
def _ws_url() -> str:
31+
port = os.environ.get("UIPATH_DEV_SERVER_PORT", "8080")
32+
host = os.environ.get("UIPATH_DEV_SERVER_HOST", "localhost")
33+
return f"ws://{host}:{port}/ws"
34+
35+
36+
def _api_url(path: str) -> str:
37+
return f"{_base_url()}/api{path}"
38+
39+
40+
async def _report_tool_call(tool: str, args: dict[str, Any] | None = None) -> None:
41+
"""Notify the dev server that an MCP tool was invoked."""
42+
try:
43+
async with httpx.AsyncClient() as client:
44+
await client.post(
45+
_api_url("/mcp/events"),
46+
json={"tool": tool, "args": args or {}},
47+
timeout=5,
48+
)
49+
except Exception:
50+
pass # Best-effort — don't fail the tool if reporting fails
51+
52+
53+
@mcp.tool()
54+
async def list_entrypoints() -> list[dict[str, Any]]:
55+
"""List all runnable agents, functions, and projects.
56+
57+
Call this first to discover what is available to run.
58+
Use the returned names with get_entrypoint_schema or run_entrypoint.
59+
"""
60+
await _report_tool_call("list_entrypoints")
61+
async with httpx.AsyncClient() as client:
62+
resp = await client.get(_api_url("/entrypoints"), timeout=10)
63+
resp.raise_for_status()
64+
return resp.json()
65+
66+
67+
@mcp.tool()
68+
async def get_entrypoint_schema(entrypoint: str) -> dict[str, Any]:
69+
"""Get the input/output schema for an entrypoint.
70+
71+
Args:
72+
entrypoint: Name of the entrypoint (from list_entrypoints).
73+
74+
Returns the JSON schema describing the entrypoint's expected
75+
input and output. Use this to construct input_data for run_entrypoint.
76+
"""
77+
await _report_tool_call("get_entrypoint_schema", {"entrypoint": entrypoint})
78+
async with httpx.AsyncClient() as client:
79+
resp = await client.get(
80+
_api_url(f"/entrypoints/{entrypoint}/schema"),
81+
timeout=30,
82+
)
83+
resp.raise_for_status()
84+
return resp.json()
85+
86+
87+
@mcp.tool()
88+
async def run_entrypoint(
89+
entrypoint: str,
90+
ctx: Context, # type: ignore[type-arg]
91+
input_data: dict[str, Any] | None = None,
92+
) -> dict[str, Any]:
93+
"""Run an agent, function, or project.
94+
95+
This is the ONLY correct way to execute code in this project — never
96+
use shell commands.
97+
98+
Args:
99+
entrypoint: Name of the entrypoint (from list_entrypoints).
100+
input_data: Input data matching the entrypoint's input schema.
101+
Use get_entrypoint_schema to see what fields are expected.
102+
Defaults to empty dict if not provided.
103+
104+
Executes on the dev server with real-time graph visualization in the
105+
browser. Streams progress as nodes execute, then returns the result.
106+
"""
107+
await _report_tool_call(
108+
"run_entrypoint", {"entrypoint": entrypoint, "input_data": input_data}
109+
)
110+
async with httpx.AsyncClient() as client:
111+
resp = await client.post(
112+
_api_url("/runs"),
113+
json={
114+
"entrypoint": entrypoint,
115+
"input_data": input_data or {},
116+
"mode": "run",
117+
},
118+
timeout=10,
119+
)
120+
resp.raise_for_status()
121+
run: dict[str, Any] = resp.json()
122+
run_id = run["id"]
123+
124+
await ctx.log("info", f"Run {run_id} created — streaming updates...")
125+
126+
# Connect to WebSocket and subscribe to run events
127+
terminal_statuses = {"completed", "failed", "suspended"}
128+
final_status = None
129+
130+
async with websockets.connect(_ws_url()) as ws:
131+
# Subscribe to this run's events
132+
await ws.send(json.dumps({"type": "subscribe", "payload": {"run_id": run_id}}))
133+
134+
async for raw in ws:
135+
msg = json.loads(raw)
136+
msg_type = msg.get("type", "")
137+
payload = msg.get("payload", {})
138+
139+
if msg_type == "run.updated" and payload.get("id") == run_id:
140+
status = payload.get("status", "")
141+
await ctx.report_progress(
142+
progress=1 if status in terminal_statuses else 0,
143+
total=1,
144+
message=f"Run status: {status}",
145+
)
146+
if status in terminal_statuses:
147+
final_status = status
148+
break
149+
150+
elif msg_type == "state" and payload.get("run_id") == run_id:
151+
node = payload.get("node_name", "")
152+
phase = payload.get("phase", "")
153+
if phase == "started":
154+
await ctx.log("info", f"Node '{node}' started")
155+
elif phase == "completed":
156+
await ctx.log("info", f"Node '{node}' completed")
157+
158+
elif msg_type == "log" and payload.get("run_id") == run_id:
159+
level = payload.get("level", "info").lower()
160+
message = payload.get("message", "")
161+
await ctx.log(level, message)
162+
163+
# Fetch final run result
164+
async with httpx.AsyncClient() as client:
165+
resp = await client.get(_api_url(f"/runs/{run_id}"), timeout=10)
166+
resp.raise_for_status()
167+
result: dict[str, Any] = resp.json()
168+
169+
if final_status == "failed":
170+
error = result.get("error", {})
171+
await ctx.log("error", f"Run failed: {error.get('detail', 'unknown error')}")
172+
else:
173+
await ctx.log("info", f"Run {final_status}: {run_id}")
174+
175+
return result
176+
177+
178+
@mcp.tool()
179+
async def get_run_status(run_id: str) -> dict[str, Any]:
180+
"""Get current status and details of a run.
181+
182+
Args:
183+
run_id: The run ID returned by run_entrypoint.
184+
185+
Returns full run details including status, output, traces, and logs.
186+
"""
187+
await _report_tool_call("get_run_status", {"run_id": run_id})
188+
async with httpx.AsyncClient() as client:
189+
resp = await client.get(_api_url(f"/runs/{run_id}"), timeout=10)
190+
resp.raise_for_status()
191+
return resp.json()
192+
193+
194+
def main() -> None:
195+
"""Entry point for the uipath-dev-mcp CLI command."""
196+
mcp.run(transport="stdio")

src/uipath/dev/server/__init__.py

Lines changed: 21 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,9 @@
2727
from uipath.dev.models.eval_data import EvalItemResult, EvalRunState
2828
from uipath.dev.models.execution import ExecutionRun
2929
from uipath.dev.server.debug_bridge import WebDebugBridge
30-
from uipath.dev.services.agent import AgentService
30+
from uipath.dev.services.cli_agent import CliAgentService
3131
from uipath.dev.services.eval_service import EvalService
3232
from uipath.dev.services.run_service import RunService
33-
from uipath.dev.services.skill_service import SkillService
3433

3534
logger = logging.getLogger(__name__)
3635

@@ -98,11 +97,11 @@ def __init__(
9897
on_eval_run_completed=self._on_eval_run_completed,
9998
)
10099

101-
self.skill_service = SkillService()
102-
103-
self.agent_service = AgentService(
104-
skill_service=self.skill_service,
105-
on_event=self._on_agent_event,
100+
self.cli_agent_service = CliAgentService(
101+
on_output=self._on_cli_agent_output,
102+
on_exit=self._on_cli_agent_exit,
103+
server_port=self.port,
104+
server_host=self.host,
106105
)
107106

108107
def create_app(self) -> Any:
@@ -140,12 +139,17 @@ async def run_async(self) -> None:
140139
log_level="warning",
141140
)
142141
server = uvicorn.Server(config)
143-
await server.serve()
142+
try:
143+
await server.serve()
144+
finally:
145+
await self.shutdown()
144146

145147
async def shutdown(self) -> None:
146148
"""Clean up resources before shutting down."""
147149
logger.info("Shutting down server resources...")
148150
self._stop_watcher()
151+
# Stop any active CLI agent PTY sessions
152+
await self.cli_agent_service.stop_all_sessions()
149153
# Close any active WebSocket connections
150154
await self.connection_manager.disconnect_all()
151155
# Give threads time to finish
@@ -319,61 +323,16 @@ def _on_eval_run_completed(self, run: EvalRunState) -> None:
319323
"""Broadcast eval run completed to all connected clients."""
320324
self.connection_manager.broadcast_eval_run_completed(run)
321325

322-
def _on_agent_event(self, event: Any) -> None:
323-
"""Route agent events to the appropriate broadcast method."""
324-
from uipath.dev.services.agent import (
325-
ErrorOccurred,
326-
PlanUpdated,
327-
StatusChanged,
328-
TextDelta,
329-
TextGenerated,
330-
ThinkingGenerated,
331-
TokenUsageUpdated,
332-
ToolApprovalRequired,
333-
ToolCompleted,
334-
ToolStarted,
335-
UserQuestionAsked,
336-
)
326+
def _on_cli_agent_output(self, session_id: str, data: bytes) -> None:
327+
"""Broadcast CLI agent PTY output as base64."""
328+
import base64
329+
330+
encoded = base64.b64encode(data).decode("ascii")
331+
self.connection_manager.broadcast_cli_agent_output(session_id, encoded)
337332

338-
cm = self.connection_manager
339-
match event:
340-
case StatusChanged(session_id=sid, status=status):
341-
cm.broadcast_agent_status(sid, status)
342-
case TextGenerated(session_id=sid, content=content, done=done):
343-
cm.broadcast_agent_text(sid, content, done)
344-
case TextDelta(session_id=sid, delta=delta):
345-
cm.broadcast_agent_text_delta(sid, delta)
346-
case ThinkingGenerated(session_id=sid, content=content):
347-
cm.broadcast_agent_thinking(sid, content)
348-
case PlanUpdated(session_id=sid, items=items):
349-
cm.broadcast_agent_plan(sid, items)
350-
case ToolStarted(session_id=sid, tool_call_id=tcid, tool=tool, args=args):
351-
cm.broadcast_agent_tool_use(sid, tcid, tool, args)
352-
case ToolCompleted(
353-
session_id=sid,
354-
tool_call_id=tcid,
355-
tool=tool,
356-
result=result,
357-
is_error=is_error,
358-
):
359-
cm.broadcast_agent_tool_result(sid, tcid, tool, result, is_error)
360-
case ToolApprovalRequired(
361-
session_id=sid, tool_call_id=tcid, tool=tool, args=args
362-
):
363-
cm.broadcast_agent_tool_approval(sid, tcid, tool, args)
364-
case UserQuestionAsked(
365-
session_id=sid, question_id=qid, question=q, options=opts
366-
):
367-
cm.broadcast_agent_question(sid, qid, q, opts)
368-
case ErrorOccurred(session_id=sid, message=message):
369-
cm.broadcast_agent_error(sid, message)
370-
case TokenUsageUpdated(
371-
session_id=sid,
372-
prompt_tokens=pt,
373-
completion_tokens=ct,
374-
total_session_tokens=total,
375-
):
376-
cm.broadcast_agent_token_usage(sid, pt, ct, total)
333+
def _on_cli_agent_exit(self, session_id: str, exit_code: int) -> None:
334+
"""Broadcast CLI agent process exit."""
335+
self.connection_manager.broadcast_cli_agent_exit(session_id, exit_code)
377336

378337
@staticmethod
379338
def _find_free_port(host: str, start_port: int, max_attempts: int = 100) -> int:

src/uipath/dev/server/app.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,6 @@ async def _config():
150150
return {"auth_enabled": auth_enabled, **_user_project}
151151

152152
# Register routes
153-
from uipath.dev.server.routes.agent import router as agent_router
154153
from uipath.dev.server.routes.entrypoints import router as entrypoints_router
155154
from uipath.dev.server.routes.evals import router as evals_router
156155
from uipath.dev.server.routes.evaluators import router as evaluators_router
@@ -198,9 +197,16 @@ def _on_reload_done(t: asyncio.Task[None]) -> None:
198197
app.include_router(reload_router, prefix="/api")
199198
app.include_router(evaluators_router, prefix="/api")
200199
app.include_router(evals_router, prefix="/api")
201-
app.include_router(agent_router, prefix="/api")
202200
app.include_router(files_router, prefix="/api")
203201

202+
from uipath.dev.server.routes.cli_agent import router as cli_agent_router
203+
204+
app.include_router(cli_agent_router, prefix="/api")
205+
206+
from uipath.dev.server.routes.mcp import router as mcp_router
207+
208+
app.include_router(mcp_router, prefix="/api")
209+
204210
from uipath.dev.server.routes.statedb import router as statedb_router
205211

206212
app.include_router(statedb_router, prefix="/api")

src/uipath/dev/server/frontend/package-lock.json

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)