diff --git a/apps/ui/html/wizard/step3-websocket.html b/apps/ui/html/wizard/step3-websocket.html
new file mode 100644
index 0000000..f2dd30c
--- /dev/null
+++ b/apps/ui/html/wizard/step3-websocket.html
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js
index d62f1ec..90c8579 100644
--- a/apps/ui/js/i18n.js
+++ b/apps/ui/js/i18n.js
@@ -605,6 +605,7 @@ var TRANSLATIONS = {
'wizard.step3.rest.label': 'Request config',
'wizard.step3.graphql.label': 'GQL operation',
'wizard.step3.grpc.label': 'RPC method',
+ 'wizard.step3.websocket.label': 'Socket config',
'wizard.step3.soap.label': 'WSDL binding',
'wizard.step3.rest.title': 'HTTP method & format',
'wizard.step3.rest.subtitle': 'Choose the HTTP verb this operation sends and configure content negotiation headers. Crank will serialize MCP tool arguments into the appropriate request body format.',
@@ -667,6 +668,23 @@ var TRANSLATIONS = {
'wizard.step3.grpc.route_label': 'gRPC route',
'wizard.step3.grpc.request_label': 'Request',
'wizard.step3.grpc.response_label': 'Response',
+ 'wizard.step3.websocket.title': 'WebSocket subscription',
+ 'wizard.step3.websocket.subtitle': 'Define the subscription handshake, message templates and runtime tuning that Crank should use before collecting bounded WebSocket events.',
+ 'wizard.step3.websocket.connection_title': 'Connection',
+ 'wizard.step3.websocket.connection_subtitle': 'The upstream base URL from step 2 becomes the WebSocket endpoint unless you override it here.',
+ 'wizard.step3.websocket.path': 'Socket path',
+ 'wizard.step3.websocket.path_hint': 'Appended to the upstream URL from step 2. Leave empty when the upstream URL already points to the full WebSocket endpoint.',
+ 'wizard.step3.websocket.subprotocols': 'Subprotocols',
+ 'wizard.step3.websocket.subprotocols_hint': 'One subprotocol per line. Crank offers them during the handshake in the same order.',
+ 'wizard.step3.websocket.messages_title': 'Subscription messages',
+ 'wizard.step3.websocket.messages_subtitle': 'Templates are sent after the socket opens. They can reference MCP input through the mapping step.',
+ 'wizard.step3.websocket.subscribe_template': 'Subscribe template',
+ 'wizard.step3.websocket.unsubscribe_template': 'Unsubscribe template',
+ 'wizard.step3.websocket.runtime_title': 'Connection tuning',
+ 'wizard.step3.websocket.runtime_subtitle': 'Used by the runtime adapter for heartbeats and reconnects in bounded session-oriented flows.',
+ 'wizard.step3.websocket.heartbeat': 'Heartbeat interval (ms)',
+ 'wizard.step3.websocket.reconnect_attempts': 'Reconnect attempts',
+ 'wizard.step3.websocket.reconnect_backoff': 'Reconnect backoff (ms)',
'wizard.step3.soap.title': 'WSDL and SOAP binding',
'wizard.step3.soap.subtitle': 'Upload the WSDL/XSD bundle, inspect services and choose the exact service, port and operation that Crank should expose as an MCP tool.',
'wizard.step3.soap.wsdl_title': 'Contract files',
@@ -1569,6 +1587,7 @@ var TRANSLATIONS = {
'wizard.step3.rest.label': 'Конфиг запроса',
'wizard.step3.graphql.label': 'GQL операция',
'wizard.step3.grpc.label': 'RPC метод',
+ 'wizard.step3.websocket.label': 'Конфиг сокета',
'wizard.step3.soap.label': 'WSDL binding',
'wizard.step3.rest.title': 'HTTP метод и формат',
'wizard.step3.rest.subtitle': 'Выберите HTTP-метод, который отправляет эта операция, и настройте content negotiation headers. Crank сериализует аргументы MCP-инструмента в нужный формат request body.',
@@ -1631,6 +1650,23 @@ var TRANSLATIONS = {
'wizard.step3.grpc.route_label': 'gRPC route',
'wizard.step3.grpc.request_label': 'Запрос',
'wizard.step3.grpc.response_label': 'Ответ',
+ 'wizard.step3.websocket.title': 'WebSocket subscription',
+ 'wizard.step3.websocket.subtitle': 'Определите handshake подписки, шаблоны сообщений и runtime-параметры, которые Crank должен использовать перед сбором bounded WebSocket events.',
+ 'wizard.step3.websocket.connection_title': 'Соединение',
+ 'wizard.step3.websocket.connection_subtitle': 'Base URL upstream-а из шага 2 станет WebSocket endpoint-ом, если вы явно не переопределите его здесь.',
+ 'wizard.step3.websocket.path': 'Путь сокета',
+ 'wizard.step3.websocket.path_hint': 'Добавляется к upstream URL из шага 2. Оставьте пустым, если upstream URL уже указывает на полный WebSocket endpoint.',
+ 'wizard.step3.websocket.subprotocols': 'Subprotocols',
+ 'wizard.step3.websocket.subprotocols_hint': 'По одному subprotocol на строку. Crank предложит их в handshake в том же порядке.',
+ 'wizard.step3.websocket.messages_title': 'Сообщения подписки',
+ 'wizard.step3.websocket.messages_subtitle': 'Шаблоны отправляются после открытия сокета. Они могут ссылаться на MCP input через mapping на следующем шаге.',
+ 'wizard.step3.websocket.subscribe_template': 'Шаблон subscribe',
+ 'wizard.step3.websocket.unsubscribe_template': 'Шаблон unsubscribe',
+ 'wizard.step3.websocket.runtime_title': 'Тюнинг соединения',
+ 'wizard.step3.websocket.runtime_subtitle': 'Используется runtime adapter-ом для heartbeat и reconnect в bounded session-oriented flows.',
+ 'wizard.step3.websocket.heartbeat': 'Интервал heartbeat (мс)',
+ 'wizard.step3.websocket.reconnect_attempts': 'Число reconnect attempts',
+ 'wizard.step3.websocket.reconnect_backoff': 'Backoff reconnect-а (мс)',
'wizard.step3.soap.title': 'WSDL и SOAP binding',
'wizard.step3.soap.subtitle': 'Загрузите WSDL/XSD bundle, получите список сервисов и выберите точный service, port и operation, который Crank должен опубликовать как MCP tool.',
'wizard.step3.soap.wsdl_title': 'Файлы контракта',
diff --git a/apps/ui/js/wizard.js b/apps/ui/js/wizard.js
index d738bb6..76a2668 100644
--- a/apps/ui/js/wizard.js
+++ b/apps/ui/js/wizard.js
@@ -94,6 +94,7 @@ function loadStep(n, callback) {
function step3PanelId() {
if (wizardProtocol === 'graphql') return 'step-panel-3-graphql';
if (wizardProtocol === 'grpc') return 'step-panel-3-grpc';
+ if (wizardProtocol === 'websocket') return 'step-panel-3-websocket';
if (wizardProtocol === 'soap') return 'step-panel-3-soap';
return 'step-panel-3-rest';
}
@@ -103,6 +104,7 @@ var STEP3_LABELS = {
rest: tKey('wizard.step3.rest.label'),
graphql: tKey('wizard.step3.graphql.label'),
grpc: tKey('wizard.step3.grpc.label'),
+ websocket: tKey('wizard.step3.websocket.label'),
soap: tKey('wizard.step3.soap.label'),
};
@@ -217,6 +219,8 @@ function bindProtocolCards() {
? 'graphql'
: card.classList.contains('grpc')
? 'grpc'
+ : card.classList.contains('websocket')
+ ? 'websocket'
: card.classList.contains('soap')
? 'soap'
: 'rest');
@@ -1510,6 +1514,32 @@ function parseExecutionConfig(text) {
useTls = !!value.tls.verify;
}
config.protocol_options = { grpc: { use_tls: useTls } };
+ } else if (wizardProtocol === 'websocket') {
+ var websocketProtocolOptions = value.protocol_options && value.protocol_options.websocket
+ ? value.protocol_options.websocket
+ : {};
+ var heartbeatInterval = websocketProtocolOptions.heartbeat_interval_ms != null
+ ? Number(websocketProtocolOptions.heartbeat_interval_ms)
+ : numericValueOrNull('websocket-heartbeat-interval-ms');
+ var reconnectAttempts = websocketProtocolOptions.reconnect_max_attempts != null
+ ? Number(websocketProtocolOptions.reconnect_max_attempts)
+ : numericValueOrNull('websocket-reconnect-max-attempts');
+ var reconnectBackoff = websocketProtocolOptions.reconnect_backoff_ms != null
+ ? Number(websocketProtocolOptions.reconnect_backoff_ms)
+ : numericValueOrNull('websocket-reconnect-backoff-ms');
+ var runtimeOptions = {};
+ if (Number.isFinite(heartbeatInterval) && heartbeatInterval > 0) {
+ runtimeOptions.heartbeat_interval_ms = heartbeatInterval;
+ }
+ if (Number.isFinite(reconnectAttempts) && reconnectAttempts >= 0) {
+ runtimeOptions.reconnect_max_attempts = reconnectAttempts;
+ }
+ if (Number.isFinite(reconnectBackoff) && reconnectBackoff >= 0) {
+ runtimeOptions.reconnect_backoff_ms = reconnectBackoff;
+ }
+ if (Object.keys(runtimeOptions).length) {
+ config.protocol_options = { websocket: runtimeOptions };
+ }
}
config.streaming = collectStreamingConfig();
@@ -1615,6 +1645,21 @@ function buildTarget() {
};
}
+ if (wizardProtocol === 'websocket') {
+ return {
+ kind: 'websocket',
+ url: joinUrl(upstream.url, textValue('websocket-path')),
+ subprotocols: textareaLines('websocket-subprotocols'),
+ subscribe_message_template: stringValueOrNull('websocket-subscribe-template')
+ ? parseStructuredText(textValue('websocket-subscribe-template'))
+ : null,
+ unsubscribe_message_template: stringValueOrNull('websocket-unsubscribe-template')
+ ? parseStructuredText(textValue('websocket-unsubscribe-template'))
+ : null,
+ static_headers: parseHeaderMap(upstream.staticHeaders),
+ };
+ }
+
var route = textValue('grpc-manual-route') || (document.getElementById('grpc-path-value') ? document.getElementById('grpc-path-value').textContent.trim() : '');
var descriptorRef = wizardCurrentVersion && wizardCurrentVersion.target && wizardCurrentVersion.target.kind === 'grpc'
? wizardCurrentVersion.target.descriptor_ref
@@ -1998,6 +2043,8 @@ function selectProtocol(protocol) {
? 'graphql'
: card.classList.contains('grpc')
? 'grpc'
+ : card.classList.contains('websocket')
+ ? 'websocket'
: card.classList.contains('soap')
? 'soap'
: 'rest');
@@ -2095,6 +2142,25 @@ function executionConfigToEditorValue(config) {
if (config.protocol_options && config.protocol_options.grpc) {
value.tls = { verify: !!config.protocol_options.grpc.use_tls };
}
+ if (config.protocol_options && config.protocol_options.websocket) {
+ value.protocol_options = value.protocol_options || {};
+ value.protocol_options.websocket = {};
+ if (config.protocol_options.websocket.heartbeat_interval_ms != null) {
+ value.protocol_options.websocket.heartbeat_interval_ms = config.protocol_options.websocket.heartbeat_interval_ms;
+ }
+ if (config.protocol_options.websocket.reconnect_max_attempts != null) {
+ value.protocol_options.websocket.reconnect_max_attempts = config.protocol_options.websocket.reconnect_max_attempts;
+ }
+ if (config.protocol_options.websocket.reconnect_backoff_ms != null) {
+ value.protocol_options.websocket.reconnect_backoff_ms = config.protocol_options.websocket.reconnect_backoff_ms;
+ }
+ if (!Object.keys(value.protocol_options.websocket).length) {
+ delete value.protocol_options.websocket;
+ }
+ if (!Object.keys(value.protocol_options).length) {
+ delete value.protocol_options;
+ }
+ }
return window.jsyaml ? window.jsyaml.dump(value, { lineWidth: -1 }) : JSON.stringify(value, null, 2);
}
@@ -2141,6 +2207,30 @@ function prefillWizardFromEdit(detail, versionDocument) {
setValue('soap-action', target.soap_action || '');
setValue('soap-version', target.soap_version || 'soap_11');
setValue('soap-binding-style', target.binding_style || 'document_literal');
+ } else if (target.kind === 'websocket') {
+ prefillUpstream(
+ target.url,
+ target.static_headers || {},
+ versionDocument.execution_config ? versionDocument.execution_config.auth_profile_ref : null
+ );
+ setValue('websocket-path', '');
+ setValue('websocket-subprotocols', (target.subprotocols || []).join('\n'));
+ setValue(
+ 'websocket-subscribe-template',
+ target.subscribe_message_template ? JSON.stringify(target.subscribe_message_template, null, 2) : ''
+ );
+ setValue(
+ 'websocket-unsubscribe-template',
+ target.unsubscribe_message_template ? JSON.stringify(target.unsubscribe_message_template, null, 2) : ''
+ );
+ var websocketOptions = versionDocument.execution_config
+ && versionDocument.execution_config.protocol_options
+ && versionDocument.execution_config.protocol_options.websocket
+ ? versionDocument.execution_config.protocol_options.websocket
+ : null;
+ setValue('websocket-heartbeat-interval-ms', websocketOptions && websocketOptions.heartbeat_interval_ms ? websocketOptions.heartbeat_interval_ms : '');
+ setValue('websocket-reconnect-max-attempts', websocketOptions && websocketOptions.reconnect_max_attempts != null ? websocketOptions.reconnect_max_attempts : '');
+ setValue('websocket-reconnect-backoff-ms', websocketOptions && websocketOptions.reconnect_backoff_ms ? websocketOptions.reconnect_backoff_ms : '');
}
setValue('tool-name', detail.name);
diff --git a/docs/protocols/websocket.md b/docs/protocols/websocket.md
index 02b85a4..72ec5f8 100644
--- a/docs/protocols/websocket.md
+++ b/docs/protocols/websocket.md
@@ -104,6 +104,28 @@ Protocol-specific runtime tuning хранится отдельно в `ProtocolO
8. Выполняет test run.
9. Публикует operation как MCP tool или tool family.
+## 7.1. Текущий UI foundation
+
+Текущий wizard support для WebSocket уже включает:
+
+- выбор `WebSocket` как protocol family на шаге 1;
+- step 2 reuse общего upstream/auth selector;
+- отдельный step 3 для:
+ - socket path override;
+ - subprotocol list;
+ - subscribe message template;
+ - unsubscribe message template;
+ - `heartbeat_interval_ms`;
+ - `reconnect_max_attempts`;
+ - `reconnect_backoff_ms`.
+
+На текущем этапе wizard сериализует:
+
+- `Target::Websocket`
+- `ExecutionConfig.protocol_options.websocket`
+
+Это дает оператору usable foundation для `window`, `session` и `async_job` flows even before deeper inspection/test-run polish.
+
## 8. Поведение runtime
При выполнении WebSocket operation runtime должен: