Skip to content
poitaPublic

About

Model Context Protocol SDK for the D Programming Language

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

mcp.d

Latest release CI codecov License: Apache 2.0 API docs (main)

A feature-complete Model Context Protocol (MCP) SDK for the D programming language — client and server, built on vibe-d.

Contents

Quickstart

A server is a handful of annotated functions plus runStdio. Save it as server.d; the comment at the top makes it a single-file dub package (see Installation for the dependency line):

/+ dub.sdl:
    name "demo-server"
    dependency "mcp-d" repository="git+https://github.com/Poita/mcp.d.git" version="~main"
+/
module demo;

import mcp;
import mcp.transport : runStdio;

@tool("add", "Add two integers")
long add(long a, long b) @safe { return a + b; }

void main()
{
    auto server = McpServer.stateless("demo", "1.0.0");
    registerModule!demo(server);
    runStdio(server);
}

A client spawns that server over stdio, negotiates the protocol (any era — legacy or modern) with connect(), calls the tool, and checks the result. Wrap the work in runWithEventLoop — it drives vibe's event loop for you and hands back the scenario's value (see Concurrency model). Save it as client.d next to server.d:

/+ dub.sdl:
    name "demo-client"
    dependency "mcp-d" repository="git+https://github.com/Poita/mcp.d.git" version="~main"
+/
import mcp;
import std.stdio : writeln;

void main()
{
    auto result = runWithEventLoop(() @safe {
        auto client = McpClient.spawn(["./demo-server"]);
        scope (exit) client.close();
        client.connect();

        return client.callTool("add", parseJsonString(`{"a": 2, "b": 3}`));
    });
    assert(result.structuredContent["result"].get!long == 5);
    writeln("2 + 3 = ", result.structuredContent["result"].get!long);
}

Build the server, then run the client from the same directory (it spawns ./demo-server, relative to the working directory). To launch a server binary that ships next to the client executable regardless of the working directory, use McpClient.spawnSibling("demo-server") instead. The server inherits the client's environment, working directory and stderr; ClientSettings.process (a SpawnSettings) adds or replaces environment variables (env, replaceEnv), sets workingDir, and discards or captures the server's stderr (stderr = StderrMode.capture with an onStderrLine callback, POSIX only).

ulimit -n 65536                 # see Build & test
dub build --single server.d     # produces ./demo-server
dub run --single client.d       # prints "2 + 3 = 5"

Serving over HTTP

The same server speaks Streamable HTTP by swapping runStdio for runStreamableHttp, which blocks serving http://127.0.0.1:8080/mcp. In server.d, keep the dub.sdl header, module demo; (which registerModule!demo names), and the @tool functions, and replace the imports and main:

import mcp;
import mcp.transport : runStreamableHttp, StreamableHttpOptions;

void main()
{
    auto server = McpServer.stateless("demo", "1.0.0");
    registerModule!demo(server);

    StreamableHttpOptions opts;
    opts.port = 8080;               // binds 127.0.0.1 by default
    runStreamableHttp(server, opts);
}

A client reaches it with McpClient.http("http://127.0.0.1:8080/mcp") in place of McpClient.spawn; the rest of client.d is unchanged. To listen on a public interface, also set opts.bindAddresses and opts.allowedHosts (see Deploying). examples/hello is a complete server/client pair that does both transports from one binary.

Installation

This README documents the main branch. Tagged releases on code.dlang.org (see the latest release badge above) can lag well behind it, so to build against the API described here, depend on main through git in your dub.sdl:

dependency "mcp-d" repository="git+https://github.com/Poita/mcp.d.git" version="~main"

or in dub.json:

"dependencies": {
    "mcp-d": { "repository": "git+https://github.com/Poita/mcp.d.git", "version": "~main" }
}

dub caches a ~main checkout and does not refresh it. The first build clones main into ~/.dub/packages/mcp-d/~main, and later builds (and dub upgrade) keep using that copy, so newer commits on main never arrive on their own; dub.selections.json records only the branch name, not the commit. Two ways to control which main you build against:

  • Pin a commit (recommended). Replace ~main with a full commit hash, e.g. version="711d9fc447140f2d95992a6e57276d2a88c81a7c". Builds are reproducible, and moving to a newer main is an explicit edit of that hash.
  • Track main. Keep ~main and, when you want the latest commit, drop the cached copy with dub remove mcp-d (it removes every cached mcp-d version); the next dub build / dub upgrade clones main afresh.

A local checkout works too: dependency "mcp-d" path="../mcp.d".

To use the latest tagged release instead, run dub add mcp-d and read the README at that release's tag, since its API may differ from main.

Then import mcp; in your source files.

Names import mcp; brings into scope

Besides its PascalCase types, import mcp; exposes a number of short, lowercase names, mostly UDAs:

  • handler UDAs: tool, taskTool, prompt, resource, resourceTemplate, event, skill, skillDir;
  • metadata and hint UDAs: title, icon, ui, meta, audience, priority, lastModified, cacheable, readOnly, destructive, idempotent, openWorld;
  • schema facets from jsonschema: minimum, maximum, minLength, maxLength, pattern, minItems, maxItems, fieldDescription, schemaDefault (and format, re-exported as schemaFormat);
  • vibe's field attributes name, optional, and ignore;
  • core.time's dur, msecs, seconds, minutes, hours, and days, and Phobos's nullable.

Your own declarations of these names (a local name variable, a module-level title function) take precedence over the import and are unaffected. A clash arises only when another imported module exports the same name, which D reports as an ambiguity error at the use site. Resolve it by qualifying the use (mcp.api.attributes.title), importing that module selectively or under a renamed import, or narrowing the mcp import:

import mcp : McpServer, tool, registerModule;   // only what this module uses
import ui = mylib.ui;                           // or rename the other module

Goals

  • Full MCP support across every protocol version (2024-11-05 → 2026-07-28) with negotiation.
  • Both transports: stdio and Streamable HTTP.
  • FastMCP-style ergonomic server API via D attributes (@tool, @resource, @prompt).
  • Batteries included: OAuth 2.1, SSE resumability, all protocol utilities.
  • Validated against the official @modelcontextprotocol/conformance suite.

Status

Every scenario the official conformance harness requires passes, for both revisions the SDK claims, with no warnings. The harness (@modelcontextprotocol/conformance 0.2.0-alpha.11) scores each protocol revision against its frozen requirement set, run at that revision's wire:

Lane Server Client
--requirements 2025-11-25 (stateful, initialize handshake) 80 checks, 0 failed 329 checks, 0 failed, 0 warnings
--requirements 2026-07-28 (stateless, per-request _meta) 185 checks, 0 failed 490 checks, 0 failed, 0 warnings

Unscored scenarios the harness also runs: DPoP and the workload-identity JWT-bearer grant (not implemented), and the Tasks extension suite, where every functional check passes and the one remaining check is the harness's own wire-schema validator rejecting any CreateTaskResult against the base CallToolResult schema.

  • ✅ All server scenarios: lifecycle, tools with every content type, resources + templates + subscribe, prompts, completion, logging, progress/logging streaming, sampling, elicitation (incl. SEP-1034/1330), DNS-rebinding protection; on 2026-07-28 also the stateless architecture checks (per-request _meta, server/discover, version negotiation, header validation, subscriptions/listen), caching hints, JSON Schema 2020-12 preservation, and the full SEP-2322 input-required-result family.

  • ✅ All client scenarios, including the complete OAuth 2.1 suite — token-endpoint auth (none/basic/post + private_key_jwt ES256), metadata discovery (all variants + 2025-03-26 backcompat + endpoint fallback), scope selection/step-up/retry-limit, offline-access, DCR, pre-registration, resource-mismatch, cross-app access (token-exchange → JWT-bearer); elicitation with schema defaults; and SSE resumption (retry: + Last-Event-ID).

  • ✅ FastMCP-style UDA API — @tool / @resource / @prompt / @taskTool / @skill with auto JSON-Schema.

  • ✅ 2026-07-28 (modern) — stateless per-request _meta, server/discover, subscriptions/listen, CacheableResult (ttlMs/cacheScope), MRTR types, the standard request headers (Mcp-Method/Mcp-Name/MCP-Protocol-Version) with HeaderMismatch validation, and x-mcp-header mirroring — on both client and server. callTool transparently drives the full MRTR (SEP-2322) round-trip loop via an internal callToolLoop, satisfying each InputRequest and resubmitting until a completed result is returned (capped at 16 rounds to guard against misbehaving servers).

  • ✅ MCP Events extension (io.modelcontextprotocol/events, a draft extension on 2026-07-28+) — the @event UDA, events/list, and all three delivery modes (poll/push/webhook) with cursors, the emit ring buffer, poll-lease lifecycle hooks, and the full webhook-security surface (Standard Webhooks signing + rotation, SSRF hardening, endpoint verification, bounded retry, deliveryStatus, optional v1a, server identity). See MCP Events.

  • ✅ Client ID Metadata Documents (SEP-991) on both sides — the spec-recommended registration mechanism now that DCR is deprecated. The client advertises and uses an HTTPS-URL client_id when the AS supports it; the server-side OAuth proxy opts in via OAuthProxyConfig.clientIdMetadataDocumentSupported, advertising client_id_metadata_document_supported, then fetching (SSRF-guarded, size-capped, cached — failures included — with failed fetches rate limited per host) and validating the hosted document at /authorize — exact client_id match, required fields (client_id, client_name, redirect_uris), and a redirect-URI allowlist sourced from the document — with confused-deputy consent keyed on the stable client_id URL. The consent screen surfaces the client_name, the client_id host, the redirect-URI hostname and the requested scopes; consent is recorded per scope set, so a request for broader access re-prompts, and only scopes listed in OAuthProxyConfig.scopesSupported are forwarded upstream (none when it is empty). DCR remains as the deprecated fallback.

  • ✅ Interactive client login — useOAuth runs the full browser authorization-code flow with a loopback redirect listener, persists tokens, and refreshes them transparently. See Client authentication.

Optional follow-up (not required for conformance): a localhost-redirect impersonation warning on the proxy's CIMD consent screen (a spec SHOULD).

Requirements

  • A D toolchain with frontend 2.111+ (DMD 2.111+, or LDC 1.41+).
  • OpenSSL 3.x must be installed on the system. The openssl dependency, and the vibe-d:tls package that vibe-d:http pulls in, link against it for TLS (HTTPS transport, OAuth 2.1).
    • Ubuntu/Debian: ships with OpenSSL 3.x (apt install libssl-dev if headers are missing).
    • macOS: brew install openssl@3, then export PKG_CONFIG_PATH="$(brew --prefix openssl@3)/lib/pkgconfig" so dub can find it.
    • Windows: install OpenSSL 3.x (e.g. choco install openssl) and ensure its bin directory is on PATH so the runtime DLLs are found.

Platform support

Linux, macOS, and Windows are all supported and exercised in CI (Linux with DMD and LDC, macOS and Windows with LDC). The stdio transport, the OS CSPRNG, and the OAuth token store each have native Windows code paths; on Windows the token store tightens file permissions via ACLs rather than POSIX modes.

On Windows, build executables that depend on mcp.d with dub build --combined (likewise dub run --combined). LDC on Windows can leave D template instantiations split across static-library objects unresolved at the final link; --combined compiles all dependency sources in one pass and avoids it.

Build & test

ulimit -n 65536        # required: dub misbehaves under ghostty's `ulimit -n unlimited`
dub build              # build the library
dub test               # run all unit tests

Formatting and linting (the same gates CI runs; just fmt / just lint wrap them):

dub run [email protected] -- --inplace source/ conformance/ examples/ bench/ deploy/ci-smoke/
./scripts/dscanner-lint.sh     # D-Scanner with the project config and documented filters

Deploying

To package an mcp.d server into a container and run it on a PaaS, see deploy/ — a reference multi-stage Dockerfile plus the system dependencies, toolchain pinning, and the server-side settings (binding 0.0.0.0:$PORT, allow-listing the public Host) a deployment needs.

API documentation

Browsable HTML API docs are generated from the ddoc comments in source/mcp:

scripts/gen-docs.sh        # auto: adrdox if available, else ddox -> docs/

The script prefers adrdox (the best D documentation generator) and falls back to dub's built-in ddox build when adrdox is not on PATH:

GENERATOR=adrdox scripts/gen-docs.sh   # require adrdox
GENERATOR=ddox   scripts/gen-docs.sh   # force the ddox fallback (dub build -b ddox)
OUTDIR=site      scripts/gen-docs.sh   # write to ./site instead of ./docs

Open docs/index.html in a browser when it finishes. The generated docs/ directory is a build artifact and is git-ignored.

CI builds the docs on every push/PR (.github/workflows/docs.yml) so doc generation can never silently break, and publishes them to GitHub Pages on every push to main (or a manual workflow_dispatch), so the published reference documents the same main API as this README (best-effort: the publish step is skipped if Pages is not enabled for the repository). For a tagged release, generate the docs locally from that tag with scripts/gen-docs.sh.

Statefulness

A server chooses one of two statefulness models at construction. Stateless is the default. The author picks the mode via the McpServer.stateless and McpServer.stateful factories (or ServerSettings.mode); there is no bare constructor.

auto s1 = McpServer.stateless("my-server", "1.0.0"); // the default mode
auto s2 = McpServer.stateful("my-server", "1.0.0");   // opt-in session management

The core invariant: a stateless server has NO shared state across HTTP calls. McpServer holds no mutable per-connection state; per-connection state lives in a ConnectionState object (mcp.server.connection) — protocol version, client capabilities, log level, resource subscriptions, and the in-flight cancellation registry. In stateful mode the SDK keys everything on Mcp-Session-Id: there is exactly one ConnectionState per session, owned by the transport's SessionManager. In stateless mode the transport builds a transient ConnectionState per request and discards it, so two concurrent peers sharing one McpServer cannot leak version, capability, subscription, or cancellation state into one another.

Because a stateless server keeps nothing across HTTP calls, anything that has to correlate a request with a separate later HTTP call is forbidden over HTTP in stateless mode and errors rather than silently dropping: server-initiated elicit/sample/roots (a server->client request whose reply arrives on a different POST), resources/subscribe/resources/unsubscribe (whose updates would be delivered on the separate standalone GET stream), and the standalone GET SSE stream itself. Each would have to ride mount-global state (the StreamCoordinator / GET-push channel / per-session subscription set), which is exactly the shared state a stateless server must not keep. The gating depends only on server.mode (ServerMode.stateless), not on the negotiated protocol version.

