use crate::report::{TestCategory, TestResult, TestStatus};
use pmcp::types::ui::CHATGPT_DESCRIPTOR_KEYS;
use pmcp::types::{ResourceInfo, ToolInfo};
use regex::Regex;
use serde_json::Value;
use std::sync::OnceLock;
use std::time::Duration;
const APP_MIME_TYPES: &[&str] = &[
"text/html",
"text/html+mcp",
"text/html+skybridge",
"text/html;profile=mcp-app",
];
#[allow(dead_code)]
fn script_block_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r#"(?is)<script(?P<attrs>[^>]*)>(?P<body>[\s\S]*?)</script>"#).unwrap()
})
}
#[allow(dead_code)]
fn ext_apps_import_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"@modelcontextprotocol/ext-apps").unwrap())
}
#[allow(dead_code)]
fn ext_apps_log_prefix_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\[ext-apps\]").unwrap())
}
#[allow(dead_code)]
fn ui_initialize_method_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"ui/initialize").unwrap())
}
#[allow(dead_code)]
fn ui_tool_result_method_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"ui/notifications/tool-result").unwrap())
}
#[allow(dead_code)]
fn app_constructor_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r"new [a-zA-Z_$][a-zA-Z0-9_$]{0,20}\(\s*\{[^}]{0,200}\bname\s*:[^,}]{0,100},\s*version\s*:",
)
.unwrap()
})
}
#[allow(dead_code)]
fn handler_onteardown_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\.\s*onteardown\s*=").unwrap())
}
#[allow(dead_code)]
fn handler_ontoolinput_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\.\s*ontoolinput\s*=").unwrap())
}
#[allow(dead_code)]
fn handler_ontoolcancelled_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\.\s*ontoolcancelled\s*=").unwrap())
}
#[allow(dead_code)]
fn handler_onerror_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\.\s*onerror\s*=").unwrap())
}
#[allow(dead_code)]
fn handler_ontoolresult_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\.\s*ontoolresult\s*=").unwrap())
}
#[allow(dead_code)]
fn connect_call_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\.\s*connect\s*\(").unwrap())
}
#[allow(dead_code)]
fn chatgpt_only_channels_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"window\.openai|window\.mcpBridge").unwrap())
}
#[allow(dead_code)]
fn html_comment_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?s)<!--.*?-->").unwrap())
}
#[allow(dead_code)]
fn js_block_comment_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"(?s)/\*.*?\*/").unwrap())
}
#[allow(dead_code)]
fn js_line_comment_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"//[^\r\n]*").unwrap())
}
#[allow(dead_code)]
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct WidgetSignals {
has_ext_apps_import: bool,
has_log_prefix: bool,
has_method_initialize: bool,
has_method_tool_result: bool,
has_sdk: bool,
has_app_constructor: bool,
has_connect: bool,
has_chatgpt_only_channels: bool,
handlers_present: Vec<&'static str>,
has_handlers: bool,
has_ontoolresult: bool,
}
#[allow(dead_code)]
fn strip_js_comments(src: &str) -> String {
let after_html = html_comment_re().replace_all(src, "");
let bytes = after_html.as_bytes();
let mut out = String::with_capacity(bytes.len());
let mut i = 0;
let n = bytes.len();
let mut state: u8 = 0;
while i < n {
let c = bytes[i];
i = match state {
0 => step_js_outside(c, bytes, i, n, &mut out, &mut state),
1 => step_js_block_comment(c, bytes, i, n, &mut state),
2 => step_js_line_comment(c, &mut out, i, &mut state),
3..=5 => step_js_string(c, bytes, i, n, &mut out, &mut state),
_ => unreachable!(),
};
}
out
}
fn step_js_outside(
c: u8,
bytes: &[u8],
i: usize,
n: usize,
out: &mut String,
state: &mut u8,
) -> usize {
if c == b'/' && i + 1 < n && bytes[i + 1] == b'*' {
*state = 1;
return i + 2;
}
if c == b'/' && i + 1 < n && bytes[i + 1] == b'/' {
*state = 2;
return i + 2;
}
if c == b'\'' {
*state = 3;
} else if c == b'"' {
*state = 4;
} else if c == b'`' {
*state = 5;
}
out.push(c as char);
i + 1
}
fn step_js_block_comment(c: u8, bytes: &[u8], i: usize, n: usize, state: &mut u8) -> usize {
if c == b'*' && i + 1 < n && bytes[i + 1] == b'/' {
*state = 0;
i + 2
} else {
i + 1
}
}
fn step_js_line_comment(c: u8, out: &mut String, i: usize, state: &mut u8) -> usize {
if c == b'\n' || c == b'\r' {
*state = 0;
out.push(c as char);
}
i + 1
}
fn step_js_string(
c: u8,
bytes: &[u8],
i: usize,
n: usize,
out: &mut String,
state: &mut u8,
) -> usize {
if c == b'\\' && i + 1 < n {
out.push(c as char);
out.push(bytes[i + 1] as char);
return i + 2;
}
let close = match *state {
3 => b'\'',
4 => b'"',
5 => b'`',
_ => unreachable!(),
};
if c == close {
*state = 0;
}
out.push(c as char);
i + 1
}
#[allow(dead_code)]
fn extract_inline_scripts(html: &str) -> String {
let mut out = String::new();
for cap in script_block_re().captures_iter(html) {
let attrs = cap.name("attrs").map_or("", |m| m.as_str());
if attrs.contains("application/json") || attrs.contains("src=") {
continue;
}
if let Some(body) = cap.name("body") {
let stripped = strip_js_comments(body.as_str());
out.push_str(&stripped);
out.push('\n');
}
}
out
}
#[allow(dead_code)]
fn scan_widget(html: &str) -> WidgetSignals {
let scripts = extract_inline_scripts(html);
let mut handlers_present: Vec<&'static str> = Vec::new();
if handler_onteardown_re().is_match(&scripts) {
handlers_present.push("onteardown");
}
if handler_ontoolinput_re().is_match(&scripts) {
handlers_present.push("ontoolinput");
}
if handler_ontoolcancelled_re().is_match(&scripts) {
handlers_present.push("ontoolcancelled");
}
if handler_onerror_re().is_match(&scripts) {
handlers_present.push("onerror");
}
let has_ext_apps_import = ext_apps_import_re().is_match(&scripts);
let has_log_prefix = ext_apps_log_prefix_re().is_match(&scripts);
let has_method_initialize = ui_initialize_method_re().is_match(&scripts);
let has_method_tool_result = ui_tool_result_method_re().is_match(&scripts);
let has_sdk =
has_ext_apps_import || has_log_prefix || has_method_initialize || has_method_tool_result;
let has_handlers = !handlers_present.is_empty();
WidgetSignals {
has_ext_apps_import,
has_log_prefix,
has_method_initialize,
has_method_tool_result,
has_sdk,
has_app_constructor: app_constructor_re().is_match(&scripts),
has_connect: connect_call_re().is_match(&scripts),
has_chatgpt_only_channels: chatgpt_only_channels_re().is_match(&scripts),
handlers_present,
has_handlers,
has_ontoolresult: handler_ontoolresult_re().is_match(&scripts),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppValidationMode {
Standard,
ChatGpt,
ClaudeDesktop,
}
impl std::fmt::Display for AppValidationMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Standard => write!(f, "standard"),
Self::ChatGpt => write!(f, "chatgpt"),
Self::ClaudeDesktop => write!(f, "claude-desktop"),
}
}
}
impl std::str::FromStr for AppValidationMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"standard" => Ok(Self::Standard),
"chatgpt" => Ok(Self::ChatGpt),
"claude-desktop" => Ok(Self::ClaudeDesktop),
other => Err(format!(
"Unknown validation mode: '{other}'. Valid: standard, chatgpt, claude-desktop"
)),
}
}
}
pub struct AppValidator {
mode: AppValidationMode,
tool_filter: Option<String>,
}
impl AppValidator {
pub fn new(mode: AppValidationMode, tool_filter: Option<String>) -> Self {
Self { mode, tool_filter }
}
pub fn validate_tools(
&self,
tools: &[ToolInfo],
resources: &[ResourceInfo],
) -> Vec<TestResult> {
let mut results = Vec::new();
let app_tools: Vec<&ToolInfo> = tools
.iter()
.filter(|t| {
if let Some(ref filter) = self.tool_filter {
t.name == *filter
} else {
Self::is_app_capable(t)
}
})
.collect();
if app_tools.is_empty() {
return results;
}
for tool in &app_tools {
let uri = Self::extract_resource_uri(tool);
results.extend(self.validate_tool_meta(tool, uri.as_deref()));
if let Some(ref uri) = uri {
results.extend(self.validate_resource_match(&tool.name, uri, resources));
}
if self.mode == AppValidationMode::ChatGpt {
if let Some(ref meta) = tool._meta {
results.extend(self.validate_chatgpt_keys(&tool.name, meta));
}
}
if let Some(ref schema) = tool.output_schema {
results.extend(self.validate_output_schema(&tool.name, schema));
}
}
results
}
pub fn is_app_capable(tool: &ToolInfo) -> bool {
Self::extract_resource_uri(tool).is_some()
}
pub fn extract_resource_uri(tool: &ToolInfo) -> Option<String> {
let meta = tool._meta.as_ref()?;
if let Some(Value::Object(ui)) = meta.get("ui") {
if let Some(Value::String(uri)) = ui.get("resourceUri") {
return Some(uri.clone());
}
}
if let Some(Value::String(uri)) = meta.get("ui/resourceUri") {
return Some(uri.clone());
}
None
}
fn validate_tool_meta(&self, tool: &ToolInfo, uri: Option<&str>) -> Vec<TestResult> {
let mut results = Vec::new();
let tool_name = &tool.name;
if tool._meta.is_none() {
results.push(TestResult {
name: format!("[{tool_name}] _meta present"),
category: TestCategory::Apps,
status: TestStatus::Failed,
duration: Duration::from_secs(0),
error: Some("Tool has no _meta field".to_string()),
details: None,
});
return results;
}
match uri {
Some(uri) => {
results.push(TestResult {
name: format!("[{tool_name}] ui.resourceUri present"),
category: TestCategory::Apps,
status: TestStatus::Passed,
duration: Duration::from_secs(0),
error: None,
details: None,
});
if uri.is_empty() || !uri.contains("://") {
results.push(TestResult {
name: format!("[{tool_name}] resourceUri format"),
category: TestCategory::Apps,
status: TestStatus::Warning,
duration: Duration::from_secs(0),
error: None,
details: Some(format!(
"URI may not be well-formed: '{uri}' (no scheme separator)"
)),
});
} else {
results.push(TestResult {
name: format!("[{tool_name}] resourceUri format"),
category: TestCategory::Apps,
status: TestStatus::Passed,
duration: Duration::from_secs(0),
error: None,
details: Some(format!("URI: {uri}")),
});
}
},
None => {
results.push(TestResult {
name: format!("[{tool_name}] ui.resourceUri present"),
category: TestCategory::Apps,
status: TestStatus::Failed,
duration: Duration::from_secs(0),
error: Some(
"_meta exists but missing ui.resourceUri (nested or flat)".to_string(),
),
details: None,
});
},
}
results
}
fn validate_resource_match(
&self,
tool_name: &str,
resource_uri: &str,
resources: &[ResourceInfo],
) -> Vec<TestResult> {
let mut results = Vec::new();
let matching = resources.iter().find(|r| r.uri == resource_uri);
match matching {
None => {
results.push(TestResult {
name: format!("[{tool_name}] resource cross-reference"),
category: TestCategory::Apps,
status: TestStatus::Warning,
duration: Duration::from_secs(0),
error: None,
details: Some(format!(
"No resource found with URI '{resource_uri}' in resources/list"
)),
});
},
Some(resource) => {
results.push(TestResult {
name: format!("[{tool_name}] resource cross-reference"),
category: TestCategory::Apps,
status: TestStatus::Passed,
duration: Duration::from_secs(0),
error: None,
details: Some(format!("Found resource: {}", resource.name)),
});
match &resource.mime_type {
None => {
results.push(TestResult {
name: format!("[{tool_name}] resource MIME type"),
category: TestCategory::Apps,
status: TestStatus::Warning,
duration: Duration::from_secs(0),
error: None,
details: Some("Resource has no MIME type set".to_string()),
});
},
Some(mime) => {
let is_valid = APP_MIME_TYPES.iter().any(|v| mime.eq_ignore_ascii_case(v));
if is_valid {
results.push(TestResult {
name: format!("[{tool_name}] resource MIME type"),
category: TestCategory::Apps,
status: TestStatus::Passed,
duration: Duration::from_secs(0),
error: None,
details: Some(format!("MIME type: {mime}")),
});
} else {
results.push(TestResult {
name: format!("[{tool_name}] resource MIME type"),
category: TestCategory::Apps,
status: TestStatus::Warning,
duration: Duration::from_secs(0),
error: None,
details: Some(format!(
"Unexpected MIME type '{mime}', expected one of: {}",
APP_MIME_TYPES.join(", ")
)),
});
}
},
}
},
}
results
}
fn validate_chatgpt_keys(
&self,
tool_name: &str,
meta: &serde_json::Map<String, Value>,
) -> Vec<TestResult> {
let mut results = Vec::new();
for key in CHATGPT_DESCRIPTOR_KEYS {
let present = meta.get(*key).is_some();
results.push(TestResult {
name: format!("[{tool_name}] ChatGPT key: {key}"),
category: TestCategory::Apps,
status: if present {
TestStatus::Passed
} else {
TestStatus::Warning
},
duration: Duration::from_secs(0),
error: None,
details: if present {
None
} else {
Some(format!("Missing ChatGPT key: {key}"))
},
});
}
let has_flat = meta.get("ui/resourceUri").is_some();
results.push(TestResult {
name: format!("[{tool_name}] ChatGPT flat ui/resourceUri"),
category: TestCategory::Apps,
status: if has_flat {
TestStatus::Passed
} else {
TestStatus::Warning
},
duration: Duration::from_secs(0),
error: None,
details: if has_flat {
None
} else {
Some("Missing flat legacy key ui/resourceUri (needed for ChatGPT)".to_string())
},
});
results
}
fn validate_output_schema(&self, tool_name: &str, schema: &Value) -> Vec<TestResult> {
let mut results = Vec::new();
let is_valid = schema.is_object() && schema.get("type").is_some();
results.push(TestResult {
name: format!("[{tool_name}] outputSchema structure"),
category: TestCategory::Apps,
status: if is_valid {
TestStatus::Passed
} else {
TestStatus::Warning
},
duration: Duration::from_secs(0),
error: None,
details: if is_valid {
None
} else {
Some("outputSchema should be an object with a 'type' field".to_string())
},
});
results
}
#[allow(dead_code)]
pub fn validate_widgets(&self, widget_bodies: &[(String, String, String)]) -> Vec<TestResult> {
if matches!(self.mode, AppValidationMode::ChatGpt) {
return Vec::new();
}
let mut results = Vec::new();
for (tool_name, uri, html) in widget_bodies {
let signals = scan_widget(html);
match self.mode {
AppValidationMode::ClaudeDesktop => {
results.extend(self.emit_results_for_claude_desktop(tool_name, uri, &signals));
},
AppValidationMode::Standard => {
if let Some(summary) =
self.emit_summary_warning_for_standard(tool_name, uri, &signals)
{
results.push(summary);
}
},
AppValidationMode::ChatGpt => {},
}
}
results
}
#[allow(dead_code)]
fn emit_results_for_claude_desktop(
&self,
tool_name: &str,
uri: &str,
s: &WidgetSignals,
) -> Vec<TestResult> {
let mut out = Vec::new();
out.push(self.widget_result_strict(
tool_name,
uri,
"MCP Apps SDK wiring",
s.has_sdk,
"Widget does not contain any of the four SDK-presence signals: `@modelcontextprotocol/ext-apps` import literal, `[ext-apps]` log prefix, `ui/initialize` method literal, or `ui/notifications/tool-result` method literal. [guide:handlers-before-connect]",
));
out.push(self.widget_result_strict(
tool_name,
uri,
"App constructor",
s.has_app_constructor,
"Widget does not call `new <App>({name, version})`. Searched for any minified-id constructor (e.g. `new yl({name:..., version:...})`). [guide:handlers-before-connect]",
));
for name in ["onteardown", "ontoolinput", "ontoolcancelled", "onerror"] {
let present = s.handlers_present.contains(&name);
out.push(self.widget_result_strict(
tool_name,
uri,
&format!("handler: {name}"),
present,
&format!("Widget does not register `app.{name}` before `connect()`. [guide:handlers-before-connect]"),
));
}
out.push(self.widget_ontoolresult_result(tool_name, uri, s));
out.push(self.widget_result_strict(
tool_name,
uri,
"connect() call",
s.has_connect,
"Widget does not call `app.connect()`. [guide:handlers-before-connect]",
));
if s.has_chatgpt_only_channels && !s.has_sdk && s.handlers_present.is_empty() {
out.push(self.widget_chatgpt_only_failed(tool_name, uri));
}
out
}
#[allow(dead_code)]
fn emit_summary_warning_for_standard(
&self,
tool_name: &str,
uri: &str,
s: &WidgetSignals,
) -> Option<TestResult> {
let mut missing: Vec<String> = Vec::new();
if !s.has_sdk {
missing.push(
"MCP Apps SDK presence (any of: @modelcontextprotocol/ext-apps import, [ext-apps] log prefix, ui/initialize method, or ui/notifications/tool-result method)"
.to_string(),
);
}
if !s.has_app_constructor {
missing.push("App constructor (looked for `new <id>({name, version})`)".to_string());
}
for name in ["onteardown", "ontoolinput", "ontoolcancelled", "onerror"] {
if !s.handlers_present.contains(&name) {
missing.push(format!("handler: {name}"));
}
}
if !s.has_connect {
missing.push("app.connect() call".to_string());
}
if missing.is_empty() {
return None;
}
let details = format!(
"Widget is missing {n} required signal(s): {list}. For Claude Desktop compatibility, run `--mode claude-desktop` to see per-signal errors. [guide:handlers-before-connect]",
n = missing.len(),
list = missing.join(", "),
);
Some(TestResult {
name: format!("[{tool_name}][{uri}] MCP Apps widget wiring (summary)"),
category: TestCategory::Apps,
status: TestStatus::Warning,
duration: Duration::from_secs(0),
error: None,
details: Some(details),
})
}
#[allow(dead_code)]
fn widget_result_strict(
&self,
tool_name: &str,
uri: &str,
label: &str,
present: bool,
missing_details: &str,
) -> TestResult {
TestResult {
name: format!("[{tool_name}][{uri}] {label}"),
category: TestCategory::Apps,
status: if present {
TestStatus::Passed
} else {
TestStatus::Failed
},
duration: Duration::from_secs(0),
error: None,
details: if present {
None
} else {
Some(missing_details.to_string())
},
}
}
#[allow(dead_code)]
fn widget_ontoolresult_result(
&self,
tool_name: &str,
uri: &str,
s: &WidgetSignals,
) -> TestResult {
TestResult {
name: format!("[{tool_name}][{uri}] handler: ontoolresult"),
category: TestCategory::Apps,
status: if s.has_ontoolresult {
TestStatus::Passed
} else {
TestStatus::Warning
},
duration: Duration::from_secs(0),
error: None,
details: if s.has_ontoolresult {
None
} else {
Some("Widget does not register `app.ontoolresult` (soft warning — may render from getHostContext().toolOutput). [guide:handlers-before-connect]".to_string())
},
}
}
#[allow(dead_code)]
fn widget_chatgpt_only_failed(&self, tool_name: &str, uri: &str) -> TestResult {
TestResult {
name: format!("[{tool_name}][{uri}] chatgpt-only channels detected"),
category: TestCategory::Apps,
status: TestStatus::Failed,
duration: Duration::from_secs(0),
error: None,
details: Some(
"Widget uses `window.openai`/`window.mcpBridge` channels but does not wire ext-apps SDK. ChatGPT will render fine; Claude Desktop will tear down the connection. [guide:common-failures-claude]".to_string(),
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_tool(name: &str, meta: Option<serde_json::Map<String, Value>>) -> ToolInfo {
let mut tool = ToolInfo::new(name, None, json!({"type": "object"}));
tool._meta = meta;
tool
}
fn make_resource(uri: &str, mime: Option<&str>) -> ResourceInfo {
let mut info = ResourceInfo::new(uri, uri);
if let Some(m) = mime {
info = info.with_mime_type(m);
}
info
}
#[test]
fn test_is_app_capable_nested() {
let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
"ui": { "resourceUri": "ui://app/test" }
}))
.unwrap();
let tool = make_tool("t1", Some(meta));
assert!(AppValidator::is_app_capable(&tool));
}
#[test]
fn test_is_app_capable_flat() {
let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
"ui/resourceUri": "ui://app/test"
}))
.unwrap();
let tool = make_tool("t2", Some(meta));
assert!(AppValidator::is_app_capable(&tool));
}
#[test]
fn test_not_app_capable() {
let tool = make_tool("t3", None);
assert!(!AppValidator::is_app_capable(&tool));
}
#[test]
fn test_validate_tools_no_app_tools() {
let validator = AppValidator::new(AppValidationMode::Standard, None);
let tools = vec![make_tool("plain", None)];
let results = validator.validate_tools(&tools, &[]);
assert!(results.is_empty());
}
#[test]
fn test_validate_tools_with_resource_match() {
let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
"ui": { "resourceUri": "ui://app/chess" }
}))
.unwrap();
let tool = make_tool("chess", Some(meta));
let resource = make_resource("ui://app/chess", Some("text/html"));
let validator = AppValidator::new(AppValidationMode::Standard, None);
let results = validator.validate_tools(&[tool], &[resource]);
let passed = results
.iter()
.filter(|r| r.status == TestStatus::Passed)
.count();
assert!(
passed >= 3,
"Expected at least 3 passed results, got {passed}"
);
}
#[test]
fn test_chatgpt_mode_checks_openai_keys() {
let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
"ui": { "resourceUri": "ui://app/test" },
"openai/outputTemplate": "<div></div>"
}))
.unwrap();
let tool = make_tool("t", Some(meta));
let validator = AppValidator::new(AppValidationMode::ChatGpt, None);
let results = validator.validate_tools(&[tool], &[]);
let chatgpt_results: Vec<_> = results
.iter()
.filter(|r| r.name.contains("ChatGPT"))
.collect();
assert!(!chatgpt_results.is_empty());
}
#[test]
fn test_strict_mode_promotes_warnings() {
let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
"ui": { "resourceUri": "ui://app/test" }
}))
.unwrap();
let tool = make_tool("t", Some(meta));
let validator = AppValidator::new(AppValidationMode::Standard, None);
let mut results = validator.validate_tools(&[tool], &[]);
for r in &mut results {
if r.status == TestStatus::Warning {
r.status = TestStatus::Failed;
}
}
let warnings = results
.iter()
.filter(|r| r.status == TestStatus::Warning)
.count();
assert_eq!(warnings, 0, "Strict mode should have zero warnings");
}
#[test]
fn test_tool_filter() {
let meta = serde_json::from_value::<serde_json::Map<String, Value>>(json!({
"ui": { "resourceUri": "ui://app/chess" }
}))
.unwrap();
let tool1 = make_tool("chess", Some(meta));
let tool2 = make_tool("other", None);
let validator = AppValidator::new(AppValidationMode::Standard, Some("other".to_string()));
let results = validator.validate_tools(&[tool1, tool2], &[]);
assert!(results.iter().any(|r| r.name.contains("other")));
assert!(!results.iter().any(|r| r.name.contains("chess")));
}
fn make_widget_html(snippets: &[&str]) -> String {
let mut s = String::from("<!doctype html><html><body>");
for snip in snippets {
s.push_str("<script>");
s.push_str(snip);
s.push_str("</script>");
}
s.push_str("</body></html>");
s
}
#[test]
fn regexes_compile() {
let _ = script_block_re();
let _ = ext_apps_import_re();
let _ = ext_apps_log_prefix_re();
let _ = ui_initialize_method_re();
let _ = ui_tool_result_method_re();
let _ = app_constructor_re();
let _ = handler_onteardown_re();
let _ = handler_ontoolinput_re();
let _ = handler_ontoolcancelled_re();
let _ = handler_onerror_re();
let _ = handler_ontoolresult_re();
let _ = connect_call_re();
let _ = chatgpt_only_channels_re();
let _ = html_comment_re();
let _ = js_block_comment_re();
let _ = js_line_comment_re();
}
#[test]
fn extract_inline_scripts_concatenates() {
let out = extract_inline_scripts("<script>A</script><script>B</script>");
assert!(out.contains('A'), "must contain script body A: {out}");
assert!(out.contains('B'), "must contain script body B: {out}");
}
#[test]
fn extract_inline_scripts_excludes_json() {
let html = r#"<script type="application/json">{"x":"@modelcontextprotocol/ext-apps"}</script><script>real</script>"#;
let out = extract_inline_scripts(html);
assert!(
!out.contains("@modelcontextprotocol/ext-apps"),
"JSON data island must NOT be included: {out}"
);
assert!(
out.contains("real"),
"real script body must be present: {out}"
);
}
#[test]
fn extract_inline_scripts_excludes_src() {
let html = r#"<script src="foo.js"></script><script>inline</script>"#;
let out = extract_inline_scripts(html);
assert!(out.contains("inline"), "inline body must remain: {out}");
assert!(
!out.contains("foo.js"),
"src attribute must NOT appear in body output: {out}"
);
}
#[test]
fn scan_widget_detects_handlers_via_property_assignment() {
let html = make_widget_html(&[
r#"var n=new App({name:"x",version:"1.0.0"});n.onteardown=async()=>{};n.ontoolinput=()=>{};n.ontoolcancelled=()=>{};n.onerror=()=>{};n.connect();"#,
]);
let signals = scan_widget(&html);
assert!(signals.has_app_constructor, "must detect new App({{...}})");
assert!(signals.has_connect, "must detect .connect()");
assert_eq!(
signals.handlers_present.len(),
4,
"must detect all 4 handlers via property-assignment regex (got {:?})",
signals.handlers_present
);
}
#[test]
fn scan_widget_detects_import_literal() {
let html = r#"<!doctype html><html><body><script type="module">
import { App } from "@modelcontextprotocol/ext-apps";
const a=new App({name:"x",version:"1"});
a.connect();
</script></body></html>"#;
let signals = scan_widget(html);
assert!(
signals.has_ext_apps_import,
"must detect @modelcontextprotocol/ext-apps import literal"
);
}
#[test]
fn scan_widget_detects_chatgpt_only_channels() {
let html = make_widget_html(&[r#"window.openai.something()"#]);
let signals = scan_widget(&html);
assert!(
signals.has_chatgpt_only_channels,
"must detect window.openai usage"
);
}
#[test]
fn strip_js_comments_strips_line_comments() {
let out = strip_js_comments("a // hidden\nb");
assert!(
!out.contains("hidden"),
"line-comment text must be stripped: {out}"
);
}
#[test]
fn strip_js_comments_strips_block_comments() {
let out = strip_js_comments("a /* hidden */ b");
assert!(
!out.contains("hidden"),
"block-comment text must be stripped: {out}"
);
assert!(out.contains('a'), "non-comment 'a' must remain: {out}");
assert!(out.contains('b'), "non-comment 'b' must remain: {out}");
}
#[test]
fn strip_js_comments_strips_html_comments() {
let out = strip_js_comments("<!-- hidden -->visible");
assert!(
!out.contains("hidden"),
"html-comment text must be stripped: {out}"
);
assert!(
out.contains("visible"),
"non-comment 'visible' must remain: {out}"
);
}
#[test]
fn scan_widget_ignores_signals_inside_comments() {
let html = r#"<!doctype html><html><body><script type="module">
// import { App } from "@modelcontextprotocol/ext-apps";
/* const a = new App({name:"x",version:"1"});
a.onteardown=()=>{}; a.ontoolinput=()=>{}; */
<!-- a.connect(); a.onerror=()=>{}; a.ontoolcancelled=()=>{}; -->
</script></body></html>"#;
let signals = scan_widget(html);
assert!(
!signals.has_ext_apps_import,
"ext-apps import in comment must NOT match"
);
assert!(
!signals.has_app_constructor,
"new App() in comment must NOT match"
);
assert!(!signals.has_connect, "connect() in comment must NOT match");
assert!(
signals.handlers_present.is_empty(),
"handlers in comments must NOT match (got {:?})",
signals.handlers_present
);
}
#[test]
fn scan_widget_g1_log_prefix_alone_satisfies_has_sdk() {
let html = r#"<html><body><script>console.log("[ext-apps] boot");</script></body></html>"#;
let s = scan_widget(html);
assert!(s.has_log_prefix, "expected has_log_prefix true");
assert!(s.has_sdk, "G1: log prefix alone must satisfy has_sdk");
assert!(!s.has_ext_apps_import);
assert!(!s.has_method_initialize);
assert!(!s.has_method_tool_result);
}
#[test]
fn scan_widget_g1_method_initialize_alone_satisfies_has_sdk() {
let html = r#"<html><body><script>rpc("ui/initialize",{});</script></body></html>"#;
let s = scan_widget(html);
assert!(
s.has_method_initialize,
"expected has_method_initialize true"
);
assert!(s.has_sdk, "G1: ui/initialize alone must satisfy has_sdk");
}
#[test]
fn scan_widget_g1_method_tool_result_alone_satisfies_has_sdk() {
let html =
r#"<html><body><script>rpc("ui/notifications/tool-result",{});</script></body></html>"#;
let s = scan_widget(html);
assert!(
s.has_method_tool_result,
"expected has_method_tool_result true"
);
assert!(
s.has_sdk,
"G1: ui/notifications/tool-result alone must satisfy has_sdk"
);
}
#[test]
fn scan_widget_g1_legacy_import_still_satisfies_has_sdk() {
let html = r#"<html><body><script type="module">import { App } from "@modelcontextprotocol/ext-apps";</script></body></html>"#;
let s = scan_widget(html);
assert!(s.has_ext_apps_import, "expected has_ext_apps_import true");
assert!(
s.has_sdk,
"Legacy import literal must still satisfy has_sdk"
);
}
#[test]
fn scan_widget_g1_no_signals_means_no_sdk() {
let html = r#"<html><body><script>var x=1;</script></body></html>"#;
let s = scan_widget(html);
assert!(!s.has_sdk, "no SDK signals → has_sdk = false");
assert!(!s.has_ext_apps_import);
assert!(!s.has_log_prefix);
assert!(!s.has_method_initialize);
assert!(!s.has_method_tool_result);
}
#[test]
fn scan_widget_g2_mangled_yl_constructor_matches() {
let html = r#"<html><body><script>var a=new yl({name:"cost-coach-cost-summary",version:"1.0.0"});</script></body></html>"#;
let s = scan_widget(html);
assert!(
s.has_app_constructor,
"G2: mangled `yl` constructor with intact payload must match"
);
}
#[test]
fn scan_widget_g2_mangled_gl_constructor_matches() {
let html = r#"<html><body><script>var a=new gl({name:"cost-coach-cost-over-time",version:"1.0.0"});</script></body></html>"#;
let s = scan_widget(html);
assert!(
s.has_app_constructor,
"G2: mangled `gl` constructor must match"
);
}
#[test]
fn scan_widget_g2_unminified_app_constructor_still_matches() {
let html = r#"<html><body><script type="module">const app = new App({name: "tool", version: "1.0.0"});</script></body></html>"#;
let s = scan_widget(html);
assert!(
s.has_app_constructor,
"G2: unminified `App` constructor must still match (App is a valid identifier under the regex)"
);
}
#[test]
fn scan_widget_g2_random_new_call_without_name_version_payload_does_not_match() {
let html = r#"<html><body><script>var d=new Date(2026,1,1);var u=new URL("http://x");</script></body></html>"#;
let s = scan_widget(html);
assert!(
!s.has_app_constructor,
"G2: random `new <X>(...)` calls without {{name, version}} payload must NOT match"
);
}
#[test]
fn scan_widget_g3_handlers_detected_independently_of_has_sdk() {
let html = r#"<html><body><script>
var obj={};
obj.onteardown=async()=>({});
obj.ontoolinput=function(p){};
obj.ontoolcancelled=function(p){};
obj.onerror=function(e){};
obj.connect();
</script></body></html>"#;
let s = scan_widget(html);
assert!(!s.has_sdk, "G3: no SDK signals → has_sdk false");
assert!(
!s.has_app_constructor,
"G3: no constructor → has_app_constructor false"
);
assert!(
s.has_handlers,
"G3: handlers detected independently of has_sdk"
);
assert!(
s.has_connect,
"G3: connect detected independently of has_sdk"
);
assert_eq!(
s.handlers_present.len(),
4,
"G3: all 4 handlers detected by member-name regex"
);
}
#[test]
fn scan_widget_g3_chatgpt_only_diagnosis_requires_genuine_evidence_absence() {
let html_a = r#"<html><body><script>window.openai.x();</script></body></html>"#;
let s_a = scan_widget(html_a);
assert!(s_a.has_chatgpt_only_channels);
assert!(!s_a.has_sdk);
assert!(s_a.handlers_present.is_empty());
let html_b = r#"<html><body><script>console.log("[ext-apps]");window.openai.x();</script></body></html>"#;
let s_b = scan_widget(html_b);
assert!(s_b.has_chatgpt_only_channels);
assert!(s_b.has_sdk);
}
fn corrected_widget_html() -> &'static str {
r#"<!doctype html><html><body><script type="module">
import { App } from "@modelcontextprotocol/ext-apps";
const a = new App({ name: "x", version: "1.0.0" });
a.onteardown = () => {};
a.ontoolinput = () => {};
a.ontoolcancelled = () => {};
a.onerror = () => {};
a.connect();
</script></body></html>"#
}
#[test]
fn claude_desktop_mode_emits_failed_for_missing_handlers() {
let html = r#"<!doctype html><html><body><script type="module">
import { App } from "@modelcontextprotocol/ext-apps";
const a = new App({ name: "x", version: "1.0.0" });
a.connect();
</script></body></html>"#;
let validator = AppValidator::new(AppValidationMode::ClaudeDesktop, None);
let results = validator.validate_widgets(&[(
"cost-coach".to_string(),
"ui://test".to_string(),
html.to_string(),
)]);
let failed: Vec<_> = results
.iter()
.filter(|r| r.status == TestStatus::Failed)
.collect();
assert!(
failed.len() >= 4,
"must emit >=4 Failed rows (got {})",
failed.len()
);
let any_onteardown = failed.iter().any(|r| r.name.contains("onteardown"));
assert!(any_onteardown, "must emit a Failed row naming onteardown");
for r in &failed {
assert!(
r.name.contains("cost-coach"),
"Failed row name must include tool name (REVISION HIGH-4): {}",
r.name
);
}
}
#[test]
fn standard_mode_emits_one_summary_warn_per_widget() {
let html = r#"<!doctype html><html><body><script type="module">
import { App } from "@modelcontextprotocol/ext-apps";
const a = new App({ name: "x", version: "1.0.0" });
a.onerror = () => {};
a.connect();
</script></body></html>"#;
let validator = AppValidator::new(AppValidationMode::Standard, None);
let results = validator.validate_widgets(&[(
"cost-coach".to_string(),
"ui://test".to_string(),
html.to_string(),
)]);
let warns: Vec<_> = results
.iter()
.filter(|r| r.status == TestStatus::Warning)
.collect();
assert_eq!(
warns.len(),
1,
"Standard mode must emit EXACTLY 1 Warning per widget (got {} for results: {:?})",
warns.len(),
results
.iter()
.map(|r| (&r.name, &r.status))
.collect::<Vec<_>>(),
);
let warn = warns[0];
assert!(
warn.name.contains("cost-coach"),
"summary WARN name must include tool name (REVISION HIGH-4): {}",
warn.name
);
assert!(
warn.name.contains("ui://test"),
"summary WARN name must include uri: {}",
warn.name
);
let details = warn
.details
.as_ref()
.expect("summary WARN must have details");
assert!(
details.contains("onteardown"),
"summary details must list onteardown as missing: {details}"
);
assert!(
details.contains("ontoolinput"),
"summary details must list ontoolinput as missing: {details}"
);
assert!(
details.contains("ontoolcancelled"),
"summary details must list ontoolcancelled as missing: {details}"
);
let failed = results
.iter()
.filter(|r| r.status == TestStatus::Failed)
.count();
assert_eq!(
failed, 0,
"Standard mode must NOT emit any Failed rows from widget signals"
);
}
#[test]
fn claude_desktop_mode_passes_corrected_widget() {
let validator = AppValidator::new(AppValidationMode::ClaudeDesktop, None);
let results = validator.validate_widgets(&[(
"good".to_string(),
"ui://good".to_string(),
corrected_widget_html().to_string(),
)]);
let failed = results
.iter()
.filter(|r| r.status == TestStatus::Failed)
.count();
assert_eq!(
failed, 0,
"Corrected widget must produce ZERO Failed rows under ClaudeDesktop (got {failed} for results: {:?})",
results
.iter()
.map(|r| (&r.name, &r.status))
.collect::<Vec<_>>(),
);
}
#[test]
fn sdk_signal_requires_independent_evidence_no_fallback() {
let html = make_widget_html(&[
r#"var n=new App({name:"x",version:"1.0.0"});n.onteardown=()=>{};n.ontoolinput=()=>{};n.ontoolcancelled=()=>{};n.connect();"#,
]);
let validator = AppValidator::new(AppValidationMode::ClaudeDesktop, None);
let results = validator.validate_widgets(&[(
"minified".to_string(),
"ui://minified".to_string(),
html,
)]);
let sdk_row = results
.iter()
.find(|r| r.name.contains("MCP Apps SDK wiring"))
.expect("must emit MCP Apps SDK wiring row");
assert_eq!(
sdk_row.status,
TestStatus::Failed,
"G3: SDK signal must NOT cascade off handler count — handlers alone do not imply SDK presence: {sdk_row:?}"
);
let onteardown_row = results
.iter()
.find(|r| r.name.contains("handler: onteardown"))
.expect("must emit handler: onteardown row");
assert_eq!(
onteardown_row.status,
TestStatus::Passed,
"G3: handler row passes independently of SDK row: {onteardown_row:?}"
);
}
#[test]
fn chatgpt_only_channels_fails_in_claude_desktop() {
let html = make_widget_html(&[
r#"window.openai.something();window.parent.postMessage({type:"x"}, "*");"#,
]);
let validator = AppValidator::new(AppValidationMode::ClaudeDesktop, None);
let results = validator.validate_widgets(&[(
"chatgpt-flavored".to_string(),
"ui://chatgpt".to_string(),
html,
)]);
let chatgpt_row = results
.iter()
.find(|r| r.status == TestStatus::Failed && r.name.contains("chatgpt-only"));
assert!(
chatgpt_row.is_some(),
"must emit a Failed row mentioning chatgpt-only channels under ClaudeDesktop (got: {:?})",
results
.iter()
.map(|r| (&r.name, &r.status))
.collect::<Vec<_>>(),
);
}
#[test]
fn chatgpt_mode_emits_no_widget_results() {
let html = r#"<!doctype html><html><body><script>
window.openai = {};
window.parent.postMessage({type:"x"}, "*");
</script></body></html>"#;
let validator = AppValidator::new(AppValidationMode::ChatGpt, None);
let results = validator.validate_widgets(&[(
"broken-tool".to_string(),
"ui://broken".to_string(),
html.to_string(),
)]);
assert_eq!(
results.len(),
0,
"ChatGpt mode must emit zero widget-related rows (got {} rows: {:?})",
results.len(),
results
.iter()
.map(|r| (&r.name, &r.status))
.collect::<Vec<_>>(),
);
}
#[test]
fn claude_desktop_mode_emits_failed_not_warning() {
let html = r#"<!doctype html><html><body><script type="module">
// No handlers at all, just an import + new App + connect.
import { App } from "@modelcontextprotocol/ext-apps";
const a = new App({ name: "x", version: "1.0.0" });
a.connect();
</script></body></html>"#;
let validator = AppValidator::new(AppValidationMode::ClaudeDesktop, None);
let results = validator.validate_widgets(&[(
"broken".to_string(),
"ui://broken".to_string(),
html.to_string(),
)]);
let warning_rows: Vec<_> = results
.iter()
.filter(|r| r.status == TestStatus::Warning)
.collect();
for w in &warning_rows {
assert!(
w.name.contains("ontoolresult"),
"Under ClaudeDesktop, only `ontoolresult` may stay Warning. Found: {}",
w.name
);
}
}
#[test]
fn standard_mode_corrected_widget_emits_zero_warnings() {
let validator = AppValidator::new(AppValidationMode::Standard, None);
let results = validator.validate_widgets(&[(
"good".to_string(),
"ui://good".to_string(),
corrected_widget_html().to_string(),
)]);
let warnings = results
.iter()
.filter(|r| r.status == TestStatus::Warning)
.count();
assert_eq!(
warnings, 0,
"Fully corrected widget under Standard mode must produce ZERO Warning rows (got {warnings} for results: {:?})",
results
.iter()
.map(|r| (&r.name, &r.status))
.collect::<Vec<_>>(),
);
}
#[test]
fn chatgpt_mode_corrected_widget_also_emits_zero() {
let validator = AppValidator::new(AppValidationMode::ChatGpt, None);
let results = validator.validate_widgets(&[(
"good".to_string(),
"ui://good".to_string(),
corrected_widget_html().to_string(),
)]);
assert_eq!(
results.len(),
0,
"ChatGpt mode emits zero widget rows even for fully corrected widgets (got: {:?})",
results
.iter()
.map(|r| (&r.name, &r.status))
.collect::<Vec<_>>(),
);
}
#[test]
fn strip_js_comments_preserves_block_comment_inside_double_quoted_string() {
let src = r#"var csp = "frame-src /*.example.com"; var i = "[ext-apps] App.connect() failed"; /* license */ var x = 1;"#;
let stripped = strip_js_comments(src);
assert!(
stripped.contains("[ext-apps]"),
"SDK literal must be preserved when /* appears inside a string literal; got: {stripped}",
);
assert!(
stripped.contains("/*.example.com"),
"CSP string content must be preserved; got: {stripped}",
);
assert!(
!stripped.contains("license"),
"Real block comments outside strings MUST still be stripped; got: {stripped}",
);
}
#[test]
fn strip_js_comments_preserves_block_comment_inside_single_quoted_string() {
let src = "var csp = 'frame-src /*.example.com'; /* real */ var x = 1;";
let stripped = strip_js_comments(src);
assert!(stripped.contains("/*.example.com"));
assert!(!stripped.contains("real"));
}
#[test]
fn strip_js_comments_preserves_line_comment_marker_inside_string() {
let src = "var url = \"https://example.com/path\"; // real comment\nvar keep = 1;";
let stripped = strip_js_comments(src);
assert!(
stripped.contains("https://example.com/path"),
"URL string with // must be preserved; got: {stripped}",
);
assert!(
!stripped.contains("real comment"),
"Real // line comment outside strings MUST still be stripped; got: {stripped}",
);
assert!(stripped.contains("var keep = 1"));
}
#[test]
fn strip_js_comments_still_strips_real_block_comments_outside_strings() {
let src = "var x = 1; /* this is a comment */ var y = 2;";
let stripped = strip_js_comments(src);
assert!(!stripped.contains("this is a comment"));
assert!(stripped.contains("var x = 1"));
assert!(stripped.contains("var y = 2"));
}
#[test]
fn strip_js_comments_still_strips_real_line_comments_outside_strings() {
let src = "var x = 1; // line comment\nvar y = 2;";
let stripped = strip_js_comments(src);
assert!(!stripped.contains("line comment"));
assert!(stripped.contains("var x = 1"));
assert!(stripped.contains("var y = 2"));
}
#[test]
fn strip_js_comments_handles_escaped_string_delimiters() {
let src = r#"var s = "He said \"hi\" /* not a comment */"; var z = 1;"#;
let stripped = strip_js_comments(src);
assert!(
stripped.contains("not a comment"),
"Block-comment-style text inside a string with escaped quotes must be preserved; got: {stripped}",
);
assert!(stripped.contains("var z = 1"));
}
#[test]
fn strip_js_comments_handles_template_literal() {
let src = r#"var t = `template /* not a comment */`; /* real */ var x = 1;"#;
let stripped = strip_js_comments(src);
assert!(stripped.contains("not a comment"));
assert!(!stripped.contains("real"));
}
#[test]
fn scan_widget_g2_cycle2_string_concat_name_value_matches() {
let html = r#"<script>function f(t){var i=new yl({name:"cost-coach-"+t,version:"1.0.0"});}</script>"#;
let signals = scan_widget(html);
assert!(
signals.has_app_constructor,
"G2 cycle-2: must match new <id>({{name:<concat-expr>, version:<expr>}}); signals: {signals:?}",
);
}
#[test]
fn scan_widget_g2_cycle2_longer_mangled_id_matches() {
let html = r#"<script>var i=new abcdefg({name:"x",version:"1"});</script>"#;
let signals = scan_widget(html);
assert!(signals.has_app_constructor);
}
#[test]
fn scan_widget_g2_cycle2_random_new_call_with_unrelated_keys_does_not_match() {
let html = r#"<script>var i=new Date({year:2026,month:1});</script>"#;
let signals = scan_widget(html);
assert!(
!signals.has_app_constructor,
"G2 cycle-2: must NOT match new Date(...) without name/version keys; signals: {signals:?}",
);
}
#[test]
fn scan_widget_g2_cycle2_real_cost_coach_prod_pattern() {
let html = r#"<script>function Rw(t,e){var i=new yl({name:"cost-coach-"+t,version:"1.0.0"});return i.connect(),i;}</script>"#;
let signals = scan_widget(html);
assert!(
signals.has_app_constructor,
"G2 cycle-2: real prod shape; signals: {signals:?}",
);
}
#[test]
fn scan_widget_g1_cycle2_csp_string_does_not_steal_sdk_section() {
let html = concat!(
r##"<script>var csp = "frame-src /*.example.com"; "##,
r##"var msg = "[ext-apps] App.connect() called before connect"; "##,
r##"function f(t){var i=new yl({name:"cost-coach-"+t,version:"1.0.0"}); "##,
r##"i.onteardown = function(){}; "##,
r##"i.ontoolinput = function(){}; "##,
r##"i.ontoolcancelled = function(){}; "##,
r##"i.onerror = function(){}; "##,
r##"i.connect();} "##,
r##"/* @license real banner */ var x = 1;</script>"##,
);
let signals = scan_widget(html);
assert!(
signals.has_log_prefix,
"G1 cycle-2: [ext-apps] preserved through string-literal-aware stripping; signals: {signals:?}",
);
assert!(signals.has_sdk);
assert!(signals.has_app_constructor);
assert!(signals.has_handlers);
assert!(signals.has_connect);
}
}