Skip to content

Commit 84a0e13

Browse files
committed
Added interface-driven Router/Framer design pattern for all Transports
1 parent b1f7bed commit 84a0e13

19 files changed

Lines changed: 1520 additions & 399 deletions

CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ add_library(mcp_cpp STATIC
5252
src/mcp/auth/OAuthClient.cpp
5353
src/mcp/auth/OAuth2ClientCredentialsAuth.cpp
5454
src/mcp/auth/ServerAuth.cpp
55+
src/mcp/JsonRpcMessageRouter.cpp
56+
src/mcp/ContentLengthFramer.cpp
5557
)
5658
add_library(mcp::cpp ALIAS mcp_cpp)
5759

include/mcp/ContentFramer.h

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//========================================================================================================
2+
// SPDX-License-Identifier: MIT
3+
// Copyright (c) 2025 Vinny Parla
4+
// File: ContentFramer.h
5+
// Purpose: Interface for message framing (MCP stdio framing)
6+
//========================================================================================================
7+
8+
#pragma once
9+
10+
#include <optional>
11+
#include <string>
12+
#include <memory>
13+
14+
namespace mcp {
15+
16+
class IContentFramer {
17+
public:
18+
virtual ~IContentFramer() = default;
19+
enum class DecodeStatus {
20+
Ok,
21+
Incomplete,
22+
InvalidHeader,
23+
BodyTooLarge
24+
};
25+
struct DecodeResult {
26+
DecodeStatus status;
27+
std::optional<std::string> payload; // present when status==Ok
28+
std::size_t bytesConsumed{0}; // header+sep or full frame bytes to drop when appropriate
29+
};
30+
virtual std::string encode(const std::string& payload) = 0;
31+
virtual std::optional<std::string> tryDecode(std::string& buffer) = 0;
32+
virtual DecodeResult tryDecodeEx(const std::string& buffer) = 0;
33+
};
34+
35+
std::unique_ptr<IContentFramer> MakeContentLengthFramer(std::size_t maxContentLength = 1024 * 1024);
36+
37+
} // namespace mcp

include/mcp/JsonRpcMessageRouter.h

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//========================================================================================================
2+
// SPDX-License-Identifier: MIT
3+
// Copyright (c) 2025 Vinny Parla
4+
// File: JsonRpcMessageRouter.h
5+
// Purpose: Interface for JSON-RPC message routing (classification and dispatch)
6+
//========================================================================================================
7+
8+
#pragma once
9+
10+
#include <functional>
11+
#include <optional>
12+
#include <string>
13+
#include <unordered_map>
14+
#include <memory>
15+
16+
#include "mcp/Transport.h"
17+
#include "mcp/JSONRPCTypes.h"
18+
19+
namespace mcp {
20+
21+
struct RouterHandlers {
22+
ITransport::RequestHandler requestHandler;
23+
ITransport::NotificationHandler notificationHandler;
24+
ITransport::ErrorHandler errorHandler;
25+
};
26+
27+
using ResponseResolver = std::function<void(JSONRPCResponse&&)>;
28+
29+
class IJsonRpcMessageRouter {
30+
public:
31+
virtual ~IJsonRpcMessageRouter() = default;
32+
33+
enum class MessageKind {
34+
Request,
35+
Response,
36+
Notification,
37+
Unknown
38+
};
39+
40+
// Classify a JSON-RPC message without invoking handlers.
41+
virtual MessageKind classify(const std::string& json) = 0;
42+
43+
// Routes a JSON-RPC message. If a response should be sent (for requests), returns
44+
// the serialized response payload; for responses/notifications, returns std::nullopt.
45+
// The resolver is invoked when a JSON-RPC response is received and must resolve any pending promise.
46+
virtual std::optional<std::string> route(
47+
const std::string& json,
48+
RouterHandlers& handlers,
49+
const ResponseResolver& resolve) = 0;
50+
};
51+
52+
// Factory: returns the default router implementation
53+
std::unique_ptr<IJsonRpcMessageRouter> MakeDefaultJsonRpcMessageRouter();
54+
55+
} // namespace mcp

include/mcp/StdioTransport.hpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,12 @@ class StdioTransport : public ITransport {
9797
//==========================================================================================================
9898
void SetWriteTimeoutMs(uint64_t timeoutMs);
9999

100+
void SetMaxContentLength(std::size_t maxBytes);
101+
100102
private:
101103
class Impl;
102104
std::unique_ptr<Impl> pImpl;
105+
friend struct StdioTransportTestHooks;
103106
};
104107

105108
//==========================================================================================================
@@ -111,4 +114,10 @@ class StdioTransportFactory : public ITransportFactory {
111114
std::unique_ptr<ITransport> CreateTransport(const std::string& config) override;
112115
};
113116

