Skip to content
agentty-orgPublic

About

Type-theoretic C++23 implementation of the Model Context Protocol (2025-11-25)

Resources

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

mcp-cpp

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

Why type-theoretic?

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.

Coverage (2025-11-25)

Every export interface / export type in the authoritative schema.ts is represented and round-trips. Concretely:

  • Base protocol — JSON-RPC 2.0 envelopes, initialize handshake + capability negotiation, ping, cancellation, progress, pagination, _meta.
  • Server features — tools (input/output schemas, annotations, execution task-support), resources + resource templates + subscribe/updated, prompts, completion (autocomplete), logging.
  • Client features — sampling (createMessage with tools/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 adds tool_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.

Stateless core (2026-07-28)

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 / clientCapabilities ride under reverse-DNS _meta keys on every request, replacing the initialize session. modern_request_meta() builds them for Engine::set_request_meta; IncomingRequest reads them back server-side with no session.
  • server/discover — the stateless replacement for the initialize handshake; Server answers 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_required result carrying an opaque, HMAC-signed requestState; 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() + RequestStateCodec
    • IncomingRequest build the server side. No sticky session anywhere.
  • Cacheable lists (SEP-2549) — tools/list / prompts/list / resources/list / resources/read carry ttlMs + cacheScope, emitted with a deterministic order so client + prompt caches stay stable across reconnects.
  • UnsupportedProtocolVersion / MissingRequiredClientCapability error codes for inline version negotiation. require_capabilities() enforces a needed client capability server-side (SEP), and routing_headers_match() validates the Mcp-Method/Mcp-Name header-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 9207 iss validation (AS-mix-up guard, enforced before code redemption), application_type=native DCR so loopback CLI redirects aren't rejected, issuer-bound access tokens (no cross-AS replay), CIMD client-id URLs, and RFC 9728 WWW-Authenticate challenge 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-based tasks/get, under the io.modelcontextprotocol/tasks extension 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 capability layer

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"}});

Batteries-included toolset

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 small std::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 CapabilityProvider

The 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.

Effect-aware parallel tool scheduling

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 Exec serialises 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 writing src/ orders against reading src/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's Splitter (serial by default), behind the same one-call surface the host already uses. Works over any provider mix — a default EffectFn reads the standard MCP readOnlyHint / openWorldHint annotations so it does something safe even for a third-party server that never heard of EffectSet.

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.

Layout

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)

Quick start — a server

#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();
    }
}

Quick start — a client

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.

Build

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_example

The 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.

Design notes

  • std::expected-free, exception-thin decode. CodecError is thrown only on shape mismatch and is caught at the RPC boundary, mapped to InvalidParams.
  • 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 a Cursor where a TaskId is 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 single CapabilityProvider implementation; a host programs against Registry, not against MCP. Swapping MCP for a local closure set or an HTTP service touches zero host code.

License

Apache-2.0.

About

Type-theoretic C++23 implementation of the Model Context Protocol (2025-11-25)

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages