Skip to content

Commit 1e9ee88

Browse files
committed
feat: add conformance server groundwork
1 parent 3a7d873 commit 1e9ee88

15 files changed

Lines changed: 1783 additions & 172 deletions

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ endif()
114114
if(MCP_BUILD_EXAMPLES)
115115
# Examples
116116
add_subdirectory(examples/basic)
117+
add_subdirectory(examples/conformance_server)
117118
add_subdirectory(examples/json_test)
118119
add_subdirectory(examples/mcp_client)
119120
add_subdirectory(examples/mcp_server)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#==========================================================================================================
2+
# SPDX-License-Identifier: MIT
3+
# Copyright (c) 2025 Vinny Parla
4+
# File: examples/conformance_server/CMakeLists.txt
5+
# Purpose: Build the MCP server conformance example
6+
#==========================================================================================================
7+
8+
cmake_minimum_required(VERSION 3.20)
9+
10+
project(mcp_conformance_server_example LANGUAGES CXX)
11+
12+
add_executable(conformance_server
13+
main.cpp
14+
)
15+
16+
target_link_libraries(conformance_server PRIVATE mcp::cpp)
17+
target_include_directories(conformance_server PRIVATE ${CMAKE_SOURCE_DIR})
18+
19+
if(MSVC)
20+
target_compile_options(conformance_server PRIVATE /W4)
21+
else()
22+
target_compile_options(conformance_server PRIVATE -Wall -Wextra -Wpedantic)
23+
endif()
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//==========================================================================================================
2+
// SPDX-License-Identifier: MIT
3+
// Copyright (c) 2025 Vinny Parla
4+
// File: examples/conformance_server/main.cpp
5+
// Purpose: Standalone streamable HTTP MCP server fixture for the official server conformance suite
6+
//==========================================================================================================
7+
8+
#include <atomic>
9+
#include <chrono>
10+
#include <csignal>
11+
#include <optional>
12+
#include <string>
13+
#include <thread>
14+
15+
#include "logging/Logger.h"
16+
#include "mcp/HTTPServer.hpp"
17+
#include "mcp/Server.h"
18+
#include "mcp/validation/Validation.h"
19+
#include "src/mcp/ConformanceServerSupport.h"
20+
21+
using namespace mcp;
22+
23+
namespace {
24+
25+
std::atomic<bool> gStopRequested{false};
26+
27+
void handleSignal(int) {
28+
gStopRequested.store(true);
29+
}
30+
31+
std::optional<std::string> getArgValue(int argc, char** argv, const std::string& key) {
32+
for (int i = 1; i < argc; ++i) {
33+
if (argv[i] == nullptr) {
34+
continue;
35+
}
36+
std::string arg = argv[i];
37+
const size_t eq = arg.find('=');
38+
if (eq == std::string::npos) {
39+
continue;
40+
}
41+
if (arg.substr(0, eq) == key) {
42+
return arg.substr(eq + 1);
43+
}
44+
}
45+
return std::nullopt;
46+
}
47+
48+
} // namespace
49+
50+
int main(int argc, char** argv) {
51+
Logger::setLogLevelFromString("INFO");
52+
std::signal(SIGINT, handleSignal);
53+
std::signal(SIGTERM, handleSignal);
54+
55+
HTTPServer::Options options;
56+
options.scheme = "http";
57+
options.address = getArgValue(argc, argv, "--address").value_or("127.0.0.1");
58+
options.port = getArgValue(argc, argv, "--port").value_or("3001");
59+
options.endpointPath = getArgValue(argc, argv, "--endpointPath").value_or("/mcp");
60+
options.streamPath = getArgValue(argc, argv, "--streamPath").value_or("");
61+
62+
Server server("MCP Conformance Server");
63+
server.SetValidationMode(validation::ValidationMode::Strict);
64+
conformance::RegisterConformanceServerProfile(server);
65+
server.SetErrorHandler([](const std::string& error) {
66+
LOG_ERROR("Conformance server error: {}", error);
67+
gStopRequested.store(true);
68+
});
69+
70+
LOG_INFO("Starting conformance server on http://{}:{}{}", options.address, options.port, options.endpointPath);
71+
server.Start(std::make_unique<HTTPServer>(options)).get();
72+
73+
while (!gStopRequested.load()) {
74+
std::this_thread::sleep_for(std::chrono::milliseconds(100));
75+
}
76+
77+
LOG_INFO("Stopping conformance server");
78+
server.Stop().get();
79+
return 0;
80+
}

