mcp-d 0.3.0
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
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
Or add it manually to your dub.json:
"dependencies": {
"mcp-d": "~>0.2"
}
Then import mcp; in your source files.
Goals
- Full MCP support across every protocol version (
2024-11-05→draft) 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.
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/@skillwith 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) 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). - ✅ 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) 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 the verifiedclient_nameand 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.100+ (DMD 2.100+, or LDC 1.30+).
- 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-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.
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/
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.)
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 draftsubscriptions/listenstream, 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 state | Notes | |
|---|---|---|
| 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 initialize | The 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.
| 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 (draft) | yes (self-contained stream) | n/a (draft-only) | yes |
Server->client elicit/sample/roots | forbidden (error; MRTR instead) | forbidden (error) | yes |
logging/setLevel | n/a (per-request _meta) | forbidden (-32601) | yes (session-scoped) |
| Session id minted | never | never | yes |
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/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 / 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
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).
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 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 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:
| Mode | Behaviour |
|---|---|
use (default) | serve a fresh entry, else fetch and store |
bypass | ignore the cache for read and write — force the network, store nothing |
refresh | skip the read, force the network, then store the fresh result |
RequestOptions o; o.cacheMode = CacheMode.refresh;
c.listResources(o); // re-fetch and update the cached entry
Automatic invalidation. The server's change notifications evict the affected
entries so the next call lazily refetches: notifications/tools/list_changed,
prompts/list_changed, and resources/list_changed drop the matching list
cache(s), and resources/updated drops just that URI's readResource entry. Each
also fires a typed callback (onToolsListChanged, onPromptsListChanged,
onResourcesListChanged, onResourceUpdated(uri)) in addition to the generic
onNotification. setBearerToken evicts the client's own partition so a
re-authenticated session never reads the previous identity's private results.
Examples
The repository ships thirteen 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.
| Example | What it shows | 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 | draft CacheableResult hints (ttlMs/cacheScope) | server | client |
| Stateless draft | the stateless draft protocol (server/discover, per-request _meta) | 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 | async @task tools (progress, cancellation, mid-task input) | 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 |
| Tasks | MCP Tasks extension (SEP-2663): @task async tasks with progress, cancellation, and input_required | server | client |
| Skills | MCP Skills extension (SEP-2640): @skill Agent Skills served as resources + skill://index.json discovery | 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). 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 type | JSON 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
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 under the draft protocol version.
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. It rides on the existing Resources
primitive (its only new message is the optional resources/directory/read
method), so any host that treats resources as a virtual filesystem consumes
MCP-served skills exactly like local ones. 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 lists
a conformant entry — verbatim frontmatter, the SKILL.md url, and its
sha256 digest — in the well-known skill://index.json discovery resource.
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 + the index
For a multi-file skill (references, templates, scripts) register it imperatively,
attaching sibling files served at skill://<path>/<file> and, optionally, a
pre-packed archive form of the whole skill served at skill://<path><suffix>:
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")],
archives: [SkillArchive(".tar.gz", "application/gzip", base64TarGz)]
};
registerSkill(server, pdf); // skill://office/pdf-forms/SKILL.md + references/FORMS.md + .tar.gz
Each archives entry is listed alongside the SKILL.md url with its own
sha256 digest, so a host can fetch the whole skill in one round trip; both forms
unpack to identical content.
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 index entry, exposes every file as a
skill://<path>/<file> resource (so subdirectories are automatically walkable via
resources/directory/read), and packs the tree into any archive formats you ask
for:
// UDA: the method returns the local directory; archives are opt-in per format.
final class Skills
{
@skillDir("team/release-helper", ArchiveFormat.zip)
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
archives: [ArchiveFormat.zip, ArchiveFormat.tarGz],
));
The directory's final path segment must equal the frontmatter name. Frontmatter
is parsed with dyaml and archives are
built with the archive package (zip is
in-tree via std.zip's format; tarGz produces real gzip). Archives are built
deterministically (sorted entries, no timestamps) so the index digest is stable.
A symlink, a nested SKILL.md, or exceeding maxFiles/maxTotalBytes is
rejected.
On the client, listSkills(client) reads skill://index.json and returns the
discovery entries (each with frontmatter, url, digest, and archives), and
readSkill(client, "git-workflow") / readSkillUri(client, uri) read a
SKILL.md. 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 the extension's one new method, resources/directory/read
(files plus inode/directory subdirectories) — enabled automatically by
enableSkills, which advertises directoryRead: true. The extension is
advertised only under the draft protocol (the
extensions negotiation map is draft-only), so a client enables enableModern()
before negotiation; 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.
- 0.3.0 released 2 months ago
- Poita/mcp.d
- github.com/Poita/mcp.d
- Apache-2.0
- Copyright © 2026 Peter Alexander
- Authors:
- Dependencies:
- archive, jsonschema, vibe-d:data, vibe-d:http, openssl, dyaml, jsonschema:vibe
- Versions:
-
Show all 33 versions0.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 - 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