mcp-d 0.4.1

Model Context Protocol SDK for D


To use this package, run the following command in your project's root directory:

Manual usage
Put the following dependency into your project's dependences section:

mcp.d

Dub version CI codecov License: Apache 2.0 API docs

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

Quickstart

A server is a handful of annotated functions plus runStdio:

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 = new McpServer("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):

// client.d — build server.d as ./demo-server first
import mcp;
import vibe.data.json : parseJsonString;

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

Installation

Add mcp.d to your project with dub:

dub add mcp-d

This always pulls the latest release (see the Dub version badge above). Then import mcp; in your source files.

Goals

  • Full MCP support across every protocol version (2024-11-05draft) 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

All official conformance tests pass (0 failures): server 39/39, client 287/287 (one advisory SHOULD warning on the optional Client-ID-Metadata-Document flow).

  • All 39 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.
  • 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 / @task / @skill with auto JSON-Schema.
  • DRAFT (2026-07-28) — 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, draft-only) — 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) 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 verified client_name and the redirect-URI hostname. DCR remains as the deprecated fallback.

Optional follow-ups (not required for conformance): a built-in loopback redirect listener for the interactive auth-code flow, and 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 / vibe-d:tls dependency links 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/macOS with DMD and LDC, 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.

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 (42 modules, ~1900 tests)

Formatting and linting:

dub run dfmt -- --inplace source/
dub run dscanner -- --styleCheck source/

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 a published release (or a manual workflow_dispatch), not on every push to main (best-effort: the publish step is skipped if Pages is not enabled for the repository).

Statefulness

A server chooses one of two statefulness models at construction. Stateless is the default. The author picks the mode via factories; the existing new McpServer(name, version) constructors keep working and default to stateless.

auto s1 = McpServer.stateless("my-server", "1.0.0"); // default; same as `new McpServer(...)`
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 draft 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 a worker leases/delivers/acks. The in-memory default is single-node; injecting a shared, durable queue (Redis/SQS/DB) plus a SubscriptionStore and running EventsRuntime.startDeliveryWorker on each node makes webhook delivery node-agnostic (any node delivers; a crashed node's leased job is re-leased) — mirroring the TaskStore/TaskDispatcher split.

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 draft subscriptions/listen stream, and the draft 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 stateNotes
Modern stateless (stateless + request >= draft)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 < draft)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, pre-draft only)ConnectionState resolved by Mcp-Session-Id, created at initializeThe draft is excluded from negotiation (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-draft and legacy) forbid the same correlation features regardless of transport — they differ only in how each request's ConnectionState is resolved.

FeatureModern statelessLegacy statelessStateful
initialize handshaken/a (server/discover)no-op (no session id)mints Mcp-Session-Id
Per-request _meta version/capsyesn/a (header + empty caps)n/a (session-negotiated)
Standalone GET SSE streamforbidden (405)forbidden (405)yes
resources/subscribe / unsubscribeforbidden (-32601)forbidden (-32601)yes
subscriptions/listen (draft)yes (self-contained stream)n/a (draft-only)yes
Server->client elicit/sample/rootsforbidden (error; MRTR instead)forbidden (error)yes
logging/setLeveln/a (per-request _meta)forbidden (-32601)yes (session-scoped)
Session id mintedneverneveryes

The subscribe capability advertisement follows the same rule: a stateless server does not advertise the resources subscribe capability. 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 (also exported as ServerTransport) 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 / ServerTransport + 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).

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)), 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 the draft marks CacheableResultlistTools, 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 pre-draft servers (or a draft 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.

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/invalidateMethod/ 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 an LRU-style size 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. The default per-client store leaves cachePartition empty and the distinction is moot.

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), sparing shared public entries and other principals' partitions; for the default per-client store that empty partition holds everything, so it behaves as a full clear.

Per-call modes via RequestOptions.cacheMode:

ModeBehaviour
use (default)serve a fresh entry, else fetch and store
bypassignore the cache for read and write — force the network, store nothing
refreshskip 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.

Examples

The repository ships fourteen runnable, self-verifying server/client pairs in examples/. 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.

ExampleWhat it showsServerClient
Tools@tool handlers with typed args/resultsserverclient
Prompts@prompt templatesserverclient
Resourcesresources + templates + subscriptions/listen pushserverclient
Cachingdraft CacheableResult hints (ttlMs/cacheScope)serverclient
Stateless draftthe stateless draft protocol (server/discover, per-request _meta)serverclient
Streamingprogress notifications from a long-running toolserverclient
MRTRmulti-round-trip tool input (carried in the result)serverclient
Tasksasync @task tools (progress, cancellation, mid-task input)serverclient
Samplingserver-initiated LLM sampling (ctx.sample)serverclient
Elicitationserver-initiated, typed user input (ctx.elicit!T)serverclient
Sticky notesstateful tools + a resource per note + elicitation-confirmed clearserverclient
AuthOAuth 2.1 protected HTTP resource server (HTTP only)serverclient
AppsMCP Apps extension: @ui tool link + a ui:// HTML resourceserverclient
TasksMCP Tasks extension (SEP-2663): @task async tasks with progress, cancellation, and input_requiredserverclient
SkillsMCP Skills extension (SEP-2640): @skill Agent Skills served as resources, skills/list / skills/get discoveryserverclient
EventsMCP Events extension: @event types delivered over poll, push, and server-signed webhook (to a client WebhookReceiver)serverclient

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). For tools whose schema is only known at runtime, drop to server.registerTool(Tool, delegate) / registerResource / registerPrompt, which receive the raw Json.

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 typeJSON Schema
bool{"type": "boolean"}
int / long / short / byte (and uint/ulong/… ){"type": "integer"} (unsigned also gets "minimum": 0)
float / double{"type": "number"}
string{"type": "string"}
enum{"type": "string", "enum": [members…]}
T[]{"type": "array", "items": <T>}
V[string]{"type": "object", "additionalProperties": <V>}
struct{"type": "object", "properties": …, "required": […]}
std.datetime SysTime/DateTime/Date/TimeOfDay{"type": "string", "format": "date-time"/"date"/"time"}
SumType!(A, B, …){"anyOf": [<A>, <B>, …]}
Nullable!T (tool parameter)<T>, made optional by omission from required
Nullable!T (output / elicitation schema){"anyOf": [<T>, {"type": "null"}]}

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 modelled as a bare type left out of required (the convention used by the MCP reference servers); declare it Nullable!T or give it a D default value (int page = 1). Field/parameter constraints are added with UDAs: @minimum / @maximum, @minLength / @maxLength, @pattern, @minItems / @maxItems, @format, @title, @schemaDefault, and @fieldDescription.

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 = new McpServer("weather", "1.0.0");
registerModule!(my.module)(server);     // a @tool tagged @ui("ui://weather/dashboard", "model", "app")
enableApps(server);               // declare the extension capability

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

