Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions src/uipath/dev/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,29 @@ def _base_url() -> str:

def _ws_url() -> str:
host = os.environ.get("UIPATH_DEV_SERVER_HOST", "localhost")
return f"ws://{host}:{_get_port()}/ws"
return f"ws://{host}:{_get_port()}/ws?token={_token()}"


def _api_url(path: str) -> str:
return f"{_base_url()}/api{path}"


def _token() -> str:
"""The running server's token, from the file it writes at startup."""
from uipath.dev.server.security import read_token_file

return read_token_file() or ""


def _client() -> httpx.AsyncClient:
"""An HTTP client presenting the dev server token."""
return httpx.AsyncClient(headers={"Authorization": f"Bearer {_token()}"})


async def _report_tool_call(tool: str, args: dict[str, Any] | None = None) -> None:
"""Notify the dev server that an MCP tool was invoked."""
try:
async with httpx.AsyncClient() as client:
async with _client() as client:
await client.post(
_api_url("/mcp/events"),
json={"tool": tool, "args": args or {}},
Expand All @@ -66,7 +78,7 @@ async def list_entrypoints() -> list[dict[str, Any]]:
Use the returned names with get_entrypoint_schema or run_entrypoint.
"""
await _report_tool_call("list_entrypoints")
async with httpx.AsyncClient() as client:
async with _client() as client:
resp = await client.get(_api_url("/entrypoints"), timeout=10)
resp.raise_for_status()
return resp.json()
Expand All @@ -83,7 +95,7 @@ async def get_entrypoint_schema(entrypoint: str) -> dict[str, Any]:
input and output. Use this to construct input_data for run_entrypoint.
"""
await _report_tool_call("get_entrypoint_schema", {"entrypoint": entrypoint})
async with httpx.AsyncClient() as client:
async with _client() as client:
resp = await client.get(
_api_url(f"/entrypoints/{entrypoint}/schema"),
timeout=30,
Expand Down Expand Up @@ -115,7 +127,7 @@ async def run_entrypoint(
await _report_tool_call(
"run_entrypoint", {"entrypoint": entrypoint, "input_data": input_data}
)
async with httpx.AsyncClient() as client:
async with _client() as client:
resp = await client.post(
_api_url("/runs"),
json={
Expand Down Expand Up @@ -169,7 +181,7 @@ async def run_entrypoint(
await ctx.log(level, message)

# Fetch final run result
async with httpx.AsyncClient() as client:
async with _client() as client:
resp = await client.get(_api_url(f"/runs/{run_id}"), timeout=10)
resp.raise_for_status()
result: dict[str, Any] = resp.json()
Expand All @@ -193,7 +205,7 @@ async def get_run_status(run_id: str) -> dict[str, Any]:
Returns full run details including status, output, traces, and logs.
"""
await _report_tool_call("get_run_status", {"run_id": run_id})
async with httpx.AsyncClient() as client:
async with _client() as client:
resp = await client.get(_api_url(f"/runs/{run_id}"), timeout=10)
resp.raise_for_status()
return resp.json()
Expand Down Expand Up @@ -231,7 +243,7 @@ async def list_eval_sets() -> list[dict[str, Any]]:
and attached evaluator IDs. Use the returned IDs with run_eval_set.
"""
await _report_tool_call("list_eval_sets")
async with httpx.AsyncClient() as client:
async with _client() as client:
resp = await client.get(_api_url("/eval-sets"), timeout=10)
resp.raise_for_status()
return resp.json()
Expand All @@ -248,7 +260,7 @@ async def get_eval_set(eval_set_id: str) -> dict[str, Any]:
and evaluation criteria.
"""
await _report_tool_call("get_eval_set", {"eval_set_id": eval_set_id})
async with httpx.AsyncClient() as client:
async with _client() as client:
resp = await client.get(_api_url(f"/eval-sets/{eval_set_id}"), timeout=10)
resp.raise_for_status()
return resp.json()
Expand All @@ -268,7 +280,7 @@ async def run_eval_set(
Returns the full run result with per-item scores and overall score.
"""
await _report_tool_call("run_eval_set", {"eval_set_id": eval_set_id})
async with httpx.AsyncClient() as client:
async with _client() as client:
resp = await client.post(_api_url(f"/eval-sets/{eval_set_id}/runs"), timeout=30)
resp.raise_for_status()
run: dict[str, Any] = resp.json()
Expand Down Expand Up @@ -305,7 +317,7 @@ async def run_eval_set(
break

# Fetch final run detail
async with httpx.AsyncClient() as client:
async with _client() as client:
resp = await client.get(_api_url(f"/eval-runs/{run_id}"), timeout=10)
resp.raise_for_status()
result = resp.json()
Expand All @@ -321,7 +333,7 @@ async def list_eval_runs() -> list[dict[str, Any]]:
and progress. Use run IDs with get_eval_run for full details.
"""
await _report_tool_call("list_eval_runs")
async with httpx.AsyncClient() as client:
async with _client() as client:
resp = await client.get(_api_url("/eval-runs"), timeout=10)
resp.raise_for_status()
return resp.json()
Expand All @@ -337,7 +349,7 @@ async def get_eval_run(eval_run_id: str) -> dict[str, Any]:
Returns per-item evaluator scores and justifications.
"""
await _report_tool_call("get_eval_run", {"eval_run_id": eval_run_id})
async with httpx.AsyncClient() as client:
async with _client() as client:
resp = await client.get(_api_url(f"/eval-runs/{eval_run_id}"), timeout=10)
resp.raise_for_status()
result = resp.json()
Expand Down
28 changes: 24 additions & 4 deletions src/uipath/dev/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@
from uipath.dev.models.eval_data import EvalItemResult, EvalRunState
from uipath.dev.models.execution import ExecutionRun
from uipath.dev.server.debug_bridge import WebDebugBridge
from uipath.dev.server.security import (
TOKEN_FILE,
generate_token,
host_is_wildcard,
write_token_file,
)
from uipath.dev.services.cli_agent import CliAgentService
from uipath.dev.services.eval_service import EvalService
from uipath.dev.services.run_service import RunService
Expand Down Expand Up @@ -72,6 +78,8 @@ def __init__(
self._watcher_stop: asyncio.Event | None = None
self.reload_pending = False

self.auth_token = generate_token()

from uipath.dev.server.ws.manager import ConnectionManager

self.connection_manager = ConnectionManager()
Expand Down Expand Up @@ -100,7 +108,7 @@ def __init__(
on_output=self._on_cli_agent_output,
on_exit=self._on_cli_agent_exit,
server_port=self.port,
server_host=self.host,
server_host=self.addressable_host,
)

def create_app(self) -> Any:
Expand All @@ -124,8 +132,9 @@ async def run_async(self) -> None:
port_file.parent.mkdir(exist_ok=True)
port_file.write_text(str(self.port))

base_url = f"http://{self.host}:{self.port}"
self._print_banner(base_url)
write_token_file(self.auth_token)

self._print_banner(self.console_url)

if self.open_browser:
threading.Thread(
Expand Down Expand Up @@ -153,6 +162,7 @@ async def shutdown(self) -> None:
logger.info("Shutting down server resources...")
port_file = Path(".uipath") / "dev-server.port"
port_file.unlink(missing_ok=True)
(Path(".uipath") / TOKEN_FILE.name).unlink(missing_ok=True)
self._stop_watcher()
# Stop any active CLI agent PTY sessions
await self.cli_agent_service.stop_all_sessions()
Expand Down Expand Up @@ -397,7 +407,17 @@ def _print_banner(base_url: str) -> None:
)
console.print()

@property
def addressable_host(self) -> str:
"""A host a client can dial. A wildcard bind names no single one."""
return "localhost" if host_is_wildcard(self.host) else self.host

@property
def console_url(self) -> str:
"""The URL to open the console with, carrying this run's token."""
return f"http://{self.addressable_host}:{self.port}/?token={self.auth_token}"

def _deferred_open_browser(self) -> None:
"""Open the browser after a short delay to let uvicorn bind."""
time.sleep(1.5)
webbrowser.open(f"http://{self.host}:{self.port}")
webbrowser.open(self.console_url)
62 changes: 56 additions & 6 deletions src/uipath/dev/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,20 @@
import os
from pathlib import Path

from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, JSONResponse

from uipath.dev.server import UiPathDeveloperServer
from uipath.dev.server.security import (
MISDIRECTED,
UNAUTHORIZED,
allowed_hosts,
api_path_requires_token,
bearer_token,
host_is_allowed,
token_matches,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -101,6 +110,32 @@ def create_app(server: UiPathDeveloperServer) -> FastAPI:
version="0.1.0",
)

# Store server reference on app state for route access
app.state.server = server

allowed = allowed_hosts(server.host)

@app.middleware("http")
async def _guard(request: Request, call_next):
"""Refuse a rebound Host, and require the token on /api."""
if not host_is_allowed(request.headers.get("host"), allowed):
return JSONResponse(
{
"detail": "This server does not answer requests addressed to this Host"
},
status_code=MISDIRECTED,
)

if api_path_requires_token(request.url.path):
presented = bearer_token(request.headers.get("authorization"))
if not token_matches(presented, server.auth_token):
return JSONResponse(
{"detail": "Missing or invalid developer server token"},
status_code=UNAUTHORIZED,
)

return await call_next(request)

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
Expand All @@ -109,9 +144,6 @@ def create_app(server: UiPathDeveloperServer) -> FastAPI:
allow_headers=["*"],
)

# Store server reference on app state for route access
app.state.server = server

auth_enabled = os.environ.get("UIPATH_AUTH_ENABLED", "true").lower() not in (
"false",
"0",
Expand Down Expand Up @@ -221,7 +253,25 @@ def _on_reload_done(t: asyncio.Task[None]) -> None:
if frontend_ready and (STATIC_DIR / "index.html").exists():
from fastapi.staticfiles import StaticFiles

app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
class _RevalidatedHtml(StaticFiles):
"""Cache the hashed assets, never the document that names them.

A cached index.html pins a browser to asset hashes a rebuild has
already deleted, so the tab keeps running code that is no longer
served and cannot pick up a new token.
"""

def file_response(self, full_path, stat_result, scope, status_code=200):
response = super().file_response(
full_path, stat_result, scope, status_code
)
if str(full_path).endswith(".html"):
response.headers["Cache-Control"] = "no-store"
return response

app.mount(
"/", _RevalidatedHtml(directory=str(STATIC_DIR), html=True), name="static"
)
else:
fallback = _fallback_html()

Expand Down
69 changes: 69 additions & 0 deletions src/uipath/dev/server/frontend/src/api/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/** The developer server token, taken from the `?token=` the server opens with. */

const STORAGE_KEY = "uipath-dev-token";

let token: string | null = null;

export function initToken(): void {
const params = new URLSearchParams(window.location.search);
const fromUrl = params.get("token");

if (fromUrl) {
token = fromUrl;
try {
sessionStorage.setItem(STORAGE_KEY, fromUrl);
} catch {
token = fromUrl;
}
const url = new URL(window.location.href);
url.searchParams.delete("token");
window.history.replaceState({}, "", url.toString());
return;
}

try {
token = sessionStorage.getItem(STORAGE_KEY);
} catch {
token = null;
}
}

export function getToken(): string | null {
return token;
}

/** Append the token to a WebSocket URL, which cannot carry headers. */
export function withToken(url: string): string {
const current = getToken();
if (!current) return url;
return `${url}${url.includes("?") ? "&" : "?"}token=${encodeURIComponent(current)}`;
}

/** Send the token on same-origin /api requests. */
export function installAuthenticatedFetch(): void {
const original = window.fetch.bind(window);

window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
const current = getToken();
if (!current) return original(input, init);

const raw =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const url = new URL(raw, window.location.href);
if (url.origin !== window.location.origin || !url.pathname.startsWith("/api")) {
return original(input, init);
}

const headers = new Headers(
init?.headers ?? (input instanceof Request ? input.headers : undefined),
);
if (!headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${current}`);
}
return original(input, { ...init, headers });
};
}
Loading
Loading