Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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");
}
@@ -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);
}