From c4190c72c2d6e964bcaa12b4c74f953ad4bbc309 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Thu, 7 May 2026 07:46:51 +0000 Subject: [PATCH 01/15] =?UTF-8?q?chore(skill):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20SKILL/?= =?UTF-8?q?=20=E2=86=92=20v8-runner/=20=D0=BF=D0=BE=D0=B4=20Anthropic=20Sk?= =?UTF-8?q?ills=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic Skills convention: каталог скила = его имя в kebab-case, внутри обязательно SKILL.md + references/. Готовый каталог можно дроп-копировать в ~/.claude/skills// без переименования. В этом репо frontmatter скила декларирует name: v8-runner, поэтому каталог приводим в соответствие. Что сделано: - git mv SKILL v8-runner (sохраняет историю) - ссылки обновлены: AGENTS.md (×2), spec/decisions/0022-*.md (×1) После рефакторинга разработчику достаточно сделать cp -r v8-runner ~/.claude/skills/ чтобы скил стал доступен Claude Code. --- .gitignore | 4 ++-- AGENTS.md | 4 ++-- ...-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md | 2 +- {SKILL => v8-runner}/SKILL.md | 0 {SKILL => v8-runner}/agents/openai.yaml | 0 {SKILL => v8-runner}/references/command-selection.md | 0 {SKILL => v8-runner}/references/config-and-backends.md | 0 .../references/file-and-artifact-workflows.md | 0 {SKILL => v8-runner}/references/project-workflows.md | 0 {SKILL => v8-runner}/references/testing.md | 0 {SKILL => v8-runner}/references/troubleshooting.md | 0 11 files changed, 5 insertions(+), 5 deletions(-) rename {SKILL => v8-runner}/SKILL.md (100%) rename {SKILL => v8-runner}/agents/openai.yaml (100%) rename {SKILL => v8-runner}/references/command-selection.md (100%) rename {SKILL => v8-runner}/references/config-and-backends.md (100%) rename {SKILL => v8-runner}/references/file-and-artifact-workflows.md (100%) rename {SKILL => v8-runner}/references/project-workflows.md (100%) rename {SKILL => v8-runner}/references/testing.md (100%) rename {SKILL => v8-runner}/references/troubleshooting.md (100%) diff --git a/.gitignore b/.gitignore index 3f895b6..4e3328b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,5 @@ kotlin-example/ 1cv8/ ai-artefacts/ references/ -!SKILL/references/ -!SKILL/references/** +!v8-runner/references/ +!v8-runner/references/** diff --git a/AGENTS.md b/AGENTS.md index 99b2962..4424856 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,9 +24,9 @@ ## Repo-local SKILL -`SKILL/SKILL.md` — это скилл для использования `v8-runner` в других 1С-проектах. При реализации задач актуализировать его, если меняются команды, workflow, контракты конфигурации, ограничения или диагностические практики, важные для внешнего применения. +`v8-runner/SKILL.md` — это скилл для использования `v8-runner` в других 1С-проектах. При реализации задач актуализировать его, если меняются команды, workflow, контракты конфигурации, ограничения или диагностические практики, важные для внешнего применения. -Правки в `SKILL/SKILL.md` должны быть краткими, емкими и по делу: добавлять только применимые инструкции, без пересказа внутренних деталей реализации и без раздувания справки. +Правки в `v8-runner/SKILL.md` должны быть краткими, емкими и по делу: добавлять только применимые инструкции, без пересказа внутренних деталей реализации и без раздувания справки. ## Commit message format diff --git a/spec/decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md b/spec/decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md index 944bf4b..585aeb5 100644 --- a/spec/decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md +++ b/spec/decisions/0022-universalnyy-mehanizm-podgotovki-rasshireniy-i-client-mcp-extension.md @@ -147,7 +147,7 @@ validation error, а не поздний platform failure. - добавить понятный hint на `v8-runner build`, если client MCP launch зависит от неподготовленной ИБ. 7. Docs/tests: - обновить `docs/CONFIGURATION.md`, `docs/CAPABILITIES.md` and examples; - - после реализации обновить `SKILL/SKILL.md`, чтобы repo-local skill описывал shipped behavior; + - после реализации обновить `v8-runner/SKILL.md`, чтобы repo-local skill описывал shipped behavior; - добавить targeted tests для source/artifact validation, EDT workspace init, build preparation, source-backed no-change skip, full-rebuild refresh and no-install launch behavior. diff --git a/SKILL/SKILL.md b/v8-runner/SKILL.md similarity index 100% rename from SKILL/SKILL.md rename to v8-runner/SKILL.md diff --git a/SKILL/agents/openai.yaml b/v8-runner/agents/openai.yaml similarity index 100% rename from SKILL/agents/openai.yaml rename to v8-runner/agents/openai.yaml diff --git a/SKILL/references/command-selection.md b/v8-runner/references/command-selection.md similarity index 100% rename from SKILL/references/command-selection.md rename to v8-runner/references/command-selection.md diff --git a/SKILL/references/config-and-backends.md b/v8-runner/references/config-and-backends.md similarity index 100% rename from SKILL/references/config-and-backends.md rename to v8-runner/references/config-and-backends.md diff --git a/SKILL/references/file-and-artifact-workflows.md b/v8-runner/references/file-and-artifact-workflows.md similarity index 100% rename from SKILL/references/file-and-artifact-workflows.md rename to v8-runner/references/file-and-artifact-workflows.md diff --git a/SKILL/references/project-workflows.md b/v8-runner/references/project-workflows.md similarity index 100% rename from SKILL/references/project-workflows.md rename to v8-runner/references/project-workflows.md diff --git a/SKILL/references/testing.md b/v8-runner/references/testing.md similarity index 100% rename from SKILL/references/testing.md rename to v8-runner/references/testing.md diff --git a/SKILL/references/troubleshooting.md b/v8-runner/references/troubleshooting.md similarity index 100% rename from SKILL/references/troubleshooting.md rename to v8-runner/references/troubleshooting.md From 0c0c6f92162a9040a9c2925d7c7db471f0d32f00 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Thu, 7 May 2026 07:55:54 +0000 Subject: [PATCH 02/15] feat(mcp): TCP probe and WS payload assembly for session-manager Adds src/use_cases/mcp_ws with: - McpClientTransport (Ws/Legacy/Auto) selector - ClientKind enum mapping entry-points to manager-side kind values - WsLaunchParams::payload_snippet() that produces the 'mcpMode=ws;manager_url=...;client_uid=...;kind=...;corr_id=...; mcp_log_level=...;mcp_ws_timeout_ms=...' segment for /C - select_transport() with probe-callback (Auto -> WS or Legacy) - probe_tcp() built on std::net::TcpStream::connect_timeout - parse_manager_addr() that extracts host:port from a ws://... URL - validate_log_level() for off|error|warn|info|debug|trace Pure module without project plumbing yet; later commits wire it into launch_app, run_tests, CLI args and config loading. --- src/use_cases/mcp_ws.rs | 441 ++++++++++++++++++++++++++++++++++++++++ src/use_cases/mod.rs | 2 + 2 files changed, 443 insertions(+) create mode 100644 src/use_cases/mcp_ws.rs diff --git a/src/use_cases/mcp_ws.rs b/src/use_cases/mcp_ws.rs new file mode 100644 index 0000000..e42af6c --- /dev/null +++ b/src/use_cases/mcp_ws.rs @@ -0,0 +1,441 @@ +//! Shared helpers for assembling the WS-mode `/C` payload that connects +//! 1C-clients to `v8-client-session-manager` instead of the legacy local +//! HTTP MCP server. +//! +//! The actual `/C` payload is parsed by the BSL extension `client_mcp` (see +//! `Мсп_ПараметрыЗапускаКлиент`). This module is responsible for: +//! +//! * choosing between the new WS transport and the legacy HTTP transport, +//! * probing the manager's TCP socket when the transport is `auto`, +//! * generating per-launch `client_uid`/`corr_id` values, +//! * and serializing the final `key=value;...` snippet. +//! +//! Higher layers (`launch_app`, `run_tests`) decide where this snippet is +//! merged into the final `/C` value. + +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use uuid::Uuid; + +/// Default WS endpoint when no override is supplied. +pub const DEFAULT_MANAGER_URL: &str = "ws://127.0.0.1:4000/sessions"; +/// Default log-level value when no override is supplied. +pub const DEFAULT_MCP_LOG_LEVEL: &str = "info"; +/// Default WS-handshake timeout when no override is supplied. +pub const DEFAULT_MCP_WS_TIMEOUT_MS: u64 = 1000; +/// Default TCP-probe timeout for `auto` transport detection. +pub const PROBE_TIMEOUT_MS: u64 = 200; + +/// Transport selector controlling how the MCP client connects to the +/// session-manager. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpClientTransport { + /// Force WS-only mode. Resolution fails if the manager is unreachable. + Ws, + /// Force the legacy local HTTP transport (`runMcp[=...][;mcpPort=...]`). + Legacy, + /// Probe the manager: WS when reachable, legacy otherwise. + Auto, +} + +impl Default for McpClientTransport { + fn default() -> Self { + Self::Auto + } +} + +impl McpClientTransport { + pub fn from_str_value(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "ws" => Some(Self::Ws), + "legacy" => Some(Self::Legacy), + "auto" => Some(Self::Auto), + _ => None, + } + } +} + +/// Internal client kind values selected by entry-point. Never exposed via CLI +/// flags; see the task brief for the reasoning. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientKind { + V8RunnerClient, + VanessaTestClient, + YaxunitRunner, +} + +impl ClientKind { + pub const fn as_str(self) -> &'static str { + match self { + Self::V8RunnerClient => "v8_runner_client", + Self::VanessaTestClient => "vanessa_test_client", + Self::YaxunitRunner => "yaxunit_runner", + } + } +} + +/// Validated supported log-level values that the BSL devkit accepts. +const ALLOWED_LOG_LEVELS: &[&str] = &["off", "error", "warn", "info", "debug", "trace"]; + +/// Returns `true` when the value is one of the levels accepted by the devkit. +pub fn is_supported_log_level(level: &str) -> bool { + ALLOWED_LOG_LEVELS.contains(&level) +} + +/// Result of resolving the WS-mode connection parameters before launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WsLaunchParams { + pub manager_url: String, + pub client_uid: String, + pub kind: ClientKind, + pub corr_id: String, + pub log_level: String, + pub ws_timeout_ms: u64, +} + +impl WsLaunchParams { + /// Builds the `mcpMode=ws;...` snippet suitable for inclusion into the + /// platform's `/C` payload. + pub fn payload_snippet(&self) -> String { + format!( + "mcpMode=ws;manager_url={};client_uid={};kind={};corr_id={};mcp_log_level={};mcp_ws_timeout_ms={}", + self.manager_url, + self.client_uid, + self.kind.as_str(), + self.corr_id, + self.log_level, + self.ws_timeout_ms + ) + } +} + +/// Inputs to [`resolve_ws_params`]. Each field carries either an explicit +/// override (CLI takes precedence over config), or `None` to fall back to the +/// internal defaults. +#[derive(Debug, Clone, Default)] +pub struct WsResolveInputs { + pub manager_url: Option, + pub client_uid: Option, + pub corr_id: Option, + pub log_level: Option, + pub ws_timeout_ms: Option, +} + +impl WsResolveInputs { + pub fn manager_url_or_default(&self) -> String { + self.manager_url + .clone() + .unwrap_or_else(|| DEFAULT_MANAGER_URL.to_owned()) + } +} + +/// Resolves the final WS launch parameters for the given client kind. +pub fn resolve_ws_params(kind: ClientKind, inputs: WsResolveInputs) -> WsLaunchParams { + let manager_url = inputs.manager_url_or_default(); + let client_uid = inputs + .client_uid + .filter(|uid| !uid.trim().is_empty()) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + let corr_id = inputs + .corr_id + .filter(|c| !c.trim().is_empty()) + .unwrap_or_else(|| default_corr_id(&client_uid)); + let log_level = inputs + .log_level + .filter(|l| !l.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_MCP_LOG_LEVEL.to_owned()); + let ws_timeout_ms = inputs.ws_timeout_ms.unwrap_or(DEFAULT_MCP_WS_TIMEOUT_MS); + WsLaunchParams { + manager_url, + client_uid, + kind, + corr_id, + log_level, + ws_timeout_ms, + } +} + +fn default_corr_id(client_uid: &str) -> String { + let short: String = client_uid.chars().filter(|c| *c != '-').take(8).collect(); + format!("vr-{short}") +} + +/// Errors that can be produced while resolving the WS endpoint or probing it. +#[derive(Debug, thiserror::Error)] +pub enum WsResolveError { + #[error("invalid manager_url '{url}': {reason}")] + InvalidManagerUrl { url: String, reason: String }, + #[error("session-manager unreachable at {url}")] + Unreachable { url: String }, + #[error("unsupported mcp_log_level '{0}'")] + UnsupportedLogLevel(String), +} + +/// Decision returned by [`select_transport`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportDecision { + Ws, + Legacy, +} + +/// Selects the effective transport given the requested mode and a `probe` +/// callback that returns `true` when the manager TCP endpoint is reachable. +pub fn select_transport( + requested: McpClientTransport, + manager_url: &str, + probe: F, +) -> Result +where + F: FnOnce(SocketAddr) -> bool, +{ + match requested { + McpClientTransport::Legacy => Ok(TransportDecision::Legacy), + McpClientTransport::Ws => { + let addr = parse_manager_addr(manager_url)?; + if probe(addr) { + Ok(TransportDecision::Ws) + } else { + Err(WsResolveError::Unreachable { + url: manager_url.to_owned(), + }) + } + } + McpClientTransport::Auto => { + let addr = parse_manager_addr(manager_url)?; + if probe(addr) { + Ok(TransportDecision::Ws) + } else { + Ok(TransportDecision::Legacy) + } + } + } +} + +/// Default sync TCP probe used in production. Tries `connect_timeout` against +/// the resolved address. +pub fn probe_tcp(addr: SocketAddr, timeout: Duration) -> bool { + TcpStream::connect_timeout(&addr, timeout).is_ok() +} + +/// Validates an `mcp_log_level` value if one is provided. +pub fn validate_log_level(level: Option<&str>) -> Result<(), WsResolveError> { + if let Some(level) = level { + if !is_supported_log_level(level) { + return Err(WsResolveError::UnsupportedLogLevel(level.to_owned())); + } + } + Ok(()) +} + +/// Parses the `host:port` portion of a `ws://host:port/path` URL and resolves +/// it to a usable [`SocketAddr`]. Falls back to lookup via `to_socket_addrs`. +pub fn parse_manager_addr(url: &str) -> Result { + let trimmed = url.trim(); + if trimmed.is_empty() { + return Err(WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: "empty url".to_owned(), + }); + } + let after_scheme = match trimmed.find("://") { + Some(idx) => &trimmed[idx + 3..], + None => trimmed, + }; + let host_port = after_scheme.split('/').next().unwrap_or(after_scheme); + if host_port.is_empty() { + return Err(WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: "missing host:port".to_owned(), + }); + } + if !host_port.contains(':') { + return Err(WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: "missing :port".to_owned(), + }); + } + host_port + .to_socket_addrs() + .map_err(|err| WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: err.to_string(), + })? + .next() + .ok_or_else(|| WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: "address resolved to empty set".to_owned(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + #[test] + fn transport_from_str_accepts_known_values() { + assert_eq!( + McpClientTransport::from_str_value("ws"), + Some(McpClientTransport::Ws) + ); + assert_eq!( + McpClientTransport::from_str_value("LEGACY"), + Some(McpClientTransport::Legacy) + ); + assert_eq!( + McpClientTransport::from_str_value("auto"), + Some(McpClientTransport::Auto) + ); + assert_eq!(McpClientTransport::from_str_value("nope"), None); + } + + #[test] + fn parse_manager_addr_extracts_host_port() { + let addr = parse_manager_addr("ws://127.0.0.1:4000/sessions").expect("parse"); + assert_eq!(addr.port(), 4000); + } + + #[test] + fn parse_manager_addr_requires_port() { + let err = parse_manager_addr("ws://127.0.0.1/sessions").expect_err("rejected"); + assert!(matches!(err, WsResolveError::InvalidManagerUrl { .. })); + } + + #[test] + fn parse_manager_addr_rejects_empty() { + let err = parse_manager_addr("").expect_err("rejected"); + assert!(matches!(err, WsResolveError::InvalidManagerUrl { .. })); + } + + #[test] + fn select_transport_legacy_short_circuits() { + let decision = select_transport( + McpClientTransport::Legacy, + "ws://127.0.0.1:4000/sessions", + |_| panic!("probe must not be called"), + ) + .expect("legacy"); + assert_eq!(decision, TransportDecision::Legacy); + } + + #[test] + fn select_transport_auto_falls_back_to_legacy_when_unreachable() { + let decision = select_transport( + McpClientTransport::Auto, + "ws://127.0.0.1:4000/sessions", + |_| false, + ) + .expect("auto-fallback"); + assert_eq!(decision, TransportDecision::Legacy); + } + + #[test] + fn select_transport_ws_errors_when_unreachable() { + let err = select_transport( + McpClientTransport::Ws, + "ws://127.0.0.1:4000/sessions", + |_| false, + ) + .expect_err("ws-required"); + assert!(matches!(err, WsResolveError::Unreachable { .. })); + } + + #[test] + fn select_transport_ws_uses_probe_result() { + let decision = select_transport( + McpClientTransport::Ws, + "ws://127.0.0.1:4000/sessions", + |_| true, + ) + .expect("ws-up"); + assert_eq!(decision, TransportDecision::Ws); + } + + #[test] + fn probe_tcp_succeeds_against_listener() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral"); + let addr = listener.local_addr().expect("local addr"); + assert!(probe_tcp(addr, Duration::from_millis(500))); + } + + #[test] + fn probe_tcp_fails_when_no_listener() { + // Bind to an ephemeral port and immediately drop the listener; the OS + // will reject connections to that port until reuse, which is enough + // for a unit-test. + let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral"); + let addr = listener.local_addr().expect("local addr"); + drop(listener); + // Allow a generous timeout — RST should arrive quickly. + let connected = probe_tcp(addr, Duration::from_millis(200)); + assert!(!connected); + } + + #[test] + fn resolve_ws_params_uses_defaults_when_inputs_empty() { + let params = resolve_ws_params(ClientKind::V8RunnerClient, WsResolveInputs::default()); + assert_eq!(params.manager_url, DEFAULT_MANAGER_URL); + assert_eq!(params.kind, ClientKind::V8RunnerClient); + assert_eq!(params.log_level, DEFAULT_MCP_LOG_LEVEL); + assert_eq!(params.ws_timeout_ms, DEFAULT_MCP_WS_TIMEOUT_MS); + assert!(!params.client_uid.is_empty()); + assert!(params.corr_id.starts_with("vr-")); + assert_eq!(params.corr_id.len(), "vr-".len() + 8); + } + + #[test] + fn resolve_ws_params_honors_overrides() { + let inputs = WsResolveInputs { + manager_url: Some("ws://manager:5555/sessions".to_owned()), + client_uid: Some("00000000-0000-0000-0000-000000000001".to_owned()), + corr_id: Some("parent/vr-deadbeef".to_owned()), + log_level: Some("debug".to_owned()), + ws_timeout_ms: Some(2500), + }; + let params = resolve_ws_params(ClientKind::YaxunitRunner, inputs); + assert_eq!(params.manager_url, "ws://manager:5555/sessions"); + assert_eq!(params.client_uid, "00000000-0000-0000-0000-000000000001"); + assert_eq!(params.corr_id, "parent/vr-deadbeef"); + assert_eq!(params.log_level, "debug"); + assert_eq!(params.ws_timeout_ms, 2500); + assert_eq!(params.kind, ClientKind::YaxunitRunner); + } + + #[test] + fn payload_snippet_contains_all_keys_in_order() { + let params = WsLaunchParams { + manager_url: "ws://m:1/s".to_owned(), + client_uid: "uid".to_owned(), + kind: ClientKind::VanessaTestClient, + corr_id: "c".to_owned(), + log_level: "info".to_owned(), + ws_timeout_ms: 1000, + }; + assert_eq!( + params.payload_snippet(), + "mcpMode=ws;manager_url=ws://m:1/s;client_uid=uid;kind=vanessa_test_client;corr_id=c;mcp_log_level=info;mcp_ws_timeout_ms=1000" + ); + } + + #[test] + fn validate_log_level_accepts_supported_values() { + for level in ["off", "error", "warn", "info", "debug", "trace"] { + validate_log_level(Some(level)).expect("supported"); + } + validate_log_level(None).expect("none ok"); + } + + #[test] + fn validate_log_level_rejects_unknown_values() { + let err = validate_log_level(Some("verbose")).expect_err("rejected"); + assert!(matches!(err, WsResolveError::UnsupportedLogLevel(_))); + } + + #[test] + fn client_kind_strings_match_session_manager_contract() { + assert_eq!(ClientKind::V8RunnerClient.as_str(), "v8_runner_client"); + assert_eq!(ClientKind::VanessaTestClient.as_str(), "vanessa_test_client"); + assert_eq!(ClientKind::YaxunitRunner.as_str(), "yaxunit_runner"); + } +} diff --git a/src/use_cases/mod.rs b/src/use_cases/mod.rs index d946807..178672b 100644 --- a/src/use_cases/mod.rs +++ b/src/use_cases/mod.rs @@ -30,6 +30,8 @@ mod interruption; pub mod launch_app; /// Shared launch key policy for Enterprise-backed use cases. mod launch_keys; +/// Shared helpers for the WS-mode (`mcpMode=ws`) `/C` payload. +pub mod mcp_ws; /// Load packaged artifacts into infobase. pub mod load_artifact; /// Text-mode live progress events shared by CLI-facing use cases. From b13bc8fd8ef80aa25d15fd6dc14f8d32fd5d0065 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Thu, 7 May 2026 07:57:21 +0000 Subject: [PATCH 03/15] feat(config): WS-mode defaults under tools.client_mcp Adds new optional fields under tools.client_mcp: - transport: ws | legacy | auto - manager_url: ws://host:port/... - log_level: off | error | warn | info | debug | trace - ws_timeout_ms: u64 (>= 1) Reuses tools.client_mcp instead of mcp.client because the loader still rejects mcp.client as a legacy migration guard (LegacyMcpClientConfig). Putting the new keys next to port/extension keeps client-MCP config in one place. Schemas (ClientMcpToolSchema, PartialClientMcpToolSchema) and validate.rs are extended with bound checks: transport/log_level use the mcp_ws helpers, manager_url is parsed via parse_manager_addr, ws_timeout_ms must be > 0. --- src/config/model.rs | 15 ++++++++++++++ src/config/schema.rs | 44 ++++++++++++++++++++++++++++++++++++++++++ src/config/validate.rs | 42 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/src/config/model.rs b/src/config/model.rs index f63b811..e5b7c1b 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -286,6 +286,21 @@ pub struct ClientMcpToolConfig { /// Optional tool extension prepared by `build` for client MCP launches. pub extension: Option, + + /// Default transport for the MCP client side: `ws`, `legacy` or `auto`. + /// When omitted, runtime treats it as `auto` (probe manager, fall back + /// to legacy local HTTP MCP). + pub transport: Option, + + /// Default WS endpoint for the session-manager + /// (e.g. `ws://127.0.0.1:4000/sessions`). + pub manager_url: Option, + + /// Default `mcp_log_level` value forwarded into the `/C` payload. + pub log_level: Option, + + /// Default `mcp_ws_timeout_ms` value forwarded into the `/C` payload. + pub ws_timeout_ms: Option, } #[derive(Debug, Clone)] diff --git a/src/config/schema.rs b/src/config/schema.rs index f974cc6..b00d18c 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -705,6 +705,18 @@ struct ClientMcpToolSchema { /// Optional tool extension prepared by `build` for client MCP launches. #[serde(default, skip_serializing_if = "Option::is_none")] extension: Option, + /// Default transport for the MCP client side: `ws`, `legacy` or `auto`. + #[serde(default, skip_serializing_if = "Option::is_none")] + transport: Option, + /// Default WS endpoint for the session-manager. + #[serde(default, skip_serializing_if = "Option::is_none")] + manager_url: Option, + /// Default `mcp_log_level` value forwarded into the `/C` payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + log_level: Option, + /// Default `mcp_ws_timeout_ms` value forwarded into the `/C` payload. + #[serde(default, skip_serializing_if = "Option::is_none")] + ws_timeout_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] @@ -717,6 +729,38 @@ struct PartialClientMcpToolSchema { #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(with = "PartialToolExtensionSchema")] extension: Option, + /// Machine-local override of the default MCP client transport. + #[serde( + default, + deserialize_with = "deserialize_non_null_optional", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + transport: Option, + /// Machine-local override of the default session-manager WS endpoint. + #[serde( + default, + deserialize_with = "deserialize_non_null_optional", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + manager_url: Option, + /// Machine-local override of the default `mcp_log_level`. + #[serde( + default, + deserialize_with = "deserialize_non_null_optional", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + log_level: Option, + /// Machine-local override of the default `mcp_ws_timeout_ms`. + #[serde( + default, + deserialize_with = "deserialize_non_null_optional", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "u64")] + ws_timeout_ms: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] diff --git a/src/config/validate.rs b/src/config/validate.rs index b03dafa..bdaa7f5 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -163,6 +163,20 @@ pub enum ConfigValidationError { #[error("tools.client_mcp.port must be greater than or equal to 1")] InvalidMcpClientPort, + #[error("tools.client_mcp.transport must be one of: ws, legacy, auto (got: {0})")] + InvalidMcpClientTransport(String), + + #[error( + "tools.client_mcp.log_level must be one of: off, error, warn, info, debug, trace (got: {0})" + )] + InvalidMcpClientLogLevel(String), + + #[error("tools.client_mcp.ws_timeout_ms must be greater than or equal to 1")] + InvalidMcpClientWsTimeoutMs, + + #[error("tools.client_mcp.manager_url must include host:port (got: {0})")] + InvalidMcpClientManagerUrl(String), + #[error("tools.client_mcp.extension.name must be a safe non-empty extension name: {0}")] InvalidToolExtensionName(String), @@ -815,6 +829,34 @@ fn validate_mcp_config(config: &AppConfig) -> Result<(), ConfigValidationError> return Err(ConfigValidationError::InvalidMcpClientPort); } + if let Some(transport) = config.tools.client_mcp.transport.as_deref() { + if crate::use_cases::mcp_ws::McpClientTransport::from_str_value(transport).is_none() { + return Err(ConfigValidationError::InvalidMcpClientTransport( + transport.to_owned(), + )); + } + } + + if let Some(level) = config.tools.client_mcp.log_level.as_deref() { + if !crate::use_cases::mcp_ws::is_supported_log_level(level) { + return Err(ConfigValidationError::InvalidMcpClientLogLevel( + level.to_owned(), + )); + } + } + + if config.tools.client_mcp.ws_timeout_ms == Some(0) { + return Err(ConfigValidationError::InvalidMcpClientWsTimeoutMs); + } + + if let Some(url) = config.tools.client_mcp.manager_url.as_deref() { + if crate::use_cases::mcp_ws::parse_manager_addr(url).is_err() { + return Err(ConfigValidationError::InvalidMcpClientManagerUrl( + url.to_owned(), + )); + } + } + Ok(()) } From a85e0c6613483be405332f3024bd59cc075b0b07 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Thu, 7 May 2026 08:02:39 +0000 Subject: [PATCH 04/15] feat(cli): WS-mode flags --mcp-transport / --manager-url / --client-uid / --corr-id / --mcp-log-level / --mcp-ws-timeout-ms Adds a shared McpClientWsArgs struct flattened into LaunchArgs and TestArgs. Values are validated at the CLI boundary (map_mcp_ws_args): transport must be ws|legacy|auto, log_level must be one of off|error|warn|info|debug|trace, ws_timeout_ms must be >= 1, manager_url must include host:port, client_uid/corr_id must not contain ';' (the /C payload is semicolon-delimited). Wired through transport-neutral McpClientWsRequest in use_cases::request, threaded into LaunchRequest and TestRequest so that downstream MCP service callers can also opt into the WS-mode without changes once they need it. The actual /C payload assembly happens in the next commit. Per-launch random client_uid is left to the use-case layer. By contract the kind is computed internally from the entry-point and is NOT exposed via CLI. --- src/cli/args.rs | 44 ++++++++++++++++++ src/cli/execute.rs | 93 +++++++++++++++++++++++++++++++++++++ src/mcp/service.rs | 3 ++ src/use_cases/launch_app.rs | 6 +++ src/use_cases/request.rs | 26 +++++++++++ src/use_cases/run_tests.rs | 2 + 6 files changed, 174 insertions(+) diff --git a/src/cli/args.rs b/src/cli/args.rs index a4f427c..8c7dce3 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -159,6 +159,9 @@ pub struct TestArgs { #[command(flatten)] pub launch: LaunchOptionsArgs, + #[command(flatten)] + pub mcp_ws: McpClientWsArgs, + #[command(subcommand)] pub runner: TestRunner, } @@ -311,6 +314,44 @@ pub struct LaunchArgs { /// Port override for onec-client-mcp-devkit `/C"...;mcpPort="` #[arg(long = "mcp-port")] pub mcp_port: Option, + + #[command(flatten)] + pub mcp_ws: McpClientWsArgs, +} + +/// Shared WS-mode (`mcpMode=ws`) options for `launch mcp`, `launch mcp va`, +/// `test yaxunit`, and `test va`. All flags are optional; values fall back to +/// the project config (`tools.client_mcp.*`) and finally to internal defaults. +#[derive(Args, Debug, Clone, Default, PartialEq, Eq)] +#[command(next_help_heading = "MCP client WS options")] +pub struct McpClientWsArgs { + /// Override the transport selection: `ws` forces WS, `legacy` forces local + /// HTTP MCP, `auto` probes the manager and falls back to legacy. + #[arg(long = "mcp-transport", value_parser = ["ws", "legacy", "auto"])] + pub mcp_transport: Option, + + /// Override the session-manager WS endpoint + /// (default `ws://127.0.0.1:4000/sessions`). + #[arg(long = "manager-url")] + pub manager_url: Option, + + /// Use a fixed `client_uid` instead of generating a fresh UUIDv4 + /// per launch. + #[arg(long = "client-uid")] + pub client_uid: Option, + + /// Override the `corr_id` used for trace correlation in manager logs. + #[arg(long = "corr-id")] + pub corr_id: Option, + + /// Override the `mcp_log_level` value passed to the BSL devkit. + #[arg(long = "mcp-log-level", value_parser = ["off", "error", "warn", "info", "debug", "trace"])] + pub mcp_log_level: Option, + + /// Override the `mcp_ws_timeout_ms` value passed to the BSL devkit + /// (WS-handshake timeout used by `auto` fallback). + #[arg(long = "mcp-ws-timeout-ms")] + pub mcp_ws_timeout_ms: Option, } #[derive(Args, Debug, Clone, Default, PartialEq, Eq)] @@ -680,6 +721,7 @@ mod tests { mcp_mode, mcp_config, mcp_port, + mcp_ws: _, }) => { assert_eq!(target, "ordinary"); assert_eq!(launch.c.as_deref(), Some("DoWork")); @@ -729,6 +771,7 @@ mod tests { mcp_mode, mcp_config, mcp_port, + mcp_ws: _, }) => { assert_eq!(target, "designer"); assert_eq!(launch, LaunchOptionsArgs::default()); @@ -765,6 +808,7 @@ mod tests { mcp_mode, mcp_config, mcp_port, + mcp_ws: _, }) => { assert_eq!(target, "mcp"); assert_eq!(launch, LaunchOptionsArgs::default()); diff --git a/src/cli/execute.rs b/src/cli/execute.rs index afde3a0..13f5a3f 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -713,6 +713,7 @@ fn map_extensions_request(args: &ExtensionsArgs) -> ConfigureExtensionsRequest { fn map_test_request(config: &AppConfig, args: &TestArgs) -> Result { let client_mode = map_test_client_mode(args.client_mode.as_deref())?; + let mcp_ws = map_mcp_ws_args(&args.mcp_ws)?; match &args.runner { TestRunner::Yaxunit(TestYaxunitArgs { scope }) => { let scope = map_yaxunit_scope(scope)?; @@ -720,12 +721,14 @@ fn map_test_request(config: &AppConfig, args: &TestArgs) -> Result Ok(TestRequest { execution: build_vanessa_execution(config, &args.launch, client_mode)?, full: args.full, scope: TestScopeRequest::All, + mcp_ws, }), } } @@ -1177,6 +1180,83 @@ fn map_launch_request(args: &LaunchArgs) -> Result target, launch: map_direct_launch_options(target, &args.launch, client_mcp.is_some())?, client_mcp, + mcp_ws: map_mcp_ws_args(&args.mcp_ws)?, + }) +} + +fn map_mcp_ws_args( + args: &crate::cli::args::McpClientWsArgs, +) -> Result { + use crate::use_cases::request::{McpClientTransportRequest, McpClientWsRequest}; + let transport = match args.mcp_transport.as_deref() { + None => None, + Some(value) => match crate::use_cases::mcp_ws::McpClientTransport::from_str_value(value) { + Some(crate::use_cases::mcp_ws::McpClientTransport::Ws) => { + Some(McpClientTransportRequest::Ws) + } + Some(crate::use_cases::mcp_ws::McpClientTransport::Legacy) => { + Some(McpClientTransportRequest::Legacy) + } + Some(crate::use_cases::mcp_ws::McpClientTransport::Auto) => { + Some(McpClientTransportRequest::Auto) + } + None => { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + format!( + "--mcp-transport must be one of: ws, legacy, auto (got: {value})" + ), + )); + } + }, + }; + if let Some(level) = args.mcp_log_level.as_deref() { + if !crate::use_cases::mcp_ws::is_supported_log_level(level) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + format!( + "--mcp-log-level must be one of: off, error, warn, info, debug, trace (got: {level})" + ), + )); + } + } + if args.mcp_ws_timeout_ms == Some(0) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "--mcp-ws-timeout-ms must be greater than or equal to 1", + )); + } + if let Some(url) = args.manager_url.as_deref() { + if crate::use_cases::mcp_ws::parse_manager_addr(url).is_err() { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + format!("--manager-url must include host:port (got: {url})"), + )); + } + } + if let Some(uid) = args.client_uid.as_deref() { + if uid.contains(';') { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "--client-uid must not contain ';' because the /C payload is semicolon-delimited", + )); + } + } + if let Some(corr) = args.corr_id.as_deref() { + if corr.contains(';') { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "--corr-id must not contain ';' because the /C payload is semicolon-delimited", + )); + } + } + Ok(McpClientWsRequest { + transport, + manager_url: args.manager_url.clone(), + client_uid: args.client_uid.clone(), + corr_id: args.corr_id.clone(), + log_level: args.mcp_log_level.clone(), + ws_timeout_ms: args.mcp_ws_timeout_ms, }) } @@ -2275,6 +2355,7 @@ mod tests { name: "ModuleA".to_owned(), }, }), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }, ) .expect("request"); @@ -2303,6 +2384,7 @@ mod tests { name: " ".to_owned(), }, }), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }, ) .expect_err("blank module should be rejected"); @@ -2346,6 +2428,7 @@ mod tests { client_mode: None, launch: LaunchOptionsArgs::default(), runner: TestRunner::Va(TestVaArgs::default()), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }, ) .expect("request"); @@ -2437,6 +2520,7 @@ mod tests { }, mcp_config: None, mcp_port: None, + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }) .expect("request"), LaunchRequest { @@ -2450,6 +2534,7 @@ mod tests { raw_args: vec!["/WA-".to_owned(), "/DisplayAllFunctions".to_owned()], }, client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), } ); assert_eq!( @@ -2460,6 +2545,7 @@ mod tests { launch: LaunchOptionsArgs::default(), mcp_config: None, mcp_port: None, + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }) .expect("request") .target, @@ -2473,6 +2559,7 @@ mod tests { launch: LaunchOptionsArgs::default(), mcp_config: None, mcp_port: None, + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }) .expect("request") .target, @@ -2486,6 +2573,7 @@ mod tests { launch: LaunchOptionsArgs::default(), mcp_config: Some("C:\\tmp\\mcp-conf.json".to_owned()), mcp_port: Some(123), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }) .expect("request"), LaunchRequest { @@ -2503,6 +2591,7 @@ mod tests { port: Some(123), addon: Some(ClientMcpAddonRequest::VanessaAutomation), }), + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), } ); let load = map_load_request(&LoadArgs { @@ -2563,6 +2652,7 @@ mod tests { launch: LaunchOptionsArgs::default(), mcp_config: None, mcp_port: None, + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }) .expect_err("launch mode should be rejected"); @@ -2772,6 +2862,7 @@ mod tests { runner: TestRunner::Yaxunit(TestYaxunitArgs { scope: TestScope::All, }), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }), &presenter, false, @@ -2803,6 +2894,7 @@ mod tests { launch: LaunchOptionsArgs::default(), mcp_config: None, mcp_port: None, + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }), &presenter, false, @@ -2835,6 +2927,7 @@ mod tests { name: " ".to_owned(), }, }), + mcp_ws: crate::cli::args::McpClientWsArgs::default(), }), &presenter, false, diff --git a/src/mcp/service.rs b/src/mcp/service.rs index 4a7d003..a0f3c1a 100644 --- a/src/mcp/service.rs +++ b/src/mcp/service.rs @@ -87,6 +87,7 @@ where execution: TestRequest::default_execution(), full: request.full.unwrap_or(false), scope: TestScopeRequest::All, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }; match self @@ -131,6 +132,7 @@ where execution: TestRequest::default_execution(), full: request.full.unwrap_or(false), scope: TestScopeRequest::Module { name: module_name }, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }; match self @@ -237,6 +239,7 @@ where })?, launch: LaunchOptions::default(), client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }; let started = Instant::now(); diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index 5f0e0c3..a3bebb6 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -315,6 +315,7 @@ mod tests { target: LaunchTargetRequest::thin_client(), launch: Default::default(), client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }, ) .expect("launch succeeds"); @@ -346,6 +347,7 @@ mod tests { target: LaunchTargetRequest::designer(), launch: Default::default(), client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }, ) .expect("launch succeeds"); @@ -377,6 +379,7 @@ mod tests { target: LaunchTargetRequest::ordinary_application(), launch: Default::default(), client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }, ) .expect("launch succeeds"); @@ -415,6 +418,7 @@ mod tests { target: LaunchTargetRequest::client_mcp_with_mode(ClientMcpMode::Thin), launch: Default::default(), client_mcp: Some(ClientMcpOptionsRequest::default()), + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }, ) .expect("launch succeeds"); @@ -446,6 +450,7 @@ mod tests { target: LaunchTargetRequest::client_mcp_with_mode(ClientMcpMode::Thin), launch: Default::default(), client_mcp: None, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }, ) .expect_err("client_mcp options are required"); @@ -464,6 +469,7 @@ mod tests { target: LaunchTargetRequest::thin_client(), launch: Default::default(), client_mcp: Some(ClientMcpOptionsRequest::default()), + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }, ) .expect_err("client_mcp options are rejected for non-mcp launch"); diff --git a/src/use_cases/request.rs b/src/use_cases/request.rs index 56bcb9e..c0dcac6 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -39,6 +39,8 @@ pub struct TestRequest { pub full: bool, /// Selected test scope. Module targets require a non-empty module name. pub scope: TestScopeRequest, + /// Optional WS-mode (session-manager) overrides shared with `launch mcp`. + pub mcp_ws: McpClientWsRequest, } impl TestRequest { @@ -608,6 +610,30 @@ pub struct LaunchRequest { pub launch: LaunchOptions, /// Client-side MCP launch options. Present only for `LaunchTargetRequest::client_mcp*`. pub client_mcp: Option, + /// Optional WS-mode (session-manager) overrides shared between + /// `launch mcp` and `test` flows. + pub mcp_ws: McpClientWsRequest, +} + +/// Transport-neutral selector for MCP client transport. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpClientTransportRequest { + Ws, + Legacy, + Auto, +} + +/// Transport-neutral overrides for the WS-mode `/C` snippet. All fields are +/// optional; the use case fills missing values from project config and +/// internal defaults at execution time. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct McpClientWsRequest { + pub transport: Option, + pub manager_url: Option, + pub client_uid: Option, + pub corr_id: Option, + pub log_level: Option, + pub ws_timeout_ms: Option, } /// Transport-neutral request for the `init` use case. diff --git a/src/use_cases/run_tests.rs b/src/use_cases/run_tests.rs index 08e123f..43e1371 100644 --- a/src/use_cases/run_tests.rs +++ b/src/use_cases/run_tests.rs @@ -730,6 +730,7 @@ mod tests { policy: ExecutionPolicy::default(), launch: LaunchOptions::default(), }, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }; let context = ExecutionContext::cli(CommandName::Test); @@ -761,6 +762,7 @@ mod tests { policy: ExecutionPolicy::default(), launch: LaunchOptions::default(), }, + mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), }; let failure = run_tests(&context, &config, &args).expect_err("cancelled"); From 1dd9eeffea6d7eb4ad36081afe607f694ee1a920 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Thu, 7 May 2026 08:05:59 +0000 Subject: [PATCH 05/15] feat(launch): mcpMode=ws /C payload for launch mcp and test runs launch_app: - effective_launch_options now returns (LaunchOptions, Option); meta drives the new LaunchResult fields (transport, client_uid, kind, manager_url, corr_id, mcp_port). - decide_mcp_transport probes manager_url with PROBE_TIMEOUT_MS and returns ws|legacy according to CLI/config preference; auto falls back to legacy when probe fails. - WS-mode emits 'mcpMode=ws;manager_url=...;client_uid=;kind= v8_runner_client|vanessa_test_client;corr_id=vr-; mcp_log_level=...;mcp_ws_timeout_ms=...'. - Legacy branch keeps the existing 'runMcp[=...][;mcpPort=...]' exactly as before for back-compat. run_tests: - After build_platform_launch the coordinator calls apply_test_mcp_ws_payload, which appends the WS snippet to the existing /C (RunUnitTests=... or VA player payload). Resolution errors are logged as warn and skipped, preserving prior behavior. - yaxunit_runner / vanessa_test_client kinds are picked from PreparedRun. LaunchResult: new optional fields are skip-if-none, so JSON envelopes for non-MCP launches are byte-identical to the previous shape. --- src/domain/launch.rs | 22 +++++ src/mcp/service.rs | 18 ++++ src/use_cases/launch_app.rs | 132 +++++++++++++++++++++++-- src/use_cases/run_tests.rs | 4 +- src/use_cases/run_tests/coordinator.rs | 4 +- src/use_cases/run_tests/helpers.rs | 47 +++++++++ 6 files changed, 216 insertions(+), 11 deletions(-) diff --git a/src/domain/launch.rs b/src/domain/launch.rs index 8219854..49e2c76 100644 --- a/src/domain/launch.rs +++ b/src/domain/launch.rs @@ -15,6 +15,28 @@ pub struct LaunchResult { pub binary: PathBuf, /// Human-readable launch summary. pub message: Option, + /// MCP transport selected for this launch (`ws` or `legacy`). + /// Present only for `launch mcp` and `test` flows. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub transport: Option, + /// Per-launch UUID announced to the session-manager + /// (`mcpMode=ws` only). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub client_uid: Option, + /// Manager-side client kind announced to the session-manager + /// (`mcpMode=ws` only). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub kind: Option, + /// Session-manager WS endpoint used for this launch (`mcpMode=ws` only). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub manager_url: Option, + /// Correlation id for trace correlation in manager logs + /// (`mcpMode=ws` only). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub corr_id: Option, + /// Local HTTP MCP port (`legacy` transport only). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub mcp_port: Option, } /// Supported application launch modes. diff --git a/src/mcp/service.rs b/src/mcp/service.rs index a0f3c1a..465fa62 100644 --- a/src/mcp/service.rs +++ b/src/mcp/service.rs @@ -1361,6 +1361,12 @@ mod tests { pid: Some(42), binary: PathBuf::from("/opt/1cv8"), message: None, + transport: None, + client_uid: None, + kind: None, + manager_url: None, + corr_id: None, + mcp_port: None, })); let config = sample_config(); let service = McpService::with_port(&config, port); @@ -1392,6 +1398,12 @@ mod tests { pid: None, binary: PathBuf::from("/opt/1cv8"), message: None, + transport: None, + client_uid: None, + kind: None, + manager_url: None, + corr_id: None, + mcp_port: None, })), ); @@ -1429,6 +1441,12 @@ mod tests { pid: None, binary: PathBuf::from("/opt/1cv8"), message: None, + transport: None, + client_uid: None, + kind: None, + manager_url: None, + corr_id: None, + mcp_port: None, })), ); diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index a3bebb6..918949c 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -14,9 +14,13 @@ use crate::support::error::AppError; use crate::use_cases::context::{ExecutionContext, ExecutionInterruption}; use crate::use_cases::launch_keys::vanessa_enterprise_launch_keys; use crate::use_cases::progress::log_live_stage; +use crate::use_cases::mcp_ws::{ + self, ClientKind, McpClientTransport, TransportDecision, WsLaunchParams, WsResolveInputs, +}; use crate::use_cases::request::{ ClientMcpAddonRequest, ClientMcpMode, ClientMcpOptionsRequest, EnterpriseLaunchTarget, - LaunchRequest as LaunchArgs, LaunchTargetRequest, + LaunchRequest as LaunchArgs, LaunchTargetRequest, McpClientTransportRequest, + McpClientWsRequest, }; use crate::use_cases::result::{UseCaseFailure, UseCaseResult}; use crate::use_cases::tool_extension; @@ -65,7 +69,7 @@ pub fn execute( )))); } - let launch = effective_launch_options(config, args) + let (launch, mcp_resolution) = effective_launch_options(config, args) .map_err(|error| UseCaseFailure::without_payload(error))?; let additional_launch_keys = effective_enterprise_launch_keys(config, args, &launch); let mut utilities = PlatformUtilities::from_config(config); @@ -93,16 +97,47 @@ pub fn execute( }) .map_err(|error| UseCaseFailure::without_payload(AppError::from(error)))?; - let result = LaunchResult { + let mut result = LaunchResult { ok: true, mode, pid: Some(spawned.pid), binary: spawned.binary.clone(), message: Some(launch_message(config, args, &spawned.binary, spawned.pid)), + transport: None, + client_uid: None, + kind: None, + manager_url: None, + corr_id: None, + mcp_port: None, }; + if let Some(meta) = mcp_resolution { + apply_mcp_resolution_to_result(&mut result, meta); + } Ok(result) } +fn apply_mcp_resolution_to_result(result: &mut LaunchResult, meta: McpResolutionMeta) { + match meta { + McpResolutionMeta::Ws(params) => { + result.transport = Some("ws".to_owned()); + result.client_uid = Some(params.client_uid); + result.kind = Some(params.kind.as_str().to_owned()); + result.manager_url = Some(params.manager_url); + result.corr_id = Some(params.corr_id); + } + McpResolutionMeta::Legacy { port } => { + result.transport = Some("legacy".to_owned()); + result.mcp_port = port; + } + } +} + +#[derive(Debug, Clone)] +enum McpResolutionMeta { + Ws(WsLaunchParams), + Legacy { port: Option }, +} + fn launch_message(config: &AppConfig, args: &LaunchArgs, binary: &Path, pid: u32) -> String { let mut message = format!( "Launched {} via {} (pid {})", @@ -185,7 +220,7 @@ fn client_mcp_launch_shape(mode: ClientMcpMode) -> (LaunchMode, UtilityType, Lau fn effective_launch_options( config: &AppConfig, args: &LaunchArgs, -) -> Result { +) -> Result<(LaunchOptions, Option), AppError> { let is_client_mcp = matches!( args.target, LaunchTargetRequest::Enterprise(EnterpriseLaunchTarget::ClientMcp { .. }) @@ -196,7 +231,7 @@ fn effective_launch_options( "launch mcp requires client_mcp options".to_owned(), )) } else { - Ok(args.launch.clone()) + Ok((args.launch.clone(), None)) }; }; if !is_client_mcp { @@ -205,8 +240,21 @@ fn effective_launch_options( )); } + let kind = launch_mcp_client_kind(client_mcp); + let decision = decide_mcp_transport(config, &args.mcp_ws)?; let mut launch = args.launch.clone(); - let mut payload = build_client_mcp_payload(client_mcp, config.tools.client_mcp.port); + let (mut payload, meta) = match decision { + TransportDecision::Ws => { + let params = resolve_ws_launch_params(config, &args.mcp_ws, kind); + let snippet = params.payload_snippet(); + (snippet, McpResolutionMeta::Ws(params)) + } + TransportDecision::Legacy => { + let payload = build_legacy_client_mcp_payload(client_mcp, config.tools.client_mcp.port); + let port = client_mcp.port.or(config.tools.client_mcp.port); + (payload, McpResolutionMeta::Legacy { port }) + } + }; if matches!( client_mcp.addon, Some(ClientMcpAddonRequest::VanessaAutomation) @@ -215,10 +263,17 @@ fn effective_launch_options( crate::use_cases::vanessa::apply_client_mcp_launch(&mut launch, &mut payload, &va_launch); } launch.c = Some(payload); - Ok(launch) + Ok((launch, Some(meta))) +} + +fn launch_mcp_client_kind(client_mcp: &ClientMcpOptionsRequest) -> ClientKind { + match client_mcp.addon { + Some(ClientMcpAddonRequest::VanessaAutomation) => ClientKind::VanessaTestClient, + None => ClientKind::V8RunnerClient, + } } -fn build_client_mcp_payload( +fn build_legacy_client_mcp_payload( options: &ClientMcpOptionsRequest, configured_port: Option, ) -> String { @@ -232,6 +287,67 @@ fn build_client_mcp_payload( payload } +/// Resolves the effective transport for an MCP-bearing launch given CLI/config +/// inputs and a TCP probe of the manager. +pub(crate) fn decide_mcp_transport( + config: &AppConfig, + cli: &McpClientWsRequest, +) -> Result { + let requested = effective_transport(config, cli); + let manager_url = effective_manager_url(config, cli); + mcp_ws::select_transport(requested, &manager_url, |addr| { + mcp_ws::probe_tcp(addr, Duration::from_millis(mcp_ws::PROBE_TIMEOUT_MS)) + }) + .map_err(|err| AppError::Validation(err.to_string())) +} + +pub(crate) fn effective_transport( + config: &AppConfig, + cli: &McpClientWsRequest, +) -> McpClientTransport { + if let Some(t) = cli.transport { + return match t { + McpClientTransportRequest::Ws => McpClientTransport::Ws, + McpClientTransportRequest::Legacy => McpClientTransport::Legacy, + McpClientTransportRequest::Auto => McpClientTransport::Auto, + }; + } + config + .tools + .client_mcp + .transport + .as_deref() + .and_then(McpClientTransport::from_str_value) + .unwrap_or_default() +} + +pub(crate) fn effective_manager_url(config: &AppConfig, cli: &McpClientWsRequest) -> String { + cli.manager_url + .clone() + .or_else(|| config.tools.client_mcp.manager_url.clone()) + .unwrap_or_else(|| mcp_ws::DEFAULT_MANAGER_URL.to_owned()) +} + +pub(crate) fn resolve_ws_launch_params( + config: &AppConfig, + cli: &McpClientWsRequest, + kind: ClientKind, +) -> WsLaunchParams { + let inputs = WsResolveInputs { + manager_url: Some(effective_manager_url(config, cli)), + client_uid: cli.client_uid.clone(), + corr_id: cli.corr_id.clone(), + log_level: cli + .log_level + .clone() + .or_else(|| config.tools.client_mcp.log_level.clone()), + ws_timeout_ms: cli + .ws_timeout_ms + .or(config.tools.client_mcp.ws_timeout_ms), + }; + mcp_ws::resolve_ws_params(kind, inputs) +} + #[cfg(test)] mod tests { use super::execute; diff --git a/src/use_cases/run_tests.rs b/src/use_cases/run_tests.rs index 43e1371..d24e8dc 100644 --- a/src/use_cases/run_tests.rs +++ b/src/use_cases/run_tests.rs @@ -34,8 +34,8 @@ mod coordinator; mod helpers; use self::helpers::{ - build_enterprise_dsl, build_platform_launch, build_summary, capped_timeout_ms, - collect_diagnostics, degraded_step, enterprise_error_kind, failed_step, + apply_test_mcp_ws_payload, build_enterprise_dsl, build_platform_launch, build_summary, + capped_timeout_ms, collect_diagnostics, degraded_step, enterprise_error_kind, failed_step, interrupted_test_failure, make_test_result, prepare_runner_artifacts, prepared_run_summary, succeeded_step, validate_runner_profile_id, validate_target, with_retained_artifacts, }; diff --git a/src/use_cases/run_tests/coordinator.rs b/src/use_cases/run_tests/coordinator.rs index 2a7bf44..6f96ce1 100644 --- a/src/use_cases/run_tests/coordinator.rs +++ b/src/use_cases/run_tests/coordinator.rs @@ -226,7 +226,9 @@ pub(super) fn run_tests( log_live_stage("test: enterprise run", "[Enterprise] running test runner"); let run_started = Instant::now(); let enterprise_runner = crate::platform::process::ProcessExecutor; - let platform_launch = build_platform_launch(&args.execution.launch, &prepared_run, &artifacts); + let mut platform_launch = + build_platform_launch(&args.execution.launch, &prepared_run, &artifacts); + apply_test_mcp_ws_payload(config, &args.mcp_ws, &prepared_run, &mut platform_launch); let enterprise = match build_enterprise_dsl( context, config, diff --git a/src/use_cases/run_tests/helpers.rs b/src/use_cases/run_tests/helpers.rs index 951bc70..37ada84 100644 --- a/src/use_cases/run_tests/helpers.rs +++ b/src/use_cases/run_tests/helpers.rs @@ -415,6 +415,53 @@ pub(super) fn build_platform_launch( launch } +/// Appends the `mcpMode=ws;...` snippet to an existing `/C` payload, joining +/// with `;` and preserving the leading payload (e.g. `RunUnitTests=...` or +/// the Vanessa player payload). When the `/C` value is absent, the snippet +/// becomes the entire `/C`. +pub(super) fn append_mcp_ws_snippet(launch: &mut LaunchOptions, snippet: &str) { + let combined = match launch.c.take() { + Some(existing) if !existing.is_empty() => format!("{existing};{snippet}"), + _ => snippet.to_owned(), + }; + launch.c = Some(combined); +} + +/// If the WS-mode resolution succeeds for this test run, append the +/// `mcpMode=ws;...` snippet to the platform `/C` so the BSL devkit registers +/// with `v8-client-session-manager` instead of starting a local HTTP MCP. +/// +/// Errors from the resolution layer (e.g. invalid `manager_url`) are logged +/// and treated as "no WS snippet"; the test run still proceeds with its +/// regular `/C` payload. This preserves the previous behavior when the +/// session-manager is not used. +pub(super) fn apply_test_mcp_ws_payload( + config: &AppConfig, + mcp_ws: &crate::use_cases::request::McpClientWsRequest, + prepared_run: &PreparedRun, + launch: &mut LaunchOptions, +) { + let kind = match prepared_run { + PreparedRun::YaXUnit => crate::use_cases::mcp_ws::ClientKind::YaxunitRunner, + PreparedRun::Vanessa { .. } => crate::use_cases::mcp_ws::ClientKind::VanessaTestClient, + }; + let decision = match crate::use_cases::launch_app::decide_mcp_transport(config, mcp_ws) { + Ok(d) => d, + Err(err) => { + tracing::warn!(error = %err, "failed to resolve MCP client transport for test run"); + return; + } + }; + if !matches!( + decision, + crate::use_cases::mcp_ws::TransportDecision::Ws + ) { + return; + } + let params = crate::use_cases::launch_app::resolve_ws_launch_params(config, mcp_ws, kind); + append_mcp_ws_snippet(launch, ¶ms.payload_snippet()); +} + pub(super) fn collect_diagnostics( platform_result: &crate::platform::result::PlatformCommandResult, mut diagnostics: Vec, From 58f08d9c5b1ea3195f7e0908e82a5b551ea01cb4 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Thu, 7 May 2026 08:09:38 +0000 Subject: [PATCH 06/15] test: explicit transport=legacy in MCP launch tests + regen schemas - launch_app::tests::client_mcp_launch_does_not_prepare_configured_tool_extension: pin transport to Legacy so the test stays deterministic in environments where a session-manager listener happens to be live on 127.0.0.1:4000 (auto would otherwise pick WS). - tests/cli_launch.rs::launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode: add --mcp-transport legacy and assert the new JSON fields transport=legacy and mcp_port=9874 in the launch envelope. - Regenerate docs/schemas/v8project.schema.json and docs/schemas/v8project.local.schema.json to include the new tools.client_mcp.{transport,manager_url,log_level,ws_timeout_ms} fields (UPDATE_CONFIG_SCHEMAS=1 cargo test generated_schema_artifacts_are_current). --- docs/schemas/v8project.local.schema.json | 18 ++++++++++++++ docs/schemas/v8project.schema.json | 30 ++++++++++++++++++++++++ src/use_cases/launch_app.rs | 9 ++++++- tests/cli_launch.rs | 4 ++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index 457579a..85a20c9 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -204,6 +204,14 @@ ], "description": "Machine-local override or reset for the client MCP tool extension." }, + "log_level": { + "description": "Machine-local override of the default `mcp_log_level`.", + "type": "string" + }, + "manager_url": { + "description": "Machine-local override of the default session-manager WS endpoint.", + "type": "string" + }, "port": { "description": "Machine-local default port passed to onec-client-mcp-devkit.", "format": "uint16", @@ -213,6 +221,16 @@ "integer", "null" ] + }, + "transport": { + "description": "Machine-local override of the default MCP client transport.", + "type": "string" + }, + "ws_timeout_ms": { + "description": "Machine-local override of the default `mcp_ws_timeout_ms`.", + "format": "uint64", + "minimum": 0, + "type": "integer" } }, "type": "object" diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index d4cf607..18811f7 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -33,6 +33,20 @@ ], "description": "Optional tool extension prepared by `build` for client MCP launches." }, + "log_level": { + "description": "Default `mcp_log_level` value forwarded into the `/C` payload.", + "type": [ + "string", + "null" + ] + }, + "manager_url": { + "description": "Default WS endpoint for the session-manager.", + "type": [ + "string", + "null" + ] + }, "port": { "description": "Default port passed to onec-client-mcp-devkit.", "format": "uint16", @@ -42,6 +56,22 @@ "integer", "null" ] + }, + "transport": { + "description": "Default transport for the MCP client side: `ws`, `legacy` or `auto`.", + "type": [ + "string", + "null" + ] + }, + "ws_timeout_ms": { + "description": "Default `mcp_ws_timeout_ms` value forwarded into the `/C` payload.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] } }, "type": "object" diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index 918949c..c8fc433 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -534,7 +534,12 @@ mod tests { target: LaunchTargetRequest::client_mcp_with_mode(ClientMcpMode::Thin), launch: Default::default(), client_mcp: Some(ClientMcpOptionsRequest::default()), - mcp_ws: crate::use_cases::request::McpClientWsRequest::default(), + mcp_ws: crate::use_cases::request::McpClientWsRequest { + transport: Some( + crate::use_cases::request::McpClientTransportRequest::Legacy, + ), + ..Default::default() + }, }, ) .expect("launch succeeds"); @@ -545,6 +550,8 @@ mod tests { .as_deref() .expect("message") .contains("v8-runner build")); + assert_eq!(result.transport.as_deref(), Some("legacy")); + assert_eq!(result.mcp_port, Some(9874)); let args = fs::read_to_string(args_log).expect("args log"); assert!(args.contains("ENTERPRISE")); assert!(args.contains("/C\"runMcp;mcpPort=9874\"")); diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index d4d94c2..3425c93 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -383,6 +383,8 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { "ordinary", "--mcp-config", "/tmp/mcp conf.json", + "--mcp-transport", + "legacy", "--raw-key", "/WA-", ]) @@ -398,6 +400,8 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { ); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); assert_eq!(payload["data"]["mode"], "mcp"); + assert_eq!(payload["data"]["transport"], "legacy"); + assert_eq!(payload["data"]["mcp_port"], 9874); assert_eq!( payload["data"]["binary"].as_str().expect("binary"), install_dir.join("bin").join("1cv8").to_string_lossy() From 72e92a1a73ac32bd973c9b164954f985e41b3f3a Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Thu, 7 May 2026 08:11:12 +0000 Subject: [PATCH 07/15] test: e2e WS-mode launches and append_mcp_ws_snippet unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/cli_launch.rs: 6 new integration tests covering the four behaviors required by the spec: - launch mcp --mcp-transport=legacy emits /C"runMcp;mcpPort=..." and a JSON envelope with transport=legacy + mcp_port. - launch mcp --mcp-transport=ws against a live ephemeral listener emits /C"mcpMode=ws;manager_url=...;client_uid=...;kind= v8_runner_client;corr_id=...;mcp_log_level=...;mcp_ws_timeout_ms= ..." plus matching JSON fields (transport=ws, client_uid, kind, manager_url, corr_id). - launch mcp --mcp-transport=ws against an unreachable port fails with 'session-manager unreachable' diagnostic. - launch mcp --mcp-transport=auto falls back to legacy when the manager is unreachable. - launch mcp --manager-url with bare host (no port) is rejected at argument-mapping time. - launch mcp --mcp-ws-timeout-ms 0 is rejected as zero. src/use_cases/run_tests/helpers.rs: 3 unit tests for append_mcp_ws_snippet covering existing /C, missing /C and empty /C. Real 1C runs are not invoked in any of these tests — the script in $tempdir/platform/bin/1cv8c just dumps args to a log. --- src/use_cases/run_tests/helpers.rs | 36 +++++ tests/cli_launch.rs | 211 +++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/src/use_cases/run_tests/helpers.rs b/src/use_cases/run_tests/helpers.rs index 37ada84..59a0277 100644 --- a/src/use_cases/run_tests/helpers.rs +++ b/src/use_cases/run_tests/helpers.rs @@ -545,3 +545,39 @@ pub(super) fn enterprise_error_kind( ), } } + +#[cfg(test)] +mod append_ws_tests { + use super::append_mcp_ws_snippet; + use crate::domain::runner::LaunchOptions; + + #[test] + fn append_appends_after_existing_payload() { + let mut launch = LaunchOptions { + c: Some("RunUnitTests=/tmp/cfg.json".to_owned()), + ..Default::default() + }; + append_mcp_ws_snippet(&mut launch, "mcpMode=ws;manager_url=ws://m:1/s"); + assert_eq!( + launch.c.as_deref(), + Some("RunUnitTests=/tmp/cfg.json;mcpMode=ws;manager_url=ws://m:1/s") + ); + } + + #[test] + fn append_uses_snippet_alone_when_c_missing() { + let mut launch = LaunchOptions::default(); + append_mcp_ws_snippet(&mut launch, "mcpMode=ws"); + assert_eq!(launch.c.as_deref(), Some("mcpMode=ws")); + } + + #[test] + fn append_replaces_empty_c() { + let mut launch = LaunchOptions { + c: Some(String::new()), + ..Default::default() + }; + append_mcp_ws_snippet(&mut launch, "mcpMode=ws"); + assert_eq!(launch.c.as_deref(), Some("mcpMode=ws")); + } +} diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index 3425c93..5c02063 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -686,3 +686,214 @@ fn launch_non_mcp_rejects_mcp_options() { "--mcp-config, --mcp-port, --mode, and MCP_SCENARIO are supported only for `launch mcp`" )); } + +// ---------------------------------------------------------------------------- +// MCP WS-mode (mcpMode=ws) integration tests +// ---------------------------------------------------------------------------- + +fn setup_mcp_project_with_logging_thin() -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = temp_workspace(); + let base_path = dir.path().join("project"); + let work_path = dir.path().join("work"); + let install_dir = dir.path().join("platform"); + let config_path = dir.path().join("v8project.yaml"); + let args_log = install_dir.join("mcp.args.log"); + + fs::create_dir_all(&base_path).expect("base"); + fs::create_dir_all(&work_path).expect("work"); + write_logging_script(&install_dir.join("bin").join("1cv8c"), &args_log); + write_script(&install_dir.join("bin").join("1cv8")); + write_config(&config_path, &base_path, &work_path, &install_dir, None); + + (dir, config_path, args_log) +} + +#[test] +fn launch_mcp_legacy_transport_emits_runmcp_payload_and_legacy_envelope() { + let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "--mcp-transport", + "legacy", + "--mcp-port", + "9999", + ]) + .output() + .expect("run command"); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["transport"], "legacy"); + assert_eq!(payload["data"]["mcp_port"], 9999); + assert!(payload["data"]["client_uid"].is_null()); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/C\"runMcp;mcpPort=9999\"")); + assert!(!args.contains("mcpMode=ws")); +} + +#[test] +fn launch_mcp_ws_transport_with_listener_emits_ws_payload_and_ws_envelope() { + let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); + // Spawn an ephemeral listener; the manager_url points at it so the probe + // succeeds and v8-runner picks the WS branch. + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let manager_url = format!( + "ws://127.0.0.1:{}/sessions", + listener.local_addr().expect("addr").port() + ); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "--mcp-transport", + "ws", + "--manager-url", + &manager_url, + "--mcp-log-level", + "debug", + "--mcp-ws-timeout-ms", + "2500", + "--client-uid", + "00000000-0000-0000-0000-000000000abc", + "--corr-id", + "vr-deadbeef", + ]) + .output() + .expect("run command"); + drop(listener); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["transport"], "ws"); + assert_eq!( + payload["data"]["client_uid"], + "00000000-0000-0000-0000-000000000abc" + ); + assert_eq!(payload["data"]["kind"], "v8_runner_client"); + assert_eq!(payload["data"]["manager_url"], manager_url); + assert_eq!(payload["data"]["corr_id"], "vr-deadbeef"); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("mcpMode=ws")); + assert!(args.contains("client_uid=00000000-0000-0000-0000-000000000abc")); + assert!(args.contains("kind=v8_runner_client")); + assert!(args.contains(&format!("manager_url={manager_url}"))); + assert!(args.contains("corr_id=vr-deadbeef")); + assert!(args.contains("mcp_log_level=debug")); + assert!(args.contains("mcp_ws_timeout_ms=2500")); + assert!(!args.contains("runMcp")); +} + +#[test] +fn launch_mcp_ws_required_fails_when_manager_unreachable() { + let (_dir, config_path, _args_log) = setup_mcp_project_with_logging_thin(); + // Bind & immediately drop to grab a guaranteed-free port. + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + let manager_url = format!("ws://127.0.0.1:{port}/sessions"); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "--mcp-transport", + "ws", + "--manager-url", + &manager_url, + ]) + .output() + .expect("run command"); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let combined = format!("{stderr}\n{stdout}"); + assert!( + combined.contains("session-manager unreachable") + || combined.contains("unreachable"), + "expected 'unreachable' diagnostic, got stderr={stderr}, stdout={stdout}" + ); +} + +#[test] +fn launch_mcp_auto_falls_back_to_legacy_when_manager_unreachable() { + let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + let manager_url = format!("ws://127.0.0.1:{port}/sessions"); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "--mcp-transport", + "auto", + "--manager-url", + &manager_url, + ]) + .output() + .expect("run command"); + assert!(output.status.success()); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["transport"], "legacy"); + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/C\"runMcp\"")); + assert!(!args.contains("mcpMode=ws")); +} + +#[test] +fn launch_mcp_rejects_invalid_manager_url() { + let (_dir, config_path, _args_log) = setup_mcp_project_with_logging_thin(); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "launch", + "mcp", + "--manager-url", + "ws://bare-host-no-port/sessions", + ]) + .output() + .expect("run command"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--manager-url")); +} + +#[test] +fn launch_mcp_rejects_zero_ws_timeout() { + let (_dir, config_path, _args_log) = setup_mcp_project_with_logging_thin(); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "launch", + "mcp", + "--mcp-ws-timeout-ms", + "0", + ]) + .output() + .expect("run command"); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr) + .contains("--mcp-ws-timeout-ms must be greater than or equal to 1")); +} From e4a992dced0480ea6c0c3680f89c656359d08c71 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Thu, 7 May 2026 08:15:24 +0000 Subject: [PATCH 08/15] docs(mcp-ws): explain WS-mode under tools.client_mcp + cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: add a 'Подключение к session-manager (WS-режим)' subsection pointing at docs/CONFIGURATION.md and the v8-client-session-manager repo. - docs/CONFIGURATION.md: extend tools.client_mcp YAML example with the new transport/manager_url/log_level/ws_timeout_ms fields and add a 'WS-режим к session-manager' subsection that documents the internal kind mapping, the /C payload shape, the CLI flags and the auto-probe behavior. - src/use_cases/mcp_ws.rs: simplify — derive Default for McpClientTransport instead of a hand-written impl, drop unused validate_log_level/UnsupportedLogLevel pair (the boundary uses is_supported_log_level directly via map_mcp_ws_args), and switch the matching test over. - cargo fmt. --- README.md | 9 +++++ docs/CONFIGURATION.md | 53 ++++++++++++++++++++++++++++-- src/cli/execute.rs | 4 +-- src/use_cases/launch_app.rs | 10 ++---- src/use_cases/mcp_ws.rs | 38 +++++---------------- src/use_cases/mod.rs | 4 +-- src/use_cases/run_tests/helpers.rs | 5 +-- tests/cli_launch.rs | 3 +- 8 files changed, 76 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index e39aa89..a0ea594 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,15 @@ v8-runner test va v8-runner launch mcp va ``` +### Подключение к session-manager (WS-режим) + +`launch mcp`, `launch mcp va`, `test yaxunit ...` и `test va ...` поддерживают подключение +1С-клиента к [`v8-client-session-manager`](../v8-client-session-manager/) вместо запуска +локального HTTP MCP. По умолчанию — `auto`: TCP-probe адреса менеджера, при успехе +собирается `/C"mcpMode=ws;manager_url=...;client_uid=...;kind=...;..."`, иначе используется +legacy `/C"runMcp;..."`. Полный список ключей `/C` и CLI-флагов см. в +[docs/CONFIGURATION.md](docs/CONFIGURATION.md#tools-client_mcp). + ### Поднимите MCP transport (MCP-транспорт) для AI-агентов: ```bash diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f3adbfa..f708fb0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -141,6 +141,10 @@ tools: source: path: /path/to/onec-client-mcp/exts/client-mcp format: EDT + transport: auto # ws | legacy | auto (default) + manager_url: ws://127.0.0.1:4000/sessions + log_level: info # off|error|warn|info|debug|trace + ws_timeout_ms: 1000 va: epf_path: /path/to/vanessa.epf platform: @@ -442,11 +446,54 @@ runtime identity и не добавляет отдельное поле конф Поддержанные поля: -- `port`, опциональный порт клиентского MCP-сервера onec-client-mcp-devkit. +- `port`, опциональный порт клиентского MCP-сервера onec-client-mcp-devkit (legacy режим). - `extension`, опциональное tool extension для клиентского MCP-сервера. +- `transport` (`ws`, `legacy`, `auto`; по умолчанию `auto`) — режим транспорта. См. раздел + «WS-режим к session-manager» ниже. +- `manager_url` — WS-эндпоинт `v8-client-session-manager`, + по умолчанию `ws://127.0.0.1:4000/sessions`. +- `log_level` (`off`/`error`/`warn`/`info`/`debug`/`trace`) — значение `mcp_log_level`, + передаваемое в `/C` BSL-расширению `client_mcp`. +- `ws_timeout_ms` — значение `mcp_ws_timeout_ms` (таймаут установки WS-сессии в режиме `auto`, + > 0). -`launch mcp` передаёт это значение как `mcpPort` внутри `/C"runMcp..."` -если CLI не указал `--mcp-port`. +`launch mcp` передаёт `port` как `mcpPort` внутри `/C"runMcp..."` +если CLI не указал `--mcp-port` и выбран legacy/`auto`-fallback транспорт. + +#### WS-режим к session-manager + +Когда `transport=ws` или `transport=auto` и менеджер доступен по `manager_url`, +v8-runner собирает `/C` так: + +```text +/C"mcpMode=ws;manager_url=...;client_uid=;kind=;corr_id=...;mcp_log_level=...;mcp_ws_timeout_ms=..." +``` + +Значение `kind` вычисляется внутри по точке входа и не может быть переопределено +из CLI: + +| Команда v8-runner | `kind` | +|-------------------------|-----------------------| +| `launch mcp` | `v8_runner_client` | +| `launch mcp va` | `vanessa_test_client` | +| `test yaxunit ...` | `yaxunit_runner` | +| `test va ...` | `vanessa_test_client` | + +Для тестовых запусков (`test yaxunit`, `test va`) WS-фрагмент **дописывается** к существующему +`/C` (`RunUnitTests=...` или Vanessa-плеер) через `;`. На стороне платформы все ключи +парсятся как `key=value` пары. + +CLI-флаги: `--mcp-transport`, `--manager-url`, `--client-uid`, `--corr-id`, +`--mcp-log-level`, `--mcp-ws-timeout-ms`. CLI имеет приоритет над конфигом, конфиг — над +встроенными дефолтами. `client_uid` по умолчанию рандомный UUIDv4 на каждый запуск, +`corr_id` по умолчанию `vr-<первые 8 символов uid>`. + +Для `transport=auto` v8-runner делает короткий TCP-probe (200 ms) на хост:порт из +`manager_url`. При успехе выбирается WS, иначе legacy. Для `transport=ws` без живого +менеджера запуск падает с ошибкой `session-manager unreachable at `. + +Сам менеджер v8-runner не запускает — его нужно поднять отдельно +(см. соседний репозиторий [`v8-client-session-manager`](../../v8-client-session-manager/)). `extension` поддерживает: diff --git a/src/cli/execute.rs b/src/cli/execute.rs index 13f5a3f..9ca1300 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -1203,9 +1203,7 @@ fn map_mcp_ws_args( None => { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - format!( - "--mcp-transport must be one of: ws, legacy, auto (got: {value})" - ), + format!("--mcp-transport must be one of: ws, legacy, auto (got: {value})"), )); } }, diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index c8fc433..a73e22f 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -13,10 +13,10 @@ use crate::platform::utilities::PlatformUtilities; use crate::support::error::AppError; use crate::use_cases::context::{ExecutionContext, ExecutionInterruption}; use crate::use_cases::launch_keys::vanessa_enterprise_launch_keys; -use crate::use_cases::progress::log_live_stage; use crate::use_cases::mcp_ws::{ self, ClientKind, McpClientTransport, TransportDecision, WsLaunchParams, WsResolveInputs, }; +use crate::use_cases::progress::log_live_stage; use crate::use_cases::request::{ ClientMcpAddonRequest, ClientMcpMode, ClientMcpOptionsRequest, EnterpriseLaunchTarget, LaunchRequest as LaunchArgs, LaunchTargetRequest, McpClientTransportRequest, @@ -341,9 +341,7 @@ pub(crate) fn resolve_ws_launch_params( .log_level .clone() .or_else(|| config.tools.client_mcp.log_level.clone()), - ws_timeout_ms: cli - .ws_timeout_ms - .or(config.tools.client_mcp.ws_timeout_ms), + ws_timeout_ms: cli.ws_timeout_ms.or(config.tools.client_mcp.ws_timeout_ms), }; mcp_ws::resolve_ws_params(kind, inputs) } @@ -535,9 +533,7 @@ mod tests { launch: Default::default(), client_mcp: Some(ClientMcpOptionsRequest::default()), mcp_ws: crate::use_cases::request::McpClientWsRequest { - transport: Some( - crate::use_cases::request::McpClientTransportRequest::Legacy, - ), + transport: Some(crate::use_cases::request::McpClientTransportRequest::Legacy), ..Default::default() }, }, diff --git a/src/use_cases/mcp_ws.rs b/src/use_cases/mcp_ws.rs index e42af6c..3c39bc1 100644 --- a/src/use_cases/mcp_ws.rs +++ b/src/use_cases/mcp_ws.rs @@ -29,22 +29,17 @@ pub const PROBE_TIMEOUT_MS: u64 = 200; /// Transport selector controlling how the MCP client connects to the /// session-manager. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum McpClientTransport { /// Force WS-only mode. Resolution fails if the manager is unreachable. Ws, /// Force the legacy local HTTP transport (`runMcp[=...][;mcpPort=...]`). Legacy, /// Probe the manager: WS when reachable, legacy otherwise. + #[default] Auto, } -impl Default for McpClientTransport { - fn default() -> Self { - Self::Auto - } -} - impl McpClientTransport { pub fn from_str_value(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { @@ -168,8 +163,6 @@ pub enum WsResolveError { InvalidManagerUrl { url: String, reason: String }, #[error("session-manager unreachable at {url}")] Unreachable { url: String }, - #[error("unsupported mcp_log_level '{0}'")] - UnsupportedLogLevel(String), } /// Decision returned by [`select_transport`]. @@ -218,16 +211,6 @@ pub fn probe_tcp(addr: SocketAddr, timeout: Duration) -> bool { TcpStream::connect_timeout(&addr, timeout).is_ok() } -/// Validates an `mcp_log_level` value if one is provided. -pub fn validate_log_level(level: Option<&str>) -> Result<(), WsResolveError> { - if let Some(level) = level { - if !is_supported_log_level(level) { - return Err(WsResolveError::UnsupportedLogLevel(level.to_owned())); - } - } - Ok(()) -} - /// Parses the `host:port` portion of a `ws://host:port/path` URL and resolves /// it to a usable [`SocketAddr`]. Falls back to lookup via `to_socket_addrs`. pub fn parse_manager_addr(url: &str) -> Result { @@ -419,23 +402,20 @@ mod tests { } #[test] - fn validate_log_level_accepts_supported_values() { + fn is_supported_log_level_accepts_known_values() { for level in ["off", "error", "warn", "info", "debug", "trace"] { - validate_log_level(Some(level)).expect("supported"); + assert!(is_supported_log_level(level), "expected {level} supported"); } - validate_log_level(None).expect("none ok"); - } - - #[test] - fn validate_log_level_rejects_unknown_values() { - let err = validate_log_level(Some("verbose")).expect_err("rejected"); - assert!(matches!(err, WsResolveError::UnsupportedLogLevel(_))); + assert!(!is_supported_log_level("verbose")); } #[test] fn client_kind_strings_match_session_manager_contract() { assert_eq!(ClientKind::V8RunnerClient.as_str(), "v8_runner_client"); - assert_eq!(ClientKind::VanessaTestClient.as_str(), "vanessa_test_client"); + assert_eq!( + ClientKind::VanessaTestClient.as_str(), + "vanessa_test_client" + ); assert_eq!(ClientKind::YaxunitRunner.as_str(), "yaxunit_runner"); } } diff --git a/src/use_cases/mod.rs b/src/use_cases/mod.rs index 178672b..fae65a6 100644 --- a/src/use_cases/mod.rs +++ b/src/use_cases/mod.rs @@ -30,10 +30,10 @@ mod interruption; pub mod launch_app; /// Shared launch key policy for Enterprise-backed use cases. mod launch_keys; -/// Shared helpers for the WS-mode (`mcpMode=ws`) `/C` payload. -pub mod mcp_ws; /// Load packaged artifacts into infobase. pub mod load_artifact; +/// Shared helpers for the WS-mode (`mcpMode=ws`) `/C` payload. +pub mod mcp_ws; /// Text-mode live progress events shared by CLI-facing use cases. mod progress; /// Transport-neutral request DTOs consumed by use cases. diff --git a/src/use_cases/run_tests/helpers.rs b/src/use_cases/run_tests/helpers.rs index 59a0277..71d6c2b 100644 --- a/src/use_cases/run_tests/helpers.rs +++ b/src/use_cases/run_tests/helpers.rs @@ -452,10 +452,7 @@ pub(super) fn apply_test_mcp_ws_payload( return; } }; - if !matches!( - decision, - crate::use_cases::mcp_ws::TransportDecision::Ws - ) { + if !matches!(decision, crate::use_cases::mcp_ws::TransportDecision::Ws) { return; } let params = crate::use_cases::launch_app::resolve_ws_launch_params(config, mcp_ws, kind); diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index 5c02063..a79549f 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -826,8 +826,7 @@ fn launch_mcp_ws_required_fails_when_manager_unreachable() { let stdout = String::from_utf8_lossy(&output.stdout); let combined = format!("{stderr}\n{stdout}"); assert!( - combined.contains("session-manager unreachable") - || combined.contains("unreachable"), + combined.contains("session-manager unreachable") || combined.contains("unreachable"), "expected 'unreachable' diagnostic, got stderr={stderr}, stdout={stdout}" ); } From 48ca339b989f30a3cedf1fd7a5a819e2acf6bcb0 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Thu, 7 May 2026 08:18:48 +0000 Subject: [PATCH 09/15] =?UTF-8?q?docs(skill):=20WS-=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=B8=D0=BC=20=D0=BA=20session-manager=20=D0=B2=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дополняет агентный скил v8-runner новой секцией. Источник правды — docs/CONFIGURATION.md (`tools.client_mcp` + «WS-режим к session-manager»), здесь — оперативная справка для агента. Что добавлено: - v8-runner/references/project-workflows.md: новая секция «WS-режим к session-manager» — транспорт/auto-probe, /C payload, internal kind mapping (v8_runner_client / vanessa_test_client / yaxunit_runner), override-флаги, JSON-output, замечание что менеджер v8-runner не поднимает (отдельный шаг). - v8-runner/references/command-selection.md: после блока launch mcp добавлены примеры WS-флагов (--mcp-transport, --manager-url, --client-uid, --corr-id, --mcp-log-level) с указанием на полный раздел в project-workflows.md. - v8-runner/SKILL.md: в Default Use-Case Routing — буллет про WS-режим с краткой логикой auto-probe и фиксированным kind-mapping (без CLI override). --- v8-runner/SKILL.md | 1 + v8-runner/references/command-selection.md | 10 ++++ v8-runner/references/project-workflows.md | 65 +++++++++++++++++++++++ 3 files changed, 76 insertions(+) diff --git a/v8-runner/SKILL.md b/v8-runner/SKILL.md index 33b1b03..61037ab 100644 --- a/v8-runner/SKILL.md +++ b/v8-runner/SKILL.md @@ -74,6 +74,7 @@ v8-runner init - Release artifacts need to be exported or external artifacts published: use `v8-runner make ...` or the `artifacts` alias. - Need a 1C UI session: use `v8-runner launch designer`, `launch thin`, `launch thick`, or `launch ordinary`. - Need onec-client-mcp-devkit launched inside 1C without VA authoring: use `v8-runner launch mcp ...`. +- Pair the launched 1С-client with a running [v8-client-session-manager](https://github.com/SteelMorgan/v8-client-session-manager) over WebSocket: rely on `--mcp-transport=auto` (default — TCP-probes `manager_url` for 200 ms). Force WS with `--mcp-transport=ws` (fails if manager is down) or skip WS entirely with `--mcp-transport=legacy`. WS-only flags: `--manager-url`, `--client-uid`, `--corr-id`, `--mcp-log-level`, `--mcp-ws-timeout-ms`. The internal `kind` mapping (`v8_runner_client` / `vanessa_test_client` / `yaxunit_runner` / `vanessa_test_client`) is fixed by entry-point and **not** overridable from CLI. Read `references/project-workflows.md` (section «WS-режим к session-manager») for the full payload, defaults, and `--json-message` shape. ## Guardrails diff --git a/v8-runner/references/command-selection.md b/v8-runner/references/command-selection.md index 3ca894c..63935fb 100644 --- a/v8-runner/references/command-selection.md +++ b/v8-runner/references/command-selection.md @@ -161,3 +161,13 @@ v8-runner launch mcp v8-runner launch mcp --mode thin --mcp-port v8-runner launch mcp --mcp-config ``` + +WS-mode flags (when v8-client-session-manager is reachable): + +```bash +v8-runner launch mcp --mcp-transport=ws --manager-url ws://127.0.0.1:4000/sessions +v8-runner launch mcp --mcp-transport=legacy # force legacy without probe +v8-runner launch mcp --mcp-log-level=debug --client-uid --corr-id +``` + +`--mcp-transport=auto` (default) probes `manager_url` for 200 ms and chooses `ws` on success, `legacy` on failure. The same WS-flags work on `test yaxunit ...` and `test va ...`. See `project-workflows.md` for the full WS-режим section, internal `kind` mapping, and `--json-message` output shape. diff --git a/v8-runner/references/project-workflows.md b/v8-runner/references/project-workflows.md index 4f94c47..9786ef1 100644 --- a/v8-runner/references/project-workflows.md +++ b/v8-runner/references/project-workflows.md @@ -132,3 +132,68 @@ For `launch mcp`, use `--mcp-config` and `--mcp-port`; do not pass `/C` through `launch mcp` and `launch mcp va` do not install or update `tools.client_mcp.extension`; run `v8-runner build` first when that extension may be missing or stale. For `launch mcp va`, read `testing.md`; it is part of the Vanessa Automation debugging and scenario-authoring workflow. + +## WS-режим к session-manager + +Когда рядом с проектом запущен [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager), 1С-клиент может подключаться к нему по WebSocket вместо локального HTTP MCP-сервера (legacy `runMcp`-режим). v8-runner делает выбор автоматически. + +### Транспорт и автоопределение + +`tools.client_mcp.transport`: + +- `auto` (по умолчанию) — короткий TCP-probe (200 ms) на хост:порт из `manager_url`. Слышим listener → WS, нет → legacy. +- `ws` — строго WS, при недоступности менеджера запуск падает с `session-manager unreachable at `. +- `legacy` — старый HTTP-режим без probe. + +Override через `--mcp-transport={ws|legacy|auto}`. CLI приоритет конфига. + +### Что v8-runner подставляет в `/C` в WS-ветке + +```text +/C"mcpMode=ws;manager_url=;client_uid=;kind=;corr_id=;mcp_log_level=;mcp_ws_timeout_ms=" +``` + +Источники значений: + +| Ключ | По умолчанию | Override | +|------|--------------|----------| +| `manager_url` | `tools.client_mcp.manager_url` или `ws://127.0.0.1:4000/sessions` | `--manager-url ` | +| `client_uid` | новый UUID v4 на каждый запуск | `--client-uid ` | +| `kind` | внутренний mapping (см. таблицу ниже) | (нет — kind не переопределяется из CLI) | +| `corr_id` | `vr-<первые 8 символов client_uid>` | `--corr-id ` | +| `mcp_log_level` | `tools.client_mcp.log_level` или `info` | `--mcp-log-level={off\|error\|warn\|info\|debug\|trace}` | +| `mcp_ws_timeout_ms` | `tools.client_mcp.ws_timeout_ms` или `1000` | `--mcp-ws-timeout-ms ` | + +### Internal `kind` mapping + +| Команда v8-runner | `kind` | +|---|---| +| `launch mcp` | `v8_runner_client` | +| `launch mcp va` | `vanessa_test_client` | +| `test yaxunit ...` | `yaxunit_runner` | +| `test va ...` | `vanessa_test_client` | + +`vanessa_test_client` имеет специальную семантику в менеджере: его прокси-тулы публикуются на MCP HTTP без префикса `__` (см. router в репозитории менеджера). Не подменяй `kind` вручную. + +### Тестовые подкоманды (`test yaxunit`, `test va`) + +Для тестовых запусков WS-фрагмент **дописывается** через `;` к существующему `/C` (`RunUnitTests=…` или Vanessa-плеер). Никаких отдельных флагов прописывать не надо — те же `--mcp-transport`/`--manager-url`/`--mcp-log-level` доступны и тут. + +### JSON-output + +В режиме `--json-message` ответ launch- и test-команд включает поля транспорта: + +WS-ветка: +```json +{ "transport": "ws", "client_uid": "...", "kind": "...", "manager_url": "...", "corr_id": "..." } +``` +Legacy-ветка: +```json +{ "transport": "legacy", "mcp_port": 9874 } +``` + +Внешний оркестратор (CI, AI-агент) использует `client_uid` для поиска сессии в `session_list` менеджера. + +### Менеджер не запускается из v8-runner + +v8-runner только подключается к запущенному менеджеру. Подъём менеджера — отдельный шаг (`cargo run --release` в репо `v8-client-session-manager`, либо systemd-юнит `systemd/v8-session-manager.service`, либо Docker-compose). Если менеджер не нужен — `--mcp-transport=legacy` форсирует старый flow. From 17f3e054caf9eb441c0aac4ab26206fd50eb58b3 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Thu, 7 May 2026 08:20:09 +0000 Subject: [PATCH 10/15] =?UTF-8?q?docs(skill):=20bootstrap=20reference=20?= =?UTF-8?q?=E2=80=94=20=D0=BA=D0=B0=D0=BA=D0=B8=D0=B5=20=D0=B2=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D1=8B=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D1=80=D0=B8=20config=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Скил знал про команду config init, но не описывал, как агенту решить какие флаги передать без лишних вопросов пользователю. Decision-tree прятался в коде src/use_cases/config_init.rs (auto-detect format, discover sources, choose builder). Что добавлено: - v8-runner/references/bootstrap.md — новый reference. Содержит: - проверка существующего v8project.yaml (force-семантика); - signals в файловой системе для format=designer|edt|auto; - когда переключать builder с DESIGNER на IBCMD; - три формы --connection (File auto-managed / File existing / Srvr server-bound) с дефолтом File=build/ib; - правила «когда задавать вопросы»: 4 ситуации и формулировки; - примеры flow для типичных проектов (File-Designer, EDT, mixed, server-bound); - чек-лист what-to-inspect после config init. - v8-runner/SKILL.md: новый bullet в decision entrypoint, ссылающийся на bootstrap.md. Источник правды по фактам — src/use_cases/config_init.rs (discover_sources, choose_format, build_source_sets) + src/cli/args.rs (ConfigInitArgs). --- v8-runner/SKILL.md | 1 + v8-runner/references/bootstrap.md | 126 ++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 v8-runner/references/bootstrap.md diff --git a/v8-runner/SKILL.md b/v8-runner/SKILL.md index 61037ab..e76caea 100644 --- a/v8-runner/SKILL.md +++ b/v8-runner/SKILL.md @@ -10,6 +10,7 @@ Use this skill to operate `v8-runner` as the automation layer for local 1C devel Keep this file as the decision entrypoint. Load only the reference file that matches the task: - `references/command-selection.md` for choosing the right command sequence. +- `references/bootstrap.md` for generating `v8project.yaml` from an existing repository — what to detect yourself and what to ask the user (decision tree for `format`, `builder`, `connection`). - `references/config-and-backends.md` for `v8project.yaml`, source sets, formats, builders, and backend limits. - `references/project-workflows.md` for common build, syntax, dump, launch, and source sync workflows across Designer and EDT projects. - `references/file-and-artifact-workflows.md` for dump, convert, load, make/artifacts, and staged publication. diff --git a/v8-runner/references/bootstrap.md b/v8-runner/references/bootstrap.md new file mode 100644 index 0000000..29b6288 --- /dev/null +++ b/v8-runner/references/bootstrap.md @@ -0,0 +1,126 @@ +# Bootstrapping `v8project.yaml` + +Use this reference when starting `v8-runner` work in a 1С repository that has no `v8project.yaml` yet. The goal is to ask the user **only the questions you cannot answer yourself by inspecting the repository**. + +`v8-runner config init` already auto-detects: +- existing source sets (Designer and EDT) under the project root, +- chosen format when `--format=auto` (default), based on what was found, +- platform version when discoverable from sources. + +So in most projects the right command is simply: + +```bash +v8-runner config init +``` + +Inspect the generated `v8project.yaml` afterwards and only re-run with explicit flags if the auto-detection was wrong or the user has constraints not visible from the filesystem. + +## Decision tree (run this BEFORE asking the user) + +### 1. Check whether the project is already configured + +If `v8project.yaml` exists — do **not** run `config init` without `--force`. Inspect it instead and report what is configured. Reaching for `--force` requires the user’s explicit consent because it overwrites whatever local tweaks they had. + +### 2. Determine source format from the filesystem + +Look at the project root and immediate subdirectories. + +| Filesystem signal | Likely format | +|---|---| +| `src/cf/Configuration.xml`, `src/cfe//Configuration.xml`, raw Designer XML tree | `DESIGNER` | +| `src/cf/.project`, `src/cf/Configuration/Configuration.mdo`, `*.mdo`, `DT-INF/` | `EDT` | +| Both kinds of trees side by side (mixed mono-repo) | `auto` (default) — let `config init` register both source sets | +| Neither of the above | the repository is not a 1С source tree — stop and ask the user what they expect to find | + +If the format is unambiguously one or the other, you may pass `--format=designer` or `--format=edt` for clarity, but do not have to — `auto` will pick the same. + +### 3. Decide the builder backend + +Defaults to `DESIGNER`. Switch to `IBCMD` only when: + +- the project is `EDT` and the team is on a platform version where `ibcmd` is supported and faster (≥ 8.3.20); **and** +- there is no Designer-only feature on the critical path (some legacy project tasks still need the Designer GUI). + +Ask the user only if the choice is ambiguous and you cannot tell from `tools.platform.version`. Default to `DESIGNER` when in doubt — it is the safer baseline. + +### 4. Decide infobase connection + +`--connection` is the connection string written into `tools.connection`. Three common shapes: + +| Shape | Example | When to use | +|---|---|---| +| File infobase auto-managed by `v8-runner` | `--connection "File=build/ib"` | most local dev cycles. Path is created on first `v8-runner init`. Safe default. | +| File infobase that already exists on disk | `--connection "File=/abs/path/to/ib"` | the user has an existing baseline they want to point at | +| Server infobase | `--connection "Srvr=cluster:1541;Ref=ibname"` | central dev base, shared infobase, CI runner attached to a cluster | + +For server connections there is no auto-creation (it requires DBA-level operations). The user has to confirm the database exists and credentials are stored elsewhere (`v8project.local.yaml` keeps secrets out of git). + +If the project README, `docker-compose.yml`, or `.env` already describes a connection, **use that** without re-asking. If nothing is documented, ask the user **once** with the three options above and a default of `File=build/ib`. + +### 5. Decide output path + +Default `./v8project.yaml`. Override via `--output` only when the project is a sub-tree inside a larger repo and the user explicitly wants a non-root config. Do not invent `--output` values. + +## When to ask the user (and how) + +Ask only when at least one of these is true: + +1. **The repository looks ambiguous or empty** — no Designer/EDT signals and no other clue. Ask: «I don’t see a Designer or EDT source tree under ``. Where do the 1С sources live, or should I create a fresh empty config?» +2. **A server infobase is hinted at, but no connection string is documented.** Ask: «This looks like a server-bound project. What is the cluster:port and infobase name? Or should I default to a local `File=build/ib`?» +3. **Mixed Designer + EDT sources are detected** and the user clearly works with only one of them. Ask: «I detected both Designer and EDT trees. Should I register both as separate source sets, or only one? Which one is the primary?» +4. **`tools.platform.version` cannot be inferred** and the user has multiple installed. Ask once. + +Phrase questions in a single round, give a default, and proceed if the user wants to keep defaults. + +## Sample interactions + +### Generic File-base project, Designer sources + +```bash +v8-runner config init --connection "File=build/ib" +v8-runner init # creates the file infobase +v8-runner build # applies sources +``` + +No questions to the user. + +### EDT project on platform 8.3.20+ + +```bash +v8-runner config init --format edt --builder IBCMD +v8-runner init +v8-runner build +``` + +No questions if the platform version is detected. + +### Mixed Designer + EDT mono-repo + +```bash +v8-runner config init # registers both source sets via auto +``` + +Ask only if the user wants only one of them to be active. + +### Server-bound project + +```bash +# After confirming with the user: +v8-runner config init --connection "Srvr=10.0.0.10:1541;Ref=dssl_drive_ai" +# v8-runner init is NOT run — the database is managed externally +v8-runner build # applies local sources to the existing infobase +``` + +Ask once for `Srvr=...;Ref=...` and credentials policy (where are they stored). Server-bound config typically lives outside git via `v8project.local.yaml`. + +## After `config init` + +Always inspect the generated `v8project.yaml` before running mutating commands. Show the user: + +- the picked format and builder (`format`, `builder`), +- detected source sets (`source-sets[*].name`, `path`), +- the connection string, +- whether `tools.platform.version` was filled, +- any warnings printed by `config init` (often hint at missing tools or unusual layouts). + +If something looks off, reach for explicit `--format`, `--builder`, or `--connection` rather than editing the YAML by hand — that keeps the workflow reproducible. From 68acc8ff12e93e1c6963fd7e7986451797c7ad4e Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Wed, 13 May 2026 12:10:51 +0000 Subject: [PATCH 11/15] fix(mcp): rename local transport mode to mcp - replace legacy transport config value with mcp\n- harden WS payload validation and schema boundaries\n- update docs, schemas, and launch tests --- README.md | 4 +- docs/CONFIGURATION.md | 16 +-- docs/schemas/v8project.local.schema.json | 7 +- docs/schemas/v8project.schema.json | 12 +- src/cli/args.rs | 8 +- src/cli/execute.rs | 20 ++-- src/config/model.rs | 6 +- src/config/schema.rs | 50 +++++++- src/config/validate.rs | 7 +- src/domain/launch.rs | 14 ++- src/use_cases/launch_app.rs | 31 +++-- src/use_cases/mcp_ws.rs | 137 +++++++++++++++------- src/use_cases/request.rs | 2 +- src/use_cases/run_tests/coordinator.rs | 20 +++- src/use_cases/run_tests/helpers.rs | 19 +-- tests/cli_launch.rs | 14 +-- v8-runner/SKILL.md | 2 +- v8-runner/references/bootstrap.md | 2 +- v8-runner/references/command-selection.md | 4 +- v8-runner/references/project-workflows.md | 14 +-- 20 files changed, 267 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index a0ea594..6363112 100644 --- a/README.md +++ b/README.md @@ -93,10 +93,10 @@ v8-runner launch mcp va ### Подключение к session-manager (WS-режим) `launch mcp`, `launch mcp va`, `test yaxunit ...` и `test va ...` поддерживают подключение -1С-клиента к [`v8-client-session-manager`](../v8-client-session-manager/) вместо запуска +1С-клиента к [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager) вместо запуска локального HTTP MCP. По умолчанию — `auto`: TCP-probe адреса менеджера, при успехе собирается `/C"mcpMode=ws;manager_url=...;client_uid=...;kind=...;..."`, иначе используется -legacy `/C"runMcp;..."`. Полный список ключей `/C` и CLI-флагов см. в +MCP `/C"runMcp;..."`. Полный список ключей `/C` и CLI-флагов см. в [docs/CONFIGURATION.md](docs/CONFIGURATION.md#tools-client_mcp). ### Поднимите MCP transport (MCP-транспорт) для AI-агентов: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f708fb0..4ab7a49 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -141,7 +141,7 @@ tools: source: path: /path/to/onec-client-mcp/exts/client-mcp format: EDT - transport: auto # ws | legacy | auto (default) + transport: auto # ws | mcp | auto (default) manager_url: ws://127.0.0.1:4000/sessions log_level: info # off|error|warn|info|debug|trace ws_timeout_ms: 1000 @@ -446,11 +446,11 @@ runtime identity и не добавляет отдельное поле конф Поддержанные поля: -- `port`, опциональный порт клиентского MCP-сервера onec-client-mcp-devkit (legacy режим). +- `port`, опциональный порт клиентского MCP-сервера onec-client-mcp-devkit (MCP-режим). - `extension`, опциональное tool extension для клиентского MCP-сервера. -- `transport` (`ws`, `legacy`, `auto`; по умолчанию `auto`) — режим транспорта. См. раздел +- `transport` (`ws`, `mcp`, `auto`; по умолчанию `auto`) — режим транспорта. См. раздел «WS-режим к session-manager» ниже. -- `manager_url` — WS-эндпоинт `v8-client-session-manager`, +- `manager_url` — WS-эндпоинт `v8-client-session-manager` с IP-адресом и портом, по умолчанию `ws://127.0.0.1:4000/sessions`. - `log_level` (`off`/`error`/`warn`/`info`/`debug`/`trace`) — значение `mcp_log_level`, передаваемое в `/C` BSL-расширению `client_mcp`. @@ -458,7 +458,7 @@ runtime identity и не добавляет отдельное поле конф > 0). `launch mcp` передаёт `port` как `mcpPort` внутри `/C"runMcp..."` -если CLI не указал `--mcp-port` и выбран legacy/`auto`-fallback транспорт. +если CLI не указал `--mcp-port` и выбран `mcp`/`auto`-fallback транспорт. #### WS-режим к session-manager @@ -488,12 +488,12 @@ CLI-флаги: `--mcp-transport`, `--manager-url`, `--client-uid`, `--corr-id`, встроенными дефолтами. `client_uid` по умолчанию рандомный UUIDv4 на каждый запуск, `corr_id` по умолчанию `vr-<первые 8 символов uid>`. -Для `transport=auto` v8-runner делает короткий TCP-probe (200 ms) на хост:порт из -`manager_url`. При успехе выбирается WS, иначе legacy. Для `transport=ws` без живого +Для `transport=auto` v8-runner делает короткий TCP-probe (200 ms) на IP:порт из +`manager_url`. При успехе выбирается WS, иначе MCP. Для `transport=ws` без живого менеджера запуск падает с ошибкой `session-manager unreachable at `. Сам менеджер v8-runner не запускает — его нужно поднять отдельно -(см. соседний репозиторий [`v8-client-session-manager`](../../v8-client-session-manager/)). +(см. репозиторий [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager)). `extension` поддерживает: diff --git a/docs/schemas/v8project.local.schema.json b/docs/schemas/v8project.local.schema.json index 85a20c9..b322bbd 100644 --- a/docs/schemas/v8project.local.schema.json +++ b/docs/schemas/v8project.local.schema.json @@ -224,12 +224,17 @@ }, "transport": { "description": "Machine-local override of the default MCP client transport.", + "enum": [ + "ws", + "mcp", + "auto" + ], "type": "string" }, "ws_timeout_ms": { "description": "Machine-local override of the default `mcp_ws_timeout_ms`.", "format": "uint64", - "minimum": 0, + "minimum": 1, "type": "integer" } }, diff --git a/docs/schemas/v8project.schema.json b/docs/schemas/v8project.schema.json index 18811f7..dc0ba08 100644 --- a/docs/schemas/v8project.schema.json +++ b/docs/schemas/v8project.schema.json @@ -41,7 +41,7 @@ ] }, "manager_url": { - "description": "Default WS endpoint for the session-manager.", + "description": "Default WS endpoint with IP address and port for the session-manager.", "type": [ "string", "null" @@ -58,7 +58,13 @@ ] }, "transport": { - "description": "Default transport for the MCP client side: `ws`, `legacy` or `auto`.", + "description": "Default transport for the MCP client side: `ws`, `mcp` or `auto`.", + "enum": [ + "ws", + "mcp", + "auto", + null + ], "type": [ "string", "null" @@ -67,7 +73,7 @@ "ws_timeout_ms": { "description": "Default `mcp_ws_timeout_ms` value forwarded into the `/C` payload.", "format": "uint64", - "minimum": 0, + "minimum": 1, "type": [ "integer", "null" diff --git a/src/cli/args.rs b/src/cli/args.rs index 8c7dce3..0b0e9d8 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -325,9 +325,9 @@ pub struct LaunchArgs { #[derive(Args, Debug, Clone, Default, PartialEq, Eq)] #[command(next_help_heading = "MCP client WS options")] pub struct McpClientWsArgs { - /// Override the transport selection: `ws` forces WS, `legacy` forces local - /// HTTP MCP, `auto` probes the manager and falls back to legacy. - #[arg(long = "mcp-transport", value_parser = ["ws", "legacy", "auto"])] + /// Override the transport selection: `ws` forces WS, `mcp` forces local + /// HTTP MCP, `auto` probes the manager and falls back to MCP. + #[arg(long = "mcp-transport", value_parser = ["ws", "mcp", "auto"])] pub mcp_transport: Option, /// Override the session-manager WS endpoint @@ -345,7 +345,7 @@ pub struct McpClientWsArgs { pub corr_id: Option, /// Override the `mcp_log_level` value passed to the BSL devkit. - #[arg(long = "mcp-log-level", value_parser = ["off", "error", "warn", "info", "debug", "trace"])] + #[arg(long = "mcp-log-level")] pub mcp_log_level: Option, /// Override the `mcp_ws_timeout_ms` value passed to the BSL devkit diff --git a/src/cli/execute.rs b/src/cli/execute.rs index 9ca1300..7126862 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -1194,8 +1194,8 @@ fn map_mcp_ws_args( Some(crate::use_cases::mcp_ws::McpClientTransport::Ws) => { Some(McpClientTransportRequest::Ws) } - Some(crate::use_cases::mcp_ws::McpClientTransport::Legacy) => { - Some(McpClientTransportRequest::Legacy) + Some(crate::use_cases::mcp_ws::McpClientTransport::Mcp) => { + Some(McpClientTransportRequest::Mcp) } Some(crate::use_cases::mcp_ws::McpClientTransport::Auto) => { Some(McpClientTransportRequest::Auto) @@ -1203,7 +1203,7 @@ fn map_mcp_ws_args( None => { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - format!("--mcp-transport must be one of: ws, legacy, auto (got: {value})"), + format!("--mcp-transport must be one of: ws, mcp, auto (got: {value})"), )); } }, @@ -1225,6 +1225,12 @@ fn map_mcp_ws_args( )); } if let Some(url) = args.manager_url.as_deref() { + if !crate::use_cases::mcp_ws::is_payload_token_safe(url) { + return Err(UseCaseError::new( + UseCaseErrorKind::Validation, + "--manager-url must not contain ';' or '=' because the /C payload is semicolon-delimited", + )); + } if crate::use_cases::mcp_ws::parse_manager_addr(url).is_err() { return Err(UseCaseError::new( UseCaseErrorKind::Validation, @@ -1233,18 +1239,18 @@ fn map_mcp_ws_args( } } if let Some(uid) = args.client_uid.as_deref() { - if uid.contains(';') { + if !crate::use_cases::mcp_ws::is_payload_token_safe(uid) { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - "--client-uid must not contain ';' because the /C payload is semicolon-delimited", + "--client-uid must not contain ';' or '=' because the /C payload is semicolon-delimited", )); } } if let Some(corr) = args.corr_id.as_deref() { - if corr.contains(';') { + if !crate::use_cases::mcp_ws::is_payload_token_safe(corr) { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - "--corr-id must not contain ';' because the /C payload is semicolon-delimited", + "--corr-id must not contain ';' or '=' because the /C payload is semicolon-delimited", )); } } diff --git a/src/config/model.rs b/src/config/model.rs index e5b7c1b..901b405 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -287,12 +287,12 @@ pub struct ClientMcpToolConfig { /// Optional tool extension prepared by `build` for client MCP launches. pub extension: Option, - /// Default transport for the MCP client side: `ws`, `legacy` or `auto`. + /// Default transport for the MCP client side: `ws`, `mcp` or `auto`. /// When omitted, runtime treats it as `auto` (probe manager, fall back - /// to legacy local HTTP MCP). + /// to local HTTP MCP). pub transport: Option, - /// Default WS endpoint for the session-manager + /// Default WS endpoint with IP address and port for the session-manager /// (e.g. `ws://127.0.0.1:4000/sessions`). pub manager_url: Option, diff --git a/src/config/schema.rs b/src/config/schema.rs index b00d18c..3eda552 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -27,6 +27,7 @@ pub fn main_config_schema_json() -> Value { set_schema_id(&mut schema, &main_config_schema_url()); add_tool_extension_schema_constraints(&mut schema); add_numeric_runtime_bounds(&mut schema); + add_mcp_transport_schema_constraints(&mut schema); schema } @@ -36,6 +37,7 @@ pub fn local_config_schema_json() -> Value { set_schema_id(&mut schema, &local_config_schema_url()); add_tool_extension_schema_constraints(&mut schema); add_numeric_runtime_bounds(&mut schema); + add_mcp_transport_schema_constraints(&mut schema); schema } @@ -233,6 +235,7 @@ fn add_numeric_runtime_bounds(schema: &mut Value) { ); for def in ["ClientMcpToolSchema", "PartialClientMcpToolSchema"] { set_numeric_bounds(schema, &[def], "port", Some(1), None); + set_numeric_bounds(schema, &[def], "ws_timeout_ms", Some(1), None); } for name in ["max_sessions", "idle_ttl_secs"] { set_numeric_bounds(schema, &["McpHttpSchema"], name, Some(1), None); @@ -279,6 +282,35 @@ fn set_numeric_bounds( } } +fn add_mcp_transport_schema_constraints(schema: &mut Value) { + for def in ["ClientMcpToolSchema", "PartialClientMcpToolSchema"] { + set_string_enum(schema, &[def], "transport", &["ws", "mcp", "auto"]); + } +} + +fn set_string_enum(schema: &mut Value, def_path: &[&str], property: &str, variants: &[&str]) { + let Some(object) = schema_object_mut(schema, def_path) else { + return; + }; + let Some(property) = object + .get_mut("properties") + .and_then(Value::as_object_mut) + .and_then(|properties| properties.get_mut(property)) + .and_then(Value::as_object_mut) + else { + return; + }; + + let mut variants = variants + .iter() + .map(|value| json!(value)) + .collect::>(); + if matches!(property.get("type"), Some(Value::Array(_))) { + variants.push(Value::Null); + } + property.insert("enum".to_owned(), Value::Array(variants)); +} + fn schema_object_mut<'a>( schema: &'a mut Value, def_path: &[&str], @@ -705,10 +737,10 @@ struct ClientMcpToolSchema { /// Optional tool extension prepared by `build` for client MCP launches. #[serde(default, skip_serializing_if = "Option::is_none")] extension: Option, - /// Default transport for the MCP client side: `ws`, `legacy` or `auto`. + /// Default transport for the MCP client side: `ws`, `mcp` or `auto`. #[serde(default, skip_serializing_if = "Option::is_none")] transport: Option, - /// Default WS endpoint for the session-manager. + /// Default WS endpoint with IP address and port for the session-manager. #[serde(default, skip_serializing_if = "Option::is_none")] manager_url: Option, /// Default `mcp_log_level` value forwarded into the `/C` payload. @@ -1460,6 +1492,20 @@ mod tests { } } + #[test] + fn schemas_and_loader_reject_invalid_client_mcp_transport() { + let config = format!( + "{}tools:\n client_mcp:\n transport: legacy\n", + minimal_project_config_without_base_path() + ); + assert_schema_invalid(&main_config_schema_json(), &config); + assert_config_loader_error_any(&config); + + let overlay = "tools:\n client_mcp:\n transport: legacy\n"; + assert_schema_invalid(&local_config_schema_json(), overlay); + assert_overlay_loader_error(overlay); + } + #[test] fn schemas_and_loader_accept_supported_runtime_sections() { let config = format!( diff --git a/src/config/validate.rs b/src/config/validate.rs index bdaa7f5..5a2dc67 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -163,7 +163,7 @@ pub enum ConfigValidationError { #[error("tools.client_mcp.port must be greater than or equal to 1")] InvalidMcpClientPort, - #[error("tools.client_mcp.transport must be one of: ws, legacy, auto (got: {0})")] + #[error("tools.client_mcp.transport must be one of: ws, mcp, auto (got: {0})")] InvalidMcpClientTransport(String), #[error( @@ -850,6 +850,11 @@ fn validate_mcp_config(config: &AppConfig) -> Result<(), ConfigValidationError> } if let Some(url) = config.tools.client_mcp.manager_url.as_deref() { + if !crate::use_cases::mcp_ws::is_payload_token_safe(url) { + return Err(ConfigValidationError::InvalidMcpClientManagerUrl( + url.to_owned(), + )); + } if crate::use_cases::mcp_ws::parse_manager_addr(url).is_err() { return Err(ConfigValidationError::InvalidMcpClientManagerUrl( url.to_owned(), diff --git a/src/domain/launch.rs b/src/domain/launch.rs index 49e2c76..a0ba964 100644 --- a/src/domain/launch.rs +++ b/src/domain/launch.rs @@ -15,10 +15,10 @@ pub struct LaunchResult { pub binary: PathBuf, /// Human-readable launch summary. pub message: Option, - /// MCP transport selected for this launch (`ws` or `legacy`). + /// MCP transport selected for this launch. /// Present only for `launch mcp` and `test` flows. #[serde(skip_serializing_if = "Option::is_none", default)] - pub transport: Option, + pub transport: Option, /// Per-launch UUID announced to the session-manager /// (`mcpMode=ws` only). #[serde(skip_serializing_if = "Option::is_none", default)] @@ -34,11 +34,19 @@ pub struct LaunchResult { /// (`mcpMode=ws` only). #[serde(skip_serializing_if = "Option::is_none", default)] pub corr_id: Option, - /// Local HTTP MCP port (`legacy` transport only). + /// Local HTTP MCP port (`mcp` transport only). #[serde(skip_serializing_if = "Option::is_none", default)] pub mcp_port: Option, } +/// MCP client transport selected for a launch result. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LaunchMcpTransport { + Ws, + Mcp, +} + /// Supported application launch modes. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index a73e22f..d055c11 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::time::Duration; use crate::config::model::AppConfig; -use crate::domain::launch::{LaunchMode, LaunchResult}; +use crate::domain::launch::{LaunchMcpTransport, LaunchMode, LaunchResult}; use crate::domain::runner::LaunchOptions; use crate::platform::enterprise::{ build_launch_args, normalize_launch_payload_path, LaunchClientMode, @@ -24,7 +24,7 @@ use crate::use_cases::request::{ }; use crate::use_cases::result::{UseCaseFailure, UseCaseResult}; use crate::use_cases::tool_extension; -use tracing::debug; +use tracing::{debug, warn}; const LAUNCH_STARTUP_PROBE: Duration = Duration::from_millis(250); @@ -119,14 +119,14 @@ pub fn execute( fn apply_mcp_resolution_to_result(result: &mut LaunchResult, meta: McpResolutionMeta) { match meta { McpResolutionMeta::Ws(params) => { - result.transport = Some("ws".to_owned()); + result.transport = Some(LaunchMcpTransport::Ws); result.client_uid = Some(params.client_uid); result.kind = Some(params.kind.as_str().to_owned()); result.manager_url = Some(params.manager_url); result.corr_id = Some(params.corr_id); } - McpResolutionMeta::Legacy { port } => { - result.transport = Some("legacy".to_owned()); + McpResolutionMeta::Mcp { port } => { + result.transport = Some(LaunchMcpTransport::Mcp); result.mcp_port = port; } } @@ -135,7 +135,7 @@ fn apply_mcp_resolution_to_result(result: &mut LaunchResult, meta: McpResolution #[derive(Debug, Clone)] enum McpResolutionMeta { Ws(WsLaunchParams), - Legacy { port: Option }, + Mcp { port: Option }, } fn launch_message(config: &AppConfig, args: &LaunchArgs, binary: &Path, pid: u32) -> String { @@ -245,14 +245,22 @@ fn effective_launch_options( let mut launch = args.launch.clone(); let (mut payload, meta) = match decision { TransportDecision::Ws => { + if client_mcp.port.is_some() || config.tools.client_mcp.port.is_some() { + warn!( + "ws transport: client_mcp.port is ignored; session-manager controls the port" + ); + } + if client_mcp.config_path.is_some() { + warn!("ws transport: client_mcp.config_path is ignored"); + } let params = resolve_ws_launch_params(config, &args.mcp_ws, kind); let snippet = params.payload_snippet(); (snippet, McpResolutionMeta::Ws(params)) } - TransportDecision::Legacy => { + TransportDecision::Mcp => { let payload = build_legacy_client_mcp_payload(client_mcp, config.tools.client_mcp.port); let port = client_mcp.port.or(config.tools.client_mcp.port); - (payload, McpResolutionMeta::Legacy { port }) + (payload, McpResolutionMeta::Mcp { port }) } }; if matches!( @@ -308,7 +316,7 @@ pub(crate) fn effective_transport( if let Some(t) = cli.transport { return match t { McpClientTransportRequest::Ws => McpClientTransport::Ws, - McpClientTransportRequest::Legacy => McpClientTransport::Legacy, + McpClientTransportRequest::Mcp => McpClientTransport::Mcp, McpClientTransportRequest::Auto => McpClientTransport::Auto, }; } @@ -354,6 +362,7 @@ mod tests { SourceFormat, SourceSetConfig, SourceSetPurpose, TestsConfig, ToolExtensionArtifactConfig, ToolExtensionConfig, ToolExtensionInput, ToolsConfig, }; + use crate::domain::launch::LaunchMcpTransport; use crate::use_cases::context::{CommandName, ExecutionContext}; use crate::use_cases::request::{ ClientMcpMode, ClientMcpOptionsRequest, LaunchRequest, LaunchTargetRequest, @@ -533,7 +542,7 @@ mod tests { launch: Default::default(), client_mcp: Some(ClientMcpOptionsRequest::default()), mcp_ws: crate::use_cases::request::McpClientWsRequest { - transport: Some(crate::use_cases::request::McpClientTransportRequest::Legacy), + transport: Some(crate::use_cases::request::McpClientTransportRequest::Mcp), ..Default::default() }, }, @@ -546,7 +555,7 @@ mod tests { .as_deref() .expect("message") .contains("v8-runner build")); - assert_eq!(result.transport.as_deref(), Some("legacy")); + assert_eq!(result.transport, Some(LaunchMcpTransport::Mcp)); assert_eq!(result.mcp_port, Some(9874)); let args = fs::read_to_string(args_log).expect("args log"); assert!(args.contains("ENTERPRISE")); diff --git a/src/use_cases/mcp_ws.rs b/src/use_cases/mcp_ws.rs index 3c39bc1..9551422 100644 --- a/src/use_cases/mcp_ws.rs +++ b/src/use_cases/mcp_ws.rs @@ -1,11 +1,11 @@ //! Shared helpers for assembling the WS-mode `/C` payload that connects -//! 1C-clients to `v8-client-session-manager` instead of the legacy local +//! 1C-clients to `v8-client-session-manager` instead of the local //! HTTP MCP server. //! //! The actual `/C` payload is parsed by the BSL extension `client_mcp` (see //! `Мсп_ПараметрыЗапускаКлиент`). This module is responsible for: //! -//! * choosing between the new WS transport and the legacy HTTP transport, +//! * choosing between the new WS transport and the local HTTP MCP transport, //! * probing the manager's TCP socket when the transport is `auto`, //! * generating per-launch `client_uid`/`corr_id` values, //! * and serializing the final `key=value;...` snippet. @@ -13,7 +13,7 @@ //! Higher layers (`launch_app`, `run_tests`) decide where this snippet is //! merged into the final `/C` value. -use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::net::{IpAddr, SocketAddr, TcpStream}; use std::time::Duration; use uuid::Uuid; @@ -33,9 +33,9 @@ pub const PROBE_TIMEOUT_MS: u64 = 200; pub enum McpClientTransport { /// Force WS-only mode. Resolution fails if the manager is unreachable. Ws, - /// Force the legacy local HTTP transport (`runMcp[=...][;mcpPort=...]`). - Legacy, - /// Probe the manager: WS when reachable, legacy otherwise. + /// Force the local HTTP MCP transport (`runMcp[=...][;mcpPort=...]`). + Mcp, + /// Probe the manager: WS when reachable, local HTTP MCP otherwise. #[default] Auto, } @@ -44,7 +44,7 @@ impl McpClientTransport { pub fn from_str_value(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "ws" => Some(Self::Ws), - "legacy" => Some(Self::Legacy), + "mcp" => Some(Self::Mcp), "auto" => Some(Self::Auto), _ => None, } @@ -75,7 +75,15 @@ const ALLOWED_LOG_LEVELS: &[&str] = &["off", "error", "warn", "info", "debug", " /// Returns `true` when the value is one of the levels accepted by the devkit. pub fn is_supported_log_level(level: &str) -> bool { - ALLOWED_LOG_LEVELS.contains(&level) + ALLOWED_LOG_LEVELS + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(level)) +} + +/// Returns `true` when a value can be embedded into the semicolon-delimited +/// `/C` payload without changing its key/value structure. +pub fn is_payload_token_safe(value: &str) -> bool { + !value.contains([';', '=']) } /// Result of resolving the WS-mode connection parameters before launch. @@ -95,16 +103,23 @@ impl WsLaunchParams { pub fn payload_snippet(&self) -> String { format!( "mcpMode=ws;manager_url={};client_uid={};kind={};corr_id={};mcp_log_level={};mcp_ws_timeout_ms={}", - self.manager_url, - self.client_uid, + encode_payload_token(&self.manager_url), + encode_payload_token(&self.client_uid), self.kind.as_str(), - self.corr_id, - self.log_level, + encode_payload_token(&self.corr_id), + encode_payload_token(&self.log_level), self.ws_timeout_ms ) } } +fn encode_payload_token(value: &str) -> String { + value + .replace('%', "%25") + .replace(';', "%3B") + .replace('=', "%3D") +} + /// Inputs to [`resolve_ws_params`]. Each field carries either an explicit /// override (CLI takes precedence over config), or `None` to fall back to the /// internal defaults. @@ -139,6 +154,7 @@ pub fn resolve_ws_params(kind: ClientKind, inputs: WsResolveInputs) -> WsLaunchP let log_level = inputs .log_level .filter(|l| !l.trim().is_empty()) + .map(|level| level.to_ascii_lowercase()) .unwrap_or_else(|| DEFAULT_MCP_LOG_LEVEL.to_owned()); let ws_timeout_ms = inputs.ws_timeout_ms.unwrap_or(DEFAULT_MCP_WS_TIMEOUT_MS); WsLaunchParams { @@ -152,7 +168,10 @@ pub fn resolve_ws_params(kind: ClientKind, inputs: WsResolveInputs) -> WsLaunchP } fn default_corr_id(client_uid: &str) -> String { - let short: String = client_uid.chars().filter(|c| *c != '-').take(8).collect(); + let mut short: String = client_uid.chars().filter(|c| *c != '-').take(8).collect(); + while short.len() < 8 { + short.push('0'); + } format!("vr-{short}") } @@ -169,7 +188,7 @@ pub enum WsResolveError { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TransportDecision { Ws, - Legacy, + Mcp, } /// Selects the effective transport given the requested mode and a `probe` @@ -183,7 +202,7 @@ where F: FnOnce(SocketAddr) -> bool, { match requested { - McpClientTransport::Legacy => Ok(TransportDecision::Legacy), + McpClientTransport::Mcp => Ok(TransportDecision::Mcp), McpClientTransport::Ws => { let addr = parse_manager_addr(manager_url)?; if probe(addr) { @@ -199,7 +218,7 @@ where if probe(addr) { Ok(TransportDecision::Ws) } else { - Ok(TransportDecision::Legacy) + Ok(TransportDecision::Mcp) } } } @@ -211,8 +230,11 @@ pub fn probe_tcp(addr: SocketAddr, timeout: Duration) -> bool { TcpStream::connect_timeout(&addr, timeout).is_ok() } -/// Parses the `host:port` portion of a `ws://host:port/path` URL and resolves -/// it to a usable [`SocketAddr`]. Falls back to lookup via `to_socket_addrs`. +/// Parses the `host:port` portion of a `ws://host:port/path` URL. +/// +/// Hostnames are intentionally rejected: transport auto-detection must stay +/// bounded by the 200 ms TCP probe, and synchronous DNS lookup would not share +/// that timeout budget. pub fn parse_manager_addr(url: &str) -> Result { let trimmed = url.trim(); if trimmed.is_empty() { @@ -232,23 +254,26 @@ pub fn parse_manager_addr(url: &str) -> Result { reason: "missing host:port".to_owned(), }); } - if !host_port.contains(':') { + let Some((host, port)) = host_port.rsplit_once(':') else { return Err(WsResolveError::InvalidManagerUrl { url: url.to_owned(), reason: "missing :port".to_owned(), }); - } - host_port - .to_socket_addrs() + }; + let host = host.trim_matches(['[', ']']); + let ip = host + .parse::() + .map_err(|_| WsResolveError::InvalidManagerUrl { + url: url.to_owned(), + reason: "host must be an IP address".to_owned(), + })?; + let port = port + .parse::() .map_err(|err| WsResolveError::InvalidManagerUrl { url: url.to_owned(), reason: err.to_string(), - })? - .next() - .ok_or_else(|| WsResolveError::InvalidManagerUrl { - url: url.to_owned(), - reason: "address resolved to empty set".to_owned(), - }) + })?; + Ok(SocketAddr::new(ip, port)) } #[cfg(test)] @@ -263,9 +288,10 @@ mod tests { Some(McpClientTransport::Ws) ); assert_eq!( - McpClientTransport::from_str_value("LEGACY"), - Some(McpClientTransport::Legacy) + McpClientTransport::from_str_value("MCP"), + Some(McpClientTransport::Mcp) ); + assert_eq!(McpClientTransport::from_str_value("legacy"), None); assert_eq!( McpClientTransport::from_str_value("auto"), Some(McpClientTransport::Auto) @@ -292,25 +318,25 @@ mod tests { } #[test] - fn select_transport_legacy_short_circuits() { + fn select_transport_mcp_short_circuits() { let decision = select_transport( - McpClientTransport::Legacy, + McpClientTransport::Mcp, "ws://127.0.0.1:4000/sessions", |_| panic!("probe must not be called"), ) - .expect("legacy"); - assert_eq!(decision, TransportDecision::Legacy); + .expect("mcp"); + assert_eq!(decision, TransportDecision::Mcp); } #[test] - fn select_transport_auto_falls_back_to_legacy_when_unreachable() { + fn select_transport_auto_falls_back_to_mcp_when_unreachable() { let decision = select_transport( McpClientTransport::Auto, "ws://127.0.0.1:4000/sessions", |_| false, ) .expect("auto-fallback"); - assert_eq!(decision, TransportDecision::Legacy); + assert_eq!(decision, TransportDecision::Mcp); } #[test] @@ -344,14 +370,8 @@ mod tests { #[test] fn probe_tcp_fails_when_no_listener() { - // Bind to an ephemeral port and immediately drop the listener; the OS - // will reject connections to that port until reuse, which is enough - // for a unit-test. - let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral"); - let addr = listener.local_addr().expect("local addr"); - drop(listener); - // Allow a generous timeout — RST should arrive quickly. - let connected = probe_tcp(addr, Duration::from_millis(200)); + let addr = SocketAddr::from(([127, 0, 0, 1], 1)); + let connected = probe_tcp(addr, Duration::from_millis(50)); assert!(!connected); } @@ -385,6 +405,18 @@ mod tests { assert_eq!(params.kind, ClientKind::YaxunitRunner); } + #[test] + fn resolve_ws_params_pads_default_corr_id_for_short_uid() { + let params = resolve_ws_params( + ClientKind::V8RunnerClient, + WsResolveInputs { + client_uid: Some("abc".to_owned()), + ..Default::default() + }, + ); + assert_eq!(params.corr_id, "vr-abc00000"); + } + #[test] fn payload_snippet_contains_all_keys_in_order() { let params = WsLaunchParams { @@ -401,11 +433,28 @@ mod tests { ); } + #[test] + fn payload_snippet_encodes_delimiters() { + let params = WsLaunchParams { + manager_url: "ws://127.0.0.1:1/s".to_owned(), + client_uid: "uid=1".to_owned(), + kind: ClientKind::V8RunnerClient, + corr_id: "corr;1".to_owned(), + log_level: "info".to_owned(), + ws_timeout_ms: 1000, + }; + assert_eq!( + params.payload_snippet(), + "mcpMode=ws;manager_url=ws://127.0.0.1:1/s;client_uid=uid%3D1;kind=v8_runner_client;corr_id=corr%3B1;mcp_log_level=info;mcp_ws_timeout_ms=1000" + ); + } + #[test] fn is_supported_log_level_accepts_known_values() { for level in ["off", "error", "warn", "info", "debug", "trace"] { assert!(is_supported_log_level(level), "expected {level} supported"); } + assert!(is_supported_log_level("INFO")); assert!(!is_supported_log_level("verbose")); } diff --git a/src/use_cases/request.rs b/src/use_cases/request.rs index c0dcac6..6524a2c 100644 --- a/src/use_cases/request.rs +++ b/src/use_cases/request.rs @@ -619,7 +619,7 @@ pub struct LaunchRequest { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum McpClientTransportRequest { Ws, - Legacy, + Mcp, Auto, } diff --git a/src/use_cases/run_tests/coordinator.rs b/src/use_cases/run_tests/coordinator.rs index 6f96ce1..646e95c 100644 --- a/src/use_cases/run_tests/coordinator.rs +++ b/src/use_cases/run_tests/coordinator.rs @@ -228,7 +228,25 @@ pub(super) fn run_tests( let enterprise_runner = crate::platform::process::ProcessExecutor; let mut platform_launch = build_platform_launch(&args.execution.launch, &prepared_run, &artifacts); - apply_test_mcp_ws_payload(config, &args.mcp_ws, &prepared_run, &mut platform_launch); + if let Err(error) = + apply_test_mcp_ws_payload(config, &args.mcp_ws, &prepared_run, &mut platform_launch) + { + let outcome = ExecutionOutcome::new(ExecutionStatus::Failed) + .with_diagnostics(vec![error.to_string()]) + .with_errors(vec![test_execution_error( + TestErrorKind::TestSetupFailed, + error.to_string(), + )]); + let result = make_test_result( + target, + mode, + outcome, + warnings, + steps, + started.elapsed().as_millis() as u64, + ); + return Err(TestExecutionFailure::with_payload(error, result)); + } let enterprise = match build_enterprise_dsl( context, config, diff --git a/src/use_cases/run_tests/helpers.rs b/src/use_cases/run_tests/helpers.rs index 71d6c2b..e0a0f38 100644 --- a/src/use_cases/run_tests/helpers.rs +++ b/src/use_cases/run_tests/helpers.rs @@ -431,32 +431,25 @@ pub(super) fn append_mcp_ws_snippet(launch: &mut LaunchOptions, snippet: &str) { /// `mcpMode=ws;...` snippet to the platform `/C` so the BSL devkit registers /// with `v8-client-session-manager` instead of starting a local HTTP MCP. /// -/// Errors from the resolution layer (e.g. invalid `manager_url`) are logged -/// and treated as "no WS snippet"; the test run still proceeds with its -/// regular `/C` payload. This preserves the previous behavior when the -/// session-manager is not used. +/// Errors from explicit WS resolution are returned to the caller. Auto mode +/// still falls back to the regular `/C` payload when the manager is down. pub(super) fn apply_test_mcp_ws_payload( config: &AppConfig, mcp_ws: &crate::use_cases::request::McpClientWsRequest, prepared_run: &PreparedRun, launch: &mut LaunchOptions, -) { +) -> Result<(), AppError> { let kind = match prepared_run { PreparedRun::YaXUnit => crate::use_cases::mcp_ws::ClientKind::YaxunitRunner, PreparedRun::Vanessa { .. } => crate::use_cases::mcp_ws::ClientKind::VanessaTestClient, }; - let decision = match crate::use_cases::launch_app::decide_mcp_transport(config, mcp_ws) { - Ok(d) => d, - Err(err) => { - tracing::warn!(error = %err, "failed to resolve MCP client transport for test run"); - return; - } - }; + let decision = crate::use_cases::launch_app::decide_mcp_transport(config, mcp_ws)?; if !matches!(decision, crate::use_cases::mcp_ws::TransportDecision::Ws) { - return; + return Ok(()); } let params = crate::use_cases::launch_app::resolve_ws_launch_params(config, mcp_ws, kind); append_mcp_ws_snippet(launch, ¶ms.payload_snippet()); + Ok(()) } pub(super) fn collect_diagnostics( diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index a79549f..532a669 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -384,7 +384,7 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { "--mcp-config", "/tmp/mcp conf.json", "--mcp-transport", - "legacy", + "mcp", "--raw-key", "/WA-", ]) @@ -400,7 +400,7 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { ); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); assert_eq!(payload["data"]["mode"], "mcp"); - assert_eq!(payload["data"]["transport"], "legacy"); + assert_eq!(payload["data"]["transport"], "mcp"); assert_eq!(payload["data"]["mcp_port"], 9874); assert_eq!( payload["data"]["binary"].as_str().expect("binary"), @@ -709,7 +709,7 @@ fn setup_mcp_project_with_logging_thin() -> (tempfile::TempDir, PathBuf, PathBuf } #[test] -fn launch_mcp_legacy_transport_emits_runmcp_payload_and_legacy_envelope() { +fn launch_mcp_transport_emits_runmcp_payload_and_mcp_envelope() { let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); let output = v8_runner_command() .args([ @@ -719,7 +719,7 @@ fn launch_mcp_legacy_transport_emits_runmcp_payload_and_legacy_envelope() { "launch", "mcp", "--mcp-transport", - "legacy", + "mcp", "--mcp-port", "9999", ]) @@ -731,7 +731,7 @@ fn launch_mcp_legacy_transport_emits_runmcp_payload_and_legacy_envelope() { String::from_utf8_lossy(&output.stderr) ); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); - assert_eq!(payload["data"]["transport"], "legacy"); + assert_eq!(payload["data"]["transport"], "mcp"); assert_eq!(payload["data"]["mcp_port"], 9999); assert!(payload["data"]["client_uid"].is_null()); @@ -832,7 +832,7 @@ fn launch_mcp_ws_required_fails_when_manager_unreachable() { } #[test] -fn launch_mcp_auto_falls_back_to_legacy_when_manager_unreachable() { +fn launch_mcp_auto_falls_back_to_mcp_when_manager_unreachable() { let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); let port = listener.local_addr().expect("addr").port(); @@ -854,7 +854,7 @@ fn launch_mcp_auto_falls_back_to_legacy_when_manager_unreachable() { .expect("run command"); assert!(output.status.success()); let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); - assert_eq!(payload["data"]["transport"], "legacy"); + assert_eq!(payload["data"]["transport"], "mcp"); let args = fs::read_to_string(args_log).expect("args log"); assert!(args.contains("/C\"runMcp\"")); assert!(!args.contains("mcpMode=ws")); diff --git a/v8-runner/SKILL.md b/v8-runner/SKILL.md index e76caea..9451bfb 100644 --- a/v8-runner/SKILL.md +++ b/v8-runner/SKILL.md @@ -75,7 +75,7 @@ v8-runner init - Release artifacts need to be exported or external artifacts published: use `v8-runner make ...` or the `artifacts` alias. - Need a 1C UI session: use `v8-runner launch designer`, `launch thin`, `launch thick`, or `launch ordinary`. - Need onec-client-mcp-devkit launched inside 1C without VA authoring: use `v8-runner launch mcp ...`. -- Pair the launched 1С-client with a running [v8-client-session-manager](https://github.com/SteelMorgan/v8-client-session-manager) over WebSocket: rely on `--mcp-transport=auto` (default — TCP-probes `manager_url` for 200 ms). Force WS with `--mcp-transport=ws` (fails if manager is down) or skip WS entirely with `--mcp-transport=legacy`. WS-only flags: `--manager-url`, `--client-uid`, `--corr-id`, `--mcp-log-level`, `--mcp-ws-timeout-ms`. The internal `kind` mapping (`v8_runner_client` / `vanessa_test_client` / `yaxunit_runner` / `vanessa_test_client`) is fixed by entry-point and **not** overridable from CLI. Read `references/project-workflows.md` (section «WS-режим к session-manager») for the full payload, defaults, and `--json-message` shape. +- Pair the launched 1С-client with a running [v8-client-session-manager](https://github.com/SteelMorgan/v8-client-session-manager) over WebSocket: rely on `--mcp-transport=auto` (default — TCP-probes `manager_url` for 200 ms). Force WS with `--mcp-transport=ws` (fails if manager is down) or use local HTTP MCP with `--mcp-transport=mcp`. WS-only flags: `--manager-url`, `--client-uid`, `--corr-id`, `--mcp-log-level`, `--mcp-ws-timeout-ms`. The internal `kind` mapping (`v8_runner_client` / `vanessa_test_client` / `yaxunit_runner` / `vanessa_test_client`) is fixed by entry-point and **not** overridable from CLI. Read `references/project-workflows.md` (section «WS-режим к session-manager») for the full payload, defaults, and `--json-message` shape. ## Guardrails diff --git a/v8-runner/references/bootstrap.md b/v8-runner/references/bootstrap.md index 29b6288..265136d 100644 --- a/v8-runner/references/bootstrap.md +++ b/v8-runner/references/bootstrap.md @@ -45,7 +45,7 @@ Ask the user only if the choice is ambiguous and you cannot tell from `tools.pla ### 4. Decide infobase connection -`--connection` is the connection string written into `tools.connection`. Three common shapes: +`--connection` is the connection string written into `infobase.connection`. Three common shapes: | Shape | Example | When to use | |---|---|---| diff --git a/v8-runner/references/command-selection.md b/v8-runner/references/command-selection.md index 63935fb..a989f5b 100644 --- a/v8-runner/references/command-selection.md +++ b/v8-runner/references/command-selection.md @@ -166,8 +166,8 @@ WS-mode flags (when v8-client-session-manager is reachable): ```bash v8-runner launch mcp --mcp-transport=ws --manager-url ws://127.0.0.1:4000/sessions -v8-runner launch mcp --mcp-transport=legacy # force legacy without probe +v8-runner launch mcp --mcp-transport=mcp # force local MCP without probe v8-runner launch mcp --mcp-log-level=debug --client-uid --corr-id ``` -`--mcp-transport=auto` (default) probes `manager_url` for 200 ms and chooses `ws` on success, `legacy` on failure. The same WS-flags work on `test yaxunit ...` and `test va ...`. See `project-workflows.md` for the full WS-режим section, internal `kind` mapping, and `--json-message` output shape. +`--mcp-transport=auto` (default) probes `manager_url` for 200 ms and chooses `ws` on success, `mcp` on failure. The same WS-flags work on `test yaxunit ...` and `test va ...`. See `project-workflows.md` for the full WS-режим section, internal `kind` mapping, and `--json-message` output shape. diff --git a/v8-runner/references/project-workflows.md b/v8-runner/references/project-workflows.md index 9786ef1..513236c 100644 --- a/v8-runner/references/project-workflows.md +++ b/v8-runner/references/project-workflows.md @@ -135,17 +135,17 @@ For `launch mcp va`, read `testing.md`; it is part of the Vanessa Automation deb ## WS-режим к session-manager -Когда рядом с проектом запущен [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager), 1С-клиент может подключаться к нему по WebSocket вместо локального HTTP MCP-сервера (legacy `runMcp`-режим). v8-runner делает выбор автоматически. +Когда рядом с проектом запущен [`v8-client-session-manager`](https://github.com/SteelMorgan/v8-client-session-manager), 1С-клиент может подключаться к нему по WebSocket вместо локального HTTP MCP-сервера (`runMcp`-режим). v8-runner делает выбор автоматически. ### Транспорт и автоопределение `tools.client_mcp.transport`: -- `auto` (по умолчанию) — короткий TCP-probe (200 ms) на хост:порт из `manager_url`. Слышим listener → WS, нет → legacy. +- `auto` (по умолчанию) — короткий TCP-probe (200 ms) на IP:порт из `manager_url`. Слышим listener → WS, нет → MCP. - `ws` — строго WS, при недоступности менеджера запуск падает с `session-manager unreachable at `. -- `legacy` — старый HTTP-режим без probe. +- `mcp` — локальный HTTP MCP-режим без probe. -Override через `--mcp-transport={ws|legacy|auto}`. CLI приоритет конфига. +Override через `--mcp-transport={ws|mcp|auto}`. CLI приоритет конфига. ### Что v8-runner подставляет в `/C` в WS-ветке @@ -187,13 +187,13 @@ WS-ветка: ```json { "transport": "ws", "client_uid": "...", "kind": "...", "manager_url": "...", "corr_id": "..." } ``` -Legacy-ветка: +MCP-ветка: ```json -{ "transport": "legacy", "mcp_port": 9874 } +{ "transport": "mcp", "mcp_port": 9874 } ``` Внешний оркестратор (CI, AI-агент) использует `client_uid` для поиска сессии в `session_list` менеджера. ### Менеджер не запускается из v8-runner -v8-runner только подключается к запущенному менеджеру. Подъём менеджера — отдельный шаг (`cargo run --release` в репо `v8-client-session-manager`, либо systemd-юнит `systemd/v8-session-manager.service`, либо Docker-compose). Если менеджер не нужен — `--mcp-transport=legacy` форсирует старый flow. +v8-runner только подключается к запущенному менеджеру. Подъём менеджера — отдельный шаг (`cargo run --release` в репо `v8-client-session-manager`, либо systemd-юнит `systemd/v8-session-manager.service`, либо Docker-compose). Если менеджер не нужен — `--mcp-transport=mcp` форсирует локальный MCP flow. From cbaf405442a07a4e79dd3664d96019d2620fc0d6 Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Wed, 13 May 2026 13:10:10 +0000 Subject: [PATCH 12/15] fix(mcp): address transport review notes - clarify WS timeout documentation\n- include manager_url parse diagnostics\n- remove legacy wording from public docs --- docs/CONFIGURATION.md | 4 ++-- src/cli/execute.rs | 4 ++-- v8-runner/references/bootstrap.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4ab7a49..61443f8 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -454,8 +454,8 @@ runtime identity и не добавляет отдельное поле конф по умолчанию `ws://127.0.0.1:4000/sessions`. - `log_level` (`off`/`error`/`warn`/`info`/`debug`/`trace`) — значение `mcp_log_level`, передаваемое в `/C` BSL-расширению `client_mcp`. -- `ws_timeout_ms` — значение `mcp_ws_timeout_ms` (таймаут установки WS-сессии в режиме `auto`, - > 0). +- `ws_timeout_ms` — значение `mcp_ws_timeout_ms`, таймаут установки WS-сессии + в миллисекундах (> 0). `launch mcp` передаёт `port` как `mcpPort` внутри `/C"runMcp..."` если CLI не указал `--mcp-port` и выбран `mcp`/`auto`-fallback транспорт. diff --git a/src/cli/execute.rs b/src/cli/execute.rs index 7126862..5310a91 100644 --- a/src/cli/execute.rs +++ b/src/cli/execute.rs @@ -1231,10 +1231,10 @@ fn map_mcp_ws_args( "--manager-url must not contain ';' or '=' because the /C payload is semicolon-delimited", )); } - if crate::use_cases::mcp_ws::parse_manager_addr(url).is_err() { + if let Err(error) = crate::use_cases::mcp_ws::parse_manager_addr(url) { return Err(UseCaseError::new( UseCaseErrorKind::Validation, - format!("--manager-url must include host:port (got: {url})"), + format!("--manager-url parse error: {error}"), )); } } diff --git a/v8-runner/references/bootstrap.md b/v8-runner/references/bootstrap.md index 265136d..557659b 100644 --- a/v8-runner/references/bootstrap.md +++ b/v8-runner/references/bootstrap.md @@ -39,7 +39,7 @@ If the format is unambiguously one or the other, you may pass `--format=designer Defaults to `DESIGNER`. Switch to `IBCMD` only when: - the project is `EDT` and the team is on a platform version where `ibcmd` is supported and faster (≥ 8.3.20); **and** -- there is no Designer-only feature on the critical path (some legacy project tasks still need the Designer GUI). +- there is no Designer-only feature on the critical path (some older project tasks still need the Designer GUI). Ask the user only if the choice is ambiguous and you cannot tell from `tools.platform.version`. Default to `DESIGNER` when in doubt — it is the safer baseline. From eb619285f70a8b01ea8fef766add1693fac971da Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Wed, 13 May 2026 10:34:57 +0000 Subject: [PATCH 13/15] fix(launch): split /C and payload into separate argv tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/C"value"` was passed as a single argv element with literal `"` chars in the payload — `std::process::Command` bypasses the shell on both Linux (execve) and Windows (CreateProcess), so the platform received `/C"…"` as one unknown key and replied with «Неверные или отсутствующие параметры соединения с информационной базой». Splitting into `/C` and the payload as two argv tokens makes the platform parse `/C` as the command-line parameter key and the next token as its value. Tests adjusted to match the new format: argv pairs `["/C", ""]` in unit tests and `/C\n\n` in the integration args.log dump (printf '%s\n' "$@" puts each argv on its own line). Recovered from a dangling stash (cdb1e0a0 "fix/c-arg-quoting WIP") that had been authored locally but never committed; rebuilds lost the patch. --- src/platform/enterprise.rs | 27 +++++++++++++++------------ src/use_cases/launch_app.rs | 2 +- tests/cli_launch.rs | 8 ++++---- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/platform/enterprise.rs b/src/platform/enterprise.rs index 08d8b7f..46acd34 100644 --- a/src/platform/enterprise.rs +++ b/src/platform/enterprise.rs @@ -157,7 +157,14 @@ pub fn build_launch_args( args.push(execute.clone()); } if let Some(c) = &launch.c { - args.push(quoted_c_arg(c)); + // `/C` and its payload must go as two separate argv tokens. + // std::process::Command bypasses the shell on both Linux (execve) and + // Windows (CreateProcess), so any surrounding `"..."` quoting would end + // up inside the value itself — the platform then sees `/C"…"` as a + // single unrecognized key and fails with «Неверные или отсутствующие + // параметры соединения с информационной базой». + args.push("/C".to_owned()); + args.push(c.clone()); } let mut extra_args = Vec::new(); @@ -182,10 +189,6 @@ fn effective_out_path(launch: &LaunchOptions) -> Option<&str> { launch.internal_out.as_deref().or(launch.out.as_deref()) } -fn quoted_c_arg(payload: &str) -> String { - format!("/C\"{payload}\"") -} - fn filtered_raw_launch_args(args: &[String]) -> Vec { let mut filtered = Vec::new(); let mut skip_value = false; @@ -262,9 +265,8 @@ mod tests { assert_eq!(args[1], "/DisableStartupDialogs"); assert!(args.iter().any(|arg| arg == "/TESTMANAGER")); assert!(args - .iter() - .any(|arg| arg == "/C\"RunUnitTests=/tmp/path with space/тест config.json\"")); - assert!(!args.iter().any(|arg| arg == "/C")); + .windows(2) + .any(|pair| pair == ["/C", "RunUnitTests=/tmp/path with space/тест config.json"])); assert!(args.iter().any(|arg| arg == "/Out")); } @@ -288,10 +290,11 @@ mod tests { .iter() .any(|arg| arg == "/tmp/va/vanessa automation.epf")); assert!(args.iter().any(|arg| arg == "/TESTMANAGER")); - assert!(args - .iter() - .any(|arg| arg == "/C\"StartFeaturePlayer;VAParams=/tmp/va/va-params.json\"")); - assert!(!args.iter().any(|arg| arg == "/C")); + assert!(args.windows(2).any(|pair| pair + == [ + "/C", + "StartFeaturePlayer;VAParams=/tmp/va/va-params.json", + ])); } #[test] diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index d055c11..b3f1ed2 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -559,7 +559,7 @@ mod tests { assert_eq!(result.mcp_port, Some(9874)); let args = fs::read_to_string(args_log).expect("args log"); assert!(args.contains("ENTERPRISE")); - assert!(args.contains("/C\"runMcp;mcpPort=9874\"")); + assert!(args.contains("/C\nrunMcp;mcpPort=9874\n")); assert!(!args.contains("/LoadCfg")); assert!(!args.contains("-Extension")); } diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index 532a669..9a34616 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -361,7 +361,7 @@ fn launch_ordinary_supports_typed_keys_and_filters_reserved_raw_duplicates() { assert!(args.contains("/UsePrivilegedMode")); assert!(args.contains("/Execute")); assert!(args.contains("/tmp/tool.epf")); - assert!(args.contains("/C\"DoWork\"")); + assert!(args.contains("/C\nDoWork\n")); assert!(args.contains("DoWork")); assert!(args.contains("/WA-")); assert!(args.contains("/tmp/user.out.log")); @@ -413,7 +413,7 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { assert!(args.contains("/RunModeOrdinaryApplication")); assert!(args.contains("/Execute")); assert!(args.contains("vanessa-automation.epf")); - assert!(args.contains("/C\"runMcp=/tmp/mcp conf.json;mcpPort=9874;VAParams=")); + assert!(args.contains("/C\nrunMcp=/tmp/mcp conf.json;mcpPort=9874;VAParams=")); assert!(!args.contains("StartFeaturePlayer")); assert!(args.contains("/TESTMANAGER")); assert!(args.contains("/WA-")); @@ -736,7 +736,7 @@ fn launch_mcp_transport_emits_runmcp_payload_and_mcp_envelope() { assert!(payload["data"]["client_uid"].is_null()); let args = fs::read_to_string(args_log).expect("args log"); - assert!(args.contains("/C\"runMcp;mcpPort=9999\"")); + assert!(args.contains("/C\nrunMcp;mcpPort=9999\n")); assert!(!args.contains("mcpMode=ws")); } @@ -856,7 +856,7 @@ fn launch_mcp_auto_falls_back_to_mcp_when_manager_unreachable() { let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); assert_eq!(payload["data"]["transport"], "mcp"); let args = fs::read_to_string(args_log).expect("args log"); - assert!(args.contains("/C\"runMcp\"")); + assert!(args.contains("/C\nrunMcp\n")); assert!(!args.contains("mcpMode=ws")); } From b34a4999e8cfd5f8f49868e5b71f020b869a4f8a Mon Sep 17 00:00:00 2001 From: steel-code-agent Date: Fri, 26 Jun 2026 13:30:44 +0300 Subject: [PATCH 14/15] fix(launch): pass WS payload for plain client launches Plain enterprise launches now honor MCP WS flags by appending mcpMode=ws to /C without a kind field. Specialized launch mcp flows keep the existing kind-bearing payload. Tests: CC=/usr/bin/gcc cargo test --test cli_launch --- src/use_cases/launch_app.rs | 61 +++++++++++++++++++++++ src/use_cases/mcp_ws.rs | 13 +++++ tests/cli_launch.rs | 96 +++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) diff --git a/src/use_cases/launch_app.rs b/src/use_cases/launch_app.rs index b3f1ed2..09258be 100644 --- a/src/use_cases/launch_app.rs +++ b/src/use_cases/launch_app.rs @@ -129,12 +129,19 @@ fn apply_mcp_resolution_to_result(result: &mut LaunchResult, meta: McpResolution result.transport = Some(LaunchMcpTransport::Mcp); result.mcp_port = port; } + McpResolutionMeta::DirectWs(params) => { + result.transport = Some(LaunchMcpTransport::Ws); + result.client_uid = Some(params.client_uid); + result.manager_url = Some(params.manager_url); + result.corr_id = Some(params.corr_id); + } } } #[derive(Debug, Clone)] enum McpResolutionMeta { Ws(WsLaunchParams), + DirectWs(WsLaunchParams), Mcp { port: Option }, } @@ -230,6 +237,8 @@ fn effective_launch_options( Err(AppError::Validation( "launch mcp requires client_mcp options".to_owned(), )) + } else if direct_mcp_ws_requested(&args.mcp_ws) { + apply_direct_mcp_ws_launch(config, args) } else { Ok((args.launch.clone(), None)) }; @@ -274,6 +283,58 @@ fn effective_launch_options( Ok((launch, Some(meta))) } +fn direct_mcp_ws_requested(cli: &McpClientWsRequest) -> bool { + cli.transport.is_some() + || cli.manager_url.is_some() + || cli.client_uid.is_some() + || cli.corr_id.is_some() + || cli.log_level.is_some() + || cli.ws_timeout_ms.is_some() +} + +fn apply_direct_mcp_ws_launch( + config: &AppConfig, + args: &LaunchArgs, +) -> Result<(LaunchOptions, Option), AppError> { + match args.target { + LaunchTargetRequest::Enterprise(EnterpriseLaunchTarget::ThinClient) + | LaunchTargetRequest::Enterprise(EnterpriseLaunchTarget::ThickClient) + | LaunchTargetRequest::Enterprise(EnterpriseLaunchTarget::OrdinaryApplication) => {} + LaunchTargetRequest::Designer + | LaunchTargetRequest::Enterprise(EnterpriseLaunchTarget::ClientMcp { .. }) => { + return Err(AppError::Validation( + "MCP WS launch options are supported only for enterprise client launches or launch mcp" + .to_owned(), + )); + } + } + + let decision = decide_mcp_transport(config, &args.mcp_ws)?; + match decision { + TransportDecision::Ws => { + let params = resolve_ws_launch_params(config, &args.mcp_ws, ClientKind::V8RunnerClient); + let mut launch = args.launch.clone(); + append_launch_payload(&mut launch, ¶ms.payload_snippet_without_kind()); + Ok((launch, Some(McpResolutionMeta::DirectWs(params)))) + } + TransportDecision::Mcp => Err(AppError::Validation( + "--mcp-transport=mcp is supported only for launch mcp".to_owned(), + )), + } +} + +fn append_launch_payload(launch: &mut LaunchOptions, snippet: &str) { + match &mut launch.c { + Some(payload) if !payload.is_empty() => { + payload.push(';'); + payload.push_str(snippet); + } + _ => { + launch.c = Some(snippet.to_owned()); + } + } +} + fn launch_mcp_client_kind(client_mcp: &ClientMcpOptionsRequest) -> ClientKind { match client_mcp.addon { Some(ClientMcpAddonRequest::VanessaAutomation) => ClientKind::VanessaTestClient, diff --git a/src/use_cases/mcp_ws.rs b/src/use_cases/mcp_ws.rs index 9551422..06f0586 100644 --- a/src/use_cases/mcp_ws.rs +++ b/src/use_cases/mcp_ws.rs @@ -111,6 +111,19 @@ impl WsLaunchParams { self.ws_timeout_ms ) } + + /// Builds a WS snippet for a plain 1C client launch. In this mode the + /// client-side extension announces its own kind during `session.register`. + pub fn payload_snippet_without_kind(&self) -> String { + format!( + "mcpMode=ws;manager_url={};client_uid={};corr_id={};mcp_log_level={};mcp_ws_timeout_ms={}", + encode_payload_token(&self.manager_url), + encode_payload_token(&self.client_uid), + encode_payload_token(&self.corr_id), + encode_payload_token(&self.log_level), + self.ws_timeout_ms + ) + } } fn encode_payload_token(value: &str) -> String { diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index 9a34616..119ce72 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -799,6 +799,102 @@ fn launch_mcp_ws_transport_with_listener_emits_ws_payload_and_ws_envelope() { assert!(!args.contains("runMcp")); } +#[test] +fn launch_thin_ws_transport_emits_ws_payload_and_ws_envelope() { + let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let manager_url = format!( + "ws://127.0.0.1:{}/sessions", + listener.local_addr().expect("addr").port() + ); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "thin", + "--mcp-transport", + "ws", + "--manager-url", + &manager_url, + "--mcp-log-level", + "debug", + "--mcp-ws-timeout-ms", + "2500", + "--client-uid", + "00000000-0000-0000-0000-000000000def", + "--corr-id", + "vr-thin", + ]) + .output() + .expect("run command"); + drop(listener); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["mode"], "thin"); + assert_eq!(payload["data"]["transport"], "ws"); + assert_eq!( + payload["data"]["client_uid"], + "00000000-0000-0000-0000-000000000def" + ); + assert!(payload["data"]["kind"].is_null()); + assert_eq!(payload["data"]["manager_url"], manager_url); + assert_eq!(payload["data"]["corr_id"], "vr-thin"); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/C\nmcpMode=ws;")); + assert!(args.contains("client_uid=00000000-0000-0000-0000-000000000def")); + assert!(!args.contains("kind=")); + assert!(args.contains(&format!("manager_url={manager_url}"))); + assert!(args.contains("corr_id=vr-thin")); + assert!(args.contains("mcp_log_level=debug")); + assert!(args.contains("mcp_ws_timeout_ms=2500")); + assert!(!args.contains("runMcp")); +} + +#[test] +fn launch_thin_ws_transport_appends_to_existing_c_payload() { + let (_dir, config_path, args_log) = setup_mcp_project_with_logging_thin(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let manager_url = format!( + "ws://127.0.0.1:{}/sessions", + listener.local_addr().expect("addr").port() + ); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "launch", + "thin", + "--c", + "DoWork=1", + "--mcp-transport", + "ws", + "--manager-url", + &manager_url, + "--client-uid", + "00000000-0000-0000-0000-000000000123", + ]) + .output() + .expect("run command"); + drop(listener); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/C\nDoWork=1;mcpMode=ws;")); + assert!(args.contains("client_uid=00000000-0000-0000-0000-000000000123")); + assert!(!args.contains("kind=")); +} + #[test] fn launch_mcp_ws_required_fails_when_manager_unreachable() { let (_dir, config_path, _args_log) = setup_mcp_project_with_logging_thin(); From aa3e45ac52132e3e856d183773db00847a99e6ca Mon Sep 17 00:00:00 2001 From: steelmorgan Date: Sat, 27 Jun 2026 14:31:22 +0000 Subject: [PATCH 15/15] fix(launch): start VA MCP as test manager Launch mcp va now mirrors the canonical Vanessa manager startup: /TESTMANAGER, unsafe-action protection disabled, /Execute vanessa-automation.epf, and VAParams without StartFeaturePlayer. WS mode publishes kind=vanessa_test_client and keeps the detached process alive in its own session. --- docs/CAPABILITIES.md | 5 +- docs/CONFIGURATION.md | 11 ++ src/platform/enterprise.rs | 21 ++-- src/platform/process.rs | 54 ++++++++++ src/use_cases/launch_keys.rs | 18 +++- src/use_cases/vanessa.rs | 31 +++++- tests/cli_launch.rs | 193 ++++++++++++++++++++++++++++++++++- tests/cli_test.rs | 19 +++- v8-runner/SKILL.md | 27 ++++- 9 files changed, 352 insertions(+), 27 deletions(-) diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 91dbae4..5018f9a 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -246,7 +246,10 @@ v8-runner launch mcp [va] [--mode ] [FLAGS] - `launch mcp --mode thick` использует `1cv8`; `launch mcp --mode ordinary` использует `1cv8` и добавляет `/RunModeOrdinaryApplication`. - `launch mcp va` дополнительно запускает Vanessa Automation из `tools.va` через `/Execute ` - и передаёт `VAParams=` без `StartFeaturePlayer`. + как тест-менеджер для MCP tools: добавляет `/TESTMANAGER` и + `/DisableUnsafeActionProtection`; WS-режим передаёт + `mcpMode=ws;...;VAParams=`, а local MCP fallback передаёт + `runMcp...;VAParams=` без `StartFeaturePlayer`. - Любой управляемый runner payload для ключа `/C` передаётся как один аргумент `/C""`: это касается `launch --c`, `launch mcp`, `test yaxunit` и `test va`. - Для `mcp` доступны typed flags `--mcp-config ` и `--mcp-port `; diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 61443f8..730ab91 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -479,6 +479,17 @@ v8-runner собирает `/C` так: | `test yaxunit ...` | `yaxunit_runner` | | `test va ...` | `vanessa_test_client` | +`launch mcp va` использует `kind=vanessa_test_client`: это режим менеджера Vanessa +для обследования/authoring. Runner добавляет `/TESTMANAGER`, +`/DisableUnsafeActionProtection` и `/Execute `. +В WS-режиме `/C` должен повторять проверенную строку +`mcpMode=ws;...;VAParams=`. +При `mcp`/fallback используется локальный HTTP MCP payload `runMcp...;VAParams=...`; +для него runner генерирует runtime `VAParams` и принудительно выставляет +`ВыполнитьСценарии=false`, `ЗавершитьРаботуСистемы=false`, +`ЗакрытьTestClientПослеЗапускаСценариев=false`, +`ЗакрыватьКлиентТестированияПринудительно=false`. + Для тестовых запусков (`test yaxunit`, `test va`) WS-фрагмент **дописывается** к существующему `/C` (`RunUnitTests=...` или Vanessa-плеер) через `;`. На стороне платформы все ключи парсятся как `key=value` пары. diff --git a/src/platform/enterprise.rs b/src/platform/enterprise.rs index 46acd34..f187b89 100644 --- a/src/platform/enterprise.rs +++ b/src/platform/enterprise.rs @@ -144,6 +144,9 @@ pub fn build_launch_args( } } .to_owned()]; + if !matches!(mode, LaunchClientMode::Designer) { + args.extend(filtered_raw_launch_args(additional_launch_keys)); + } args.push("/DisableStartupDialogs".to_owned()); args.extend(connection.args()); if matches!(mode, LaunchClientMode::Ordinary) { @@ -168,9 +171,6 @@ pub fn build_launch_args( } let mut extra_args = Vec::new(); - if !matches!(mode, LaunchClientMode::Designer) { - extra_args.extend(filtered_raw_launch_args(additional_launch_keys)); - } extra_args.extend(filtered_raw_launch_args(&launch.raw_args)); args.extend(extra_args); @@ -262,8 +262,13 @@ mod tests { ); assert_eq!(args[0], "ENTERPRISE"); - assert_eq!(args[1], "/DisableStartupDialogs"); assert!(args.iter().any(|arg| arg == "/TESTMANAGER")); + let position = |needle: &str| { + args.iter() + .position(|arg| arg == needle) + .unwrap_or_else(|| panic!("{needle} argument")) + }; + assert!(position("/TESTMANAGER") < position("/DisableStartupDialogs")); assert!(args .windows(2) .any(|pair| pair == ["/C", "RunUnitTests=/tmp/path with space/тест config.json"])); @@ -290,11 +295,9 @@ mod tests { .iter() .any(|arg| arg == "/tmp/va/vanessa automation.epf")); assert!(args.iter().any(|arg| arg == "/TESTMANAGER")); - assert!(args.windows(2).any(|pair| pair - == [ - "/C", - "StartFeaturePlayer;VAParams=/tmp/va/va-params.json", - ])); + assert!(args + .windows(2) + .any(|pair| pair == ["/C", "StartFeaturePlayer;VAParams=/tmp/va/va-params.json",])); } #[test] diff --git a/src/platform/process.rs b/src/platform/process.rs index f86f9f3..ee7992f 100644 --- a/src/platform/process.rs +++ b/src/platform/process.rs @@ -344,6 +344,18 @@ fn build_command(request: &ProcessRequest, io_mode: ProcessIoMode) -> Command { ProcessIoMode::Detached => { cmd.stdout(Stdio::null()); cmd.stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } } ProcessIoMode::Captured => { cmd.stdout(Stdio::piped()); @@ -760,6 +772,48 @@ mod tests { assert_eq!(result.binary, script); } + #[cfg(unix)] + #[test] + fn spawn_detached_starts_new_session_and_process_group() { + let dir = tempdir().expect("tempdir"); + let script = dir.path().join("identity.sh"); + let identity_log = dir.path().join("identity.log"); + write_script( + &script, + "pid=$$\nsid=$(ps -o sid= -p \"$pid\" | tr -d ' ')\npgid=$(ps -o pgid= -p \"$pid\" | tr -d ' ')\nprintf '%s %s %s\\n' \"$pid\" \"$sid\" \"$pgid\" > \"$1\"\nsleep 0.2", + ); + + let runner = ProcessExecutor; + let result = runner + .spawn(&ProcessRequest { + program: script, + args: vec![identity_log.display().to_string()], + workdir: None, + stdout_log_path: None, + stderr_log_path: None, + startup_probe: Some(Duration::from_millis(50)), + }) + .expect("spawn"); + + let mut identity = String::new(); + for _ in 0..40 { + match fs::read_to_string(&identity_log) { + Ok(value) => { + identity = value; + break; + } + Err(_) => thread::sleep(Duration::from_millis(25)), + } + } + assert!(!identity.is_empty(), "identity log was not written"); + let parts: Vec = identity + .split_whitespace() + .map(|part| part.parse::().expect("numeric identity")) + .collect(); + + assert_eq!(parts, vec![result.pid, result.pid, result.pid]); + } + #[cfg(unix)] #[test] fn spawn_detects_immediate_exit_when_probe_is_requested() { diff --git a/src/use_cases/launch_keys.rs b/src/use_cases/launch_keys.rs index dd573c7..6894d2d 100644 --- a/src/use_cases/launch_keys.rs +++ b/src/use_cases/launch_keys.rs @@ -1,18 +1,28 @@ use crate::domain::runner::LaunchOptions; pub(crate) const VANESSA_TEST_MANAGER_ARG: &str = "/TESTMANAGER"; +pub(crate) const DISABLE_UNSAFE_ACTION_PROTECTION_ARG: &str = "/DisableUnsafeActionProtection"; pub(crate) fn vanessa_enterprise_launch_keys( config_keys: &[String], launch: &LaunchOptions, ) -> Vec { let mut keys = config_keys.to_vec(); - if has_launch_key(&launch.raw_args, VANESSA_TEST_MANAGER_ARG) { - remove_launch_key(&mut keys, VANESSA_TEST_MANAGER_ARG); + ensure_vanessa_launch_key(&mut keys, &launch.raw_args, VANESSA_TEST_MANAGER_ARG); + ensure_vanessa_launch_key( + &mut keys, + &launch.raw_args, + DISABLE_UNSAFE_ACTION_PROTECTION_ARG, + ); + keys +} + +fn ensure_vanessa_launch_key(keys: &mut Vec, raw_args: &[String], key: &str) { + if has_launch_key(raw_args, key) { + remove_launch_key(keys, key); } else { - ensure_launch_key(&mut keys, VANESSA_TEST_MANAGER_ARG); + ensure_launch_key(keys, key); } - keys } fn ensure_launch_key(keys: &mut Vec, key: &str) { diff --git a/src/use_cases/vanessa.rs b/src/use_cases/vanessa.rs index db6ce1e..873be3f 100644 --- a/src/use_cases/vanessa.rs +++ b/src/use_cases/vanessa.rs @@ -91,6 +91,7 @@ pub(crate) fn prepare_client_mcp_launch(config: &AppConfig) -> Result, artifacts: VanessaTestArt Value::Bool(true), ); object.insert("ДелатьОтчетВФорматеjUnit".to_owned(), Value::Bool(true)); - object.insert( - "КаталогВыгрузкиJUnit".to_owned(), - Value::String(artifacts.junit_dir.display().to_string()), - ); + let junit_dir = Value::String(artifacts.junit_dir.display().to_string()); + object.insert("КаталогВыгрузкиJUnit".to_owned(), junit_dir.clone()); + ensure_object(object, "ОтчетJUnit").insert("КаталогВыгрузкиJUnit".to_owned(), junit_dir); apply_logging_overlay( object, artifacts.runner_log, @@ -237,6 +237,29 @@ fn apply_test_overlay(object: &mut Map, artifacts: VanessaTestArt ); } +fn apply_manager_overlay(object: &mut Map) { + object.insert("ВыполнитьСценарии".to_owned(), Value::Bool(false)); + object.insert("ЗавершитьРаботуСистемы".to_owned(), Value::Bool(false)); + object.insert( + "ЗакрытьTestClientПослеЗапускаСценариев".to_owned(), + Value::Bool(false), + ); + object.insert( + "ЗакрыватьКлиентТестированияПринудительно".to_owned(), + Value::Bool(false), + ); +} + +fn ensure_object<'a>(object: &'a mut Map, key: &str) -> &'a mut Map { + if !object.get(key).is_some_and(Value::is_object) { + object.insert(key.to_owned(), Value::Object(Map::new())); + } + object + .get_mut(key) + .and_then(Value::as_object_mut) + .expect("object value was just inserted") +} + fn apply_logging_overlay( object: &mut Map, text_log_path: &Path, diff --git a/tests/cli_launch.rs b/tests/cli_launch.rs index 119ce72..141136e 100644 --- a/tests/cli_launch.rs +++ b/tests/cli_launch.rs @@ -109,7 +109,11 @@ fn setup_mcp_va_project_with_options( fs::create_dir_all(va_params.parent().expect("cfg dir")).expect("cfg dir"); fs::create_dir_all(&features_dir).expect("features"); fs::write(&va_epf, "epf").expect("epf"); - fs::write(&va_params, "{\n \"existing\": true\n}\n").expect("params"); + fs::write( + &va_params, + "{\n \"existing\": true,\n \"ВыполнитьСценарии\": true,\n \"ЗавершитьРаботуСистемы\": true,\n \"ЗакрытьTestClientПослеЗапускаСценариев\": true,\n \"ЗакрыватьКлиентТестированияПринудительно\": true\n}\n", + ) + .expect("params"); fs::write(features_dir.join("login.feature"), "Feature: Login\n").expect("feature"); write_script(&install_dir.join("bin").join("1cv8c")); write_logging_script(&install_dir.join("bin").join("1cv8"), &args_log); @@ -416,6 +420,7 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { assert!(args.contains("/C\nrunMcp=/tmp/mcp conf.json;mcpPort=9874;VAParams=")); assert!(!args.contains("StartFeaturePlayer")); assert!(args.contains("/TESTMANAGER")); + assert!(args.contains("/DisableUnsafeActionProtection")); assert!(args.contains("/WA-")); let params_arg = args .lines() @@ -436,6 +441,13 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { assert_eq!(params_json["ОстановкаПриВозникновенииОшибки"], false); assert_eq!(params_json["СписокФичДляВыполнения"][0], "login"); assert_eq!(params_json["СписокТеговОтбор"][0], "smoke"); + assert_eq!(params_json["ВыполнитьСценарии"], false); + assert_eq!(params_json["ЗавершитьРаботуСистемы"], false); + assert_eq!(params_json["ЗакрытьTestClientПослеЗапускаСценариев"], false); + assert_eq!( + params_json["ЗакрыватьКлиентТестированияПринудительно"], + false + ); assert_eq!( params_json["ДелатьЛогВыполненияСценариевВТекстовыйФайл"], true @@ -473,9 +485,175 @@ fn launch_mcp_va_builds_payload_from_configured_port_and_ordinary_mode() { } #[test] -fn launch_mcp_va_does_not_duplicate_explicit_testmanager_raw_key() { - let (_dir, config_path, _install_dir, args_log) = - setup_mcp_va_project_with_options("work", &["/TESTMANAGER"]); +fn launch_mcp_va_ws_transport_emits_vanessa_test_client_payload() { + let (_dir, config_path, _install_dir, args_log) = setup_mcp_va_project(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let manager_url = format!( + "ws://127.0.0.1:{}/sessions", + listener.local_addr().expect("addr").port() + ); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "va", + "--mode", + "ordinary", + "--mcp-transport", + "ws", + "--manager-url", + &manager_url, + "--mcp-log-level", + "debug", + "--mcp-ws-timeout-ms", + "2500", + "--client-uid", + "00000000-0000-0000-0000-000000000c0d", + "--corr-id", + "va-manager", + ]) + .output() + .expect("run command"); + drop(listener); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["mode"], "mcp"); + assert_eq!(payload["data"]["transport"], "ws"); + assert_eq!(payload["data"]["kind"], "vanessa_test_client"); + assert_eq!(payload["data"]["manager_url"], manager_url); + assert_eq!(payload["data"]["corr_id"], "va-manager"); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/Execute")); + assert!(args.contains("vanessa-automation.epf")); + assert!(args.contains("/TESTMANAGER")); + assert!(args.contains("/DisableUnsafeActionProtection")); + assert!(args.contains("/C\nmcpMode=ws;")); + assert!(args.contains("kind=vanessa_test_client")); + assert!(args.contains("client_uid=00000000-0000-0000-0000-000000000c0d")); + assert!(args.contains(&format!("manager_url={manager_url}"))); + assert!(args.contains("corr_id=va-manager")); + assert!(args.contains("mcp_log_level=debug")); + assert!(args.contains("mcp_ws_timeout_ms=2500")); + assert!(args.contains(";VAParams=")); + assert!(!args.contains("runMcp")); + assert!(!args.contains("StartFeaturePlayer")); + let lines: Vec<&str> = args.lines().collect(); + let pos = |needle: &str| { + lines + .iter() + .position(|line| *line == needle) + .unwrap_or_else(|| panic!("{needle} argument")) + }; + assert!(pos("/TESTMANAGER") < pos("/DisableStartupDialogs")); + assert!(pos("/DisableUnsafeActionProtection") < pos("/IBConnectionString")); + assert!(pos("/TESTMANAGER") < pos("/Execute")); + assert!(pos("/DisableUnsafeActionProtection") < pos("/C")); + + let params_arg = args + .lines() + .find(|line| line.contains("VAParams=")) + .expect("VAParams argument"); + let params_path = params_arg + .split("VAParams=") + .nth(1) + .expect("VAParams path") + .trim_end_matches('"'); + let params_json: Value = + serde_json::from_str(&fs::read_to_string(params_path).expect("runtime params")) + .expect("runtime params JSON"); + assert_eq!(params_json["ВыполнитьСценарии"], false); + assert_eq!(params_json["ЗавершитьРаботуСистемы"], false); + assert_eq!(params_json["ЗакрытьTestClientПослеЗапускаСценариев"], false); + assert_eq!( + params_json["ЗакрыватьКлиентТестированияПринудительно"], + false + ); +} + +#[test] +fn launch_mcp_va_auto_transport_falls_back_to_manager_mcp_payload() { + let (_dir, config_path, _install_dir, args_log) = setup_mcp_va_project(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + let manager_url = format!("ws://127.0.0.1:{port}/sessions"); + let output = v8_runner_command() + .args([ + "--config", + &config_path.display().to_string(), + "--json-message", + "launch", + "mcp", + "va", + "--mode", + "ordinary", + "--mcp-transport", + "auto", + "--manager-url", + &manager_url, + ]) + .output() + .expect("run command"); + + assert!( + output.status.success(), + "status={:?}\nstdout={}\nstderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let payload: Value = serde_json::from_slice(&output.stdout).expect("json"); + assert_eq!(payload["data"]["mode"], "mcp"); + assert_eq!(payload["data"]["transport"], "mcp"); + + let args = fs::read_to_string(args_log).expect("args log"); + assert!(args.contains("/Execute")); + assert!(args.contains("vanessa-automation.epf")); + assert!(args.contains("/TESTMANAGER")); + assert!(args.contains("/DisableUnsafeActionProtection")); + assert!(args.contains("/C\nrunMcp")); + assert!(args.contains(";VAParams=")); + assert!(!args.contains("mcpMode=ws")); + assert!(!args.contains("StartFeaturePlayer")); + + let params_arg = args + .lines() + .find(|line| line.contains("VAParams=")) + .expect("VAParams argument"); + let params_path = params_arg + .split("VAParams=") + .nth(1) + .expect("VAParams path") + .trim_end_matches('"'); + let params_json: Value = + serde_json::from_str(&fs::read_to_string(params_path).expect("runtime params")) + .expect("runtime params JSON"); + assert_eq!(params_json["ВыполнитьСценарии"], false); + assert_eq!(params_json["ЗавершитьРаботуСистемы"], false); + assert_eq!(params_json["ЗакрытьTestClientПослеЗапускаСценариев"], false); + assert_eq!( + params_json["ЗакрыватьКлиентТестированияПринудительно"], + false + ); +} + +#[test] +fn launch_mcp_va_does_not_duplicate_explicit_vanessa_raw_keys() { + let (_dir, config_path, _install_dir, args_log) = setup_mcp_va_project_with_options( + "work", + &["/TESTMANAGER", "/DisableUnsafeActionProtection"], + ); let output = v8_runner_command() .args([ "--config", @@ -487,6 +665,8 @@ fn launch_mcp_va_does_not_duplicate_explicit_testmanager_raw_key() { "ordinary", "--raw-key", "/TESTMANAGER", + "--raw-key", + "/DisableUnsafeActionProtection", ]) .output() .expect("run command"); @@ -505,6 +685,11 @@ fn launch_mcp_va_does_not_duplicate_explicit_testmanager_raw_key() { .filter(|arg| arg.eq_ignore_ascii_case("/TESTMANAGER")) .count(); assert_eq!(test_manager_count, 1); + let unsafe_action_protection_count = args + .split_whitespace() + .filter(|arg| arg.eq_ignore_ascii_case("/DisableUnsafeActionProtection")) + .count(); + assert_eq!(unsafe_action_protection_count, 1); } #[test] diff --git a/tests/cli_test.rs b/tests/cli_test.rs index 51b08f4..60b12f6 100644 --- a/tests/cli_test.rs +++ b/tests/cli_test.rs @@ -302,7 +302,7 @@ fn setup_va_project_with_work_name( ) }; let config = format!( - "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: 5\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\n ignore_tags:\n - '@draft'\n scenario_filter:\n - Проверка логина\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n va:\n epf_path: '{}'\n platform:\n path: '{}'\n{}", + "basePath: '{}'\nworkPath: '{}'\nformat: DESIGNER\nbuilder: DESIGNER\ninfobase:\n connection: 'File=/tmp/ib'\n password: secret\ntests:\n execution_timeout_seconds: 5\n va:\n params_path: '{}'\n profile: smoke\n profiles:\n smoke:\n feature_path: '{}'\n features_to_run:\n - login\n filter_tags:\n - '@smoke'\n ignore_tags:\n - '@draft'\n scenario_filter:\n - Проверка логина\nsource-set:\n - name: main\n type: CONFIGURATION\n path: main\ntools:\n va:\n epf_path: '{}'\n client_mcp:\n transport: mcp\n platform:\n path: '{}'\n{}", base_path.display(), work_path.display(), va_params.display(), @@ -757,6 +757,7 @@ fn test_va_builds_vanessa_command_and_overlay() { assert!(calls.contains("vanessa-automation.epf")); assert!(calls.contains("StartFeaturePlayer;VAParams=")); assert!(calls.contains("/TESTMANAGER")); + assert!(calls.contains("/DisableUnsafeActionProtection")); assert!(calls.contains("/VAUSER")); assert!(calls.contains("ci-user")); @@ -899,9 +900,11 @@ fn test_va_rejects_semicolon_in_generated_params_path() { } #[test] -fn test_va_does_not_duplicate_explicit_testmanager_raw_key() { - let (_dir, config_path, _build_calls, test_calls, _captured_params) = - setup_va_project(JUNIT_SMOKE_REPORT_FIXTURE, &["/TESTMANAGER"]); +fn test_va_does_not_duplicate_explicit_vanessa_raw_keys() { + let (_dir, config_path, _build_calls, test_calls, _captured_params) = setup_va_project( + JUNIT_SMOKE_REPORT_FIXTURE, + &["/TESTMANAGER", "/DisableUnsafeActionProtection"], + ); let output = v8_runner_command() .args([ @@ -910,6 +913,8 @@ fn test_va_does_not_duplicate_explicit_testmanager_raw_key() { "test", "--raw-key", "/TESTMANAGER", + "--raw-key", + "/DisableUnsafeActionProtection", "va", ]) .output() @@ -929,6 +934,11 @@ fn test_va_does_not_duplicate_explicit_testmanager_raw_key() { .filter(|arg| arg.eq_ignore_ascii_case("/TESTMANAGER")) .count(); assert_eq!(test_manager_count, 1); + let unsafe_action_protection_count = calls + .split_whitespace() + .filter(|arg| arg.eq_ignore_ascii_case("/DisableUnsafeActionProtection")) + .count(); + assert_eq!(unsafe_action_protection_count, 1); } #[test] @@ -961,6 +971,7 @@ fn test_va_adds_testmanager_when_raw_value_matches_name() { let calls = fs::read_to_string(test_calls).expect("test calls"); assert!(calls.contains("/VAUser")); assert!(calls.contains("TESTMANAGER")); + assert!(calls.contains("/DisableUnsafeActionProtection")); assert!(calls .split_whitespace() .any(|arg| arg.eq_ignore_ascii_case("/TESTMANAGER"))); diff --git a/v8-runner/SKILL.md b/v8-runner/SKILL.md index 9451bfb..2f11fc4 100644 --- a/v8-runner/SKILL.md +++ b/v8-runner/SKILL.md @@ -67,7 +67,13 @@ v8-runner init - Branch switch, rebase, large object moves, stale source-backed tool extension state, or suspicious incremental state: run `v8-runner build --full-rebuild`. - Syntax check: inspect `format` and `builder`, then choose `syntax designer-modules`, `syntax designer-config`, or `syntax edt`. - Behavior validation: run the relevant `v8-runner test ...` command; tests build first. -- Vanessa Automation debugging or scenario authoring: use `v8-runner launch mcp va ...` to start the client MCP server with VA loaded. +- Vanessa Automation debugging or scenario authoring: use `v8-runner launch mcp va ...` + to start the VA test-manager for MCP tools. The runner adds `/TESTMANAGER` and + `/DisableUnsafeActionProtection`; WS mode passes `mcpMode=ws;...;VAParams=` + in `/C`, while local HTTP MCP fallback passes `runMcp...;VAParams=` + without `StartFeaturePlayer`. Transport defaults to `auto`: WS is selected when the + session-manager is reachable, while local HTTP MCP is selected only with + `--mcp-transport=mcp` or as the `auto` fallback. - Extension properties need synchronization: use `v8-runner extensions` or `extensions --name `. - Infobase changes need to become Git-visible files: check `git status`, then run the relevant `v8-runner dump ...` command. - Source files need conversion between Designer and EDT: use `v8-runner convert`; this is CLI-only and does not use the infobase. @@ -77,6 +83,25 @@ v8-runner init - Need onec-client-mcp-devkit launched inside 1C without VA authoring: use `v8-runner launch mcp ...`. - Pair the launched 1С-client with a running [v8-client-session-manager](https://github.com/SteelMorgan/v8-client-session-manager) over WebSocket: rely on `--mcp-transport=auto` (default — TCP-probes `manager_url` for 200 ms). Force WS with `--mcp-transport=ws` (fails if manager is down) or use local HTTP MCP with `--mcp-transport=mcp`. WS-only flags: `--manager-url`, `--client-uid`, `--corr-id`, `--mcp-log-level`, `--mcp-ws-timeout-ms`. The internal `kind` mapping (`v8_runner_client` / `vanessa_test_client` / `yaxunit_runner` / `vanessa_test_client`) is fixed by entry-point and **not** overridable from CLI. Read `references/project-workflows.md` (section «WS-режим к session-manager») for the full payload, defaults, and `--json-message` shape. +## Launch Modes + +| Mode | Purpose | MCP/VA behavior | +|---|---|---| +| `launch designer` | Open Designer. | No client MCP tools; enterprise additional keys are not applied. | +| `launch thin`, `launch thick`, `launch ordinary` | Open a regular 1C UI client. | Optional WS base client tools; no VA tools by itself. | +| `launch mcp` | Start onec-client-mcp-devkit inside 1C without Vanessa. | `kind=v8_runner_client` in WS, or local HTTP MCP with `--mcp-transport=mcp` / `auto` fallback. | +| `launch mcp va` | Start the Vanessa test-manager for research, authoring, and VA client MCP tools. | `kind=vanessa_test_client`; runner adds `/TESTMANAGER`, `/DisableUnsafeActionProtection`, `/Execute `; WS mode uses `mcpMode=ws;...;VAParams=` in `/C`, local MCP fallback uses `runMcp...;VAParams=`, and neither mode uses `StartFeaturePlayer`. | +| `test yaxunit ...` | Run YAxUnit tests. | `kind=yaxunit_runner` in WS; not an interactive UI session. | +| `test va` | Run Vanessa feature scenarios. | `kind=vanessa_test_client`, but payload is `StartFeaturePlayer;VAParams=...`; this is scenario execution, not the manager research mode. | + +For `launch mcp va --mcp-transport=ws`, wait for the `kind=vanessa_test_client` +session in `v8-client-session-manager`: normal startup/tool registration takes 10-90 seconds. +Poll `session_list` every 5-10 seconds and use 120 seconds as a diagnostic limit. After the +VA tools appear, call `connect_test_client` with the required `profileName` argument; then the VA +client MCP tools become available. After the investigation or on error, call `close_test_client` +with the same `profileName` (or without it to close the current connected profile) so extra 1C +test-client sessions are not kept alive. + ## Guardrails - Do not delete or recreate an infobase, workspace, temp directory, or generated state unless the user explicitly asks or the command itself is the documented recovery path.