A modern, type-theoretic C++23 implementation of the Model Context Protocol (revision 2026-07-28 — the stateless core — with dual-era interop back to 2025-11-25). Header-only, built on jsonrpc-cpp, no codegen.
It has no runtime: no threads, transports, futures or clocks. The protocol is
types (every method declared once, in protocol.hpp) and answer tables (a
Server for what a server answers, ClientHandlers for the server's
callbacks). The host drives a jsonrpc::Engine over whatever byte channel it
has and decides what runs where.
The protocol is encoded in the type system: every wire message is a closed
expression in a small algebra of constructors, and (de)serialisation is a
fold over that expression — there is not a single hand-written
to_json/from_json at any call site.
T ⇄ Json A Codec<T> witnesses a partial isomorphism with the law
encode decode decode ∘ encode = id_T
The MCP schema (schema.ts) is a family of inductive types built from a handful
of constructors. We give those constructors names and compose them:
| Schema construct | Algebra | Combinator |
|---|---|---|
interface { … } (record) |
product Π fᵢ : Tᵢ |
record(field…) |
A | B tagged by "type" |
coproduct Σ Aᵢ |
sum_tagged(key, …) |
A | B by shape |
structural union | variant_codec(…) |
"a" | "b" | "c" |
closed enum | enum_codec(…) |
T[] |
List functor |
list_codec |
T? |
Maybe ≅ T + 1 |
maybe_codec |
| opaque string id | Newtype<Tag, string> |
newtype_codec |
A representative type — the elicitation field schema union (SEP-1330) — reads as a single declarative expression:
using PrimitiveSchema = Sum<
UntitledSingleSelectEnum, TitledSingleSelectEnum,
UntitledMultiSelectEnum, TitledMultiSelectEnum,
NumberSchema, BooleanSchema, StringSchema>;
template <> struct CodecOf<PrimitiveSchema> {
static Codec<PrimitiveSchema> get() {
return variant_codec<PrimitiveSchema>(
codec<UntitledSingleSelectEnum>(), codec<TitledSingleSelectEnum>(),
codec<UntitledMultiSelectEnum>(), codec<TitledMultiSelectEnum>(),
codec<NumberSchema>(), codec<BooleanSchema>(), codec<StringSchema>());
}
};The algebra, codecs and JSON-RPC engine are jsonrpc-cpp's, shared with acp-cpp.
Every export interface / export type in the authoritative
schema.ts
is represented and round-trips. Concretely:
- Base protocol — JSON-RPC 2.0 envelopes,
initializehandshake + capability negotiation,ping, cancellation, progress, pagination,_meta. - Server features — tools (input/output schemas, annotations,
executiontask-support), resources + resource templates + subscribe/updated, prompts, completion (autocomplete), logging. - Client features — sampling (
createMessagewithtools/toolChoice, SEP-1577), roots, elicitation (form + URL mode, SEP-1036) with the full SEP-1330 enum-schema family. - Content — text / image / audio /
resource_link/ embedded resource; sampling addstool_use/tool_result. - Icons (SEP-973) on tools, resources, prompts, implementation info.
- Tasks (durable requests, SEP-1686) —
tasks/get,tasks/result,tasks/cancel,tasks/list, status notifications, task-augmented params.
The headline of the new revision: MCP becomes a request/response stateless protocol. This library implements both halves, dual-era, so a modern peer and a legacy handshake peer both interoperate:
- Per-request
_meta—protocolVersion/clientInfo/clientCapabilitiesride under reverse-DNS_metakeys on every request, replacing theinitializesession.modern_request_meta()builds them forEngine::set_request_meta;IncomingRequestreads them back server-side with no session. server/discover— the stateless replacement for the initialize handshake;Serveranswers it out of the box (supported versions, capabilities, instructions,serverInfo, cache hint).- MRTR (Multi Round-Trip Requests) — a server answers a request with a
non-final
input_requiredresult carrying an opaque, HMAC-signedrequestState; the client fulfils the sampling / elicitation / roots sub-requests locally and retries.mrtr_retry()is one turn of that loop as a pure function (the caller sends);input_required()+RequestStateCodecIncomingRequestbuild the server side. No sticky session anywhere.
- Cacheable lists (SEP-2549) —
tools/list/prompts/list/resources/list/resources/readcarryttlMs+cacheScope, emitted with a deterministic order so client + prompt caches stay stable across reconnects. UnsupportedProtocolVersion/MissingRequiredClientCapabilityerror codes for inline version negotiation.require_capabilities()enforces a needed client capability server-side (SEP), androuting_headers_match()validates theMcp-Method/Mcp-Nameheader-based routing (SEP-2243) against the body (confused-deputy guard).- Authorization (
mcp/auth.hpp, SEP-2468/837/2352) — a dependency-free OAuth 2.1 + PKCE(S256) client flow with the revision's hardening baked in: RFC 9207issvalidation (AS-mix-up guard, enforced before code redemption),application_type=nativeDCR so loopback CLI redirects aren't rejected, issuer-bound access tokens (no cross-AS replay), CIMD client-id URLs, and RFC 9728WWW-Authenticatechallenge parsing. Includes a portable SHA-256 + base64url so it pulls in no crypto library. - Tasks extension (SEP-2663) —
tasks/update+subscriptions/listen(the single opt-in notification stream) alongside the existing poll-basedtasks/get, under theio.modelcontextprotocol/tasksextension key. - Deprecations (SEP-2577) — Roots, Sampling, Logging, and legacy HTTP+SSE
are marked deprecated (still fully functional);
is_deprecated_method()answers for tooling that wants to steer new code off them.
Every method is a type: to_server::CallTool, to_client::Elicit, … each
carries its wire name, params and result, so
auto [id, fx] = mcp::request<to_server::CallTool>(engine, params);
auto res = mcp::result<to_server::CallTool>(completed); // expected<CallToolResult, RpcError>
router.on<to_server::CallTool>([](Ctx&, const CallToolParams& p) { … });resolve the method string and the result type from a single name — a mismatched (method, params, result) triple is unrepresentable.
The wire protocol (client.hpp / server.hpp) answers how two peers talk
JSON-RPC. Above it sits mcp/cap/ — one uniform abstraction for the question
an agent actually has:
What can I do? → Registry::tools()
Do this thing. → Registry::dispatch(Request{tool, args})
A CapabilityProvider is the single seam. LocalProvider backs in-process
C++ closures (the host's own tools); a host adds its own providers for
anything else — a remote MCP server it connects to, an RPC service, a
database — by implementing one interface. The agent cannot tell them
apart, which is the point. (agentty's mcp::Connection is such a provider:
an MCP server over stdio or HTTP, run on the host's own threads.)
A Registry fans N providers into one surface: it presents the union of their
tools, namespaces collisions as <origin>__<name>, routes dispatch() to the
owning provider, and fans in resources + prompts the same way. It is a plain
value with one owner: build it, then read it. MCP is just one kind of
provider — remove it and the abstraction is intact.
#include <mcp/cap/cap.hpp>
using namespace mcp::cap;
Registry reg;
reg.add(std::make_shared<LocalProvider>("local")); // your closures
reg.add(my_remote_server_provider); // any CapabilityProvider
for (const Tool& t : reg.tools()) advertise(t); // union, namespaced
Result r = reg.dispatch("local__read", Json{{"path","a.c"}});mcp/tools/ is a COMPILED add-on (libmcp_tools / mcp::tools, built when
MCP_BUILD_TOOLS=ON) — separate from the header-only core, linked only if you
want a production agent toolset out of the box:
- Tier-1, self-contained —
read/write/edit/list_dir,bash,grep/glob/find_definition,diagnostics,git_status/git_diff/git_log/git_commit,web_fetch/web_search. Fully defined by the library (the web tools need an injected HTTP backend). - Host-coupled SHELLS —
remember/forget/wipe_memory,todo,skill,search_docs,task. mcp-cpp owns each tool's shell (name, schema, arg parsing, output formatting, protocol surface); the host injects the one operation it performs via a smallstd::function-based interface. Inversion of control: a tool is registered only if its backend is installed, so the library never drags in your database / RAG stack / agent loop.
#include <mcp/tools/toolset.hpp>
mcp::tools::HostServices svc;
svc.files = std::make_shared<MyFileSystem>(); // enables read/edit/grep/... (see files.hpp)
svc.memory = std::make_shared<MyMemoryStore>(); // enables remember/forget/wipe
svc.retriever = std::make_shared<MyDocRetriever>(); // enables search_docs
svc.http = std::make_shared<MyHttpClient>(); // enables web_fetch/web_search
svc.exec = std::make_shared<MyExec>(); // enables bash, git_*, process_*
// leave svc.subagent null → no `task` tool is offered
auto provider = mcp::tools::make_provider(svc); // a CapabilityProviderThe tools keep no state of their own. What they remember between calls —
the workspace boundary, what each reader has already been shown, the last
snapshot of every file they read or wrote, background processes, repo
graphs — is a ToolState (state.hpp) the host owns and hands over as
svc.state, together with how it is shared (StateAccess::with). Leave it
null for a host that runs one call at a time. Per-call inputs ride on the
request: Request::reader (whose context a read serves) and
Request::cancelled. Running programs is always the host's (svc.exec),
and so is its sandbox. So is the disk: every file a tool reads, writes, lists
or stats goes through svc.files (files.hpp), and the tool library itself
does no IO. The library ships no disk implementation; the tests have one in
tests/support/disk_files.cpp that shows the expected behaviour (atomic
writes, symlinks followed, no walking through linked dirs).
Two pieces of metadata the bare wire has no field for ride in the result's
structured payload under a reserved key (meta.hpp): an EffectSet
(Read / Write / Net / Exec — drives permission prompts + the scheduler below)
and, for write/edit, a FileChange (before/after for a diff-review UI). A
host that cares decodes them; one that doesn't just sees the text.
A differentiator no other MCP client ships. When a model emits a batch of
tool calls in one turn — and frontier models do this constantly (three reads,
a grep, a glob, then an edit) — almost every agent runtime runs them
strictly sequentially, because the bare wire gives no machine-readable
interference model. mcp-cpp has one: every tool declares an EffectSet
(Read / Write / Net / Exec) and most fs tools name their target paths right
in the args. From those two facts the scheduler derives a provably-safe
concurrent execution plan with zero host annotation:
#include <mcp/cap/scheduler.hpp>
std::vector<cap::Request> batch = model_tool_calls(); // what the model asked for
auto results = cap::run(registry, batch, // 1:1 with batch, original order
mcp::tools::make_effect_fn()); // rich built-in effects- A wave of pairwise non-conflicting calls runs concurrently; waves run in submission order, so a write is never reordered before a read the model intended first. The result vector comes back 1:1 with the input — the agent loop is none the wiser anything ran in parallel.
- Conflict model: any
Execserialises against everything (a shell command's blast radius is unbounded); two pure reads never conflict; a write serialises only against calls whose paths overlap (prefix-aware, so writingsrc/orders against readingsrc/a.c); a write with no extractable path serialises against all fs peers. plan_waves()is a pure planner (no I/O, unit-tested in isolation);run()runs each wave through the caller'sSplitter(serial by default), behind the same one-call surface the host already uses. Works over any provider mix — a defaultEffectFnreads the standard MCPreadOnlyHint/openWorldHintannotations so it does something safe even for a third-party server that never heard ofEffectSet.
Four independent reads that each block 8 ms collapse from 32 ms serial to 8 ms concurrent — and a write that touches one of those paths still serialises exactly where it must.
| Header | Role |
|---|---|
mcp/core.hpp |
the algebra: Unit, Maybe, List, Sum, Newtype (from jsonrpc-cpp) |
mcp/codec.hpp |
Codec<T>, record, sum_tagged, variant_codec, … (from jsonrpc-cpp) |
mcp/ids.hpp |
RequestId/ProgressToken unions, Role, enums |
mcp/content.hpp |
content blocks, annotations, resource contents |
mcp/types.hpp |
implementation, capabilities, tool, resource, prompt, task |
mcp/elicit.hpp |
elicitation primitive/enum schemas |
mcp/methods.hpp |
every request/result param record + mcp::method::* |
mcp/protocol.hpp |
every method as a type (to_server::, to_client::), MCP error codes |
mcp/client.hpp |
ClientHandlers: what the client answers (sampling, roots, elicitation, notifications) |
mcp/server.hpp |
Server: what the server answers, with a tool/resource/prompt registry |
mcp/mrtr.hpp |
MRTR as pure steps (mrtr_retry, input_required fulfilment) |
mcp/server_stateless.hpp |
stateless server tools (IncomingRequest, RequestStateCodec, input_required, cache hints) |
mcp/auth.hpp |
OAuth 2.1 + PKCE client flow, RFC 9207 iss validation (2026-07-28 hardening) |
mcp/cap/cap.hpp |
capability layer umbrella (Registry, LocalProvider, …) |
mcp/cap/scheduler.hpp |
effect-aware parallel tool scheduling (see above) |
mcp/tools/toolset.hpp |
compiled batteries-included toolset (MCP_BUILD_TOOLS) |
mcp/tools/host.hpp |
HostServices IoC seam for host-coupled tool shells |
mcp/mcp.hpp |
umbrella include (core protocol) |
#include <mcp/mcp.hpp>
using namespace mcp;
int main() {
Server server(Implementation{"my-server", "1.0"});
Tool add; add.name = "add";
add.inputSchema.properties = Json{{"a",{{"type","integer"}}},{"b",{{"type","integer"}}}};
server.register_tool(std::move(add), [](const Json& args) {
CallToolResult r;
r.content = { text("sum = " + std::to_string(args.value("a",0L)+args.value("b",0L))) };
return r;
});
// The host loop: here, stdio on this thread.
Engine engine;
for (std::string line; std::getline(std::cin, line);) {
Effects fx = step(engine, Received{line});
for (auto& f : fx.send) std::cout << f << '\n';
for (auto& c : fx.calls) std::cout << server.handle(c) << '\n';
for (auto& n : fx.notifications) server.handle(n);
std::cout.flush();
}
}A client is an engine plus ClientHandlers for the server's callbacks.
request<M> gives a frame to send and an id; the matching Completed comes
back out of step() when the reply arrives. examples/client_example.cpp
waits for each one on a single thread; a real host (agentty) runs the engine
on its own reader and writer tasks.
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
ctest --test-dir build --output-on-failure
# end-to-end: client spawns the server over stdio
./build/examples/mcp_client_example ./build/examples/mcp_server_exampleThe core protocol + capability layer are header-only. The batteries-included
toolset is a separate compiled library (libmcp_tools, target mcp::tools),
built by default; turn it off with -DMCP_BUILD_TOOLS=OFF if you only want the
protocol + capability headers. Tests (MCP_BUILD_TESTS) and examples
(MCP_BUILD_EXAMPLES) are also on by default.
Requires a C++23 compiler (GCC 14+/Clang 17+). nlohmann/json and jsonrpc-cpp are fetched automatically by CMake when they aren't already targets.
std::expected-free, exception-thin decode.CodecErroris thrown only on shape mismatch and is caught at the RPC boundary, mapped toInvalidParams.- One codec per type, built once.
codec<T>()builds each once; nested codecs share cached nodes, so encode/decode are plain function-pointer calls. Newtype<Tag, std::string>gives nominal typing for opaque ids at zero runtime cost — you cannot pass aCursorwhere aTaskIdis expected.- No runtime. Deferring a slow reply, running calls in parallel, timeouts and cancellation are the host's: it owns the engine and the threads.
- MCP is one provider, not the center. The capability layer (
cap/) treats the wire protocol as a singleCapabilityProviderimplementation; a host programs againstRegistry, not against MCP. Swapping MCP for a local closure set or an HTTP service touches zero host code.
Apache-2.0.