A feature-complete Model Context Protocol (MCP) SDK for the D programming language — client and server, built on vibe-d.
- Quickstart
- Installation
- Goals
- Status
- Requirements
- Build & test
- Deploying
- API documentation
- Statefulness
- Implementing a custom server transport
- Client response cache
- TLS
- Client authentication (OAuth login)
- Examples
- MCP Apps (interactive UI)
- MCP Tasks (asynchronous execution)
- MCP Events (triggers)
- Skills
- Concurrency model
- Running the conformance suite
- Contributing
- License
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"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.
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
~mainwith a full commit hash, e.g.version="711d9fc447140f2d95992a6e57276d2a88c81a7c". Builds are reproducible, and moving to a newermainis an explicit edit of that hash. - Track
main. Keep~mainand, when you want the latest commit, drop the cached copy withdub remove mcp-d(it removes every cached mcp-d version); the nextdub build/dub upgradeclonesmainafresh.
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.
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(andformat, re-exported asschemaFormat); - vibe's field attributes
name,optional, andignore; core.time'sdur,msecs,seconds,minutes,hours, anddays, and Phobos'snullable.
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- 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/conformancesuite.
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_jwtES256), 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/@skillwith 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) withHeaderMismatchvalidation, andx-mcp-headermirroring — on both client and server.callTooltransparently drives the full MRTR (SEP-2322) round-trip loop via an internalcallToolLoop, satisfying eachInputRequestand 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@eventUDA,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, optionalv1a,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_idwhen the AS supports it; the server-side OAuth proxy opts in viaOAuthProxyConfig.clientIdMetadataDocumentSupported, advertisingclient_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— exactclient_idmatch, required fields (client_id,client_name,redirect_uris), and a redirect-URI allowlist sourced from the document — with confused-deputy consent keyed on the stableclient_idURL. The consent screen surfaces theclient_name, theclient_idhost, 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 inOAuthProxyConfig.scopesSupportedare forwarded upstream (none when it is empty). DCR remains as the deprecated fallback. -
✅ Interactive client login —
useOAuthruns 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).
- A D toolchain with frontend 2.111+ (DMD 2.111+, or LDC 1.41+).
- OpenSSL 3.x must be installed on the system. The
openssldependency, and thevibe-d:tlspackage thatvibe-d:httppulls in, link against it for TLS (HTTPS transport, OAuth 2.1).- Ubuntu/Debian: ships with OpenSSL 3.x (
apt install libssl-devif headers are missing). - macOS:
brew install openssl@3, then exportPKG_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 itsbindirectory is onPATHso the runtime DLLs are found.
- Ubuntu/Debian: ships with OpenSSL 3.x (
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.
ulimit -n 65536 # required: dub misbehaves under ghostty's `ulimit -n unlimited`
dub build # build the library
dub test # run all unit testsFormatting 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 filtersTo 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.
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 ./docsOpen 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.
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 managementThe 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/subscribepush over HTTP, construct the server withMcpServer.stateful(). Stateless is correct for plain request/response tools, resources, prompts, progress, the modernsubscriptions/listenstream, 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.
| 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 |
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.
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/handleRawfamily. The minimal recipe is to parse the peer's bytes into a single string and callhandleRaw, 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'sctx.log/ctx.reportProgressreach the peer while the request is in flight, andhandleRaw(text, sink, serverRequest)to additionally carry the blocking server->client request channel behindctx.sample/ctx.elicit. -
Outbound (server -> peer) is the
RequestContextinterface (mcp.server.context), the named companion the transport implements. Supply a concreteRequestContexttohandle(or via thesink/serverRequestoverloads ofhandleRaw); the server calls back into it to emit notifications and issue server->client requests. A transport that multiplexes many sessions over one server also implementsConnectionScopedon itsRequestContextso the core scopes per-connection state (the cancellation registry) per session, and threads each request's ownConnectionStateviahandleRaw(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.*.
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 requestConfigure 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 entryAutomatic 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.
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);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.scopeswhen set, otherwise thescopeof the 401 challenge passed asOAuthLogin.wwwAuthenticate, otherwise the protected-resource metadata'sscopes_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.scopesor the challenge (a step-up afterinsufficient_scope), which needs a new authorization. A token granted only part of the fallbackscopes_supportedis 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 athttp://127.0.0.1:<callbackPort><callbackPath>(an ephemeral port by default);stateand RFC 9207issare verified, and the wait is bounded bycallbackTimeout(5 minutes by default). -
Persists tokens through
OAuthLogin.store. The default is aFileTokenStoreat$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 directoryuseOAuththrows rather than writing tokens to the working directory. Subclass it and overrideserialize/deserializeto encrypt at rest, or supplyMemoryTokenStoreor your ownTokenStore. -
Refreshes transparently: every request asks the returned
OAuthSessionfor 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 withinvalid_grantdrops the refresh token, so later requests fail fast untiluseOAuthsigns in again. A request the server rejects with401 invalid_tokenis refreshed and retried once; with no refresh token, or when the refreshed token is rejected too within a minute of that refresh, the 401 (anHttpStatusExceptioncarrying theWWW-Authenticatechallenge) reaches the caller, who can pass the challenge touseOAuthasOAuthLogin.wwwAuthenticateto 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.
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];
}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
}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-appSet 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.
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 resumeNot supported: the experimental 2025-11-25 tasks. The
tasksfeature that shipped in the 2025-11-25 core specification (a top-leveltaskscapability,tasks/list,tasks/result, the per-toolexecution.taskSupportfield, and the per-requesttaskparameter) was a stopgap the spec has since replaced with this extension. It is intentionally not implemented — those methods answer-32601and notaskscapability is advertised. Only the SEP-2663 extension above is supported, and only on 2026-07-28 and later.
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 queuepublish(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-32601on 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.
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/listFor 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.mdEvery 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"}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.
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.
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-25By 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-28The 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.
Contributions are welcome! See CONTRIBUTING.md for dev setup, the build/test/lint commands, project conventions, and the PR flow.
Apache-2.0 — see LICENSE and NOTICE. This aligns the SDK with the Model Context Protocol project, which is licensed under Apache-2.0.