include/mcp/Server.h

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,16 @@ class IServerFactory;
2929
using ToolResult = CallToolResult;
3030
using ResourceContent = ReadResourceResult;
3131
using PromptResult = GetPromptResult;
32+
using ResourceTemplateVariables = std::unordered_map<std::string, std::string>;
3233

3334
// Async, cancellable handler forms using std::stop_token (C++20)
3435
// Note: These are the canonical handler types. Handlers must return a future.
3536
using ToolHandler = std::function<std::future<ToolResult>(const JSONValue&, std::stop_token)>;
3637
using ResourceHandler = std::function<std::future<ResourceContent>(const std::string&, std::stop_token)>;
38+
using ResourceTemplateHandler =
39+
std::function<std::future<ResourceContent>(const std::string&,
40+
const ResourceTemplateVariables&,
41+
std::stop_token)>;
3742
using PromptHandler = std::function<PromptResult(const JSONValue&)>;
3843
using CompletionHandler = std::function<std::future<CompletionResult>(const CompleteParams&)>;
3944

@@ -268,6 +273,17 @@ class IServer {
268273
//==========================================================================================================
269274
virtual void RegisterResourceTemplate(const ResourceTemplate& resourceTemplate) = 0;
270275

276+
//==========================================================================================================
277+
// Registers a resource template with a concrete read handler.
278+
// Args:
279+
// resourceTemplate: Template metadata to add or replace (by uriTemplate).
280+
// handler: Async callback invoked for concrete URIs that match the template.
281+
// Returns:
282+
// (none)
283+
//==========================================================================================================
284+
virtual void RegisterResourceTemplate(const ResourceTemplate& resourceTemplate,
285+
ResourceTemplateHandler handler) = 0;
286+
271287
//==========================================================================================================
272288
// Unregisters a resource template by uriTemplate.
273289
// Args:
@@ -660,6 +676,8 @@ class Server : public IServer {
660676

661677
// Resource template management
662678
void RegisterResourceTemplate(const ResourceTemplate& resourceTemplate) override;
679+
void RegisterResourceTemplate(const ResourceTemplate& resourceTemplate,
680+
ResourceTemplateHandler handler) override;
663681
void UnregisterResourceTemplate(const std::string& uriTemplate) override;
664682
std::vector<ResourceTemplate> ListResourceTemplates() override;
665683

include/mcp/typed/Content.h

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,25 @@ inline std::vector<std::string> collectText(const CallToolResult& r) {
151151
}
152152

153153
inline std::vector<std::string> collectText(const ReadResourceResult& r) {
154-
return collectText(r.contents);
154+
std::vector<std::string> out;
155+
out.reserve(r.contents.size());
156+
for (const auto& value : r.contents) {
157+
auto typedText = getText(value);
158+
if (typedText.has_value()) {
159+
out.push_back(typedText.value());
160+
continue;
161+
}
162+
if (!std::holds_alternative<JSONValue::Object>(value.value)) {
163+
continue;
164+
}
165+
const auto& objectValue = std::get<JSONValue::Object>(value.value);
166+
auto textIt = objectValue.find("text");
167+
if (textIt != objectValue.end() && textIt->second &&
168+
std::holds_alternative<std::string>(textIt->second->value)) {
169+
out.push_back(std::get<std::string>(textIt->second->value));
170+
}
171+
}
172+
return out;
155173
}
156174

157175
inline std::vector<std::string> collectText(const GetPromptResult& r) {

include/mcp/validation/Validators.h

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -234,16 +234,10 @@ inline bool isPromptMessageItem(const JSONValue& v) {
234234
return false;
235235
}
236236
auto contentIt = obj.find("content");
237-
if (contentIt == obj.end() || !contentIt->second || !std::holds_alternative<JSONValue::Array>(contentIt->second->value)) {
237+
if (contentIt == obj.end() || !contentIt->second) {
238238
return false;
239239
}
240-
const auto& arr = std::get<JSONValue::Array>(contentIt->second->value);
241-
for (const auto& item : arr) {
242-
if (!item || !isContentItem(*item)) {
243-
return false;
244-
}
245-
}
246-
return true;
240+
return isContentItem(*contentIt->second);
247241
}
248242

249243
//------------------------------ JSON validators (for client-side raw JSON) ------------------------------
@@ -300,7 +294,19 @@ inline bool validateReadResourceResultJson(const JSONValue& v) {
300294
if (!p) {
301295
return false;
302296
}
303-
if (!isContentItem(*p)) {
297+
if (!std::holds_alternative<JSONValue::Object>(p->value)) {
298+
return false;
299+
}
300+
const auto& item = std::get<JSONValue::Object>(p->value);
301+
const bool isChunkText = isTextContentItem(*p);
302+
const bool hasUri = isStringField(item, "uri");
303+
const bool hasText = isStringField(item, "text");
304+
const bool hasBlob = isStringField(item, "blob");
305+
const bool isResourceContents =
306+
hasUri &&
307+
(hasText != hasBlob) &&
308+
isOptionalStringField(item, "mimeType");
309+
if (!isChunkText && !isResourceContents) {
304310
return false;
305311
}
306312
}
@@ -648,15 +654,19 @@ inline bool validateCreateMessageParamsJson(const JSONValue& v) {
648654
if (!m || !std::holds_alternative<JSONValue::Object>(m->value)) return false;
649655
const auto& mo = std::get<JSONValue::Object>(m->value);
650656
auto c = mo.find("content");
651-
if (c != mo.end()) {
652-
if (!c->second || !std::holds_alternative<JSONValue::Array>(c->second->value)) return false;
657+
if (c != mo.end()) {
658+
if (!c->second) return false;
659+
if (std::holds_alternative<JSONValue::Array>(c->second->value)) {
653660
const auto& carr = std::get<JSONValue::Array>(c->second->value);
654661
for (const auto& ci : carr) {
655662
if (!ci) return false;
656663
if (!isContentItem(*ci)) return false;
657664
}
665+
} else if (!isContentItem(*c->second)) {
666+
return false;
658667
}
659668
}
669+
}
660670
// modelPreferences/systemPrompt/includeContext are optional, any JSON types accepted
661671
return true;
662672
}
@@ -666,11 +676,15 @@ inline bool validateCreateMessageResultJson(const JSONValue& v) {
666676
const auto& o = std::get<JSONValue::Object>(v.value);
667677
auto mdl = o.find("model"); if (mdl == o.end() || !mdl->second || !std::holds_alternative<std::string>(mdl->second->value)) return false;
668678
auto role = o.find("role"); if (role == o.end() || !role->second || !std::holds_alternative<std::string>(role->second->value)) return false;
669-
auto cont = o.find("content"); if (cont == o.end() || !cont->second || !std::holds_alternative<JSONValue::Array>(cont->second->value)) return false;
670-
const auto& arr = std::get<JSONValue::Array>(cont->second->value);
671-
for (const auto& ci : arr) {
672-
if (!ci) return false;
673-
if (!isContentItem(*ci)) return false;
679+
auto cont = o.find("content"); if (cont == o.end() || !cont->second) return false;
680+
if (std::holds_alternative<JSONValue::Array>(cont->second->value)) {
681+
const auto& arr = std::get<JSONValue::Array>(cont->second->value);
682+
for (const auto& ci : arr) {
683+
if (!ci) return false;
684+
if (!isContentItem(*ci)) return false;
685+
}
686+
} else if (!isContentItem(*cont->second)) {
687+
return false;
674688
}
675689
// stopReason optional
676690
return true;
@@ -682,7 +696,24 @@ inline bool validateCallToolResult(const CallToolResult& r) {
682696
}
683697

684698
inline bool validateReadResourceResult(const ReadResourceResult& r) {
685-
return isContentArray(r.contents);
699+
for (const auto& item : r.contents) {
700+
if (!std::holds_alternative<JSONValue::Object>(item.value)) {
701+
return false;
702+
}
703+
const auto& objectValue = std::get<JSONValue::Object>(item.value);
704+
const bool isChunkText = isTextContentItem(item);
705+
const bool hasUri = isStringField(objectValue, "uri");
706+
const bool hasText = isStringField(objectValue, "text");
707+
const bool hasBlob = isStringField(objectValue, "blob");
708+
const bool isResourceContents =
709+
hasUri &&
710+
(hasText != hasBlob) &&
711+
isOptionalStringField(objectValue, "mimeType");
712+
if (!isChunkText && !isResourceContents) {
713+
return false;
714+
}
715+
}
716+
return true;
686717
}
687718

688719
inline bool validateGetPromptResult(const GetPromptResult& r) {

scripts/run_server_conformance.sh

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env bash
2+
#==========================================================================================================
3+
# SPDX-License-Identifier: MIT
4+
# Copyright (c) 2025 Vinny Parla
5+
# File: scripts/run_server_conformance.sh
6+
# Purpose: Build and run the official MCP server conformance suite against the repo's Dockerized conformance server
7+
#==========================================================================================================
8+
set -euo pipefail
9+
10+
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
11+
cd "$repo_root"
12+
13+
build_image="${MCP_CONFORMANCE_BUILD_IMAGE:-mcp-cpp-build}"
14+
server_container="${MCP_CONFORMANCE_SERVER_CONTAINER:-mcp-cpp-conformance-server}"
15+
server_port="${MCP_CONFORMANCE_SERVER_PORT:-3001}"
16+
server_url="${MCP_CONFORMANCE_SERVER_URL:-http://127.0.0.1:${server_port}/mcp}"
17+
18+
cleanup() {
19+
docker logs "$server_container" >/dev/null 2>&1 || true
20+
docker rm -f "$server_container" >/dev/null 2>&1 || true
21+
}
22+
trap cleanup EXIT
23+
24+
echo "[conformance] Building Dockerfile.demo build stage"
25+
docker buildx build \
26+
-f Dockerfile.demo \
27+
--target build \
28+
--progress=plain \
29+
--pull \
30+
--load \
31+
-t "$build_image" .
32+
33+
docker rm -f "$server_container" >/dev/null 2>&1 || true
34+
35+
echo "[conformance] Starting conformance server container on ${server_url}"
36+
docker run -d \
37+
--rm \
38+
--name "$server_container" \
39+
--network host \
40+
"$build_image" \
41+
bash -lc "/src/build/examples/conformance_server/conformance_server --address=127.0.0.1 --port=${server_port} --endpointPath=/mcp"
42+
43+
echo "[conformance] Waiting for server readiness"
44+
ready=0
45+
for _ in $(seq 1 50); do
46+
if bash -lc "exec 3<>/dev/tcp/127.0.0.1/${server_port}" >/dev/null 2>&1; then
47+
ready=1
48+
break
49+
fi
50+
sleep 0.2
51+
done
52+
53+
if [[ "$ready" -ne 1 ]]; then
54+
echo "[conformance] Server did not become ready on port ${server_port}" >&2
55+
docker logs "$server_container" || true
56+
exit 1
57+
fi
58+
59+
echo "[conformance] Running official MCP server conformance suite"
60+
docker run --rm \
61+
--network host \
62+
node:22-bookworm \
63+
bash -lc "npx --yes @modelcontextprotocol/conformance server --url ${server_url} --suite active"
64+
65+
echo "[conformance] MCP server conformance suite completed successfully"

0 commit comments

Comments
 (0)