#![cfg(feature = "mcp")]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::fs;
use std::path::PathBuf;
use anyhow::Result;
use git2::{Repository, Signature};
use rmcp::{
model::{CallToolRequestParams, RawContent, ReadResourceRequestParams, ResourceContents},
service::ServiceExt,
ClientHandler, RoleClient,
};
use tempfile::TempDir;
use omni_dev::mcp::OmniDevServer;
struct TestRepo {
_temp_dir: TempDir,
repo_path: PathBuf,
repo: Repository,
commits: Vec<git2::Oid>,
}
impl TestRepo {
fn new() -> Result<Self> {
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
fs::create_dir_all(&tmp_root)?;
let temp_dir = tempfile::tempdir_in(&tmp_root)?;
let repo_path = temp_dir.path().to_path_buf();
let repo = Repository::init(&repo_path)?;
{
let mut config = repo.config()?;
config.set_str("user.name", "Test User")?;
config.set_str("user.email", "test@example.com")?;
}
Ok(Self {
_temp_dir: temp_dir,
repo_path,
repo,
commits: Vec::new(),
})
}
fn add_commit(&mut self, message: &str, content: &str) -> Result<git2::Oid> {
let file_path = self.repo_path.join("test.txt");
fs::write(&file_path, content)?;
let mut index = self.repo.index()?;
index.add_path(std::path::Path::new("test.txt"))?;
index.write()?;
let signature = Signature::now("Test User", "test@example.com")?;
let tree_id = index.write_tree()?;
let tree = self.repo.find_tree(tree_id)?;
let parents: Vec<git2::Commit<'_>> = match self.commits.last() {
Some(id) => vec![self.repo.find_commit(*id)?],
None => vec![],
};
let parent_refs: Vec<&git2::Commit<'_>> = parents.iter().collect();
let commit_id = self.repo.commit(
Some("HEAD"),
&signature,
&signature,
message,
&tree,
&parent_refs,
)?;
self.commits.push(commit_id);
Ok(commit_id)
}
}
#[derive(Clone, Default)]
struct TestClient;
impl ClientHandler for TestClient {}
async fn spawn_server() -> (
rmcp::service::RunningService<RoleClient, TestClient>,
tokio::task::JoinHandle<Result<()>>,
) {
let (server_transport, client_transport) = tokio::io::duplex(64 * 1024);
let server = OmniDevServer::new();
let server_handle = tokio::spawn(async move {
let service = server.serve(server_transport).await?;
service.waiting().await?;
Ok(())
});
let client = TestClient.serve(client_transport).await.unwrap();
(client, server_handle)
}
#[tokio::test]
async fn list_tools_includes_git_view_commits() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let tools = client.list_tools(Option::default()).await?;
let names: Vec<_> = tools.tools.iter().map(|t| t.name.as_ref()).collect();
assert!(names.contains(&"git_view_commits"), "tools were: {names:?}");
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_tools_includes_jira_extension_tools() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let tools = client.list_tools(Option::default()).await?;
let names: Vec<_> = tools.tools.iter().map(|t| t.name.as_ref()).collect();
for expected in [
"jira_attachment_download",
"jira_attachment_images",
"jira_board_list",
"jira_board_issues",
"jira_changelog",
"jira_delete",
"jira_field_list",
"jira_field_options",
"jira_project_list",
"jira_sprint_list",
"jira_sprint_issues",
"jira_sprint_add",
"jira_sprint_create",
"jira_sprint_update",
"jira_watcher_list",
"jira_watcher_add",
"jira_watcher_remove",
"jira_worklog_list",
"jira_worklog_add",
] {
assert!(
names.contains(&expected),
"missing {expected}; tools were: {names:?}"
);
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_tools_includes_all_jira_tools() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let tools = client.list_tools(Option::default()).await?;
let names: Vec<_> = tools.tools.iter().map(|t| t.name.as_ref()).collect();
for expected in [
"jira_read",
"jira_search",
"jira_create",
"jira_write",
"jira_transition",
"jira_comment",
"jira_link",
"jira_dev",
] {
assert!(names.contains(&expected), "missing {expected}: {names:?}");
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn jira_tools_advertise_schemas() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let tools = client.list_tools(Option::default()).await?;
for expected in ["jira_read", "jira_search", "jira_create"] {
let tool = tools
.tools
.iter()
.find(|t| t.name.as_ref() == expected)
.unwrap_or_else(|| panic!("missing {expected}"));
assert!(
tool.description.as_ref().is_some_and(|d| !d.is_empty()),
"{expected} missing description"
);
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_tools_includes_confluence_and_atlassian_tools() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let tools = client.list_tools(Option::default()).await?;
let names: Vec<_> = tools.tools.iter().map(|t| t.name.as_ref()).collect();
for expected in [
"confluence_read",
"confluence_search",
"confluence_create",
"confluence_write",
"confluence_delete",
"confluence_download",
"atlassian_convert",
] {
assert!(
names.contains(&expected),
"expected {expected}, got {names:?}"
);
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn jira_extension_tools_route_through_mcp() -> Result<()> {
let cases: &[(&str, serde_json::Value)] = &[
(
"jira_attachment_download",
serde_json::json!({"key": "PROJ-1"}),
),
(
"jira_attachment_images",
serde_json::json!({"key": "PROJ-1"}),
),
("jira_board_list", serde_json::json!({})),
("jira_board_issues", serde_json::json!({"board_id": 1})),
("jira_changelog", serde_json::json!({"key": "PROJ-1"})),
(
"jira_delete",
serde_json::json!({"key": "PROJ-1", "confirm": true}),
),
("jira_field_list", serde_json::json!({})),
(
"jira_field_options",
serde_json::json!({"field_id": "customfield_1", "context_id": "ctx-1"}),
),
("jira_project_list", serde_json::json!({})),
("jira_sprint_list", serde_json::json!({"board_id": 1})),
("jira_sprint_issues", serde_json::json!({"sprint_id": 1})),
(
"jira_sprint_add",
serde_json::json!({"sprint_id": 1, "issue_keys": ["PROJ-1"]}),
),
(
"jira_sprint_create",
serde_json::json!({"board_id": 1, "name": "S"}),
),
("jira_sprint_update", serde_json::json!({"sprint_id": 1})),
("jira_watcher_list", serde_json::json!({"key": "PROJ-1"})),
(
"jira_watcher_add",
serde_json::json!({"key": "PROJ-1", "account_id": "abc"}),
),
(
"jira_watcher_remove",
serde_json::json!({"key": "PROJ-1", "account_id": "abc"}),
),
("jira_worklog_list", serde_json::json!({"key": "PROJ-1"})),
(
"jira_worklog_add",
serde_json::json!({"key": "PROJ-1", "time_spent": "1h"}),
),
];
{
let _env = AtlassianEnvGuard::empty()?;
let (client, server_handle) = spawn_server().await;
for (tool, args) in cases {
let outcome = client
.call_tool(
CallToolRequestParams::new(*tool)
.with_arguments(args.as_object().unwrap().clone()),
)
.await;
let failed = match outcome {
Ok(result) => result.is_error.unwrap_or(false),
Err(_) => true,
};
assert!(failed, "tool {tool} unexpectedly succeeded without creds");
}
client.cancel().await?;
let _ = server_handle.await;
}
let mock = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::any())
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [],
"issues": [],
"worklogs": [],
"watchers": [],
"watchCount": 0,
"fields": {"attachment": []},
"total": 0,
"isLast": true,
"id": 1,
"name": "S",
"state": "future"
})),
)
.mount(&mock)
.await;
{
let _env = AtlassianEnvGuard::new(&mock.uri(), "u@t.com", "tok")?;
let (client, server_handle) = spawn_server().await;
for (tool, args) in cases {
let result = client
.call_tool(
CallToolRequestParams::new(*tool)
.with_arguments(args.as_object().unwrap().clone()),
)
.await;
let _ = result;
}
client.cancel().await?;
let _ = server_handle.await;
}
Ok(())
}
#[tokio::test]
async fn jira_delete_without_confirm_returns_error() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let outcome = client
.call_tool(
CallToolRequestParams::new("jira_delete").with_arguments(
serde_json::json!({
"key": "PROJ-1",
"confirm": false,
})
.as_object()
.unwrap()
.clone(),
),
)
.await;
match outcome {
Ok(result) => {
assert!(
result.is_error.unwrap_or(false),
"expected destructive guard to surface as a tool error"
);
let text: String = result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect();
assert!(
text.contains("confirm: true"),
"expected guard message; got: {text}"
);
}
Err(err) => {
let msg = format!("{err}");
assert!(
msg.contains("confirm: true"),
"expected guard message in protocol error; got: {msg}"
);
}
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
static ATLASSIAN_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct AtlassianEnvGuard {
_guard: std::sync::MutexGuard<'static, ()>,
prev_home: Option<String>,
prev_xdg: Option<String>,
prev_url: Option<String>,
prev_email: Option<String>,
prev_token: Option<String>,
_tmp: tempfile::TempDir,
}
impl AtlassianEnvGuard {
fn new(instance_url: &str, email: &str, token: &str) -> Result<Self> {
let guard = ATLASSIAN_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir()?;
let prev_home = std::env::var("HOME").ok();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
let prev_url = std::env::var("ATLASSIAN_INSTANCE_URL").ok();
let prev_email = std::env::var("ATLASSIAN_EMAIL").ok();
let prev_token = std::env::var("ATLASSIAN_API_TOKEN").ok();
std::env::set_var("HOME", tmp.path());
std::env::set_var("XDG_CONFIG_HOME", tmp.path().join("xdg"));
std::env::set_var("ATLASSIAN_INSTANCE_URL", instance_url);
std::env::set_var("ATLASSIAN_EMAIL", email);
std::env::set_var("ATLASSIAN_API_TOKEN", token);
Ok(Self {
_guard: guard,
prev_home,
prev_xdg,
prev_url,
prev_email,
prev_token,
_tmp: tmp,
})
}
fn empty() -> Result<Self> {
let guard = ATLASSIAN_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let tmp = tempfile::tempdir()?;
let prev_home = std::env::var("HOME").ok();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
let prev_url = std::env::var("ATLASSIAN_INSTANCE_URL").ok();
let prev_email = std::env::var("ATLASSIAN_EMAIL").ok();
let prev_token = std::env::var("ATLASSIAN_API_TOKEN").ok();
std::env::set_var("HOME", tmp.path());
std::env::set_var("XDG_CONFIG_HOME", tmp.path().join("xdg"));
std::env::remove_var("ATLASSIAN_INSTANCE_URL");
std::env::remove_var("ATLASSIAN_EMAIL");
std::env::remove_var("ATLASSIAN_API_TOKEN");
Ok(Self {
_guard: guard,
prev_home,
prev_xdg,
prev_url,
prev_email,
prev_token,
_tmp: tmp,
})
}
}
impl Drop for AtlassianEnvGuard {
fn drop(&mut self) {
restore_env("HOME", self.prev_home.as_deref());
restore_env("XDG_CONFIG_HOME", self.prev_xdg.as_deref());
restore_env("ATLASSIAN_INSTANCE_URL", self.prev_url.as_deref());
restore_env("ATLASSIAN_EMAIL", self.prev_email.as_deref());
restore_env("ATLASSIAN_API_TOKEN", self.prev_token.as_deref());
}
}
fn restore_env(key: &str, prev: Option<&str>) {
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
fn tool_call_text(result: &rmcp::model::CallToolResult) -> String {
result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect()
}
#[tokio::test]
async fn jira_tool_handlers_round_trip_through_wiremock() -> Result<()> {
let server = wiremock::MockServer::start().await;
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "10001",
"key": "PROJ-1",
"fields": {
"summary": "Sample",
"status": {"name": "Open"},
"issuetype": {"name": "Task"},
"issuelinks": [],
"description": {
"version": 1,
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Body"}]}]
}
}
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/search/jql"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issues": [{"key": "PROJ-1", "fields": {"summary": "Sample"}}],
"total": 1
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/rest/api/3/issue"))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
"id": "100",
"key": "PROJ-100",
"self": "https://example.atlassian.net/rest/api/3/issue/100"
})))
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/rest/api/3/issue/PROJ-1"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1/transitions"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"transitions": [{"id": "11", "name": "In Progress"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issue/PROJ-1/comment"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"startAt": 0,
"maxResults": 100,
"total": 0,
"comments": []
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/api/3/issueLinkType"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"issueLinkTypes": [
{"id": "1", "name": "Blocks", "inward": "is blocked by", "outward": "blocks"}
]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/dev-status/1.0/issue/summary"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"summary": {
"pullrequest": {"overall": {"count": 0}, "byInstanceType": {}},
"branch": {"overall": {"count": 0}, "byInstanceType": {}},
"repository": {"overall": {"count": 0}, "byInstanceType": {}}
}
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/dev-status/1.0/issue/detail"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"detail": [{"pullRequests": [], "branches": [], "repositories": []}]
})))
.mount(&server)
.await;
let _env = AtlassianEnvGuard::new(&server.uri(), "test@test.com", "token")?;
let (client, server_handle) = spawn_server().await;
let calls: [(&str, serde_json::Value); 8] = [
("jira_read", serde_json::json!({"key": "PROJ-1"})),
("jira_search", serde_json::json!({"jql": "project = PROJ"})),
(
"jira_create",
serde_json::json!({"project": "PROJ", "summary": "T", "description": "Body"}),
),
(
"jira_write",
serde_json::json!({"key": "PROJ-1", "content": "Body"}),
),
(
"jira_transition",
serde_json::json!({"key": "PROJ-1", "list": true}),
),
(
"jira_comment",
serde_json::json!({"key": "PROJ-1", "action": "list"}),
),
("jira_link", serde_json::json!({"action": "types"})),
("jira_dev", serde_json::json!({"key": "PROJ-1"})),
];
for (name, args) in &calls {
let result = client
.call_tool(
CallToolRequestParams::new(*name).with_arguments(args.as_object().unwrap().clone()),
)
.await
.unwrap_or_else(|e| panic!("{name} failed: {e}"));
assert!(
!result.is_error.unwrap_or(false),
"{name} returned an error: {}",
tool_call_text(&result)
);
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn jira_tool_handlers_surface_tool_error_without_credentials() -> Result<()> {
let _env = AtlassianEnvGuard::empty()?;
let (client, server_handle) = spawn_server().await;
for name in [
"jira_read",
"jira_search",
"jira_create",
"jira_write",
"jira_transition",
"jira_comment",
"jira_link",
"jira_dev",
] {
let args = match name {
"jira_search" => serde_json::json!({"jql": "x"}),
"jira_create" => serde_json::json!({"project": "P", "summary": "s"}),
"jira_write" => serde_json::json!({"key": "X-1", "content": "b"}),
"jira_comment" => serde_json::json!({"key": "X-1", "action": "list"}),
"jira_link" => serde_json::json!({"action": "types"}),
_ => serde_json::json!({"key": "X-1"}),
};
let outcome = client
.call_tool(
CallToolRequestParams::new(name).with_arguments(args.as_object().unwrap().clone()),
)
.await;
match outcome {
Ok(result) => assert!(
result.is_error.unwrap_or(false),
"{name} should have returned tool_error without credentials"
),
Err(_) => { }
}
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn atlassian_convert_to_adf_roundtrip() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let result = client
.call_tool(
CallToolRequestParams::new("atlassian_convert").with_arguments(
serde_json::json!({
"content": "# Title\n\nParagraph body.",
"direction": "to-adf",
})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
assert!(!result.is_error.unwrap_or(false), "tool returned error");
let text: String = result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect();
assert!(text.contains("\"type\""), "expected ADF JSON: {text}");
assert!(text.contains("\"doc\""));
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn atlassian_convert_invalid_direction_returns_error() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let outcome = client
.call_tool(
CallToolRequestParams::new("atlassian_convert").with_arguments(
serde_json::json!({
"content": "x",
"direction": "sideways",
})
.as_object()
.unwrap()
.clone(),
),
)
.await;
if let Ok(result) = outcome {
assert!(
result.is_error.unwrap_or(false),
"expected tool error for invalid direction"
);
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn git_view_commits_returns_yaml_for_temp_repo() -> Result<()> {
let mut repo = TestRepo::new()?;
repo.add_commit("feat: initial", "hello")?;
repo.add_commit("fix: tweak", "hello world")?;
let (client, server_handle) = spawn_server().await;
let result = client
.call_tool(
CallToolRequestParams::new("git_view_commits").with_arguments(
serde_json::json!({
"range": "HEAD~1..HEAD",
"repo_path": repo.repo_path.to_string_lossy(),
})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
assert!(!result.is_error.unwrap_or(false), "tool returned error");
let text: String = result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect();
assert!(text.contains("commits:"), "missing commits section: {text}");
assert!(text.contains("fix: tweak"), "missing latest commit subject");
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn git_view_commits_invalid_repo_path_returns_error() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let bad_path = "/nonexistent/path/to/no/repo";
let outcome = client
.call_tool(
CallToolRequestParams::new("git_view_commits").with_arguments(
serde_json::json!({
"range": "HEAD",
"repo_path": bad_path,
})
.as_object()
.unwrap()
.clone(),
),
)
.await;
if let Ok(result) = outcome {
assert!(
result.is_error.unwrap_or(false),
"expected tool error for nonexistent repo path"
);
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn mcp_binary_handshakes_and_exits() -> Result<()> {
use std::process::Stdio;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
let bin = env!("CARGO_BIN_EXE_omni-dev-mcp");
let mut child = Command::new(bin)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let initialize = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.0.0"}
}
});
let mut stdin = child.stdin.take().expect("stdin pipe");
stdin
.write_all(format!("{initialize}\n").as_bytes())
.await?;
drop(stdin);
let status = tokio::time::timeout(std::time::Duration::from_secs(10), child.wait()).await??;
let _ = status;
Ok(())
}
#[tokio::test]
async fn mcp_binary_reports_error_on_bad_handshake() -> Result<()> {
use std::process::Stdio;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
let bin = env!("CARGO_BIN_EXE_omni-dev-mcp");
let mut child = Command::new(bin)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdin = child.stdin.take().expect("stdin pipe");
stdin.write_all(b"not valid json\n").await?;
drop(stdin);
let output = tokio::time::timeout(std::time::Duration::from_secs(10), async move {
let status = child.wait().await?;
let mut stderr_buf = Vec::new();
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_end(&mut stderr_buf).await;
}
anyhow::Ok((status, stderr_buf))
})
.await??;
let _ = output;
Ok(())
}
#[tokio::test]
async fn list_tools_includes_phase1_git_tools() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let tools = client.list_tools(Option::default()).await?;
let names: Vec<_> = tools.tools.iter().map(|t| t.name.as_ref()).collect();
for expected in [
"git_view_commits",
"git_branch_info",
"git_check_commits",
"git_twiddle_commits",
"git_create_pr",
] {
assert!(names.contains(&expected), "missing {expected} in {names:?}");
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_tools_includes_phase_3_tools() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let tools = client.list_tools(Option::default()).await?;
let names: Vec<_> = tools.tools.iter().map(|t| t.name.as_ref()).collect();
for expected in [
"ai_chat",
"claude_skills_sync",
"claude_skills_clean",
"claude_skills_status",
"config_models_show",
"atlassian_auth_status",
] {
assert!(names.contains(&expected), "missing {expected}: {names:?}");
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn git_branch_info_returns_yaml_for_temp_repo() -> Result<()> {
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
std::fs::create_dir_all(&tmp_root)?;
let temp_dir = tempfile::tempdir_in(&tmp_root)?;
let repo_path = temp_dir.path().to_path_buf();
let repo = Repository::init(&repo_path)?;
{
let mut config = repo.config()?;
config.set_str("user.name", "Test")?;
config.set_str("user.email", "test@example.com")?;
}
repo.set_head("refs/heads/main")?;
let signature = Signature::now("Test", "test@example.com")?;
fs::write(repo_path.join("a.txt"), "content")?;
let mut idx = repo.index()?;
idx.add_path(std::path::Path::new("a.txt"))?;
idx.write()?;
let tree_id = idx.write_tree()?;
let tree = repo.find_tree(tree_id)?;
repo.commit(
Some("HEAD"),
&signature,
&signature,
"feat: only",
&tree,
&[],
)?;
let (client, server_handle) = spawn_server().await;
let result = client
.call_tool(
CallToolRequestParams::new("git_branch_info").with_arguments(
serde_json::json!({
"repo_path": repo_path.to_string_lossy(),
})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
assert!(!result.is_error.unwrap_or(false));
let text: String = result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect();
assert!(
text.contains("branch:"),
"branch_info should be present: {text}"
);
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn config_models_show_returns_yaml() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let result = client
.call_tool(
CallToolRequestParams::new("config_models_show").with_arguments(serde_json::Map::new()),
)
.await?;
assert!(!result.is_error.unwrap_or(false), "tool returned error");
let text: String = result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect();
assert!(
text.contains("models:") || text.contains("providers:") || text.contains("claude"),
"expected models YAML, got: {text}"
);
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn git_view_commits_rejects_malformed_range() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let outcome = client
.call_tool(
CallToolRequestParams::new("git_view_commits").with_arguments(
serde_json::json!({
"range": "HEAD; rm -rf /",
})
.as_object()
.unwrap()
.clone(),
),
)
.await;
assert!(outcome.is_err(), "expected invalid params error");
let err = outcome.err().unwrap();
let text = format!("{err}");
assert!(
text.contains("not a well-formed git range") || text.contains("range"),
"expected validation message, got: {text}"
);
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn git_branch_info_invalid_repo_returns_error() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let outcome = client
.call_tool(
CallToolRequestParams::new("git_branch_info").with_arguments(
serde_json::json!({ "repo_path": "/no/such/path" })
.as_object()
.unwrap()
.clone(),
),
)
.await;
if let Ok(result) = outcome {
assert!(
result.is_error.unwrap_or(false),
"expected tool error for bad repo path"
);
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn atlassian_auth_status_never_leaks_secrets() -> Result<()> {
let _lock = ATLASSIAN_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let original_home = std::env::var("HOME").ok();
let original_url = std::env::var("ATLASSIAN_INSTANCE_URL").ok();
let original_email = std::env::var("ATLASSIAN_EMAIL").ok();
let original_token = std::env::var("ATLASSIAN_API_TOKEN").ok();
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
fs::create_dir_all(&tmp_root)?;
let tmp = tempfile::tempdir_in(&tmp_root)?;
let omni_dir = tmp.path().join(".omni-dev");
fs::create_dir_all(&omni_dir)?;
fs::write(
omni_dir.join("settings.json"),
r#"{"env":{
"ATLASSIAN_INSTANCE_URL":"https://leakcheck.atlassian.net",
"ATLASSIAN_EMAIL":"leak-email@example.com",
"ATLASSIAN_API_TOKEN":"leak-token-do-not-return"
}}"#,
)?;
std::env::set_var("HOME", tmp.path());
std::env::remove_var("ATLASSIAN_INSTANCE_URL");
std::env::remove_var("ATLASSIAN_EMAIL");
std::env::remove_var("ATLASSIAN_API_TOKEN");
let (client, server_handle) = spawn_server().await;
let result = client
.call_tool(
CallToolRequestParams::new("atlassian_auth_status")
.with_arguments(serde_json::Map::new()),
)
.await?;
assert!(!result.is_error.unwrap_or(false), "tool returned error");
let text: String = result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect();
assert!(text.contains("has_email: true"), "got: {text}");
assert!(text.contains("has_token: true"), "got: {text}");
assert!(
!text.contains("leak-token-do-not-return"),
"leaked token: {text}"
);
assert!(
!text.contains("leak-email@example.com"),
"leaked email: {text}"
);
client.cancel().await?;
let _ = server_handle.await;
restore_env("HOME", original_home.as_deref());
restore_env("ATLASSIAN_INSTANCE_URL", original_url.as_deref());
restore_env("ATLASSIAN_EMAIL", original_email.as_deref());
restore_env("ATLASSIAN_API_TOKEN", original_token.as_deref());
Ok(())
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn ai_chat_returns_tool_error_when_credentials_missing() -> Result<()> {
let _lock = ATLASSIAN_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let original_home = std::env::var("HOME").ok();
let snapshots: Vec<(&str, Option<String>)> = vec![
"USE_OPENAI",
"USE_OLLAMA",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_API_KEY",
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BEDROCK_BASE_URL",
"OPENAI_API_KEY",
"OPENAI_AUTH_TOKEN",
"OLLAMA_MODEL",
"OLLAMA_BASE_URL",
"ANTHROPIC_MODEL",
]
.into_iter()
.map(|k| (k, std::env::var(k).ok()))
.collect();
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
fs::create_dir_all(&tmp_root)?;
let tmp = tempfile::tempdir_in(&tmp_root)?;
std::env::set_var("HOME", tmp.path());
for (k, _) in &snapshots {
std::env::remove_var(k);
}
let (client, server_handle) = spawn_server().await;
let outcome = client
.call_tool(
CallToolRequestParams::new("ai_chat").with_arguments(
serde_json::json!({"message": "hello"})
.as_object()
.unwrap()
.clone(),
),
)
.await;
if let Ok(result) = outcome {
assert!(
result.is_error.unwrap_or(false),
"expected tool error when credentials are missing"
);
}
client.cancel().await?;
let _ = server_handle.await;
restore_env("HOME", original_home.as_deref());
for (k, v) in snapshots {
restore_env(k, v.as_deref());
}
Ok(())
}
#[tokio::test]
async fn claude_skills_status_returns_yaml_report() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let result = client
.call_tool(
CallToolRequestParams::new("claude_skills_status").with_arguments(
serde_json::json!({"format": "yaml"})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
assert!(!result.is_error.unwrap_or(false), "tool returned error");
let text: String = result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect();
assert!(text.contains("targets:"), "missing targets: {text}");
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn git_view_commits_non_git_dir_returns_tool_error() -> Result<()> {
let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
fs::create_dir_all(&tmp_root)?;
let temp_dir = tempfile::tempdir_in(&tmp_root)?;
let dir_path = temp_dir.path();
let (client, server_handle) = spawn_server().await;
let outcome = client
.call_tool(
CallToolRequestParams::new("git_view_commits").with_arguments(
serde_json::json!({
"range": "HEAD",
"repo_path": dir_path.to_string_lossy(),
})
.as_object()
.unwrap()
.clone(),
),
)
.await;
if let Ok(result) = outcome {
assert!(
result.is_error.unwrap_or(false),
"expected tool error from non-git directory"
);
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn git_view_commits_rejects_relative_repo_path() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let outcome = client
.call_tool(
CallToolRequestParams::new("git_view_commits").with_arguments(
serde_json::json!({
"range": "HEAD",
"repo_path": "relative/path",
})
.as_object()
.unwrap()
.clone(),
),
)
.await;
assert!(outcome.is_err(), "expected absolute-path validation error");
let err = outcome.err().unwrap();
let text = format!("{err}");
assert!(
text.contains("absolute") || text.contains("repo_path"),
"expected path validation message, got: {text}"
);
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
async fn call_tool_expect_error(tool_name: &'static str, args: serde_json::Value) -> Result<()> {
let (client, server_handle) = spawn_server().await;
let outcome = client
.call_tool(
CallToolRequestParams::new(tool_name).with_arguments(args.as_object().unwrap().clone()),
)
.await;
if let Ok(result) = outcome {
let _ = result;
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn mcp_binary_stdout_is_pure_json_rpc() -> Result<()> {
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
let bin = env!("CARGO_BIN_EXE_omni-dev-mcp");
let mut child = Command::new(bin)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdin = child.stdin.take().expect("stdin pipe");
let stdout = child.stdout.take().expect("stdout pipe");
let mut reader = BufReader::new(stdout);
let initialize = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "stdout-test", "version": "0.0.0"}
}
});
let initialized_notification = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
let list_tools = serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
});
stdin
.write_all(format!("{initialize}\n").as_bytes())
.await?;
stdin
.write_all(format!("{initialized_notification}\n").as_bytes())
.await?;
stdin
.write_all(format!("{list_tools}\n").as_bytes())
.await?;
stdin.flush().await?;
let mut frames: Vec<serde_json::Value> = Vec::new();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if tokio::time::Instant::now() >= deadline {
break;
}
let mut line = String::new();
let read = tokio::time::timeout(
std::time::Duration::from_secs(5),
reader.read_line(&mut line),
)
.await??;
if read == 0 {
break;
}
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed.is_empty() {
continue;
}
let parsed: serde_json::Value = serde_json::from_str(trimmed)
.unwrap_or_else(|e| panic!("stdout line is not JSON: {e}: {trimmed:?}"));
assert_eq!(
parsed.get("jsonrpc").and_then(|v| v.as_str()),
Some("2.0"),
"frame missing jsonrpc version: {parsed}",
);
let seen_id = parsed.get("id").and_then(serde_json::Value::as_i64);
frames.push(parsed);
if seen_id == Some(2) {
break;
}
}
drop(stdin);
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await;
assert!(!frames.is_empty(), "server emitted no stdout frames");
Ok(())
}
#[tokio::test]
async fn git_check_commits_without_credentials_returns_tool_error() -> Result<()> {
call_tool_expect_error(
"git_check_commits",
serde_json::json!({
"range": "HEAD",
"repo_path": "/no/such/path",
}),
)
.await
}
#[tokio::test]
async fn git_twiddle_commits_without_credentials_returns_tool_error() -> Result<()> {
call_tool_expect_error(
"git_twiddle_commits",
serde_json::json!({
"dry_run": true,
"repo_path": "/no/such/path",
}),
)
.await
}
#[tokio::test]
async fn git_create_pr_without_credentials_returns_tool_error() -> Result<()> {
call_tool_expect_error(
"git_create_pr",
serde_json::json!({
"repo_path": "/no/such/path",
}),
)
.await
}
#[tokio::test]
async fn git_view_commits_default_range_is_head() -> Result<()> {
let mut repo = TestRepo::new()?;
repo.add_commit("feat: only commit", "hello")?;
let (client, server_handle) = spawn_server().await;
let result = client
.call_tool(
CallToolRequestParams::new("git_view_commits").with_arguments(
serde_json::json!({
"repo_path": repo.repo_path.to_string_lossy(),
})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
assert!(!result.is_error.unwrap_or(false));
let text: String = result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect();
assert!(text.contains("feat: only commit"), "got: {text}");
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
static CWD_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[tokio::test]
async fn list_resources_returns_all_five_uri_templates() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let listing = client.list_resources(Option::default()).await?;
let uris: Vec<&str> = listing.resources.iter().map(|r| r.uri.as_str()).collect();
assert!(
uris.contains(&"git://repo/commits/{range}"),
"got: {uris:?}"
);
assert!(uris.contains(&"jira://issue/{key}"));
assert!(uris.contains(&"jira://issue/{key}.adf"));
assert!(uris.contains(&"confluence://page/{id}"));
assert!(uris.contains(&"confluence://page/{id}.adf"));
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_resource_templates_returns_descriptions() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let listing = client.list_resource_templates(Option::default()).await?;
assert_eq!(listing.resource_templates.len(), 5);
for tpl in &listing.resource_templates {
assert!(
tpl.description.is_some(),
"missing description on {}",
tpl.uri_template
);
assert!(
tpl.mime_type.is_some(),
"missing mime on {}",
tpl.uri_template
);
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "multi_thread")]
async fn read_resource_git_commits_head_returns_yaml() -> Result<()> {
let mut repo = TestRepo::new()?;
repo.add_commit("feat: first resource", "r1")?;
{
let _guard = CWD_MUTEX.lock().unwrap();
let original_cwd = std::env::current_dir()?;
std::env::set_current_dir(&repo.repo_path)?;
let (client, server_handle) = spawn_server().await;
let result = client
.read_resource(ReadResourceRequestParams::new("git://repo/commits/HEAD"))
.await;
std::env::set_current_dir(&original_cwd)?;
let result = result?;
assert_eq!(result.contents.len(), 1);
match &result.contents[0] {
ResourceContents::TextResourceContents {
text,
mime_type,
uri,
..
} => {
assert!(text.contains("commits:"), "missing commits section: {text}");
assert!(
text.contains("feat: first resource"),
"missing commit subject: {text}"
);
assert_eq!(mime_type.as_deref(), Some("application/yaml"));
assert_eq!(uri, "git://repo/commits/HEAD");
}
other @ ResourceContents::BlobResourceContents { .. } => {
panic!("expected text, got: {other:?}")
}
}
client.cancel().await?;
let _ = server_handle.await;
}
Ok(())
}
#[tokio::test]
async fn read_resource_unknown_scheme_is_resource_not_found() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let err = client
.read_resource(ReadResourceRequestParams::new("ftp://example.com/foo"))
.await
.expect_err("unknown scheme should error");
let rendered = err.to_string();
assert!(
rendered.contains("ftp://example.com/foo"),
"missing uri in err: {rendered}"
);
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn read_resource_malformed_git_uri_is_resource_not_found() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let err = client
.read_resource(ReadResourceRequestParams::new("git://repo/bogus-path"))
.await
.expect_err("malformed git uri should error");
let rendered = err.to_string();
assert!(
rendered.contains("git://repo/bogus-path"),
"missing uri in err: {rendered}"
);
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_tools_includes_confluence_extensions() -> Result<()> {
let (client, server_handle) = spawn_server().await;
let tools = client.list_tools(Option::default()).await?;
let names: Vec<_> = tools.tools.iter().map(|t| t.name.as_ref()).collect();
for expected in [
"confluence_children",
"confluence_comment_list",
"confluence_comment_add",
"confluence_label_list",
"confluence_label_add",
"confluence_label_remove",
"confluence_user_search",
] {
assert!(names.contains(&expected), "missing {expected}: {names:?}");
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
fn confluence_tool_text(result: &rmcp::model::CallToolResult) -> String {
result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect()
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn confluence_tools_success_paths_via_wiremock() -> Result<()> {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/wiki/rest/api/content/1/child/page"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "2", "title": "Child", "status": "current"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/wiki/api/v2/pages/12345/footer-comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"id": "c1", "version": {"authorId": "alice", "createdAt": "2026-04-01T10:00:00Z"}}
]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/wiki/api/v2/footer-comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "c9"})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/wiki/api/v2/pages/12345/labels"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "1", "name": "arch", "prefix": "global"}]
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/wiki/rest/api/content/12345/label"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"prefix": "global", "name": "arch", "id": "1"}]
})))
.mount(&server)
.await;
Mock::given(method("DELETE"))
.and(path("/wiki/rest/api/content/12345/label/arch"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/wiki/rest/api/search/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [
{"user": {"accountId": "abc", "displayName": "Alice", "email": "a@x.com"}}
]
})))
.mount(&server)
.await;
let _env = AtlassianEnvGuard::new(&server.uri(), "user@test.com", "token")?;
let (client, server_handle) = spawn_server().await;
let children = client
.call_tool(
CallToolRequestParams::new("confluence_children")
.with_arguments(serde_json::json!({"id": "1"}).as_object().unwrap().clone()),
)
.await?;
assert!(!children.is_error.unwrap_or(false));
assert!(confluence_tool_text(&children).contains("Child"));
let comments = client
.call_tool(
CallToolRequestParams::new("confluence_comment_list").with_arguments(
serde_json::json!({"id": "12345"})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
assert!(!comments.is_error.unwrap_or(false));
assert!(confluence_tool_text(&comments).contains("id: c1"));
let comment_add = client
.call_tool(
CallToolRequestParams::new("confluence_comment_add").with_arguments(
serde_json::json!({"id": "12345", "content": "Hello **world**"})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
assert!(!comment_add.is_error.unwrap_or(false));
assert!(confluence_tool_text(&comment_add).contains("Comment added"));
let labels = client
.call_tool(
CallToolRequestParams::new("confluence_label_list").with_arguments(
serde_json::json!({"id": "12345"})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
assert!(!labels.is_error.unwrap_or(false));
assert!(confluence_tool_text(&labels).contains("arch"));
let label_add = client
.call_tool(
CallToolRequestParams::new("confluence_label_add").with_arguments(
serde_json::json!({"id": "12345", "labels": ["arch"]})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
assert!(!label_add.is_error.unwrap_or(false));
assert!(confluence_tool_text(&label_add).contains("Added 1 label"));
let label_remove = client
.call_tool(
CallToolRequestParams::new("confluence_label_remove").with_arguments(
serde_json::json!({"id": "12345", "labels": ["arch"]})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
assert!(!label_remove.is_error.unwrap_or(false));
assert!(confluence_tool_text(&label_remove).contains("Removed 1 label"));
let users = client
.call_tool(
CallToolRequestParams::new("confluence_user_search").with_arguments(
serde_json::json!({"query": "alice"})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
assert!(!users.is_error.unwrap_or(false));
assert!(confluence_tool_text(&users).contains("Alice"));
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn confluence_children_without_credentials_returns_error() -> Result<()> {
let _env = AtlassianEnvGuard::empty()?;
let (client, server_handle) = spawn_server().await;
let outcome = client
.call_tool(
CallToolRequestParams::new("confluence_children").with_arguments(
serde_json::json!({"id": "12345"})
.as_object()
.unwrap()
.clone(),
),
)
.await;
if let Ok(result) = outcome {
assert!(
result.is_error.unwrap_or(false),
"expected tool error without credentials"
);
}
client.cancel().await?;
let _ = server_handle.await;
Ok(())
}