* Studio: persistent stdio MCP sessions so server state survives across tool calls call_tool_sync spawned a fresh stdio subprocess per tool call (keep_alive=False) and tore it down when the call returned, so any stateful MCP server lost its state between calls: with @playwright/mcp, browser_navigate opened the page in one subprocess and browser_take_screenshot ran in a brand-new one, screenshotting about:blank. Keep one connected client per (command, env) on a dedicated event-loop thread and reuse it across calls: - idle sessions are reaped after 5 minutes (in-flight calls excluded) and everything closes at exit, preserving the old design's no-orphans property - a dead subprocess is detected via is_connected() and retried once on a fresh session; tool-level errors leave the session alone - cancel and timeout semantics are unchanged, and a timed-out call does not tear the session down - updating a server's endpoint/env/enabled state or deleting it closes its live session - HTTP/SSE servers stay one-shot per call * address review feedback * fix stdio session cleanup * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * address review: per-thread MCP scope, close-during-connect and abort races * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * address review: unblock no-limit calls on close, drain borrowers before close, scope closes to url+env * don't retry sessions closed by config changes, re-verify server row before caching, keep env secrets out of generation keys * fail fast on connect errors and make the stdio key-lock wait cancellable * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * quote MCP scope parts so IDs with colons can't collide * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * serialize per-session stdio calls, span one timeout budget across connect and call, hash urls in generation keys * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Harden persistent stdio MCP sessions: crash recovery, concurrency, scoping - Evict a stdio session on any transport-level (non-ToolError) call failure and do not replay it, so a mid-call subprocess crash can no longer poison the scope. Never gate liveness on Client.is_connected() (it only reports that a session object exists, not that the subprocess is alive); add a version-adaptive dead-transport probe that works on fastmcp 3.0.2 and newer. - Re-check closed/defunct/config and transport liveness after acquiring the call lock, and retire a session before releasing the lock, so a queued same-scope caller never reuses a session that another caller's timeout already retired. - Force a ProactorEventLoop on Windows so the stdio transport can always spawn subprocesses regardless of the active event-loop policy. - Scope stdio sessions per conversation: require thread_id to persist, and tag the fields so a session_id and a thread_id with the same value cannot collide. A session_id alone is project-wide, so it now falls back to a safe one-shot session instead of sharing browser/DB/REPL state across conversations. - Forward thread_id on the Anthropic Messages path. - Treat timeout=None as unlimited on connect and the key lock (was capped at 60s). - Bound the session cache (default 32, override via UNSLOTH_STUDIO_MAX_STDIO_MCP_SESSIONS) with LRU eviction of idle sessions. - Run config_check on cache hits, and log a redacted exe#digest label instead of the raw command so credentials in argv never reach the logs. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim the stdio MCP session cache on release and skip close-generation for HTTP servers Two fixes from review of the persistent stdio session lifecycle: - Re-enforce the session cap when a session goes idle. A concurrent burst of distinct-scope calls can overshoot the cap while every cached session is busy (insert-time eviction only reclaims idle sessions), and the overshoot used to persist until the 5-minute idle reaper. _release_stdio_session now trims the idle overshoot back within the cap, without ever evicting an in-flight call. - close_stdio_sessions() now no-ops for a specific non-stdio (HTTP/SSE) url. Those transports are never cached as stdio sessions, so calling it on every HTTP server update or delete used to accrue an unbounded close-generation entry. Both are covered by regression tests that fail before the change and pass after. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep the live stdio MCP session across a display-name rename The edit dialog resends url, headers, and use_oauth unchanged whenever a server is saved, so gating the tool-cache invalidation and stdio session close on field presence dropped the persistent process on a plain rename or any no-op edit. Gate on a real value change against the stored row so only a genuine endpoint, auth, or enable change closes the session. Regression tests: a rename that resends unchanged url/headers/oauth keeps the session; a real command change still closes it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten comments in the stdio MCP session lifecycle Collapse a few verbose comments to fewer lines with the wording preserved, and drop one that restated the clear_oauth_tokens_async docstring. Comments only; no code change. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com>
346 lines
14 KiB
Python
346 lines
14 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
import asyncio
|
|
import json
|
|
import uuid
|
|
from urllib.parse import urlparse
|
|
|
|
import structlog
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
from auth.authentication import get_current_subject
|
|
from core.inference.mcp_client import (
|
|
TOOL_CACHE_INVALIDATING_FIELDS,
|
|
cache_tools,
|
|
clear_oauth_tokens_async,
|
|
close_stdio_sessions,
|
|
invalidate_tool_cache,
|
|
is_stdio,
|
|
list_tools_async,
|
|
parse_server_headers,
|
|
parse_stdio_command,
|
|
probe_timeout,
|
|
record_probe_failure,
|
|
stdio_mcp_enabled,
|
|
)
|
|
from core.inference.mcp_config_import import parse_mcp_config
|
|
from models.mcp_servers import (
|
|
McpServerCreate,
|
|
McpServerImportRequest,
|
|
McpServerImportResult,
|
|
McpServerProbeResult,
|
|
McpServerResponse,
|
|
McpServerTestRequest,
|
|
McpServerUpdate,
|
|
)
|
|
from storage import mcp_servers_db
|
|
from utils.utils import safe_curated_detail, log_and_http_error
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _looks_like_command(value: str) -> bool:
|
|
"""Whitespace is a one-way signal: a URL can't hold an unencoded space, so
|
|
a value with whitespace is definitely a command. No whitespace proves
|
|
nothing (a lone token may be a single-arg command or a scheme-less URL)."""
|
|
return any(ch.isspace() for ch in value)
|
|
|
|
|
|
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, a non-HTTP value is a local command (reuses this
|
|
# field so stdio servers ride existing CRUD/storage).
|
|
if stdio_mcp_enabled() and is_stdio(trimmed):
|
|
try:
|
|
parts = parse_stdio_command(trimmed)
|
|
except ValueError as exc:
|
|
raise log_and_http_error(
|
|
exc,
|
|
400,
|
|
"Invalid command. Check quoting and try again.",
|
|
event = "mcp_servers.invalid_command",
|
|
log = logger,
|
|
)
|
|
if not parts or not parts[0].strip():
|
|
raise HTTPException(status_code = 400, detail = "command must not be empty")
|
|
if "://" in parts[0]:
|
|
# A URL-scheme first token is a mistyped URL, not a command. Reject
|
|
# cleanly instead of exec-ing it (mirrors the frontend check).
|
|
raise HTTPException(
|
|
status_code = 400,
|
|
detail = "Enter an http(s):// URL, or a local command whose "
|
|
"first token is an executable (not a URL).",
|
|
)
|
|
return trimmed
|
|
parsed = urlparse(trimmed)
|
|
if parsed.scheme not in ("http", "https"):
|
|
if _looks_like_command(trimmed):
|
|
detail = (
|
|
"Local commands aren't enabled on this server. To allow them, "
|
|
"set UNSLOTH_STUDIO_ALLOW_STDIO_MCP=1 and restart Studio, or use "
|
|
"an http:// or https:// URL instead."
|
|
)
|
|
else:
|
|
detail = (
|
|
"MCP server address must start with http:// or https:// "
|
|
"(for example https://example.com/mcp)."
|
|
)
|
|
raise HTTPException(status_code = 400, detail = detail)
|
|
if not parsed.netloc:
|
|
raise HTTPException(status_code = 400, detail = "url is missing a host")
|
|
return trimmed
|
|
|
|
|
|
def _normalize_headers(headers: dict[str, str] | None) -> dict[str, str] | None:
|
|
"""Trim header names, drop empties, coerce values to str; None if empty."""
|
|
if not headers:
|
|
return None
|
|
out: dict[str, str] = {}
|
|
for raw_key, value in headers.items():
|
|
key = str(raw_key).strip()
|
|
if key:
|
|
out[key] = str(value)
|
|
return out or None
|
|
|
|
|
|
def _row_to_response(row: dict) -> McpServerResponse:
|
|
return McpServerResponse(
|
|
id = row["id"],
|
|
display_name = row["display_name"],
|
|
url = row["url"],
|
|
headers = parse_server_headers(row) or {},
|
|
is_enabled = bool(row["is_enabled"]),
|
|
use_oauth = bool(row.get("use_oauth")),
|
|
created_at = row["created_at"],
|
|
updated_at = row["updated_at"],
|
|
)
|
|
|
|
|
|
@router.get("/", response_model = list[McpServerResponse])
|
|
async def list_mcp_servers(current_subject: str = Depends(get_current_subject)):
|
|
return [_row_to_response(row) for row in mcp_servers_db.list_servers()]
|
|
|
|
|
|
@router.post("/", response_model = McpServerResponse, status_code = 201)
|
|
async def create_mcp_server(
|
|
payload: McpServerCreate, current_subject: str = Depends(get_current_subject)
|
|
):
|
|
display_name = (payload.display_name or "").strip()
|
|
if not display_name:
|
|
raise HTTPException(status_code = 400, detail = "display_name must not be empty")
|
|
url = _validate_url(payload.url)
|
|
headers = _normalize_headers(payload.headers)
|
|
# OAuth is HTTP-only; force it off for stdio commands so a stale flag can't
|
|
# push the probe onto the 305s OAuth timeout. Backend enforces this.
|
|
use_oauth = payload.use_oauth and not is_stdio(url)
|
|
|
|
server_id = uuid.uuid4().hex[:16]
|
|
mcp_servers_db.create_server(
|
|
id = server_id,
|
|
display_name = display_name,
|
|
url = url,
|
|
headers_json = json.dumps(headers) if headers else None,
|
|
is_enabled = payload.is_enabled,
|
|
use_oauth = use_oauth,
|
|
)
|
|
return _row_to_response(mcp_servers_db.get_server(server_id))
|
|
|
|
|
|
def _changes_from_payload(payload: McpServerUpdate) -> dict:
|
|
sent = payload.model_fields_set
|
|
changes: dict = {}
|
|
|
|
if "display_name" in sent:
|
|
name = (payload.display_name or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code = 400, detail = "display_name must not be empty")
|
|
changes["display_name"] = name
|
|
if "url" in sent:
|
|
changes["url"] = _validate_url(payload.url or "")
|
|
if "headers" in sent:
|
|
headers = _normalize_headers(payload.headers)
|
|
changes["headers_json"] = json.dumps(headers) if headers else None
|
|
if "is_enabled" in sent:
|
|
if payload.is_enabled is None:
|
|
raise HTTPException(status_code = 400, detail = "is_enabled must be true or false")
|
|
changes["is_enabled"] = payload.is_enabled
|
|
if "use_oauth" in sent:
|
|
if payload.use_oauth is None:
|
|
raise HTTPException(status_code = 400, detail = "use_oauth must be true or false")
|
|
changes["use_oauth"] = payload.use_oauth
|
|
# stdio is OAuth-less: drop a stale OAuth flag when switching to a command.
|
|
if "url" in changes and is_stdio(changes["url"]):
|
|
changes["use_oauth"] = False
|
|
return changes
|
|
|
|
|
|
@router.put("/{server_id}", response_model = McpServerResponse)
|
|
async def update_mcp_server(
|
|
server_id: str,
|
|
payload: McpServerUpdate,
|
|
current_subject: str = Depends(get_current_subject),
|
|
):
|
|
old = mcp_servers_db.get_server(server_id)
|
|
if not old:
|
|
raise HTTPException(status_code = 404, detail = "MCP server not found")
|
|
changes = _changes_from_payload(payload)
|
|
if not changes:
|
|
raise HTTPException(status_code = 400, detail = "No fields to update")
|
|
# headers == HTTP headers (remote) or env vars (stdio). On a transport-type
|
|
# switch with no new headers, drop the old ones so env secrets aren't
|
|
# re-sent as HTTP headers (or vice versa).
|
|
if (
|
|
"url" in changes
|
|
and is_stdio(changes["url"]) != is_stdio(old["url"])
|
|
and "headers_json" not in changes
|
|
):
|
|
changes["headers_json"] = None
|
|
# Clear persisted OAuth tokens when the URL changes or OAuth is disabled;
|
|
# fastmcp keys tokens by URL and would otherwise let a re-pointed server
|
|
# silently inherit the old account's credentials.
|
|
if bool(old.get("use_oauth")) and (
|
|
("url" in changes and changes["url"] != old["url"]) or changes.get("use_oauth") is False
|
|
):
|
|
await clear_oauth_tokens_async(old["url"])
|
|
mcp_servers_db.update_server(server_id, changes)
|
|
# A new endpoint/auth makes cached tools wrong and disabling makes them unreachable, so drop
|
|
# them and let the next send re-probe; a rename leaves them valid. Live stdio sessions for the
|
|
# old endpoint close too. Gate on a real value change, not mere presence: the edit dialog
|
|
# resends url/headers/oauth unchanged on a rename, which must not drop the session.
|
|
if any(changes[k] != old.get(k) for k in changes.keys() & TOOL_CACHE_INVALIDATING_FIELDS):
|
|
invalidate_tool_cache(server_id)
|
|
# Narrow to this row's env: another server row sharing the command but
|
|
# with a different env keeps its live sessions.
|
|
await asyncio.to_thread(close_stdio_sessions, old["url"], parse_server_headers(old))
|
|
return _row_to_response(mcp_servers_db.get_server(server_id))
|
|
|
|
|
|
@router.delete("/{server_id}", status_code = 204)
|
|
async def delete_mcp_server(server_id: str, current_subject: str = Depends(get_current_subject)):
|
|
old = mcp_servers_db.get_server(server_id)
|
|
if not old:
|
|
raise HTTPException(status_code = 404, detail = "MCP server not found")
|
|
if old.get("use_oauth"):
|
|
await clear_oauth_tokens_async(old["url"])
|
|
mcp_servers_db.delete_server(server_id)
|
|
invalidate_tool_cache(server_id)
|
|
await asyncio.to_thread(close_stdio_sessions, old["url"], parse_server_headers(old))
|
|
|
|
|
|
@router.post("/{server_id}/refresh", response_model = McpServerProbeResult)
|
|
async def refresh_mcp_server_tools(
|
|
server_id: str, current_subject: str = Depends(get_current_subject)
|
|
):
|
|
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 = probe_timeout(server["url"], use_oauth),
|
|
use_oauth = use_oauth,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 — surface transport+timeout errors to UI
|
|
logger.error(
|
|
"mcp_servers.refresh_failed",
|
|
server_id = server_id,
|
|
error = str(exc),
|
|
exc_info = True,
|
|
)
|
|
current = mcp_servers_db.get_server(server_id)
|
|
if current is not None and not any(
|
|
current.get(k) != server.get(k) for k in TOOL_CACHE_INVALIDATING_FIELDS
|
|
):
|
|
# Start the cool-off so the next chat send doesn't immediately re-hang
|
|
# on this server's timeout. If the row changed while the probe was
|
|
# awaiting, the failure belongs to the old config and must not park
|
|
# the newly edited server.
|
|
record_probe_failure(server_id, use_oauth)
|
|
return McpServerProbeResult(ok = False, error = safe_curated_detail(exc))
|
|
|
|
# Warm the chat-path cache so the next send skips re-probing.
|
|
current = mcp_servers_db.get_server(server_id)
|
|
if current is not None and not any(
|
|
current.get(k) != server.get(k) for k in TOOL_CACHE_INVALIDATING_FIELDS
|
|
):
|
|
cache_tools(server_id, tools)
|
|
return McpServerProbeResult(ok = True, tool_count = len(tools))
|
|
|
|
|
|
@router.post("/import", response_model = McpServerImportResult)
|
|
async def import_mcp_servers(
|
|
payload: McpServerImportRequest, current_subject: str = Depends(get_current_subject)
|
|
):
|
|
"""Bulk-register servers from a standard mcpServers JSON config (issue
|
|
#5936). Each entry rides the existing create path: _validate_url applies
|
|
the same stdio gate (a stdio entry becomes a per-entry error when stdio is
|
|
off; http still imports), and entries whose url already exists are skipped
|
|
so re-importing the same file is idempotent. One bad entry never 400s the
|
|
whole batch -- failures are reported per entry."""
|
|
entries, errors = parse_mcp_config(payload.config)
|
|
created: list[McpServerResponse] = []
|
|
skipped: list[str] = []
|
|
seen_urls = {row["url"] for row in mcp_servers_db.list_servers()}
|
|
|
|
for entry in entries:
|
|
try:
|
|
url = _validate_url(entry.url)
|
|
except HTTPException as exc:
|
|
errors.append(f"{entry.display_name}: {exc.detail}")
|
|
continue
|
|
if url in seen_urls:
|
|
skipped.append(entry.display_name)
|
|
continue
|
|
headers = _normalize_headers(entry.headers)
|
|
server_id = uuid.uuid4().hex[:16]
|
|
mcp_servers_db.create_server(
|
|
id = server_id,
|
|
display_name = entry.display_name,
|
|
url = url,
|
|
headers_json = json.dumps(headers) if headers else None,
|
|
is_enabled = entry.is_enabled,
|
|
use_oauth = entry.use_oauth and not is_stdio(url),
|
|
)
|
|
seen_urls.add(url)
|
|
created.append(_row_to_response(mcp_servers_db.get_server(server_id)))
|
|
|
|
return McpServerImportResult(created = created, skipped = skipped, errors = errors)
|
|
|
|
|
|
@router.post("/test", response_model = McpServerProbeResult)
|
|
async def test_mcp_server(
|
|
payload: McpServerTestRequest, current_subject: str = Depends(get_current_subject)
|
|
):
|
|
# URL/header validation must surface as 400 like create/update so the
|
|
# frontend's create-form pre-flight gets the same error semantics as the
|
|
# save call. Only catch transport/timeout errors below.
|
|
url = _validate_url(payload.url)
|
|
headers = _normalize_headers(payload.headers)
|
|
try:
|
|
tools = await list_tools_async(
|
|
url = url,
|
|
headers = headers,
|
|
timeout = probe_timeout(url, payload.use_oauth),
|
|
use_oauth = payload.use_oauth,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.error(
|
|
"mcp_servers.test_failed",
|
|
error = str(exc),
|
|
exc_info = True,
|
|
)
|
|
return McpServerProbeResult(ok = False, error = safe_curated_detail(exc))
|
|
|
|
return McpServerProbeResult(ok = True, tool_count = len(tools))
|