🔄 synced local 'skyvern/' with remote 'skyvern/'

This commit is contained in:
andrewneilson 2026-04-28 02:49:49 +00:00
parent 3d9a0d1b69
commit 3e0e131776
8 changed files with 284 additions and 9 deletions

View file

@ -30,6 +30,12 @@ register_lazy_command("block", "skyvern.cli.block", "block_app", "Inspect and va
register_lazy_command(
"credential", "skyvern.cli.credential", "credential_app", "MCP-parity credential commands (list/get/delete)."
)
register_lazy_command(
"config",
"skyvern.cli.config_command",
"config_app",
"Read and update organization settings (max_steps_per_run, webhook URL, retries, artifact URL expiry).",
)
register_lazy_command("workflow", "skyvern.cli.workflow", "workflow_app", "Workflow management commands.")
register_lazy_command("tasks", "skyvern.cli.tasks", "tasks_app", "Task management commands.")
register_lazy_command(

View file

@ -0,0 +1,105 @@
"""Organization config CLI: ``skyvern config show | get | set``."""
from __future__ import annotations
from typing import Any
import typer
from dotenv import load_dotenv
from skyvern.config import settings
from skyvern.forge.sdk.schemas.organizations import Organization, OrganizationUpdate
from skyvern.utils.env_paths import resolve_backend_env_path
from .commands._output import run_tool
from .mcp_tools.org import skyvern_org_get as tool_org_get
from .mcp_tools.org import skyvern_org_update as tool_org_update
_SETTABLE_KEYS: frozenset[str] = frozenset(OrganizationUpdate.model_fields)
# ``clear_*`` keys are write-only verbs (reset to NULL); not surfaced by ``get``.
_READABLE_KEYS: frozenset[str] = frozenset(
{name for name in Organization.model_fields if not name.startswith("clear_")}
)
config_app = typer.Typer(
help="Read and update organization settings (max_steps_per_run, webhook URL, retries, artifact URL expiry).",
no_args_is_help=True,
)
@config_app.callback()
def config_callback(
api_key: str | None = typer.Option(
None,
"--api-key",
envvar="SKYVERN_API_KEY",
help="Skyvern API key.",
),
) -> None:
"""Load env and apply the optional API key override."""
load_dotenv(resolve_backend_env_path())
if api_key:
settings.SKYVERN_API_KEY = api_key
@config_app.command("show")
def config_show(
json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
) -> None:
"""Show all current organization settings."""
async def _run() -> dict[str, Any]:
return await tool_org_get()
run_tool(
_run,
json_output=json_output,
hint_on_exception="Check your API key and Skyvern connection.",
action="skyvern_org_get",
)
@config_app.command("get")
def config_get(
key: str = typer.Argument(..., help=f"Setting key. One of: {', '.join(sorted(_READABLE_KEYS))}."),
json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
) -> None:
"""Get a single setting value."""
if key not in _READABLE_KEYS:
raise typer.BadParameter(f"Unknown key: {key!r}. Allowed: {', '.join(sorted(_READABLE_KEYS))}")
async def _run() -> dict[str, Any]:
result = await tool_org_get()
if not result.get("ok"):
return result
full = result.get("data") or {}
return {**result, "data": {key: full.get(key)}}
run_tool(
_run,
json_output=json_output,
hint_on_exception="Check your API key and Skyvern connection.",
action="skyvern_org_get",
)
@config_app.command("set")
def config_set(
key: str = typer.Argument(..., help=f"Setting key. One of: {', '.join(sorted(_SETTABLE_KEYS))}."),
value: str = typer.Argument(..., help="New value (Pydantic coerces strings to ints/bools)."),
json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
) -> None:
"""Update a single organization setting."""
if key not in _SETTABLE_KEYS:
raise typer.BadParameter(f"Unknown key: {key!r}. Allowed: {', '.join(sorted(_SETTABLE_KEYS))}")
async def _run() -> dict[str, Any]:
return await tool_org_update(updates={key: value})
run_tool(
_run,
json_output=json_output,
hint_on_exception="Check your API key and Skyvern connection.",
action="skyvern_org_update",
)

View file

@ -65,6 +65,10 @@ from .inspection import (
skyvern_network_route,
skyvern_network_unroute,
)
from .org import (
skyvern_org_get,
skyvern_org_update,
)
from .prompts import build_workflow, debug_automation, extract_data, qa_test
from .response import size_capped
from .scripts import (
@ -347,6 +351,10 @@ mcp.tool(tags={"storage"}, annotations=_dest("Clear Local Storage"))(skyvern_cle
mcp.tool(tags={"block_discovery"}, annotations=_ro("Get Workflow Block Schema"))(skyvern_block_schema)
mcp.tool(tags={"block_discovery"}, annotations=_ro("Validate Workflow Block"))(skyvern_block_validate)
# -- Organization settings (no browser needed) --
mcp.tool(tags={"settings"}, annotations=_ro("Get Organization Settings"))(skyvern_org_get)
mcp.tool(tags={"settings"}, annotations=_mut("Update Organization Settings"))(skyvern_org_update)
# -- Credential lookup (no browser needed) --
mcp.tool(tags={"credential"}, annotations=_ro("List Credentials"))(skyvern_credential_list)
mcp.tool(tags={"credential"}, annotations=_ro("Get Credential"))(skyvern_credential_get)
@ -448,6 +456,9 @@ __all__ = [
# Block discovery + validation
"skyvern_block_schema",
"skyvern_block_validate",
# Organization settings
"skyvern_org_get",
"skyvern_org_update",
# Credential lookup
"skyvern_credential_list",
"skyvern_credential_get",

View file

@ -17,17 +17,34 @@ async def raw_http_get(path: str, params: dict[str, Any] | None = None) -> Any:
Raises NotFoundError on 404, RuntimeError on other HTTP errors.
"""
return await _raw_http_request("GET", path, params=params)
async def raw_http_put(path: str, json_body: dict[str, Any] | None = None) -> Any:
"""PUT request to Skyvern API for endpoints without SDK methods.
Raises NotFoundError on 404, RuntimeError on other HTTP errors.
"""
return await _raw_http_request("PUT", path, json_body=json_body)
async def _raw_http_request(
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: dict[str, Any] | None = None,
) -> Any:
from ._session import get_skyvern
skyvern = get_skyvern()
# Temporary workaround: these MCP routes do not have public Fern SDK methods yet,
# so we reach through the generated client's private wrapper. Revisit if the SDK
# is regenerated or adds first-class methods for these endpoints.
response = await skyvern._client_wrapper.httpx_client.request(
path,
method="GET",
params=params or {},
)
kwargs: dict[str, Any] = {"method": method, "params": params or {}}
if json_body is not None:
kwargs["json"] = json_body
response = await skyvern._client_wrapper.httpx_client.request(path, **kwargs)
if response.status_code == 404:
raise NotFoundError(body={"detail": f"Not found: {path}"})
if response.status_code >= 400:
@ -54,5 +71,6 @@ __all__ = [
"make_error",
"make_result",
"raw_http_get",
"raw_http_put",
"save_artifact",
]

View file

@ -0,0 +1,112 @@
"""Skyvern MCP organization-settings tools."""
from __future__ import annotations
from typing import Annotated, Any
from pydantic import Field, ValidationError
from skyvern.forge.sdk.schemas.organizations import OrganizationUpdate
from ._common import ErrorCode, Timer, make_error, make_result, raw_http_get, raw_http_put
_UPDATE_FIELDS: frozenset[str] = frozenset(OrganizationUpdate.model_fields)
async def skyvern_org_get() -> dict[str, Any]:
"""Get the caller's organization settings.
Use this to discover valid keys before calling skyvern_org_update.
"""
with Timer() as timer:
try:
data = await raw_http_get("api/v1/organizations/me")
timer.mark("http")
except Exception as e:
return make_result(
"skyvern_org_get",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(ErrorCode.API_ERROR, str(e), "Check your API key and Skyvern connection"),
)
if not isinstance(data, dict) or not data.get("organization_id"):
return make_result(
"skyvern_org_get",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(ErrorCode.API_ERROR, "Unexpected response from /organizations/me", str(data)[:200]),
)
return make_result("skyvern_org_get", data=data, timing_ms=timer.timing_ms)
async def skyvern_org_update(
updates: Annotated[
dict[str, Any],
Field(
description=(
"Partial settings dict. Allowed keys: "
"max_steps_per_run (int >= 1), "
"max_retries_per_step (int >= 0), "
"webhook_callback_url (string), "
"artifact_url_expiry_seconds (int 3600-604800), "
"clear_artifact_url_expiry_seconds (bool — resets the expiry override to the global default)."
)
),
],
) -> dict[str, Any]:
"""Update organization settings. Pass only the fields you want to change."""
if not updates:
return make_result(
"skyvern_org_update",
ok=False,
error=make_error(ErrorCode.INVALID_INPUT, "updates dict is empty", "Pass at least one settable key"),
)
none_keys = sorted(k for k, v in updates.items() if v is None)
if none_keys:
return make_result(
"skyvern_org_update",
ok=False,
error=make_error(
ErrorCode.INVALID_INPUT,
f"None is not a valid value for: {', '.join(none_keys)}",
"Omit keys you don't want to change instead of passing None",
),
)
unknown = sorted(set(updates) - set(_UPDATE_FIELDS))
if unknown:
return make_result(
"skyvern_org_update",
ok=False,
error=make_error(
ErrorCode.INVALID_INPUT,
f"Unknown settings keys: {', '.join(unknown)}",
f"Allowed keys: {', '.join(_UPDATE_FIELDS)}",
),
)
try:
validated = OrganizationUpdate.model_validate(updates).model_dump(exclude_unset=True)
except ValidationError as e:
return make_result(
"skyvern_org_update",
ok=False,
error=make_error(ErrorCode.INVALID_INPUT, str(e), "Check field types and ranges"),
)
with Timer() as timer:
try:
data = await raw_http_put("api/v1/organizations", json_body=validated)
timer.mark("http")
except Exception as e:
return make_result(
"skyvern_org_update",
ok=False,
timing_ms=timer.timing_ms,
error=make_error(ErrorCode.API_ERROR, str(e), "Verify field types and ranges"),
)
return make_result("skyvern_org_update", data=data, timing_ms=timer.timing_ms)

View file

@ -165,11 +165,12 @@ class OrganizationsRepository(BaseRepository):
raise NotFoundError
if organization_name:
organization.organization_name = organization_name
if webhook_callback_url:
# ``is not None`` (not truthy): "" clears the webhook, 0 disables retries.
if webhook_callback_url is not None:
organization.webhook_callback_url = webhook_callback_url
if max_steps_per_run:
if max_steps_per_run is not None:
organization.max_steps_per_run = max_steps_per_run
if max_retries_per_step:
if max_retries_per_step is not None:
organization.max_retries_per_step = max_retries_per_step
# ``clear_*`` decouples "don't update" (None) from "explicitly clear":
# callers pass ``clear_artifact_url_expiry_seconds=True`` to reset

View file

@ -3272,6 +3272,8 @@ async def update_organization(
return await app.DATABASE.organizations.update_organization(
current_org.organization_id,
max_steps_per_run=org_update.max_steps_per_run,
max_retries_per_step=org_update.max_retries_per_step,
webhook_callback_url=org_update.webhook_callback_url,
artifact_url_expiry_seconds=org_update.artifact_url_expiry_seconds,
clear_artifact_url_expiry_seconds=org_update.clear_artifact_url_expiry_seconds,
)
@ -3294,6 +3296,23 @@ async def get_organizations(
return GetOrganizationsResponse(organizations=[current_org])
@legacy_base_router.get(
"/organizations/me",
tags=["server"],
openapi_extra={
"x-fern-sdk-method-name": "get_current_organization",
},
)
@legacy_base_router.get(
"/organizations/me/",
include_in_schema=False,
)
async def get_current_organization(
current_org: Organization = Depends(org_auth_service.get_current_org),
) -> Organization:
return current_org
@legacy_base_router.get(
"/organizations/{organization_id}/apikeys/",
tags=["server"],

View file

@ -176,7 +176,10 @@ class GetOrganizationAPIKeysResponse(BaseModel):
class OrganizationUpdate(BaseModel):
max_steps_per_run: int | None = None
max_steps_per_run: int | None = Field(default=None, ge=1)
# 0 is a valid "disable retries" value — see ForgeAgent.execute_step.
max_retries_per_step: int | None = Field(default=None, ge=0)
webhook_callback_url: str | None = None
artifact_url_expiry_seconds: int | None = Field(
None,
description=(