A @tool carries its UI link via @ui(resourceUri, visibility…) (folded into the tool's _meta.ui); the dynamic path uses setUiToolMeta(tool, UiToolMeta(...)). clientSupportsApps(server) reports whether the connected 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 @task 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; pass a TaskStore for durability

struct Approval { bool deploy; }

@task("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
}

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

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.

On the client, callToolAwait hides the whole flow — it drives the poll loop and returns the final CallToolResult, so task and non-task tools look identical. 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), which polls to completion (and surfaces mid-task input requests to an optional callback).

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 under the draft protocol version.

MCP Events (triggers)

The MCP Events extension (io.modelcontextprotocol/events, a draft-only proposal) 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 SubscriptionStore). The handle also carries typed onSubscribe/onUnsubscribe lifecycle hooks (SubContext.runUntilUnsubscribe hosts an author-owned live source loop) and pollInterval. The hooks fire exactly once per `(principal, name, arguments)` per node: the lifecycle refcount is node-local, so on a multi-node deployment (where webhook subscriptions are shared via the SubscriptionStore) the hooks fire once per node that first/last sees the key, not once cluster-wide — write them to be idempotent across nodes. A cluster-coherent shared-store atomic refcount is future work.

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, args), newCursor());
}

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: subscribePoll(PollParams, onEvent, onControl), subscribeStream(StreamParams, …), and subscribeWebhook(WebhookReceiver, SubscribeParams, …) each return one mode-neutral EventSubscription handle — the SDK runs the poll loop (pacing by nextPollMs, threading the cursor, signalling gaps), demuxes the push stream, or keeps the webhook grant refreshed before it lapses; sub.cursor() exposes the live watermark for resume 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 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 (challenge handshake, server allowlist, or out-of-band) cached per (principal, url); bounded retry with exponential backoff (410/413 are non-retryable) and a safe-to-persist watermark cursor; and deliveryStatus surfaced on refresh. 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-only. Like Tasks, the Events extension is confined to the draft protocol version — every events/* method answers -32601 on a pre-draft session, and the capability is advertised only under draft. This is a design-sketch proposal (experimental-ext-triggers-events); the wire surface may change as it moves through WG review.

Skills (SEP-2640)

The MCP Skills extension (io.modelcontextprotocol/skills, 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 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 of the bytes 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.

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} pair. 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.

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

The directory's final path segment must equal the frontmatter name (dyaml parses the frontmatter). A symlink or exceeding maxFiles/maxTotalBytes is rejected. 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.

On the client, listSkills(client) calls skills/list (paginating to completion) and returns the typed entries; getSkill(client, uri) calls skills/get to fetch one skill's entry by its SKILL.md URI — including skills absent from the listing, which MAY be empty or partial. 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 SEP requires (digest match, unlisted-file rejection, field-by-field frontmatter comparison). 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/"), readDirectory(client, uri) scope-lists that directory's direct children via resources/directory/read (files plus inode/directory subdirectories) — enabled automatically by enableSkills, which advertises directoryRead: true. The extension is advertised from 2025-11-25 onward (its entry in the extensions negotiation map carries that version floor), so it appears for clients on the latest stable version or the draft; 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:

import vibe.core.core : runTask;

runTask({ auto a = client.callTool("one", argsA); /* ... */ });
runTask({ auto b = client.callTool("two", argsB); /* ... */ });

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 (@task / 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

Server suite:

dub build -c conformance-server
./conformance-server --port 3000 &
npx @modelcontextprotocol/[email protected] server --url http://127.0.0.1:3000/mcp

Client suite:

dub build -c conformance-client
npx @modelcontextprotocol/[email protected] client --command ./conformance-client --suite all

Both suites run automatically in CI on every push and pull request via the Conformance workflow, with the harness version pinned for reproducibility. The job fails on any scenario failure, keeping the server 39/39 and client 287/287 baseline honest.

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.

Authors:
  • Peter Alexander
Dependencies:
jsonschema, standardwebhooks, vibe-d:data, vibe-d:http, openssl, dyaml, jsonschema:vibe
Versions:
0.4.2 2026-Jul-21
0.4.1 2026-Jul-16
0.4.0 2026-Jun-20
0.3.1 2026-Jun-16
0.3.0 2026-Jun-15
Show all 33 versions
Download Stats:
  • 0 downloads today

  • 0 downloads this week

  • 16 downloads this month

  • 217 downloads total

Score:
0.2
Short URL:
mcp-d.dub.pm