#include <atomic>
#include <thread>
#include <agent_smith/agent_smith.hpp>
#include <agent_smith/detail/gather_all.hpp>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <mw/http_client_async_mock.hpp>
namespace agent_smith
{
namespace
{
using namespace std::chrono_literals;
using Json = nlohmann::json;
mcp::ServerConfig server(std::string mode = "normal",
std::string alias = "test")
{
return {.alias = std::move(alias),
.transport = mcp::StdioServerConfig{{AGENT_SMITH_TEST_PYTHON,
AGENT_SMITH_MCP_FIXTURE,
std::move(mode)}},
.request_timeout = 2s};
}
TEST(McpJsonrpcTest, InitializesDiscoversAndRendersText)
{
Runtime runtime;
std::atomic<int> notifications{0};
auto result = syncWait(mcp::Client::connect(
runtime, server(),
[¬ifications](const mcp::Event& event)
{
if(std::holds_alternative<mcp::Notification>(event))
{
++notifications;
}
}));
ASSERT_TRUE(result) << result.error().msg();
auto& client = **result;
EXPECT_EQ(client.info().name, "agent-smith-example");
ASSERT_EQ(client.tools().size(), 1U);
EXPECT_EQ(client.tools()[0].name, "add");
ASSERT_EQ(client.resources().size(), 1U);
EXPECT_EQ(client.resources()[0].mime_type, "text/plain");
ASSERT_EQ(client.prompts().size(), 1U);
auto sum = syncWait(client.callTool("add", {{"a", 2}, {"b", 3}}, 1s));
ASSERT_TRUE(sum) << sum.error().msg();
EXPECT_EQ(sum->content, "5");
EXPECT_EQ(sum->structured["sum"], 5);
auto resource = syncWait(client.readResource("example://help"));
ASSERT_TRUE(resource);
EXPECT_EQ(*resource, "The add tool adds two numbers.");
auto prompt = syncWait(client.getPrompt("greet", {{"name", "Alice"}}));
ASSERT_TRUE(prompt);
ASSERT_EQ(prompt->messages.size(), 1U);
EXPECT_EQ(std::get<UserMessage>(prompt->messages[0]).content,
"Say hello to Alice.");
EXPECT_GE(notifications.load(), 1);
EXPECT_TRUE(syncWait(client.shutdown()));
EXPECT_TRUE(syncWait(client.shutdown()));
}
TEST(McpJsonrpcTest, RoutesOutOfOrderResponsesConcurrently)
{
Runtime runtime;
auto connected = syncWait(mcp::Client::connect(runtime, server()));
ASSERT_TRUE(connected);
std::vector<mw::Task<mw::E<ToolResult>>> tasks;
tasks.push_back(
(*connected)->callTool("ordered", {{"label", "first"}}, 1s));
tasks.push_back(
(*connected)->callTool("ordered", {{"label", "second"}}, 1s));
auto results = syncWait(internal::gatherAll(std::move(tasks)));
ASSERT_EQ(results.size(), 2U);
ASSERT_TRUE(results[0]);
ASSERT_TRUE(results[1]);
EXPECT_EQ(results[0]->content, "first");
EXPECT_EQ(results[1]->content, "second");
}
TEST(McpJsonrpcTest, DistinguishesToolFailureAndRpcError)
{
Runtime runtime;
auto connected = syncWait(mcp::Client::connect(runtime, server()));
ASSERT_TRUE(connected);
auto failure = syncWait((*connected)->callTool("add", Json::object(), 1s));
ASSERT_TRUE(failure);
EXPECT_TRUE(failure->is_error);
auto remote =
syncWait((*connected)->callTool("rpc_error", Json::object(), 1s));
ASSERT_FALSE(remote);
const auto* error = remote.error().as<McpError>();
ASSERT_NE(error, nullptr);
EXPECT_EQ(error->rpc_code, -32001);
EXPECT_EQ(error->server, "test");
EXPECT_EQ(error->data["reason"], "test");
}
TEST(McpJsonrpcTest, TimesOutAndNotifiesCancellation)
{
Runtime runtime;
auto connected = syncWait(mcp::Client::connect(runtime, server()));
ASSERT_TRUE(connected);
auto hung = syncWait((*connected)->callTool("hang", Json::object(), 30ms));
ASSERT_FALSE(hung);
EXPECT_NE(hung.error().as<TimeoutError>(), nullptr);
auto observed =
syncWait((*connected)->callTool("cancelled", Json::object(), 1s));
ASSERT_TRUE(observed);
EXPECT_EQ(Json::parse(observed->content).size(), 1U);
auto next =
syncWait((*connected)->callTool("add", {{"a", 1}, {"b", 4}}, 1s));
ASSERT_TRUE(next);
EXPECT_EQ(next->content, "5");
}
TEST(McpJsonrpcTest, CancelsAnInFlightRequest)
{
Runtime runtime;
auto connected = syncWait(mcp::Client::connect(runtime, server()));
ASSERT_TRUE(connected);
CancellationSource cancel;
std::jthread thread(
[cancel]() mutable
{
std::this_thread::sleep_for(30ms);
cancel.cancel();
});
auto hung = syncWait(
(*connected)->callTool("hang", Json::object(), 1s, cancel.token()));
ASSERT_FALSE(hung);
EXPECT_NE(hung.error().as<CancelledError>(), nullptr);
auto observed =
syncWait((*connected)->callTool("cancelled", Json::object(), 1s));
ASSERT_TRUE(observed);
EXPECT_EQ(Json::parse(observed->content).size(), 1U);
}
TEST(McpJsonrpcTest, FiltersToolsWithDenyTakingPrecedence)
{
Runtime runtime;
auto config = server("pagination");
config.allow_tools = {"add", "second"};
config.deny_tools = {"add"};
auto connected = syncWait(mcp::Client::connect(runtime, std::move(config)));
ASSERT_TRUE(connected);
ASSERT_EQ((*connected)->tools().size(), 1U);
EXPECT_EQ((*connected)->tools()[0].name, "second");
}
TEST(McpJsonrpcTest, OptionalMethodsMayBeMissingButMalformedListsFail)
{
Runtime runtime;
auto connected =
syncWait(mcp::Client::connect(runtime, server("optional_missing")));
ASSERT_TRUE(connected);
EXPECT_TRUE((*connected)->resources().empty());
EXPECT_TRUE((*connected)->prompts().empty());
auto malformed =
syncWait(mcp::Client::connect(runtime, server("optional_bad")));
ASSERT_FALSE(malformed);
EXPECT_NE(malformed.error().as<McpError>(), nullptr);
}
TEST(McpJsonrpcTest, RequiredToolsMustBeAvailable)
{
Runtime runtime;
EXPECT_FALSE(syncWait(mcp::Client::connect(runtime, server("no_tools"))));
auto config = server("no_tools");
config.tools_required = false;
auto connected = syncWait(mcp::Client::connect(runtime, std::move(config)));
ASSERT_TRUE(connected);
EXPECT_TRUE((*connected)->tools().empty());
}
TEST(McpJsonrpcTest, RejectsInvalidProtocolAndPagination)
{
Runtime runtime;
for(const auto& mode : {"bad_version", "bad_json", "cycle"})
{
auto connected = syncWait(mcp::Client::connect(runtime, server(mode)));
ASSERT_FALSE(connected) << mode;
EXPECT_NE(connected.error().as<McpError>(), nullptr);
}
}
TEST(McpJsonrpcTest, InitializationHasDeadlineAndLaunchErrorsAreImmediate)
{
Runtime runtime;
auto config = server("hang_init");
config.request_timeout = 100ms;
auto connected = syncWait(mcp::Client::connect(runtime, std::move(config)));
ASSERT_FALSE(connected);
EXPECT_NE(connected.error().as<TimeoutError>(), nullptr);
config = server();
config.transport = mcp::StdioServerConfig{{"/no/such/mcp/executable"}};
auto failed = syncWait(mcp::Client::connect(runtime, std::move(config)));
ASSERT_FALSE(failed);
ASSERT_NE(failed.error().as<McpError>(), nullptr);
EXPECT_TRUE(failed.error().as<McpError>()->transport);
}
TEST(McpJsonrpcTest, RejectsUnrepresentableContentAndMalformedEnvelopes)
{
Runtime runtime;
auto connected = syncWait(mcp::Client::connect(runtime, server("binary")));
ASSERT_TRUE(connected);
EXPECT_FALSE(syncWait((*connected)->readResource("example://help")));
EXPECT_FALSE(
syncWait((*connected)->callTool("multimodal", Json::object(), 1s)));
EXPECT_FALSE(
syncWait((*connected)->callTool("bad_envelope", Json::object(), 1s)));
auto prompt = syncWait(mcp::Client::connect(runtime, server("bad_prompt")));
ASSERT_TRUE(prompt);
EXPECT_FALSE(syncWait((*prompt)->getPrompt("greet", Json::object())));
}
TEST(McpJsonrpcTest, DoesNotReplayWrittenToolCallsAndReconnectsNextOperation)
{
Runtime runtime;
std::atomic<int> reconnects{0};
auto connected = syncWait(mcp::Client::connect(
runtime, server(),
[&reconnects](const mcp::Event& event)
{
const auto* transition = std::get_if<mcp::LifecycleEvent>(&event);
if(transition &&
transition->kind == mcp::LifecycleEventKind::RECONNECTING)
{
++reconnects;
}
}));
ASSERT_TRUE(connected);
auto failed =
syncWait((*connected)->callTool("disconnect", Json::object(), 1s));
ASSERT_FALSE(failed);
EXPECT_EQ(reconnects.load(), 0);
auto next =
syncWait((*connected)->callTool("add", {{"a", 1}, {"b", 2}}, 1s));
ASSERT_TRUE(next) << next.error().msg();
EXPECT_EQ(next->content, "3");
EXPECT_EQ(reconnects.load(), 1);
}
TEST(McpJsonrpcTest, PrefixesAdaptersAndRetainsClientsAcrossServers)
{
Runtime runtime;
ToolRegistry registry;
for(const auto& alias : {"one", "two"})
{
auto connected =
syncWait(mcp::Client::connect(runtime, server("normal", alias)));
ASSERT_TRUE(connected);
auto client = std::shared_ptr<mcp::Client>(std::move(*connected));
for(auto& tool : mcp::adaptTools(client))
{
EXPECT_TRUE(registry.add(std::move(tool)));
}
}
EXPECT_EQ(registry.size(), 4U);
ASSERT_NE(registry.find("mcp_one__add"), nullptr);
auto result = syncWait(registry.find("mcp_one__add")
->invoke({{"a", 8}, {"b", 9}},
{.budget = 1s, .runtime = &runtime}));
ASSERT_TRUE(result);
EXPECT_EQ(result->content, "17");
auto resource = syncWait(registry.find("mcp_two__read_resource")
->invoke({{"uri", "example://help"}},
{.budget = 1s, .runtime = &runtime}));
ASSERT_TRUE(resource);
EXPECT_EQ(resource->content, "The add tool adds two numbers.");
}
TEST(McpJsonrpcTest, ShutdownReapsUncooperativeServer)
{
Runtime runtime;
auto connected =
syncWait(mcp::Client::connect(runtime, server("stubborn")));
ASSERT_TRUE(connected);
const auto start = std::chrono::steady_clock::now();
EXPECT_TRUE(syncWait((*connected)->shutdown()));
EXPECT_LT(std::chrono::steady_clock::now() - start, 2s);
EXPECT_FALSE(
syncWait((*connected)->callTool("add", {{"a", 1}, {"b", 2}}, 1s)));
}
TEST(McpJsonrpcTest, ReconnectsReadOnlyOperationsAtMostOnce)
{
Runtime runtime;
std::atomic<int> reconnects{0};
auto connected = syncWait(mcp::Client::connect(
runtime, server("resource_disconnect"),
[&reconnects](const mcp::Event& event)
{
const auto* transition = std::get_if<mcp::LifecycleEvent>(&event);
if(transition &&
transition->kind == mcp::LifecycleEventKind::RECONNECTING)
{
++reconnects;
}
}));
ASSERT_TRUE(connected);
EXPECT_FALSE(syncWait((*connected)->readResource("example://help")));
EXPECT_EQ(reconnects.load(), 1);
}
TEST(McpJsonrpcTest, CanDisableReconnectAndCancelBeforeSending)
{
Runtime runtime;
auto config = server();
config.reconnect_on_transport_error = false;
auto connected = syncWait(mcp::Client::connect(runtime, std::move(config)));
ASSERT_TRUE(connected);
CancellationSource cancel;
cancel.cancel();
auto cancelled = syncWait(
(*connected)
->callTool("add", {{"a", 1}, {"b", 2}}, 1s, cancel.token()));
ASSERT_FALSE(cancelled);
EXPECT_NE(cancelled.error().as<CancelledError>(), nullptr);
EXPECT_FALSE(
syncWait((*connected)->callTool("disconnect", Json::object(), 1s)));
EXPECT_FALSE(
syncWait((*connected)->callTool("add", {{"a", 1}, {"b", 2}}, 1s)));
}
TEST(McpJsonrpcTest, AgentUsesMcpSchemasAndRecordsToolResults)
{
using ::testing::_;
using ::testing::Invoke;
Runtime runtime;
mw::HTTPSessionAsyncMock http;
LlmClient client(runtime, http,
{.endpoint = *mw::URL::fromStr("https://example.test"),
.model = "test",
.max_retries = 0});
auto memory = std::make_shared<FullMemory>();
Agent agent(runtime, client, memory, {.stream_default = false});
std::atomic<int> connected_events{0};
agent.hooks().on_mcp_event = [&connected_events](const mcp::Event& event)
{
if(const auto* transition = std::get_if<mcp::LifecycleEvent>(&event);
transition && transition->kind == mcp::LifecycleEventKind::CONNECTED)
{
++connected_events;
}
};
ASSERT_TRUE(syncWait(agent.connectMcp(server())));
EXPECT_EQ(connected_events.load(), 1);
int requests = 0;
EXPECT_CALL(http, post(_))
.Times(2)
.WillRepeatedly(Invoke(
[&requests](mw::HTTPRequest request)
{
const auto body = Json::parse(request.request_data);
EXPECT_EQ(body["tools"][0]["function"]["name"],
"mcp_test__add");
EXPECT_EQ(body["tools"][1]["function"]["name"],
"mcp_test__read_resource");
Json message{{"role", "assistant"}, {"content", "sum is 5"}};
if(++requests == 1)
{
message["tool_calls"] =
Json::array({{{"id", "add-call"},
{"type", "function"},
{"function",
{{"name", "mcp_test__add"},
{"arguments", R"({"a":2,"b":3})"}}}}});
}
else
{
EXPECT_EQ(body["messages"].back()["role"], "tool");
EXPECT_EQ(body["messages"].back()["content"], "5");
}
return mw::HTTPSessionAsyncMock::complete(mw::HTTPResponse(
200,
Json{{"choices", Json::array({{{"message", message}}})}}
.dump()));
}));
auto result = syncWait(agent.send("add two and three"));
ASSERT_TRUE(result) << result.error().msg();
EXPECT_EQ(result->content, "sum is 5");
EXPECT_EQ(memory->size(), 4U);
}
TEST(McpJsonrpcTest, AgentRejectsCollisionsWithoutPartialRegistration)
{
Runtime runtime;
LlmClient client(runtime,
{.endpoint = *mw::URL::fromStr("https://example.test"),
.model = "test"});
auto connected = syncWait(mcp::Client::connect(runtime, server()));
ASSERT_TRUE(connected);
auto remote = std::shared_ptr<mcp::Client>(std::move(*connected));
{
Agent agent(runtime, client, std::make_shared<FullMemory>());
EXPECT_FALSE(agent.addMcpClient(nullptr));
ASSERT_TRUE(agent.addTool(makeTool(
ToolSchema{"mcp_test__read_resource",
"occupied",
{{"type", "object"}, {"properties", Json::object()}}},
[](Json, ToolContext) { return "local"; })));
EXPECT_FALSE(agent.addMcpClient(remote));
// A failed batch must not leave the earlier add adapter registered.
EXPECT_TRUE(agent.addTool(makeTool(
ToolSchema{"mcp_test__add",
"local add",
{{"type", "object"}, {"properties", Json::object()}}},
[](Json, ToolContext) { return "local"; })));
}
{
Agent agent(runtime, client, std::make_shared<FullMemory>());
ASSERT_TRUE(agent.addMcpClient(remote));
EXPECT_FALSE(agent.addMcpClient(remote));
}
// Hooks bound by the destroyed agent must not retain a dangling this.
EXPECT_TRUE(syncWait(remote->shutdown()));
}
TEST(McpJsonrpcTest, CancelsBlockedWritesWithoutWaitingForRequestDeadline)
{
Runtime runtime;
auto connected =
syncWait(mcp::Client::connect(runtime, server("stop_reading")));
ASSERT_TRUE(connected);
CancellationSource cancel;
std::jthread thread(
[cancel]() mutable
{
std::this_thread::sleep_for(30ms);
cancel.cancel();
});
const auto start = std::chrono::steady_clock::now();
auto result = syncWait(
(*connected)
->callTool("add", {{"padding", std::string(1024 * 1024, 'x')}}, 2s,
cancel.token()));
ASSERT_FALSE(result);
EXPECT_NE(result.error().as<CancelledError>(), nullptr);
EXPECT_LT(std::chrono::steady_clock::now() - start, 1s);
}
TEST(McpJsonrpcTest, AppliesWriteDeadlineWhenServerStopsReading)
{
Runtime runtime;
auto connected =
syncWait(mcp::Client::connect(runtime, server("stop_reading")));
ASSERT_TRUE(connected);
const auto start = std::chrono::steady_clock::now();
auto result = syncWait(
(*connected)
->callTool("add", {{"padding", std::string(1024 * 1024, 'x')}},
40ms));
ASSERT_FALSE(result);
EXPECT_NE(result.error().as<TimeoutError>(), nullptr);
EXPECT_LT(std::chrono::steady_clock::now() - start, 1s);
}
TEST(McpJsonrpcTest, RejectsInvalidConfigurationAndDeniedDirectCalls)
{
Runtime runtime;
auto config = server();
config.alias = "bad alias";
EXPECT_FALSE(syncWait(mcp::Client::connect(runtime, config)));
config = server();
config.request_timeout = 0ms;
EXPECT_FALSE(syncWait(mcp::Client::connect(runtime, config)));
config = server();
config.transport =
mcp::StdioServerConfig{{"python3", std::string("bad\0arg", 7)}};
EXPECT_FALSE(syncWait(mcp::Client::connect(runtime, config)));
config = server();
config.deny_tools = {"add"};
auto connected = syncWait(mcp::Client::connect(runtime, config));
ASSERT_TRUE(connected);
EXPECT_TRUE((*connected)->tools().empty());
EXPECT_FALSE(
syncWait((*connected)->callTool("add", {{"a", 1}, {"b", 2}}, 1s)));
}
} // namespace
} // namespace agent_smith