Reader: an author whose repository holds more than one package.
The question this chapter answers: how do several packages become one build, and what does a member share with the others.
Not here: publishing those packages, which is 11 — Publishing a Library. Before: 06 — Features and Capabilities. After: 08 — Testing.
A workspace organizes multiple related mcpp packages (libraries or applications) within a single repository. Member packages share a unified set of dependency versions and toolchain settings while each keeping its own mcpp.toml project file.
Workspaces address the following problems:
- Unified dependency-version management — multiple sub-packages use the same versions of third-party dependencies, avoiding duplicate declarations and version drift.
- Shared toolchain configuration — declare the toolchain once at the workspace root; members inherit it or override it as needed.
- Multi-package co-development — libraries and applications are developed in the same repository and reference one another through
pathdependencies.
A workspace does not change how dependencies are declared. Members reference one another through the existing path = "..." mechanism, exactly as in a non-workspace project.
Declare [workspace] in the mcpp.toml at the repository root:
[workspace]
members = [
"libs/core",
"libs/http",
"apps/server",
]members lists the relative path of each member package; every such path must contain its own mcpp.toml.
The optional exclude field excludes specific paths:
[workspace]
members = ["libs/*"]
exclude = ["libs/experimental"]Virtual workspace: the root mcpp.toml contains only [workspace] and no [package]. The root produces no build artifacts and serves purely as a management node.
# Virtual workspace — [workspace] only
[workspace]
members = ["libs/core", "apps/server"]Root-package workspace: the root mcpp.toml contains both [package] and [workspace]. The root itself is also a buildable package.
[workspace]
members = ["libs/core"]
[package]
name = "myapp"
version = "0.1.0"
[dependencies]
myproject.core = { path = "libs/core" }Each member maintains its own mcpp.toml, structured just like a regular project:
# libs/core/mcpp.toml
[package]
namespace = "myproject"
name = "core"
version = "0.1.0"
[targets.core]
kind = "lib"Members reference one another through path dependencies:
# libs/http/mcpp.toml
[package]
namespace = "myproject"
name = "http"
version = "0.1.0"
[dependencies]
myproject.core = { path = "../core" }
[dependencies.compat]
mbedtls.workspace = trueDeclare dependency versions centrally under [workspace.dependencies]; members inherit them with .workspace = true:
# root mcpp.toml
[workspace.dependencies]
cmdline = "0.0.2"
mcpplibs.capi.lua = "0.0.3" # exact selector: (mcpplibs.capi, lua)
[workspace.dependencies.compat]
mbedtls = "3.6.1"
gtest = "1.15.2"# member mcpp.toml
[dependencies.compat]
mbedtls.workspace = true # inherits version → "3.6.1"
[dev-dependencies.compat]
gtest.workspace = true # inherits version → "1.15.2"A member can override an inherited version:
[dependencies.compat]
mbedtls = "4.0.0" # override; does not use the workspace versionAn entry that says .workspace = true and that no workspace resolves is
refused wherever the package enters a build (the root, a member selected with
-p, a path, git or index dependency), naming the table and the entry
(mcpp 2026.9.27.1+). It is resolved against the [workspace.dependencies] of
the workspace whose members list the package; a workspace root that carries
its own [package] resolves its own entries the same way.
The workspace root's [toolchain] and [target.<triple>] settings are automatically inherited by all members. A member can override them in its own project file.
Configuration precedence (highest to lowest):
- Command-line arguments (
--target,--static) - Declarations in the member
mcpp.toml - Declarations in the workspace-root
mcpp.toml - Global configuration (
~/.mcpp/config.toml) - Built-in defaults
# workspace root
[toolchain]
default = "gcc@16.1.0"
[target.x86_64-linux-musl]
toolchain = "gcc@16.1.0"
linkage = "static"# a member overrides the toolchain
[toolchain]
default = "llvm@20.1.7"[toolchain], [target.<triple>] and [indices] choose the compiler, the
target rows and the indices for a whole graph, so a member takes them from the
workspace root only where it is the root of a build: built from the workspace,
with -p, or as a host tool of another package (mcpp 2026.9.27.1+ for the
last). A member reached as a dependency takes them from that build's root.
A build without --target targets the host, and [target.<host-triple>]
applies to it as --target <host-triple> would (mcpp 2026.9.27.1+).
The root's [xlings.workspace] entries, including its
[target.<selector>.xlings.workspace] rows, are inherited implicitly as well
(mcpp 2026.9.27.1+): a payload describes the environment a build runs in, like
[toolchain], so no opt-in is needed. A member's own declaration of the same
package wins. [feature-xlings.<f>] entries are not inherited, because a
feature belongs to the package that declares it.
Package metadata and build flags shared by every member are declared once at the workspace root:
[workspace]
members = ["libs/core", "libs/http", "apps/server"]
[workspace.package]
standard = 26 # or "c++26"; both spellings are accepted
version = "0.4.2"
license = "Apache-2.0"
authors = ["example"]
[workspace.build]
cxxflags = ["-Wall", "-Wextra"]
dialect_cxxflags = ["-fno-exceptions"]A member then declares only what is its own:
[package]
name = "core"
# standard, version, license and authors are inherited;
# [workspace.build] cxxflags are inheritedThe merge rule.
| kind | rule |
|---|---|
scalars (standard, version, license, c_standard, linkage, …) |
the member wins when it declared the key; otherwise the workspace value applies |
vectors (cxxflags, cflags, ldflags, dialect_cxxflags, include_dirs, …) |
append, workspace first |
defines |
a set keyed by macro name: a member entry for an inherited name replaces it, and !NAME removes it (2026.9.25.1+) |
[workspace.dependencies] |
explicit opt-in per dependency, x.workspace = true (§3) |
"Declared" means the key was written, not that its value differs from the
default. A member that deliberately pins standard = "c++23" under a
[workspace.package] standard = 26 keeps c++23; a member that says nothing gets
c++26. Those two are the same value and opposite intents, which is why the
distinction is recorded rather than inferred.
Scalars and vectors are inherited implicitly, without a per-key opt-in. The drift a workspace exists to prevent is a member that forgot to opt in, so inheritance is the default and overriding is what has to be stated. Dependencies keep their explicit opt-in because a dependency is an edge in the resolution graph: inheriting one implicitly would change what a member resolves without its own manifest naming it.
What an appended vector overrides. The member's words follow the
workspace's on the command line. A flag the compiler resolves last-wins is
therefore overridden by restating it: -fexceptions after -fno-exceptions,
-Wno-x after -Wx, -O2 after -O0. Include directories are searched in
order, so a header in a workspace include_dirs directory is found before a
header of the same name in the member's. A macro is overridden through
defines, which emits one -DNAME word per name:
# workspace root
[workspace.build]
defines = ["LOG_LEVEL=1", "TRACE"]
# member
[build]
defines = ["LOG_LEVEL=3", "!TRACE"] # compiles with -DLOG_LEVEL=3 and no TRACEA defines entry also replaces a -DNAME word for the same name written in
cflags or cxxflags of the same package. !NAME requires mcpp 2026.9.25.1 or
later; an older mcpp passes it to the compiler as -D!NAME, which is an error.
Every member receives the inherited values exactly once, in every position
(2026.9.25.1+): as the package a command builds (-p <member>, or a command run
inside the member), as another member's path dependency, and as a member of a
git-hosted workspace consumed through git (§6). A member reached as a dependency
also resolves its own x.workspace = true entries.
version may be omitted by a member when [workspace.package] supplies it.
It remains required overall — a member with neither is refused, naming both the
member and the workspace key that would have supplied it.
Not everything is inheritable. [workspace.build] allow_host_libs is
refused. It disables the hermetic-link check for a specific artifact, and a
workspace root able to set it once would disable that check for members added
later by someone who never read the root manifest. Keys that describe how to
build are inheritable; keys that describe which safety check not to run stay
with the package whose artifact it is. Any other unknown key in
[workspace.package] / [workspace.build] is refused too, rather than ignored:
a key that is silently dropped from a table whose whole purpose is propagation
produces a workspace that looks configured and is not.
There is no [workspace.target.<triple>]. A plain [target.<triple>] block
in the workspace root is already inherited by every member, per triple, with the
member winning. A second spelling for the same capability would be surface with
no function.
A C++ module graph has exactly one standard: BMIs are not compatible across
levels, so the root package's standard is applied to every package in the
graph, including dependencies. A dependency's own standard is not applied.
One kind of package is the exception. A package that provides the C++ layer
(the standard library itself) and states standard compiles each of its
translation units that neither provides nor imports a module at exactly that
level; its module units stay at the graph's level
(22 — Target Side, "The Standard Library's Own Language
Level"). No BMI crosses those units, so the rule above is not broken, and it is
what lets a c++20 project use a standard library whose sources are written for
C++23.
When a dependency declares a level higher than the graph is built at, mcpp reports it before compiling:
warning: dependency `render` declares standard = "c++26", and this graph is
built at c++23
impact: a C++ module graph has one standard, so the dependency's declaration
is not applied and its sources are compiled at the graph's level
hint: raise the consumer's standard to "c++26", or declare it once for
every member:
[workspace.package]
standard = "c++26"
This is a warning rather than an error — such a build usually succeeds, and it
is promoted to an error by --strict. A C++-layer provider whose statement is
applied as described above is not reported. It is reported only for manifests the
project author controls (the root package, workspace members, and path
dependencies): a package resolved from an index carries a standard written by
a descriptor generator rather than by the person reading the message.
mcpp build # virtual workspace → builds ALL members; rooted → the root package
mcpp build -p server # build a specific member and its dependencies
mcpp build --workspace # build every member explicitly
mcpp test # virtual workspace → tests ALL members; rooted → the root package
mcpp test -p core # test a single member
mcpp test --workspace # test every member (one report per member; continues past failures)At a virtual workspace root (only [workspace], no [package]), bare
mcpp build / mcpp test act on all members. At a rooted workspace
([package] + [workspace]), they act on the root package; --workspace
acts on the root package and every member. mcpp test --workspace builds + runs each member's
tests/**/*.cpp independently — discovery is scoped per member, so two members may
each have a tests/main.cpp without colliding.
cd libs/http
mcpp build # auto-detects the workspace and builds the current membermcpp searches upward from the current directory; if it finds an mcpp.toml containing [workspace] and the current directory is listed in members, it automatically enters workspace mode and inherits the workspace configuration. The command then acts as mcpp build -p <this member> at the workspace root: it builds in the workspace's build directory (§6).
-p works with build, test, run, and other commands to select the target
member. Its value is resolved in one order, because the option names a
package:
- a member's qualified name,
<namespace>.<name>(only meaningful for a member that declares a namespace); - otherwise, a member's bare
package.name— refused, naming every match, if two or more members share it; - otherwise, a member's path as written in
[workspace] members, or its directory's last segment (the historical spellings, kept as a fallback).
mcpp build -p server # matches apps/server (by directory or package name)
mcpp test -p core # matches libs/core
mcpp run -p server -- --port 8080A value that is one member's package name and a different member's directory selects the member named by the package, with a warning naming the other one — the option promises a package, so an exact package-name match outranks a directory that merely happens to share the spelling.
--workspace (on build and test) is the fan-out form: it acts on every
member. mcpp test --workspace reports each member separately and continues past a
failing member, exiting non-zero if any member failed — ideal as a single,
shell-free CI step for a workspace that tests many libraries.
Workspace testing member 'libs/core' (3/97)
test_paths ... ok (0.31s)
test result ok. 7 passed; 0 failed; finished in 9.50s (build 8.90s + run 0.60s)
Workspace member 'libs/core' (3/97) ok — 7 passed in 9.50s
...
workspace result ok. 97 member(s); 412 passed; 0 failed; finished in 355.20s
slowest: libs/jsc 93.5s, libs/install 32.2s, libs/http 24.1s
M/N progress, per-test durations, and a per-member time split into build vs
run. The split is the useful part: a member whose tests take milliseconds but
whose link takes 90 seconds looks identical to a slow test suite in a single merged
number, and only one of those is worth investigating.
--message-format json carries the same data as NDJSON. Every test record is
member-qualified ("member"), and the stream ends with a workspace_summary
record naming the failed and not-run members — a bare test name is ambiguous the
moment two members both have a smoke.
mcpp test --workspace --timeout 60 # per-test RUN deadline (default 300)
mcpp test --workspace --build-timeout 300 # per-ninja-drive deadline (default 0 = no limit)
mcpp test --workspace --workspace-timeout 1800 # whole fan-out (default 0 = no limit)The fan-out is serial, so an unbounded member stalls every member after it. All
three deadlines report rather than abort: a timed-out test fails that test and the
fan-out continues; a timed-out build fails that member; --workspace-timeout stops
the fan-out and lists what did not run instead of leaving the CI job to kill the
process (which discards everything it had to say).
A command on a workspace plans its members together: the selected members and
everything they depend on are one build graph, with one build.ninja, and a
member that several members use is compiled once.
- Configurations. Members are built in one graph when they share their
toolchain request, target, C++ standard,
dialect_cxxflags, C++ runtime,linkage, profile, indices and the other[build]values that apply to a whole graph. Members that differ in one of them are built in separate graphs, at the same time, sharing the command's jobs. A relative path a member writes, such as its own[indices]path, is read from the member's directory. - Selection.
--workspace, and a virtual root without-p, select every member.-p X, and a command run in X's directory, plan X and what X reaches. The two share the build directory, somcpp build --workspacefollowed bymcpp build -p Xcompiles nothing, and a package is compiled again only when its active features differ between the two commands. - Flags. A member's
cflags,cxxflags,ldflagsand defines apply to that member's commands. Editing them recompiles that member and what imports it; the build directory stays the same. - Features.
--features factivatesfin each selected member that declares it, and is refused when no selected member declares it. A package that several members of one configuration use is compiled once, with the union of the features they ask for; a package that members of two configurations use is compiled once in each. - The root's declarations. Each selected member declares as the root did
when it was planned alone: its
pathorgitoverride of a dependency wins over another package's declaration,linkageon its dependency edges is honoured, and its registry dependencies are considered for the index refresh (2026.9.30.2+). Two selected members that disagree about one dependency's checkout (its kind or its reference) or its link form are refused, naming both. See 05 — When two declarations of one dependency disagree. - Hooks. The
[hooks]of every selected member run around the build, in member order. - Resources. A member's
[resources]andwindows_code_pageare compiled against the member's directory and include directories and embedded into that member's programs and shared libraries only (2026.9.29.2+). - Build programs. The members' build programs run dependencies first, and a program's result is reused by every command whose inputs to it are unchanged, whichever members the command selects (2026.9.29.5+).
- Compile database.
mcpp build --configure-onlyandmcpp emit build-databaseplan as the build does, one plan per configuration with each member's tests, so a package the members share is described once per configuration. A command that planned several configurations writes the rootcompile_commands.jsononce, as the union of their databases (2026.9.29.5+). - No-op builds. A command repeated with nothing changed is answered by one check per configuration, without planning.
- Module names. A module name is unique within one program, not within one
graph (2026.9.30.2+). Two members that share no program may each provide a
module of the same name, and one
--workspacecommand builds both; a member that links both is refused. See 05 — One module per name in each program.
The recommended directory layout for a workspace:
myproject/
├── mcpp.toml # [workspace] declaration
├── libs/
│ ├── core/
│ │ ├── mcpp.toml # [package] namespace="myproject" name="core"
│ │ └── src/
│ │ └── core.cppm # export module myproject.core;
│ └── http/
│ ├── mcpp.toml
│ └── src/
│ └── http.cppm # export module myproject.http;
└── apps/
└── server/
├── mcpp.toml
└── src/
└── main.cpp # import myproject.http;
A workspace builds at its root (2026.9.29.1+):
myproject/
├── mcpp.lock # one lock for the workspace
└── target/<triple>/<configuration>/
├── build.ninja, compile_commands.json # one graph and one database per configuration
├── obj/<package>/ # intermediate objects of every package
└── bin/
├── server/ # a member's products: bin/<package name>/
│ ├── server
│ └── libfoo.so # shared libraries and runtime files it loads
└── ...
- A member's programs and shared libraries are in its product directory,
bin/<package name>/, with the shared libraries, DLLs and deployed files its programs load placed beside them. Two members with the same package name usebin/<namespace>.<name>/. A rooted workspace's own package keepsbin/. - A shared library placed in several product directories is one file with several names where the file system supports hard links; elsewhere it is copied.
compile_commands.jsonat the workspace root covers every member that has been built or configured.mcpp.lockat the workspace root records the resolution of every member.mcpp build --workspacewrites the whole record;mcpp build -p Xupdates the entries of X's graph.- A member's build program writes to
<member>/target/.build-mcpp/. - Build directories a member held under its own
target/with an earlier mcpp are not read;mcpp clean --staleremoves them.
A project outside the workspace reaches a member of a git-hosted workspace by
the member's identity: myproject.http = { git = "...", rev = "..." } selects
libs/http among the root manifest's members, at the same commit, and the
member inherits [workspace.package] as it does here (mcpp 2026.9.16.1+; see
05 — Dependencies). It also inherits its repository's
[workspace.build] and resolves its x.workspace = true entries against that
repository's [workspace.dependencies] (2026.9.25.1+), so the same commit
compiles the same way in its own checkout and in a consumer's graph. A member
that an index descriptor points at inside a tag tarball receives the tarball's
workspace in the same way. Publishing a member with mcpp publish writes the
inherited values into the published manifest
(11 — Publishing a Library).
Workspaces work in concert with the C++23 module mechanism:
- Interface visibility is controlled by the language —
export moduleandimportstatements determine a module's public interface; the workspace imposes no additional visibility restrictions. - Module names are chosen by the library author — the workspace does not require module names to match the package name or namespace.
- Partitions are for internal organization — a partition imported via
import :internal;(withoutexport) is invisible to consumers, with no build-tool involvement required.
See examples/04-workspace/ for a complete, runnable example of a three-member workspace.