From a050d702422cf640e4b1938a9520a64480640a5c Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 7 Sep 2026 17:31:21 +0800 Subject: [PATCH] fix(mcp): keep servers healthy when ping is unsupported --- .../services/services-integrations/AGENTS.md | 1 + .../src/mcp/protocol/transport_remote.rs | 24 +++++- .../tests/mcp_streamable_http_contracts.rs | 75 ++++++++++++++++++- tests/e2e/AGENTS.md | 13 ++++ tests/e2e/specs/l1-mcp-huawei-health.spec.ts | 71 ++++++++++++++++++ 5 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/specs/l1-mcp-huawei-health.spec.ts diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index bcc3f4f373..b734c1dc11 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -109,6 +109,7 @@ streamable HTTP stay independent. Representative stable entry points are: ```bash cargo check -p openbitfun-services-integrations --no-default-features cargo test -p openbitfun-services-integrations --no-default-features --features mcp --test mcp_contracts +cargo test -p openbitfun-services-integrations --no-default-features --features mcp --test mcp_streamable_http_contracts cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh --test remote_ssh_contracts remote_ssh_disabled_contracts:: cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::manager::tests::workspace_ cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::wsl::tests:: diff --git a/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs b/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs index 3b378e6da0..674aa3d2f5 100644 --- a/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs +++ b/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs @@ -562,8 +562,28 @@ impl RemoteMCPTransport { fut, "MCP ping timeout".to_string(), ) - .await? - .map_err(|e| MCPRuntimeError::mcp(format!("MCP ping failed: {}", e)))?; + .await?; + + let result = match result { + // Some reachable HTTP servers (including Huawei Developer Knowledge) + // omit ping. Verify a supported read-only operation instead of + // putting an otherwise usable connection into a reconnect loop. + Err(rmcp::service::ServiceError::McpError(error)) + if error.code == rmcp::model::ErrorCode::METHOD_NOT_FOUND + && service + .peer() + .peer_info() + .is_some_and(|info| info.capabilities.tools.is_some()) => + { + debug!("MCP server does not implement ping; checking tools/list"); + self.list_tools(None).await.map_err(|error| { + MCPRuntimeError::mcp(format!("MCP health check tools/list failed: {}", error)) + })?; + return Ok(()); + } + other => other + .map_err(|error| MCPRuntimeError::mcp(format!("MCP ping failed: {}", error)))?, + }; match result { rmcp::model::ServerResult::EmptyResult(_) => Ok(()), diff --git a/src/crates/services/services-integrations/tests/mcp_streamable_http_contracts.rs b/src/crates/services/services-integrations/tests/mcp_streamable_http_contracts.rs index 53814bbf45..6abd7056c1 100644 --- a/src/crates/services/services-integrations/tests/mcp_streamable_http_contracts.rs +++ b/src/crates/services/services-integrations/tests/mcp_streamable_http_contracts.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -23,6 +23,9 @@ struct TestState { saw_sampling_capability: Arc, saw_elicitation_capability: Arc, initialize_delay_ms: Arc, + ping_error: Arc, + tools_error: Arc, + tools_requests: Arc, } struct TestRequest { @@ -206,6 +209,11 @@ async fn handle_post( // which should be treated as Accepted by the client. "notifications/initialized" => write_response(stream, "200 OK", &[], "").await, "tools/list" => { + state.tools_requests.fetch_add(1, Ordering::SeqCst); + if state.tools_error.load(Ordering::SeqCst) { + return write_response(stream, "500 Internal Server Error", &[], "unavailable") + .await; + } let sid = headers .get("mcp-session-id") .map(String::as_str) @@ -257,6 +265,17 @@ async fn handle_post( } Ok(()) } + "ping" if state.ping_error.load(Ordering::SeqCst) != 0 => { + let response = json!({"jsonrpc": "2.0", "id": id, + "error": {"code": state.ping_error.load(Ordering::SeqCst), "message": "fixture ping error"}}); + write_response( + stream, + "200 OK", + &[("Content-Type", "application/json")], + &response.to_string(), + ) + .await + } _ => { let response = json!({ "jsonrpc": "2.0", @@ -492,3 +511,57 @@ async fn remote_mcp_streamable_http_accepts_202_and_delivers_response_via_sse() "client should advertise elicitation capability" ); } + +#[tokio::test] +async fn remote_mcp_health_falls_back_only_for_unsupported_ping() { + let state = TestState::default(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server_state = state.clone(); + let server = tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + let state = server_state.clone(); + tokio::spawn(async move { + let _ = handle_connection(stream, state).await; + }); + } + }); + let connection = MCPConnection::new_remote( + "health-test", + format!("http://{addr}/mcp"), + Default::default(), + false, + ) + .await + .unwrap(); + connection + .initialize("OpenBitFunTest", "1.0.0") + .await + .unwrap(); + if !state.sse_connected.load(Ordering::SeqCst) { + tokio::time::timeout( + Duration::from_secs(2), + state.sse_connected_notify.notified(), + ) + .await + .unwrap(); + } + connection.ping().await.unwrap(); + assert_eq!(state.tools_requests.load(Ordering::SeqCst), 0); + state.ping_error.store(-32601, Ordering::SeqCst); + connection + .ping() + .await + .expect("unsupported ping should use tools/list"); + assert_eq!(state.tools_requests.load(Ordering::SeqCst), 1); + state.ping_error.store(-32603, Ordering::SeqCst); + assert!(connection.ping().await.is_err()); + assert_eq!(state.tools_requests.load(Ordering::SeqCst), 1); + state.ping_error.store(-32601, Ordering::SeqCst); + state.tools_error.store(true, Ordering::SeqCst); + assert!( + connection.ping().await.is_err(), + "failed fallback must not report healthy" + ); + server.abort(); +} diff --git a/tests/e2e/AGENTS.md b/tests/e2e/AGENTS.md index 2ad3edfb54..7f13f9e92a 100644 --- a/tests/e2e/AGENTS.md +++ b/tests/e2e/AGENTS.md @@ -62,3 +62,16 @@ screenshots plus `result.json` under the printed temporary evidence directory. Set `GITEE_TOKEN` in the runner environment to authenticate both the desktop and independent API reads when anonymous quota is exhausted. Do not put tokens in the test source, command arguments, or retained evidence. + +### Huawei knowledge MCP (live, opt-in) + +After building Desktop, verify the public Huawei endpoint through settings, +health checks, document search, and document retrieval with isolated storage: + +```bash +OPENBITFUN_E2E_HUAWEI_MCP=1 OPENBITFUN_E2E_STORAGE_ROOT="$(mktemp -d /tmp/openbitfun-huawei-e2e.XXXXXX)" pnpm --dir tests/e2e exec wdio run ./config/wdio.conf.ts --spec "./specs/l1-mcp-huawei-health.spec.ts" +``` + +The spec is skipped by default, uses no account credentials, and restores the +isolated MCP configuration after the run. Internet access and availability of +the public endpoint are required. diff --git a/tests/e2e/specs/l1-mcp-huawei-health.spec.ts b/tests/e2e/specs/l1-mcp-huawei-health.spec.ts new file mode 100644 index 0000000000..647bd5752a --- /dev/null +++ b/tests/e2e/specs/l1-mcp-huawei-health.spec.ts @@ -0,0 +1,71 @@ +import { $, browser, expect } from '@wdio/globals'; +import { MCPSettingsPage } from '../page-objects/MCPSettingsPage'; + +type Snapshot = { jsonConfig: string; fingerprint: string }; +async function invoke(command: string, args: unknown = {}): Promise { + return browser.execute(async (name: string, params: unknown) => { + const host = window as typeof window & { __TAURI__: { core: { invoke: (name: string, params: unknown) => Promise } } }; + return host.__TAURI__.core.invoke(name, params); + }, command, args); +} + +// Live opt-in: exercises the documented public endpoint without account credentials. +const live = process.env.OPENBITFUN_E2E_HUAWEI_MCP === '1' ? describe : describe.skip; +live('L1 Huawei knowledge MCP health and document search', () => { + const page = new MCPSettingsPage(); + const serverId = 'e2e-huawei-knowledge'; + let original: Snapshot; + before(async () => { + if (process.env.OPENBITFUN_E2E_STORAGE_GUARD !== '1') throw new Error('Isolated E2E storage required'); + original = await invoke('load_mcp_json_config'); + await page.open(); + await page.openEditor(); + await page.edit(JSON.stringify({ mcpServers: { [serverId]: { + type: 'http', url: 'https://connect-api.cloud.huawei.com/api/developerknowledge/mcp', + enabled: true, autoStart: true, + } } })); + await page.save(); + await page.input.waitForDisplayed({ reverse: true, timeout: 45000 }); + }); + it('stays usable after the immediate health probe and calls searchDocuments', async () => { + const row = await $(`[data-testid="mcp-server-item"][data-server-id="${serverId}"]`); + await row.waitForDisplayed(); + // Check after the immediate heartbeat, not just the initial handshake. + await browser.pause(3000); + const servers = await invoke>('get_mcp_servers'); + console.log('HUAWEI_MCP_STATUS', JSON.stringify(servers.find(server => server.id === serverId))); + expect(servers.find(server => server.id === serverId)?.status).toMatch(/^(healthy|connected)$/i); + await page.waitForConnected(serverId); + const result = await invoke<{ result: { content: Array<{ type: string; text?: string }>; isError: boolean } }>('send_mcp_app_message', { request: { + serverId, + jsonrpc: '2.0', id: 1, method: 'tools/call', params: { + name: 'searchDocuments', arguments: { SearchDocumentsReq: { query: 'ArkUI Text component' } }, + }, + } }); + console.log('HUAWEI_MCP_SEARCH', JSON.stringify(result).slice(0, 1800)); + expect(result.result.isError).toBe(false); + const text = result.result.content.find(item => item.type === 'text')?.text; + const search = JSON.parse(text!); + expect(search.code).toBe(0); + expect(search.resultList.length).toBeGreaterThan(0); + const document = await invoke<{ result: { content: Array<{ type: string; text?: string }>; isError: boolean } }>('send_mcp_app_message', { request: { + serverId, jsonrpc: '2.0', id: 2, method: 'tools/call', params: { + name: 'getDocumentsById', arguments: { GetDocumentsByIdRequest: { names: [search.resultList[0].parent] } }, + }, + } }); + expect(document.result.isError).toBe(false); + const detail = JSON.parse(document.result.content.find(item => item.type === 'text')?.text!); + expect(detail.code).toBe(0); + expect(detail.resultList.length).toBeGreaterThan(0); + console.log('HUAWEI_MCP_DOCUMENT', JSON.stringify(detail).slice(0, 800)); + await browser.pause(31000); + const settled = await invoke>('get_mcp_servers'); + expect(settled.find(server => server.id === serverId)?.status).toMatch(/^(healthy|connected)$/i); + await browser.saveScreenshot('/tmp/huawei-mcp-e2e.png'); + }); + after(async () => { + if (!original) return; + const current = await invoke('load_mcp_json_config'); + await invoke('save_mcp_json_config', { jsonConfig: original.jsonConfig, expectedFingerprint: current.fingerprint }); + }); +});