Skip to content

M2 upstream: HTTP-split + perf, HttpServerHost fixes, Windows/MSVC, MCP_HTTP_NO_TLS, session-close + client-capabilities hooks - #4

Merged
nicholicaron merged 12 commits into
mainfrom
feat/m2-upstream
Jul 6, 2026
Merged

M2 upstream: HTTP-split + perf, HttpServerHost fixes, Windows/MSVC, MCP_HTTP_NO_TLS, session-close + client-capabilities hooks#4
nicholicaron merged 12 commits into
mainfrom
feat/m2-upstream

Conversation

@nicholicaron

Copy link
Copy Markdown
Contributor

Summary

Lands the post-0.1.0 SDK work as reviewable slices, plus three additive,
non-breaking public-API hooks. All eight commits are on this branch; the tree
builds and passes the full test suite (225/225) in both the default
(MCP_ENABLE_HTTP=ON) and MCP_HTTP_NO_TLS=ON configurations.

Changed / Performance

  • HTTP transport split into its own mcp::http target. The core mcp
    library now depends only on nlohmann_json; the Streamable-HTTP transport
    (HttpServerHost / HttpClientTransport) and its cpp-httplib → OpenSSL
    dependency move to a separate mcp::http target (still gated by
    MCP_ENABLE_HTTP, default ON). A stdio-only server linked against mcp::mcp
    now loads no OpenSSL/brotli/Security at all.
    • Breaking (build only): consumers of the HTTP transport must link
      mcp::http instead of mcp::mcp. Stdio-only consumers are unaffected.
  • cpp-httplib's optional brotli/zlib auto-detection is pinned off so
    HTTP builds are deterministic regardless of what's installed on the host.
  • Request methods no longer spawn a std::thread per call. The per-call
    std::async(std::launch::async) used only to type-convert the result now runs
    as a typed continuation on the session read thread via
    Session::send_request_for<T>(). Measured in-process tools/call throughput
    +~25% and p50 latency −~21% on an Apple Silicon dev machine.