117+
struct StdioTransportTestHooks {
118+
static void drainFrames(StdioTransport& t, std::string& buffer);
119+
static void setConnected(StdioTransport& t, bool v);
120+
static bool isConnected(const StdioTransport& t);
121+
};
122+
114123
} // namespace mcp

src/mcp/ContentLengthFramer.cpp

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//========================================================================================================
2+
// SPDX-License-Identifier: MIT
3+
// Copyright (c) 2025 Vinny Parla
4+
// File: ContentLengthFramer.cpp
5+
// Purpose: Default Content-Length based framer for MCP stdio transport
6+
//========================================================================================================
7+
8+
#include <algorithm>
9+
#include <cctype>
10+
#include <limits>
11+
#include <optional>
12+
#include <string>
13+
14+
#include "logging/Logger.h"
15+
#include "mcp/ContentFramer.h"
16+
17+
namespace mcp {
18+
19+
namespace {
20+
class ContentLengthFramer : public IContentFramer {
21+
public:
22+
explicit ContentLengthFramer(std::size_t maxLen) : maxContentLength(maxLen) {}
23+
24+
std::string encode(const std::string& payload) override {
25+
std::string header = "Content-Length: " + std::to_string(payload.size()) + "\r\n\r\n";
26+
std::string frame; frame.reserve(header.size() + payload.size());
27+
frame.append(header);
28+
frame.append(payload);
29+
return frame;
30+
}
31+
32+
DecodeResult tryDecodeEx(const std::string& buffer) override {
33+
const std::string sep = "\r\n\r\n";
34+
std::size_t headerEnd = buffer.find(sep);
35+
if (headerEnd == std::string::npos) {
36+
return { DecodeStatus::Incomplete, std::nullopt, 0 };
37+
}
38+
39+
std::size_t pos = 0;
40+
std::size_t contentLength = 0;
41+
bool haveLength = false;
42+
while (pos < headerEnd) {
43+
std::size_t eol = buffer.find("\r\n", pos);
44+
if (eol == std::string::npos || eol > headerEnd) {
45+
break;
46+
}
47+
std::string line = buffer.substr(pos, eol - pos);
48+
auto colon = line.find(':');
49+
if (colon != std::string::npos) {
50+
std::string name = line.substr(0, colon);
51+
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c){ return static_cast<char>(std::tolower(c)); });
52+
std::string value = line.substr(colon + 1);
53+
value.erase(value.begin(), std::find_if(value.begin(), value.end(), [](unsigned char ch){ return !std::isspace(ch); }));
54+
if (name == "content-length") {
55+
try {
56+
unsigned long long v64 = std::stoull(value);
57+
if (v64 > maxContentLength || v64 > std::numeric_limits<std::size_t>::max()) {
58+
LOG_WARN("Content-Length {} exceeds limits (max={})", v64, maxContentLength);
59+
return { DecodeStatus::BodyTooLarge, std::nullopt, headerEnd + sep.size() };
60+
}
61+
contentLength = static_cast<std::size_t>(v64);
62+
haveLength = true;
63+
} catch (...) {
64+
LOG_WARN("Invalid Content-Length header: {}", value);
65+
return { DecodeStatus::InvalidHeader, std::nullopt, headerEnd + sep.size() };
66+
}
67+
}
68+
}
69+
pos = eol + 2;
70+
}
71+
72+
if (!haveLength) {
73+
LOG_WARN("Missing Content-Length header");
74+
return { DecodeStatus::InvalidHeader, std::nullopt, headerEnd + sep.size() };
75+
}
76+
77+
const std::size_t headerAndSep = headerEnd + sep.size();
78+
if (contentLength > std::numeric_limits<std::size_t>::max() - headerAndSep) {
79+
LOG_WARN("Frame size overflow detected (header={}, len={})", headerAndSep, contentLength);
80+
return { DecodeStatus::InvalidHeader, std::nullopt, headerEnd + sep.size() };
81+
}
82+
std::size_t frameTotal = headerAndSep + contentLength;
83+
if (buffer.size() < frameTotal) {
84+
return { DecodeStatus::Incomplete, std::nullopt, 0 };
85+
}
86+
87+
std::string payload = buffer.substr(headerAndSep, contentLength);
88+
return { DecodeStatus::Ok, std::make_optional(std::move(payload)), frameTotal };
89+
}
90+
91+
std::optional<std::string> tryDecode(std::string& buffer) override {
92+
DecodeResult r = tryDecodeEx(buffer);
93+
if (r.status == DecodeStatus::Ok && r.payload.has_value()) {
94+
if (r.bytesConsumed > 0 && r.bytesConsumed <= buffer.size()) {
95+
buffer.erase(0, r.bytesConsumed);
96+
}
97+
return r.payload;
98+
}
99+
return std::nullopt;
100+
}
101+
102+
private:
103+
std::size_t maxContentLength;
104+
};
105+
} // namespace
106+
107+
std::unique_ptr<IContentFramer> MakeContentLengthFramer(std::size_t maxContentLength) {
108+
return std::make_unique<ContentLengthFramer>(maxContentLength);
109+
}
110+
111+
} // namespace mcp

