mcp-d ~worktree-server-side-cimd
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:
// server.d
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!(__traits(parent, add))(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:
// client.d — build server.d as ./demo-server first
import mcp;
import vibe.data.json : parseJsonString;
import vibe.core.core : runTask, runEventLoop, exitEventLoop;
void main()
{
// The client drives vibe's event loop.
runTask(() nothrow {
scope (exit) exitEventLoop();
try
{
auto client = McpClient.spawn(["./demo-server"]);
scope (exit) client.close();
client.connect();
auto r = client.callTool("add", parseJsonString(`{"a": 2, "b": 3}`));
assert(r.structuredContent["result"].get!long == 5);
}
catch (Exception e) assert(false, e.msg);
});
runEventLoop();
}
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.1"
}
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/@promptwith 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, and a redirect-URI allowlist sourced from the document — with confused-deputy consent keyed on the stableclient_idURL. 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 even after
enableResourceSubscriptions() (the opt-in is inert in stateless mode), so a
client never expects per-resource update push it could not receive. 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.
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 |
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.registerDynamicTool(Tool, delegate) / registerResource /
registerDynamicPrompt, which receive the raw Json.
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([InputRequest.elicitation!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.
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.
Event-loop model
McpClient speaks vibe.d async I/O: call every McpClient method from inside the
vibe event loop — wrap your calls in a runTask under runEventLoop() (the
examples' shared scaffold does this for you). The same API (initialize /
listTools / callTool / listResources / readResource / listPrompts /
getPrompt / subscribe / setLogLevel, plus the auto-paginated list helpers and
enableModern()) 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 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.
- ~worktree-server-side-cimd released 22 hours ago
- Poita/mcp.d
- github.com/Poita/mcp.d
- Apache-2.0
- Copyright © 2026 Peter Alexander
- Authors:
- Dependencies:
- vibe-d:data, vibe-d:http, openssl
- Versions:
-
Show all 13 versions0.1.0 2026-Jun-07 0.0.1 2026-Jun-06 ~worktree-windows-support 2026-Jun-07 ~worktree-server-side-cimd 2026-Jun-07 ~worktree-release-0.1.0 2026-Jun-07 - Download Stats:
-
-
7 downloads today
-
36 downloads this week
-
36 downloads this month
-
36 downloads total
-
- Score:
- 0.4
- Short URL:
- mcp-d.dub.pm