A self-contained long-lived stream is fine, because it never correlates a second HTTP call: the modern subscriptions/listen works in stateless mode. Its POST opens an SSE response and the server streams notifications/resources/updated / list_changed down that same response, filtered by the stream's own subscription set — exactly like a tool call emitting progress on its own SSE stream. (Whether a mutation originating on another node reaches the stream is the deployment's out-of-band concern, not the SDK's.) The Events extension's events/poll (per request) and events/stream (a self-contained push response) work in stateless mode for the same reason; webhook subscription state is held mount-globally by the EventsRuntime — the same model that lets the task store work under ServerMode.stateless — and is keyed on the authenticated principal. Webhook delivery is decoupled from publish via a pluggable DeliveryQueue (EventsOptions.deliveryQueue): publish enqueues a job per matching subscription, and the periodic worker enableEvents starts (every EventsOptions.workerInterval) leases/delivers/acks — the same pass also expires poll leases, sweeps lapsed webhook subscriptions, and starts poll-driven webhook delivery for fetch-handler types (each subscription's fetch in its own task, at most EventsOptions.webhookMaxConcurrentFetches at once, so a slow upstream never holds up the pass). McpServer.shutdown() stops this worker and the Tasks TTL sweeper. The in-memory default is single-node; injecting a shared, durable queue (Redis/SQS/DB) plus a WebhookSubscriptionStore makes webhook delivery node-agnostic (any node's worker delivers; a crashed node's leased job is re-leased) — mirroring the TaskStore/TaskDispatcher split. Like TaskStore, a shared WebhookSubscriptionStore implements compareAndSwap on the record's revision as one atomic conditional write: every update to a stored subscription (watermark, verification, delivery health, refresh) is a read-modify-write retried on a lost race, so nodes never overwrite each other. Removal goes through compareAndRemove on the same revision, so a node never deletes a subscription that was refreshed or re-created after it read it. Creating a subscription goes through insert, a create-if-absent write: when two nodes create the same one at once, the second refreshes the first's record.

Guidance: if your tools initiate elicitation/sampling/roots, or use the 2025-era resources/subscribe push over HTTP, construct the server with McpServer.stateful(). Stateless is correct for plain request/response tools, resources, prompts, progress, the modern subscriptions/listen stream, and the modern MRTR (more-requests-then-respond) input flow.

stdio note: stdio is a single implicit connection for the life of the process (it negotiates protocol 2025-11-25 by default). Statefulness (server.mode), not the transport, governs server->client requests (elicit/sample/roots) and logging/setLevel: the same mode-based gating applies over stdio and HTTP alike. A stateless server has no elicit/sample/roots and no logging/setLevel on any transport; use McpServer.stateful() for those features, or MRTR on the modern protocol.

The three effective modes

Resolution of per-connection state Notes
Modern stateless (stateless + request >= 2026-07-28) Per-request _meta (protocolVersion + clientCapabilities + logLevel) No initialize (uses server/discover); input via MRTR; subscriptions/listen is supported (a self-contained stream); no blocking server->client elicitation/sampling on any transport (see the feature-gating matrix)
Legacy stateless (stateless + request < 2026-07-28) MCP-Protocol-Version header (default 2025-03-26; stdio assumes 2025-11-25); client capabilities unknown (assumed none) initialize/notifications/initialized are no-ops (no session id minted); a tools/call may be the first request with no prior initialize; correlation features are forbidden
Stateful (opt-in, legacy revisions only) ConnectionState resolved by Mcp-Session-Id, created at initialize 2026-07-28 is excluded from negotiation (it has no initialize; a request for it is clamped down to <= 2025-11-25); server/discover is not served; DELETE terminates the session

Feature-gating matrix

The gating is keyed on server.mode, not the protocol version, so the two stateless eras (modern and legacy) forbid the same correlation features regardless of transport — they differ only in how each request's ConnectionState is resolved.

Feature Modern stateless Legacy stateless Stateful
initialize handshake n/a (server/discover) no-op (no session id) mints Mcp-Session-Id
Per-request _meta version/caps yes n/a (header + empty caps) n/a (session-negotiated)
Standalone GET SSE stream forbidden (405) forbidden (405) yes
resources/subscribe / unsubscribe forbidden (-32601) forbidden (-32601) yes
subscriptions/listen (2026-07-28) yes (self-contained stream) n/a (modern-only) no (2026-07-28 refused with -32022)
Server->client elicit/sample/roots forbidden (error; MRTR instead) forbidden (error) yes
logging/setLevel n/a (per-request _meta) forbidden (-32601); the logging capability stays advertised because ctx.log still reaches the request's own response stream at the default info level yes (session-scoped)
listChanged in capabilities yes (via subscriptions/listen) never advertised (no channel to notify on) yes
Session id minted never never yes

The subscribe capability advertisement follows the same rule: a stateless server does not advertise the resources subscribe capability on a 2025-era session. On 2026-07-28 its server/discover advertises resources.subscribe whenever it advertises resources, meaning subscriptions/listen accepts resourceSubscriptions (a server without resources acknowledges no resourceSubscriptions). Calling enableResourceSubscriptions() on a stateless server throws rather than silently doing nothing — it names McpServer.stateful() as the remedy — so the mistake surfaces at construction instead of as a missing capability at runtime. The server->client (elicit/sample/roots) gating is transport-agnostic — stdio follows the same server.mode rules as HTTP.

The Streamable HTTP transport derives session minting purely from server.mode (ServerMode.stateful => mint and require Mcp-Session-Id; ServerMode.stateless => never). There is no separate enableSessions option.

Implementing a custom server transport

The server side has a named transport seam symmetric to the client's ClientTransport: the ServerCore interface in mcp.server.transport, reachable from import mcp.transport;. McpServer implements it, so a transport can hold its server through the interface and drive it without depending on the concrete class.

Two directions make up the contract:

  • Inbound (peer -> server) is the handle / handleRaw family. The minimal recipe is to parse the peer's bytes into a single string and call handleRaw, writing the returned string back (empty string => nothing to send, e.g. a notification):

    import mcp.transport; // ServerCore + the wire transports
    
    void pump(ServerCore core, string requestText, void delegate(string) @safe reply)
    {
        const responseText = core.handleRaw(requestText);
        if (responseText.length)
            reply(responseText);
    }

    For a transport that can also push out-of-band frames on the same channel (the way stdio does), use handleRaw(text, sink) so a handler's ctx.log / ctx.reportProgress reach the peer while the request is in flight, and handleRaw(text, sink, serverRequest) to additionally carry the blocking server->client request channel behind ctx.sample / ctx.elicit.

  • Outbound (server -> peer) is the RequestContext interface (mcp.server.context), the named companion the transport implements. Supply a concrete RequestContext to handle (or via the sink / serverRequest overloads of handleRaw); the server calls back into it to emit notifications and issue server->client requests. A transport that multiplexes many sessions over one server also implements ConnectionScoped on its RequestContext so the core scopes per-connection state (the cancellation registry) per session, and threads each request's own ConnectionState via handleRaw(text, conn, token, auth).

Connection / session ownership is in-package only. The fallback ConnectionState hook the out-of-request notify/push path uses (McpServer.bindConnection) and the SessionManager that owns per-session state are package(mcp)-private — the in-tree Streamable HTTP transport relies on them. This is a deliberate pre-1.0 choice: the seam stays narrow rather than exposing connection internals that would be hard to evolve. An out-of-package transport can fully drive request/response and per-request state through ServerCore + RequestContext (building its own ConnectionState and passing it to handleRaw(text, conn, token, auth)), but it cannot own the fallback connection the out-of-request notify/push path reads. Transports that need that ownership belong in mcp.transport.*.

Client response cache

McpClient caches the six read-only operations 2026-07-28 marks CacheableResult — listTools, listResources, listResourceTemplates, listPrompts, readResource, and discover — so a repeat call within the server's freshness window is served locally with no round-trip. callTool and getPrompt are never cached (the spec excludes them).

On by default, byte-identical when idle. A client built via McpClient.http/stdio/spawn ships an in-memory store. Caching engages only when a result carries a positive ttlMs hint, so against legacy servers (or a modern server sending ttlMs:0) behaviour matches the uncached client exactly. The stored entry's lifetime is the server's ttlMs; its cacheScope (public/private) is recorded for shared backends. A paginated list is cached as one entry under the shortest ttlMs of its pages, and as private if any page is.

auto c = McpClient.http("https://server.example/mcp");
c.connect();
c.listTools();   // round-trips, then caches per the server's ttlMs
c.listTools();   // served from cache — no request

Configure via ClientSettings (the per-client knobs you'd otherwise pass loose):

ClientSettings s;
s.cache = noCache;            // disable caching entirely
s.cache = new MyRedisStore;   // or bring your own CacheStore (shared/persistent)
s.defaultCacheTtl = 30.seconds; // cache even responses the server left unhinted
auto c = McpClient.http(url, s);

A CacheStore is a small get/put/invalidate/invalidatePartition/clear interface; supply your own to pre-seed entries and skip round-trips, or share one across clients. The default InMemoryCacheStore is per-client and bounded by a least-recently-used size cap (256 entries by default; new InMemoryCacheStore(n) sets another, 0 none). Expired entries are not swept on their own; one nobody reads again is reclaimed by that cap. client.setCache, setDefaultCacheTtl, and clearCache adjust this at runtime; cache() exposes the live store for pre-seeding.

public vs private scope (shared caches). The server's cacheScope controls where an entry is stored, which only matters when several clients share one backend. A public result lives under a shared key, so every client hits the same entry — the point of a shared cache. A private result is namespaced under the requesting client's cachePartition (a stable principal / tenant id you set in ClientSettings), so it is never served to another identity. A cacheScope the client does not recognize is treated as private. A client with no cachePartition keeps its private results under a partition unique to itself. Every entry is also keyed by server (CacheKey.server: the endpoint URL, the spawn command, or ClientSettings.cacheServer), so clients of different servers sharing one store never read each other's results.

auto store = new MyRedisStore;
ClientSettings sa; sa.cache = store; sa.cachePartition = "tenant-a";
ClientSettings sb; sb.cache = store; sb.cachePartition = "tenant-b";
// a public listTools fetched by tenant-a is served to tenant-b with no round-trip;
// a private readResource stays isolated to its tenant.

On setBearerToken, the client evicts only its own partition (the previous identity's private entries) from a store you supplied, sparing shared public entries and other principals' partitions; the default per-client store also drops its public entries, so it behaves as a full clear.

Per-call modes via RequestOptions.cacheMode:

Mode Behaviour
use (default) serve a fresh entry, else fetch and store
bypass ignore the cache for read and write — force the network, store nothing
refresh skip the read, force the network, then store the fresh result
RequestOptions o; o.cacheMode = CacheMode.refresh;
c.listResources(o); // re-fetch and update the cached entry

Automatic invalidation. The server's change notifications evict the affected entries so the next call lazily refetches: notifications/tools/list_changed, prompts/list_changed, and resources/list_changed drop the matching list cache(s), and resources/updated drops just that URI's readResource entry. Each also fires a typed callback (onToolsListChanged, onPromptsListChanged, onResourcesListChanged, onResourceUpdated(uri)) in addition to the generic onNotification. setBearerToken evicts the client's own partition so a re-authenticated session never reads the previous identity's private results.

TLS

Every outbound https connection the SDK makes (the HTTP client transport, OAuth discovery/token/JWKS/introspection fetches, webhook delivery) requires a server certificate that chains to a trusted CA and matches the host name. The system CA bundle is used by default (SSL_CERT_FILE overrides it; on platforms without a PEM bundle, such as Windows, set it or caFile). For the client transport, ClientSettings.tls trusts a private CA or a local server's self-signed certificate, or — for local development only — disables verification:

ClientSettings s;
s.tls.caFile = "dev-ca.pem";        // trust this CA in place of the system store
// s.tls.insecureSkipVerify = true; // development only: no certificate checks
auto c = McpClient.http("https://localhost:8443/mcp", s);

Client authentication (OAuth login)

useOAuth(client, endpoint, opts) (from import mcp.auth;) logs an HTTP client in to an OAuth-protected MCP server interactively and attaches the result as the client's bearer provider. Call it before connect():

import mcp;
import mcp.auth : useOAuth, OAuthLogin;

runWithEventLoop(() @safe {
    enum endpoint = "https://mcp.example.com/mcp";
    auto client = McpClient.http(endpoint);
    scope (exit) client.close();

    OAuthLogin login;
    login.scopes = ["mcp:read"];
    useOAuth(client, endpoint, login);

    client.connect();
    auto tools = client.listTools();
});

It discovers the protected-resource and authorization-server metadata, then:

  • Picks the scopes: OAuthLogin.scopes when set, otherwise the scope of the 401 challenge passed as OAuthLogin.wwwAuthenticate, otherwise the protected-resource metadata's scopes_supported.

  • Reuses a stored token for the endpoint when one is still valid, or redeems its refresh token, without opening a browser — unless it lacks one of the scopes named by OAuthLogin.scopes or the challenge (a step-up after insufficient_scope), which needs a new authorization. A token granted only part of the fallback scopes_supported is still reused.

  • Otherwise registers the client — a pre-registered OAuthLogin.clientId, a Client ID Metadata Document (clientIdMetadataUrl, when the AS supports it), or Dynamic Client Registration — and runs authorization-code + PKCE in the system browser. The redirect is captured on a loopback listener at http://127.0.0.1:<callbackPort><callbackPath> (an ephemeral port by default); state and RFC 9207 iss are verified, and the wait is bounded by callbackTimeout (5 minutes by default).

  • Persists tokens through OAuthLogin.store. The default is a FileTokenStore at $XDG_CONFIG_HOME/dlang-mcp/tokens.json (or ~/.config/dlang-mcp/tokens.json; %APPDATA%\dlang-mcp\tokens.json, else %LOCALAPPDATA%, on Windows), written owner-only. A variable holding a relative path is ignored, and with no per-user directory useOAuth throws rather than writing tokens to the working directory. Subclass it and override serialize/deserialize to encrypt at rest, or supply MemoryTokenStore or your own TokenStore.

  • Refreshes transparently: every request asks the returned OAuthSession for its bearer, which runs the refresh-token grant when the access token is within 30 seconds of expiry and saves the new token. Concurrent refreshes are single-flighted, and a session first re-reads the store, so processes sharing one token file adopt each other's refreshed tokens instead of replaying a rotated refresh token. A refresh the AS answers with invalid_grant drops the refresh token, so later requests fail fast until useOAuth signs in again. A request the server rejects with 401 invalid_token is refreshed and retried once; with no refresh token, or when the refreshed token is rejected too within a minute of that refresh, the 401 (an HttpStatusException carrying the WWW-Authenticate challenge) reaches the caller, who can pass the challenge to useOAuth as OAuthLogin.wwwAuthenticate to sign in again.

OAuthLogin.openBrowser replaces the platform browser launcher (for example, to print the URL instead). For non-interactive flows (client credentials, token exchange) use the lower-level OAuthClient and setBearerToken.

Examples

The repository ships sixteen runnable, self-verifying server/client pairs in examples/ (plus examples/common, a small shared helper package). examples/hello is the standalone one to copy into a new project; the others use the repo-only examples/common scaffold, and examples/README.md lists the SDK calls that replace it. Each client.d is an end-to-end test that asserts the matching server's behaviour, and CI runs every pair over both stdio and Streamable HTTP, except Auth, which is HTTP-only.

Example What it shows Server Client
Hello minimal standalone server + client (no shared scaffold) server client
Tools @tool handlers with typed args/results server client
Prompts @prompt templates server client
Resources resources + templates + subscriptions/listen push server client
Caching modern CacheableResult hints (ttlMs/cacheScope) server client
Modern the 2026-07-28 protocol end to end (server/discover, per-request _meta, connect()) server client
Streaming progress notifications from a long-running tool server client
MRTR multi-round-trip tool input (carried in the result) server client
Tasks MCP Tasks extension (SEP-2663): async @taskTool tools with progress, cancellation, and input_required server client
Sampling server-initiated LLM sampling (ctx.sample) server client
Elicitation server-initiated, typed user input (ctx.elicit!T) server client
Sticky notes stateful tools + a resource per note + elicitation-confirmed clear server client
Auth OAuth 2.1 protected HTTP resource server (HTTP only) server client
Apps MCP Apps extension: @ui tool link + a ui:// HTML resource server client
Skills MCP Skills extension: @skill / @skillDir / dynamic Agent Skills served as resources, skills/list / skills/get discovery with digest + size manifests server client
Events MCP Events extension: @event types delivered over poll, push, and server-signed webhook (to a client WebhookReceiver) server client

Annotate plain typed D functions with @tool / @resource / @prompt and register a whole module with registerModule!(my.module)(server) — the input schema (from the parameter types) and output schema (from the return type) are derived at compile time, and arguments/results are marshalled for you. A handler may take a trailing RequestContext parameter to report progress, log, or call back to the client (sampling/elicitation). Registration is all or nothing: when one handler fails to register (a name already taken, a missing @skillDir directory), the handlers that same call already registered are removed again before the exception propagates. For tools whose schema is only known at runtime, drop to server.registerTool(Tool, delegate) / registerResource / registerPrompt, which receive the raw Json; argsAs!T(arguments) binds that Json into a typed struct with the same rules the UDA layer uses, throwing a ToolError (an isError result) for a malformed argument.

A tool reports a failure the model should see by throwing ToolError (or returning CallToolResult.error(msg)): the client receives a result with isError: true and that message as its text. Any other exception a tool handler throws is logged server-side and reaches the client as an isError result whose text is just Internal error, since its message may carry file paths, SQL, or other internals; server.exposeInternalErrors() (or ServerSettings.exposeInternalErrors) sends the real message instead, which is useful in development. An McpException becomes a JSON-RPC error.

A prompt has no isError result, so its failures are JSON-RPC errors: throwing PromptError from a prompt handler sends an Internal error (-32603) carrying that message, while any other non-McpException is logged and masked as Internal error in the same way (throw an McpException such as invalidParams for a different code).

@tool("weather", "Current weather for a city")
string weather(string city) @safe
{
    if (city !in forecasts)
        throw new ToolError("unknown city: " ~ city);
    return forecasts[city];
}

D type → JSON Schema mapping

The compile-time schema generator maps D types as follows (so the emitted schema is predictable when porting a hand-built server):

D type JSON Schema
bool {"type": "boolean"}
int / long / short / byte (and uint/ulong/… ) {"type": "integer"}; byte/ubyte/short/ushort/uint also get their type's minimum and maximum, and ulong gets "minimum": 0; an argument also binds from a whole JSON number such as 2.0
float / double {"type": "number"}; in an output schema {"type": ["number", "null"]}, since vibe writes a NaN (an unset value's .init) as null
string {"type": "string"}
enum {"type": "string", "enum": [members…]}
T[] {"type": "array", "items": <T>}
V[string] {"type": "object", "additionalProperties": <V>}
struct {"type": "object", "properties": …, "required": […]}, keyed by the serialized field name (vibe's @name, else the field name minus one trailing _); a Nullable, vibe-@optional, or defaulted field (an initializer differing from the type's .init, which an input schema also advertises as its "default") is not required, and @allOptional on the struct makes every field optional
Json {} (any JSON value)
std.datetime SysTime / Date {"type": "string", "format": "date-time"/"date"}; a SysTime argument must carry a UTC offset (Z or +hh:mm), as RFC 3339 requires
std.uuid UUID {"type": "string", "format": "uuid"}, read and written as the hyphenated string form
std.datetime DateTime / TimeOfDay {"type": "string", "pattern": …} matching YYYY-MM-DDTHH:MM:SS / HH:MM:SS (no UTC offset, so not the RFC 3339 date-time/time formats)
SumType!(A, B, …) {"anyOf": [<A>, <B>, …]}; an argument binds to the member its JSON type natively matches (an integer to int over double), else the first member that accepts it; a result is written as the value it holds
Nullable!T (tool parameter / input field) <T> widened to admit null ("type": [<T's type>, "null"], plus null in an enum), made optional by omission from required; an explicit null binds as unset
Nullable!T (elicitation field) <T>, made optional by omission from required
Nullable!T (output schema) {"anyOf": [<T>, {"type": "null"}]}

A parameter, field, or return type with no JSON form — a class, pointer, delegate, char, std.typecons.Tuple, an associative array keyed by a non-string, a struct whose fields are all private or @ignored, a struct with two fields serialized under one name (limit and limit_, or a clashing @name), a core.time.Duration (take a long count of milliseconds or seconds instead), or a real parameter (use double) — is rejected at compile time with a message naming the handler and parameter, as is a @schemaDefault whose value is not exactly a value of the parameter's type: it must convert implicitly, an integer must be within the parameter's range (@schemaDefault(300) ubyte is rejected), and an enum parameter's default must be a member of that enum (@schemaDefault(Mode.dark), not "dark" or 1).

Integer types map to "integer" (not "number") deliberately — it is the more precise constraint; use double for a field that should accept fractional values. An optional tool parameter is left out of required; declare it Nullable!T (whose schema also admits an explicit null, as LLM clients often send one) or give it a D default value (int page = 1), which is also emitted as the property's "default". A @schemaDefault(v) parameter is optional too: v is advertised as its "default" and is what an omitted argument binds to, over any D default (likewise for a struct field). A parameter whose struct type is @allOptional is optional as well, and an omitted one binds as the struct's .init, as an empty object would. Only a Nullable parameter's schema admits null: input validation rejects an explicit null for a parameter that merely has a default. When clients may send null, declare the parameter Nullable!T and apply the fallback in the handler (page.get(1)), since an explicit null binds as unset rather than taking a D default. A Nullable parameter mirrored into a header with @mcpHeader keeps the bare primitive type x-mcp-header requires. A parameter is named on the wire like a struct field, minus one trailing _, so string version_ is the version argument (and @describeParam / @mcpHeader name it "version"). Field/parameter constraints are added with UDAs: @minimum / @maximum, @minLength / @maxLength, @pattern, @minItems / @maxItems, @schemaFormat (JSON Schema format), @title, @schemaDefault, and @fieldDescription. A constraint that cannot apply to its type (@minLength on an int, @minimum on a string, @minItems on a scalar) is a compile error, as is a method-level UDA on a handler kind that does not read it (such as @cacheable on a @tool, @readOnly on a @prompt, or @taskTtl on a plain @tool). A prompt argument has no JSON Schema, so on a @prompt parameter only @fieldDescription and @title (its description and title) and @schemaDefault apply, and a URI template variable takes no constraint UDA at all; any other placement is a compile error. An argument a tool does not declare is ignored; mark the method @strictArgs to advertise "additionalProperties": false and reject one.

A struct field initialized to its type's own .init stays required, unlike a parameter: D cannot tell int offset = 0; or bool verbose = false; from a field with no initializer, so both are listed in required. To make such a field optional, give it @schemaDefault(0) (which also advertises the default), mark it vibe's @optional, declare it Nullable, or put @allOptional on the struct. import mcp; re-exports vibe's @optional, @name (wire name) and @ignore (left out) field attributes. They apply to struct fields only: on a handler parameter they are a compile error, since a parameter is named by its identifier and made optional by Nullable, a default value, or @schemaDefault:

struct Page
{
    @schemaDefault(0) int offset;   // optional, defaults to 0
    int limit = 10;                 // optional: differs from int.init
    @optional bool verbose;         // optional, keeps false when omitted
}

MCP Apps (interactive UI)

The MCP Apps extension (io.modelcontextprotocol/ui) lets a server ship an interactive HTML UI that a host renders inline in the conversation. On the server side it is metadata plus a resource convention, and import mcp; brings in the helpers (mcp.api.apps):

auto server = McpServer.stateless("weather", "1.0.0");
registerModule!(my.module)(server);     // a @tool tagged @ui("ui://weather/dashboard", "model", "app")

UiResource ui = {uri: "ui://weather/dashboard", name: "weather_dashboard", html: dashboardHtml};
ui.meta.csp.connectDomains = ["https://api.open-meteo.com"];
ui.meta.prefersBorder = nullable(true);
registerUiResource(server, ui);         // serve the ui:// HTML with text/html;profile=mcp-app

Set UiResource.htmlProvider instead of html to generate the document on each read.

A @tool carries its UI link via @ui(resourceUri, visibility…) (folded into the tool's _meta.ui); the dynamic path uses server.setUiToolMeta(tool, UiToolMeta(...)). Registering a @ui tool or a UI resource, or calling setUiToolMeta, advertises the extension; call server.enableApps(mimeTypes) only to declare content types in addition to text/html;profile=mcp-app, which is always advertised (before or after registering). clientSupportsApps(ctx) reports whether the calling client opted into the extension. The runnable Apps example verifies the whole surface over both transports.

The extension's ui/ postMessage dialect (iframe ↔ host) and sandbox rendering are a host (browser) concern and intentionally out of scope for this transport-level SDK — when the embedded app calls a tool, the host proxies it to the server as an ordinary tools/call, so the server implements no ui/ methods.

MCP Tasks (asynchronous execution)

The MCP Tasks extension (io.modelcontextprotocol/tasks, SEP-2663) lets a server answer a long-running tools/call with a durable task handle instead of blocking — the client polls tasks/get until it completes, and may tasks/update (mid-flight input) or tasks/cancel. Mark a function @taskTool and it becomes one of these tools: the call returns a handle at once, the body runs asynchronously, and its return value becomes the result; the injected TaskContext reports progress, observes cancellation, and elicits input mid-task.

auto rt = server.enableTasks();   // keep the runtime; set TaskOptions.store for durability

struct Approval { bool deploy; }

@taskTool("deploy", "Deploy a build, confirming first; finishes when the deploy signals back.")
@taskTtl(10.minutes) @taskPollInterval(2.seconds)
string deploy(string gitRef, TaskContext tc) @safe
{
    if (!tc.hasInput("ok"))
        return tc.requireInput([elicitationRequest!Approval("ok", "Deploy " ~ gitRef ~ "?")]);
    if (!tc.inputAs!ElicitResult("ok").contentAs!Approval().deploy)
        return "skipped";
    startDeploy(gitRef, tc.taskId);             // fictional: kicks off the deploy, returns at once
    return tc.detach("deploying " ~ gitRef);    // leave it working; the webhook below completes it
}

registerModule!deployments(server);   // the module declaring `deploy`

// The deploy system's callback — runs on any node, holds no fiber:
void onDeployFinished(string taskId, bool ok) @safe
{
    if (ok)
        rt.complete(taskId, CallToolResult([Content.makeText("deployed")]).toJson());
    else
        rt.fail(taskId, internalError("deploy failed"));
}

Registering a task tool enables the extension with default TaskOptions when enableTasks() has not been called; calling enableTasks(opts) afterwards replaces those options and keeps the registered task tools. A stateful server never speaks 2026-07-28, so registering a task tool on one throws.

The three exits cover the lifecycle: return a value completes the task, tc.requireInput(...) suspends it for a client answer (delivered via tasks/update), and tc.detach(...) leaves it working for onDeployFinished to complete out of band via rt.complete / rt.fail — no fiber held, so it works on any node. See examples/tasks for cancellation, durable stores, and the client side.

A task's TTL (@taskTtl, else TaskOptions.defaultTtl, 10 minutes) is how long its record is kept after it settles, so the client has that long to collect the result (unlimitedTaskTtl keeps it forever). A task that is still working or awaiting input does not expire by TTL; instead TaskOptions.maxUnsettledIdle (24 hours; zero disables it) fails one whose status has not changed for that long — so a detached task whose callback never arrives is failed then, unless you rt.fail or cancel it first. A long-running executor keeps its task alive by reporting progress (tc.progress).

On the client, callToolAwait(name, args, opts, onInputRequired) hides the whole flow — it drives the poll loop and returns the final CallToolResult, so task and non-task tools look identical. Like callTool and every other request verb, it takes its RequestOptions (progress, log level, cancellation) after the call's own arguments. When you need to survive a restart, call plain callTool instead: if the server made a task the result is the handle (result.isTask, with the seed Task in result.task). Persist result.task.taskId, then resume any time — even in a fresh process — with awaitTask(taskId, opts, onInputRequired), which polls to completion (cancellable through opts.cancellation, and surfacing mid-task input requests to the optional callback).

The client must declare the extension before connecting; otherwise the server runs an optional task tool inline (or rejects a required one with -32021):

client.enableTasks();                        // advertise io.modelcontextprotocol/tasks
client.connect();

auto r = client.callTool("deploy", args);   // sync or task — you needn't know
if (r.isTask)
{
    store.save(r.task.taskId);               // durable handle; survives a restart
    auto done = client.awaitTask(r.task.taskId);
}
else
    use(r);                                  // synchronous tool, nothing to resume

Not supported: the experimental 2025-11-25 tasks. The tasks feature that shipped in the 2025-11-25 core specification (a top-level tasks capability, tasks/list, tasks/result, the per-tool execution.taskSupport field, and the per-request task parameter) was a stopgap the spec has since replaced with this extension. It is intentionally not implemented — those methods answer -32601 and no tasks capability is advertised. Only the SEP-2663 extension above is supported, and only on 2026-07-28 and later.

MCP Events (triggers)

The MCP Events extension (io.modelcontextprotocol/events, an extension still in draft) lets a client subscribe to things happening upstream — a Slack message, a GitHub push, a PagerDuty incident — and have the agent react when they occur. A server declares event types; a client subscribes with (name, arguments) and receives EventOccurrence records over one of three delivery modes, advertised per type: poll (events/poll), push (events/stream), and webhook (events/subscribe, signed per Standard Webhooks). Event types are strongly typed — a subscription-argument type A (the filter, → inputSchema) and a payload type P (→ payloadSchema):

struct IncidentArgs { string severity; }              // filter → inputSchema
struct Incident     { string id; string severity; }   // payload → payloadSchema

auto rt = server.enableEvents();

// A push source: define the typed type and publish from wherever events arrive.
auto incidents = rt.define!(IncidentArgs, Incident)("incident.created",
        "Fires when a new incident is raised")
    .match((IncidentArgs a, Incident i) @safe => a.severity.length == 0 || i.severity == a.severity);

// ... when an incident occurs (an upstream webhook handler, a domain event):
incidents.publish(Incident("INC-1", "P1"));   // fans out to streams/poll + the webhook queue

publish(P) is the single push verb; its reach is the scope of the injected registries (stream/poll are always node-local; webhook is as wide as the WebhookSubscriptionStore). Poll sees published events only on a type without a fetch handler: once onFetch is set, poll reads from the handler, so publish reaches live streams and webhook subscribers but never a poller. A type's match and transform shape every event a subscription receives, published or fetched, on every delivery mode. The handle also carries typed onSubscribe/onUnsubscribe lifecycle hooks (start an author-owned live source task in onSubscribe that publishes, and stop it in onUnsubscribe) and pollInterval. A poller's lease lasts EventsOptions.pollLeaseTtl or two poll intervals, whichever is longer, so onUnsubscribe fires only once a poller stops polling. A poll that sets no maxEvents returns at most EventsOptions.pollDefaultMaxEvents (100) events, and a larger request is clamped to pollMaxEvents (1000). The hooks fire exactly once per (principal, name, arguments) per node: the lifecycle refcount is node-local. A webhook subscription fires onSubscribe on the node that creates it and on every node that registers the hook while the subscription is in the WebhookSubscriptionStore, so a node restarted over a durable store provisions its upstream sources again; it does not fire on a node for a webhook subscription another node creates later. Write the hooks to be idempotent across nodes.

A pull source (a cursor-addressable upstream — Gmail history, Kafka offsets) supplies a fetch handler instead, declared with the @event UDA — EventBatch!P fetch(A args, FetchContext ctx) — which the SDK calls to serve poll directly and stream/webhook via its loop:

@event("email.received", "A new email arrives")
EventBatch!Email checkEmail(EmailArgs args, FetchContext ctx) @safe
{
    if (ctx.isBootstrap) return EventBatch!Email.empty(currentCursor());
    return EventBatch!Email.of(fetchSince(ctx.cursor.get, args), newCursor());
}

ctx.cursor is a Nullable!string: null means the client asked to start from now (isBootstrap), so read it with .get only after that check.

Registering an @event (or a registerEventType) enables the extension with default options when enableEvents() has not been called. To pass a webhook store or EventsOptions, call enableEvents(store, opts) before registering any event type: afterwards it throws rather than discard the registered types. A stateful server never speaks 2026-07-28, so registering an event type on one throws.

The dynamic server.registerEventType(EventRegistration(...)) path (raw Json) remains for runtime-defined types.

On the client, client.eventsSupported() reports the negotiated extension and listEvents() enumerates types. For consuming a subscription, prefer the managed layer: subscribeEvents(name, SubscribeOptions, onEvent, onControl) looks the type up, intersects its advertised delivery with the client's EventClientSettings.preferredModes (webhook when a webhook receiver is configured, then push, then poll; NoCompatibleDeliveryMode when nothing fits, or force one via SubscribeOptions.delivery), and returns one mode-neutral EventSubscription handle (sub.mode() reports the choice). The per-mode subscribePoll/subscribeStream/subscribeWebhook factories return the same handle. The SDK runs the poll loop (pacing by nextPollMs above a configurable floor, default 1 s), demuxes the push stream and reopens it from the last cursor when no frame arrives for streamDeadAfter (default 60 s), keeps the webhook grant refreshed before it lapses (and, for a no-expiry grant, at a health-check cadence), deduplicates by eventId, and signals gaps (including a webhook subscribe or refresh reported truncated); sub.cursor() exposes the live watermark for resume, sub.deliveryStatus() the webhook delivery health the server last reported, and sub.cancel() tears the subscription down. The low-level pollEvents(...)/streamEvents(...)/subscribeWebhookEvents(...) round-trips remain for callers who want to drive the loop themselves. A WebhookReceiver verifies inbound Standard Webhooks deliveries, answers the verification challenge, deduplicates per subscription, and routes occurrences — usable as the forward proxy a delivery.url points at; generateWhsecSecret() mints the client-supplied whsec_ signing secret. The runnable Events example exercises all three modes end-to-end over both transports: poll and push through the server, and webhook by running the WebhookReceiver as a loopback HTTP listener the server signs and POSTs to.

Webhook delivery follows the sketch end to end: a fresh events/subscribe replays from the client's cursor (the emit buffer for push sources, the fetch handler for pull sources) bounded by maxAgeMs and reports truncated; a refresh of a live subscription keeps the server's watermark; a lapsed one is recreated. Fetch-handler types are served over webhook by the periodic worker enableEvents starts, which also sweeps lapsed subscriptions and expires poll leases. The watermark advances to a position only once every earlier in-flight delivery has settled. Delivery is suspended on a sustained failure rate (WebhookSuspension: 95 % over a 60-minute window with a 20-attempt minimum); events emitted while suspended are not queued, and the refresh that reactivates it sends a gap envelope for the missed positions. A subscription's backlog is bounded (webhookMaxPendingPerSubscription, default 1000) with the overflow likewise signalled by a gap; deliveries to an endpoint that never verifies are dropped after the attempt bound. Retries are 5 attempts over about 7.5 minutes, and bodies over 256 KiB are abandoned with a gap envelope.

Webhook security is implemented in full: https-only callback URLs; delivery-time SSRF hardening (the resolved IP is validated against the IANA special-purpose registries and pinned, with no redirect following); per-delivery HMAC (v1,) signing with the client-supplied secret and a secret-rotation dual-signing grace window; mandatory endpoint verification before delivery (a receiver-published /.well-known/mcp-webhook-receiver.json, the challenge handshake, server allowlist, or out-of-band) cached per (principal, url); bounded retry with exponential backoff, waited out in the delivery queue so a failing endpoint holds no delivery slot (a 410 ends the subscription; a 413 skips the event with a gap) and a safe-to-persist watermark cursor; and deliveryStatus surfaced on refresh.

Termination. rt.unregister(name) removes a type and ends every subscription to it with NotFound {kind: "event"}; re-registering a type whose schema changed non-additively ends them with Unsupported {reason: "schema_changed"} (additive changes keep them). The extension is advertised without listChanged: no subscriptions/listen filter key carries notifications/events/list_changed, so clients re-call events/list to see type changes. rt.terminatePrincipal(principal, name, error) ends a principal's subscriptions once access is revoked. Push streams receive notifications/events/terminated followed by the final StreamEventsResult; webhook subscriptions a signed terminated envelope. A throwing fetch handler mid-stream sends a recoverable notifications/events/error, and a gap re-sends active {truncated: true}. Optional server identity asymmetric signing (v1a,, ed25519) is an opt-in build: the default library is OpenSSL-only, and the library-ed25519 dub configuration adds standardwebhooks:ed25519 (which links libsodium) and defines version(MCPWebhookEd25519). Built that way, setting EventsOptions.webhookSigningKey to a whsk_ key auto-wires the signer (AsymmetricWebhook) and publishes the public key as a JWKS; consumers select it with "subConfigurations": { "mcp-d": "library-ed25519" } (or supply their own EventsOptions.v1aSigner). Its discovery mechanism is contingent on the still-draft SEP-2127 (server cards).

events/subscribe/events/unsubscribe require an authenticated principal (the spec forbids webhook on unauthenticated servers); poll and push do not. A single-tenant or dev server that authenticates outside the SDK (or not at all) can set EventsOptions.assumePrincipal to treat callers as one fixed principal, and EventsOptions.allowPrivateCallbackHosts additionally relaxes the SSRF host check and permits a plain-http loopback callback — both are how the Events example drives webhook delivery to its local receiver. Production multi-tenant servers use real auth so the subscription key isolates tenants, and https-only callbacks.

Status: draft extension. Like Tasks, the Events extension requires the modern protocol — every events/* method answers -32601 on a legacy session, and the capability is advertised only from 2026-07-28. The extension itself is a design-sketch proposal (experimental-ext-triggers-events); the wire surface may change as it moves through WG review.

Skills

The official MCP Skills extension (io.modelcontextprotocol/skills, originally SEP-2640) serves Agent Skills — a SKILL.md of instructions plus optional supporting files — over MCP. Skill content rides on the existing Resources primitive, so any host that treats resources as a virtual filesystem consumes MCP-served skills exactly like local ones. The extension adds three methods: skills/list and skills/get (implemented by every server that declares the extension) and the optional resources/directory/read (gated behind the directoryRead capability setting). Ship a skill alongside the tools it describes and they version and travel together.

Mark a no-argument method @skill and it returns the SKILL.md body; the SDK synthesizes the YAML frontmatter from the path/description, serves it at skill://<path>/SKILL.md as text/markdown, advertises the extension (and, because skill files are served through resources/read, the resources capability), and publishes a conformant entry — the SKILL.md uri, the verbatim frontmatter, and a complete resources manifest listing every file of the skill with the sha256 digest and byte size of the content it serves — via skills/list and skills/get. The skill path's final segment is the skill name; a leading prefix (acme/billing/refunds) is an optional organizational namespace. The description must be 1..1024 characters; the optional license, compatibility (at most 500 characters), and allowedTools (allowed-tools) frontmatter fields follow it — @skill("x", "…", "MIT") — or are set on a Skill / DynamicSkill. The method runs once, at registerHandlers, and its body is served for the server's lifetime (use registerDynamicSkill for a body generated per read).

final class Skills
{
    @skill("git-workflow", "Follow this team's Git branching and commit conventions")
    string gitWorkflow() @safe
    {
        return "# Git Workflow\n\n1. Branch from `main`.\n2. Squash-merge once approved.\n";
    }
}

registerHandlers(server, new Skills);    // serves skill://git-workflow/SKILL.md, listed by skills/list

For a multi-file skill (references, templates, scripts) register it imperatively, attaching sibling files served at skill://<path>/<file>:

Skill pdf = {
    path: "office/pdf-forms",
    description: "Fill in PDF forms using the field reference",
    instructions: "# PDF Forms\n\nConsult `references/FORMS.md`, then fill each field.\n",
    files: [SkillFile("references/FORMS.md", "text/markdown", "# Form Fields\n- applicant_name\n")]
};
registerSkill(server, pdf);   // skill://office/pdf-forms/SKILL.md + references/FORMS.md

Every file — the SKILL.md itself included — appears in the entry's resources manifest as a {uri, digest, size} entry. The manifest is the unit a host verifies reads against and binds a user's approval to: a changed, added, or unlisted file is a verification failure, so content cannot rotate under a persisted approval. The extension fixes two per-skill limits that every conforming host accepts — maxSkillResources (512 entries, SKILL.md included) and maxSkillTotalBytes (16 MiB summed over size) — and registration rejects a skill over either, since no host is required to load it.

A skill whose SKILL.md is generated on demand cannot publish stable digests. Register it as a DynamicSkill and its entry carries resources: "dynamic" instead of a manifest; the body delegate runs on every read beneath fixed, synthesized frontmatter. Such a skill offers hosts no content integrity and cannot be content-bound to an approval, so some hosts decline to load one — prefer a static Skill whenever the content can be fixed at registration:

DynamicSkill daily = {
    path: "reports/daily",
    description: "Assemble today's operational report from live data",
    instructions: () @safe => renderTodaysReport(),
};
registerDynamicSkill(server, daily);   // skills/list: {..., "resources": "dynamic"}

Serving a skill from a local directory

To serve an existing on-disk skill — a SKILL.md with authored frontmatter plus whatever files and subdirectories it ships — point registerSkillDir (or the @skillDir UDA) at the directory. The SDK serves the SKILL.md verbatim, parses its frontmatter into the skill's entry, and exposes every file as a skill://<path>/<file> resource (so subdirectories are automatically walkable via resources/directory/read):

// UDA: the method returns the local directory.
final class Skills
{
    @skillDir("team/release-helper")
    string releaseHelper() @safe => "skills/release-helper";
}

// Or imperatively, with full control via SkillDirOptions:
registerSkillDir(server, "skills/release-helper", SkillDirOptions(
    path: "team/release-helper",            // empty derives it from the frontmatter name
));

A relative directory is resolved against the server process's current working directory, not the source file or the executable, so a server launched from elsewhere (e.g. spawned over stdio by a host) will not find it. Return an absolute path when the working directory is not fixed, for example one built from std.file.thisExePath.

The directory's name, and the final segment of the skill path, must equal the frontmatter name, as the Agent Skills specification requires (dyaml parses the frontmatter). A symlink or exceeding maxFiles/maxTotalBytes is rejected. Dot-prefixed files and directories (.git/, .DS_Store) are skipped unless you supply SkillDirOptions.include, which then decides for every file, dot-prefixed or not. Skills may nest: a SKILL.md in a descendant directory is ordinary supporting content of the enclosing skill (its files appear in the enclosing resources manifest too), and SkillDirOptions.publishNested (the default) additionally publishes each nested skill as its own flat entry — authored frontmatter, resources covering exactly its subtree — validated by the same rules as a top-level skill, so one invalid nested SKILL.md rejects the whole directory. When a nested SKILL.md is not meant as a skill (a template with placeholder frontmatter, say), set publishNested = false (SkillDirOptions or @skillDir(publishNested: false)) to serve it as a plain supporting file.

On the client, client.skillsList() calls skills/list (paginating to completion) and .entries gives the typed entries; client.skillsGet(uri) calls skills/get to fetch one skill's entry (.entry) by its SKILL.md URI — including skills absent from the listing, which MAY be empty or partial. Both results also carry the server's cache hint. A SkillEntry exposes its resources manifest, isDynamic for a "dynamic" entry, and isValid (an entry with neither shape is malformed and must not be loaded). readSkill(client, "git-workflow") / readSkillUri(client, uri) read a SKILL.md via plain resources/read, and verifyResourceDigest / verifySkillMarkdown implement the host-side integrity checks the extension requires (byte-length and digest match, unlisted-file rejection, field-by-field frontmatter comparison); checkSkillLimits decides from the entry alone whether a skill exceeds the fixed per-skill limits. Note that no URI scheme marks a resource as a skill — a resource is known to be a skill only through a skills/list entry or a skills/get answer. When a skill's instructions point at a directory ("pick a template from templates/"), client.readDirectory(uri) scope-lists that directory's direct children via resources/directory/read (files plus subdirectories, whose mimeType is skillDirectoryMimeType, inode/directory) — enabled automatically by enableSkills, which advertises directoryRead: true.

The stable specification is written against protocol revision 2026-07-28, where ListSkillsResult and GetSkillResult extend CacheableResult: on a modern session both carry the required ttlMs/cacheScope (configure them with setListCacheHint("skills/list", …) / setListCacheHint("skills/get", …); the default is ttlMs: 0), and every result carries resultType: "complete". They are a freshness hint, not an integrity property — the manifest governs what a host may read regardless. The extension is also advertised on 2025-11-25 (its entry in the extensions negotiation map carries that floor), where the base protocol has no CacheableResult and the results carry neither field; the resource reads themselves work on any version. See examples/skills for the full e2e.

Concurrency model

McpClient speaks vibe.d async I/O. Its verbs (connect / initialize / listTools / callTool / listResources / readResource / listPrompts / getPrompt / subscribe / setLogLevel, plus the auto-paginated list helpers and enableModern()) are fiber-blocking, not thread-blocking — this is the Go-SDK model: a call reads like a plain blocking call, but under the hood it yields its fiber back to the event loop until the reply arrives, costing you nothing but a cheap green thread. You get concurrency by spawning more tasks, not by reaching for an async surface.

Because the loop stays live during a call, progress notifications and server→client handlers (sampling / elicitation) still dispatch mid-call — a callTool that takes a minute does not freeze the loop. The same API works over every transport: McpClient.http(url) builds a client over Streamable HTTP, McpClient.spawn(command) / McpClient.stdio(readLine, writeLine) build one over stdio. The server side is runStreamableHttp(server, port) or runStdio(server).

Running concurrent calls. Every verb must run inside a task under a running event loop. Spawn each concurrent call as its own runTask and let the loop interleave their I/O. vibe requires a task body to be nothrow, so catch exceptions inside it:

import vibe.core.core : runTask;

runTask(() nothrow {
    try { auto a = client.callTool("one", argsA); /* ... */ }
    catch (Exception e) { /* report e.msg */ }
});
runTask(() nothrow {
    try { auto b = client.callTool("two", argsB); /* ... */ }
    catch (Exception e) { /* report e.msg */ }
});

Entering the loop from a non-vibe process. If your process is not otherwise vibe-based and its MCP work has a scoped lifetime (CLI tools, batch jobs, tests), use runWithEventLoop — it spins up the loop, runs your scenario inside a task, exits the loop when the scenario returns, hands back its value, and rethrows any exception on your side:

import mcp; // re-exports runWithEventLoop

void main()
{
    auto five = runWithEventLoop(() @safe {
        auto client = McpClient.spawn(["./demo-server"]);
        scope (exit) client.close();
        client.connect();
        auto r = client.callTool("add", parseJsonString(`{"a": 2, "b": 3}`));
        return r.structuredContent["result"].get!long;
    });
    assert(five == 5);
}

A vibe-native app needs no runner — there is already a loop, so just call McpClient from any task. For a long-lived non-vibe host (a GUI, a game loop, a server in another framework), run your MCP integration on the loop on its own thread and hand results to the rest of the app over your own channel, rather than entering and exiting the loop per call.

Long-running work belongs to RequestOptions.onProgress and the Tasks extension (@taskTool / awaitTask), not to a blocked thread.

No sync wrapper, by design. There is deliberately no blocking cross-thread synchronous McpClient facade and no "sync client" wrapper: tool calls can run for minutes, and parking a host thread on one is a foot-gun. Fiber-blocking already gives you the Go model on the loop's own thread, so the decision is settled — reach for runTask and the Tasks extension, not a thread bridge.

Running the conformance suite

Each protocol revision runs against its frozen requirement set, at that revision's wire: the dated revisions through 2025-11-25 use the stateful initialize handshake, 2026-07-28 is stateless with per-request _meta, so the server is started once per lane.

just conformance                       # all four lanes (server + client, both revisions)
just conformance-server-lane 2026-07-28 # one lane: `conformance-server --stateless` + --requirements
just conformance-client-lane 2025-11-25

By hand:

dub build -c conformance-server && dub build -c conformance-client
./conformance-server --port 3000 &              # stateful: 2025-11-25 lane
./conformance-server --port 3001 --stateless &  # modern:   2026-07-28 lane
npx @modelcontextprotocol/[email protected] server --url http://127.0.0.1:3000/mcp --requirements 2025-11-25
npx @modelcontextprotocol/[email protected] server --url http://127.0.0.1:3001/mcp --requirements 2026-07-28
npx @modelcontextprotocol/[email protected] client --command ./conformance-client --requirements 2025-11-25
npx @modelcontextprotocol/[email protected] client --command ./conformance-client --requirements 2026-07-28

The client harness forwards the revision to conformance-client as MCP_CONFORMANCE_PROTOCOL_VERSION, which selects the stateful or the stateless lifecycle. All four lanes run in CI on every push and pull request via the Conformance workflow, with the harness version pinned for reproducibility; a lane fails on any required-scenario failure. Unscored scenarios (extensions, scenarios added after a revision shipped) are reported but never fail the job.

Contributing

Contributions are welcome! See CONTRIBUTING.md for dev setup, the build/test/lint commands, project conventions, and the PR flow.

License

Apache-2.0 — see LICENSE and NOTICE. This aligns the SDK with the Model Context Protocol project, which is licensed under Apache-2.0.

About

Model Context Protocol SDK for the D Programming Language

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages