Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/crates/services/services-integrations/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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::
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(()),
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -23,6 +23,9 @@ struct TestState {
saw_sampling_capability: Arc<AtomicBool>,
saw_elicitation_capability: Arc<AtomicBool>,
initialize_delay_ms: Arc<AtomicU64>,
ping_error: Arc<AtomicI32>,
tools_error: Arc<AtomicBool>,
tools_requests: Arc<AtomicU64>,
}

struct TestRequest {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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();
}
13 changes: 13 additions & 0 deletions tests/e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
71 changes: 71 additions & 0 deletions tests/e2e/specs/l1-mcp-huawei-health.spec.ts
Original file line number Diff line number Diff line change
@@ -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<T>(command: string, args: unknown = {}): Promise<T> {
return browser.execute(async (name: string, params: unknown) => {
const host = window as typeof window & { __TAURI__: { core: { invoke: (name: string, params: unknown) => Promise<T> } } };
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<Snapshot>('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<Array<{ id: string; status: string; last_error?: string }>>('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<Array<{ id: string; status: string }>>('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<Snapshot>('load_mcp_json_config');
await invoke('save_mcp_json_config', { jsonConfig: original.jsonConfig, expectedFingerprint: current.fingerprint });
});
});
Loading