SKY-7801/7802: MCP Foundation + Hybrid Browser Tools (selector + AI intent) (#4660)

This commit is contained in:
Marc Kelechava
2026-02-07 02:33:13 -08:00
committed by GitHub
parent cb7225c6e6
commit 4f1bf25768
12 changed files with 1826 additions and 6 deletions

View File

@@ -0,0 +1,43 @@
"""Shared core layer for Skyvern CLI and MCP tools.
This package provides reusable primitives that both MCP tools and CLI commands
import from, preventing logic duplication across interfaces.
"""
from .artifacts import get_artifact_dir, save_artifact
from .client import get_skyvern
from .result import Artifact, BrowserContext, ErrorCode, Timer, make_error, make_result
from .session_manager import (
BrowserNotAvailableError,
SessionState,
browser_session,
get_current_session,
get_page,
no_browser_error,
resolve_browser,
set_current_session,
)
__all__ = [
# client.py
"get_skyvern",
# result.py
"Artifact",
"BrowserContext",
"ErrorCode",
"Timer",
"make_error",
"make_result",
# artifacts.py
"get_artifact_dir",
"save_artifact",
# session_manager.py
"BrowserNotAvailableError",
"SessionState",
"browser_session",
"get_current_session",
"get_page",
"no_browser_error",
"resolve_browser",
"set_current_session",
]

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
from .result import Artifact
def get_artifact_dir(session_id: str | None = None, run_id: str | None = None) -> Path:
base = Path.home() / ".skyvern" / "artifacts" / datetime.now(timezone.utc).strftime("%Y-%m-%d")
if session_id:
return base / session_id
if run_id:
return base / run_id
return base / "anonymous"
def save_artifact(
content: bytes,
kind: str,
filename: str,
mime: str,
session_id: str | None = None,
) -> Artifact:
dir_path = get_artifact_dir(session_id)
dir_path.mkdir(parents=True, exist_ok=True)
file_path = dir_path / filename
file_path.write_bytes(content)
return Artifact(kind=kind, path=str(file_path), mime=mime, bytes=len(content))

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import os
from contextvars import ContextVar
from skyvern.client import SkyvernEnvironment
from skyvern.config import settings
from skyvern.library.skyvern import Skyvern
_skyvern_instance: ContextVar[Skyvern | None] = ContextVar("skyvern_instance", default=None)
def get_skyvern() -> Skyvern:
"""Get or create a Skyvern client instance."""
instance = _skyvern_instance.get()
if instance is not None:
return instance
api_key = settings.SKYVERN_API_KEY or os.environ.get("SKYVERN_API_KEY")
base_url = settings.SKYVERN_BASE_URL or os.environ.get("SKYVERN_BASE_URL")
if api_key:
instance = Skyvern(
api_key=api_key,
environment=SkyvernEnvironment.CLOUD,
base_url=base_url,
)
else:
instance = Skyvern.local()
_skyvern_instance.set(instance)
return instance

106
skyvern/cli/core/result.py Normal file
View File

@@ -0,0 +1,106 @@
from __future__ import annotations
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
class ErrorCode:
NO_ACTIVE_BROWSER = "NO_ACTIVE_BROWSER"
BROWSER_NOT_FOUND = "BROWSER_NOT_FOUND"
SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND"
ACTION_FAILED = "ACTION_FAILED"
AI_FALLBACK_FAILED = "AI_FALLBACK_FAILED"
SDK_ERROR = "SDK_ERROR"
TIMEOUT = "TIMEOUT"
INVALID_INPUT = "INVALID_INPUT"
@dataclass
class Artifact:
kind: str
path: str
mime: str
bytes: int
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def to_dict(self) -> dict[str, Any]:
return {
"kind": self.kind,
"path": self.path,
"mime": self.mime,
"bytes": self.bytes,
"created_at": self.created_at,
}
@dataclass
class BrowserContext:
mode: str
session_id: str | None = None
cdp_url: str | None = None
def to_dict(self) -> dict[str, Any]:
return {
"mode": self.mode,
"session_id": self.session_id,
"cdp_url": self.cdp_url,
}
def make_result(
action: str,
*,
ok: bool = True,
browser_context: BrowserContext | None = None,
data: dict[str, Any] | None = None,
artifacts: list[Artifact] | None = None,
timing_ms: dict[str, int] | None = None,
warnings: list[str] | None = None,
error: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"ok": ok,
"action": action,
"browser_context": (browser_context or BrowserContext(mode="none")).to_dict(),
"data": data,
"artifacts": [a.to_dict() for a in (artifacts or [])],
"timing_ms": timing_ms or {},
"warnings": warnings or [],
"error": error,
}
def make_error(
code: str,
message: str,
hint: str,
details: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"code": code,
"message": message,
"hint": hint,
"details": details or {},
}
class Timer:
def __init__(self) -> None:
self._start: float = 0
self._marks: dict[str, int] = {}
def __enter__(self) -> Timer:
self._start = time.perf_counter()
return self
def __exit__(self, *args: Any) -> None:
self._marks["total"] = int((time.perf_counter() - self._start) * 1000)
def mark(self, name: str) -> None:
self._marks[name] = int((time.perf_counter() - self._start) * 1000)
@property
def timing_ms(self) -> dict[str, int]:
return self._marks.copy()

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, AsyncIterator
from .client import get_skyvern
from .result import BrowserContext, ErrorCode, make_error
if TYPE_CHECKING:
from skyvern.library.skyvern_browser import SkyvernBrowser
from skyvern.library.skyvern_browser_page import SkyvernBrowserPage
@dataclass
class SessionState:
browser: SkyvernBrowser | None = None
context: BrowserContext | None = None
console_messages: list[dict[str, Any]] = field(default_factory=list)
tracing_active: bool = False
har_enabled: bool = False
_current_session: ContextVar[SessionState | None] = ContextVar("mcp_session", default=None)
def get_current_session() -> SessionState:
state = _current_session.get()
if state is None:
state = SessionState()
_current_session.set(state)
return state
def set_current_session(state: SessionState) -> None:
_current_session.set(state)
async def resolve_browser(
session_id: str | None = None,
cdp_url: str | None = None,
local: bool = False,
create_session: bool = False,
timeout: int | None = None,
headless: bool = False,
) -> tuple[SkyvernBrowser, BrowserContext]:
"""Resolve browser from parameters or current session.
Note: For MCP tools, sessions are stored in ContextVar and persist across tool calls.
Cleanup is done via explicit skyvern_session_close() call. For scripts that need
guaranteed cleanup, use the browser_session() context manager instead.
"""
skyvern = get_skyvern()
current = get_current_session()
browser: SkyvernBrowser | None = None
try:
if session_id:
browser = await skyvern.connect_to_cloud_browser_session(session_id)
ctx = BrowserContext(mode="cloud_session", session_id=session_id)
set_current_session(SessionState(browser=browser, context=ctx))
return browser, ctx
if cdp_url:
browser = await skyvern.connect_to_browser_over_cdp(cdp_url)
ctx = BrowserContext(mode="cdp", cdp_url=cdp_url)
set_current_session(SessionState(browser=browser, context=ctx))
return browser, ctx
if local:
browser = await skyvern.launch_local_browser(headless=headless)
ctx = BrowserContext(mode="local")
set_current_session(SessionState(browser=browser, context=ctx))
return browser, ctx
if create_session:
browser = await skyvern.launch_cloud_browser(timeout=timeout)
ctx = BrowserContext(mode="cloud_session", session_id=browser.browser_session_id)
set_current_session(SessionState(browser=browser, context=ctx))
return browser, ctx
except Exception:
if browser is not None:
try:
await browser.close()
except Exception:
pass
set_current_session(SessionState())
raise
if current.browser is not None and current.context is not None:
return current.browser, current.context
raise BrowserNotAvailableError()
async def get_page(
session_id: str | None = None,
cdp_url: str | None = None,
) -> tuple[SkyvernBrowserPage, BrowserContext]:
"""Get the working page from the current or specified browser session."""
browser, ctx = await resolve_browser(session_id=session_id, cdp_url=cdp_url)
page = await browser.get_working_page()
return page, ctx
@asynccontextmanager
async def browser_session(
session_id: str | None = None,
cdp_url: str | None = None,
local: bool = False,
timeout: int | None = None,
headless: bool = False,
) -> AsyncIterator[tuple[SkyvernBrowser, BrowserContext]]:
"""Context manager for browser sessions with guaranteed cleanup.
Use this in scripts that need guaranteed resource cleanup on error.
MCP tools use resolve_browser() directly since sessions persist across calls.
Example:
async with browser_session(local=True) as (browser, ctx):
page = await browser.get_working_page()
await page.goto("https://example.com")
# Browser is automatically closed on exit or exception
"""
browser, ctx = await resolve_browser(
session_id=session_id,
cdp_url=cdp_url,
local=local,
create_session=not (session_id or cdp_url or local),
timeout=timeout,
headless=headless,
)
try:
yield browser, ctx
finally:
try:
await browser.close()
except Exception:
pass # Best effort cleanup
set_current_session(SessionState())
class BrowserNotAvailableError(Exception):
"""Raised when no browser session is available."""
def no_browser_error() -> dict[str, Any]:
return make_error(
ErrorCode.NO_ACTIVE_BROWSER,
"No browser session available",
"Create a session with skyvern_session_create, provide session_id, or cdp_url",
)