src/mcp/HTTPServer.cpp

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
#include "mcp/HTTPServer.hpp"
2727
#include "mcp/JSONRPCTypes.h"
2828
#include "mcp/auth/ServerAuth.hpp"
29+
#include "mcp/JsonRpcMessageRouter.h"
2930

3031
#include <openssl/ssl.h>
3132

@@ -57,6 +58,8 @@ class HTTPServer::Impl {
5758
std::promise<void> listenReadyPromise;
5859
std::atomic<bool> listenReadySignaled{false};
5960

61+
std::unique_ptr<IJsonRpcMessageRouter> router;
62+
6063
explicit Impl(const HTTPServer::Options& o) : opts(o) {
6164
if (opts.scheme == "https") {
6265
sslCtx = std::make_unique<ssl::context>(ssl::context::tls_server);
@@ -74,6 +77,7 @@ class HTTPServer::Impl {
7477
ssl::context::default_workarounds | ssl::context::no_sslv2 | ssl::context::no_sslv3 |
7578
ssl::context::no_tlsv1 | ssl::context::no_tlsv1_1 | ssl::context::no_tlsv1_2);
7679
}
80+
router = MakeDefaultJsonRpcMessageRouter();
7781
}
7882

7983
~Impl() {
@@ -211,27 +215,38 @@ class HTTPServer::Impl {
211215
return res;
212216
}
213217
mcp::auth::TokenInfoScope tokenScope(bearerAuthEnabled ? &info : nullptr);
218+
// Preserve ParseError mapping for invalid request JSON
214219
JSONRPCRequest rpc;
215220
if (!rpc.Deserialize(req.body())) {
216221
auto err = CreateErrorResponse(nullptr, JSONRPCErrorCodes::ParseError, "Parse error");
217222
res.body() = err->Serialize(); res.prepare_payload();
218223
return res;
219224
}
220-
std::unique_ptr<JSONRPCResponse> out;
221-
try {
222-
out = requestHandler ? requestHandler(rpc) : nullptr;
223-
} catch (const std::exception& e) {
224-
auto er = std::make_unique<JSONRPCResponse>(); er->id = rpc.id;
225-
er->error = CreateErrorObject(JSONRPCErrorCodes::InternalError, e.what());
226-
out = std::move(er);
225+
226+
RouterHandlers handlers{};
227+
handlers.requestHandler = requestHandler;
228+
handlers.notificationHandler = notificationHandler;
229+
handlers.errorHandler = errorHandler;
230+
231+
auto resolve = [&](JSONRPCResponse&&) {
232+
// Unexpected inbound response on /rpc endpoint; surface via error handler only
233+
if (errorHandler) { errorHandler("HTTPServer: unexpected response received on /rpc"); }
234+
};
235+
236+
auto routed = router ? router->route(req.body(), handlers, resolve) : std::optional<std::string>{};
237+
if (routed.has_value()) {
238+
res.body() = routed.value();
239+
res.prepare_payload();
240+
return res;
227241
}
228-
if (!out) {
229-
auto er = std::make_unique<JSONRPCResponse>(); er->id = rpc.id;
230-
er->error = CreateErrorObject(JSONRPCErrorCodes::InternalError, "No response from handler");
231-
out = std::move(er);
242+
// Safety fallback (should not happen if request parsed successfully)
243+
{
244+
auto er = std::make_unique<JSONRPCResponse>();
245+
er->id = rpc.id;
246+
er->error = CreateErrorObject(JSONRPCErrorCodes::InternalError, "Router did not produce response");
247+
res.body() = er->Serialize(); res.prepare_payload();
248+
return res;
232249
}
233-
res.body() = out->Serialize(); res.prepare_payload();
234-
return res;
235250
}
236251

237252
//==========================================================================================================
@@ -253,10 +268,15 @@ class HTTPServer::Impl {
253268
res.body() = er->Serialize(); res.prepare_payload();
254269
return res;
255270
}
256-
if (notificationHandler) {
257-
try { notificationHandler(std::make_unique<JSONRPCNotification>(std::move(note))); }
258-
catch (const std::exception& e) { setError(std::string("Notification handler error: ") + e.what()); }
259-
}
271+
272+
RouterHandlers handlers{};
273+
handlers.requestHandler = requestHandler; // not used on notify path but harmless
274+
handlers.notificationHandler = notificationHandler;
275+
handlers.errorHandler = errorHandler;
276+
277+
auto resolve = [](JSONRPCResponse&&) {};
278+
(void)(router ? router->route(req.body(), handlers, resolve) : std::optional<std::string>{});
279+
260280
res.body() = std::string("{}"); res.prepare_payload();
261281
return res;
262282
}

0 commit comments

Comments
 (0)