Fixed

  • HttpServerHost now honors Options::port (previously always
    bind_to_any_port, silently ignoring a requested fixed port); a bind failure
    on the requested port throws instead of falling back.
  • POSTing to a terminated or unknown Mcp-Session-Id now returns 404 as the
    spec requires (the client's cue to re-initialize), instead of 400.

Added

  • Windows (MSVC) support for the core + HTTP transport. The POSIX-only
    StdioTransport is compiled out on _WIN32 (its header carries a matching
    #error guard); everything else builds and tests under MSVC.
  • MCP_HTTP_NO_TLS CMake option — build the HTTP transport without OpenSSL
    (plaintext only), for loopback deployments and for embedding in processes that
    already ship their own TLS/OpenSSL (game engines, editors). The full test suite
    passes in this configuration and the resulting binaries link no crypto at all.
  • examples/http_echo_server — a minimal Streamable-HTTP interop server, verified
    end-to-end against a real MCP client.
  • bench/ — in-process micro-benchmarks plus footprint/startup measurements.
  • HttpServerHost::Options::on_session_closed — a std::function<void(Server&)>
    invoked exactly once per session on each teardown path (client DELETE, and
    stop() for every surviving session) while the Server is still fully alive,
    before the transport is closed or the Server is destroyed. Lets an embedder
    holding a raw Server* (e.g. a cross-thread logging fan-out) deregister it
    while the pointer is valid, closing a use-after-free window that ~Server
    member-destruction order would otherwise leave open.
  • Server::client_capabilities()std::optional<ClientCapabilities> captured
    at initialize, readable from any thread. Lets an embedder gate optional
    server-initiated flows (sampling, elicitation) on what the connected client
    negotiated, instead of discovering the gap through a round-trip failure.

Windows CI

The windows-latest matrix jobs (Debug + RelWithDebInfo) are the SDK's
first-ever MSVC compile — this PR is how they get proven. MCP_WARNINGS_AS_ERRORS
is off on the Windows job until the MSVC warning surface has been triaged once.

Test plan

  • Default config (MCP_ENABLE_HTTP=ON): 225/225 green locally.
  • MCP_HTTP_NO_TLS=ON: 225/225 green locally; binaries link no crypto.
  • Windows (MSVC) Debug + RelWithDebInfo via CI (first run).
  • ASan + TSan sanitizer jobs via CI.
  • install + downstream consume via CI.

🤖 Generated with Claude Code

nicholicaronand others added 12 commits July 4, 2026 14:55
…arget
Two changes that were developed together and share the CMake/target
reshaping, landed as one slice.
Performance: request methods no longer spawn a std::thread per call.
Client::call_tool et al. (and Server::sample/list_roots/elicit) wrapped
each call in std::async(std::launch::async) purely to convert the result
JSON to a typed value. That now runs as a typed continuation on the
session read thread via the new Session::send_request_for<T>(),
eliminating the per-call thread while preserving std::future semantics
(including .wait_for()). Measured in-process tools/call throughput
+~25% and p50 latency -~21% on Apple Silicon.
HTTP split: the Streamable-HTTP transport (HttpServerHost /
HttpClientTransport) and its cpp-httplib -> OpenSSL dependency move to a
separate mcp-http / mcp::http target. The core mcp library now depends
only on nlohmann_json, so a stdio-only server links no OpenSSL/brotli/
Security at all. httplib's optional brotli/zlib auto-detection is pinned
off so builds are deterministic regardless of what is installed on the
host. A shared mcp_target_warnings() function applies the warning set to
both first-party targets.
Breaking (build only): consumers of the HTTP transport must link
mcp::http instead of mcp::mcp; stdio-only consumers are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two HttpServerHost bugs surfaced by real-client interop testing.
Options::port was silently ignored: start() always called
bind_to_any_port, so every consumer asking for a fixed port got an
OS-assigned one. start() now binds the requested port via
bind_to_port and throws on failure instead of falling back to a random
port; port 0 keeps the OS-assigned behavior.
POSTing to a terminated or unknown Mcp-Session-Id returned 400; the
2025-11-25 spec requires 404 so the client knows to re-initialize with
a fresh InitializeRequest. A session-less non-initialize POST still
returns 400 (malformed usage, not a stale session).
Regression tests: HttpEndToEnd.FixedPortIsHonored and
HttpEndToEnd.PostToTerminatedSessionIs404.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The POSIX-only StdioTransport (poll(2) + file descriptors) is compiled
out on _WIN32: its header carries a matching #error guard, and the
CMake source lists gate it behind if(NOT WIN32) in src/, tests/, and
examples/. mcp.hpp only includes stdio_transport.hpp off Windows, and
audit_regression_test.cpp guards its stdio/pipe(2) test and its
<unistd.h> include the same way. Everything else — protocol, session,
client/server, and the Streamable HTTP transport — builds and tests
under MSVC.
New windows-latest CI jobs (Debug + RelWithDebInfo) make this a
supported configuration. MCP_WARNINGS_AS_ERRORS is off on the Windows
job until the first MSVC warning surface has been triaged — this is the
SDK's first MSVC compile.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New CMake option MCP_HTTP_NO_TLS (default OFF) drops the OpenSSL
dependency from the HTTP transport by setting
HTTPLIB_USE_OPENSSL_IF_AVAILABLE OFF before FetchContent_MakeAvailable.
The resulting binaries link no crypto at all — https:// URLs then fail
at runtime, which plaintext-loopback deployments and hosts that
terminate TLS elsewhere don't care about.
The motivating consumer is an embedder (a game engine) that already
ships its own OpenSSL: a second copy in the same process is a
symbol-collision lottery. The full test suite passes in this
configuration. README options table updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Minimal Streamable-HTTP server exposing a single `echo` tool, for
exercising real MCP clients (Claude Code, VS Code, Cursor, MCP
Inspector) against HttpServerHost: initialize handshake, Mcp-Session-Id
round-trip, tools/list, tools/call, long-lived sessions, DELETE
teardown, and optional MCP_TOKEN bearer enforcement. Links mcp::http.
Verified end-to-end against Claude Code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bench/mcp_bench.cpp measures the SDK's own per-call cost in isolation
(tools/call and ping round-trips over the in-process paired transport,
plus the JSON-RPC codec), and bench/README.md records the results and
the native-vs-interpreted footprint/startup rationale. Standalone: built
ad-hoc against the prebuilt static lib per the README, not wired into
the CMake build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HttpServerHost destroys each session's Server on teardown, but exposed
no hook for an embedder to drop a raw Server* it holds before that
destruction. An embedder with a cross-thread Server* registry (e.g. a
logging fan-out calling Server::log() from an arbitrary thread) thus has
no safe deregistration point: ~Server is too late, because member
destruction order lets a concurrent log() lock an already-destroyed
session mutex — a genuine use-after-free.
Options::on_session_closed(Server&) is invoked exactly once per session
on each teardown path (client DELETE, and stop() for every surviving
session), while the Server is still fully alive — before the transport
is closed, the run thread is joined, or the Server is destroyed. The
sessions map lock is not held during the call, and the hook is
exception-contained. The header documents the full threading contract.
This also carries session identity (the Server&), which is what a
session-lifetime consumer such as a sampling provider needs to know a
capable session exists and to be torn down with it — so no separate
session-lifetime API is required.
Tests: HttpEndToEnd.OnSessionClosedFiresOnceOnDelete (fires once on the
DELETE path, not re-fired by a later stop()) and
HttpEndToEnd.OnSessionClosedFiresOnStop (fires once per surviving
session, idempotent across repeated stop()).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture the client's negotiated capabilities during initialize handling
and expose them via client_capabilities(), returning
std::optional<ClientCapabilities> (nullopt before initialize). Guarded
by its own mutex and readable from any thread — the narrow-surface
mutex style already used for the other handler-registration state.
Only the initialize that wins the initialized_ compare-exchange records
the capabilities, so the store is race-free. This lets an embedder gate
optional server-initiated flows (only sample() when the client
advertised sampling; short-circuit an elicitation-driven tool with an
actionable message when elicitation is absent) instead of discovering
the gap through a round-trip failure.
Test: HttpEndToEnd.ClientCapabilitiesCapturedAtInitialize — a fresh
Server returns nullopt; after an initialize advertising sampling +
elicitation, the accessor returns exactly those (read cross-thread from
the on_session_closed hook). CHANGELOG updated for both new SDK hooks.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first MSVC compile of the HTTP host failed: http_server_host.cpp
uses std::deque (buffered_/get_queue_), std::condition_variable
(get_cv_), std::optional, std::vector, std::system_error facilities
(std::errc/error_code/make_error_code), and std::stdexcept types
(invalid_argument/runtime_error) but relied on them being pulled in
transitively — which libstdc++/libc++ do and MSVC's STL does not.
All 36 Windows errors cascaded from these missing includes; the core
protocol/session/server/client TUs compiled clean under MSVC.
Add the explicit includes. No behavior change on any platform.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ignore list covered build/, build-asan/, build-tsan/ but not the
build-rel/ dir the HTTP examples/release config uses, nor macOS
.DS_Store. An untracked build-rel/ makes `git status --porcelain`
non-empty, which trips the strict dirty check in mcp-unreal's
Tools/vendor_mcp_cpp.py (it refuses to vendor from a dirty tree, by
design — untracked files at vendored paths could perturb the header
glob). Collapse the build dirs to a single `build*/` glob so the vendor
script sees a clean tree without weakening its guard or resorting to
--allow-dirty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Options::idle_timeout documented that idle sessions are torn down after
the interval, but no enforcement exists: last_seen is stamped on each
request (src/http_server_host.cpp) and never read, and there is no
reaper. Sessions live until the client sends DELETE or the host is
stop()ed. Rewrite the comment to say so plainly, and record that when an
idle reaper is eventually added it MUST invoke on_session_closed for the
reaped session before destroying its Server, matching the DELETE/stop()
teardown contract (otherwise embedders holding a raw Server* get a UAF).
Documentation-only; no behavior change. A reaper is deliberately out of
scope here — it needs on_session_closed integration and dedicated tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sanitizers/tsan job (ubuntu, default libstdc++) intermittently
reported a data race whose entire stack sits in UNINSTRUMENTED
libstdc++.so.6 — std::_Sp_counted_base::_M_release /
std::__exception_ptr::exception_ptr::_M_release reached from the plain
~promise / shared_ptr<Session> teardown in Session::send_request_for<T>'s
resolver path (session.cpp). TSan cannot see the atomic refcount /
happens-before edges inside precompiled libstdc++, so it flags a false
positive on standard-conformant refcount teardown. That is what flaked
run 28716770512.
Building the tsan job with -stdlib=libc++ (libc++'s std headers are
inlined into the TU and therefore TSan-instrumented) removes the blind
spot without a suppression file. The macOS runs, which already use
header-inlined instrumented libc++, were clean 20+ times, and a bounded
worker4 repro under default libstdc++ reproduced the false positive in
5/40 runs — all in the same Session-teardown family — confirming the
race is environment-dependent, not a real bug.
Install libc++-dev/libc++abi-dev in the tsan job only, and pass
-stdlib=libc++ at both compile and link. No suppressions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nicholicaron
nicholicaron merged commit 57be640 into mainJul 6, 2026
11 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@nicholicaron