fix: harden workflow config rpc target routing

This commit is contained in:
lda
2026-06-03 09:49:56 +07:00 Verified
parent 8a1d627f06
commit 9389dd6df6
14 changed files with 379 additions and 47 deletions
+115 -17
View File
@@ -2,19 +2,21 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import json
import typer
from pydantic import ValidationError
from wf_api import WorkflowApi
from wf_mcp.broker import build_service_from_config, load_broker_config
from wf_mcp.broker.service import WfMcpService
from wf_mcp.broker.service.workflow_operation_context import context_from_service
from wf_config import (
FilesystemStoreConfig,
LocalTargetConfig,
RpcHttpTargetConfig,
load_workflow_config,
)
from wf_mcp.broker import build_service_from_config, load_broker_config
from wf_mcp.broker.service import WfMcpService
from wf_mcp.broker.service.workflow_operation_context import context_from_service
from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient
@@ -28,6 +30,15 @@ class CliContext:
handlers: "WorkflowApi | RpcWorkflowApiClient"
@dataclass(frozen=True)
class LocalCliContext:
"""CLI context for commands that still require same-process WorkflowApi."""
config_path: Path
service: WfMcpService | None
handlers: WorkflowApi
def config_path_from_context(ctx: typer.Context) -> str:
"""Return the root --config path captured by the Typer callback."""
obj = ctx.obj if isinstance(ctx.obj, dict) else {}
@@ -44,7 +55,24 @@ def load_cli_context(
) -> CliContext:
"""Load config and build workflow-surface handlers for CLI commands."""
resolved_config_path = Path(config_path)
if resolved_config_path.name == "wf_mcp.config.json":
if force_local and rpc_url is not None:
raise ValueError("--local and --url are mutually exclusive")
if rpc_url is not None:
_validate_rpc_url(rpc_url)
return CliContext(
config_path=resolved_config_path,
service=None,
handlers=RpcWorkflowApiClient(
url=rpc_url,
timeout_seconds=_rpc_timeout_from_optional_config(
resolved_config_path,
override=rpc_timeout_seconds,
),
),
)
if _is_legacy_mcp_config(resolved_config_path):
config = load_broker_config(resolved_config_path)
service = build_service_from_config(config)
return CliContext(
@@ -55,13 +83,6 @@ def load_cli_context(
config = load_workflow_config(resolved_config_path)
target = config.client.target
if rpc_url is not None:
timeout = rpc_timeout_seconds if rpc_timeout_seconds is not None else 30.0
return CliContext(
config_path=resolved_config_path,
service=None,
handlers=RpcWorkflowApiClient(url=rpc_url, timeout_seconds=timeout),
)
if force_local or isinstance(target, LocalTargetConfig):
store = config.server.store
if not isinstance(store, FilesystemStoreConfig):
@@ -88,6 +109,32 @@ def load_cli_context(
raise ValueError(f"unsupported workflow target {target!r}")
def load_local_cli_context(
config_path: str | Path,
*,
force_local: bool = False,
rpc_url: str | None = None,
rpc_timeout_seconds: float | None = None,
) -> LocalCliContext:
"""Load a local WorkflowApi context for commands not remote-enabled yet."""
context = load_cli_context(
config_path,
force_local=force_local,
rpc_url=rpc_url,
rpc_timeout_seconds=rpc_timeout_seconds,
)
if not isinstance(context.handlers, WorkflowApi):
raise ValueError(
"this CLI command is not available for rpc_http targets yet; "
"use --local or run a cap/run command"
)
return LocalCliContext(
config_path=context.config_path,
service=context.service,
handlers=context.handlers,
)
def force_local_from_context(ctx: typer.Context) -> bool:
obj = ctx.obj if isinstance(ctx.obj, dict) else {}
return bool(obj.get("force_local", False))
@@ -106,9 +153,60 @@ def rpc_timeout_from_context(ctx: typer.Context) -> float | None:
def load_cli_context_from_typer(ctx: typer.Context) -> CliContext:
return load_cli_context(
config_path_from_context(ctx),
force_local=force_local_from_context(ctx),
rpc_url=rpc_url_from_context(ctx),
rpc_timeout_seconds=rpc_timeout_from_context(ctx),
)
try:
return load_cli_context(
config_path_from_context(ctx),
force_local=force_local_from_context(ctx),
rpc_url=rpc_url_from_context(ctx),
rpc_timeout_seconds=rpc_timeout_from_context(ctx),
)
except ValueError as exc:
raise typer.BadParameter(str(exc)) from exc
def load_local_cli_context_from_typer(ctx: typer.Context) -> LocalCliContext:
try:
return load_local_cli_context(
config_path_from_context(ctx),
force_local=force_local_from_context(ctx),
rpc_url=rpc_url_from_context(ctx),
rpc_timeout_seconds=rpc_timeout_from_context(ctx),
)
except ValueError as exc:
raise typer.BadParameter(str(exc)) from exc
def _is_legacy_mcp_config(path: Path) -> bool:
"""Detect legacy broker config by content, not filename.
This keeps `wf_mcp.config.json` compatibility without making the filename a
load-bearing part of the neutral workflow config migration.
"""
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return False
if any(key in data for key in ("version", "client", "server")):
return False
return any(key in data for key in ("store_root", "connections"))
def _rpc_timeout_from_optional_config(
path: Path,
*,
override: float | None,
) -> float:
if override is not None:
return override
try:
config = load_workflow_config(path)
except FileNotFoundError, json.JSONDecodeError, ValidationError:
return 30.0
target = config.client.target
if isinstance(target, RpcHttpTargetConfig):
return target.timeout_seconds
return 30.0
def _validate_rpc_url(url: str) -> None:
if not url.startswith(("http://", "https://")):
raise ValueError("rpc url must start with http:// or https://")