From f213663d5b161fedd7c90b8b0e2c193433540cb9 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 31 May 2026 01:46:55 -0700 Subject: [PATCH 01/15] ci(security-audit): make package installs network-resilient (#5853) Make the network-touching install and download steps in the security-audit workflow resilient to transient failures without relaxing any integrity check. - Add top-level retry and backoff env knobs for pip, cargo, and npm. - Wrap the pip-audit + cargo install and npm ci steps in an exponential-backoff retry helper, preserving --locked and --ignore-scripts. - Re-pin swatinem/rust-cache to the v2.9.1 commit so the SHA matches its comment. - Split the OSV-Scanner download and SHA-256 verification into a hard-gated step: a checksum mismatch fails the job, while a transient download failure skips the scan; the advisory scan stays non-blocking. --- .github/workflows/security-audit.yml | 126 ++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 21 deletions(-) diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index a1e7b2efa6..33ac3b9bd8 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -72,6 +72,31 @@ concurrency: permissions: contents: read +# ────────────────────────────────────────────────────────────────────── +# Network-resilience knobs, applied to every job/step. These add retries +# and backoff ONLY; they do not relax a single integrity check. cargo +# still resolves against Cargo.lock (--locked), pip still verifies the +# wheels it downloads, npm still enforces package-lock integrity, the +# harden-runner egress allowlists below are unchanged, and every action +# stays SHA-pinned. The advisory-audit run on 2026-05-29 red-failed when +# one crates.io tarball fetch hit "Recv failure: Connection reset by +# peer" (curl 56); cargo's default of 3 retries over an HTTP/2-multiplexed +# connection did not recover. The settings below make that class of +# transient fault self-heal instead of failing the whole run. +env: + # pip: raise the built-in retry count and per-connection timeout. + PIP_RETRIES: "10" + PIP_DEFAULT_TIMEOUT: "60" + # cargo: retry network ops and disable HTTP/2 multiplexing -- the + # documented mitigation for the curl-56 connection resets above. + CARGO_NET_RETRY: "10" + CARGO_HTTP_MULTIPLEXING: "false" + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + # npm: retry registry fetches with capped exponential backoff. + NPM_CONFIG_FETCH_RETRIES: "5" + NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: "2000" + NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "60000" + jobs: # ───────────────────────────────────────────────────────────────────── # Combined advisory-DB audit: pip-audit + npm audit + cargo audit @@ -140,7 +165,7 @@ jobs: - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @ 2026-03-27 - - uses: swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 + - uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: workspaces: studio/src-tauri -> target @@ -153,8 +178,23 @@ jobs: # crashes with a TOML parse error on that file. # npm audit is bundled with the node toolchain, no install. run: | - python -m pip install --upgrade pip 'pip-audit>=2.7' - cargo install --locked --version '^0.22' cargo-audit + retry() { # retry with exponential backoff + local max="$1"; shift + local n=1 delay=5 + until "$@"; do + if [ "$n" -ge "$max" ]; then + echo "::error::command failed after ${n} attempts: $*" >&2 + return 1 + fi + echo "attempt ${n}/${max} failed; retrying in ${delay}s: $*" >&2 + sleep "$delay"; n=$((n + 1)); delay=$((delay * 2)) + done + } + retry 5 python -m pip install --upgrade pip 'pip-audit>=2.7' + # --locked keeps the resolved tree identical to Cargo.lock; the + # CARGO_NET_* env above plus this outer loop survive transient + # crates.io connection resets without weakening that guarantee. + retry 5 cargo install --locked --version '^0.22' cargo-audit # ───────────────────────────────────────────────────────────── # Python: pip-audit @@ -330,32 +370,60 @@ jobs: # ───────────────────────────────────────────────────────────── # OSV-Scanner: cross-ecosystem advisory DB (PyPI + npm + cargo) # ───────────────────────────────────────────────────────────── + - name: Download + verify OSV-Scanner + # Split out from the scan below so binary integrity is a HARD gate: + # a checksum mismatch (swapped release asset, the Trivy-style pivot + # this workflow refuses) fails the job instead of being swallowed by + # the scan step's continue-on-error. A download still failing after + # retries is transient, so we skip the scan rather than red-fail. + # SHA-256 verified BEFORE chmod +x / exec. Bump OSV_SHA256 in lockstep + # with OSV_VERSION (value from the release's osv-scanner_SHA256SUMS). + run: | + set -euo pipefail + OSV_VERSION="v2.0.2" + OSV_SHA256="3abcfd7126c453a00421487e721b296e0cb68085bd431d6cef60872774170fc8" + if ! curl --proto '=https' --tlsv1.2 -fsSL \ + --retry 5 --retry-delay 3 --retry-connrefused --retry-all-errors \ + -o /tmp/osv-scanner \ + "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64"; then + echo "::warning::osv-scanner download failed after retries; skipping scan" >&2 + rm -f /tmp/osv-scanner + exit 0 # transient availability: do not red-fail the job + fi + if ! echo "${OSV_SHA256} /tmp/osv-scanner" | sha256sum -c -; then + echo "::error::osv-scanner checksum mismatch; refusing to execute" >&2 + rm -f /tmp/osv-scanner + exit 1 # integrity failure: hard-fail + fi + chmod +x /tmp/osv-scanner + /tmp/osv-scanner --version + - name: OSV-Scanner (PyPI + npm + cargo, cross-ecosystem advisories) # OSV's advisory feed is a superset of GitHub-Advisory + RustSec # + npm advisories; running it alongside the per-ecosystem audit # tools catches CVEs that haven't propagated to the per-ecosystem # DBs yet (e.g. langchain-core CVE-2025-68664 was on OSV before # GitHub Advisory). Single binary, one transitive resolver, all - # three lockfile types in one pass. Non-blocking until baselines - # close. + # three lockfile types in one pass. Binary is checksum-verified in + # the step above; only the advisory scan stays non-blocking until + # baselines close. continue-on-error: true run: | set +e - # OSV-Scanner ships a raw binary (no tarball) in v2.x. - curl -fsSL -o /tmp/osv-scanner \ - https://github.com/google/osv-scanner/releases/download/v2.0.2/osv-scanner_linux_amd64 - chmod +x /tmp/osv-scanner - /tmp/osv-scanner --version - /tmp/osv-scanner scan source \ - --lockfile=studio/frontend/package-lock.json \ - --lockfile=studio/src-tauri/Cargo.lock \ - --lockfile=requirements.txt:audit-reqs/unsloth-deps.txt \ - --lockfile=requirements.txt:audit-reqs/studio.txt \ - --lockfile=requirements.txt:audit-reqs/no-torch-runtime.txt \ - --lockfile=requirements.txt:audit-reqs/overrides.txt \ - --lockfile=requirements.txt:audit-reqs/extras.txt \ - --lockfile=requirements.txt:audit-reqs/extras-no-deps.txt \ - --format=table 2>&1 | tee logs-osv-scanner.txt + if [ ! -x /tmp/osv-scanner ]; then + echo "osv-scanner unavailable this run; skipping scan" | tee logs-osv-scanner.txt + else + /tmp/osv-scanner scan source \ + --lockfile=studio/frontend/package-lock.json \ + --lockfile=studio/src-tauri/Cargo.lock \ + --lockfile=requirements.txt:audit-reqs/unsloth-deps.txt \ + --lockfile=requirements.txt:audit-reqs/studio.txt \ + --lockfile=requirements.txt:audit-reqs/no-torch-runtime.txt \ + --lockfile=requirements.txt:audit-reqs/overrides.txt \ + --lockfile=requirements.txt:audit-reqs/extras.txt \ + --lockfile=requirements.txt:audit-reqs/extras-no-deps.txt \ + --format=table 2>&1 | tee logs-osv-scanner.txt + fi { echo "## OSV-Scanner (cross-ecosystem)" echo @@ -1075,7 +1143,23 @@ jobs: # new-install-script gate below protects against, and we must # not run any third-party hook to set up the audit. working-directory: studio/frontend - run: npm ci --ignore-scripts + run: | + retry() { # retry with exponential backoff + local max="$1"; shift + local n=1 delay=5 + until "$@"; do + if [ "$n" -ge "$max" ]; then + echo "::error::command failed after ${n} attempts: $*" >&2 + return 1 + fi + echo "attempt ${n}/${max} failed; retrying in ${delay}s: $*" >&2 + sleep "$delay"; n=$((n + 1)); delay=$((delay * 2)) + done + } + # --ignore-scripts is mandatory here (no third-party hook runs); + # the retry only re-attempts the registry fetch, it never relaxes + # that flag or the package-lock integrity check npm ci enforces. + retry 5 npm ci --ignore-scripts - name: npm audit signatures (informational) # Surfaces unsigned / mis-signed packages from the npm From ff00fdd155910801f35ec5a6b616cff8cdd726e8 Mon Sep 17 00:00:00 2001 From: oobabooga Date: Sun, 31 May 2026 05:54:46 -0300 Subject: [PATCH 02/15] Studio: add stdio MCP server support (#5863) * Studio: add stdio MCP server support * Fix stdio command validation and Windows quoting --- studio/backend/core/inference/mcp_client.py | 64 +++++++++ studio/backend/core/inference/tools.py | 13 +- studio/backend/main.py | 5 + studio/backend/routes/mcp_servers.py | 36 +++-- .../features/chat/chat-mcp-servers-dialog.tsx | 129 ++++++++++++------ 5 files changed, 192 insertions(+), 55 deletions(-) diff --git a/studio/backend/core/inference/mcp_client.py b/studio/backend/core/inference/mcp_client.py index a5e614899d..1a38bc8c49 100644 --- a/studio/backend/core/inference/mcp_client.py +++ b/studio/backend/core/inference/mcp_client.py @@ -5,6 +5,9 @@ from __future__ import annotations import asyncio import json +import os +import shlex +import sys from typing import Any, Optional from loggers import get_logger @@ -16,7 +19,55 @@ MCP_TOOL_PREFIX = "mcp__" _oauth_token_store = None +def is_stdio(address: str) -> bool: + """A non-HTTP address is a local stdio command, e.g. + 'npx -y @modelcontextprotocol/server-filesystem /path'.""" + return not address.strip().lower().startswith(("http://", "https://")) + + +def parse_stdio_command(address: str) -> list[str]: + """Split a stdio command line into argv. Shared by route validation and the + transport so both agree on quoting (notably Windows backslash paths).""" + posix = sys.platform != "win32" + parts = shlex.split(address, posix = posix) + if not posix: + # posix=False keeps backslash paths intact but also keeps the surrounding + # quotes on a token. Strip a matched pair so the argv reaches the + # subprocess clean ('"C:\\Program Files\\node"' -> C:\\Program Files\\node). + parts = [ + p[1:-1] if len(p) >= 2 and p[0] == p[-1] and p[0] in "\"'" else p + for p in parts + ] + return parts + + +def stdio_mcp_enabled() -> bool: + """stdio MCP servers spawn local processes as the backend user (and bypass + the python/terminal sandbox), so they are only allowed when the backend + host is the user's own machine. The Tauri desktop app sets + UNSLOTH_STUDIO_ALLOW_STDIO_MCP=1 (see main.py); advanced localhost / + self-hosted users can opt in with the same variable. It stays off for + Colab and any network (0.0.0.0) bind.""" + return os.environ.get("UNSLOTH_STUDIO_ALLOW_STDIO_MCP") == "1" + + +# Probe timeouts for discovering a server's tool list. OAuth needs minutes for +# first-connect/expired-token browser sign-in; stdio allows for first-run +# package download (e.g. `npx -y ...`); HTTP fails fast. +_HTTP_PROBE_TIMEOUT = 8.0 +_OAUTH_PROBE_TIMEOUT = 305.0 +_STDIO_PROBE_TIMEOUT = 60.0 + + +def probe_timeout(address: str, use_oauth: bool) -> float: + if use_oauth: + return _OAUTH_PROBE_TIMEOUT + return _STDIO_PROBE_TIMEOUT if is_stdio(address) else _HTTP_PROBE_TIMEOUT + + def parse_server_headers(server: dict) -> Optional[dict]: + """Parsed headers_json. For stdio servers this dict is the process + environment instead of HTTP headers (see _client).""" raw = server.get("headers_json") if not raw: return None @@ -63,6 +114,19 @@ async def clear_oauth_tokens_async(url: str) -> None: def _client(url: str, headers: Optional[dict], use_oauth: bool = False): from fastmcp import Client + + if is_stdio(url): + from fastmcp.client.transports import StdioTransport + + parts = parse_stdio_command(url) + if not parts: + raise ValueError(f"Empty stdio command: {url!r}") + # stdio env vars ride the (HTTP-only) headers field. The MCP SDK merges + # them over its default safe env (PATH etc.), so pass them through as-is. + return Client( + StdioTransport(command = parts[0], args = parts[1:], env = headers or None) + ) + from fastmcp.client.transports import SSETransport, StreamableHttpTransport from fastmcp.mcp_config import infer_transport_type_from_url diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 9572a2169a..baf1236456 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -28,8 +28,11 @@ import urllib.request from core.inference.mcp_client import ( MCP_TOOL_PREFIX, call_tool_sync, + is_stdio, list_tools_async, parse_server_headers, + probe_timeout, + stdio_mcp_enabled, ) from storage import mcp_servers_db @@ -568,17 +571,19 @@ def _mcp_specs_for_server(server: dict, mcp_tools: list[dict]) -> list[dict]: async def get_enabled_mcp_tools() -> list[dict]: servers = [s for s in mcp_servers_db.list_servers() if s.get("is_enabled")] + # Never spawn stdio servers when stdio is disabled on this host (e.g. a DB + # carried over from a desktop install onto a Colab / network deployment). + if not stdio_mcp_enabled(): + servers = [s for s in servers if not is_stdio(s["url"])] if not servers: return [] - # OAuth probes need minutes for first-connect/expired-token browser - # sign-in; non-OAuth probes fail fast. Matches routes/mcp_servers.py. results = await asyncio.gather( *( list_tools_async( url = s["url"], headers = parse_server_headers(s), - timeout = 305.0 if s.get("use_oauth") else 8.0, + timeout = probe_timeout(s["url"], bool(s.get("use_oauth"))), use_oauth = bool(s.get("use_oauth")), ) for s in servers @@ -630,6 +635,8 @@ def execute_tool( return f"Error: MCP server '{server_id}' not found" if not server.get("is_enabled"): return f"Error: MCP server '{server_id}' is disabled" + if is_stdio(server["url"]) and not stdio_mcp_enabled(): + return f"Error: stdio MCP server '{server_id}' is disabled on this host" return call_tool_sync( url = server["url"], headers = parse_server_headers(server), diff --git a/studio/backend/main.py b/studio/backend/main.py index 6b8ac438c0..b6cf58a02c 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -297,6 +297,11 @@ def _load_desktop_owner() -> dict[str, str] | None: _DESKTOP_OWNER = _load_desktop_owner() +# The Tauri desktop app runs the backend on the owner's own machine, so local +# stdio MCP servers are safe there. setdefault lets an explicit "0" opt out. +if _DESKTOP_OWNER: + os.environ.setdefault("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1") + def _desktop_owner() -> dict[str, str] | None: return _DESKTOP_OWNER diff --git a/studio/backend/routes/mcp_servers.py b/studio/backend/routes/mcp_servers.py index a7501d1691..04b776776a 100644 --- a/studio/backend/routes/mcp_servers.py +++ b/studio/backend/routes/mcp_servers.py @@ -11,8 +11,12 @@ from fastapi import APIRouter, Depends, HTTPException from auth.authentication import get_current_subject from core.inference.mcp_client import ( clear_oauth_tokens_async, + is_stdio, list_tools_async, parse_server_headers, + parse_stdio_command, + probe_timeout, + stdio_mcp_enabled, ) from models.mcp_servers import ( McpServerCreate, @@ -28,16 +32,22 @@ logger = structlog.get_logger(__name__) router = APIRouter() -_PROBE_TIMEOUT_SECONDS = 8.0 -# When OAuth probes need to open a browser, wait long enough for the user to -# sign in. Matches fastmcp's default OAuth callback_timeout (300 s) + slack. -_OAUTH_PROBE_TIMEOUT_SECONDS = 305.0 - - def _validate_url(url: str) -> str: trimmed = (url or "").strip() if not trimmed: raise HTTPException(status_code = 400, detail = "url must not be empty") + # When stdio is enabled on this host, a non-HTTP value is a local command. + # Reuse this field so stdio servers ride the existing CRUD/storage with no + # schema change. When stdio is disabled the value falls through to the + # http-only validation below, so non-HTTP input is just a bad URL (400). + if stdio_mcp_enabled() and is_stdio(trimmed): + try: + parts = parse_stdio_command(trimmed) + except ValueError as exc: + raise HTTPException(status_code = 400, detail = f"Invalid command: {exc}") + if not parts or not parts[0].strip(): + raise HTTPException(status_code = 400, detail = "command must not be empty") + return trimmed parsed = urlparse(trimmed) if parsed.scheme not in ("http", "https"): raise HTTPException( @@ -180,15 +190,19 @@ async def refresh_mcp_server_tools( server = mcp_servers_db.get_server(server_id) if not server: raise HTTPException(status_code = 404, detail = "MCP server not found") + # Refresh uses the stored address, so re-check the stdio gate here too: a + # stdio row from a desktop DB must not spawn on a hosted/network host. + if is_stdio(server["url"]) and not stdio_mcp_enabled(): + raise HTTPException( + status_code = 400, detail = "stdio MCP servers are disabled on this host" + ) use_oauth = bool(server.get("use_oauth")) try: tools = await list_tools_async( url = server["url"], headers = parse_server_headers(server), - timeout = _OAUTH_PROBE_TIMEOUT_SECONDS - if use_oauth - else _PROBE_TIMEOUT_SECONDS, + timeout = probe_timeout(server["url"], use_oauth), use_oauth = use_oauth, ) except Exception as exc: # noqa: BLE001 — surface transport+timeout errors to UI @@ -212,9 +226,7 @@ async def test_mcp_server( tools = await list_tools_async( url = url, headers = headers, - timeout = _OAUTH_PROBE_TIMEOUT_SECONDS - if payload.use_oauth - else _PROBE_TIMEOUT_SECONDS, + timeout = probe_timeout(url, payload.use_oauth), use_oauth = payload.use_oauth, ) except Exception as exc: # noqa: BLE001 diff --git a/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx b/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx index 35b5aca64c..dfb50b5bc2 100644 --- a/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx @@ -66,23 +66,40 @@ function headersToObject(rows: HeaderRow[]): Record | undefined return Object.keys(out).length > 0 ? out : undefined; } -function isValidUrl(url: string): boolean { - const trimmed = url.trim(); +// A non-HTTP address is a local stdio command. Case-insensitive to match the +// backend's is_stdio(), so all layers split http-vs-command identically. +function isHttpAddress(value: string): boolean { + const trimmed = value.trim().toLowerCase(); + return trimmed.startsWith("http://") || trimmed.startsWith("https://"); +} + +function isValidAddress(value: string): boolean { + const trimmed = value.trim(); if (!trimmed) return false; - try { - const parsed = new URL(trimmed); - return parsed.protocol === "http:" || parsed.protocol === "https:"; - } catch { - return false; + if (isHttpAddress(trimmed)) { + try { + const parsed = new URL(trimmed); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } } + // Anything else is treated as a local command (stdio); the backend gates + // whether stdio servers are allowed on this host. Reject other URL schemes + // only when the command itself is a URL; "://" is fine inside an argument + // (e.g. a database connection string passed to the server). + return !trimmed.split(/\s+/)[0].includes("://"); } function HeadersEditor({ rows, onChange, + stdio, }: { rows: HeaderRow[]; onChange: (rows: HeaderRow[]) => void; + // stdio servers reuse this editor for environment variables instead of headers. + stdio: boolean; }) { const update = (id: string, patch: Partial) => onChange(rows.map((row) => (row.id === id ? { ...row, ...patch } : row))); @@ -91,19 +108,41 @@ function HeadersEditor({ const remove = (id: string) => onChange(rows.filter((row) => row.id !== id)); + const copy = stdio + ? { + label: "Environment variables", + add: "Add variable", + keyPlaceholder: "Variable name", + valuePlaceholder: "Variable value", + remove: "Remove variable", + } + : { + label: "Custom headers", + add: "Add header", + keyPlaceholder: "Header name", + valuePlaceholder: "Header value", + remove: "Remove header", + }; + return ( <>
- +
{rows.length === 0 ? (
- Optional. Add an Authorization header here for servers - that require auth. + {stdio ? ( + "Optional. Environment variables passed to the server process." + ) : ( + <> + Optional. Add an Authorization header here for servers + that require auth. + + )}
) : (
@@ -111,12 +150,12 @@ function HeadersEditor({
update(row.id, { key: e.target.value })} /> update(row.id, { value: e.target.value })} /> @@ -199,8 +238,8 @@ export function ChatMcpServersDialog({ async function testConnection() { const trimmedUrl = form.url.trim(); - if (!isValidUrl(trimmedUrl)) { - toast.error("Enter a valid http:// or https:// URL first"); + if (!isValidAddress(trimmedUrl)) { + toast.error("Enter an http(s):// URL or a local command first"); return; } setTesting(true); @@ -236,11 +275,11 @@ export function ChatMcpServersDialog({ return; } if (!trimmedUrl) { - toast.error("URL is required"); + toast.error("URL or command is required"); return; } - if (!isValidUrl(trimmedUrl)) { - toast.error("URL must start with http:// or https://"); + if (!isValidAddress(trimmedUrl)) { + toast.error("Enter an http(s):// URL or a local command"); return; } setSaving(true); @@ -331,6 +370,9 @@ export function ChatMcpServersDialog({ } const showForm = view.kind !== "list"; + // A local stdio command uses env vars, not headers or OAuth. + const addressIsCommand = + form.url.trim() !== "" && !isHttpAddress(form.url); return ( @@ -338,7 +380,7 @@ export function ChatMcpServersDialog({ MCP Servers - Register remote MCP servers. + Register remote (HTTP) or local (stdio command) MCP servers. @@ -356,40 +398,47 @@ export function ChatMcpServersDialog({ />
- + setForm((prev) => ({ ...prev, url: e.target.value })) } - placeholder="https://example.com/mcp" + placeholder="https://example.com/mcp or npx -y @modelcontextprotocol/server-filesystem /tmp" /> + + An http(s) URL for a remote server, or a local command to run an + stdio server (desktop app only). +
-
-
- - - For servers that require browser-based authentication - (GitHub, Linear, etc.). A browser window will open on first - connect. - + {!addressIsCommand && ( +
+
+ + + For servers that require browser-based authentication + (GitHub, Linear, etc.). A browser window will open on first + connect. + +
+ + setForm((prev) => ({ ...prev, useOauth })) + } + />
- - setForm((prev) => ({ ...prev, useOauth })) - } - /> -
+ )} setForm((prev) => ({ ...prev, headers }))} + stdio={addressIsCommand} />
From 76574fb8da5814fd7ca60c9fc5548151501e0c03 Mon Sep 17 00:00:00 2001 From: oobabooga Date: Sun, 31 May 2026 05:55:09 -0300 Subject: [PATCH 03/15] studio/frontend: fix MCP dialog overflow on long URLs (#5864) --- studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx b/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx index dfb50b5bc2..b377e7a88e 100644 --- a/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx @@ -464,7 +464,7 @@ export function ChatMcpServersDialog({
) : ( -
+
From dfba4cc5cae7c95871c0faced86b9cefd5808b28 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Mon, 1 Jun 2026 08:35:18 +0200 Subject: [PATCH 15/15] Studio: add HTML artifacts to chat (#5772) * Studio: add chat HTML artifact primitives * Studio: add local render_html tool support * Studio: wire render_html artifacts in chat UI * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: add chat artifact surface * Studio: mount chat artifact panel and overlay * Studio: fix chat artifact review regressions * Studio: fix chat artifact panel and sandbox previews * Studio: address chat artifact review follow-ups * Studio: polish chat artifact UI affordances * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: scope artifact IDs by message to prevent cross-turn collisions * Studio: fix artifact panel for local threads and surface tool errors * Studio: restrict artifact frame embedding to same-origin * Studio: stop local chat thread remount loop * Studio: fix chat artifact store cleanup regressions * Studio: shim artifact preview storage in sandbox * feat(chat): add artifact rendering controls * fix(chat): show artifact progress during tool calls * fix(chat): refine artifact preview behavior * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(chat): ignore tool markers inside arguments * feat(chat): polish artifact preview panel * fix(chat): stabilize artifact panel behavior * fix(inference): merge duplicate Anthropic tool starts * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .../core/inference/anthropic_compat.py | 70 ++- studio/backend/core/inference/llama_cpp.py | 88 +++- .../core/inference/safetensors_agentic.py | 112 +++- .../core/inference/tool_call_parser.py | 26 +- studio/backend/core/inference/tools.py | 51 +- studio/backend/main.py | 6 +- studio/backend/models/inference.py | 11 +- studio/backend/routes/chat_history.py | 2 + studio/backend/routes/inference.py | 287 ++++++++--- .../backend/tests/test_anthropic_messages.py | 92 ++++ .../tests/test_safetensors_tool_loop.py | 126 ++++- .../components/assistant-ui/markdown-text.tsx | 181 +++---- .../src/components/assistant-ui/thread.tsx | 27 + .../components/assistant-ui/tool-group.tsx | 16 +- .../assistant-ui/tool-ui-render-html.tsx | 142 ++++++ .../frontend/src/components/ui/resizable.tsx | 102 ++-- .../src/features/chat/api/chat-adapter.ts | 117 +++-- .../features/chat/api/chat-settings-api.ts | 2 + .../features/chat/artifacts/artifact-card.tsx | 141 +++++ .../chat/artifacts/artifact-surface.tsx | 362 +++++++++++++ .../features/chat/artifacts/html-frame.tsx | 100 ++++ .../src/features/chat/artifacts/store.ts | 107 ++++ .../src/features/chat/artifacts/types.ts | 84 +++ .../frontend/src/features/chat/chat-page.tsx | 337 ++++++++++-- .../src/features/chat/chat-settings-sheet.tsx | 11 - .../chat/hooks/use-chat-model-runtime.ts | 12 +- .../chat/hooks/use-chat-sidebar-items.ts | 5 + studio/frontend/src/features/chat/index.ts | 5 + .../src/features/chat/shared-composer.tsx | 481 +++++++++++------- .../chat/stores/chat-runtime-store.ts | 56 ++ .../chat/utils/chat-settings-storage.ts | 23 + .../src/features/native-intents/index.ts | 11 + .../src/features/settings/tabs/chat-tab.tsx | 45 +- studio/frontend/src/i18n/locales/en.ts | 9 + studio/frontend/src/index.css | 143 ++++++ 35 files changed, 2800 insertions(+), 590 deletions(-) create mode 100644 studio/frontend/src/components/assistant-ui/tool-ui-render-html.tsx create mode 100644 studio/frontend/src/features/chat/artifacts/artifact-card.tsx create mode 100644 studio/frontend/src/features/chat/artifacts/artifact-surface.tsx create mode 100644 studio/frontend/src/features/chat/artifacts/html-frame.tsx create mode 100644 studio/frontend/src/features/chat/artifacts/store.ts create mode 100644 studio/frontend/src/features/chat/artifacts/types.ts create mode 100644 studio/frontend/src/features/native-intents/index.ts diff --git a/studio/backend/core/inference/anthropic_compat.py b/studio/backend/core/inference/anthropic_compat.py index bc792c3b99..cdb0fdebff 100644 --- a/studio/backend/core/inference/anthropic_compat.py +++ b/studio/backend/core/inference/anthropic_compat.py @@ -218,6 +218,8 @@ class AnthropicStreamEmitter: def __init__(self) -> None: self.block_index: int = 0 self._text_block_open: bool = False + self._open_tool_call_id: Optional[str] = None + self._open_tool_args_sent: bool = False self._prev_text: str = "" self._usage: dict = {} @@ -263,8 +265,10 @@ class AnthropicStreamEmitter: def finish(self, stop_reason: str = "end_turn") -> list[str]: """Close any open block and emit message_delta + message_stop.""" events = [] - if self._text_block_open: + if self._text_block_open or self._open_tool_call_id is not None: events.append(self._close_block()) + self._open_tool_call_id = None + self._open_tool_args_sent = False events.append( build_anthropic_sse_event( "message_delta", @@ -310,12 +314,26 @@ class AnthropicStreamEmitter: return events def _handle_tool_start(self, event: dict) -> list[str]: + tool_call_id = event.get("tool_call_id", "") + args = event.get("arguments", {}) + if tool_call_id and self._open_tool_call_id == tool_call_id: + return self._tool_arguments_delta(args) + events = [] - # Close current text block if open + # Close current text block if open. if self._text_block_open: events.append(self._close_block()) - # Open a tool_use block + # Defensive: if a replacement/different tool_start arrives while a + # tool_use block is open, close the stale block before starting another. + elif self._open_tool_call_id is not None: + events.append(self._close_block()) + self._open_tool_call_id = None + self._open_tool_args_sent = False + + # Open a tool_use block. self.block_index += 1 + self._open_tool_call_id = tool_call_id + self._open_tool_args_sent = False events.append( build_anthropic_sse_event( "content_block_start", @@ -324,35 +342,43 @@ class AnthropicStreamEmitter: "index": self.block_index, "content_block": { "type": "tool_use", - "id": event.get("tool_call_id", ""), + "id": tool_call_id, "name": event.get("tool_name", ""), "input": {}, }, }, ) ) - # Emit the arguments as input_json_delta - args = event.get("arguments", {}) - if args: - events.append( - build_anthropic_sse_event( - "content_block_delta", - { - "type": "content_block_delta", - "index": self.block_index, - "delta": { - "type": "input_json_delta", - "partial_json": json.dumps(args), - }, - }, - ) - ) + events.extend(self._tool_arguments_delta(args)) return events + def _tool_arguments_delta(self, args: dict) -> list[str]: + if not args: + return [] + if self._open_tool_args_sent: + return [] + self._open_tool_args_sent = True + return [ + build_anthropic_sse_event( + "content_block_delta", + { + "type": "content_block_delta", + "index": self.block_index, + "delta": { + "type": "input_json_delta", + "partial_json": json.dumps(args), + }, + }, + ) + ] + def _handle_tool_end(self, event: dict) -> list[str]: events = [] - # Close the tool_use block - events.append(self._close_block()) + # Close the tool_use block. + if self._open_tool_call_id is not None or self._text_block_open: + events.append(self._close_block()) + self._open_tool_call_id = None + self._open_tool_args_sent = False # Emit custom tool_result event (non-standard, ignored by SDKs) events.append( build_anthropic_sse_event( diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index cce95fc34c..7bcf02dc35 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -52,6 +52,7 @@ from utils.subprocess_compat import ( windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs, ) from core.inference.tool_call_parser import ( + RENDER_HTML_REPEAT_NUDGE, parse_tool_calls_from_text as _shared_parse_tool_calls_from_text, ) @@ -4616,6 +4617,7 @@ class LlamaCppBackend: # a transient failure are allowed (only block when the previous # identical call succeeded). _tool_call_history: list[tuple[str, bool]] = [] # (key, failed) + _render_html_succeeded = False # ── Re-prompt on plan-without-action ───────────────── # When the model describes what it intends to do (forward-looking @@ -4690,6 +4692,7 @@ class LlamaCppBackend: _iter_timings = None _stream_done = False _last_emitted = "" + provisional_render_html_tool_call_ids = set() stream_timeout = httpx.Timeout( connect = 10, @@ -4799,6 +4802,33 @@ class LlamaCppBackend: tool_calls_acc[idx]["function"][ "arguments" ] += func["arguments"] + current_name = tool_calls_acc[idx][ + "function" + ].get("name", "") + fallback_id = f"call_{idx}" + current_id = tool_calls_acc[idx].get( + "id", fallback_id + ) + already_started = ( + current_id + in provisional_render_html_tool_call_ids + ) + has_real_id = current_id != fallback_id + if ( + current_name == "render_html" + and not _render_html_succeeded + and not already_started + and has_real_id + ): + provisional_render_html_tool_call_ids.add( + current_id + ) + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": current_id, + "arguments": {}, + } continue # ── Reasoning tokens ── @@ -4980,13 +5010,25 @@ class LlamaCppBackend: "content": _stripped, } ) + available_tool_names = [ + tool.get("function", {}).get("name") + for tool in tools + if isinstance(tool, dict) + and isinstance(tool.get("function"), dict) + ] + available_tool_names = [ + name for name in available_tool_names if name + ] + tool_hint = ( + " or ".join(available_tool_names) or "an available tool" + ) conversation.append( { "role": "user", "content": ( "STOP. Do NOT write code or explain. " "You MUST call a tool NOW. " - "Call web_search or python immediately." + f"Call {tool_hint} immediately." ), } ) @@ -5158,7 +5200,12 @@ class LlamaCppBackend: arguments = json.loads(raw_args) except (json.JSONDecodeError, ValueError): if auto_heal_tool_calls: - arguments = {"query": raw_args} + heal_key = { + "python": "code", + "terminal": "command", + "render_html": "code", + }.get(tool_name, "query") + arguments = {heal_key: raw_args} else: arguments = {"raw": raw_args} else: @@ -5195,14 +5242,18 @@ class LlamaCppBackend: ) else: status_text = f"Calling: {tool_name}" - yield {"type": "status", "text": status_text} + _repeat_render_html = ( + tool_name == "render_html" and _render_html_succeeded + ) + if not _repeat_render_html: + yield {"type": "status", "text": status_text} - yield { - "type": "tool_start", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "arguments": arguments, - } + yield { + "type": "tool_start", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "arguments": arguments, + } # ── Duplicate call detection ────────────── # str(dict) is stable here: arguments always comes from @@ -5210,7 +5261,9 @@ class LlamaCppBackend: # so insertion order is deterministic (Python 3.7+). _tc_key = tool_name + str(arguments) _prev = _tool_call_history[-1] if _tool_call_history else None - if _prev and _prev[0] == _tc_key and not _prev[1]: + if _repeat_render_html: + result = RENDER_HTML_REPEAT_NUDGE + elif _prev and _prev[0] == _tc_key and not _prev[1]: result = ( "You already made this exact call. " "Do not repeat the same tool call. " @@ -5248,12 +5301,13 @@ class LlamaCppBackend: session_id = session_id, ) - yield { - "type": "tool_end", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "result": result, - } + if not _repeat_render_html: + yield { + "type": "tool_end", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "result": result, + } # Nudge model to try a different approach on errors _error_prefixes = ( @@ -5269,6 +5323,8 @@ class LlamaCppBackend: _is_error = isinstance(result, str) and result.lstrip().startswith( _error_prefixes ) + if tool_name == "render_html" and not _is_error: + _render_html_succeeded = True _tool_call_history.append((_tc_key, _is_error)) # Strip image sentinel before feeding result to the LLM # (the full result with sentinel is still yielded via diff --git a/studio/backend/core/inference/safetensors_agentic.py b/studio/backend/core/inference/safetensors_agentic.py index 73bb3d090a..94e9e303ab 100644 --- a/studio/backend/core/inference/safetensors_agentic.py +++ b/studio/backend/core/inference/safetensors_agentic.py @@ -18,6 +18,7 @@ cumulative text and dispatches them via ``core.inference.tools``. """ import json +import re import threading from typing import Callable, Generator, Optional from urllib.parse import urlparse @@ -27,6 +28,7 @@ from loggers import get_logger from core.inference.tool_call_parser import ( BUDGET_EXHAUSTED_NUDGE, DUPLICATE_CALL_NUDGE, + RENDER_HTML_REPEAT_NUDGE, TOOL_ERROR_NUDGE, TOOL_ERROR_PREFIXES, TOOL_XML_SIGNALS, @@ -66,7 +68,34 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str: return f"Calling: {tool_name}" -_CANONICAL_HEAL_ARG = {"python": "code", "terminal": "command"} +_CANONICAL_HEAL_ARG = { + "python": "code", + "terminal": "command", + "render_html": "code", +} + + +_FUNCTION_SIGNAL_RE = re.compile(r"") +_TOOL_CALL_NAME_RE = re.compile(r'"name"\s*:\s*"([\w-]+)"') + + +def _detect_render_html_tool_start(content: str) -> bool: + """Return True when the first drained tool call is clearly render_html.""" + function_match = _FUNCTION_SIGNAL_RE.search(content) + tool_call_index = content.find("") + if not function_match and tool_call_index < 0: + return False + + if function_match and ( + tool_call_index < 0 or function_match.start() < tool_call_index + ): + return function_match.group(1) == "render_html" + + if tool_call_index >= 0: + name_match = _TOOL_CALL_NAME_RE.search(content[tool_call_index:]) + return bool(name_match and name_match.group(1) == "render_html") + + return False def _coerce_arguments(raw_args, *, heal: bool, tool_name: str = "") -> dict: @@ -135,6 +164,7 @@ def run_safetensors_tool_loop( """ conversation = list(messages) tool_call_history: list[tuple[str, bool]] = [] + render_html_succeeded = False final_attempt_done = False allowed_tool_names = { (tool.get("function") or {}).get("name") @@ -161,6 +191,8 @@ def run_safetensors_tool_loop( content_accum = "" cumulative_display = "" last_emitted = "" + provisional_render_html_started = False + provisional_render_html_id = f"call_{next_call_id}" gen = single_turn(conversation) prev_cumulative = "" @@ -179,6 +211,18 @@ def run_safetensors_tool_loop( content_accum += delta if detect_state == _state_draining: + if ( + not render_html_succeeded + and not provisional_render_html_started + and _detect_render_html_tool_start(content_accum) + ): + provisional_render_html_started = True + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "arguments": {}, + } continue if detect_state == _state_streaming: @@ -196,6 +240,18 @@ def run_safetensors_tool_loop( yield {"type": "content", "text": cleaned_before} cumulative_display = candidate detect_state = _state_draining + if ( + not render_html_succeeded + and not provisional_render_html_started + and _detect_render_html_tool_start(content_accum) + ): + provisional_render_html_started = True + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "arguments": {}, + } continue cumulative_display = candidate cleaned = strip_tool_markup(cumulative_display) @@ -222,6 +278,18 @@ def run_safetensors_tool_loop( if is_match: detect_state = _state_draining + if ( + not render_html_succeeded + and not provisional_render_html_started + and _detect_render_html_tool_start(content_accum) + ): + provisional_render_html_started = True + yield { + "type": "tool_start", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "arguments": {}, + } elif is_prefix and len(stripped) < _MAX_BUFFER_CHARS: continue else: @@ -282,6 +350,13 @@ def run_safetensors_tool_loop( # literal "" prose is preserved. if content_accum: yield {"type": "content", "text": content_accum} + if provisional_render_html_started: + yield { + "type": "tool_end", + "tool_name": "render_html", + "tool_call_id": provisional_render_html_id, + "result": "Error: render_html tool call could not be parsed.", + } yield {"type": "status", "text": ""} return content_text = strip_tool_markup(content_accum, final = True) @@ -308,16 +383,20 @@ def run_safetensors_tool_loop( tool_name = tool_name, ) - yield {"type": "status", "text": _status_for_tool(tool_name, arguments)} - yield { - "type": "tool_start", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "arguments": arguments, - } + repeat_render_html = tool_name == "render_html" and render_html_succeeded + if not repeat_render_html: + yield {"type": "status", "text": _status_for_tool(tool_name, arguments)} + yield { + "type": "tool_start", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "arguments": arguments, + } tc_key = tool_name + str(arguments) - if allowed_tool_names and tool_name not in allowed_tool_names: + if repeat_render_html: + result = RENDER_HTML_REPEAT_NUDGE + elif allowed_tool_names and tool_name not in allowed_tool_names: result = ( f"Error: tool '{tool_name}' is not enabled for this " "request. Use one of the enabled tools or provide a " @@ -345,16 +424,19 @@ def run_safetensors_tool_loop( logger.exception("Tool %s raised: %s", tool_name, exc) result = f"Error: tool raised an exception: {exc}" - yield { - "type": "tool_end", - "tool_name": tool_name, - "tool_call_id": tc.get("id", ""), - "result": result, - } + if not repeat_render_html: + yield { + "type": "tool_end", + "tool_name": tool_name, + "tool_call_id": tc.get("id", ""), + "result": result, + } is_error = isinstance(result, str) and result.lstrip().startswith( TOOL_ERROR_PREFIXES ) + if tool_name == "render_html" and not is_error: + render_html_succeeded = True tool_call_history.append((tc_key, is_error)) # Strip frontend image sentinel from the model's view. diff --git a/studio/backend/core/inference/tool_call_parser.py b/studio/backend/core/inference/tool_call_parser.py index 2f94990623..dacbc19ac0 100644 --- a/studio/backend/core/inference/tool_call_parser.py +++ b/studio/backend/core/inference/tool_call_parser.py @@ -49,6 +49,12 @@ DUPLICATE_CALL_NUDGE = ( "provide your final answer now." ) +RENDER_HTML_REPEAT_NUDGE = ( + "Error: render_html was already called for this response. Do not call " + "render_html again in this response unless the user asks for changes. " + "Provide the final answer now." +) + TOOL_ERROR_NUDGE = ( "\n\nThe tool call encountered an issue. Please try a different " "approach or rephrase your request." @@ -70,6 +76,20 @@ _TC_FUNC_CLOSE_RE = re.compile(r"\s*\s*$") # `issue-number`, `repo-name`); using `\w+` here dropped those keys. _TC_PARAM_START_RE = re.compile(r"\s*") _TC_PARAM_CLOSE_RE = re.compile(r"\s*\s*$") +_PARAM_CLOSE_TAG = "" +_FUNC_CLOSE_TAG = "" + + +def _inside_open_parameter(content: str, pos: int) -> bool: + """Return True when ``pos`` falls inside an unclosed parameter value.""" + last_param_start = -1 + for match in _TC_PARAM_START_RE.finditer(content, 0, pos): + last_param_start = match.start() + if last_param_start < 0: + return False + last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos) + last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos) + return last_param_start > max(last_param_close, last_func_close) def strip_tool_markup(text: str, *, final: bool = False) -> str: @@ -151,7 +171,11 @@ def parse_tool_calls_from_text(content: str, *, id_offset: int = 0) -> list[dict # optional; don't use as body boundary because code # values can contain that literal. if not tool_calls: - func_starts = list(_TC_FUNC_START_RE.finditer(content)) + func_starts = [ + fm + for fm in _TC_FUNC_START_RE.finditer(content) + if not _inside_open_parameter(content, fm.start()) + ] for idx, fm in enumerate(func_starts): func_name = fm.group(1) body_start = fm.end() diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index baf1236456..eecb84ca27 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -514,7 +514,35 @@ TERMINAL_TOOL = { }, } -ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL] +RENDER_HTML_TOOL = { + "type": "function", + "function": { + "name": "render_html", + "description": ( + "Render a self-contained HTML/CSS/JavaScript artifact for the user. " + "Call this at most once per assistant response unless the user " + "explicitly asks for changes in that response. Future user requests " + "for new artifacts may call render_html once. Put the entire document " + "in code, including any CSS in