Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
//! Integration tests for McpAgentAdapter trait and DetectedServer.
|
||||
//!
|
||||
//! Uses a mock adapter to verify the trait's public API contract:
|
||||
//! object safety, install/detect/remove lifecycle, and error cases.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use nomifun_common::McpSource;
|
||||
use nomifun_mcp::{DetectedServer, McpAgentAdapter, McpError, McpServerTransport};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock adapter (in-memory, for integration tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct InMemoryAdapter {
|
||||
source: McpSource,
|
||||
installed: bool,
|
||||
servers: Mutex<Vec<DetectedServer>>,
|
||||
}
|
||||
|
||||
impl InMemoryAdapter {
|
||||
fn new(source: McpSource, installed: bool) -> Self {
|
||||
Self {
|
||||
source,
|
||||
installed,
|
||||
servers: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpAgentAdapter for InMemoryAdapter {
|
||||
fn source(&self) -> McpSource {
|
||||
self.source
|
||||
}
|
||||
|
||||
async fn is_installed(&self) -> Result<bool, McpError> {
|
||||
Ok(self.installed)
|
||||
}
|
||||
|
||||
async fn detect_existing(&self) -> Result<Vec<DetectedServer>, McpError> {
|
||||
if !self.installed {
|
||||
return Err(McpError::AgentNotInstalled(format!("{:?}", self.source)));
|
||||
}
|
||||
Ok(self.servers.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn install_server(&self, name: &str, transport: &McpServerTransport) -> Result<(), McpError> {
|
||||
if !self.installed {
|
||||
return Err(McpError::AgentNotInstalled(format!("{:?}", self.source)));
|
||||
}
|
||||
let mut servers = self.servers.lock().unwrap();
|
||||
servers.retain(|s| s.name != name);
|
||||
servers.push(DetectedServer {
|
||||
name: name.to_owned(),
|
||||
transport: transport.clone(),
|
||||
importable: true,
|
||||
import_skip_reason: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_server(&self, name: &str) -> Result<(), McpError> {
|
||||
if !self.installed {
|
||||
return Err(McpError::AgentNotInstalled(format!("{:?}", self.source)));
|
||||
}
|
||||
let mut servers = self.servers.lock().unwrap();
|
||||
servers.retain(|s| s.name != name);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn trait_object_safety_with_arc() {
|
||||
let adapter: Arc<dyn McpAgentAdapter> = Arc::new(InMemoryAdapter::new(McpSource::Claude, true));
|
||||
|
||||
assert_eq!(adapter.source(), McpSource::Claude);
|
||||
assert!(adapter.is_installed().await.unwrap());
|
||||
assert!(adapter.detect_existing().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_lifecycle_install_detect_remove() {
|
||||
let adapter = InMemoryAdapter::new(McpSource::Gemini, true);
|
||||
|
||||
// Install two servers
|
||||
let t1 = McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec!["-y".into(), "server-a".into()],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
let t2 = McpServerTransport::Http {
|
||||
url: "https://example.com/mcp".into(),
|
||||
headers: HashMap::from([("Auth".into(), "Bearer x".into())]),
|
||||
};
|
||||
|
||||
adapter.install_server("server-a", &t1).await.unwrap();
|
||||
adapter.install_server("server-b", &t2).await.unwrap();
|
||||
|
||||
// Detect both
|
||||
let detected = adapter.detect_existing().await.unwrap();
|
||||
assert_eq!(detected.len(), 2);
|
||||
|
||||
let names: Vec<&str> = detected.iter().map(|s| s.name.as_str()).collect();
|
||||
assert!(names.contains(&"server-a"));
|
||||
assert!(names.contains(&"server-b"));
|
||||
|
||||
// Remove one
|
||||
adapter.remove_server("server-a").await.unwrap();
|
||||
let detected = adapter.detect_existing().await.unwrap();
|
||||
assert_eq!(detected.len(), 1);
|
||||
assert_eq!(detected[0].name, "server-b");
|
||||
|
||||
// Remove the other
|
||||
adapter.remove_server("server-b").await.unwrap();
|
||||
let detected = adapter.detect_existing().await.unwrap();
|
||||
assert!(detected.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_replaces_existing_by_name() {
|
||||
let adapter = InMemoryAdapter::new(McpSource::Qwen, true);
|
||||
|
||||
let t1 = McpServerTransport::Stdio {
|
||||
command: "old-cmd".into(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
let t2 = McpServerTransport::Stdio {
|
||||
command: "new-cmd".into(),
|
||||
args: vec!["--flag".into()],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
adapter.install_server("my-server", &t1).await.unwrap();
|
||||
adapter.install_server("my-server", &t2).await.unwrap();
|
||||
|
||||
let detected = adapter.detect_existing().await.unwrap();
|
||||
assert_eq!(detected.len(), 1);
|
||||
assert_eq!(detected[0].transport, t2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_nonexistent_is_idempotent() {
|
||||
let adapter = InMemoryAdapter::new(McpSource::Nomifun, true);
|
||||
// Should succeed without error
|
||||
adapter.remove_server("does-not-exist").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_installed_errors() {
|
||||
let adapter = InMemoryAdapter::new(McpSource::Codex, false);
|
||||
|
||||
assert!(!adapter.is_installed().await.unwrap());
|
||||
|
||||
let err = adapter.detect_existing().await.unwrap_err();
|
||||
assert!(matches!(err, McpError::AgentNotInstalled(_)));
|
||||
|
||||
let transport = McpServerTransport::Stdio {
|
||||
command: "x".into(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
let err = adapter.install_server("s", &transport).await.unwrap_err();
|
||||
assert!(matches!(err, McpError::AgentNotInstalled(_)));
|
||||
|
||||
let err = adapter.remove_server("s").await.unwrap_err();
|
||||
assert!(matches!(err, McpError::AgentNotInstalled(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multiple_adapters_independent() {
|
||||
let claude: Arc<dyn McpAgentAdapter> = Arc::new(InMemoryAdapter::new(McpSource::Claude, true));
|
||||
let gemini: Arc<dyn McpAgentAdapter> = Arc::new(InMemoryAdapter::new(McpSource::Gemini, true));
|
||||
|
||||
let transport = McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
claude.install_server("shared-server", &transport).await.unwrap();
|
||||
|
||||
// Claude has the server, Gemini does not
|
||||
assert_eq!(claude.detect_existing().await.unwrap().len(), 1);
|
||||
assert!(gemini.detect_existing().await.unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Integration tests for McpConnectionTestService.
|
||||
//!
|
||||
//! Tests from test-plan §2 (Connection Test):
|
||||
//! - CT-3: Command not found (ENOENT)
|
||||
//! - CT-4: URL not reachable
|
||||
//! - CT-5: Needs OAuth authentication (401)
|
||||
//! - CT-6: Timeout
|
||||
//! - SSE auth probe (M-33 coverage)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use nomifun_mcp::McpConnectionTestService;
|
||||
use nomifun_mcp::McpServerTransport;
|
||||
|
||||
fn make_service() -> McpConnectionTestService {
|
||||
McpConnectionTestService::new(reqwest::Client::new())
|
||||
}
|
||||
|
||||
fn make_service_with_timeout(timeout: Duration) -> McpConnectionTestService {
|
||||
McpConnectionTestService::new(reqwest::Client::new()).with_timeout(timeout)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CT-3: Command not found (ENOENT)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_nonexistent_command_returns_not_found_error() {
|
||||
let svc = make_service();
|
||||
let transport = McpServerTransport::Stdio {
|
||||
command: "nonexistent-mcp-cmd-xyz-12345".into(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = svc.test_connection("test-server", &transport).await;
|
||||
|
||||
assert!(!result.success);
|
||||
let error = result.error.as_deref().unwrap();
|
||||
assert!(
|
||||
error.contains("Command not found"),
|
||||
"expected 'Command not found' in: {error}"
|
||||
);
|
||||
assert!(result.tools.is_none());
|
||||
assert!(result.needs_auth.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CT-4: URL not reachable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_unreachable_url_returns_connection_error() {
|
||||
let svc = make_service_with_timeout(Duration::from_secs(5));
|
||||
let transport = McpServerTransport::Http {
|
||||
url: "http://127.0.0.1:1/mcp-unreachable".into(),
|
||||
headers: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = svc.test_connection("test-http", &transport).await;
|
||||
|
||||
assert!(!result.success);
|
||||
let error = result.error.as_deref().unwrap();
|
||||
assert!(
|
||||
error.contains("Connection failed"),
|
||||
"expected connection failure in: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_unreachable_url_returns_connection_error() {
|
||||
let svc = make_service_with_timeout(Duration::from_secs(5));
|
||||
let transport = McpServerTransport::Sse {
|
||||
url: "http://127.0.0.1:1/sse-unreachable".into(),
|
||||
headers: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = svc.test_connection("test-sse", &transport).await;
|
||||
|
||||
assert!(!result.success);
|
||||
let error = result.error.as_deref().unwrap();
|
||||
assert!(
|
||||
error.contains("Connection failed"),
|
||||
"expected connection failure in: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CT-5: HTTP 401 Unauthorized -> needsAuth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_401_returns_needs_auth() {
|
||||
// Spin up a mock server that returns 401 with WWW-Authenticate
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_handle = tokio::spawn(async move {
|
||||
let app = axum::Router::new().route(
|
||||
"/mcp",
|
||||
axum::routing::post(|| async {
|
||||
(
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
[(axum::http::header::WWW_AUTHENTICATE, "Bearer realm=\"mcp-server\"")],
|
||||
"",
|
||||
)
|
||||
}),
|
||||
);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let svc = make_service();
|
||||
let transport = McpServerTransport::Http {
|
||||
url: format!("http://{}/mcp", addr),
|
||||
headers: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = svc.test_connection("auth-server", &transport).await;
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(result.needs_auth, Some(true));
|
||||
assert!(result.auth_method.is_some());
|
||||
assert!(result.www_authenticate.is_some());
|
||||
assert!(result.error.is_none());
|
||||
|
||||
server_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sse_401_returns_needs_auth() {
|
||||
// Spin up a mock server that returns 401 for GET
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_handle = tokio::spawn(async move {
|
||||
let app = axum::Router::new().route(
|
||||
"/sse",
|
||||
axum::routing::get(|| async {
|
||||
(
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
[(axum::http::header::WWW_AUTHENTICATE, "Bearer realm=\"mcp-sse\"")],
|
||||
"",
|
||||
)
|
||||
}),
|
||||
);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let svc = make_service();
|
||||
let transport = McpServerTransport::Sse {
|
||||
url: format!("http://{}/sse", addr),
|
||||
headers: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = svc.test_connection("sse-auth", &transport).await;
|
||||
|
||||
assert!(!result.success);
|
||||
assert_eq!(result.needs_auth, Some(true));
|
||||
assert!(result.www_authenticate.is_some());
|
||||
|
||||
server_handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CT-6: Timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_timeout_returns_timeout_error() {
|
||||
// Use `sleep` which produces no stdout — our protocol read will block
|
||||
let svc = make_service_with_timeout(Duration::from_secs(1));
|
||||
let transport = McpServerTransport::Stdio {
|
||||
command: "sleep".into(),
|
||||
args: vec!["60".into()],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = svc.test_connection("timeout-server", &transport).await;
|
||||
|
||||
assert!(!result.success);
|
||||
let error = result.error.as_deref().unwrap();
|
||||
assert!(error.contains("timed out"), "expected timeout in: {error}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP non-success status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_500_returns_error_with_status() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_handle = tokio::spawn(async move {
|
||||
let app = axum::Router::new().route(
|
||||
"/mcp",
|
||||
axum::routing::post(|| async { axum::http::StatusCode::INTERNAL_SERVER_ERROR }),
|
||||
);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let svc = make_service();
|
||||
let transport = McpServerTransport::Http {
|
||||
url: format!("http://{}/mcp", addr),
|
||||
headers: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = svc.test_connection("error-server", &transport).await;
|
||||
|
||||
assert!(!result.success);
|
||||
let error = result.error.as_deref().unwrap();
|
||||
assert!(error.contains("500"), "expected HTTP 500 in: {error}");
|
||||
|
||||
server_handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP transport with custom headers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_custom_headers_are_sent() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let server_handle = tokio::spawn(async move {
|
||||
let app = axum::Router::new().route(
|
||||
"/mcp",
|
||||
axum::routing::post(|headers: axum::http::HeaderMap| async move {
|
||||
// Verify the custom header was received
|
||||
if headers.get("x-api-key").and_then(|v| v.to_str().ok()) == Some("secret") {
|
||||
// Return a valid initialize response
|
||||
axum::Json(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"serverInfo": { "name": "test", "version": "1.0" }
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
// Return error if header missing
|
||||
axum::Json(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": { "code": -1, "message": "Missing API key" }
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let svc = make_service();
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("X-Api-Key".into(), "secret".into());
|
||||
let transport = McpServerTransport::Http {
|
||||
url: format!("http://{}/mcp", addr),
|
||||
headers,
|
||||
};
|
||||
|
||||
let result = svc.test_connection("header-server", &transport).await;
|
||||
|
||||
// The server returns a valid initialize response for request id=1,
|
||||
// but the subsequent tools/list (id=2) will also hit the same handler.
|
||||
// Either way, the first request should succeed (no initialize error).
|
||||
// The tools/list might succeed or fail depending on how the mock handles id=2.
|
||||
// For this test, we just verify the custom header was sent (no "Missing API key" error).
|
||||
if let Some(ref error) = result.error {
|
||||
assert!(
|
||||
!error.contains("Missing API key"),
|
||||
"Custom header should have been sent"
|
||||
);
|
||||
}
|
||||
|
||||
server_handle.abort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stdio with args and env
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_with_args_spawns_correctly() {
|
||||
// Use echo as a simple command that exits immediately
|
||||
// Since echo doesn't speak MCP, we expect a protocol error (not a spawn error)
|
||||
let svc = make_service_with_timeout(Duration::from_secs(3));
|
||||
let transport = McpServerTransport::Stdio {
|
||||
command: "echo".into(),
|
||||
args: vec!["hello".into()],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = svc.test_connection("echo-server", &transport).await;
|
||||
|
||||
// echo outputs "hello\n" then exits — not valid JSON-RPC
|
||||
assert!(!result.success);
|
||||
let error = result.error.as_deref().unwrap();
|
||||
// Should be a protocol error, not a spawn error
|
||||
assert!(!error.contains("Command not found"), "echo should be found");
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
//! Isolated PATH-resolution coverage for stdio MCP connection tests.
|
||||
//!
|
||||
//! This file intentionally contains one test because it mutates process PATH
|
||||
//! to model the startup-enhanced GUI environment.
|
||||
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nomifun_mcp::{McpConnectionTestService, McpServerTransport};
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_npx_resolves_from_enhanced_process_path() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let bin_dir = tmp.path().join("bin");
|
||||
std::fs::create_dir(&bin_dir).unwrap();
|
||||
|
||||
let fake_npx = bin_dir.join("npx");
|
||||
std::fs::write(
|
||||
&fake_npx,
|
||||
r#"#!/bin/sh
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*'"id":1'*)
|
||||
printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fake-npx","version":"1.0.0"}}}'
|
||||
;;
|
||||
*'"id":2'*)
|
||||
printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut perms = std::fs::metadata(&fake_npx).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&fake_npx, perms).unwrap();
|
||||
|
||||
let original_path = std::env::var_os("PATH");
|
||||
unsafe {
|
||||
std::env::set_var("PATH", &bin_dir);
|
||||
}
|
||||
|
||||
let svc = McpConnectionTestService::new(reqwest::Client::new());
|
||||
let transport = McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
|
||||
let result = svc.test_connection("fake-npx", &transport).await;
|
||||
|
||||
unsafe {
|
||||
if let Some(path) = original_path {
|
||||
std::env::set_var("PATH", path);
|
||||
} else {
|
||||
std::env::remove_var("PATH");
|
||||
}
|
||||
}
|
||||
|
||||
assert!(result.success, "expected fake npx MCP server to connect: {result:?}");
|
||||
assert!(result.tools.unwrap().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Integration tests for file-based MCP Agent adapters (Opencode, Nomi, Nomi).
|
||||
//!
|
||||
//! These tests exercise the real filesystem read/write logic using temp
|
||||
//! directories. CLI detection (`is_installed`, `which`) is NOT tested here
|
||||
//! because it depends on the host environment.
|
||||
//!
|
||||
//! For Nomi, we use a mock repository since it reads from the DB.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_common::McpSource;
|
||||
use nomifun_mcp::{McpAgentAdapter, McpServerTransport, NomifunAdapter};
|
||||
|
||||
// ===========================================================================
|
||||
// Nomi adapter (DB-backed)
|
||||
// ===========================================================================
|
||||
|
||||
mod nomifun {
|
||||
use super::*;
|
||||
use nomifun_db::models::McpServerRow;
|
||||
use nomifun_db::{CreateMcpServerParams, DbError, IMcpServerRepository, UpdateMcpServerParams};
|
||||
|
||||
struct MockRepo {
|
||||
servers: tokio::sync::Mutex<Vec<McpServerRow>>,
|
||||
}
|
||||
|
||||
impl MockRepo {
|
||||
fn new(servers: Vec<McpServerRow>) -> Self {
|
||||
Self {
|
||||
servers: tokio::sync::Mutex::new(servers),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IMcpServerRepository for MockRepo {
|
||||
async fn list(&self) -> Result<Vec<McpServerRow>, DbError> {
|
||||
Ok(self.servers.lock().await.clone())
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: i64) -> Result<Option<McpServerRow>, DbError> {
|
||||
Ok(self.servers.lock().await.iter().find(|s| s.id == id).cloned())
|
||||
}
|
||||
|
||||
async fn find_by_name(&self, name: &str) -> Result<Option<McpServerRow>, DbError> {
|
||||
Ok(self.servers.lock().await.iter().find(|s| s.name == name).cloned())
|
||||
}
|
||||
|
||||
async fn create(&self, _p: CreateMcpServerParams<'_>) -> Result<McpServerRow, DbError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn update(&self, _id: i64, _p: UpdateMcpServerParams<'_>) -> Result<McpServerRow, DbError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn delete(&self, _id: i64) -> Result<(), DbError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn batch_upsert(&self, _s: &[CreateMcpServerParams<'_>]) -> Result<Vec<McpServerRow>, DbError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn update_status(
|
||||
&self,
|
||||
_id: i64,
|
||||
_s: &str,
|
||||
_lc: Option<nomifun_common::TimestampMs>,
|
||||
) -> Result<(), DbError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn update_tools(&self, _id: i64, _t: Option<&str>) -> Result<(), DbError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_row(name: &str, t_type: &str, t_config: &str) -> McpServerRow {
|
||||
McpServerRow {
|
||||
id: name.bytes().map(i64::from).sum::<i64>().max(1),
|
||||
name: name.to_owned(),
|
||||
description: None,
|
||||
enabled: true,
|
||||
transport_type: t_type.into(),
|
||||
transport_config: t_config.into(),
|
||||
tools: None,
|
||||
last_test_status: "disconnected".into(),
|
||||
last_connected: None,
|
||||
original_json: None,
|
||||
builtin: false,
|
||||
deleted_at: None,
|
||||
created_at: 1000,
|
||||
updated_at: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn source_is_nomifun() {
|
||||
let repo = Arc::new(MockRepo::new(vec![]));
|
||||
let adapter = NomifunAdapter::new(repo);
|
||||
assert_eq!(adapter.source(), McpSource::Nomifun);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn always_installed() {
|
||||
let repo = Arc::new(MockRepo::new(vec![]));
|
||||
let adapter = NomifunAdapter::new(repo);
|
||||
assert!(adapter.is_installed().await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_returns_all_db_servers() {
|
||||
let rows = vec![
|
||||
make_row("stdio-srv", "stdio", r#"{"command":"npx","args":[]}"#),
|
||||
make_row("http-srv", "http", r#"{"url":"https://example.com/mcp","headers":{}}"#),
|
||||
make_row("sse-srv", "sse", r#"{"url":"https://example.com/sse","headers":{}}"#),
|
||||
];
|
||||
let repo = Arc::new(MockRepo::new(rows));
|
||||
let adapter = NomifunAdapter::new(repo);
|
||||
|
||||
let servers = adapter.detect_existing().await.unwrap();
|
||||
assert_eq!(servers.len(), 3);
|
||||
assert_eq!(servers[0].name, "stdio-srv");
|
||||
assert_eq!(servers[1].name, "http-srv");
|
||||
assert_eq!(servers[2].name, "sse-srv");
|
||||
|
||||
assert!(matches!(servers[0].transport, McpServerTransport::Stdio { .. }));
|
||||
assert!(matches!(servers[1].transport, McpServerTransport::Http { .. }));
|
||||
assert!(matches!(servers[2].transport, McpServerTransport::Sse { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_empty_db_returns_empty() {
|
||||
let repo = Arc::new(MockRepo::new(vec![]));
|
||||
let adapter = NomifunAdapter::new(repo);
|
||||
let servers = adapter.detect_existing().await.unwrap();
|
||||
assert!(servers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn install_is_noop() {
|
||||
let repo = Arc::new(MockRepo::new(vec![]));
|
||||
let adapter = NomifunAdapter::new(repo.clone());
|
||||
|
||||
let transport = McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
};
|
||||
adapter.install_server("test", &transport).await.unwrap();
|
||||
|
||||
// DB should still be empty since install is a no-op
|
||||
let servers = adapter.detect_existing().await.unwrap();
|
||||
assert!(servers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_is_noop() {
|
||||
let rows = vec![make_row("srv", "stdio", r#"{"command":"npx","args":[]}"#)];
|
||||
let repo = Arc::new(MockRepo::new(rows));
|
||||
let adapter = NomifunAdapter::new(repo);
|
||||
|
||||
adapter.remove_server("srv").await.unwrap();
|
||||
|
||||
// Server should still be in DB since remove is a no-op
|
||||
let servers = adapter.detect_existing().await.unwrap();
|
||||
assert_eq!(servers.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trait_object_safety() {
|
||||
let repo = Arc::new(MockRepo::new(vec![]));
|
||||
let adapter: Arc<dyn McpAgentAdapter> = Arc::new(NomifunAdapter::new(repo));
|
||||
assert_eq!(adapter.source(), McpSource::Nomifun);
|
||||
assert!(adapter.is_installed().await.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Opencode adapter (filesystem-backed)
|
||||
// ===========================================================================
|
||||
|
||||
// Note: Full lifecycle tests for Opencode require controlling the config
|
||||
// directory path, which the adapter currently derives from `dirs::config_dir()`.
|
||||
// The unit tests in opencode.rs thoroughly cover parsing and serialization.
|
||||
// Here we verify that the adapter implements the trait correctly and that
|
||||
// the public API surface is accessible from outside the crate.
|
||||
|
||||
mod opencode {
|
||||
use super::*;
|
||||
use nomifun_mcp::OpencodeAdapter;
|
||||
|
||||
#[test]
|
||||
fn source_is_opencode() {
|
||||
assert_eq!(OpencodeAdapter.source(), McpSource::OpenCode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trait_object_safety() {
|
||||
let adapter: Box<dyn McpAgentAdapter> = Box::new(OpencodeAdapter);
|
||||
assert_eq!(adapter.source(), McpSource::OpenCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Nomi adapter (CLI + TOML-backed)
|
||||
// ===========================================================================
|
||||
|
||||
// Note: Full lifecycle tests for Nomi require the `nomi` CLI to be
|
||||
// installed (for `--config-path`). The unit tests in nomi.rs thoroughly
|
||||
// cover TOML parsing, serialization, and roundtrip behavior. Here we
|
||||
// verify the public API surface.
|
||||
|
||||
mod nomi {
|
||||
use super::*;
|
||||
use nomifun_mcp::NomiAdapter;
|
||||
|
||||
#[test]
|
||||
fn source_is_nomi() {
|
||||
assert_eq!(NomiAdapter.source(), McpSource::Nomi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trait_object_safety() {
|
||||
let adapter: Box<dyn McpAgentAdapter> = Box::new(NomiAdapter);
|
||||
assert_eq!(adapter.source(), McpSource::Nomi);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Integration tests for McpOAuthService with real SQLite.
|
||||
//!
|
||||
//! Tests from test-plan §4 (OAuth) at the service layer.
|
||||
//! These tests exercise check_status, logout, get_authenticated_servers,
|
||||
//! and get_token with a real DB. The full login flow (browser + callback)
|
||||
//! cannot be tested end-to-end here; it requires a mock OAuth server.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_db::{IOAuthTokenRepository, SqliteOAuthTokenRepository, UpsertOAuthTokenParams};
|
||||
use nomifun_mcp::McpOAuthService;
|
||||
|
||||
async fn make_service() -> (McpOAuthService, Arc<dyn IOAuthTokenRepository>) {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn IOAuthTokenRepository> = Arc::new(SqliteOAuthTokenRepository::new(db.pool().clone()));
|
||||
let svc = McpOAuthService::new(repo.clone(), reqwest::Client::new());
|
||||
// Keep db alive by leaking it (integration test only).
|
||||
std::mem::forget(db);
|
||||
(svc, repo)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OA-1: Unauthenticated server returns false
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_status_unauthenticated_returns_false() {
|
||||
let (svc, _repo) = make_service().await;
|
||||
let status = svc.check_oauth_status("https://new-server.example.com").await.unwrap();
|
||||
assert!(!status.authenticated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OA-2: Authenticated server returns true
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_status_authenticated_returns_true() {
|
||||
let (svc, repo) = make_service().await;
|
||||
|
||||
// Seed a valid token.
|
||||
repo.upsert(UpsertOAuthTokenParams {
|
||||
server_url: "https://mcp.example.com",
|
||||
access_token: "access_123",
|
||||
refresh_token: Some("refresh_456"),
|
||||
token_type: "bearer",
|
||||
// Expires in the far future.
|
||||
expires_at: Some(nomifun_common::now_ms() + 3_600_000),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = svc.check_oauth_status("https://mcp.example.com").await.unwrap();
|
||||
assert!(status.authenticated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OA-2b: Expired token treated as unauthenticated
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_status_expired_token_returns_false() {
|
||||
let (svc, repo) = make_service().await;
|
||||
|
||||
repo.upsert(UpsertOAuthTokenParams {
|
||||
server_url: "https://expired.example.com",
|
||||
access_token: "old_token",
|
||||
refresh_token: None,
|
||||
token_type: "bearer",
|
||||
// Already expired.
|
||||
expires_at: Some(1000),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = svc.check_oauth_status("https://expired.example.com").await.unwrap();
|
||||
assert!(!status.authenticated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OA-2c: Token with no expiry treated as valid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_status_no_expiry_treated_as_valid() {
|
||||
let (svc, repo) = make_service().await;
|
||||
|
||||
repo.upsert(UpsertOAuthTokenParams {
|
||||
server_url: "https://no-expiry.example.com",
|
||||
access_token: "no_exp_token",
|
||||
refresh_token: None,
|
||||
token_type: "bearer",
|
||||
expires_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let status = svc.check_oauth_status("https://no-expiry.example.com").await.unwrap();
|
||||
assert!(status.authenticated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OA-3: Get all authenticated URLs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_authenticated_servers_returns_all_urls() {
|
||||
let (svc, repo) = make_service().await;
|
||||
|
||||
repo.upsert(UpsertOAuthTokenParams {
|
||||
server_url: "https://a.example.com",
|
||||
access_token: "tok_a",
|
||||
refresh_token: None,
|
||||
token_type: "bearer",
|
||||
expires_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
repo.upsert(UpsertOAuthTokenParams {
|
||||
server_url: "https://b.example.com",
|
||||
access_token: "tok_b",
|
||||
refresh_token: None,
|
||||
token_type: "bearer",
|
||||
expires_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let urls = svc.get_authenticated_servers().await.unwrap();
|
||||
assert_eq!(urls.len(), 2);
|
||||
assert!(urls.contains(&"https://a.example.com".to_string()));
|
||||
assert!(urls.contains(&"https://b.example.com".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_authenticated_servers_empty_when_no_tokens() {
|
||||
let (svc, _repo) = make_service().await;
|
||||
let urls = svc.get_authenticated_servers().await.unwrap();
|
||||
assert!(urls.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OA-5: Login with invalid URL (no OAuth endpoints discoverable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_invalid_url_returns_error() {
|
||||
let (svc, _repo) = make_service().await;
|
||||
// This URL won't have .well-known endpoints.
|
||||
let result = svc.login("https://127.0.0.1:1").await;
|
||||
// Should return an McpError::OAuth about discovery failure.
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OA-6: Logout deletes stored token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_deletes_stored_token() {
|
||||
let (svc, repo) = make_service().await;
|
||||
|
||||
repo.upsert(UpsertOAuthTokenParams {
|
||||
server_url: "https://logout.example.com",
|
||||
access_token: "to_delete",
|
||||
refresh_token: None,
|
||||
token_type: "bearer",
|
||||
expires_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify token exists.
|
||||
let status = svc.check_oauth_status("https://logout.example.com").await.unwrap();
|
||||
assert!(status.authenticated);
|
||||
|
||||
// Logout.
|
||||
svc.logout("https://logout.example.com").await.unwrap();
|
||||
|
||||
// Verify token is gone.
|
||||
let status = svc.check_oauth_status("https://logout.example.com").await.unwrap();
|
||||
assert!(!status.authenticated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OA-7: Logout is idempotent for non-authenticated URL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_idempotent_for_unauthenticated() {
|
||||
let (svc, _repo) = make_service().await;
|
||||
// Should not error.
|
||||
svc.logout("https://never-authed.example.com").await.unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// get_token tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_token_returns_none_for_unknown_url() {
|
||||
let (svc, _repo) = make_service().await;
|
||||
let token = svc.get_token("https://unknown.example.com").await.unwrap();
|
||||
assert!(token.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_token_returns_access_token_when_valid() {
|
||||
let (svc, repo) = make_service().await;
|
||||
|
||||
repo.upsert(UpsertOAuthTokenParams {
|
||||
server_url: "https://valid.example.com",
|
||||
access_token: "my_access_token",
|
||||
refresh_token: None,
|
||||
token_type: "bearer",
|
||||
expires_at: Some(nomifun_common::now_ms() + 3_600_000),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let token = svc.get_token("https://valid.example.com").await.unwrap();
|
||||
assert_eq!(token.as_deref(), Some("my_access_token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_token_returns_expired_token_when_no_refresh_token() {
|
||||
let (svc, repo) = make_service().await;
|
||||
|
||||
repo.upsert(UpsertOAuthTokenParams {
|
||||
server_url: "https://expired.example.com",
|
||||
access_token: "old_access",
|
||||
refresh_token: None,
|
||||
token_type: "bearer",
|
||||
expires_at: Some(1000),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// With no refresh_token, returns the expired token as-is.
|
||||
let token = svc.get_token("https://expired.example.com").await.unwrap();
|
||||
assert_eq!(token.as_deref(), Some("old_access"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_token_returns_no_expiry_token() {
|
||||
let (svc, repo) = make_service().await;
|
||||
|
||||
repo.upsert(UpsertOAuthTokenParams {
|
||||
server_url: "https://noexp.example.com",
|
||||
access_token: "forever_token",
|
||||
refresh_token: None,
|
||||
token_type: "bearer",
|
||||
expires_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let token = svc.get_token("https://noexp.example.com").await.unwrap();
|
||||
assert_eq!(token.as_deref(), Some("forever_token"));
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Integration tests for McpConfigService with real SQLite.
|
||||
//!
|
||||
//! Tests from test-plan §1 (CRUD) at the service layer.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::{
|
||||
BatchImportMcpServersRequest, CreateMcpServerRequest, ImportMcpServerRequest, McpTransport, UpdateMcpServerRequest,
|
||||
};
|
||||
use nomifun_db::SqliteMcpServerRepository;
|
||||
use nomifun_mcp::{McpConfigService, McpError};
|
||||
|
||||
async fn make_service() -> McpConfigService {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(SqliteMcpServerRepository::new(db.pool().clone()));
|
||||
McpConfigService::new(repo)
|
||||
}
|
||||
|
||||
fn stdio_req(name: &str) -> CreateMcpServerRequest {
|
||||
CreateMcpServerRequest {
|
||||
name: name.to_owned(),
|
||||
description: Some("test".to_owned()),
|
||||
transport: McpTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec!["-y".into(), "@test/server".into()],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
original_json: None,
|
||||
builtin: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_req(name: &str) -> CreateMcpServerRequest {
|
||||
CreateMcpServerRequest {
|
||||
name: name.to_owned(),
|
||||
description: None,
|
||||
transport: McpTransport::Http {
|
||||
url: "https://example.com/mcp".into(),
|
||||
headers: HashMap::from([("Auth".into(), "Bearer tok".into())]),
|
||||
},
|
||||
original_json: None,
|
||||
builtin: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn stdio_import_req(name: &str) -> ImportMcpServerRequest {
|
||||
ImportMcpServerRequest {
|
||||
name: name.to_owned(),
|
||||
description: Some("test".to_owned()),
|
||||
transport: McpTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec!["-y".into(), "@test/server".into()],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
original_json: None,
|
||||
builtin: false,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_import_req(name: &str) -> ImportMcpServerRequest {
|
||||
ImportMcpServerRequest {
|
||||
name: name.to_owned(),
|
||||
description: None,
|
||||
transport: McpTransport::Http {
|
||||
url: "https://example.com/mcp".into(),
|
||||
headers: HashMap::from([("Auth".into(), "Bearer tok".into())]),
|
||||
},
|
||||
original_json: None,
|
||||
builtin: false,
|
||||
enabled: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_and_get_stdio_server() {
|
||||
let svc = make_service().await;
|
||||
let resp = svc.add_server(stdio_req("test-stdio")).await.unwrap();
|
||||
|
||||
// Host-local INTEGER primary key, surfaced as a number on the DTO.
|
||||
assert!(resp.id > 0);
|
||||
assert_eq!(resp.name, "test-stdio");
|
||||
assert!(!resp.enabled);
|
||||
assert_eq!(resp.description.as_deref(), Some("test"));
|
||||
|
||||
let found = svc.get_server(&resp.id.to_string()).await.unwrap();
|
||||
assert_eq!(found.id, resp.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_http_with_headers() {
|
||||
let svc = make_service().await;
|
||||
let resp = svc.add_server(http_req("test-http")).await.unwrap();
|
||||
|
||||
match resp.transport {
|
||||
McpTransport::Http { ref url, ref headers } => {
|
||||
assert_eq!(url, "https://example.com/mcp");
|
||||
assert_eq!(headers.get("Auth").unwrap(), "Bearer tok");
|
||||
}
|
||||
_ => panic!("expected Http"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_same_name_upserts() {
|
||||
let svc = make_service().await;
|
||||
let first = svc.add_server(stdio_req("dup")).await.unwrap();
|
||||
let second = svc.add_server(http_req("dup")).await.unwrap();
|
||||
|
||||
assert_eq!(first.id, second.id);
|
||||
match second.transport {
|
||||
McpTransport::Http { .. } => {}
|
||||
_ => panic!("expected Http after upsert"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_empty() {
|
||||
let svc = make_service().await;
|
||||
assert!(svc.list_servers().await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_returns_all() {
|
||||
let svc = make_service().await;
|
||||
svc.add_server(stdio_req("a")).await.unwrap();
|
||||
svc.add_server(http_req("b")).await.unwrap();
|
||||
assert_eq!(svc.list_servers().await.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_not_found() {
|
||||
let svc = make_service().await;
|
||||
let err = svc.get_server("nonexistent").await.unwrap_err();
|
||||
assert!(matches!(err, McpError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_name_is_rejected() {
|
||||
let svc = make_service().await;
|
||||
let created = svc.add_server(stdio_req("old")).await.unwrap();
|
||||
|
||||
let err = svc
|
||||
.edit_server(
|
||||
&created.id.to_string(),
|
||||
UpdateMcpServerRequest {
|
||||
name: Some("new".into()),
|
||||
description: None,
|
||||
transport: None,
|
||||
original_json: None,
|
||||
builtin: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, McpError::InvalidEdit(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_transport() {
|
||||
let svc = make_service().await;
|
||||
let created = svc.add_server(stdio_req("test")).await.unwrap();
|
||||
|
||||
let updated = svc
|
||||
.edit_server(
|
||||
&created.id.to_string(),
|
||||
UpdateMcpServerRequest {
|
||||
name: None,
|
||||
description: None,
|
||||
transport: Some(McpTransport::Sse {
|
||||
url: "https://new.url".into(),
|
||||
headers: HashMap::new(),
|
||||
}),
|
||||
original_json: None,
|
||||
builtin: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match updated.transport {
|
||||
McpTransport::Sse { ref url, .. } => assert_eq!(url, "https://new.url"),
|
||||
_ => panic!("expected Sse"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_clears_description() {
|
||||
let svc = make_service().await;
|
||||
let created = svc.add_server(stdio_req("test")).await.unwrap();
|
||||
assert!(created.description.is_some());
|
||||
|
||||
let updated = svc
|
||||
.edit_server(
|
||||
&created.id.to_string(),
|
||||
UpdateMcpServerRequest {
|
||||
name: None,
|
||||
description: Some(None),
|
||||
transport: None,
|
||||
original_json: None,
|
||||
builtin: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(updated.description.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_not_found() {
|
||||
let svc = make_service().await;
|
||||
let err = svc
|
||||
.edit_server(
|
||||
"nonexistent",
|
||||
UpdateMcpServerRequest {
|
||||
name: Some("x".into()),
|
||||
description: None,
|
||||
transport: None,
|
||||
original_json: None,
|
||||
builtin: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, McpError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_name_conflict() {
|
||||
let svc = make_service().await;
|
||||
svc.add_server(stdio_req("a")).await.unwrap();
|
||||
let b = svc.add_server(stdio_req("b")).await.unwrap();
|
||||
|
||||
let err = svc
|
||||
.edit_server(
|
||||
&b.id.to_string(),
|
||||
UpdateMcpServerRequest {
|
||||
name: Some("a".into()),
|
||||
description: None,
|
||||
transport: None,
|
||||
original_json: None,
|
||||
builtin: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, McpError::InvalidEdit(_)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_server() {
|
||||
let svc = make_service().await;
|
||||
let created = svc.add_server(stdio_req("del")).await.unwrap();
|
||||
let was_enabled = svc.delete_server(&created.id.to_string()).await.unwrap();
|
||||
assert!(!was_enabled);
|
||||
|
||||
let err = svc.get_server(&created.id.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, McpError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_enabled_returns_true() {
|
||||
let svc = make_service().await;
|
||||
let created = svc.add_server(stdio_req("del-en")).await.unwrap();
|
||||
svc.toggle_server(&created.id.to_string()).await.unwrap();
|
||||
|
||||
let was_enabled = svc.delete_server(&created.id.to_string()).await.unwrap();
|
||||
assert!(was_enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_not_found() {
|
||||
let svc = make_service().await;
|
||||
let err = svc.delete_server("nonexistent").await.unwrap_err();
|
||||
assert!(matches!(err, McpError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn toggle_enables_then_disables() {
|
||||
let svc = make_service().await;
|
||||
let created = svc.add_server(stdio_req("tog")).await.unwrap();
|
||||
assert!(!created.enabled);
|
||||
|
||||
let toggled = svc.toggle_server(&created.id.to_string()).await.unwrap();
|
||||
assert!(toggled.enabled);
|
||||
|
||||
let toggled_back = svc.toggle_server(&created.id.to_string()).await.unwrap();
|
||||
assert!(!toggled_back.enabled);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_import_creates_and_upserts() {
|
||||
let svc = make_service().await;
|
||||
svc.add_server(stdio_req("existing")).await.unwrap();
|
||||
|
||||
let req = BatchImportMcpServersRequest {
|
||||
servers: vec![
|
||||
http_import_req("existing"), // upsert
|
||||
stdio_import_req("new"), // create
|
||||
],
|
||||
};
|
||||
let results = svc.batch_import(req).await.unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
|
||||
let all = svc.list_servers().await.unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_import_preserves_enabled_in_database() {
|
||||
let svc = make_service().await;
|
||||
let mut req = stdio_import_req("enabled-db-mcp");
|
||||
req.enabled = Some(true);
|
||||
|
||||
let result = svc
|
||||
.batch_import(BatchImportMcpServersRequest { servers: vec![req] })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].name, "enabled-db-mcp");
|
||||
assert!(result[0].enabled);
|
||||
|
||||
let listed = svc.list_servers().await.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert!(listed[0].enabled);
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
//! Integration tests for ACP session MCP injection.
|
||||
//!
|
||||
//! Covers test-plan items SI-1 through SI-7: capability parsing,
|
||||
//! format conversion, enabled-only filtering, and builtin server injection.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nomifun_common::McpServerStatus;
|
||||
use nomifun_mcp::{
|
||||
AcpMcpCapabilities, AcpSessionMcpServer, ImageGenConfig, McpServer, McpServerTransport, NameValuePair,
|
||||
build_builtin_image_gen_server, build_session_mcp_servers, parse_acp_mcp_capabilities,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_server(name: &str, enabled: bool, transport: McpServerTransport) -> McpServer {
|
||||
McpServer {
|
||||
// Injection keys on `name`, never `id`; any stable value works here.
|
||||
id: name.bytes().map(i64::from).sum::<i64>().max(1),
|
||||
name: name.into(),
|
||||
description: None,
|
||||
enabled,
|
||||
transport,
|
||||
tools: vec![],
|
||||
last_test_status: McpServerStatus::Disconnected,
|
||||
last_connected: None,
|
||||
original_json: None,
|
||||
builtin: false,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SI-1: Full capabilities → all transports retained
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn si_1_full_capabilities_retains_all_transports() {
|
||||
let caps = AcpMcpCapabilities {
|
||||
stdio: true,
|
||||
http: true,
|
||||
sse: true,
|
||||
};
|
||||
let servers = vec![
|
||||
make_server(
|
||||
"stdio-mcp",
|
||||
true,
|
||||
McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec!["-y".into(), "test-server".into()],
|
||||
env: HashMap::from([("KEY".into(), "VAL".into())]),
|
||||
},
|
||||
),
|
||||
make_server(
|
||||
"http-mcp",
|
||||
true,
|
||||
McpServerTransport::Http {
|
||||
url: "https://example.com/mcp".into(),
|
||||
headers: HashMap::from([("Authorization".into(), "Bearer tok".into())]),
|
||||
},
|
||||
),
|
||||
make_server(
|
||||
"sse-mcp",
|
||||
true,
|
||||
McpServerTransport::Sse {
|
||||
url: "https://example.com/sse".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
let result = build_session_mcp_servers(&servers, &caps);
|
||||
assert_eq!(result.len(), 3, "all 3 transports should be retained");
|
||||
|
||||
// Verify each type is present
|
||||
let has_stdio = result
|
||||
.iter()
|
||||
.any(|s| matches!(s, AcpSessionMcpServer::Stdio { name, .. } if name == "stdio-mcp"));
|
||||
let has_http = result
|
||||
.iter()
|
||||
.any(|s| matches!(s, AcpSessionMcpServer::Http { name, .. } if name == "http-mcp"));
|
||||
let has_sse = result
|
||||
.iter()
|
||||
.any(|s| matches!(s, AcpSessionMcpServer::Sse { name, .. } if name == "sse-mcp"));
|
||||
|
||||
assert!(has_stdio, "stdio server missing");
|
||||
assert!(has_http, "http server missing");
|
||||
assert!(has_sse, "sse server missing");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SI-2: stdio-only capabilities → only stdio retained
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn si_2_stdio_only_keeps_stdio_servers() {
|
||||
let caps = AcpMcpCapabilities {
|
||||
stdio: true,
|
||||
http: false,
|
||||
sse: false,
|
||||
};
|
||||
let servers = vec![
|
||||
make_server(
|
||||
"stdio-mcp",
|
||||
true,
|
||||
McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
),
|
||||
make_server(
|
||||
"http-mcp",
|
||||
true,
|
||||
McpServerTransport::Http {
|
||||
url: "https://example.com/mcp".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
),
|
||||
make_server(
|
||||
"sse-mcp",
|
||||
true,
|
||||
McpServerTransport::Sse {
|
||||
url: "https://example.com/sse".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
let result = build_session_mcp_servers(&servers, &caps);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(matches!(&result[0], AcpSessionMcpServer::Stdio { name, .. } if name == "stdio-mcp"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SI-3: No capabilities → empty list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn si_3_no_capabilities_returns_empty() {
|
||||
let caps = AcpMcpCapabilities {
|
||||
stdio: false,
|
||||
http: false,
|
||||
sse: false,
|
||||
};
|
||||
let servers = vec![
|
||||
make_server(
|
||||
"s1",
|
||||
true,
|
||||
McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
),
|
||||
make_server(
|
||||
"s2",
|
||||
true,
|
||||
McpServerTransport::Http {
|
||||
url: "https://example.com".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
let result = build_session_mcp_servers(&servers, &caps);
|
||||
assert!(result.is_empty(), "no capabilities → empty result");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SI-4: stdio server format conversion (env Record → Vec<{name,value}>)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn si_4_stdio_format_conversion() {
|
||||
let caps = AcpMcpCapabilities {
|
||||
stdio: true,
|
||||
http: false,
|
||||
sse: false,
|
||||
};
|
||||
let servers = vec![make_server(
|
||||
"test-stdio",
|
||||
true,
|
||||
McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec!["-y".into(), "test-server".into()],
|
||||
env: HashMap::from([
|
||||
("NODE_ENV".into(), "production".into()),
|
||||
("DEBUG".into(), "true".into()),
|
||||
]),
|
||||
},
|
||||
)];
|
||||
|
||||
let result = build_session_mcp_servers(&servers, &caps);
|
||||
assert_eq!(result.len(), 1);
|
||||
|
||||
match &result[0] {
|
||||
AcpSessionMcpServer::Stdio {
|
||||
name,
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
assert_eq!(name, "test-stdio");
|
||||
assert_eq!(command, "npx");
|
||||
assert_eq!(args, &["-y", "test-server"]);
|
||||
assert_eq!(env.len(), 2);
|
||||
// Sorted by name
|
||||
assert_eq!(env[0].name, "DEBUG");
|
||||
assert_eq!(env[0].value, "true");
|
||||
assert_eq!(env[1].name, "NODE_ENV");
|
||||
assert_eq!(env[1].value, "production");
|
||||
}
|
||||
_ => panic!("expected Stdio variant"),
|
||||
}
|
||||
|
||||
// Verify JSON wire format
|
||||
let json = serde_json::to_value(&result[0]).unwrap();
|
||||
assert_eq!(json["type"], "stdio");
|
||||
assert_eq!(json["name"], "test-stdio");
|
||||
assert_eq!(json["command"], "npx");
|
||||
assert!(json["env"].is_array());
|
||||
assert_eq!(json["env"][0]["name"], "DEBUG");
|
||||
assert_eq!(json["env"][0]["value"], "true");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SI-5: http server format conversion (headers Record → Vec<{name,value}>)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn si_5_http_format_conversion() {
|
||||
let caps = AcpMcpCapabilities {
|
||||
stdio: false,
|
||||
http: true,
|
||||
sse: false,
|
||||
};
|
||||
let servers = vec![make_server(
|
||||
"test-http",
|
||||
true,
|
||||
McpServerTransport::Http {
|
||||
url: "https://example.com/mcp".into(),
|
||||
headers: HashMap::from([
|
||||
("Authorization".into(), "Bearer tok".into()),
|
||||
("X-Custom".into(), "val".into()),
|
||||
]),
|
||||
},
|
||||
)];
|
||||
|
||||
let result = build_session_mcp_servers(&servers, &caps);
|
||||
assert_eq!(result.len(), 1);
|
||||
|
||||
match &result[0] {
|
||||
AcpSessionMcpServer::Http { name, url, headers } => {
|
||||
assert_eq!(name, "test-http");
|
||||
assert_eq!(url, "https://example.com/mcp");
|
||||
assert_eq!(headers.len(), 2);
|
||||
// Sorted by name
|
||||
assert_eq!(headers[0].name, "Authorization");
|
||||
assert_eq!(headers[0].value, "Bearer tok");
|
||||
assert_eq!(headers[1].name, "X-Custom");
|
||||
assert_eq!(headers[1].value, "val");
|
||||
}
|
||||
_ => panic!("expected Http variant"),
|
||||
}
|
||||
|
||||
// Verify JSON wire format
|
||||
let json = serde_json::to_value(&result[0]).unwrap();
|
||||
assert_eq!(json["type"], "http");
|
||||
assert_eq!(json["name"], "test-http");
|
||||
assert_eq!(json["url"], "https://example.com/mcp");
|
||||
assert!(json["headers"].is_array());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SI-6: only enabled servers appear
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn si_6_only_enabled_servers_in_result() {
|
||||
let caps = AcpMcpCapabilities {
|
||||
stdio: true,
|
||||
http: true,
|
||||
sse: true,
|
||||
};
|
||||
let servers = vec![
|
||||
make_server(
|
||||
"enabled-stdio",
|
||||
true,
|
||||
McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
),
|
||||
make_server(
|
||||
"disabled-stdio",
|
||||
false,
|
||||
McpServerTransport::Stdio {
|
||||
command: "node".into(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
),
|
||||
make_server(
|
||||
"enabled-http",
|
||||
true,
|
||||
McpServerTransport::Http {
|
||||
url: "https://example.com".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
),
|
||||
make_server(
|
||||
"disabled-http",
|
||||
false,
|
||||
McpServerTransport::Http {
|
||||
url: "https://other.com".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
let result = build_session_mcp_servers(&servers, &caps);
|
||||
assert_eq!(result.len(), 2, "only 2 enabled servers should appear");
|
||||
|
||||
let names: Vec<&str> = result
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
AcpSessionMcpServer::Stdio { name, .. } => name.as_str(),
|
||||
AcpSessionMcpServer::Http { name, .. } => name.as_str(),
|
||||
AcpSessionMcpServer::Sse { name, .. } => name.as_str(),
|
||||
})
|
||||
.collect();
|
||||
assert!(names.contains(&"enabled-stdio"));
|
||||
assert!(names.contains(&"enabled-http"));
|
||||
assert!(!names.contains(&"disabled-stdio"));
|
||||
assert!(!names.contains(&"disabled-http"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SI-7: builtin MCP injection (image generation with env vars)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn si_7_builtin_image_gen_injection() {
|
||||
let caps = AcpMcpCapabilities {
|
||||
stdio: true,
|
||||
http: true,
|
||||
sse: true,
|
||||
};
|
||||
|
||||
let img_config = ImageGenConfig {
|
||||
model: Some("dall-e-3".into()),
|
||||
api_url: Some("https://api.openai.com/v1".into()),
|
||||
api_key: Some("sk-test-key".into()),
|
||||
size: Some("1024x1024".into()),
|
||||
quality: Some("hd".into()),
|
||||
style: Some("natural".into()),
|
||||
};
|
||||
|
||||
// Build user servers
|
||||
let user_servers = vec![make_server(
|
||||
"user-mcp",
|
||||
true,
|
||||
McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec!["-y".into(), "user-server".into()],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
)];
|
||||
|
||||
let mut session_servers = build_session_mcp_servers(&user_servers, &caps);
|
||||
|
||||
// Inject builtin image gen server
|
||||
if let Some(builtin) = build_builtin_image_gen_server(&caps, "/usr/local/bin/nomifun-img-gen", &img_config) {
|
||||
session_servers.push(builtin);
|
||||
}
|
||||
|
||||
assert_eq!(session_servers.len(), 2, "user server + builtin image gen");
|
||||
|
||||
// Verify the builtin server
|
||||
let builtin = &session_servers[1];
|
||||
match builtin {
|
||||
AcpSessionMcpServer::Stdio { name, command, env, .. } => {
|
||||
assert_eq!(name, "nomifun-image-generation");
|
||||
assert_eq!(command, "/usr/local/bin/nomifun-img-gen");
|
||||
|
||||
// Verify all 6 env vars are present
|
||||
assert_eq!(env.len(), 6);
|
||||
|
||||
let env_map: HashMap<&str, &str> = env.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect();
|
||||
assert_eq!(env_map["NOMIFUN_IMG_MODEL"], "dall-e-3");
|
||||
assert_eq!(env_map["NOMIFUN_IMG_API_URL"], "https://api.openai.com/v1");
|
||||
assert_eq!(env_map["NOMIFUN_IMG_API_KEY"], "sk-test-key");
|
||||
assert_eq!(env_map["NOMIFUN_IMG_SIZE"], "1024x1024");
|
||||
assert_eq!(env_map["NOMIFUN_IMG_QUALITY"], "hd");
|
||||
assert_eq!(env_map["NOMIFUN_IMG_STYLE"], "natural");
|
||||
}
|
||||
_ => panic!("expected Stdio variant for builtin"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability parsing from various response formats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parse_capabilities_from_real_response_shape() {
|
||||
// Simulates a realistic ACP backend response with nested capabilities
|
||||
let response = serde_json::json!({
|
||||
"status": "ok",
|
||||
"version": "1.2.3",
|
||||
"mcp_capabilities": {
|
||||
"stdio": true,
|
||||
"http": true,
|
||||
"sse": false
|
||||
}
|
||||
});
|
||||
|
||||
let caps = parse_acp_mcp_capabilities(&response);
|
||||
assert!(caps.stdio);
|
||||
assert!(caps.http);
|
||||
assert!(!caps.sse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_capabilities_empty_response() {
|
||||
let response = serde_json::json!({});
|
||||
let caps = parse_acp_mcp_capabilities(&response);
|
||||
// Default: stdio only
|
||||
assert!(caps.stdio);
|
||||
assert!(!caps.http);
|
||||
assert!(!caps.sse);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// End-to-end: parse capabilities + build servers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn end_to_end_parse_then_build() {
|
||||
let acp_response = serde_json::json!({
|
||||
"mcp_capabilities": { "stdio": true, "http": true, "sse": false }
|
||||
});
|
||||
let caps = parse_acp_mcp_capabilities(&acp_response);
|
||||
|
||||
let servers = vec![
|
||||
make_server(
|
||||
"stdio-srv",
|
||||
true,
|
||||
McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec![],
|
||||
env: HashMap::new(),
|
||||
},
|
||||
),
|
||||
make_server(
|
||||
"http-srv",
|
||||
true,
|
||||
McpServerTransport::Http {
|
||||
url: "https://example.com".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
),
|
||||
make_server(
|
||||
"sse-srv",
|
||||
true,
|
||||
McpServerTransport::Sse {
|
||||
url: "https://example.com/sse".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
let result = build_session_mcp_servers(&servers, &caps);
|
||||
assert_eq!(result.len(), 2, "sse should be filtered out");
|
||||
|
||||
let names: Vec<&str> = result
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
AcpSessionMcpServer::Stdio { name, .. } => name.as_str(),
|
||||
AcpSessionMcpServer::Http { name, .. } => name.as_str(),
|
||||
AcpSessionMcpServer::Sse { name, .. } => name.as_str(),
|
||||
})
|
||||
.collect();
|
||||
assert!(names.contains(&"stdio-srv"));
|
||||
assert!(names.contains(&"http-srv"));
|
||||
assert!(!names.contains(&"sse-srv"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON wire format verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn wire_format_is_acp_compatible() {
|
||||
let servers = vec![
|
||||
AcpSessionMcpServer::Stdio {
|
||||
name: "test-stdio".into(),
|
||||
command: "npx".into(),
|
||||
args: vec!["-y".into(), "server".into()],
|
||||
env: vec![NameValuePair {
|
||||
name: "K".into(),
|
||||
value: "V".into(),
|
||||
}],
|
||||
},
|
||||
AcpSessionMcpServer::Http {
|
||||
name: "test-http".into(),
|
||||
url: "https://example.com/mcp".into(),
|
||||
headers: vec![NameValuePair {
|
||||
name: "Auth".into(),
|
||||
value: "Bearer x".into(),
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
let json = serde_json::to_value(&servers).unwrap();
|
||||
let arr = json.as_array().unwrap();
|
||||
|
||||
// stdio variant
|
||||
assert_eq!(arr[0]["type"], "stdio");
|
||||
assert_eq!(arr[0]["name"], "test-stdio");
|
||||
assert_eq!(arr[0]["command"], "npx");
|
||||
assert_eq!(arr[0]["args"][0], "-y");
|
||||
assert_eq!(arr[0]["env"][0]["name"], "K");
|
||||
assert_eq!(arr[0]["env"][0]["value"], "V");
|
||||
|
||||
// http variant
|
||||
assert_eq!(arr[1]["type"], "http");
|
||||
assert_eq!(arr[1]["name"], "test-http");
|
||||
assert_eq!(arr[1]["url"], "https://example.com/mcp");
|
||||
assert_eq!(arr[1]["headers"][0]["name"], "Auth");
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Integration tests for read-only Agent MCP config discovery.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_common::McpSource;
|
||||
use nomifun_db::SqliteMcpServerRepository;
|
||||
use nomifun_mcp::{DetectedServer, McpAgentAdapter, McpError, McpServerTransport, McpSyncService};
|
||||
|
||||
struct MockAdapter {
|
||||
source: McpSource,
|
||||
installed: bool,
|
||||
servers: std::sync::Mutex<Vec<DetectedServer>>,
|
||||
}
|
||||
|
||||
impl MockAdapter {
|
||||
fn new(source: McpSource, installed: bool) -> Self {
|
||||
Self {
|
||||
source,
|
||||
installed,
|
||||
servers: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_servers(source: McpSource, servers: Vec<DetectedServer>) -> Self {
|
||||
Self {
|
||||
source,
|
||||
installed: true,
|
||||
servers: std::sync::Mutex::new(servers),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpAgentAdapter for MockAdapter {
|
||||
fn source(&self) -> McpSource {
|
||||
self.source
|
||||
}
|
||||
|
||||
async fn is_installed(&self) -> Result<bool, McpError> {
|
||||
Ok(self.installed)
|
||||
}
|
||||
|
||||
async fn detect_existing(&self) -> Result<Vec<DetectedServer>, McpError> {
|
||||
if !self.installed {
|
||||
return Err(McpError::AgentNotInstalled(format!("{:?}", self.source)));
|
||||
}
|
||||
Ok(self.servers.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn install_server(&self, _name: &str, _transport: &McpServerTransport) -> Result<(), McpError> {
|
||||
unreachable!("write-to-CLI is no longer supported")
|
||||
}
|
||||
|
||||
async fn remove_server(&self, _name: &str) -> Result<(), McpError> {
|
||||
unreachable!("write-to-CLI is no longer supported")
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_service(adapters: Vec<Arc<dyn McpAgentAdapter>>) -> McpSyncService {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
let repo: Arc<dyn nomifun_db::IMcpServerRepository> = Arc::new(SqliteMcpServerRepository::new(db.pool().clone()));
|
||||
McpSyncService::new(repo, adapters)
|
||||
}
|
||||
|
||||
fn stdio_transport() -> McpServerTransport {
|
||||
McpServerTransport::Stdio {
|
||||
command: "npx".into(),
|
||||
args: vec!["-y".into(), "@test/server".into()],
|
||||
env: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_agent_configs_returns_installed_agents() {
|
||||
let adapter_claude = Arc::new(MockAdapter::with_servers(
|
||||
McpSource::Claude,
|
||||
vec![DetectedServer {
|
||||
name: "existing-srv".into(),
|
||||
transport: stdio_transport(),
|
||||
importable: true,
|
||||
import_skip_reason: None,
|
||||
}],
|
||||
));
|
||||
let adapter_gemini = Arc::new(MockAdapter::new(McpSource::Gemini, false));
|
||||
let adapter_qwen = Arc::new(MockAdapter::new(McpSource::Qwen, true));
|
||||
|
||||
let sync_svc = make_service(vec![
|
||||
adapter_claude as Arc<dyn McpAgentAdapter>,
|
||||
adapter_gemini,
|
||||
adapter_qwen,
|
||||
])
|
||||
.await;
|
||||
let configs = sync_svc.get_agent_configs().await.unwrap();
|
||||
|
||||
assert_eq!(configs.len(), 2);
|
||||
assert_eq!(configs[0].source, McpSource::Claude);
|
||||
assert_eq!(configs[0].servers.len(), 1);
|
||||
assert_eq!(configs[0].servers[0].server.name, "existing-srv");
|
||||
assert_eq!(configs[1].source, McpSource::Qwen);
|
||||
assert!(configs[1].servers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_agent_configs_empty_when_none_installed() {
|
||||
let adapter = Arc::new(MockAdapter::new(McpSource::Claude, false));
|
||||
let sync_svc = make_service(vec![adapter as Arc<dyn McpAgentAdapter>]).await;
|
||||
|
||||
let configs = sync_svc.get_agent_configs().await.unwrap();
|
||||
assert!(configs.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Integration tests for nomifun-mcp core types.
|
||||
//!
|
||||
//! Tests the public API surface: McpServer construction from DB rows,
|
||||
//! transport parsing/serialization, and response conversion.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nomifun_common::McpServerStatus;
|
||||
use nomifun_db::models::McpServerRow;
|
||||
use nomifun_mcp::{McpServer, McpServerTransport, McpTool};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// McpServer::from_row — full pipeline tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn row(transport_type: &str, transport_config: &str, tools: Option<&str>, status: &str) -> McpServerRow {
|
||||
McpServerRow {
|
||||
id: 42,
|
||||
name: "integration-test".into(),
|
||||
description: Some("Integration test server".into()),
|
||||
enabled: true,
|
||||
transport_type: transport_type.into(),
|
||||
transport_config: transport_config.into(),
|
||||
tools: tools.map(String::from),
|
||||
last_test_status: status.into(),
|
||||
last_connected: Some(9999),
|
||||
original_json: Some(r#"{"name":"integration-test"}"#.into()),
|
||||
builtin: false,
|
||||
deleted_at: None,
|
||||
created_at: 1000,
|
||||
updated_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_server_full_pipeline() {
|
||||
let config = serde_json::json!({
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-everything"],
|
||||
"env": { "NODE_ENV": "test", "DEBUG": "mcp:*" }
|
||||
});
|
||||
let tools_json = serde_json::json!([
|
||||
{ "name": "echo", "description": "Echo input back" },
|
||||
{ "name": "add", "description": "Add numbers", "input_schema": { "type": "object" } }
|
||||
]);
|
||||
|
||||
let r = row("stdio", &config.to_string(), Some(&tools_json.to_string()), "connected");
|
||||
let server = McpServer::from_row(r).unwrap();
|
||||
|
||||
// Verify all fields
|
||||
assert_eq!(server.id, 42);
|
||||
assert_eq!(server.name, "integration-test");
|
||||
assert_eq!(server.description.as_deref(), Some("Integration test server"));
|
||||
assert!(server.enabled);
|
||||
assert_eq!(server.last_test_status, McpServerStatus::Connected);
|
||||
assert_eq!(server.last_connected, Some(9999));
|
||||
assert!(!server.builtin);
|
||||
|
||||
// Verify transport
|
||||
match &server.transport {
|
||||
McpServerTransport::Stdio { command, args, env } => {
|
||||
assert_eq!(command, "npx");
|
||||
assert_eq!(args.len(), 2);
|
||||
assert_eq!(env.len(), 2);
|
||||
assert_eq!(env["NODE_ENV"], "test");
|
||||
}
|
||||
_ => panic!("expected Stdio transport"),
|
||||
}
|
||||
|
||||
// Verify tools
|
||||
assert_eq!(server.tools.len(), 2);
|
||||
assert_eq!(server.tools[0].name, "echo");
|
||||
assert!(server.tools[1].input_schema.is_some());
|
||||
|
||||
// Convert to API response and verify
|
||||
let resp = server.into_response();
|
||||
assert_eq!(resp.id, 42);
|
||||
assert_eq!(resp.last_test_status, McpServerStatus::Connected);
|
||||
assert!(resp.tools.is_some());
|
||||
assert_eq!(resp.tools.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_server_with_headers() {
|
||||
let config = serde_json::json!({
|
||||
"url": "https://mcp.example.com/v1",
|
||||
"headers": { "Authorization": "Bearer secret123", "X-Custom": "value" }
|
||||
});
|
||||
|
||||
let r = row("http", &config.to_string(), None, "disconnected");
|
||||
let server = McpServer::from_row(r).unwrap();
|
||||
|
||||
match &server.transport {
|
||||
McpServerTransport::Http { url, headers } => {
|
||||
assert_eq!(url, "https://mcp.example.com/v1");
|
||||
assert_eq!(headers.len(), 2);
|
||||
assert_eq!(headers["Authorization"], "Bearer secret123");
|
||||
}
|
||||
_ => panic!("expected Http transport"),
|
||||
}
|
||||
|
||||
// Response should have no tools
|
||||
let resp = server.into_response();
|
||||
assert!(resp.tools.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_server_minimal() {
|
||||
let config = serde_json::json!({ "url": "https://sse.example.com/events" });
|
||||
let r = row("sse", &config.to_string(), None, "testing");
|
||||
let server = McpServer::from_row(r).unwrap();
|
||||
|
||||
assert_eq!(server.last_test_status, McpServerStatus::Testing);
|
||||
match &server.transport {
|
||||
McpServerTransport::Sse { url, headers } => {
|
||||
assert_eq!(url, "https://sse.example.com/events");
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
_ => panic!("expected Sse transport"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport DB roundtrip: domain -> JSON -> DB -> domain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn transport_db_roundtrip_preserves_all_fields() {
|
||||
let transports = vec![
|
||||
McpServerTransport::Stdio {
|
||||
command: "python3".into(),
|
||||
args: vec!["-m".into(), "mcp_server".into()],
|
||||
env: HashMap::from([
|
||||
("PYTHONPATH".into(), "/usr/lib/python3".into()),
|
||||
("LOG_LEVEL".into(), "debug".into()),
|
||||
]),
|
||||
},
|
||||
McpServerTransport::Sse {
|
||||
url: "https://sse.example.com/mcp".into(),
|
||||
headers: HashMap::from([
|
||||
("Authorization".into(), "Bearer tok".into()),
|
||||
("Accept".into(), "text/event-stream".into()),
|
||||
]),
|
||||
},
|
||||
McpServerTransport::Http {
|
||||
url: "https://http.example.com/mcp".into(),
|
||||
headers: HashMap::new(),
|
||||
},
|
||||
];
|
||||
|
||||
for original in transports {
|
||||
let ttype = original.transport_type();
|
||||
let json = original.to_config_json().unwrap();
|
||||
let reconstructed = McpServerTransport::from_db(ttype, &json).unwrap();
|
||||
assert_eq!(reconstructed, original, "roundtrip failed for {ttype}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error scenarios
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn invalid_transport_type_is_rejected() {
|
||||
let r = row("grpc", r#"{"endpoint":"localhost:50051"}"#, None, "disconnected");
|
||||
let err = McpServer::from_row(r).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("unknown transport type"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_transport_json_is_rejected() {
|
||||
let r = row("stdio", "{broken json", None, "disconnected");
|
||||
let err = McpServer::from_row(r).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("JSON"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_tools_json_is_rejected() {
|
||||
let r = row(
|
||||
"stdio",
|
||||
r#"{"command":"node"}"#,
|
||||
Some("[{not valid json}]"),
|
||||
"connected",
|
||||
);
|
||||
let err = McpServer::from_row(r).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("JSON"), "got: {msg}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// McpTool construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tool_fields_preserved_through_conversion() {
|
||||
let schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": { "type": "string" }
|
||||
},
|
||||
"required": ["path"]
|
||||
});
|
||||
|
||||
let tool = McpTool {
|
||||
name: "read_file".into(),
|
||||
description: Some("Read a file from disk".into()),
|
||||
input_schema: Some(schema.clone()),
|
||||
};
|
||||
|
||||
// Domain -> API response
|
||||
let resp: nomifun_api_types::McpToolResponse = tool.clone().into();
|
||||
assert_eq!(resp.name, "read_file");
|
||||
assert_eq!(resp.description.as_deref(), Some("Read a file from disk"));
|
||||
assert_eq!(resp.input_schema, Some(schema.clone()));
|
||||
|
||||
// API response -> domain
|
||||
let back: McpTool = resp.into();
|
||||
assert_eq!(back, tool);
|
||||
}
|
||||
Reference in New Issue
Block a user