Archive
Where retired entries go.
docs/release-runbook.md says never to delete a feature entry: either strike
its heading through and set status: retired, or move the whole entry here and
leave a one-line stub in the feature list pointing at it. Heavy refactors move
the old text here too.
This file is append-only and unordered. It is not a changelog and nothing reads it in sequence; it exists so that a question about why something was removed has an answer that is still in the tree, rather than only in a squashed commit message.
An entry keeps the key block it had when it was live, plus the reason it was retired and what replaced it.
It is empty because nothing has been retired yet. The runbook named it before
it existed, which is the documentation form of a capability declared and not
delivered; TestEveryDocumentationLinkResolves in internal/defaults is what
noticed, and what will notice the next one.
The binary
Binary
mcpx ships as one Go binary, built from cmd/mcpx. This page
states what is shipped — what the binary contains, what it needs
next to it, and how the two packaged forms are built — not what
could be built. Where a fact changes per release channel, this
page says so and stops; release and install channels are a
different page’s subject.
What is in it
The version is 0.1.0, hard-coded as the default in
cmd/mcpx/main.go and stamped by the package with
-X main.version=0.1.0; a build may override it with the same
-ldflags variable, and nothing else about the binary changes.
Everything else is the module’s direct dependencies. go.mod is
the source of truth — seven direct requirements:
| module | what it is for |
|---|---|
modernc.org/sqlite | the log index: a pure-Go translation of SQLite, not a cgo binding |
github.com/charmbracelet/bubbletea | the full-screen browser, mcpx tui |
github.com/charmbracelet/bubbles | its widgets |
github.com/charmbracelet/lipgloss | its layout |
gopkg.in/yaml.v3 | OpenAPI documents, which are published as YAML as often as JSON |
github.com/santhosh-tekuri/jsonschema/v6 | JSON Schema validation (internal/schemaval) |
github.com/tetratelabs/wazero | the WebAssembly runtime behind wasm tool sources and embeddings (internal/wasmtools) |
The three charmbracelet modules are one decision: about 1.2 MB in
the stripped binary, in exchange for not hand-writing terminal
handling. dependencies.md argues each of the
original five in full; it and package.nix still say “five”
because both predate the last two rows — trust go.mod, and this
table, over that count.
Two absences are as deliberate as the presences:
- No MCP SDK. mcpx is built on none — there is no MCP module
in
go.mod. The MCP client (internal/mcpclient), the server (internal/mcpserver) and the JSON-RPC framing are mcpx’s own code, about a package each. That is most of why the dependency list stays this short. - Everything else is the standard library: the daemon and its HTTP API, the process pools, the JSON Schema to TypeScript compiler, the CLI.
How the shipped builds link
Every direct dependency is pure Go, so a CGO_ENABLED=0 build is
fully static, and that is what the Nix package ships:
env.CGO_ENABLED = "0" in package.nix, stripped (-s -w), so
the shipped binary is a static executable that needs no libc next
to it.
The container image makes the other choice, on purpose for
now: its Containerfile defaults CGO_ENABLED to 1, so the
binary it ships links glibc and the runtime stage is Debian
bookworm-slim. A static container build was measured working
end-to-end before that default was chosen — see
container.md, which this page links rather than
restates, because the container is still changing.
The behavioural difference between the two is narrow and worth
stating exactly: with cgo off, pure-Go os/user reads
/etc/passwd rather than asking NSS/LDAP, and the pure-Go
resolver bypasses NSS modules — on Linux only; on macOS,
CGO_ENABLED=0 changes neither, because Go uses the system
resolver and user database there without cgo either way. Neither
difference prevents a static build, and this page never claims
they do; which form to ship where is a policy question, tracked
with the release work, not a technical block.
Size, as last measured in container.md: the mcpx binary is about 14.8 MB in the container build. (The 21 MB figure sometimes quoted is an older linux/arm64 static build; measure the artifact you are shipping, not either number here.)
What it needs next to it
The binary alone answers ls, types, catalog, search and
call. The rest of what it reaches for is external programs —
the full table, with what degrades when each is missing, is in
dependencies.md:
- A JavaScript runtime — deno, bun or node — for
mcpx runandmcpx execonly. Without one, those two commands fail and everything else still works. git— a fallback only. mcpx reads.gititself for therepoandworktreescopes (see git-discovery.md) and runsgit rev-parseonly for a repository layout its own reader does not vouch for. Without git, those rare layouts degrade to keying by directory, andmcpx doctorsays why.- The configured MCP servers themselves — spawned with the
command,args,envandcwdthe config names, or reached over Streamable HTTP forurlservers.
The Nix package wraps the binary with a PATH suffix naming
git, deno, bun-bin and nodejs, so those four are findable
without depending on the calling shell — and because it is a
suffix, a runtime already on your PATH wins over the wrapped
one. The same package installs the generated man page and shell
completions (see the CLI page).
The container image ships exactly one runtime — bun — and sets
MCPX_SCRIPT_RUNTIME=bun, so the runtime choice inside the image
is unambiguous; it leaves git out entirely, for the reason above.
What the image deliberately does not contain, and why, is
container.md’s subject.
The CLI
The mcpx command line, in three parts: how a session with it
normally goes, the two kinds of flag it accepts, and where the full
command reference lives. The CLI is one of the
client interfaces — everything
it does goes through the daemon’s /v1 API, and anything a person
can do here, a script or a plugin can do there.
The CLI
The loop
Almost every session is the same four steps (the same loop
AGENTS.md teaches agents):
mcpx ls— which namespaces exist. Cheap; run it whenever.mcpx search <words>— find a tool by name or description.mcpx types <namespace>— load signatures for that namespace only (ormcpx types <namespace>.<tool>for one tool, which is far cheaper).mcpx catalogfits every namespace into a token budget when you are still deciding.mcpx run <name>ormcpx exec '<code>'— do the work.
Do not run mcpx types for every namespace: loading one costs a
few hundred tokens, loading all of them costs thousands and
defeats the point of keeping schemas out of context until asked.
Output a program can read
Human output is for humans. Anything that has to be parsed gets
--json instead:
mcpx --json ls
mcpx --json run screenshot
--json returns one document — for a script run, the result, the
logs, the exit code and the duration. Never parse the human tables.
Bare invocation, and a runnable first argument
Bare mcpx opens the TUI (the tui browser) when there is a
terminal to draw on, and prints help when there is not — a pipe
gets the old behaviour.
A first argument that is obviously a script runs without naming a
command: a path ending in .ts, .js, .mts or .mjs, any
existing file, or text that is obviously source (it contains
(){};, await , console., tools. or =>) is handed to
run or exec as appropriate:
mcpx ./report.ts
mcpx 'await chrome_devtools.list_pages({})'
This is deliberately conservative: a bare word that is not a file
fails as an unknown command, because guessing that mcpx serach
was a snippet would replace a clear error with a baffling one.
Exit codes worth knowing
| code | meaning |
|---|---|
| 0 | success |
| 1 | the command failed; the error is on stderr |
| 2 | an unknown command, an unknown global flag, or a settings conflict — including a contradiction in a config file, which is reported once, before dispatch |
| 75 | EX_TEMPFAIL: a call stopped to ask a question (an elicitation). Not a failure, and not a cue to retry: the call continues as a task — answer with mcpx elicit answer …, then collect the original call’s result with mcpx task result <id>. Retrying would start a second call |
The man page and shell completions
Both are generated from the same command declarations as everything else, so they cannot drift from the binary:
mcpx man
mcpx completion bash # also: zsh, fish
The Nix package installs both for you: the man page to
share/man/man1/mcpx.1, and completions for all three shells.
CLI arguments
Flags come in two kinds, and most confusion about mcpx flags is really about which kind one is looking at.
Kind 1: hand-parsed global flags, before the subcommand
A short list, parsed by hand in cmd/mcpx/main.go before any
subcommand is chosen. These, and only these, have fixed global
meanings:
| flag | what it does |
|---|---|
--json | one machine-readable document instead of human output |
--tui | open the TUI browser (also a subcommand) |
--profile <a,b> | add profiles to the active set |
--skip-default | narrow the active set to exactly the named profiles |
--all-profiles | activate every profile |
--config <file> | use this config file alone |
--version, -v | print the version and exit |
--help, -h | print help and exit |
One extension to that list: a setting that every command
accepts is also accepted as a global flag, so
mcpx --plumbing-strict-unknown-keys ls means what
mcpx ls --plumbing-strict-unknown-keys does. An unrecognised
global flag is an error (exit 2), never a guess.
Kind 2: registry flags, bound per command
Every other flag is generated from the settings registry and
bound per command: parseFlags calls BindFlags for the
command being run, which binds exactly the settings that command
reads (internal/cli/root.go, internal/settings/bind.go). A
command accepts the settings it reads — no more, no less — so the
flag list of mcpx run and the flag list of mcpx ls are
different lists, on purpose. Flags for internals are marked
[plumbing] in help rather than hidden.
Three consequences worth knowing:
- The full inventory is generated, not listed here. Every
setting, its flag, its environment variable, its default and its
scope are in configuration.md, which a
test keeps identical to the registry; the environment names are
in environment.md.
mcpx help <command>shows one command’s own flags;mcpx settingsshows the resolved values and where each came from. - Aliases are spellings of one setting. A setting may publish
shorter aliases (
--runtimefor--script-runtime). Giving two spellings of the same setting on one command line is an error that names both — they are the same setting, and mcpx will not guess which value you meant. - Flag names are derived, not chosen per setting. The flag is
derived mechanically from the setting’s dotted path
(
daemon.leaseTTLbecomes--daemon-lease-ttl), so the spelling in configuration.md is authoritative; do not guess a spelling the table does not show.
Where a value can come from at all — built-in default, config files, environment, presets, flags — and which source wins, is the precedence chain in configuration.md (“Where a value can come from”). This page only adds: a contradiction between sources that the chain cannot resolve is reported once, up front, with exit code 2, rather than by whichever command happens to read the value first.
CLI commands
The command table is generated, and this page does not copy
it. Every command is declared once in Commands()
(internal/cli/manual.go) — name, aliases, group, usage, detail,
examples — and from that declaration come mcpx help, the man
page, the shell completions, and the generated CLI commands
table in parity.md, which lists each command
beside the operation it reaches. A test
(TestEveryDispatchedCommandIsDeclared) holds the declaration
and the binary’s dispatch table equal, in both directions: no
dispatched command is missing from the list, and nothing on the
list fails to run. That parity is recent — serve, api,
registry, adapter, openapi, man and completion were
once dispatched but undeclared, and therefore invisible to help,
the man page and completions; the test exists so that cannot
happen silently again.
What prose can usefully add over the generated table is the
grouping mcpx help presents, and what each group is for:
| group | what is in it |
|---|---|
| running | doing the work: run, exec, scripts, skills, recipes, prompt, proxy, embeddings, feedback |
| discovery | finding out what exists before paying for its schemas: ls, catalog, types, search, call, batch, client, schema, prompts, resources, openapi |
| daemon | the daemon’s own lifecycle: status, daemons, refresh, reload, upgrade, restart, stop, supervise, daemon, serve |
| configuration | shaping mcpx itself: config, settings, servers, secrets, init, registry, api, adapter, plus help, man and completion |
| inspection | looking at what happened: diagnose, elicit, doctor, tui, explore, dashboard, interactions, log, stats |
| operations | one generated command per /v1 operation (for example mcpx artifact list) — these exist the moment an operation is declared, with flags from its parameters |
For any one command, mcpx help <command> is the reference: it
is printed from the same declaration the table is generated
from, so the two cannot disagree.
The compendium: structure and mapping
created: 2026-10-29T19:00:00-05:00
last-updated: 2026-10-29T19:00:00-05:00
increment: 1
status: standard
tags: area:docs, area:compendium
description: what the compendium is, the chapters in order, which file is the
source of each, and the rule that keeps a file in exactly one.
The compendium is level 3 of the book: the encyclopedia. Level 1 is the overview, level 2 is the guides, and level 3 is where “basically everything that is built can be described here in as much detail as you want”. It is deliberately not limited to mcpx: note 88 asks it to catalogue MCP servers, resources, indexes, URLs and projects as well (chapter 7).
This page is the structure. The per-file mapping is
compendium.manifest.json, next to it, and a test
keeps the two in step (see Keeping this true).
Approach: the docs are the book source
Approach C, as recommended on #1867 and not reopened here:
- Source root
pkgs/mcpx/docs. Files render where they already are. - Navigation is generated from
compendium.manifest.json, not written by hand and not kept in a second tree. README.mdstays a symlink todocs/story.md(git mode 120000), and the<!-- table-of-contents-marker -->stays where it is. The runbook’s checktest -L README.md && readlink README.mdkeeps passing.- One home per document. Other levels link to a chapter or excerpt it; they do not copy it. That rule is what makes a level-3 page safe to be as long as it needs to be.
Approaches A (move the files under a book directory) and B (one stub page per
document) were rejected on #1867 for the reasons recorded there: A breaks every
relative link, internal/settings/docs_test.go, and the symlink; B needs a stub
per document plus a check that the stubs have not drifted, and breaks links a
different way.
Chapters, in order
A chapter’s source is a file or a directory. When it is a directory, its
README.md is the chapter index and the rest of the directory is the chapter’s
pages; a generated navigation therefore has to accept a directory, which is the
concrete requirement the three in-flight directories from the 2026-09-30
inventory turned into.
| # | Chapter | Source | Holds |
|---|---|---|---|
| 1 | Features | docs/story.md, below <!-- table-of-contents-marker --> | the feature list: one page per key-block entry or area: tag, split at build time at section IDs (#151). The walk above the marker is the level-1 overview (#157), not a chapter |
| 2 | Designs | docs/elicitation.md, exec.md, consumer.md, opencode-plugin.md, plugin-discovery.md, environment.md, runtimes.md, schema-validation.md | the “why” documents: a design kept as the record of a decision, at full depth |
| 3 | Decision records | docs/decisions/ (directory) + declared-but-unread.md, landing.md, git-discovery.md | one record per collision, following the template in decisions/README.md. The two files at docs/ root record a decision each and say so in their own front matter; the chapter index links them |
| 4 | Protocol | docs/protocol.md + docs/spec/ (directory) | protocol.md is the design and the entry point; spec/README.md is the chapter index for the records under it: what each revision asks of each side, what mcpx sends, and how it is measured |
| 5 | Build and packaging | docs/container.md, dependencies.md | what the binary and the image are made of, and what they deliberately are not |
| 6 | Proposed | docs/proposals.md | open designs, badged proposed. A reader deciding whether mcpx will do something next starts here |
| 7 | Catalogue | docs/catalogue/ (directory) | things outside mcpx: MCP servers, resources, indexes and registries, URLs, projects. Structure below; the entries are seeded by the follow-up task |
| 8 | Comparisons and history | docs/compare/ (directory) + ASSESSMENT.md, OPENCODE-V2.md | the living comparison and the historical documents, kept as history with their text intact (#153) |
| 9 | Research and findings | docs/audit/ (directory) + in-name-only.md, work-strategies.md, research-and-findings.md | audits and lessons: what was found, not what was designed. The research chapter (#1871) has joined; its Nix section is still a later slice |
| 10 | Maintaining these docs | docs/release-runbook.md, compendium.md | how story.md stays true, the key-block rules, and this structure |
| 11 | Document history | docs/archive.md | retired entries and refactored-out text, append-only and unordered (#150) |
The migration mapping
Every one of the 96 markdown files under pkgs/mcpx at origin/main is in the
manifest, with a chapter or an explicit reason not to have one. The 2026-09-30
inventory on #1867 covered 20-odd of them; the seven it never saw now land as:
| file | lands in |
|---|---|
docs/archive.md | 11 Document history |
docs/environment.md | 2 Designs (what mcpx tells a program it starts, #172) |
docs/git-discovery.md | 3 Decision records (it calls itself a decision record, and says why it is not in decisions/) |
docs/in-name-only.md | 9 Research and findings (the curated list of what only exists on paper) |
docs/runtimes.md | 2 Designs |
docs/schema-validation.md | 2 Designs |
docs/work-strategies.md | 9 Research and findings (lessons learned) |
docs/research-and-findings.md | 9 Research and findings (the #1871 chapter: findings by topic, provenance-tagged) |
docs/audit/ (3 files) | 9 Research and findings |
docs/compare/ (18 files) | 8 Comparisons and history |
docs/decisions/ (5 files) | 3 Decision records |
docs/spec/ (14 files) | 4 Protocol |
Explicitly not compendium, and why
These are in the manifest with role: not-compendium, so the mapping is
complete rather than merely plausible:
| file(s) | why not |
|---|---|
docs/ideas.md | raw unvetted intake (“some of it is probably wrong”). The proposed chapter publishes the index-of-issues version instead; see open question 3 |
plugin/opencode/skills/** (12 files) | skill text shipped inside the plugin and rendered as level-2 pages (#148). Restating it in the compendium would make two sources for one text |
daemon/README.md | an operational how-to for the systemd user unit: level 2 |
internal/mcpspec/schema/README.md | a provenance note for vendored schemas; it documents a tree, not a design |
TODO.md | a scratch list |
Four more are role: included: AGENTS.md (the agent instruction file, linked
and included but chapterless, per #1867), docs/configuration.md and
docs/parity.md (reference, #144, and level 2, #146 — the compendium links to
them; internal/settings/docs_test.go reads configuration.md), and
plugin/opencode/README.md (install, level 2, agent twin #120).
README.md is in the manifest as a symlink entry rather than a source, so the
generator has an explicit instruction about it instead of a rule it has to
remember.
The catalogue chapter
Structure only in this change; the entries are a follow-up so that this page can be reviewed as a structure rather than as a directory listing.
- Source
docs/catalogue/, with a chapterREADME.mdand one page per section:servers.md,registries.md,resources.md,urls.md,projects.md. - Generated or written: C. A hand-written list says which servers,
registries, resources, URLs and projects are in the catalogue and why; the
facts per server (
mcpx --json lstool counts,mcpx typestool lists,mcpx --json registry searchpublisher and version) are captured into a committed snapshot by a refresh script. Nothing at docs build time starts a server or calls the network, because CI must not have to start seven MCP servers to render a page. - First entries are the seven servers configured on this machine —
codebase-memory,codedb,fff,fff-nix,fff-worktree,chrome-devtools(thesession-mode case) andcontext7— plus the official registryregistry.modelcontextprotocol.io, the recorded rejection (HAPI), and the projects list from #1867. Scope beyond that machine is open question 5.
Approach A (hand-written entries only) and B (mirror mcpx registry search)
stay rejected for the reasons on #1867; C is recorded here so the follow-up does
not have to re-argue it.
Open questions on #1867, answered
- Split
story.mdper entry, or one long page? Split, at build time, at section IDs (#151). One page with good anchors is readable, but the feature list is also what level-2 pages excerpt (#146-#148) and what the catalogue and the decision records link to by name; a stable page per entry gives those links a target that survives an edit elsewhere in the list. The split pages are generated and not committed, sostory.mdand the symlinkedREADME.mdstay one file on GitHub. - Move
ASSESSMENT.mdandOPENCODE-V2.mdintodocs/, or include them from the root? Move them intodocs/. Both are chapter sources for chapter 8, both are linked by relative path fromstory.mdandproposals.md, and a chapter whose sources live in two trees needs every link rewritten twice.AGENTS.mdstays at the root: it is the agent instruction file and the name is the convention. ideas.md: unpublished, raw, or an index of issues? Publish the index-of-issues version in the proposed chapter; keepideas.mditself as raw intake and do not publish it. It says of itself that it anticipates becoming “an index of links” once issues exist, and they do.- Which “resources” and which “indexes” does note 88 mean? All of them, and
the chapter separates them instead of choosing:
resources.mdcovers MCP resources (the protocol feature, and what the configured servers actually expose) and reading material as its own section, whileregistries.mdcovers registries and the code indexes the configured servers maintain (fff,codedb,codebase-memory). A reader looking for either arrives at the same page. - Catalogue scope: this machine’s servers, or also the ones mcpx was tested
against? This machine’s seven first, because each has a recorded reason to
be configured. Servers mcpx was merely tested against (the Swagger petstore
in
OPENCODE-V2.md,io.github.microsoft/playwright-mcpinstory.md) go in a clearly marked second section, since the reason they are listed is different: evidence, not configuration. - Do stale facts in living documents get fixed before the compendium
publishes them? Yes, as a separate small fix, not inside this structure
change:
dependencies.md(“One.”), thestory.md“Nix packaging” entry (vendorHash = null, “one third-party Go dependency”), and the missingnix/darwin-module.nixthatASSESSMENT.mdpoints at. Publishing them is what turns a known staleness into a citable error. Tracked as its own follow-up, and deferred to #1907 if it is not picked up first.
Constraint this structure keeps
The one test that pins a doc path is internal/settings/docs_test.go, which
reads ../../docs/configuration.md. Nothing here moves a file, so it is
untouched. No relative link changes either: this change adds two files under
docs/ and edits nothing else, which is also why the story.md links written
relative to docs/ (../AGENTS.md, ../ASSESSMENT.md, ../OPENCODE-V2.md,
./release-runbook.md, ../scripts/stress.sh) keep resolving. When open
question 2 is implemented and the two root documents move into docs/, those
links change in the same pull request.
Keeping this true
internal/settings/compendium_test.go walks every markdown file under
pkgs/mcpx and fails when:
- a file is not in the manifest at all (a new document must be placed, and the failure names it);
- a manifest path does not exist (a moved or deleted document leaves a chapter pointing at nothing);
- an entry’s
chapteris not one of the chapters listed here; - a
chapter-sourceentry has no chapter, or anot-compendiumentry has one without saying why.
That is the whole mechanism: adding a document to pkgs/mcpx is not finished
until it has a chapter, or a reason for not having one.
Related: #1867 (this structure), #1907 (level-3 tracker), #117 (generator source root), #150/#151 (section IDs and split), #153 (chapters 8), #154/#1871 (chapter 9), #157 (level-1 overview), #144/#146-#148 (reference and level 2), #120 (agent twin), #24 (elicitation as a design).
Configuration
Every number in mcpx is a setting, including the ones nobody will ever change.
That sounds like an overreaction until you have hit the alternative. A constant written next to the code that needs it is fine right up to the day somebody’s proxy kills an idle event stream at thirty seconds, or their sandbox refuses to exec the running binary, or a tool legitimately takes a five-megabyte argument. At that point the only remedies are a patched binary or a fork, and both are absurd outcomes for a number that was chosen in five seconds and never revisited. Declaring it costs one line and removes that outcome entirely.
So the rule is: nothing is hardcoded. A value lives in
internal/defaults/defaults.json as data, and – wherever a user could
conceivably want it different – it is declared once in the settings registry,
from which its configuration key, its environment variable, its command-line
flag, its /v1 representation and its MCP tool are all derived. There is no
second place to update, which is the point: the arrangement this replaced had
four, and they drifted.
The handful of exceptions are permission bits. 0700 on the state directory
and 0600 on the socket are not preferences. The socket grants the power to
run tools as you; a configuration key that widens it would be a footgun with
no legitimate use. They are in defaults.json as data so there is still one
place to read them, and they are deliberately absent from the registry.
A guard test enforces this. It matches inline durations in both spellings gofmt produces, byte sizes written as shifts, bounds assigned to variables whose names say they are bounds, and permission literals, with an allowlist that carries a reason for each exemption. The version it replaced matched fixed strings and missed every duration gofmt had compacted into a function argument – nine of them, including the daemon’s shutdown grace and the event stream’s keepalive.
Where a value can come from
Lowest to highest:
- default –
internal/defaults/defaults.json, embedded in the binary. - file – every configuration file on the search path, ranked by
distance, nearest highest.
mcpx config --sourceslists them. - env –
MCPX_*. Every setting has one, derived from its path, whether or not anybody uses it. - flag – the command line.
- runtime – an override set through
PUT /v1/settings/{path}ormcpx settings set. Highest because it is the most recent statement of intent: somebody changed their mind while the thing was running, and having a flag from ten minutes ago win would be inexplicable.
mcpx settings get <path> prints the winning value, where it came from, and
what it displaced. “Why is this not what my config says” is the most common
configuration question there is, and the answer is always in that list.
The search path itself is decided before any of those layers exist, in two
passes. The discovery pass walks the default locations, seeded by
MCPX_PATHS_CONFIG / --paths-config, and reads only paths.config. The
load pass resolves that list and loads everything on it, nearest last: a
file’s paths.config governs the rest of the chain from that point, a null
entry stands for the locations the default walk provided, and a list with
no null replaces the farther remainder. A paths.config found only in the
load pass is reported by mcpx config --sources, not applied, and a cycle
is refused naming the path that closed it. MCPX_CONFIG bypasses both
passes and loads exactly one file.
Two spellings of one setting at the same level is an error rather than a race:
--log-level and --logging-level together, or two variables that mean the
same thing with different values. The environment has no order, so there is
genuinely nothing to prefer, and picking one silently is how a configuration
becomes unexplainable.
Scope: who reads it
Every setting says which process acts on it. This exists because of a failure with no symptom.
A daemon is started once, in whatever environment it happened to have, and then serves every command for hours. A flag on one of those commands that governs something the daemon does was simply lost: accepted, validated, printed back, and then ignored, because the process that would act on it had read its configuration an hour earlier. Nothing anywhere said so.
- daemon – the pooled servers, the listener, the log the daemon writes.
A client changes one by persisting it and restarting, or through
mcpx settings setif the setting is hot. - client – the CLI process itself: how it renders, how it finds a daemon, whether it may start one. Setting it on the daemon does nothing.
- call – meaningful per request. The CLI sends its effective value with
every request in an
X-Mcpx-Settingsheader, and the daemon honours it for that request only. This is what makesmcpx search --limit 3work against a daemon that was started without it. Only call-scoped settings are honoured from that header: widening it would make a header a way for any client to reconfigure a shared daemon. - plugin – read by the opencode plugin, which is neither. The plugin
reads
process.envdirectly, so the variable names are the contract between the two and a test pins them.
Hot: does it take effect now
A hot setting is read afresh every time it is used, so changing it at
runtime works. A cold one was consumed once at startup – a listener address,
a log file that is already open – and recording a change to it would do
nothing. PUT /v1/settings/{path} says so rather than accepting a value that
would be silently inert.
Reaching them
| surface | read | change |
|---|---|---|
| config file | any mcpx config on the search path | edit it |
| environment | MCPX_* | export it |
| CLI | mcpx settings list, mcpx settings get <path> | mcpx settings set <path> <value> [--persist runtime|project|user], mcpx settings unset <path> |
/v1 | GET /v1/settings, GET /v1/settings/{path} | PUT /v1/settings/{path}, DELETE /v1/settings/{path} |
| MCP | mcpx_settings_list, mcpx_settings_get | mcpx_settings_set, mcpx_settings_unset |
mcpx settings list resolves locally, which is what the client will use.
--daemon asks the daemon what it resolved, which is what a daemon-scoped
setting actually is. The two differing is information, not a bug: it is
exactly the case that used to be invisible.
--persist runtime changes the running daemon and nothing on disk, which is
what an experiment wants. project and user write the corresponding
configuration file and apply at once where the setting allows it – a change
written to a file but not applied would leave mcpx settings get answering
with the old value, which reads as the write having failed.
Local-model controls by surface
These are independent controls, not one fleet-wide Qwen switch. In particular, parking hosted chat-model listings does not disable embeddings or an interpreter. The checked-in state below is from this repository’s main configuration, not a guarantee about a running process: nearer files, environment, flags and runtime overrides can change it. See #2218 for the remaining single-switch work.
| Surface | Control and checked-in state | What it does not control |
|---|---|---|
| mcpx catalog embeddings | embeddings.backend defaults to auto; .mcpx.json sets no embeddings block. With no URL or WASM path configured there, the cascade reaches the built-in pure-Go subword engine, not a Qwen model. local forces that engine. | Prompt generation, OpenCode messaging and provider listings. auto can use a configured remote or WASM model; it is not a global local-model off switch. |
| mcpx prompt generation | prompt.sample defaults to never; model.provider defaults to none. Neither is set in .mcpx.json. The file does contain a legacy repair block naming opencode and claude-code/claude-haiku-5-5, not Qwen. | Embeddings or arbitrary models chosen by a caller or external OpenCode. |
| OpenCode messaging embeddings | .opencode/opencode.jsonc sets messaging.embedding to "off". Removing that setting restores the OpenCode default, which starts its Qwen3-Embedding local server. | Chat provider listings or mcpx embeddings. |
| OpenCode chat provider listings | Qwen definitions for cline-pass and commandcode are parked as comments; other Qwen/Llama listings are under disabled providers or commented blocks. | Messaging embeddings. Restoring the parked blocks requires editing them: there is not yet a single switch restoring all listings. |
| OpenChamber route interpreter | The separate local route-interpreter stage is controlled in OpenChamber Settings; its Qwen2.5 model requires a download and was reported absent on the laptop in the #2218 audit. | OpenCode provider listings and mcpx. This is a separate cross-repository remainder, not inherited state. |
For catalog embeddings, auto tries the configured embeddings.url remote
endpoint, then a successfully loaded embeddings.wasmPath, then the subword
engine. remote and wasm each fall back to subword on failure; local uses
subword directly. A remote endpoint may itself be local (for example Ollama):
the backend name does not say where the model runs. embeddings.model names
the remote model, not a local download. These settings select an embedder; they
do not switch semantic search itself off.
For prompt generation, prompt.sample: never prevents an unmatched-recipe
request from asking a model by default; ask allows it. An explicit request
provider or model, or a non-none model.provider, also opts in even when
prompt.sample is never. model.provider selects the newer
model layer (none, auto, sampling, plugin, openai, anthropic, or
google), with model.baseURL and model.default for a configured endpoint
and model. An OpenAI-compatible local endpoint can serve Qwen, but it must be
configured separately. model.provider: none alone is not a universal veto:
the legacy scripting provider can resolve an explicit request, environment or
installed CLI when generation is allowed. The legacy repair.provider and
repair.model keys in the fleet file are not registered settings in this
version, so do not assume that block selects the effective provider. Per-request
mcpx prompt --provider / --model can also select a provider. None of these
controls constrain independent callers of external services.
Use mcpx settings get embeddings.backend --daemon, mcpx settings get prompt.sample --daemon, and mcpx settings get model.provider --daemon to
inspect the daemon’s resolved settings; check mcpx config --sources and the
daemon environment as well. Embedding backends are constructed when settings
are installed, so verify the running daemon after changing configuration rather
than assuming an existing index or embedder has switched. Model provider and
prompt sampling settings are daemon-scoped and cold.
Servers
Servers are configuration too, and until now adding one meant an editor and a restart.
| surface | list | add | remove |
|---|---|---|---|
| CLI | mcpx servers list | mcpx servers add <name> -- <cmd>..., mcpx registry add <n> --write | mcpx servers remove <name> |
/v1 | GET /v1/servers | POST /v1/servers | DELETE /v1/servers/{name} |
| MCP | mcpx_servers_list | mcpx_servers_add | mcpx_servers_remove |
The entry is written to a configuration file and the daemon reloads, so the server is callable immediately and is still there tomorrow. A server whose process definition did not change keeps its running child: adding one server must not restart the rest, or a stateful server – a browser holding a session – loses it because a neighbour appeared.
The daemon also re-reads its configuration files when they change on disk,
checked on the same tick that reaps idle instances (daemon.watchConfig). An
edit by hand therefore takes effect within daemon.reapInterval without
anything being restarted.
Making that work required changing what identifies a daemon. The socket used
to be keyed to the contents of the config files, so that editing one got a
fresh daemon rather than a stale one. A daemon that reloads changes the
contents of the files it is keyed by, which moves its own key, which makes it
unreachable by the client that just edited it – and hands the next command a
different daemon that happens to match the new key with old servers loaded.
That was observed: mcpx servers remove succeeded, the file was correct, and
the next mcpx servers list showed the removed server. The key is now the
file set, and staleness is handled where it belongs.
Hiding tools
tools (an allowlist) and excludeTools (a denylist) go in a server’s mcpx
block, or in the top-level pool block to apply to every server. A hidden
tool is left out of every listing – mcpx tools, generated types, search,
the MCP pass-through – and a call to it by name is refused with 400.
{
"pool": { "excludeTools": ["delete_*"] },
"mcpServers": {
"datadog": { "command": "mcp-remote", "args": ["..."], "mcpx": { "excludeTools": ["execute_*", "/^(update|upsert)_.*dashboard/"] } }
}
}
Each entry is one of:
| entry | means | example |
|---|---|---|
/.../ | a Go regular expression, unanchored as regexps are | /delete/, /^delete_/ |
contains *, ? or [ | a glob over the whole name: * any run, ? one character, [a-z] / [!a-z] a class | delete_*, *_monitor |
| anything else | the exact tool name | delete_datadog_workflow |
A pattern that cannot compile is a configuration error naming the field, not a rule that silently matches nothing.
Constraining calls by their arguments
Hiding decides by tool name: a tool is callable with any arguments or not at
all. argumentRules decides by the call: a per-server map in the mcpx
block – also allowed in the top-level pool block – whose keys are tool
patterns matched exactly as tools/excludeTools match (exact name, glob,
or /regexp/), and whose values are JSON Schema objects. A call is allowed
only if its arguments validate against every rule whose key matches the
tool. Pool rules and server rules add up, and so do rules from layered config
files: rules only tighten, and a nearer file cannot loosen a farther one.
{
"mcpServers": {
"github": {
"command": "...",
"mcpx": {
"argumentRules": {
"create_repository": {
"required": ["private", "organization"],
"properties": {
"private": { "const": true },
"organization": { "pattern": "^(my-org|my-org-sandbox)$" }
}
},
"/^(push_files|create_or_update_file|delete_file)$/": {
"required": ["branch"],
"properties": { "branch": { "not": { "enum": ["main", "master"] } } }
},
"*": {
"properties": { "owner": { "pattern": "^(my-org|my-user)$" } }
}
}
}
}
}
}
Per-argument rules are the same mechanism: a "*" key whose schema is a
properties object constrains that argument on every tool that carries it,
and leaves tools without the argument untouched.
A refused call is a 400, as a hidden tool is, before the server is started:
the message names the argument path, the value sent, the constraint it
broke, and where the rule came from (config file, server, key). Over the MCP
pass-through the same refusal arrives as a tool result with isError: true.
Listings advertise the tightened schema: the inputSchema shown by
mcpx types, the generated client, search, and the pass-through’s
tools/list is the upstream schema allOf every matching rule, so an agent
sees private: true before it ever calls.
A rule whose key matches no tool on its server warns at load and on catalog
changes – a server that renames a tool must not silently disable the rule
that guarded it. A value that is not a valid schema, or a pattern the
validator cannot compile (JSON Schema patterns are ECMA-262; lookahead and
backreferences are unsupported), is a configuration error naming the server
and key, like a bad tool pattern.
Two cautions, plainly:
- Server defaults are invisible. A rule constrains what is sent, not
what the server fills in. If a constraint matters, list the argument in
required– never rely on a server-side default to satisfy it. - Free-form arguments cannot truly be constrained. A
patternover aquery/jql/sqlstring matches text, not meaning. A tool whose danger lives in a free-form argument belongs inexcludeTools, not in a rule.
extraIncludeTools takes the same entries and adds them to the allowlist
instead of replacing it, so a server can widen a pool-wide tools by one tool
without restating the rest:
{
"pool": { "tools": ["get_*", "list_*", "search_*"] },
"mcpServers": {
"datadog": { "mcpx": { "extraIncludeTools": ["create_datadog_monitor"] } }
}
}
With no tools anywhere, extraIncludeTools is the allowlist – it includes,
as tools does, so everything it does not name is hidden.
excludeTools wins over both. A server’s own tools replaces the pool’s;
extraIncludeTools and excludeTools from the pool and the server all apply,
so a server that hides one more tool does not undo a pool-wide rule. A nearer
config file’s pool.extraIncludeTools likewise adds to a farther one’s. An alias (aliasOf) filters
independently of the server it shares a process with.
Names are the server’s own tool names, as mcpx tools prints them in its
tool column, not the generated function names.
Some servers can filter on their side too – Datadog’s omit_tools URL
parameter, GitHub’s GITHUB_READ_ONLY – which keeps the tool from ever
reaching mcpx. Those are each server’s own convention; this one works for any
server.
Write policy (policy.write)
A tool call is a write when its tool is not annotated readOnlyHint
true, read with the specification’s defaults: an unannotated tool may
write, and may be destructive. The daemon-wide setting policy.write
(default ask) gates every such call, between resolving an instance
and using it:
| mode | what happens to a write call |
|---|---|
ask | a confirmation is raised showing the exact call – the tool and its arguments in canonical (key-sorted) JSON – and the call proceeds only if somebody answers true |
allow | the call proceeds with no confirmation from the policy |
deny | the call is refused, with an error naming the tool, the mode, and the settings that can change it |
A server’s own writePolicy in its mcpx block overrides the daemon
mode for that server alone:
{ "mcpServers": { "datadog": { "command": "...", "mcpx": { "writePolicy": "deny" } } } }
Under ask, a caller that cannot answer questions is refused, not
waited out and not let through: the error says the policy is ask, that
there is no question broker to answer it, and that setting
policy.write to allow (or deny) is the escape. An unanswered or
refused confirmation refuses the call too.
elicit.confirmDestructive is a floor on top of this, not an
alternative to it: with it on, a destructive call asks even under
allow – raised by the destructive confirmation itself, with its
annotation-trust rules unchanged – and the floor never weakens a
deny. Under ask the policy has already asked once, so no call is
asked about twice.
This gate is an operator consent gate, not the autonomy dial
(docs/decisions/0002-autonomy-dial.md): the dial bounds what mcpx
does on its own, the write policy bounds what a caller may do to the
outside world through it, and neither clamps the other.
Every setting
Everything between the markers below is generated from settings.Registry() by
TestTheDocumentationListsEverySetting, which fails when it is stale:
go test ./internal/settings -run TestTheDocumentationListsEverySetting -update
It used to say it was generated while being written by hand, and it had
drifted: two settings were missing an alias their flag really answers to.
mcpx config --schema --plumbing prints the same inventory, and
mcpx settings list prints it with the values that are actually in force.
Entries marked (plumbing) are internals. They work and they are supported,
but there is no ordinary reason to change one, so they are kept out of
ordinary help rather than hidden – a flag a user can find in the source and
that --help denies exists is worse than a long list.
paths.configFile is the one setting a configuration file may not set: it
decides which file is read, so it is accepted from the environment and from
the global --config, which goes before the command.
plumbing.strictUnknownKeys governs keys no setting claims, and keys a
server entry declares that mcpx does not know — type, alwaysAllow and the
rest, which belong to the other hosts that read the same file. Those are
recorded, reported by mcpx doctor, and refused only when this is on.
A key inside a server’s "mcpx" block is not governed by it and is always
refused. That block is mcpx’s alone — nothing else writes into it — so a key
it does not know is a typo or a name that no longer exists, not another host’s
field. This is deliberately not a setting: it is how mcpx init wrote
"mode": "session" into every new config for months without anyone noticing,
and a switch to turn the report off would have kept it quiet.
A script whose first line names its interpreter (#!) runs under that
interpreter, ahead of every setting below: a -S/--split-string
payload is re-split with shell quoting rules (so -S sh -c "printf hello" keeps printf hello one argument; an unterminated quote fails
the run), VAR=value assignments reach the child even when the value
contains /, env -u/env -i apply (the child starts from the parent’s
environment minus what they remove, or from an empty one), and
env -C/env --chdir moves the run, beating script.cwd the way the
interpreter choice beats script.runtime. An env option mcpx does not
implement fails the run rather than being dropped. A dispatched run
never inherits MCPX_CLIENT, MCPX_RUNTIME, MCPX_ENTRY or
MCPX_DISPATCH from an outer run, and its notice names anything else it
goes without (no generated client, no enforced permission profiles,
skipped launcher phases).
artifacts
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
artifacts.chunkBytes | bytes | 256KiB | daemon | no | --artifacts-chunk-bytes | MCPX_ARTIFACTS_CHUNK_BYTES | (plumbing) how much of a body one streamed frame carries |
artifacts.delivery | enum | reference | call | yes | --artifacts-delivery | MCPX_ARTIFACTS_DELIVERY | how artifact bodies reach the caller |
artifacts.dir | string | (empty) | call | yes | --artifacts-dir | MCPX_ARTIFACTS_DIR | write this run’s artifacts here |
artifacts.enabled | bool | true | daemon | no | --artifacts-enabled | MCPX_ARTIFACTS_ENABLED | whether the daemon keeps files scripts produce |
artifacts.gcInterval | duration | 10m | daemon | no | --artifacts-gc-interval | MCPX_ARTIFACTS_GC_INTERVAL | (plumbing) how often expired artifacts are swept |
artifacts.idleTtl | duration | 0s | daemon | no | --artifacts-idle-ttl | MCPX_ARTIFACTS_IDLE_TTL | when set, reading an artifact renews it for this long; off keeps absolute expiry |
artifacts.inlineMaxBytes | bytes | 1MiB | call | yes | --artifacts-inline-max-bytes | MCPX_ARTIFACTS_INLINE_MAX_BYTES | the largest artifact that may be base64’d into a result |
artifacts.interceptImages | bool | true | call | yes | --artifacts-intercept-images | MCPX_ARTIFACTS_INTERCEPT_IMAGES | turn image and audio content in a result into artifact references |
artifacts.maxAge | duration | 168h | daemon | no | --artifacts-max-age | MCPX_ARTIFACTS_MAX_AGE | the absolute ceiling on a renewed artifact’s life, however often it is read |
artifacts.maxBytes | bytes | 64MiB | daemon | no | --artifacts-max-bytes | MCPX_ARTIFACTS_MAX_BYTES | the largest single artifact that may be stored |
artifacts.quota | bytes | 1GiB | daemon | no | --artifacts-quota | MCPX_ARTIFACTS_QUOTA | the total the artifact store may hold |
artifacts.ttl | duration | 24h | daemon | no | --artifacts-ttl | MCPX_ARTIFACTS_TTL | how long an artifact is kept before it is collected |
autonomy
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
autonomy.max | enum | run | daemon | no | --autonomy-max | MCPX_AUTONOMY_MAX | the most autonomy this daemon exercises, whatever a caller asks for |
autostart
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
autostart.args | list | daemon,--detached | client | no | --autostart-args | MCPX_AUTOSTART_ARGS | (plumbing) the arguments an auto-started daemon is given |
autostart.bin | string | (empty) | client | no | --autostart-bin | MCPX_AUTOSTART_BIN | which executable is started as the daemon |
autostart.connectTimeout | duration | 20s | client | no | --autostart-connect-timeout | MCPX_AUTOSTART_CONNECT_TIMEOUT | how long to wait for a started daemon to answer |
autostart.idleExit | duration | 4h0m0s | client | no | --autostart-idle-exit | MCPX_AUTOSTART_IDLE_EXIT | how long an auto-started daemon survives with nothing to do |
autostart.logTail | bytes | 2000 | client | no | --autostart-log-tail | MCPX_AUTOSTART_LOG_TAIL | (plumbing) how much of the daemon log is shown when it will not start |
autostart.pingTimeout | duration | 2s | client | no | --autostart-ping-timeout | MCPX_AUTOSTART_PING_TIMEOUT | (plumbing) how long a health check waits before calling it dead |
autostart.pollInterval | duration | 50ms | client | no | --autostart-poll-interval | MCPX_AUTOSTART_POLL_INTERVAL | (plumbing) how often a starting daemon is probed |
catalog
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
catalog.bias | list | (empty) | call | yes | --catalog-bias, --bias | MCPX_CATALOG_BIAS | words that pull matching tools toward the front |
catalog.budget | int | 2000 | call | yes | --catalog-budget, --budget | MCPX_CATALOG_BUDGET | token ceiling for the catalog listing |
catalog.instructions | bool | true | call | yes | --catalog-instructions | MCPX_CATALOG_INSTRUCTIONS | include each server’s own instructions |
catalog.pinnedTools | list | (empty) | daemon | no | --catalog-pinned-tools | MCPX_CATALOG_PINNED_TOOLS | tools always exposed in reactive mode |
completion
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
completion.maxValues | int | 100 | daemon | yes | --completion-max-values | MCPX_COMPLETION_MAX_VALUES | (plumbing) how many completions one reply carries |
completion.perMinute | int | 120 | daemon | yes | --completion-per-minute | MCPX_COMPLETION_PER_MINUTE | (plumbing) how many completion requests one caller may make against one server per minute; 0 disables |
completion.serverPerMinute | int | 600 | daemon | yes | --completion-server-per-minute | MCPX_COMPLETION_SERVER_PER_MINUTE | (plumbing) how many completion requests one server may receive per minute across all callers; 0 disables |
daemon
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
daemon.address | string | 127.0.0.1 | daemon | no | --daemon-address, --address | MCPX_DAEMON_ADDRESS | which interface the daemon listens on |
daemon.autostart | bool | true | client | no | --daemon-autostart | MCPX_DAEMON_AUTOSTART | start the daemon on demand when it is not running |
daemon.endpoint | string | (empty) | client | no | --daemon-endpoint | MCPX_DAEMON_ENDPOINT | a daemon somewhere else, instead of the local socket |
daemon.idleExit | duration | 0s | daemon | no | --daemon-idle-exit, --idle-exit | MCPX_DAEMON_IDLE_EXIT | stop the daemon after this long with nothing to do |
daemon.inline | bool | false | client | no | --daemon-inline | MCPX_DAEMON_INLINE | as a last resort, run servers inside this process |
daemon.inlineStartPoll | duration | 20ms | client | no | --daemon-inline-start-poll | MCPX_DAEMON_INLINE_START_POLL | (plumbing) how often a starting in-process daemon is probed |
daemon.inlineStartTimeout | duration | 5s | client | no | --daemon-inline-start-timeout | MCPX_DAEMON_INLINE_START_TIMEOUT | (plumbing) how long an in-process daemon has to become reachable |
daemon.leaseTTL | duration | 30m0s | daemon | yes | --daemon-lease-ttl | MCPX_DAEMON_LEASE_TTL | (plumbing) how long a silent caller’s instances are remembered |
daemon.port | int | 0 | daemon | no | --daemon-port, --port | MCPX_DAEMON_PORT | listen on a TCP port instead of choosing one |
daemon.probeTimeout | duration | 200ms | client | no | --daemon-probe-timeout | MCPX_DAEMON_PROBE_TIMEOUT | (plumbing) how long another daemon’s socket is given to answer |
daemon.reapInterval | duration | 30s | daemon | no | --daemon-reap-interval | MCPX_DAEMON_REAP_INTERVAL | (plumbing) how often idle instances are swept |
daemon.refreshTimeout | duration | 3m0s | daemon | yes | --daemon-refresh-timeout | MCPX_DAEMON_REFRESH_TIMEOUT | how long POST /v1/refresh may take |
daemon.roots | paths | (empty) | daemon | no | --daemon-roots | MCPX_DAEMON_ROOTS | filesystem paths exposed to upstream servers through roots/list |
daemon.saveInterval | duration | 5m | daemon | no | --daemon-save-interval | MCPX_DAEMON_SAVE_INTERVAL | (plumbing) how often daemon state is written to disk |
daemon.socketProbeTimeout | duration | 500ms | daemon | no | --daemon-socket-probe-timeout | MCPX_DAEMON_SOCKET_PROBE_TIMEOUT | (plumbing) how long a socket left by a crashed daemon is given to answer |
daemon.takeoverTimeout | duration | 15s | daemon | no | --daemon-takeover-timeout | MCPX_DAEMON_TAKEOVER_TIMEOUT | (plumbing) how long a handoff may take, and how long the old daemon waits for its successor to hang up |
daemon.tsnet.hostname | string | (empty) | daemon | no | --daemon-tsnet-hostname | MCPX_DAEMON_TSNET_HOSTNAME | also serve the daemon on a tailnet, under this name |
daemon.tsnetUpTimeout | duration | 30s | daemon | no | --daemon-tsnet-up-timeout | MCPX_DAEMON_TSNET_UP_TIMEOUT | (plumbing) how long a node may take to authenticate |
daemon.ui | bool | true | daemon | yes | --daemon-ui, --ui | MCPX_DAEMON_UI | serve the dashboard page at /dashboard |
daemon.uiDir | string | (empty) | daemon | yes | --daemon-ui-dir, --ui-dir | MCPX_DAEMON_UI_DIR | serve your own pages instead of the embedded dashboard |
daemon.warm | bool | true | daemon | no | --daemon-warm, --warm | MCPX_DAEMON_WARM | read every server’s schemas in the background at startup |
daemon.warmTimeout | duration | 3m0s | daemon | no | --daemon-warm-timeout | MCPX_DAEMON_WARM_TIMEOUT | (plumbing) how long the background schema fetch may take |
daemon.watchConfig | bool | true | daemon | yes | --daemon-watch-config | MCPX_DAEMON_WATCH_CONFIG | re-read the configuration files when they change on disk |
deps
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
deps.autoInstall | enum | off | client | no | --deps-auto-install | MCPX_DEPS_AUTO_INSTALL | whether the runtime may fetch a missing package on its own |
deps.manifest | source | (empty) | client | no | --deps-manifest | MCPX_DEPS_MANIFEST | the package.json or package.jsonc naming a script’s packages |
deps.packages | list | (empty) | client | no | --deps-packages, --package | MCPX_DEPS_PACKAGES | packages a script may import, as name or name@version |
deps.source | enum | host | client | no | --deps-source | MCPX_DEPS_SOURCE | where a script’s packages come from when nothing more specific says |
diagnose
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
diagnose.history | int | 8 | daemon | no | --diagnose-history | MCPX_DIAGNOSE_HISTORY | (plumbing) how many changes are kept per tool |
diagnose.preflight | bool | true | call | yes | --diagnose-preflight | MCPX_DIAGNOSE_PREFLIGHT | check a script’s tool calls against the live schemas first |
doctor
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
doctor.timeout | duration | 20s | client | no | --doctor-timeout | MCPX_DOCTOR_TIMEOUT | how long mcpx doctor gives the daemon to answer |
elicit
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
elicit.askTimeout | duration | 45s | daemon | no | --elicit-ask-timeout | MCPX_ELICIT_ASK_TIMEOUT | how long mcpx waits for an answer to a question it raised itself |
elicit.confirmDestructive | bool | false | daemon | no | --elicit-confirm-destructive | MCPX_ELICIT_CONFIRM_DESTRUCTIVE | ask before a call to a tool that may be destructive |
elicit.disambiguate | enum | never | daemon | no | --elicit-disambiguate | MCPX_ELICIT_DISAMBIGUATE | ask which instance of a stateful server to use when several are live and the script did not choose |
elicit.disambiguateDefault | enum | new | daemon | no | --elicit-disambiguate-default | MCPX_ELICIT_DISAMBIGUATE_DEFAULT | which instance is used when nobody answers in time: the one the scope would have chosen, or the most recently used |
elicit.maxAge | duration | 30m | daemon | no | --elicit-max-age | MCPX_ELICIT_MAX_AGE | the absolute ceiling on a question’s life, however often it is read |
elicit.pendingLimit | int | 100 | daemon | yes | --elicit-pending-limit | MCPX_ELICIT_PENDING_LIMIT | (plumbing) how many unanswered questions one listing returns |
elicit.ttl | duration | 120s | daemon | no | --elicit-ttl | MCPX_ELICIT_TTL | how long a question waits for an answer, renewed by use |
embeddings
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
embeddings.apiKey | string | (empty) | daemon | yes | --embeddings-api-key | MCPX_EMBEDDINGS_API_KEY | API key for remote embeddings endpoint |
embeddings.backend | string | auto | daemon | yes | --embeddings-backend | MCPX_EMBEDDINGS_BACKEND | which embedding engine to use (auto, local, remote, wasm) |
embeddings.model | string | text-embedding-3-small | daemon | yes | --embeddings-model | MCPX_EMBEDDINGS_MODEL | model name for remote embeddings endpoint |
embeddings.url | string | (empty) | daemon | yes | --embeddings-url | MCPX_EMBEDDINGS_URL | OpenAI-compatible embeddings endpoint URL |
embeddings.wasmPath | string | (empty) | daemon | yes | --embeddings-wasm-path | MCPX_EMBEDDINGS_WASM_PATH | path to compiled WebAssembly neural embedding model |
events
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
events.history | int | 1024 | daemon | no | --events-history | MCPX_EVENTS_HISTORY | how many past events a late subscriber can replay |
events.reconnect | duration | 2s | client | no | --events-reconnect | MCPX_EVENTS_RECONNECT | how long a client waits before resuming a dropped stream |
exec
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
exec.maxOutputBytes | bytes | 64KiB | call | yes | --exec-max-output-bytes | MCPX_EXEC_MAX_OUTPUT_BYTES | cap the stdout a run returns, with a truncation marker |
exec.output | enum | text | call | yes | --exec-output, --output | MCPX_EXEC_OUTPUT | text for a terminal, structured for one document, stream for frames |
exec.timeout | duration | 120s | call | yes | --exec-timeout | MCPX_EXEC_TIMEOUT | kill a script after this long |
exec.where | enum | auto | call | yes | --exec-where | MCPX_EXEC_WHERE | in this process, or on the daemon |
hooks
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
hooks | string | (empty) | daemon | no | --hooks | MCPX_HOOKS | config-declared hooks over the event bus: a JSON array of hook objects |
hooks.autonomy | enum | run | call | yes | --hooks-autonomy | MCPX_HOOKS_AUTONOMY | whether configured script hooks (before, onSuccess, onError) run |
hooks.timeout | duration | 2s | daemon | no | --hooks-timeout | MCPX_HOOKS_TIMEOUT | the hard timeout for a declared hook that does not set its own |
http
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
http.bodyLimit | bytes | 1048576 | daemon | yes | --http-body-limit | MCPX_HTTP_BODY_LIMIT | (plumbing) the ceiling on an ordinary /v1 request body |
http.callBodyLimit | bytes | 67108864 | daemon | yes | --http-call-body-limit | MCPX_HTTP_CALL_BODY_LIMIT | the ceiling on a POST /v1/call body |
http.controlBodyLimit | bytes | 65536 | daemon | yes | --http-control-body-limit | MCPX_HTTP_CONTROL_BODY_LIMIT | (plumbing) the ceiling on a body that should hold one field |
http.idleConnTimeout | duration | 30s | client | no | --http-idle-conn-timeout | MCPX_HTTP_IDLE_CONN_TIMEOUT | (plumbing) how long an unused connection is kept |
http.idleConns | int | 8 | client | no | --http-idle-conns | MCPX_HTTP_IDLE_CONNS | (plumbing) idle connections kept to a local daemon |
http.readHeaderTimeout | duration | 10s | daemon | no | --http-read-header-timeout | MCPX_HTTP_READ_HEADER_TIMEOUT | (plumbing) how long a client has to finish sending its headers |
http.remoteIdleConns | int | 16 | client | no | --http-remote-idle-conns | MCPX_HTTP_REMOTE_IDLE_CONNS | (plumbing) idle connections kept to a daemon over the network |
http.requestTimeout | duration | 10m0s | client | no | --http-request-timeout | MCPX_HTTP_REQUEST_TIMEOUT | how long a CLI request to the daemon may take |
http.shutdownGrace | duration | 5s | daemon | yes | --http-shutdown-grace | MCPX_HTTP_SHUTDOWN_GRACE | how long in-flight requests have when the daemon stops |
http.ssePing | duration | 15s | daemon | yes | --http-sse-ping | MCPX_HTTP_SSE_PING | (plumbing) how often a comment is sent on an idle event stream |
http.sseRetry | duration | 2s | daemon | yes | --http-sse-retry | MCPX_HTTP_SSE_RETRY | (plumbing) how long a dropped subscriber is told to wait |
http.streamBufferInit | bytes | 65536 | client | no | --http-stream-buffer-init | MCPX_HTTP_STREAM_BUFFER_INIT | (plumbing) the initial line buffer when reading an event stream |
http.streamBufferMax | bytes | 8388608 | client | no | --http-stream-buffer-max | MCPX_HTTP_STREAM_BUFFER_MAX | (plumbing) the largest single event line that will be read |
logging
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
logging.dir | string | (empty) | daemon | no | --logging-dir, --log-dir | MCPX_LOGGING_DIR | where the JSONL files are written |
logging.file | bool | true | daemon | no | --logging-file | MCPX_LOGGING_FILE | whether the durable JSONL log is written at all |
logging.format | enum | text | client | no | --logging-format, --format | MCPX_LOGGING_FORMAT | how records are rendered |
logging.include | list | host,user,process,version | daemon | no | --logging-include, --include | MCPX_LOGGING_INCLUDE | which context blocks are attached to lifecycle records |
logging.keep | int | 8 | daemon | no | --logging-keep, --keep | MCPX_LOGGING_KEEP | how many rolled files to keep |
logging.level | enum | info | daemon | yes | --logging-level, --log-level | MCPX_LOGGING_LEVEL, MCPX_LOG_LEVEL | the lowest level that is kept |
logging.maxAge | duration | 24h | daemon | no | --logging-max-age | MCPX_LOGGING_MAX_AGE | roll the log file once it is this old |
logging.maxBytes | bytes | 16MB | daemon | no | --logging-max-bytes | MCPX_LOGGING_MAX_BYTES | roll the log file once it reaches this size |
logging.maxLines | int | 0 | daemon | no | --logging-max-lines | MCPX_LOGGING_MAX_LINES | roll the log file once it holds this many records |
logging.source | enum | warn | daemon | yes | --logging-source, --log-source | MCPX_LOGGING_SOURCE | from which level upward to record the calling file and line |
logging.trace | bool | false | daemon | no | --logging-trace | MCPX_TRACE | log which instance served every tool call |
logstore
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
logstore.followBacklog | int | 1000 | client | no | --logstore-follow-backlog | MCPX_LOGSTORE_FOLLOW_BACKLOG | (plumbing) how many records one poll of mcpx log --follow may emit |
logstore.queryLimit | int | 100 | call | yes | --logstore-query-limit | MCPX_LOGSTORE_QUERY_LIMIT | how many records a log query returns by default |
mcp
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
mcp.pageSize | int | 100 | client | no | --mcp-page-size | MCPX_MCP_PAGE_SIZE | how many items one tools/list reply carries |
mcp.passthrough | string | (empty) | client | no | --mcp-passthrough, --passthrough | MCPX_MCP_PASSTHROUGH | serve upstreams’ tools, prompts and resources under their own names |
model
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
model.apiKeyEnv | string | (empty) | daemon | no | --model-api-key-env | MCPX_MODEL_API_KEY_ENV | the environment variable holding the configured provider’s API key |
model.baseURL | string | (empty) | daemon | no | --model-base-url | MCPX_MODEL_BASE_URL | the OpenAI-compatible endpoint the configured provider calls |
model.budget | int | 0 | daemon | no | --model-budget | MCPX_MODEL_BUDGET | hard ceiling on the tokens one generation through the layer may ask for; 0 is no ceiling |
model.default | string | (empty) | daemon | no | --model-default | MCPX_MODEL_DEFAULT | the model id used when an operation names none, as provider/model |
model.provider | enum | none | daemon | no | --model-provider | MCPX_MODEL_PROVIDER | where mcpx’s own generation gets a model: a configured provider, sampling through the caller, or none |
model.timeout | duration | 1m30s | daemon | no | --model-timeout | MCPX_MODEL_TIMEOUT | how long one generation through the layer may take |
output
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
output.color | enum | auto | client | no | --output-color | MCPX_OUTPUT_COLOR | whether to colourise the browser |
output.json | bool | false | client | no | --output-json, --json | MCPX_OUTPUT_JSON | emit one machine-readable document |
paths
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
paths.adapters | paths | (empty) | client | no | --paths-adapters | MCPX_PATHS_ADAPTERS | files declaring command-line programs as MCP servers |
paths.apis | paths | (empty) | client | no | --paths-apis | MCPX_PATHS_APIS | files naming OpenAPI documents to expose as tools |
paths.cache | string | (empty) | client | no | --paths-cache | MCPX_PATHS_CACHE, MCPX_CACHE_DIR | where generated clients and schemas are kept |
paths.config | paths | (empty) | client | no | --paths-config | MCPX_PATHS_CONFIG | where configuration files are looked for |
paths.configFile | string | (empty) | client | no | --config | MCPX_CONFIG | read exactly this configuration file instead of searching |
paths.deps | paths | (empty) | client | no | --paths-deps | MCPX_PATHS_DEPS | where a script’s package.json / package.jsonc is looked for |
paths.placeholders | paths | (empty) | client | no | --paths-placeholders | MCPX_PATHS_PLACEHOLDERS | directories of files declaring launcher placeholders |
paths.plugins | paths | (empty) | client | no | --paths-plugins | MCPX_PATHS_PLUGINS | directories of plugins: an mcpx-plugin.json manifest, or executables loaded as zero-manifest plugins |
paths.scripts | paths | (empty) | client | no | --paths-scripts | MCPX_PATHS_SCRIPTS | where named scripts are looked for |
paths.state | string | (empty) | client | no | --paths-state | MCPX_PATHS_STATE, MCPX_STATE_DIR | where the daemon socket, logs and index live |
plugin
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
plugin.annotate | bool | true | plugin | no | --plugin-annotate | MCPX_PLUGIN_ANNOTATE | add mcpx’s own notes to a tool result |
plugin.backend | enum | auto | plugin | no | --plugin-backend | MCPX_PLUGIN_BACKEND | whether the plugin talks to the daemon or spawns the binary |
plugin.bin | string | mcpx | plugin | no | --plugin-bin | MCPX_PLUGIN_BIN | the executable the plugin invokes |
plugin.binArgs | list | (empty) | plugin | no | --plugin-bin-args | MCPX_PLUGIN_BIN_ARGS | arguments put in front of every plugin invocation of mcpx |
plugin.daemonTools | enum | auto | plugin | no | --plugin-daemon-tools | MCPX_PLUGIN_DAEMON_TOOLS | offer the tools that list and choose between daemons |
plugin.discoveryRetry | duration | 1m0s | plugin | no | --plugin-discovery-retry | MCPX_PLUGIN_DISCOVERY_RETRY | how long the plugin waits before looking for mcpx again |
plugin.env | enum | full | plugin | no | --plugin-env | MCPX_PLUGIN_ENV | how much session context is put into each command’s environment |
plugin.headless | enum | auto | plugin | no | --plugin-headless | MCPX_PLUGIN_HEADLESS | suppress toasts, because nobody is looking at a screen |
plugin.instructions | bool | false | plugin | no | --plugin-instructions | MCPX_PLUGIN_INSTRUCTIONS | add mcpx usage guidance to the system prompt |
plugin.remember | enum | session | plugin | no | --plugin-remember | MCPX_PLUGIN_REMEMBER | how long a chosen daemon stays chosen |
plugin.toolTiming | bool | false | plugin | no | --plugin-tool-timing | MCPX_PLUGIN_TOOL_TIMING | write every tool outcome into mcpx’s log |
plugin.tools | bool | false | plugin | no | --plugin-tools | MCPX_PLUGIN_TOOLS | expose mcpx itself as tools the model can call |
plumbing
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
plumbing.allowTsJsOverlap | bool | false | client | no | --plumbing-allow-ts-js-overlap | MCPX_PLUMBING_ALLOW_TS_JS_OVERLAP | (plumbing) permit a script directory holding both foo.ts and foo.js |
plumbing.indexOnQuery | bool | true | daemon | no | --plumbing-index-on-query | MCPX_PLUMBING_INDEX_ON_QUERY | (plumbing) bring the log index up to date before answering a query |
plumbing.launcherPlaceholderRepeat | list | (empty) | client | no | --plumbing-launcher-placeholder-repeat | MCPX_PLUMBING_LAUNCHER_PLACEHOLDER_REPEAT | (plumbing) launcher placeholders permitted to resolve more than once |
plumbing.sourceDirAllowed | bool | true | client | no | --plumbing-source-dir-allowed | MCPX_PLUMBING_SOURCE_DIR_ALLOWED | (plumbing) whether a directory may stand in for a source string at all |
plumbing.sourceDirRecursive | bool | false | client | no | --plumbing-source-dir-recursive | MCPX_PLUMBING_SOURCE_DIR_RECURSIVE | (plumbing) when a directory is given as source, descend into subdirectories |
plumbing.sourceProbePaths | bool | true | client | no | --plumbing-source-probe-paths | MCPX_PLUMBING_SOURCE_PROBE_PATHS | (plumbing) treat a source argument that names an existing file as a file |
plumbing.strictUnknownKeys | bool | false | client | no | --plumbing-strict-unknown-keys | MCPX_PLUMBING_STRICT_UNKNOWN_KEYS | (plumbing) fail on a configuration key no setting claims |
plumbing.validatePaths | bool | true | client | no | --plumbing-validate-paths | MCPX_PLUMBING_VALIDATE_PATHS | (plumbing) resolve and verify every referenced path before doing any work |
policy
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
policy.write | enum | ask | daemon | no | --policy-write | MCPX_POLICY_WRITE | what to do before a call that may write: ask for confirmation, allow it, or deny it |
pool
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
pool.callTimeout | duration | 120s | daemon | yes | --pool-call-timeout | MCPX_POOL_CALL_TIMEOUT | how long one tool call may take |
pool.idleTimeout | duration | 5m | daemon | yes | --pool-idle-timeout | MCPX_POOL_IDLE_TIMEOUT | how long an unused server lingers before it is stopped |
pool.max | int | 4 | daemon | yes | --pool-max | MCPX_POOL_MAX | how many copies of one server may run at once |
pool.min | int | 0 | daemon | yes | --pool-min | MCPX_POOL_MIN | how many copies to keep started even when idle |
pool.scope | enum | global | daemon | no | --pool-scope | MCPX_POOL_SCOPE | what counts as the same caller for sharing purposes |
pool.sharing | enum | shared | daemon | no | --pool-sharing | MCPX_POOL_SHARING | whether callers reuse one instance or each get their own |
pool.startTimeout | duration | 60s | daemon | yes | --pool-start-timeout | MCPX_POOL_START_TIMEOUT | how long a server has to become ready |
preset
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
preset | list | (empty) | client | no | --preset | MCPX_PRESET | which presets to apply, in order |
presets
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
presets | string | (empty) | client | no | --presets | MCPX_PRESETS | named flag bundles: {name: [“–json”, “–timeout=30s”]} |
prompt
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
prompt.autonomy | enum | propose | call | yes | --prompt-autonomy | MCPX_PROMPT_AUTONOMY | whether a request returns the script (propose) or runs it (run) |
prompt.catalogBudget | int | 1200 | call | yes | --prompt-catalog-budget | MCPX_PROMPT_CATALOG_BUDGET | approximate token ceiling for the slice of catalog sent with a sampling request |
prompt.maxTokens | int | 1500 | call | yes | --prompt-max-tokens | MCPX_PROMPT_MAX_TOKENS | (plumbing) maxTokens on the sampling request |
prompt.runTimeout | duration | 5m0s | call | yes | --prompt-run-timeout | MCPX_PROMPT_RUN_TIMEOUT | how long a recipe or generated script may run |
prompt.sample | enum | never | daemon | no | --prompt-sample | MCPX_PROMPT_SAMPLE | whether a request with no matching recipe may ask the caller’s model to write one |
prompt.sampleTimeout | duration | 1m30s | daemon | no | --prompt-sample-timeout | MCPX_PROMPT_SAMPLE_TIMEOUT | how long a generation request waits for a model |
proto
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
proto.askPoll | duration | 500ms | daemon | no | --proto-ask-poll | MCPX_PROTO_ASK_POLL | (plumbing) how long one wait for a question may block |
proto.askRounds | int | 8 | daemon | no | --proto-ask-rounds | MCPX_PROTO_ASK_ROUNDS | how many times one request may come back asking for more |
proto.askTTL | duration | 15m | daemon | no | --proto-ask-ttl | MCPX_PROTO_ASK_TTL | (plumbing) how long the daemon keeps a call waiting for an answer |
proto.askTimeout | duration | 10m | daemon | no | --proto-ask-timeout | MCPX_PROTO_ASK_TIMEOUT | how long one request may be held while a question goes unanswered |
proto.elicitationPerMinute | int | 30 | daemon | no | --proto-elicitation-per-minute | MCPX_PROTO_ELICITATION_PER_MINUTE | max elicitation requests one upstream server may make per minute, across all its sessions |
proto.mcpPath | string | /mcp | daemon | no | --proto-mcp-path | MCPX_PROTO_MCP_PATH | where the daemon serves MCP |
proto.native | bool | true | daemon | no | --proto-native | MCPX_PROTO_NATIVE | put an upstream server’s questions to mcpx’s own MCP client |
proto.samplingPerMinute | int | 60 | daemon | no | --proto-sampling-per-minute | MCPX_PROTO_SAMPLING_PER_MINUTE | max sampling requests one upstream server may make per minute, across all its sessions |
proto.samplingSessionPerMinute | int | 20 | daemon | no | --proto-sampling-session-per-minute | MCPX_PROTO_SAMPLING_SESSION_PER_MINUTE | max sampling requests one upstream server may make within a single session per minute |
proto.serveMCP | bool | true | daemon | no | --proto-serve-mcp | MCPX_PROTO_SERVE_MCP | mount mcpx’s own MCP server on the daemon’s listeners |
proto.sessionIdle | duration | 30m | daemon | no | --proto-session-idle | MCPX_PROTO_SESSION_IDLE | (plumbing) how long an unused Streamable HTTP session is kept |
proto.stateTTL | duration | 30m | daemon | no | --proto-state-ttl | MCPX_PROTO_STATE_TTL | (plumbing) how long a client may resume an interrupted request with |
protoMessages
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
protoMessages.listMaxAge | duration | 1m0s | daemon | no | --proto-messages-list-max-age | MCPX_PROTO_MESSAGES_LIST_MAX_AGE | ttlMs on server/discover and every list result sent to a 2026-07-28 client |
protoMessages.readMaxAge | duration | 0s | daemon | no | --proto-messages-read-max-age | MCPX_PROTO_MESSAGES_READ_MAX_AGE | ttlMs on resources/read results sent to a 2026-07-28 client |
protoMessages.taskAfter | duration | 2s | daemon | no | --proto-messages-task-after | MCPX_PROTO_MESSAGES_TASK_AFTER | how long a tools/call runs in line before a tasks-extension client is handed a task |
protoTasks
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
protoTasks.cancelGrace | duration | 5s | daemon | no | --proto-tasks-cancel-grace | MCPX_PROTO_TASKS_CANCEL_GRACE | how long cooperative task work may finish after cancellation |
protoTasks.maxPerRequestor | int | 32 | daemon | no | --proto-tasks-max-per-requestor | MCPX_PROTO_TASKS_MAX_PER_REQUESTOR | how many non-terminal tasks one requestor may hold at once |
protoTasks.maxTTL | duration | 1h0m0s | daemon | no | --proto-tasks-max-ttl | MCPX_PROTO_TASKS_MAX_TTL | the longest TTL a task may ask for; longer requests are clamped |
protoTasks.perMinute | int | 120 | daemon | no | --proto-tasks-per-minute | MCPX_PROTO_TASKS_PER_MINUTE | the tasks/* admission window per requestor; 0 disables it |
protoTasks.pollInterval | duration | 1s | daemon | no | --proto-tasks-poll-interval | MCPX_PROTO_TASKS_POLL_INTERVAL | the pollInterval every task carries |
recipes
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
recipes.limit | int | 5 | call | yes | --recipes-limit | MCPX_RECIPES_LIMIT | how many ranked recipes are returned |
recipes.matchMargin | int | 150 | call | yes | --recipes-match-margin | MCPX_RECIPES_MATCH_MARGIN | how far ahead the best recipe must be, as a percentage of the next one |
recipes.minScore | int | 30 | call | yes | --recipes-min-score | MCPX_RECIPES_MIN_SCORE | the score below which a request is not considered a match for any recipe |
registry
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
registry.limit | int | 20 | call | yes | --registry-limit | MCPX_REGISTRY_LIMIT | how many registry results are returned |
registry.maxPages | int | 10 | client | no | --registry-max-pages | MCPX_REGISTRY_MAX_PAGES | (plumbing) how many requests one registry search may make |
registry.pageSize | int | 30 | client | no | --registry-page-size | MCPX_REGISTRY_PAGE_SIZE | (plumbing) how many entries are fetched per registry request |
registry.timeout | duration | 30s | client | no | --registry-timeout | MCPX_REGISTRY_TIMEOUT | how long a registry request may take |
registry.url | string | https://registry.modelcontextprotocol.io | client | no | --registry-url | MCPX_REGISTRY_URL | where mcpx registry looks for servers |
repair
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
repair.autonomy | enum | advise | call | yes | --repair-autonomy | MCPX_REPAIR_AUTONOMY | how far mcpx goes about a failed call: off attaches nothing, advise attaches diagnostics |
retention
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
retention.maxTools | int | 24 | daemon | no | --retention-max-tools | MCPX_RETENTION_MAX_TOOLS | maximum active tools in reactive working set |
retention.stickyWindow | duration | 2h | daemon | no | --retention-sticky-window | MCPX_RETENTION_STICKY_WINDOW | duration explicitly requested tools remain retained |
script
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
script.before | source | (empty) | client | no | --script-before | MCPX_SCRIPT_BEFORE | runs first, ahead of the globals being installed |
script.captureConsole | bool | true | client | no | --script-capture-console | MCPX_SCRIPT_CAPTURE_CONSOLE | route console calls into the log |
script.cwd | enum | caller | client | no | --script-cwd | MCPX_SCRIPT_CWD | where a script runs: caller, script, config or workdir |
script.env | list | (empty) | client | no | --script-env, --env | MCPX_SCRIPT_ENV | KEY=VALUE pairs added to the script’s environment |
script.imports | enum | report | client | no | --script-imports | MCPX_SCRIPT_IMPORTS | resolve the script’s import graph before running it |
script.importsOnDynamic | enum | warn | client | no | --script-imports-on-dynamic | MCPX_SCRIPT_IMPORTS_ON_DYNAMIC | what to do about an import() whose specifier is computed |
script.interpreter | string | (empty) | client | no | --script-interpreter, --interpreter, --shell | MCPX_SCRIPT_INTERPRETER | command line that runs scripts instead of a JavaScript runtime |
script.launcher | source | (empty) | client | no | --script-launcher | MCPX_SCRIPT_LAUNCHER | replace the generated launcher entirely |
script.onError | source | (empty) | client | no | --script-on-error | MCPX_SCRIPT_ON_ERROR | runs when the script throws; the error still propagates |
script.onSuccess | source | (empty) | client | no | --script-on-success | MCPX_SCRIPT_ON_SUCCESS | runs when the script returns without throwing |
script.permissions | string | all | client | no | --script-permissions, --permissions | MCPX_SCRIPT_PERMISSIONS, MCPX_PERMISSIONS | permission profiles, composed in order, or raw: flags |
script.prefix | source | (empty) | client | no | --script-prefix | MCPX_SCRIPT_PREFIX | runs after globals are installed, before the script |
script.profiles | string | (empty) | client | no | --script-profiles | MCPX_SCRIPT_PROFILES | user profiles: {name: {deno: [flags], node: [flags]}} or {name: “raw flags”} |
script.runtime | string | auto | client | no | --script-runtime, --runtime | MCPX_SCRIPT_RUNTIME | which JavaScript runtime executes the script |
script.runtimeOrder | list | deno,bun,node | client | no | --script-runtime-order | MCPX_SCRIPT_RUNTIME_ORDER | the runtimes auto tries, in order |
script.runtimes | string | (empty) | client | no | --script-runtimes | MCPX_SCRIPT_RUNTIMES | named runtime binaries: {name: {kind, bin, args}} |
script.suffix | source | (empty) | client | no | --script-suffix | MCPX_SCRIPT_SUFFIX | runs last on both paths, like a finally |
script.typecheck | enum | off | client | no | --script-typecheck | MCPX_SCRIPT_TYPECHECK | check the generated program before running it |
search
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
search.limit | int | 20 | call | yes | --search-limit, --limit, --n | MCPX_SEARCH_LIMIT | how many tools a search returns |
search.semantic | bool | false | call | yes | --search-semantic | MCPX_SEARCH_SEMANTIC | rank search results using local dense vector embeddings |
serve
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
serve.mode | string | code-mode | daemon | no | --serve-mode, --mode | MCPX_SERVE_MODE | server mode: code-mode, reactive, or full |
session
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
session.releaseTimeout | duration | 10s | client | no | --session-release-timeout | MCPX_SESSION_RELEASE_TIMEOUT | (plumbing) how long releasing a finished session may take |
spec
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
spec.first | string | (empty) | daemon | no | --mcp-spec | MCPX_MCP_SPEC | move one MCP revision to the front of spec.precedence |
spec.lenient | list | (empty) | daemon | no | --spec-lenient | MCPX_SPEC_LENIENT | MCP revisions not held strictly; every revision not listed is strict |
spec.precedence | list | 2026-07-28,2025-11-25,2025-06-18,2025-03-26,2024-11-05 | daemon | no | --spec-precedence | MCPX_SPEC_PRECEDENCE | MCP revisions in the order their rules win a conflict, first wins |
stats
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
stats.top | int | 20 | call | yes | --stats-top | MCPX_STATS_TOP | how many rows a ranked statistic shows |
tasks
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
tasks.resultWait | duration | 2m0s | call | yes | --tasks-result-wait | MCPX_TASKS_RESULT_WAIT | how long collecting a task result blocks before giving up |
tasks.ttl | duration | 10m0s | daemon | yes | --tasks-ttl | MCPX_TASKS_TTL | how long a finished task’s result is kept |
transport
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
transport.allowedOrigins | list | (empty) | daemon | no | --transport-allowed-origins | MCPX_TRANSPORT_ALLOWED_ORIGINS | browser Origins, beyond loopback, that may reach /mcp |
transport.bearerToken | string | (empty) | daemon | no | --transport-bearer-token | MCPX_TRANSPORT_BEARER_TOKEN | when set, every /v1 and /mcp request must carry this token as a Bearer credential |
transport.sessionMetaKeys | list | dev.mcpx/session,ai.opencode/sessionID | daemon | yes | --transport-session-meta-keys | MCPX_TRANSPORT_SESSION_META_KEYS | the _meta keys a client may name its session with, first present wins |
transport.sseKeepAlive | duration | 15s | daemon | no | --transport-sse-keep-alive | MCPX_TRANSPORT_SSE_KEEP_ALIVE | how often a quiet MCP event stream carries a comment line |
transport.stdioDrain | duration | 30s | client | no | --transport-stdio-drain | MCPX_TRANSPORT_STDIO_DRAIN | how long mcpx serve finishes in-flight requests after its input closes |
upstream
| setting | kind | default | scope | hot | flag | variable | governs |
|---|---|---|---|---|---|---|---|
upstream.eraCache | bool | true | daemon | no | --upstream-era-cache | MCPX_UPSTREAM_ERA_CACHE | remember which era each server configuration speaks, across restarts |
upstream.probeTimeout | duration | 2s | daemon | no | --upstream-probe-timeout | MCPX_UPSTREAM_PROBE_TIMEOUT | how long a stdio server/discover may go unanswered before initialize is also sent |
upstream.protocol | enum | prefer-discover | daemon | no | --upstream-protocol | MCPX_UPSTREAM_PROTOCOL | which request to send first to a server that names no protocol of its own; one of prefer-discover (also modern, prefer-modern, prefer-stateless, prefer-newest), prefer-initialize (also legacy, prefer-legacy, prefer-session, prefer-oldest), force-discover (also force-modern, force-stateless), force-initialize (also force-legacy, force-session), follow |
Adapter declarations
An entry in a paths.adapters file names a program and the subcommands worth
exposing. command is normally a binary on PATH; runtime makes it a
script instead, and resolves the interpreter for you:
{ "adapters": [{
"name": "reports", "command": "./tools/report.py", "runtime": "py",
"tools": [{ "name": "daily", "params": [{ "name": "day", "required": true }] }]
}] }
runtime is bash, js, ts or py. bash resolves bash (then sh), py
resolves python3 (then python), and js/ts use the same deno/bun/node order
script.runtimeOrder chooses for mcpx exec. This is a separate resolver on
purpose: script.runtime names the runtime mcpx itself executes generated
scripts on, not the one a declaration asks for. A relative command resolves
against the declaration’s dir, then PATH. Without runtime, nothing
changes: command is looked up on PATH and exec’d directly. mcpx adapter check reports a missing interpreter or script.
mcpx catalog also ends with the recipe tools in tools/list: how many are
exposed against the recipe budget, and which recipes were kept out by a name
collision or the cap.
mcpx as a consumer
Sampling, elicitation and completion are things a server asks of its client. mcpx is a client to its upstream servers and a server to whatever drives it, so today it only passes them through. Are there cases where mcpx should initiate them itself?
— issue #41
Four, and they are not equally good. This is the record of which, why, and what was deliberately not built.
1. Deterministic schema-change diagnostics
The most valuable thing here needs no model at all, and that was the central
finding of the issue. mcpx already knows every tool’s input schema. What it
did not know was what those schemas used to be — internal/codegen could
fingerprint a catalog and diff two of them, but nothing ever stored one, so
every diff was against nothing and the question “when did this change” had no
answer anywhere in the program.
So internal/diagnose keeps one. Each time the daemon reads schemas, it
records every tool’s flattened shape and appends whatever differs to a per-tool
change list in <state>/catalog-history.json. That file is the missing half.
With it, a script that calls a tool with no arguments after that tool gained a
required one is not a mystery; it is two facts mcpx is holding, put next to
each other:
demo.echo: the argument object is no longer optional: message is required
(schema changed 2026-09-20: `message` became required)
you wrote: await demo.echo() (line 2)
minimum: await demo.echo({ message: "" })
Three decisions inside that are worth stating.
The scanner does not parse TypeScript. It looks for two identifiers, a dot and a parenthesis, and skips every construct that could hide one: strings, template literals, both comment forms. A call assembled dynamically is invisible to it, and that is the correct failure — a diagnostic about a call nobody wrote is worse than no diagnostic. It also stops at the first sign of not knowing: a spread in an argument object silences the missing-argument check entirely, because a spread can supply anything.
Only configured namespaces are diagnosed. console.log(x) and
JSON.parse(s) are indistinguishable from tool calls at this level. Having an
opinion about somebody else’s code is not this package’s business.
The issue’s example needed correcting. It reads:
demo.create_issue: argument 1 is now required (`options` became required).
you wrote: await demo.create_issue("title")
minimum: await demo.create_issue("title", {})
mcpx’s generated client does not take positional arguments. codegen binds
every tool as fn(args?: {...}) — one object, optional exactly when the schema
has no required properties. So the diagnostic mcpx can actually produce is
“the argument object is no longer optional”, which is the same fact in the
shape the generated code has. Anything else would be advice that does not
compile.
The upstream half, diagnose.CallError, runs in Registry.Call
(internal/daemon/callerr.go), the one place a failed call passes on its way
to every surface. The server’s error stays first and whole; the rendered
diagnostic follows it, and /v1/call carries the same finding as data:
{ "error": "mcp error -32602: Invalid params\ndemo.create_issue: the call was rejected and repo is required but was not sent (schema changed 2026-09-30: `repo` was added and is required)\n minimum: await demo.create_issue({ repo: \"\", ... })", "diagnostics": [{ "kind": "invalid-params", "tool": "demo.create_issue", "field": "repo", "changed": { "when": "2026-09-30T…", "kind": "argument-added", "what": "`repo` was added and is required" }, "fix": "await demo.create_issue({ repo: \"\", ... })", "fatal": true }] }
So mcpx call prints it, mcpx --json call puts the document on stdout, a
task’s result carries it, a script’s ToolError has .diagnostics, and
mcpx_call shows the text. Data beside the prose, because an agent can act on
field where it would have to parse a sentence.
Three conditions decide whether it runs, and each is there because of what it
said without them. Only an answer from a server is explained: a timeout or a
server that would not start never answered, so the arguments were not at
fault. Not a code in -32000..-32099: mcpx’s own client reports a dropped
connection as -32000 connection closed: unexpected EOF, and the text match
read “unexpected” as “expected” — a crash was explained as a missing argument.
And not a namespace whose schemas have not been read, where CallError said
“no server is configured under this namespace” about a configured server; it
used to answer that for any failure, before it looked at the code at all.
Not covered: mcpx_call from a client that can answer questions goes through
CallAsk, which leases the pool itself; /v1/call/{server}/{tool} carries the
text but not the field; and a server that reports bad arguments as a result
with isError has made a successful call, so there is no failure to explain.
--diagnose-preflight is on by default on run and exec. The failure it
catches surfaces otherwise as an error from a server, halfway through, after
the side effects of every call before it.
2. Elicitation mcpx raises for itself
Two policies, both off by default, both landing on Registry.Call between
resolving an instance and using it.
Disambiguation (elicit.disambiguate, never | ask). It asks only when
the answer is genuinely open: the server is exclusive, so its instances are
distinct things rather than copies; several are live; and the caller’s own
scope key names none of them. When the caller already has an instance, the
scope key is the answer and a question would be theatre. The form is an enum
of the live keys plus “new”, each with what it is doing — holders, calls,
idle time, pid — because a choice without that is not a choice.
Destructive confirmation (elicit.confirmDestructive, off). Before a call
to a tool that may be destructive: annotated destructiveHint: true, or (the specification’s default) not annotated readOnlyHint: true or destructiveHint: false. This is the step a
human-driven client has and a script does not.
The two disagree about what a silent deadline means, deliberately. An
unanswered disambiguation takes elicit.disambiguateDefault, which is new —
a caller that wanted a particular browser and did not say so gets a fresh one
rather than somebody else’s. An unanswered confirmation refuses the call.
There, doing nothing is safe; here, doing nothing is the destructive thing.
Routing needed no change in internal/elicit. A confirmation is a lone
boolean called confirm, which Route already sends to a human because
consent is not the agent’s to give; a disambiguation is an enum, which goes to
the agent that raised the call. internal/elicit/policy.go adds only the
constructors, whose job is to make it impossible to raise one of mcpx’s own
questions without a default and a deadline.
The daemon had no access to settings before this — daemon.Options carries a
config.Config and nothing else — so settings.Resolve folds the same
configuration files and environment the CLI reads. Whether a destructive call
is confirmed is not a question with two correct answers depending on which
process asked.
Several daemons, the other disambiguation case in the issue, is not here. It belongs to the plugin: the question is which daemon serves a directory, and by the time mcpx is answering, that has been decided.
3. Recipes
A recipe is not a new artefact. It is a script already on the search path that declares its own holes:
// Close stale issues in a repository.
// @param repo:string which repository, as owner/name
// @param days:number = 30 how old counts as stale
const found = await demo.search({ repo: @repo, olderThanDays: @days });
Declaration is a header comment because a comment survives every runtime,
cannot affect execution, and is visible in the file somebody edits. References
are @name, the spelling the launcher already uses for its own holes.
A recipe is also an MCP tool. The @param header is already a typed
schema, so tools/list carries one tool per recipe, named recipe_<name>,
with that schema and the summary as its description. Calling it runs the
recipe through /v1/recipes/{name}/run – the same render, diagnostics and
runner mcpx recipes run uses. Exposure is on by default; // @tool false
in the header opts one out. The number exposed is capped
(DefaultToolBudget, 25) so fifty saved scripts do not become fifty schemas
in every host, and mcpx catalog ends with the count, the cap and any recipe
kept out – a name another producer already claimed is reported there rather
than silently dropped.
Values are substituted as JSON, never as text. A recipe runs with the user’s credentials; raw substitution would make every placeholder an injection site. A string arrives quoted, an object arrives as an object, and a recipe with an unfilled required hole is an invalid script — which is the right way round, because it cannot then be run by accident.
Matching is a score over four things the recipe already carries: its name,
its summary, its placeholder names and the tools it calls. No model, no
embedding, no index. The same request gives the same recipe every time, at no
cost. Decide refuses to choose when the leader is not ahead of the runner-up
by recipes.matchMargin (150%, half as much again), because running the wrong
saved script is a side effect rather than a wrong answer.
JavaScript built-ins are excluded from the tool list. console.log counted as
a tool makes every recipe match the word “log”.
Missing placeholders become one form with one deadline, not one question each: a person filling in four fields is doing one thing, and four deadlines are four chances for a run to die half-configured.
4. /prompt, which is POST /v1/intent
The name. POST /v1/prompt was taken, by the operation that renders an
upstream server’s MCP prompt, and Go’s mux keys on method and path. Renaming
somebody else’s operation to free a word was not worth it. The CLI command is
mcpx prompt, which is what the issue asked for; the route is /v1/intent.
The order is recipes first, generation second, and generation is off by
default (prompt.sample: never). The issue sequences sampling-backed
generation last and conditions it on there being a caller that can answer
sampling; most cannot, and a caller that cannot pays the entire deadline to
find out. With it off, an unmatched request returns the ranked recipes and
says so in one sentence. That is the honest default, and it is also the one
that does not make mcpx prompt hang for ninety seconds on a machine with no
answerer.
With it on, the request goes out as an ordinary MCP sampling request through
the same broker that carries every other question — so the plugin, an agent
watching mcpx elicit, and an MCP client that declared sampling all answer it
without learning anything new. It is given the slice of the catalog the
existing search ranked for this request, inside prompt.catalogBudget tokens,
and nothing else. The whole reason mcpx is worth having is that tool schemas
stay out of a model’s context; a generation request has to send some, so it
sends as few as it can.
The generated script is validated with the step-1 diagnostics before anything
runs, and returned rather than run unless autonomy: run is asked for.
Whatever is asked for, the daemon’s autonomy.max is the most it does: a
request above it — in a body, the X-Mcpx-Settings header, the CLI’s
--run, an MCP tool call or the plugin — is lowered to it, not refused, and
the answer carries requested and clampedBy beside autonomy. The CLI
prints requested run, clamped to propose by autonomy.max (...) on stderr.
The same ceiling bounds repair.autonomy (whether a failed call carries
diagnostics) and hooks.autonomy (whether configured script hooks run).
Running a script from the daemon
recipe_run and intent in run mode need to execute something, and the
daemon has never run scripts. Server.execScript calls internal/runner
directly, against the daemon’s own endpoint. This is a seam, not a design.
POST /v1/exec has since landed, with its own options and permission model
(internal/api/ops_exec.go, internal/daemon/routes_exec.go), but
execScript (internal/daemon/consumer.go:517) has not yet been rerouted
through it. It is one function, marked, for exactly that reason.
The concerns from the issue, and what was decided
Scope — “anything that makes mcpx call a model has to justify that it isn’t
rebuilding the harness.” Three of the four things built here never call one.
The fourth originally stopped there: option 3 from the issue, a configured
provider with an API key, was not built, on the grounds that it is the one
that makes mcpx an agent. That conclusion has since been reversed by the
facts #1925 records: the 2026-07-28 MCP revision deprecates sampling
(SEP-2577) and names integrating directly with provider APIs as the
migration, and no opencode — the primary consumer — declares sampling at
all. So a configured provider now exists, as one backend of the thin
provider layer in internal/model: a Provider interface with a single
Generate, reached only through the model.* settings, with
model.provider defaulting to none — a user who configures nothing gets
exactly the behaviour described above. Sampling through the caller survives
as another backend of the same layer: it is mcpx asking the harness it
already sits under, which remains right where a client declares it. What
has not changed is the guard that made the original refusal coherent: no
operation calls a model on a path where a deterministic answer exists, and
a provider error returns the deterministic result plus a note that the
model was unavailable. A Generate function is not an agent; the harness
mcpx sits under still owns the loop, the tools and the session.
Trust — “generated code runs with the user’s credentials.” Return the
script by default (prompt.autonomy: propose, one level of the dial in
decisions/0002); run only on explicit request.
Recipe values are JSON-encoded, so a recipe cannot be turned into an injection
site by its arguments. A recipe name that would become a path is refused.
Latency and cost — “never sample on a path that doesn’t need it.” The deterministic diagnostics come first and answer the case the issue identified as the common one. Recipe matching is free and deterministic. Sampling happens on exactly one path, only when nothing matched, and only when it has been turned on.
Headless callers. Every question mcpx raises for itself carries a default
and a deadline, and elicit.Ask exists so that forgetting either is not
possible. The defaults differ by question, for the reasons above. A caller
that gets no answer is told which question it raised, so it can look up what
was asked rather than guessing.
Not built
- A language service. The issue is right that suggesting a fix to a script
is LSP work rather than MCP completion, and right to keep the two apart.
internal/diagnoseis the part an LSP would need; the server itself is a separate piece of work and nothing here presumes its shape. - Sampled rewrites of a diagnostic. When the caller is an agent it reads
the error either way; the value is in the error being specific, not in a
model rewording it. If a human-facing front end ever wants a proposed
rewrite,
diagnose.Diagnosticis already the structured input for one. - Daemon disambiguation, as above: the plugin’s question, not mcpx’s.
Bugs found on the way
The schema cache (cacheVersion) is not invalidated when a cached entry gains
a field. Adding Annotations to mcpclient.Tool parsed cleanly against an
existing cache file and left it empty, which presented as the destructive-call
policy silently never firing — the annotations were there upstream and absent
from the cache nobody had reason to reread. The version is bumped, with a
comment saying why; the shape of the trap is still there for the next field.
Running mcpx in a container
What Containerfile builds, what it deliberately does not, and what was
measured along the way.
id: running-mcpx-in-a-container
since: pre-release
created: 2026-09-30T04:00:00-05:00
last-updated: 2026-10-30T06:40:00-05:00
status: implemented
A starting point rather than a shipping image. The goal was the smallest thing that actually works – builds, runs, and can execute a script – so that the decisions that come next are made against something real instead of a design.
1. What it is
Three stages:
| stage | image | why |
|---|---|---|
build | golang:1.27-bookworm | go.mod asks for 1.26.7; the image ships 1.27.1 |
bun | oven/bun:1-debian | a pinned bun, so the build does not depend on a release URL |
| runtime | debian:bookworm-slim | ordinary, and has the glibc the builder linked against |
All three are pinned by tag and digest. The tag says what it is; the digest says which one. A tag moves, and a build that depends on when it ran is not a build.
The runtime stage adds ca-certificates, copies in bun and mcpx, drops
to an unprivileged user, and sets ENTRYPOINT ["mcpx"]. It has no git; §3
says why that is not a loss.
The build context is the module directory, because the Containerfile copies
go.mod and builds ./cmd/mcpx — both relative to pkgs/mcpx, not to the
repository root:
cd pkgs/mcpx
docker build -f Containerfile -t mcpx .
docker run --rm mcpx --version
docker run --rm -v "$PWD/.mcpx.json:/work/.mcpx.json:ro" mcpx ls
2. The JavaScript runtime
The image ships bun. mcpx exec and mcpx run are the point of mcpx, and
neither can do anything without a JavaScript runtime – runner.Detect
returns no JavaScript runtime found and the command fails. An image that
cannot run a script is an image that can only do the things a plain MCP client
could already do.
Bun because it is the smallest of the three. Measured on this machine (linux/arm64, the binary inside each project’s own Debian image):
| runtime | binary |
|---|---|
| bun 1.4.2 | 79.4 MB |
deno (denoland/deno:debian) | 85.0 MB |
node 22 (node:22-bookworm-slim) | 122.2 MB |
Smaller margins than the x86-64 figures usually quoted, but the same order.
runner.Resolve picks the first of script.runtimeOrder (by default deno, bun, node) on PATH, so an image
with only bun would find bun anyway. MCPX_SCRIPT_RUNTIME=bun is set anyway,
because relying on absence to select a default means adding a second runtime
later silently changes which one runs. The setting is script.runtime
(--script-runtime, script.runtime in config); naming a runtime the image
does not contain is an error, not a fallback, so anyone who wants deno’s
permission model has to add deno to the image.
That is the one real cost of choosing bun: bun has no permission model, so
any script.permissions profile other than all is refused under it (see
runtimes.md). Node enforces the readnet profile through
--permission; deno enforces all of them.
Inside a container that is a smaller gap than it is on a laptop, but it is a
gap.
3. No git
The repo and worktree sharing scopes key server instances by the
repository a call came from. They used to ask git, and the first version of
this image installed git so they could: 105 MB, most of it perl.
They no longer ask git. mcpx reads .git itself – a port of git’s own
discovery rules, compared against git rev-parse case by case in the test
suite – and runs git only for the few layouts that port does not vouch for:
a repository format newer than it knows, an extension it has not heard of, a
config file it cannot parse. Without git those key by directory, and mcpx doctor says which file it could not read. The full record, every case and
what was decided for it, is docs/git-discovery.md.
Taking git out cost nothing, because in this image it had not been working:
$ docker run --rm -v "$PWD:/work:ro" --entrypoint git mcpx:before -C /work/proj \
rev-parse --git-common-dir
fatal: detected dubious ownership in repository at '/work/proj'
To add an exception for this directory, call:
git config --global --add safe.directory /work/proj
A mounted checkout belongs to the host’s uid, the image runs as 1000, and git
refuses a repository someone else owns. So with git installed, the repo and
worktree scopes fell back to per-directory keys for every mounted checkout –
the situation the old version of this section said git was there to prevent.
Native discovery does not check ownership: safe.directory exists to stop
git running configuration from a repository someone else controls, and
discovery runs nothing.
What was verified in the image with no git on PATH, one daemon for the whole
run, a real repository with a linked worktree (made with
worktree.useRelativePaths, so its paths survive the mount) and a submodule,
a bare repository, and a tmpfs mounted inside the checkout:
INSTANCE CALLS KEY
byrepo#2 4 repo:/work/proj/.git proj, proj/sub, proj-wt/sub, and exec from proj-wt
byrepo#3 1 repo:/work/proj/.git/modules/mod the submodule is its own repository
bytree#2 2 worktree:/work/proj proj and proj/sub
bytree#3 2 worktree:/work/proj-wt proj-wt/sub and exec
bytree#4 1 worktree:/work/proj/mod
bytree#5 1 cwd:/work/bare.git no worktree: core.bare is true in /work/bare.git/config
bytree#6 1 cwd:/work/proj/mnt/inner discovery stops at the mount point /work/proj/mnt
tiny#1 1 global
The old image’s git, told to trust the mount with safe.directory=*, gave
the same answers for the submodule, the bare repository and the mount – and
refused the other three directories with unknown repository extension found: relativeworktrees, because Debian’s git is 2.39 and the checkout was
made by 2.55. With GIT_DISCOVERY_ACROSS_FILESYSTEM=1 in the
container’s environment the mounted directory keys to worktree:/work/proj,
as it would in git. mcpx ls listed all three namespaces, call and exec
(on bun) reached each, and mcpx doctor -v from the linked worktree said:
ok git repo /work/proj/.git, worktree /work/proj-wt; resolved natively; no git on PATH,
which only matters for a repository native discovery cannot read
A repository that does need git is visible, not silent: the key falls back to
the directory, the daemon logs why once, and doctor turns the line into a
warning that says to install git. Adding it back is one package in the
apt-get install line.
4. Static linking: the policy
CGO_ENABLED is unset in an ordinary Go build, which means enabled on a
native build, and two packages change behaviour when it is:
internal/logging/trace.go:9importsos/user, which under cgo callsgetpwuid_rthrough libc rather than parsing/etc/passwd;netis reachable from most of the tree, and under cgo its resolver isgetaddrinfo, which honoursnsswitch.conf.
Policy (#1987): the artifacts that ship as a bare mcpx binary ship
static. The trade-off is the two bullets above — pure-Go os/user does
not consult NSS and the pure-Go resolver ignores nsswitch.conf — and it
is accepted for these artifacts because neither behaviour is load-bearing
for a daemon/CLI whose state lives in its own directories, while a binary
that only runs beside one exact glibc (or one Nix store path) fails
everywhere else silently.
| artifact | static? | note |
|---|---|---|
| Nix package | yes | package.nix sets env.CGO_ENABLED = "0"; the installed bin/mcpx is a makeBinaryWrapper launcher by design — the binary it launches is the static one |
this image’s mcpx | yes | the Containerfile defaults ARG CGO_ENABLED=0 and the build fails if the binary does not record the setting it was built with (go version -m) |
| this image as a whole | no | bun is a dynamically linked distribution binary; the runtime stage is debian:bookworm-slim for bun’s glibc, no longer for mcpx’s |
| release tarball | not decided here | the release pipeline is #1881’s; this policy is the input it should consume |
Measured, not assumed (the CGO_ENABLED=0 build, before it became the default):
$ docker build -f Containerfile --build-arg CGO_ENABLED=0 -t mcpx:cgo0 .
$ docker run --rm --entrypoint sh mcpx:cgo0 -c 'ldd /usr/local/bin/mcpx'
not a dynamic executable
$ docker run --rm mcpx:cgo0 --version
mcpx 0.1.0
$ docker run --rm -v "$PWD:/work:ro" mcpx:cgo0 \
exec 'const r = await tiny.echo({ message: "cgo0" }); console.log(String(r));'
you said: cgo0
Same size to the byte-ish – 14 811 298 against 14 811 401 – so the
pure-Go os/user and resolver cost nothing here.
Tier-2 container legs
Tier 2 (#1987) means: runs nightly and on pushes to main that touch
pkgs/mcpx, never gates a PR. The legs are
scripts/tier2-containers.sh, run by the mcpx tier-2 containers
workflow; each leg asserts the surface its image can actually support,
including mcpx’s own clean refusal where a surface is absent:
| leg | image | asserts |
|---|---|---|
alpine-nonexec | alpine:3.21 | --version; exec refuses with “no usable JavaScript runtime” |
alpine-bunmusl-exec | alpine + bun’s musl build | exec runs a script |
scratch-nonexec | FROM scratch + CA certs | --version; the same clean exec refusal |
distroless-static-nonexec | gcr.io/distroless/static-debian12 | --version; the same clean exec refusal |
distroless-cc-deno-exec | gcr.io/distroless/cc-debian12 + deno | exec runs a script |
Deferred, with the reason recorded on #1987: an alpine + nodejs exec
leg. Verified 2026-10-30, mcpx’s node path dies with
ERR_NO_TYPESCRIPT under Alpine’s nodejs build, so the leg would
enshrine a failure instead of catching one; it joins the matrix when the
node invocation works on a musl node. Tier 3 (fedora, flatpak,
silverblue/kinoite hosts, the Windows port) is unchanged: separate
issues when a user appears.
5. Where the size goes
Measured on linux/arm64 with the digests pinned in Containerfile, before
and after §3, both built on the same machine:
| layer | with git | without |
|---|---|---|
debian:bookworm-slim | 108 MB | 108 MB |
apt-get install | 105 MB (ca-certificates git) | 10.4 MB (ca-certificates) |
| bun | 79.4 MB | 79.4 MB |
| mcpx | 14.8 MB | 14.9 MB |
| layers, uncompressed | 307 MB | 213 MB |
| compressed content | 106 MB | 75.6 MB |
docker images disk usage | 413 MB | 288 MB |
The 413 MB this document used to quote is the last row, which is the uncompressed layers plus the compressed blobs they were unpacked from. The layers are what a running container occupies; the compressed size is what a pull transfers.
git costing more than bun was not expected: with --no-install-recommends
already set, the weight is perl, which Debian’s git depends on outright.
What remains of the apt layer is ca-certificates and the openssl it pulls
in, for the registry. perl-base is still in the image – it is part of
Debian’s essential set, in the base layer – but nothing in mcpx uses it.
6. What was deliberately left out
- No per-PR container gate. The tier-2 legs (§4) run nightly and on pushes to main; building images on every PR is a cost the tier system deliberately does not pay.
- No full e2e suite inside an image. The e2e suite spawns daemons and binds unix sockets; the tier-2 legs assert surfaces, which is a different, smaller harness.
- No shipped scratch or distroless variant. They exist as tier-2 test
legs; a shipped minimal image still drops
exec(no static JS runtime exists to put in it), which is a product decision nobody has taken. - Not wired into
package.nix. The Nix build and the container build are two ways to produce the same binary; making one depend on the other buys nothing yet.
7. What to watch
The .dockerignore excludes result – a dangling symlink into the Nix store
that does not exist inside the build. If a nix build has been run in the
tree and this file is removed, the container build will fail on a broken
symlink, and the error will not say why.
Declared but never read
A knob that does nothing is worse than one that does not exist. A missing flag
fails loudly at the prompt; a flag that parses, appears in --help, appears in
mcpx config --schema and then changes nothing costs somebody an afternoon
before they conclude the feature is broken rather than the wiring.
This has shipped repeatedly. --typecheck on exec (#66). Five exec flags at
once (#75). MCP sampling, declared and unreachable (#39). The opencode
plugin’s permission.ask hook. Every one of them was found by a person trying
the thing, which is the expensive way.
This is the audit that went looking for the rest of them, and the guard that makes the next one a test failure.
What the audit was
The settings registry had 149 entries when this audit ran; it has 164 today,
and the counts below are the ones the audit measured, not current figures. Each one derives a config key, an
MCPX_ variable and a flag from a single declaration, which is the whole
argument for the registry existing. The question is therefore not “is this path
string mentioned anywhere” but “does anything read the resolved value”.
Those are not the same question, and the difference is where the interesting
half of the findings came from. pool.max was read from a config file, through config.Config.Pool, which
the JSON decoder fills. MCPX_POOL_MAX and --pool-max landed in the resolved
Set, and nothing on that path looked at them. Grepping for "pool.max" finds
a match in internal/defaults (an error label) and calls it live. A user who
set the variable, saw nothing change, moved it into their config file and saw it
work would reasonably conclude the variable name was wrong rather than that the
layer was. That is fixed now: config.ApplyPoolSettings
(internal/config/poolsettings.go:36) copies every layer of the resolved Set
into Config.Pool, and a runtime change through settings set reloads the
pools (internal/daemon/routes_settings.go:268), so the change reaches pools
that are already running.
So the audit looked for one specific shape: the path handed to an accessor on a
resolved Set — set.Int("pool.max"), a.Settings().Bool(...),
cs.Duration(...). That is the only reader in the program that has seen the
file, the variable and the flag together. os.Getenv has seen one layer. A
config struct field has seen one. A hand-written flag variable has seen one.
It found 54, in a registry of 149.
Verdicts
Counts: 24 wired, 3 deleted, 9 legitimately external (the plugin — seven are named below, and two more were reclassified while the audit ran; there are twelve plugin-scoped settings today), 1 left open, 2 added (the inverse bug), plus 15 settings the naive path-string grep called dead that the accessor scan cleared.
Wired
| setting | layer | verdict | evidence | fix |
|---|---|---|---|---|
pool.max pool.min pool.sharing pool.scope pool.idleTimeout pool.callTimeout pool.startTimeout | settings | was partially dead — file only; wired | config.Config.Pool filled by the decoder (config.go:174); Resolve picks from it (config.go:616, the pick for max); no accessor read the resolved Set until ApplyPoolSettings (poolsettings.go:36) | config.ApplyPoolSettings folds the resolved value into Config.Pool — the layer beneath a server’s own mcpx block, so a per-server choice still wins. Called at the five places a config is loaded next to a resolved set (internal/daemon/routes_settings.go:659, internal/cli/root.go:157, internal/cli/root.go:392, internal/cli/connect.go:216, internal/cli/doctor.go:97). A runtime change through settings set or settings unset reloads them (routes_settings.go:268), and a reload that fails is rolled back (routes_settings.go:280). |
logging.maxBytes logging.maxLines logging.maxAge logging.keep | settings | dead | every NewFileSink call passed FileOptions{Dir: dir} and nothing else (cli/root.go, cli/commands.go, cli/logs.go); file.go:87-96 fell back to the built-in constants | cli.fileOptions builds the whole policy from the set; both durable sinks use it |
logging.file | settings | dead | nothing decided whether to open a sink at all | guards sink creation in the daemon and in run/exec |
logging.include | settings | partially dead — flag and file only | os.Getenv("MCPX_INCLUDE"), a name the registry does not own (root.go:157) | reads set.List("logging.include") |
script.before script.prefix script.onSuccess script.onError script.suffix | settings | partially dead — file and short flag only | cfg.ScriptPhase(...) plus a hand-written repeatable flag; --script-prefix and MCPX_SCRIPT_PREFIX were bound, recorded and never read | App.phaseValues adds Set.ListAboveFile(path). Only the layers above the file: cfg.ScriptPhase folds the file layers itself, with an inheritance marker the registry does not model, and taking both would run a line twice. |
script.env | settings | dead twice over | --env is a hand-written alias so Bind skipped it; and envPairs was assembled, preflight-checked and then ignored in favour of envVars.Values() at the point the environment was actually built | the loop reads envPairs |
script.launcher | settings | dead | only *launcherFlag was read | falls back to the resolved value; the Bare form resolves to none, so --script-launcher and --no-launcher agree |
paths.state paths.cache | settings | partially dead — env alias only | daemon.ResolvePaths reads MCPX_STATE_DIR/MCPX_CACHE_DIR by name (paths.go:37-42) | daemon.PathsAt, applied in parseFlags once all three layers are known. The by-name reads stay, allowlisted: they run before any config file has been found. |
output.json | settings | partially dead — global flag only | --json consumed in main before the subcommand; mcpx ls --json set output.json and left App.JSON false | App.adoptSettings in parseFlags |
catalog.instructions | settings | dead | only --no-instructions on types was read | ANDed with the setting; Commands narrowed to types, the only output that carries instructions |
proto.askTimeout proto.askPoll proto.askRounds proto.stateTTL proto.sessionIdle | settings | dead | internal/mcpserver read defaults.Proto* at the point of use (ask.go:155,165,196, state.go:59, server.go:1253) | mcpserver.Timing, passed in from cli/serve.go. Passed rather than read there, because the protocol package should not know a config file exists. |
proto.askTTL | settings | dead | routes_proto.go:260 | s.set.Duration("proto.askTTL") |
plumbing.sourceDirAllowed plumbing.sourceDirRecursive plumbing.sourceProbePaths | settings | dead — and source.Options’ own doc comment claimed otherwise | both call sites wrote the three booleans as literals, and disagreed: a launcher refused a directory while a phase accepted one | cli.plumbingSourceOptions |
plumbing.strictUnknownKeys | settings | dead | Set.Unknown() was reported by doctor as a warning and nothing else | refuses in App.Settings(), after the files that carry the switch have been read |
plumbing.validatePaths | settings | dead | the one preflight path check ran unconditionally, on os.Getenv("MCPX_LOGGING_DIR") | guarded by the switch, and reads the resolved logging.dir |
plumbing.indexOnQuery | settings | dead | four unconditional st.Ingest() calls | guarded in cli.openStore and daemon.openStore. log --follow still ingests: its whole purpose is to see what was just written. |
plumbing.launcherPlaceholderRepeat | settings | dead | opts.AllowRepeat came only from --allow-repeat | appended from the setting |
elicit.pendingLimit | settings | dead | elicit.go:415 used defaults.ElicitPending | passed in as Filter.Limit; the broker is a store and should not read policy |
registry.timeout registry.pageSize | settings | dead | registry.go:43,129 used the constants | registry.Options, passed by both callers |
tasks.ttl | settings | dead | tasks.DefaultTTL() from the constant | the daemon supplies the configured value when a caller names none |
plugin.discoveryRetry | plugin | dead | TUNING.missCooldownMs: 60_000, hardcoded | goDurationMs(env.MCPX_PLUGIN_DISCOVERY_RETRY) — the plugin parses Go’s duration spelling, because that is the syntax every MCPX_ duration is written in |
completion.maxValues | settings | half-read | /v1/complete read it (ops.go:350); the MCP handler wrote 100 (server.go:1558) | Server.MaxCompletions |
Deleted
| setting | why |
|---|---|
plugin.skills | MCPX_PLUGIN_SKILLS appears in no TypeScript and no Go. Skills are directories a user copies into ~/.config/opencode/skills/ by hand; nothing registers them from a list, and there is no code for the setting to be wired into. |
plumbing.consoleReleaseOnExit | “hand the original console back before the process ends”. The console wrapper lives in the script’s runtime, which is a child process that is about to die. Restoring it changes nothing anybody can observe. releaseConsole() remains a global a script can call mid-run, which is the case that was real. |
Setting.AllowDir (a schema field, not a setting) | declared on six KindSource settings, always false, read by nothing. It also contradicted plumbing.sourceDirAllowed, which is the switch that actually decides this. |
Legitimately external
plugin.bin, plugin.binArgs, plugin.backend, plugin.toolTiming,
plugin.env, plugin.instructions, plugin.tools are read by the TypeScript
plugin through process.env, by the exact name the registry derives
(mcpx-session.ts:180-191, mcpx/daemon.ts:848-850). Verified, and now
guarded: a plugin-scoped setting whose variable appears nowhere in
plugin/**/*.ts fails the test. README mentions do not count — a README can
describe a knob nobody wired.
Left open
paths.config was circular by construction (#5): the config search path is
what produces the settings, so the settings cannot decide it. It is no longer
left open. config.ResolveChainFrom resolves the chain in two passes
(issue #1842): a discovery pass over the default locations, seeded by
MCPX_PATHS_CONFIG / --paths-config, reads only paths.config; the load
pass then resolves that list, nearest last. A paths.config found in pass 2
that differs is reported by mcpx config --sources, not applied, and a cycle
is refused naming the path that closed it. MCPX_CONFIG bypasses both
passes.
The inverse bug
The plugin honoured four variables the registry had never heard of, which
breaks the registry’s promise from the other side: mcpx settings was supposed
to answer “what will the plugin do” without anybody reading TypeScript.
plugin.remember and plugin.annotate are now declared.
MCPX_PLUGIN_HEADLESS and MCPX_PLUGIN_DAEMON_TOOLS were allowlisted at the
time, because their defaults are computed at boot — whether the plugin is on
the main thread, whether discovery was ambiguous — so a declared value would
be right in one of the two realms the plugin runs in and wrong in the other.
They are declared now, as plugin.headless and plugin.daemonTools
(internal/settings/plugin.go:105, :114), with the default auto: auto is
the declared name for “work it out from the process shape”, which is the thing
a number could not express.
The guard
internal/settings/consumed_test.go, three tests.
TestEverySettingIsReadSomewhere scans every non-test .go file outside
internal/settings for each path used as the argument to a Set accessor. A
plugin-scoped setting is checked differently: its EnvName() must appear in
the plugin’s own TypeScript sources.
The obvious alternative was a ReadBy: "daemon" field on Setting. It was
rejected, and the reason is worth stating because it generalises: that note
lives in the same struct literal as the declaration. Whoever writes the
declaration writes the note in the same keystroke, believing both, and nothing
ever checks the second half. It would have been a second declaration, subject
to exactly the bug it was meant to catch. A scan asks the code, which cannot be
optimistic.
The narrowness is the other half of the design. A scan for the path string
anywhere would have passed pool.max, script.prefix, logging.include and
every other half-wired setting in the table above — the ones that were hardest
to find and most confusing to hit. Requiring the accessor is requiring the
whole promise, because the Set is the only reader that has all three layers.
TestNoHandRolledSettingEnv closes the other direction: os.Getenv of a
variable the registry owns is how a setting becomes env-only. Three entries on
its allowlist, each because the read genuinely happens before a Set exists.
(Since replaced by TestNoUndeclaredEnvRead, which walks the syntax tree and
also refuses a variable nothing declares – see docs/environment.md.)
TestThePluginReadsNoUndeclaredSetting closes the third: a variable the
plugin honours that no setting declares.
Every allowlist entry carries its reason in the map. There is deliberately no way to silence any of the three in bulk.
The other three layers
The same shape exists wherever something is declared in one file and consumed in another.
/v1 parameters
161 declared across 60 operations at the time of the audit; 162 across 60
today. The parity tests in internal/api already
bind every route to an op and every op to an MCP tool — but they match paths,
not parameters, so a parameter can be declared, published in InputSchema(),
rendered into the OpenAPI document, marshalled into the body by a generated MCP
tool, decoded by the handler and discarded, with every test green.
POST /v1/diagnose,session— decoded into the request struct and never referenced. Its own description said “for the record”, and no record was written. Fixed: adiagnose.runline now goes to the durable log, with the session on it, which is what makes it joinable to the run it was diagnosing.GET /v1/log?chain=—Chain(id, limitPerLevel)takes no filters, solevel,event,server,tool,session,trace,grep,sinceanduntilwere all accepted and dropped. The result is the whole trace tree at every level, which reads as “that trace touched everything” rather than as a filter that did not run. Fixed: the combination is refused, in/v1and inmcpx log. The e2e test for chains was itself passing--level debug.GET /v1/stats?top=is inert for four of seven dimensions andGET /v1/events?since=loses toLast-Event-ID; both say so in their own descriptions. Not defects.
CLI flags
No flag declared with fs.String/fs.Bool in internal/cli is wholly unread.
The defects are of two kinds.
The first is structural and was fixed: Schema.Bind registers every setting
whose Commands list contains the command or is empty, so a command
accepts flags it never reads. --budget and --bias were offered on types,
ls and search; --catalog-instructions on ls and catalog. Narrowed.
The second is a class this audit did not resolve and is reported rather than fixed, because each case is a judgement about the command rather than a wiring error:
logging.*declares noCommands, so--log-level,--logging-format,--include,--keepand the rotation flags bind on every command. Onlydaemonandrun/execread them.mcpx log --log-level debugparses and does nothing. Narrowinglogging.*would be correct and is a larger change than this one.mcpx ls -v --jsonsilently drops the per-instance detail-vasks for; the JSON branch returns before it is used.mcpx doctor -v --jsonlikewise.mcpx stats errors --by slowestreports errors: a positional dimension wins over--bythroughfirstSet, silently.mcpx stats --opencodereturns before--server,--tool,--sessionand--log-dirare used.mcpx log --format json --fields ts,msgignores--format:--fieldsbypasses the writer entirely.mcpx log --reverse --followreverses the backlog and not the tail;followLogresetsq.Reverseunconditionally.mcpx prompt --mode script --runbecomesrun;mcpx recipes run x --mode run --scriptbecomes script mode. Both overwrite the flag they were given.mcpx servers add x --header K=V -- cmdaccepts the headers and drops them: they are read only inside the--urlbranch.mcpx api tools --methods alldoes nothing without--spec: six flags are read only insideif *specFlag != "".mcpx elicit show --session Saccepts--sessionand--audienceon every subcommand and reads them in two.parseFlagsleaks into the package-levelflagSetCommandmap whenfs.Parsefails, and the map is unguarded whilelazyMCPbuilds on a request goroutine.
MCP capabilities
logging— declared to every pre-2026-07-28 client. The capability means “this server sends log messages to the client”; mcpx emits nonotifications/messageanywhere in the package. Withdrawn. Thelogging/setLevelmethod is still answered, because refusing would make a well-behaved client that asked anyway treat the whole connection as degraded.resources.subscribe,*.listChanged— gated ons.Notify != nil, a property of the server. Delivery is a property of the connection, and every HTTP connection the daemon serves is built with a nil send function. So every client over the transport the daemon actually mounts was told to subscribe, had itssubscriptions/listenacknowledged, and then heard nothing for ever. Now gated onConn.canPush().*.listChangedadditionally requires a stream the client opened, and only 2026-07-28 hassubscriptions/listen. An older client was told it would receive notifications it has no mechanism to receive. Now gated on the revision too.subscriptionIdin_meta— 2026-07-28 makes it mandatory on every notification delivered on a listen stream (2026-07-28.ts:121-132). mcpx sets it on the acknowledgement only. A client with two subscriptions cannot correlate anything. Since fixed: every notification on a listen stream carries it (internal/mcpserver/listen.go:94), pinned byinternal/e2e/subscribe_test.goandinternal/e2e/protomsg_test.go.promptsandresourcesare declared unconditionally; both schemas say “present if the server offers any”. The handlers return[]correctly, so this is over-declaration rather than a lie. Not fixed — reported.events.MCPNotificationtranslatesElicitCompletedtonotifications/elicitation/complete, and nothing can reach that branch:ListenFilterhas no field for it, correctly, since 2026-07-28 dropped it.
One thing the audit found that it did not fix
Setting.EnvName() derived MCPX_PROTO_ASK_T_T_L from proto.askTTL, and
--proto-ask-t-t-l with it. The camel-case splitter did not know that TTL
is one word. proto.stateTTL, proto.askTTL, daemon.leaseTTL and
proto.serveMCP were affected.
Fixed since: a run of capitals is now one word (MCPX_PROTO_ASK_TTL,
--proto-serve-mcp), and TestNoDeclaredNameHasASingleLetterWord fails if a
derived flag or variable contains a one-letter word, which is what the next
acronym split into letters would look like.
Dependencies
Everything mcpx needs, and why. Nothing here is incidental — if it is listed, something breaks without it.
Go libraries
Nine, and pkgs/mcpx/package.nix states the trade for each one beside
the vendorHash that pays for them.
| module | what it is for |
|---|---|
modernc.org/sqlite | the log index |
github.com/charmbracelet/bubbletea | the full-screen browser, mcpx tui |
github.com/charmbracelet/bubbles | its widgets |
github.com/charmbracelet/lipgloss | its layout |
gopkg.in/yaml.v3 | OpenAPI documents, which are published as YAML as often as JSON, and which one you get is not the caller’s choice |
github.com/santhosh-tekuri/jsonschema/v6 | the JSON Schema validator (internal/schemaval): tool input/output schemas and elicitation schemas are checked with a real validator, not by hand |
github.com/tetratelabs/wazero | the WebAssembly runtime: wasm tooling and the embeddings worker run without a cgo toolchain |
golang.org/x/sys | the OS-specific syscalls the standard library does not expose (peer credentials on the daemon socket, process groups) |
tailscale.com | the tsnet identity transport (#1967): the daemon embeds a Tailscale node and is reachable by its tailnet identity, still as a pure-Go CGO_ENABLED=0 binary |
The three charmbracelet modules are one decision, not three: they cost about
1.2 MB in the stripped binary and replace raw terminal handling – escape
sequences, resize, a redraw loop – which is the kind of code that is never
finished and never correct on every terminal.
Everything else – the MCP client, the JSON-RPC framing, the process pools, the JSON Schema to TypeScript compiler, the HTTP API and the CLI – is standard library, and that is worth keeping.
Why this driver
modernc.org/sqlite is a pure-Go translation of SQLite rather than a binding.
The usual choice, mattn/go-sqlite3, is cgo, and cgo would mean:
- a C toolchain required to build, which the Nix derivation would have to carry;
- cross-compilation broken, so no building a Linux binary from a Mac;
CGO_ENABLED=0builds failing outright.
That is a large bill for a database that is only ever an index. The JSONL files remain the source of truth: the index can be deleted at any time and is rebuilt on the next query. Paying in build complexity for something reconstructible was the wrong trade.
The cost of the pure-Go driver is a slower build and a larger binary. Both are acceptable; neither affects anyone running mcpx.
What this means for the build
vendorHashis pinned inpkgs/mcpx/package.nixand must be updated when the dependency changes.- The build still needs no network beyond the vendor fetch, and still needs no C toolchain.
The standard library packages doing real work: net/http (daemon and client),
encoding/json (everything), os/exec + syscall (child processes and
process groups), sync (pools), path/filepath (path identity),
text/tabwriter (human output).
If a dependency is ever added, it should be for something genuinely hard — a tokeniser, a SQLite driver — and the trade should be stated here.
External programs
| Program | Needed for | If missing |
|---|---|---|
git | repo and worktree scopes, for repository layouts native discovery does not read | only those layouts degrade to cwd, with a log line and a doctor warning saying why |
deno, bun or node | mcpx run and mcpx exec | those commands fail; mcpx call, ls, types, catalog, search still work |
| the configured MCP servers | their own namespaces | that namespace reports error with the server’s stderr |
git is optional. mcpx finds a repository by reading .git itself –
a port of git’s discovery rules, docs/git-discovery.md – and runs git rev-parse --path-format=absolute --git-common-dir --show-toplevel only for a
layout that port does not vouch for, such as a repository format newer than
it knows. That needs git 2.31 (March 2021); an older git’s answer is refused
rather than misread. Nothing else about git is used — no fetching, no
writing, no config.
A JavaScript runtime is chosen at first use: deno, then bun, then node.
Override with --runtime or the runtime config key. Deno runs with
--no-check; the generated client is machine-written and a type error in the
agent’s own script surfaces at runtime anyway.
MCP servers are whatever the config names. mcpx spawns them with the given
command, args, env and cwd, or connects over Streamable HTTP for url
servers. Their own configuration is exactly those argv and environment
entries — MCP has no separate configuration channel.
Network
The daemon binds two listeners, both local: a unix socket, and
127.0.0.1 on an OS-assigned ephemeral port. Generated script clients
prefer the socket — Deno fetches over it with a custom HTTP client and
Bun with a fetch option (internal/codegen/emit.go) — and fall back
to the TCP port where a runtime cannot address a socket (Node’s
fetch cannot, without undici). Neither listener is reachable off the
machine; the tailnet identity transport (#1967) is the separate,
authenticated exception, and it is opt-in.
Outbound traffic happens only when a configured server is a url server, or
when a script makes its own requests.
Nix packaging
pkgs/mcpx/package.nix wraps the binary with git, deno, bun-bin and
nodejs suffixed onto PATH, so behaviour does not depend on the calling
shell. git is in that list because repo and worktree scopes are
silently weaker without it — that was a real gap, found by asking this
question.
The unit suites run in the build sandbox. The end-to-end suite spawns
JavaScript runtimes and binds unix sockets, so it runs outside with
go test ./....
Elicitation: a design
id: elicitation-a-design
since: pre-release
created: 2026-09-28T18:00:00-05:00
last-updated: 2026-09-28T18:00:00-05:00
status: partial
tags: area:protocol, area:integration
description: how a server asks a question, and how the answer gets back
through a CLI, a REST call, a script, an MCP host or a plugin.
note: largely shipped, and differently in places
This was written before the feature existed, and the feature has since
shipped – not always in the shape proposed here. It is kept as the design
record, because the reasoning is still the reasoning. What actually ships is in
docs/parity.md (the elicit_* rows) and docs/consumer.md; the authority is
internal/elicit/, internal/cli/elicit.go and the three /v1/elicit routes
in internal/daemon/server.go:424-426.
Where this document and the code disagree, the code is right. The disagreements worth knowing before you read on:
| this document says | what shipped |
|---|---|
| §2’s three “live bugs” | all three fixed: dispatch routes server-initiated requests (internal/mcpclient/client.go:404-421), negotiate no longer echoes a version it does not serve (internal/mcpserver/server.go:1092-1095), and the client declares elicitation (internal/mcpclient/modern.go:71-84) |
mcpx call --elicit=<mode> | not built. There is no --elicit flag on any command |
a waiting call exits 75 | built (#286), with a different document: mcpx call, mcpx exec and mcpx run exit 75 with the question, its schema, the mcpx elicit answer command and the task id that collects the result. The call keeps running on the daemon as a task. The wire is POST /v1/call with X-Mcpx-Input: report, answered 202 {"inputRequired": {...}} – see §5.1 |
POST /v1/elicit/<id>/answer | POST /v1/elicit/{id}/{action} – the action is in the path, the body is content only |
GET /v1/elicit/subscribe | not built. mcpx elicit watch polls; the SSE stream is GET /v1/events |
mcpx_elicit_pending | the tool is mcpx_elicit_list |
plumbing.elicitTTL, elicit.mode, elicit.rules | none exist. The TTL is elicit.ttl in defaults.json and is not a setting; the shipped policy is elicit.Route (internal/elicit/elicit.go:491), which is not rule-based |
MCPX_PLUGIN_ELICIT | does not exist |
onElicit() / ask() script globals, the TUI view, the mcpx-elicitation skill | none built |
a 202 with Location and Retry-After, an Elicitation OpenAPI component | not built. The retry shape that shipped is MCP-side input_required |
1. What elicitation actually is
Every other MCP method runs client → server. Elicitation runs the other way: a server asks the client a question in the middle of handling a tool call, and waits for an answer before finishing.
mcpx ──── tools/call ────────────────▶ server
mcpx ◀─── elicitation/create ──────── server "which account?"
mcpx ──── {action:"accept", ...} ────▶ server
mcpx ◀─── the tool result ──────────── server
Two modes:
| mode | carries | for |
|---|---|---|
form | requestedSchema, a flat object of primitives | a value the caller can type |
url | url + elicitationId | anything that must not pass through a model: credentials, OAuth, payment |
Three answers, and the distinction matters:
accept— approved, withcontentmatching the schemadecline— explicitly refused. The server should offer an alternative.cancel— dismissed without choosing. The server may ask again later.
Collapsing decline and cancel is the obvious mistake. A server that treats “not now” as “never” stops offering something the user wanted.
Two eras, and mcpx is in the older one
This is bigger than elicitation, and correcting it is a prerequisite.
The spec divides implementations into two eras:
| versions | how it works | |
|---|---|---|
| legacy | 2025-11-25 and earlier | initialize handshake establishes a session; version negotiated once |
| modern | 2026-07-28 (current) and later | no handshake; every request carries its version in _meta; server/discover is mandatory |
The current version is 2026-07-28. It is not a draft.
The compatibility matrix is unforgiving. A modern client against a legacy server fails. A legacy client against a modern server fails. Only a dual-era implementation bridges them, and it must implement both.
mcpx was legacy on both sides when this was written: its client sent
initialize with ProtocolVersion = "2025-06-18", and its server answered
initialize. It is dual-era now – the client offers 2025-11-25
(internal/mcpclient/client.go:22) and probes modern with server/discover
first, and the server serves both eras.
And its server lies. negotiate() echoes whatever version the client
asked for:
$ printf '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2026-07-28"}}\n' | mcpx serve
we claimed: 2026-07-28
$ ... server/discover
-32601 no method server/discover
A modern client is told “yes, I speak 2026-07-28”, and then finds no
server/discover and no _meta handling. The spec says an unsupported
version MUST get an UnsupportedProtocolVersionError (-32022) listing
what is actually supported. This was a second live bug, independent of
elicitation. It is fixed: negotiate echoes a version only when it is both
supported and non-modern, and otherwise answers Latest
(internal/mcpserver/server.go:1092-1095); server/discover is implemented
(server.go:535) and -32022 exists (server.go:327).
Elicitation in each era
The delivery mechanism differs, which is why the era question comes first.
- Legacy: a genuine mid-flight request. The server sends
elicitation/createwhiletools/callis still open, and waits. - Modern: a retry. The call returns an
InputRequiredResultcarryinginputRequests, the call ends, and the client re-sends the original request withinputResponsesattached.
The modern shape is dramatically easier for mcpx, because nothing stays open: an invocation can return and a person can answer an hour later. The legacy shape is what every server that exists today actually speaks.
So the broker below is designed around the modern semantics – a question is state with a deadline – and the legacy path is implemented by holding the call open and feeding it from the same broker. Both are needed. One shape of storage, two shapes of wire.
2. What is in the way today
recvLoop silently drops server-initiated requests
internal/mcpclient/client.go:
var resp rpcResponse
if err := json.Unmarshal(raw, &resp); err != nil { continue }
if resp.ID == nil { continue } // notification
ch, ok := c.pending[*resp.ID] // ← a request has an ID too
if ok { ch <- &resp } // ← no match, frame discarded
A server→client request has both an id and a method. It unmarshals
cleanly, finds no pending entry, and is dropped. The server then waits
forever, the call hits pool.callTimeout, and the error says “timed out” —
which is true and useless.
This was a live bug, not only a missing feature, and it has been fixed:
recvLoop delegates to dispatch, which looks for a method as well as an
id and routes the frame to handleServerRequest
(internal/mcpclient/client.go:404-421).
initialize does not declare the capability
It did not, when this was written. mcpx advertised tools, resources,
prompts, and a well-behaved server checking for elicitation would not send
one – protection by silence, which is luck rather than design. The client now
declares elicitation (with form, and url when a handler is set), roots
and sampling (internal/mcpclient/modern.go:71-84, wired in at
client.go:315).
Nothing owns “a question awaiting an answer”
Every existing mcpx state is either in-process (a lease) or durable and append-only (the log). A pending elicitation is neither: it outlives a process and it is mutated once.
3. The shape: a broker
One new component. internal/elicit.
type Request struct {
ID string // elc-<16 hex>
Trace string // the call that triggered it
Parent string // that call's own parent
Session string // who owns it
Server string // which server is asking
Tool string // during which tool call
Mode string // form | url
Message string // why, in words
Schema json.RawMessage // form mode
URL string // url mode
Created time.Time
ExpiresAt time.Time // Created + TTL
State string // pending | answered | expired | abandoned
}
type Answer struct {
ID string
Action string // accept | decline | cancel
Content map[string]any // accept, form mode only
By string // which surface answered
At time.Time
}
Stored in the existing SQLite index beside records, in two tables. It is
already there, already rotated, already queryable, and a pending question is
exactly the kind of thing somebody will later want to ask mcpx log sql
about.
The broker is one object with four operations:
Proposed as four operations; what shipped is seven, in
internal/elicit/elicit.go:
OpenRequest(r Request) (Request, error) // register; fills id, mode, TTL
Await(ctx, id) (Answer, error) // block until answered or expired
Respond(a Answer) error // from any surface
Get(id) (Request, bool, error) // the question
Lookup(id) (Answer, bool, error) // its answer, once it has one
Pending(f Filter) ([]Request, error) // what is outstanding
Close() error
Await takes no timeout: the deadline rides on the Request, so one
question cannot be awaited with two different lifetimes.
Await blocks on a channel when the answerer is in-process, and polls the
table when it is not. Polling is acceptable here because the interval that
matters is human — a 250 ms poll against a question a person answers in twenty
seconds is not a cost anyone can measure.
Time to live
Every request carries one. The default is elicit.ttl, 120 s, in
internal/defaults/defaults.json (defaults.ElicitTTL). It is not a
registry setting and not per-server overridable: OpenRequest
(internal/elicit/elicit.go:206) and the daemon’s hooks use the constant
unconditionally. Making it a setting is unfinished work, not a decision.
On expiry the broker answers cancel on the requester’s behalf, because that
is what expiry means: dismissed without an explicit choice. Answering
decline would tell the server the user said no, which is a different and
wrong thing.
The TTL is also the backpressure. Without it a stuck server accumulates questions nobody will ever see, and the table becomes a graveyard.
4. The policy layer: answering without a human
A CLI invocation frequently has no human attached. Guessing is unacceptable; failing every time is unusable. So the broker consults a policy first, and only asks a human if the policy abstains.
Not built. None of elicit.mode, elicit.ttl as a setting, or
elicit.rules is in the registry, and MCPX_ASSUME_YES does not exist. The
policy that shipped is elicit.Route (internal/elicit/elicit.go:491), which
chooses an audience rather than matching rules, together with the settings
elicit.disambiguate, elicit.disambiguateDefault, elicit.confirmDestructive
and elicit.askTimeout. The block below is the design, kept for the reasoning.
{
"elicit": {
"mode": "ask", // ask | auto | decline | error
"ttl": "120s",
"rules": [
{ "match": { "server": "github", "field": "username" }, "answer": "octocat" },
{ "match": { "mode": "url" }, "action": "decline", "because": "no browser in CI" },
{ "match": { "field": "confirm" }, "action": "accept", "when": "MCPX_ASSUME_YES=1" },
],
},
}
The four modes:
ask(default, interactive) — a human is expectedauto— rules only; anything unmatched iscancel. This is the CI mode.decline— refuse everything, politely. For a sandbox that should never be asked.error— fail the call outright, so a script that did not expect a question does not silently get a cancelled one
Rules are matched before a human is ever asked, and every match is logged with the rule that fired. A policy nobody can audit is a policy nobody should trust.
5. Every surface
The same broker, six front doors. The rule throughout: an unanswered question is a first-class result, not an error.
5.1 CLI — the exchange you described
A call that elicits does not hang. It returns a document and a non-zero exit
code reserved for this case (75, EX_TEMPFAIL, which is what it is).
$ mcpx call github.create_issue '{"title":"x"}'
{
"status": "input-required",
"elicitation": {
"id": "elc-9f2c1a84bb0e7d31",
"trace": "cal-d13e4c64102e9113",
"session": "s87630-989154000",
"server": "github",
"mode": "form",
"message": "Which repository should this issue go in?",
"requestedSchema": {
"type": "object",
"properties": { "repo": { "type": "string", "description": "owner/name" } },
"required": ["repo"]
},
"expiresAt": "2026-09-28T18:02:00Z",
"ttlSeconds": 120,
"respondWith": "mcpx elicit answer elc-9f2c1a84bb0e7d31 '{\"repo\":\"me/thing\"}'"
}
}
$ echo $?
75
What shipped (#286) carries the same information in a different shape:
$ mcpx call ask.need_repo; echo $?
mcpx: ask.need_repo is waiting for input and nothing here can answer it.
asks Which repository should this go in?
schema {"properties":{"repo":{"description":"owner/name","type":"string"}},"required":["repo"],"type":"object"}
expires 2026-09-30T22:31:37-05:00 (2m0s from now)
answer mcpx elicit answer elc-93e18b28a5a3c1f5 '{"repo":"..."}'
mcpx elicit decline elc-93e18b28a5a3c1f5 | mcpx elicit cancel elc-93e18b28a5a3c1f5
The call keeps running until it is answered or expires; collect its result with: mcpx task result tsk-06654ccc9f064a5c
75
$ mcpx elicit answer elc-93e18b28a5a3c1f5 '{"repo":"me/thing"}'
$ mcpx task result tsk-06654ccc9f064a5c # the call's real result
--json puts {"inputRequired": {callId, server, tool, questions[], text}} on
stdout. A script under mcpx exec or mcpx run stops at the call that asked
and exits 75 with the same message, even if it catches ToolError: the
generated client sees the daemon’s 202 and exits rather than throwing.
Two limits. A daemon running inside the command (daemon.inline) stops with
it, so the call cannot outlive the exit and the message says so. And when other
calls share the server’s instance a question cannot be tied to one call for
certain; it is still reported, marked ambiguous, because waiting out the TTL
and exiting 0 was the bug.
respondWith is there because the alternative is the caller assembling it
from three fields, and they will get it wrong once and then copy it forever.
$ mcpx elicit answer elc-9f2c1a84bb0e7d31 '{"repo":"me/thing"}'
$ mcpx elicit decline elc-9f2c... # explicitly no
$ mcpx elicit cancel elc-9f2c... # dismissed
$ mcpx elicit list # what is outstanding, with time left
$ mcpx elicit watch # follow, for a terminal left open
Under the retry model, answering re-drives the original call automatically and
the result lands in the log; mcpx elicit result <id> fetches it. Under the
bidirectional model the original invocation is still blocked and simply
proceeds.
A flag for the common case, where the caller is a person who will wait.
Not built – mcpx call takes only -session and -raw, and
--elicit=prompt is rejected as an undefined flag:
$ mcpx call --elicit=prompt github.create_issue '{...}' # NOT BUILT
Which repository should this issue go in?
repo (owner/name): me/thing
--elicit takes prompt | return | auto | decline | error, defaulting to
prompt on a terminal and return otherwise. That default is the whole
ergonomic story: interactive use just works, scripted use gets a document.
5.2 Scripts
A script is the one place where blocking is natural, because a script already awaits.
const issue = await tools.github.create_issue({ title: "x" });
By default the runtime answers from policy or cancels, and the call returns
whatever the server does with that. To participate – not built: no
onElicit and no ask are emitted into the script’s globals
(internal/codegen/emit.go):
onElicit(async (q) => {
// NOT BUILT
if (q.mode === "url") return { action: "decline" };
if (q.schema.properties.repo) return { action: "accept", content: { repo: "me/thing" } };
return { action: "cancel" };
});
And for a script run on a terminal, ask(q) would prompt. The handler is
registered rather than passed per call, because the question arrives from
inside a call the script did not know would ask.
5.3 REST and OpenAPI
HTTP has a status code for exactly this, and it is not 400.
POST /v1/call/github/create_issue
→ 202 Accepted # built only with X-Mcpx-Input: report, as {"inputRequired": {...}}; no Location/Retry-After
Location: /v1/elicit/elc-9f2c1a84bb0e7d31
Retry-After: 120
{ "status": "input-required", "elicitation": { ... } }
POST /v1/elicit/elc-9f2c.../accept {"content":{...}} # also /decline, /cancel
GET /v1/elicit list
GET /v1/elicit/elc-9f2c... one, with time remaining
202 is right: the request was understood and accepted, and completion is
pending. A 400 would say the caller made a mistake, which they did not.
The OpenAPI document gains a 202 response on every tool path and an
Elicitation schema component. Both are generated, so they cannot drift.
Neither is built: no operation declares a 202, and there is no
Elicitation component in internal/api/openapi.go. What shipped instead is
the MCP-side input_required result (internal/mcpserver/ask.go).
5.4 mcpx as an MCP server — the pass-through
This is the interesting one, and the reason the whole design is worth building. A question from a server three layers down has to reach the agent at the top.
agent ──▶ mcpx (MCP server) ──▶ mcpx daemon ──▶ github server
│
agent ◀── elicitation/create ◀── broker ◀────── "which repo?"
mcpx declares elicitation in its own initialize capabilities only if its
client did. Capability negotiation has to be honest in both directions, or
mcpx promises something it cannot deliver and the failure surfaces at the
worst moment.
Then:
- Client supports elicitation → forward it, rewriting the message to name the real originator: “github (via mcpx) asks: which repository?”. The agent must know which server is asking; that is a spec requirement and also just decent.
- Client does not → fall back to policy, then to returning
input-requiredin the tool result text so the agent can callmcpx_elicit_answeritself.
That fallback means an agent can answer a question with no client support at all, which matters because most MCP hosts do not implement elicitation yet.
Two new tools on the MCP surface:
mcpx_elicit_list— what is waiting, with schemas and deadlinesmcpx_elicit_answer— answer one
5.4a Who is the question for: the agent or the person?
The spec does not have a field for this, and deliberately so. From the Python SDK’s own words: “If the client is an agent, it might decide how to handle the elicitation – either by asking the user or automatically generating a response.” The decision is the client’s, not the server’s.
So mcpx needs a routing policy, and the useful thing is that the elicitation itself carries enough to route on:
| signal | route to | why |
|---|---|---|
mode: "url" | human | the point is a browser and a person’s consent |
format: "password", or a name matching token|secret|key|password | human | a model should not be handed a credential, and cannot know one |
a confirm/approve boolean on a destructive call | human | consent is the whole content of the question |
| an enum the agent can evaluate from context | agent | “which of these three repos” is answerable from what it just read |
| free text with no constraint | agent first, human on decline | the agent may know; if it does not it should decline, not invent |
The last row is the one that matters. A model asked “which repository?” with no way to know will produce a plausible name. The default for anything the agent cannot verify must be decline, not guess, and the skill in §5.7 exists mostly to say so.
A server may hint through _meta – io.mcpx/audience: "user" – and mcpx
should honour a hint toward the human and ignore one toward the agent, because
a server pushing a question past a person is exactly the direction that needs
resisting.
In the plugin this becomes concrete: notify puts it to the agent, ask puts
it to the person, and the routing policy decides which when the mode is
auto. A question routed to the agent that the agent declines is re-raised
to the person rather than failing – that escalation is the behaviour somebody
would expect and would otherwise have to build by hand.
5.5 The opencode plugin
The plugin is where this becomes genuinely good, because opencode has a real user and a real UI.
"tool.execute.after": async (input, output) => {
const pending = await pendingFor(input.sessionID)
if (!pending.length) return
output.output += "\n\n" + renderElicitation(pending[0])
}
Three levels, opt-in as everything else in that plugin is. Not built:
MCPX_PLUGIN_ELICIT is not a variable the registry derives or the plugin
reads, and there is no --plugin-elicit.
MCPX_PLUGIN_ELICIT=notify— a pending question is appended to the tool result, so the agent sees it and can answer via a tool. No UI needed.MCPX_PLUGIN_ELICIT=ask— the plugin uses opencode’s own permission mechanism (ask({ permission, metadata })) to put the question to the user. This is the real prize: a genuine dialog, in the harness the person is already looking at.MCPX_PLUGIN_ELICIT=auto— policy only.
The session id the plugin already injects is what makes this work. A question raised by a call from session X is offered to session X and to nobody else. No new plumbing; the existing injection is exactly the binding the spec requires between an elicitation and a user identity.
5.6 TUI
Not built – nothing in internal/tui/ mentions elicitation.
A seventh view, and a badge in the header when something is pending — because the one thing worse than a question nobody answers is a question nobody knows was asked.
Form mode renders the schema as fields. URL mode shows the URL and offers to
open it. a accepts, d declines, c cancels, enter opens the detail.
The header badge is deliberately loud: ● 1 question in the accent colour.
5.7 Skills
Not built – there is no mcpx-elicitation skill in plugin/opencode/skills/.
mcpx-elicitation, loaded on demand, would cover: what input-required means,
that decline and cancel are different, how to answer from a tool, and the
one thing an agent gets wrong — inventing a value rather than declining.
A model asked “which repository?” with no way to know will happily guess.
The skill’s job is to say: if you do not know, decline and explain why.
6. What it unlocks
Elicitation is not only a feature. It is the missing half of four things mcpx already has.
Third-party authorisation flows
I overstated this in the first draft. Two different things share the word OAuth:
- MCP’s own authorization spec – how a client authenticates to a remote MCP server over HTTP. That is a separate document, separate machinery (OAuth 2.1, resource metadata, token handling), and elicitation does not provide it. It remains a genuine gap.
- A server sending its user through someone else’s authorisation flow – “connect your GitHub account”. That is url-mode elicitation, and it does fall out of this design.
The second is common and currently impossible. The first is unaffected. The
line in OPENCODE-V2.md should be split accordingly rather than crossed off.
Write operations in OpenAPI and CLI adapters
Both currently default to read-only, because a specification describes what a service can do rather than what you meant to allow. With elicitation there is a middle setting: expose writes, and elicit a confirmation before each one.
{ "methods": ["all"], "confirm": ["POST", "DELETE"] }
That is strictly better than the two options available now, which are “no writes” and “all writes, no questions asked”.
Credentials at the moment of need
mcpx registry add currently reports which variables a server requires and
leaves them empty, because inventing a secret is wrong. With elicitation the
server can ask for it on first use, and the answer can go to a keychain rather
than a config file. The current behaviour is the best available without
elicitation; this is better.
Disambiguation instead of guessing
“Three repositories match, which did you mean?” is the single most common thing a tool cannot currently do. Today it either guesses or fails. Neither is what a person would do.
And two things it unlocks that are not about servers at all
mcpx can elicit on its own behalf. mcpx doctor finding an unconfigured
server could ask rather than report. mcpx registry add could ask for the
secrets it currently lists. The broker does not care whether the asker is an
upstream server or mcpx itself, and building it only for pass-through would
leave that on the table.
Long-running approvals become possible. With a TTL measured in minutes and a durable store, “this deploy needs a second pair of eyes” is expressible: a question raised by CI, answered by a person from a terminal or the TUI, and the call resumes. That is a different product feature that falls out of the same machinery.
6a. How the answer travels: four mechanisms, not one
The re-exec shape in §5.1 is right for one case and wrong as the only option. There are four ways an answer can reach the broker, and each is best somewhere.
| mechanism | latency | survives exit | best for |
|---|---|---|---|
| stdin/stdout prompt | immediate | no | a person at a terminal |
re-exec (mcpx elicit answer) | seconds to hours | yes | scripts, CI, anything that returned |
| daemon socket subscription | immediate | while connected | the TUI, the plugin, any long-lived client |
HTTP (POST /v1/elicit/<id>/<action>) | immediate | yes | remote callers, webhooks, another machine |
The daemon already owns a unix socket and a loopback port. A long-lived
client – the TUI, the plugin, a mcpx elicit watch left running – should
subscribe rather than poll:
GET /v1/events → server-sent events, carrying elicit.* kinds
(GET /v1/elicit/subscribe was proposed and not built;
mcpx elicit watch polls, internal/cli/elicit.go:164)
That removes the polling in §3 for every case that matters, and polling survives only as the fallback for a process that cannot hold a connection.
The defaults follow from the table:
- terminal → prompt, no re-exec, no state to reconcile
- non-terminal → return the document, expect
mcpx elicit answer - TUI, plugin → subscribe
- REST caller → the HTTP endpoints, which they are already speaking
Re-exec is the conventional shape because it is the only one that works when the asking process is gone, and that is the case a CLI has to handle. It is not the best shape when the process is still there.
6b. Continuity across the re-exec
A re-exec means a new process, and the question is whether anything is lost.
Nothing needs to be, because session identity already lives in the
environment rather than the process. MCPX_SESSION_ID is injected by the
harness; a second invocation in the same shell carries the same value and
therefore leases the same pooled server.
What the audit trail needs, and what the design should record:
elicit.open trace=cal-d13e… elicit=elc-9f2c… session=s876… pid=41201
elicit.answered elicit=elc-9f2c… action=accept by=cli
session=s876… pid=41533 answeredAfterMs=18420
elicit.resumed trace=cal-a77b… parent=cal-d13e… elicit=elc-9f2c…
Three things fall out of that. The pid changes and is recorded on both
sides, so “who answered this” is answerable. The session is the same, so
mcpx log --chain walks from the resumed call back through the elicitation to
the original call. And answeredAfterMs is the metric worth having: it is the
human latency, and it is the number that tells you whether a TTL is set
sensibly.
If the session id is absent – a bare shell, no harness – the broker binds the question to the config fingerprint and the working directory instead, and says so in the record. Refusing to work without a session would make the feature unusable from a plain terminal, which is where people debug.
7. Cost, and what could go wrong
Roughly 1,200 lines, in five pieces. The broker and its two tables. The
recvLoop fix plus a bidirectional path. Policy evaluation. Six surface
adaptations, most of them under fifty lines each. The TUI view.
Three things I would expect to go wrong:
Deadlock under the bidirectional model. A call holds a lease while waiting for an answer; the answer arrives through a surface that needs the same pool. Mitigated by the TTL, but the retry model avoids it entirely — which is the strongest argument for preferring it.
Questions nobody sees. A CLI invocation that returns input-required into
a script that ignores exit codes produces a question that expires unanswered,
and the user sees only a mysterious cancellation. The TUI badge and
mcpx elicit list are the mitigation; so would be a --elicit=error, which
would make not expecting a question a loud failure rather than a quiet one.
Neither the badge nor the flag is built.
Rule-based auto-answers drifting into dishonesty. A rule that answers
“yes” to everything is a policy that lies to a server about a user’s consent.
Logging every rule that fires is the minimum; I would also make auto mode
refuse to match a rule whose match is empty, so there is no “match
everything” shorthand.
8. The explainer
Everything mcpx does today assumes the conversation goes one way. You ask a server to do something; it does it; you read the answer. Every part of mcpx — the CLI, the scripts, the REST endpoints, the MCP server, the TUI — is shaped by that assumption.
Elicitation breaks it. It lets a server stop halfway through a job and ask a question: which repository did you mean? are you sure you want to delete this? I need you to log in — here is the link. The server cannot continue until somebody answers.
That is awkward for mcpx specifically, because mcpx is usually invoked by something that cannot answer. A shell script has no opinion about which repository. A cron job cannot log in. A model can answer, but it will happily invent a repository name it has no way to know, which is worse than not answering at all.
So the design turns a blocking question into a piece of state that anything can answer, later. The question gets an identity, a deadline and a home in the existing database. The tool call stops and says “I need input, here is what and here is the ticket”. Anyone holding that ticket — a person at a terminal, a script that knew this might happen, an agent through a tool, a dialog in the editor — can supply the answer, and the call resumes.
Three consequences are worth stating plainly.
The answer does not have to come from whoever asked. CI can raise a question a human answers from a laptop twenty minutes later. That is not a special feature; it falls out of the question being state rather than a blocked function call.
“No” and “not now” stay different. The spec separates decline from cancel, and it matters: a server told “no” should offer an alternative, and a server told “not now” should ask again later. Collapsing them — which is the tempting simplification — makes tools worse in a way nobody traces back to this decision.
Not answering is a legitimate outcome. A deadline expires, the question becomes a cancellation, the server is told honestly that nobody chose. The failure mode to avoid is a system that waits forever, or one that invents an answer to avoid waiting.
The reason to build it is not the feature itself. It is that four things mcpx already does are currently half-finished for want of a way to ask a question. OAuth is elicitation. Safe write access is elicitation plus a confirmation. Credentials-on-first-use is elicitation. Disambiguation is elicitation. Each of those is presently either missing or solved by refusing to do the risky thing, and they all become available from one piece of machinery.
The catch worth knowing when this was written – since fixed: mcpx’s receive loop treated every inbound frame as a reply to something it sent. A server that asks a question gets silence, waits, and eventually times out — and the error mcpx reports is “the call timed out”, which is true, useless, and points at entirely the wrong thing. That is worth fixing whether or not the rest of this is ever built.
The script environment
Settings are what a person tells mcpx. This page is the other direction: what mcpx, and the opencode plugin, tell a program they start.
A script reads its session from here. The generated client finds the daemon
from here – the endpoint, the socket, whether the daemon can see this
filesystem. An mcpx command run inside a script, or inside a shell the
editor opened, learns which session it belongs to from here, which is what
lets a session-scoped server give each agent its own browser instead of
sharing one. It is an interface with as many consumers as the settings have.
Until #172 it was declared nowhere. The only list was the code that happened
to set each name, spread across five packages and a TypeScript file, and the
documentation had already drifted from it: it said the plugin sets
MCPX_SESSION and MCPX_CALL. The plugin sets MCPX_SESSION_ID and
MCPX_CALL_ID; MCPX_SESSION is a different variable, set by mcpx for its
own scripts; and MCPX_CALL has never existed. Two variables, one renamed in
somebody’s head, is exactly the drift a hand-written list produces.
How it is kept true
The table below is generated from settings.ScriptEnv() in
internal/settings/scriptenv.go, and tests hold that table to the code:
- Everything set is declared, by the right setter. The Go sources are
walked as syntax – a map key, an assignment to an index, a
KEY=VALUEstring,os.Setenv– and the plugin’sput(e, "MCPX_...")calls are read, and every name found must be in the table with the part of mcpx that set it listed. A setter the table names that sets nothing fails too. - Everything read is declared. A lookup of an
MCPX_variable anywhere outsideinternal/settingsmust be either a setting read through the resolved settings, a variable in this table, or one of four lookups on an allowlist that carries the reason for each – three happen before any settings exist, the fourth where there is no App to ask. An entry nothing needs fails the test. A variable the registry owns may not be read by name even when it is also here, because that would ignore the config file and the flag. - The “read by” column is checked. If it says the generated client reads a variable, the generated TypeScript does; if the plugin reads one, the column says so.
- This page is current. The test regenerates the table and fails when it differs.
This table is what mcpx adds, not all a child gets
Every one of these is added to the environment mcpx itself was started with.
A child gets the parent’s environment entire, and then these on top — unless its
shebang replaces part of it: env -i starts the child from an empty
environment, and env -u NAME removes just that name (both then get these on
top). Both the dispatched path and the JavaScript path apply what the shebang
asked for:
internal/runner/runner.go:359—cmd.Env = append(os.Environ(), …), every script the runner startsinternal/adapter/adapter.go:239—cmd.Env = os.Environ()internal/mcpclient/stdio.go:66—env = append(env, os.Environ()...), underInheritEnv, for every stdio MCP server the pool starts
So a script also sees SSH_AUTH_SOCK, GITHUB_TOKEN, AWS_*, and whatever
else was in the shell that started the daemon. PATH is inherited rather than
constructed, which means a daemon started from a shell with an unusual PATH
passes it on.
script.permissions does not change this. It sandboxes the filesystem and the
network through the runtime’s own flags; there is no allowlist or denylist for
the environment, and nothing here is filtered.
This is worth knowing before putting a secret in the environment of a shell
you then run mcpx serve from. It is stated rather than fixed because the
alternative — an allowlist — would break every server that expects its own API
key to arrive this way, which is most of them.
Configuration is not here
A variable here is a fact about one run – which session, which daemon, which
file – handed from a parent to a child. A knob is a setting, with a
configuration key, a flag, a /v1/settings row and an MCP tool; those are in
configuration.md. Two names are both: MCPX_LOG_LEVEL and
MCPX_DAEMON_ENDPOINT are written for a child, and a child that is mcpx
reads each as the setting it names.
Some variables are read by mcpx and are neither: which configuration file to
load (MCPX_CONFIG, the setting paths.configFile) and where the state lives
(MCPX_STATE_DIR, MCPX_CACHE_DIR) are needed before there is a
configuration to resolve settings from. They are settings, marked as such,
and read by name for that reason.
The variables
| variable | set by | when | what it is | read by |
|---|---|---|---|---|
MCPX_SESSION | mcpx exec / mcpx run; the exec service (/v1/exec, mcpx_exec); an event consumer | always | the session key this run’s tool calls are leased under: –session, else MCPX_SESSION_ID, else one minted for the run | the generated client, which sends it with every call |
MCPX_SESSION_ID | mcpx exec / mcpx run; the exec service (/v1/exec, mcpx_exec); an event consumer; the opencode plugin; the plugin loader, for a discovered plugin | the CLI passes through the value it was given, which may be empty; the plugin sets it at every plugin.env level | the session the host assigned. Unlike MCPX_SESSION it survives from one command to the next, so it is what session-scoped servers key on | mcpx run inside the script or shell, as its session when –session is not given |
MCPX_PARENT_SESSION_ID | mcpx exec / mcpx run; the opencode plugin | the CLI passes through what it was given; the plugin at plugin.env=full, when there is a parent | the session that spawned this one, so a parent agent and its subagents can share a server | mcpx run inside the script or shell, and the generated client for parent-session scoping |
MCPX_EPHEMERAL | mcpx exec / mcpx run | always | 1 when the session key was minted for this run alone, so its instances may be stopped as soon as it ends | the generated client |
MCPX_RUN | mcpx exec / mcpx run; the exec service (/v1/exec, mcpx_exec) | always | this execution’s run id, which groups the artifacts it produces | the generated client, when storing an artifact |
MCPX_INPUT | mcpx exec / mcpx run | mcpx exec and mcpx run, locally and with –remote; not when mcpx serve collects the output | report: nothing in this run can answer a server’s question mid-call, so a call that asks one exits 75 with the question instead of waiting for it to expire | the generated client, sent as X-Mcpx-Input |
MCPX_HOOK_NAME | the declared-hook runner, for a hook it runs | always | the declared name of the hook being run | the hook itself |
MCPX_HOOK_PHASE | the declared-hook runner, for a hook it runs; the exec service (/v1/exec, mcpx_exec) | always | the hook’s phase; always observe for config-declared hooks, the exec hook’s phase (pre, post, onError, onSuccess) for a per-call exec hook | the hook itself |
MCPX_HOOK_POINT | the exec service (/v1/exec, mcpx_exec) | always | the point the hook is attached to; script for per-call exec hooks in this version | the hook itself |
MCPX_HOOK_ERROR | the exec service (/v1/exec, mcpx_exec) | only for a hook running because the script failed; truncated to 1024 bytes | the error the hook is running for, in one line; the full hook context is JSON on stdin | the hook itself |
MCPX_HOOK_EVENT_KIND | the declared-hook runner, for a hook it runs | always | the kind of the event the hook is running for; the full event is JSON on stdin | the hook itself |
MCPX_HOOK_EVENT_SEQ | the declared-hook runner, for a hook it runs | always | the event’s sequence number on the daemon’s bus | the hook itself |
MCPX_HOOK_EVENT_SERVER | the declared-hook runner, for a hook it runs | empty when the event names no server | the server the event is about | the hook itself |
MCPX_HOOK_EVENT_SESSION | the declared-hook runner, for a hook it runs | empty when the event belongs to no session | the session the event belongs to | the hook itself |
MCPX_HOOK_EVENT_URI | the declared-hook runner, for a hook it runs | empty when the event names no resource | the resource URI the event is about | the hook itself |
MCPX_ENDPOINT | mcpx exec / mcpx run; the exec service (/v1/exec, mcpx_exec); an event consumer | always | the daemon’s HTTP endpoint | the generated client; the plugin as a fallback for MCPX_DAEMON_ENDPOINT |
MCPX_SOCKET | mcpx exec / mcpx run; the exec service (/v1/exec, mcpx_exec); the plugin loader, for a discovered plugin | empty when the daemon is remote, since its socket is not reachable from here | the daemon’s unix socket, which is faster than the endpoint. Also where a daemon binds and a client dials, for anyone who sets it by hand | the generated client, mcpx run inside the script or shell, the plugin |
MCPX_TRANSPORT_BEARER_TOKEN | mcpx exec / mcpx run | only when transport.bearerToken is configured | the daemon’s bearer token, handed to the script so the generated client can authenticate to a daemon that requires it (#1975); the token itself stays out of the generated code | the generated client, mcpx run inside the script or shell, the plugin; also the setting transport.bearerToken |
MCPX_SETTINGS | mcpx exec / mcpx run; the exec service (/v1/exec, mcpx_exec) | only when the caller sent call settings with the run (for example a requested pool.callTimeout) | the call-settings header for this run, carried into the script so the generated client forwards the caller’s per-call requests on the calls it makes on the script’s behalf | the generated client, which sends it as the x-mcpx-settings header |
MCPX_ARTIFACTS_LOCAL | mcpx exec / mcpx run; the exec service (/v1/exec, mcpx_exec) | always | 1 when the daemon shares this filesystem, so artifact({path}) may hand over a path instead of the bytes | the generated client |
MCPX_CWD | mcpx exec / mcpx run; the exec service (/v1/exec, mcpx_exec) | the exec service only when the caller sent its working directory | the directory the caller ran mcpx from, which a remote run cannot discover | the generated client (paths.cwd, and cwd scoping) |
MCPX_PID | mcpx exec / mcpx run; the exec service (/v1/exec, mcpx_exec) | always | the pid of the mcpx process that started the script | the generated client, for pid scoping |
MCPX_ENTRY | the runner, for every script | always | the file the runtime was started on: mcpx’s generated launcher, or the script itself under –no-launcher. For exec the script is a generated file | the generated client (paths.entry) |
MCPX_CLIENT | the runner, for every script | always | the generated client module | the generated client (paths.client) |
MCPX_CONFIG_PATH | mcpx exec / mcpx run | always | the nearest configuration file in effect, empty when there is none | the generated client (paths.config) |
MCPX_SCRIPT_DIRS | mcpx exec / mcpx run | always | the directories searched for named scripts, nearest first, colon-separated | the generated client (paths.scriptDirs) |
MCPX_API_DECLARATION_SHA256 | mcpx exec / mcpx run | always | the fingerprint of a declared API’s declaration, set on the child a declared API launches: PoolID hashes the environment, so editing the declaration replaces the pool instead of keeping a child started for the old one | the pool, as a pool identity input (api serve: PoolID hashes the server environment) |
MCPX_RUNTIME | the runner, for every script | always | deno, bun or node; on a dispatched run, the interpreter’s name (sh, python3) | the generated client; on a dispatched run, the script itself |
MCPX_DISPATCH | the runner, for every script | only on a dispatched (non-JavaScript) run | how the interpreter was chosen: shebang or interpreter (script.interpreter) | the script itself, and anyone reading its environment |
MCPX_ALLOW_READ | the runner, for every script | only when the permissions allow reading files | 1: the client’s file helpers may read. Without it they refuse, naming the permission, rather than leaving it to the sandbox | the generated client |
MCPX_LOG_LEVEL | mcpx exec / mcpx run | always | the lowest level the script’s log keeps | the generated client; mcpx run inside the script or shell reads it as logging.level; also the setting logging.level |
MCPX_LOG_SOURCE | mcpx exec / mcpx run | always | which levels record a call site: a level name for that level and above, 0 for none | the generated client |
MCPX_DAEMON_ENDPOINT | the opencode plugin | plugin.env=standard or above, once the plugin has chosen a daemon | the daemon this editor session settled on, so a shell command that runs mcpx talks to the same one instead of looking for its own | mcpx run inside the script or shell, as daemon.endpoint; the plugin, as the daemon to use; also the setting daemon.endpoint |
MCPX_TRACE_IDS | the opencode plugin | plugin.env=standard or above, when there is anything to say | JSON pairs such as [[“session_id”,“abc”],[“worktree”,“/p”]], extended at full with the parent session and the whole ancestry | mcpx run inside the script or shell, which puts every pair on every log record |
MCPX_CALL_ID | the opencode plugin | plugin.env=standard or above | the editor’s id for this one tool call | the command |
MCPX_OPENCODE_CWD | the opencode plugin | plugin.env=standard or above | the directory the command runs in, which is not always the project root | the command |
MCPX_OPENCODE_DIRECTORY | the opencode plugin | plugin.env=standard or above | the project directory the editor opened | the command |
MCPX_OPENCODE_WORKTREE | the opencode plugin | plugin.env=standard or above | the project’s worktree | the command |
MCPX_WORKTREE_NAME | the opencode plugin | plugin.env=standard or above, when there is a worktree | the worktree’s last path element | the command |
MCPX_PROJECT_ID | the opencode plugin | plugin.env=standard or above | the editor’s project id | the command |
MCPX_HARNESS | the opencode plugin | plugin.env=standard or above | opencode | the command |
MCPX_HARNESS_VERSION | the opencode plugin | plugin.env=standard or above | the editor’s version | the command |
MCPX_HARNESS_PID | the opencode plugin | plugin.env=standard or above | the editor’s pid | the command |
MCPX_HARNESS_STARTED | the opencode plugin | plugin.env=standard or above | when the plugin started | the command |
MCPX_SHELL_SEQ | the opencode plugin | plugin.env=standard or above | a counter of shell commands in this editor process | the command |
MCPX_SESSION_TITLE | the opencode plugin | plugin.env=full | the session’s title | the command |
MCPX_SESSION_DIRECTORY | the opencode plugin | plugin.env=full | the session’s directory | the command |
MCPX_SESSION_VERSION | the opencode plugin | plugin.env=full | the editor version that created the session | the command |
MCPX_SESSION_DEPTH | the opencode plugin | plugin.env=full | how many parents the session has | the command |
MCPX_SESSION_CREATED | the opencode plugin | plugin.env=full, when known | when the session was created, ISO 8601 | the command |
MCPX_SESSION_AGE_MS | the opencode plugin | plugin.env=full, when known | the session’s age when the command started, in milliseconds | the command |
Examples
Four scripts you can run as they are. Each one calls the same deterministic
fixture server, prints something short, and has its output checked into
examples/ beside it. go test ./examples/ runs every script
and compares stdout with that file, so an example that stops being true fails
the build instead of sitting here looking plausible.
They are deliberately small. tools is on globalThis, so none of them
imports anything, and the first one is barely longer than the command that
runs it.
Run them from pkgs/mcpx. The first run writes mcpx-client.ts and its
declarations beside the script, which is how mcpx run works for any file;
the .gitignore in that directory keeps them out of git.
Set up the demo server first
Every script calls the demo namespace, which is not a public server: it
is the deterministic fixture in internal/testsupport/fakemcp, and outside
the test harness nothing configures it for you. From a clean checkout,
build the fixture and mcpx itself, then give mcpx a config that maps
demo to the fixture — the same arrangement examples/examples_test.go
builds for itself:
cd pkgs/mcpx
go build -o /tmp/mcpx-bin/mcpx ./cmd/mcpx
go build -o /tmp/mcpx-bin/fakemcp ./internal/testsupport/fakemcp
cat > /tmp/mcpx-bin/demo.json <<'JSON'
{
"mcpServers": {
"demo": { "command": "/tmp/mcpx-bin/fakemcp" }
}
}
JSON
export MCPX_CONFIG=/tmp/mcpx-bin/demo.json
export PATH="/tmp/mcpx-bin:$PATH"
MCPX_CONFIG is how the mcpx run commands below pick that config up.
With it set, the first example prints exactly what the page shows;
without it, the same command fails, because no server answers to demo.
Call one tool
The whole shape: top-level await, one argument object, print the result.
demo.echo answers with a text block, which arrives as a string.
// Call one tool and print what it returns.
//
// Nothing is imported: `tools` is already on globalThis.
const echoed = await tools.demo.echo({ message: "hello from a script" });
console.log("demo.echo:", String(echoed));
mcpx run examples/call-a-tool.ts
demo.echo: hello from a script
Find a tool from inside the script
search() and describe() read the generated client, so looking something up
costs no round trip to the server. This one lists the namespace, reads one
tool’s signature, then calls two tools — one returning text, one returning
structuredContent, which arrives already parsed.
// Find out what a namespace offers from inside the script, then call two of
// the tools it lists.
//
// `search()` and `describe()` read the generated client, so they cost no
// round trip to the server.
console.log("tools:", Object.keys(tools.demo).join(", "));
console.log(describe("demo.echo").trim());
const echoed = await tools.demo.echo({ message: "two tools" });
const structured = await tools.demo.structured();
console.log("echo:", String(echoed));
console.log("structured:", JSON.stringify(structured));
mcpx run examples/discover.ts
tools: boom, chatty, echo, fancy_name, open, slow, state, structured, wipe
demo.echo({ message })
Echo a message back.
echo: two tools
structured: {"n":42}
State that survives between calls
open records a value in the server process and state reads it back. The
daemon keeps one process for the session, so a later call sees what an earlier
one did — no local variable involved. The script prints only whether its own
two values are there, because the full list also holds whatever an earlier run
recorded.
log() is on globalThis too, and writes to the durable log on stderr. The
expected output below is stdout, which is what the test compares.
// `open` records a value in the server process and `state` reads it back, so
// a later call in the same script can see what an earlier one did.
//
// log() writes to the durable log on stderr; console.log is the script's
// stdout, which is what the expected output below holds.
log("recording two values");
console.log(String(await tools.demo.open({ value: "alpha" })));
console.log(String(await tools.demo.open({ value: "beta" })));
const state = await tools.demo.state();
console.log("alpha recorded:", state.seen.includes("alpha"));
console.log("beta recorded:", state.seen.includes("beta"));
mcpx run examples/stateful.ts
opened alpha
opened beta
alpha recorded: true
beta recorded: true
A recipe with parameters
The header declares the holes and the body uses @name, which is the whole
recipe convention. mcpx run refuses a file like this — it cannot fill the
holes — so it is saved and run with values instead. times has a default and
is not passed here.
// Echo a message a few times through the demo server.
//
// The header declares the holes; `mcpx recipes run` fills them. A plain
// `mcpx run` refuses this file, because @message would reach the runtime
// unfilled.
//
// @param message:string what to echo
// @param times:number = 2 how many times
for (let i = 0; i < @times; i++) {
console.log(String(await tools.demo.echo({ message: @message })));
}
mcpx recipes save recipe-echo examples/recipe-echo.ts
mcpx recipes run recipe-echo message="piped through a recipe"
The mcpx: recipe recipe-echo line goes to stderr; the two echoed lines are
stdout:
piped through a recipe
piped through a recipe
Every one of these is executed by go test ./... on each change, against
the same fixture server, so the output above is what the scripts print today
rather than what they printed when the page was written. For why the fixture
is a fake and not a real server, see the FAQ.
exec and artifacts
A script produced a screenshot. Who gets it, and what does it cost them?
The thing that was wrong
mcpx exec ran the script in the CLI process. The CLI generated the client,
spawned deno, and the script called back into the daemon over its socket. That
is the right arrangement for a person at a terminal — the script owns stdout,
reads real standard input, and a relative path in it means what it means on
the command line.
It is the only arrangement, and that was the problem. Two callers could not use it at all:
- The opencode plugin holds a unix socket and nothing else. It has no mcpx binary to spawn. It could list tools, call one, read the log — and could not run a script, which is the thing mcpx exists for.
- A daemon on another machine could not be asked to run one. The servers are over there; the script ran over here and reached across the network for every single call.
So POST /v1/exec: the daemon runs the script. The generated client it writes
points at the daemon’s own socket, so the calls that used to cross a network
now cross a socket, and a caller with no binary has one request to make.
The refactor that made that safe is internal/execsvc. Two implementations of
“run a script” would be two sets of bugs and a script that behaves differently
depending on who asked — which is exactly what this whole feature is trying to
stop. There is one.
Why the CLI still builds its own runner options
execsvc.Run is the wire entry point: everything arrives as ExecOptions and
nothing is inherited from a terminal that is not there.
execsvc.RunWith takes runner options the caller assembled. The CLI uses it,
because mcpx run genuinely has more knobs than /v1/exec does — launcher
templates, five injection phases, --keep, a typecheck mode, placeholder
files — and flattening all of that into the wire type would make the wire type
a mirror of one command’s flag list.
What must not be duplicated is everything after the process starts: which
frames are produced, how artifacts are reconciled, when media is intercepted,
how delivery is applied. That is all in RunWith, and it is the same code for
both. Hooks the caller already set are chained rather than replaced, so the
CLI keeps rendering logs to a terminal while the service collects the same
records for the result.
Where a script runs
exec.where is auto, local or remote, spelled --local and --remote
at the command line.
Auto runs locally when the daemon is on this machine and on the daemon when it is not. That is the rule that makes the common case right without anyone choosing: a person gets a terminal-shaped run, and a configured remote endpoint gets a remote one, because running locally against a remote daemon means every call crosses the network and every file the script writes lands on the wrong side.
--local against a remote endpoint is refused rather than attempted. It
does work, in the sense that the script runs and the calls succeed; it just
quietly produces a different artifact story, and saying so beats a subtly
different result.
A remote run sends the script’s text, not its path. A path would mean the
daemon’s filesystem, and somebody typing mcpx run ./report.ts --remote means
the file in front of them. The consequence is that relative imports do not
survive the trip: the daemon writes the text to its own disk, and a sibling
module is not beside it. --export does survive, because the launcher imports
the written file and calls the named export there.
Every flag that shapes the program travels with it: --env, --typecheck,
--launcher/--no-launcher, --allow-repeat, --no-capture-console,
--export, and the five phases (--before, --prefix, --suffix,
--on-success, --on-error). The CLI resolves them – reading any file a
phase or launcher names on its side – and sends source, so the daemon runs
the program that would have run locally. Until #192 the remote path sent five
of these and silently dropped the rest; TestEveryExecFlagChangesSomething
now runs every row both ways.
ExecOptions
One flat object that grows, rather than a new endpoint per capability. This is step 7 of the issue and it is the right shape: the caller declares what it can receive, the script declares what it produced, mcpx handles delivery.
type ExecOptions = {
runtime?: "auto" | "deno" | "bun" | "node";
timeout?: string;
permissions?: string;
placeholders?: Record<string, unknown>;
session?: string;
cwd?: string;
env?: Record<string, string>;
stdin?: string;
output?: "text" | "structured" | "stream";
capabilities?: string[];
artifacts?: {
delivery?: "reference" | "inline" | "stream";
maxBytes?: number;
sharedFs?: string;
dir?: string;
};
task?: { ttl: number };
ns?: string[];
args?: string[];
export?: string;
typecheck?: "off" | "on" | "strict";
launcher?: string;
launcherName?: string; // template text, or "none"
allowRepeat?: string[];
captureConsole?: boolean;
phases?: { before?: string[]; prefix?: string[]; onSuccess?: string[]; onError?: string[]; suffix?: string[] };
};
cwd, env and stdin are the caller’s context, not the daemon’s. A
remote daemon has a different working directory and a different environment,
and a script that reads either should see the one belonging to whoever asked.
A local caller passes its own, which is how the two cases stay identical.
Three fields are not in the issue’s list. ns and args are there because
mcpx exec --ns and mcpx run script a b already exist, and an option the
CLI has and /v1 does not is a parity hole — the one thing this repository
checks for automatically. dir sits beside sharedFs because they are
different statements: sharedFs is “I can see your disk”, dir is “put them
here for me”. They happen to be implemented the same way when the caller is
local, and they are not the same claim.
task reuses internal/tasks unchanged. A long exec with artifacts is a task
whose result holds references, which already fits tasks/result.
Output shapes
Three renderings of one run.
- text — the CLI default. stdout is the script’s answer and a person reading it wants it unadorned. It goes to the terminal as it is produced, not collected and printed at the end; a long run that printed as it went must keep doing so.
- structured — the
/v1and MCP default. One document:{result, emits[], logs[], stdout, artifacts[], exitCode, durationMs, error?}. A caller over HTTP wants fields, not text it has to scrape. - stream — frames as they happen. SSE by default because it is plain
HTTP and a browser consumes it with no code; NDJSON on
Accept: application/x-ndjson, because a program with a JSON decoder should not have to stripdata:off every line first.
result is the script’s stdout parsed when stdout is JSON. That rule is
inherited from mcpx --json run rather than invented, so the two agree.
The frame order, and why it is the order
start{runId}
hook{phase:"before",...} (one per before hook, in declaration order)
log | emit | stdout | call{server,tool,argsHash,durationMs,ok,error?,destructiveHint?} (interleaved, as they happen)
artifact{id,name,mime,size,sha256,uri}
hook{phase:"onSuccess"|"onError",...}
result | error
hook{phase:"suffix",...} (one per suffix hook, either path)
end{exitCode,durationMs}
---- only for delivery:"stream" ----
artifact.chunk{id,seq,data}
artifact.end{id}
A call frame records one tool call the script made while it ran: the
server and tool, a hash of the arguments (never the arguments – a stream
is a thing other people read), how long the call took, whether it
succeeded, and the tool’s destructiveHint when its annotations declare
one. Call frames appear on runs the daemon itself executes, whose call
path can reach the run’s stream; a run whose calls arrive at a daemon
that is not executing it has no stream there to join.
Hook frames sit where the hook ran, so the answer a consumer reads is the
final one: an onSuccess/onError hook frame precedes the result/error
frame it may have replaced (its frame carries the original when it changed
the value), and a suffix hook frame follows the outcome, before end. A
replaced error produces no error frame at all.
Artifact metadata comes before end; artifact bodies come strictly
after it. That is the whole point of the design, and it is step 5 of the
issue: a streaming consumer reads the emits and the return value, decides it
does not need the 4 MB video, and disconnects at a frame boundary having paid
for none of it.
A disconnect is not merely tolerated. The sink returning an error cancels the
run context, which kills the process group; and every file the CLI writes from
a stream goes to a temporary name and is renamed on artifact.end. A
cancelled stream must not leave something that looks like a complete file,
because the next thing to read that directory cannot tell the difference.
Artifacts
The script API is the same everywhere
const shot = await chrome_devtools.take_screenshot({ format: "png" });
await artifact("checkout.png", shot);
return { total };
data may be a string, a Uint8Array, an ArrayBuffer, { path }, an MCP
image/audio/resource content item, or a tool result carrying one on
.raw. Accepting the content item directly is what makes the common case one
line: a screenshot arrives as {type:"image",data,mimeType} and that is
exactly what gets passed in.
The script never asks where it is running. The issue’s step 1 — if (mcpx.session.isRemote) — was rejected for that reason: an author who has to
know about remoteness writes two code paths and one of them is never tested.
What actually happens underneath differs, and only in a way nobody has to
think about. artifact() POSTs to /v1/artifacts over the transport the
generated client already has. When the runner set MCPX_ARTIFACTS_LOCAL=1 —
which it does when a unix socket proves the daemon shares this filesystem —
a {path} is sent as a header and the daemon reads, hashes, and copies the
file into the store. Otherwise the bytes travel once to the daemon and are
then fetched by whoever wants them.
Remoteness is declared, never inferred. A unix socket can be ssh-forwarded and
loopback TCP can be a container with its own filesystem; neither transport
tells you whether the caller can see the daemon’s disk. So the caller says
so, with capabilities: ["artifacts"] and optionally sharedFs.
An ssh:// endpoint is the clearest case of sharedFs: false: the bytes
cross a process boundary on another machine, so the caller uploads a {path}
artifact’s bytes rather than giving the daemon a path it can read.
The store
Content-addressed by sha256 under <state>/artifacts/blobs/ab/cdef..., with a
SQLite index beside it. SQLite because it is already a dependency and because
“list the artifacts of this run” is a query rather than a directory walk.
The handle is not the hash. A content hash is guessable by anyone who can guess the content — and for a screenshot of a public page, that is everyone. So an artifact is served by a separate random 128-bit id mapped to the hash. That id is the entire access control until OAuth privilege levels land, which is why it is sized as a secret rather than as an identifier. When scopes arrive, the natural shape is that the session that produced an artifact owns it and a privileged scope can read any of them; the index already carries the session for exactly that.
Two registrations of the same bytes share one blob and get two handles. The quota counts distinct content, so a script that produces the same screenshot twice pays once. Deleting a registration only removes the body when no other registration refers to it.
Everything is written to a temporary name and renamed on completion — in the
store, in --artifacts-dir, and in the stream consumer. A cancelled upload
leaves nothing a later reader could mistake for a whole file.
Names are untrusted
The name comes from the script, and a script is not trusted with a path.
SanitizeName takes the last component (so ../../etc/passwd is passwd),
keeps [A-Za-z0-9.-_+], turns spaces into hyphens and everything else into
underscores, strips leading and trailing dots, and truncates to
artifacts.nameMaxLength while keeping the extension. An empty result becomes
artifact, because a file has to be called something.
Removal rather than rejection: a caller whose artifact is refused over its name has lost the file, and the name is decoration — the artifact is reached by id.
Collisions get a numeric suffix before the extension — shot-1.png, not
shot.png-1 — so the file still opens in whatever the extension implies.
Nothing is ever overwritten: two files called shot.png are two files, and
silently keeping one of them is data loss. The reservation uses
O_CREAT|O_EXCL, because testing with Stat and then creating is a race two
concurrent runs of the same script would lose.
Delivery
| mode | what crosses | when |
|---|---|---|
reference (default) | an id and a URI | always correct; the only mode whose cost does not scale with what the script happened to produce |
inline | base64 in the structured result, bounded by artifacts.inlineMaxBytes and the caller’s maxBytes | a caller that cannot make a second request |
stream | frames after end | one response, cancellable |
A sharedFs or dir lets the caller read the destination directly, but the
store still copies the bytes into that directory; this saves a later transfer,
not filesystem I/O.
Reference is the default because of the round-trip arithmetic. Over a unix
socket a second request costs about 0.2 ms, so separate GETs are free locally
and bring Range and parallel downloads with them. Remotely a single stream is
better, which is why stream exists — and artifacts remain fetchable by id
afterwards either way, so choosing the stream costs nothing.
inline’s ceiling is deliberately well under the per-artifact ceiling. Inline
delivery puts bytes in the caller’s context, which is the cost this whole
feature exists to avoid.
Intercepting upstream media
When a structured or streamed result — or an emit — contains an MCP image,
audio or blob resource block, and the caller declared artifacts, the
block is stored and replaced by a resource_link carrying the URI, the name,
the type and the size.
This is the local win, and it is worth having with no remote daemon anywhere. chrome-devtools returns a screenshot as base64 image content; left alone that lands in an agent’s context, where one screenshot costs more than the rest of the task and where nobody will ever look at it.
It happens at the output boundary, not inside the script. A script that
received a handle instead of the bytes could not inspect the image it just
took — measure it, crop it, compare two — which is a real thing scripts do. So
the script sees whatever the upstream server sent, and only what crosses back
to the caller is rewritten. artifacts.interceptImages turns it off.
Reaching an artifact
| surface | how |
|---|---|
| MCP | resource_link in the mcpx_exec result, then resources/read on mcpx://artifacts/{id}; also resources/list and a resource template |
/v1 | GET /v1/artifacts/{id} with Range, GET /v1/artifacts?run=…, DELETE /v1/artifacts/{id} |
| CLI | mcpx exec --artifacts-dir ./out |
GET /v1/artifacts/{id} goes through http.ServeContent, which brings Range,
conditional requests and 206 for free — and Range is what makes reading the
tail of a large artifact cheap, one of the three things printing the file
could not do. Content-Disposition is attachment with the sanitised name,
because a browser deciding to render somebody else’s bytes inline is how a
store of arbitrary files becomes an XSS surface.
resources/read carries text, so a binary body comes back base64 with its
real type stated. That is lossy in exactly one way — the client decodes — and
it is what the protocol offers. A client that wants raw bytes has the /v1
route.
One edit outside this area
mcpserver.dispatch returns a string, which cannot carry a resource_link.
Threading a content array through a dozen cases to serve one of them would
make eleven signatures worse to serve the twelfth. So mcpx_exec encodes its
blocks into the string it already returns (mcpserver.EncodeResult), and
tools/call decodes it in exactly one place. EncodeResult returns the text
unchanged when there are no blocks, so every other tool is byte-for-byte what
it was.
Settings
Everything is declared in internal/settings/exec.go. It is not declared
once: unlike internal/settings/consumer.go and internal/settings/wire.go,
which read defaults.*, every Default: in exec.go is an inline string
literal ("120s", "64MiB", "10m", …), so each value that also lives in
defaults.json is written in two places with nothing holding them equal. Three
of them — exec.where, artifacts.enabled and artifacts.dir — have no
defaults.json key at all. The values below are what both say today.
| setting | default | what it decides |
|---|---|---|
exec.timeout | 120s | how long a script may run, wherever it runs |
exec.output | text | text, structured or stream |
exec.where | auto | local, remote, or decided by whether the daemon is |
artifacts.enabled | true | whether the store exists at all |
artifacts.dir | — | --artifacts-dir |
artifacts.delivery | reference | reference, inline or stream |
artifacts.ttl | 24h | retention before collection |
artifacts.maxBytes | 64MiB | the largest single artifact |
artifacts.quota | 1GiB | the total, over distinct content |
artifacts.inlineMaxBytes | 1MiB | the largest that may be base64’d into a result |
artifacts.chunkBytes | 256KiB | one streamed body frame |
artifacts.gcInterval | 10m | how often expired artifacts are swept |
artifacts.interceptImages | true | rewrite inline media at the boundary |
Exceeding maxBytes is an error, not a truncation. A truncated screenshot is
worse than a refused one, because it looks like it worked.
nameMaxLength, nameCollisionLimit, idBytes and listLimit live in
defaults.json without a registry entry: they are read at package
initialisation and a configuration file arriving later could not change them,
and a setting that does nothing is worse than no setting. (This paragraph used
to offer plumbing.eventHistory as the comparison. That one is a setting —
events.history, internal/settings/wire.go:239, flag --events-history —
so it was the wrong example.)
Not done
- The other direction. An inbox — uploading a file a script needs as
input, with a script-side
input("name")— is out of scope for this cut. Nothing here prevents it:POST /v1/artifactsalready stores by content and the index already carries a session. - OAuth scoping. Artifacts are served by an unguessable id on an
unauthenticated API, which is the same access control every other
/v1route has. The index carries the session so a scope check has something to check. multipart/mixed. The issue offers it as an alternative to base64 in the stream. NDJSON and SSE carry chunks base64’d, which costs 33% on the wire. Worth revisiting; not worth a second framing before the first one has a user.- Relative imports on a remote run. They need sibling modules on the runtime’s disk, and a remote run sends one file’s text.
FAQ
The five questions a skeptical first-time reader asks first, in the order they ask them, answered in the same voice.
Short answers here; the long ones live in the pages each answer links to. Every question keeps the wording it was written in.
“so why’d you get rid of all the mcp and then make one, do you like mcp or not”
We like MCP fine. What we got rid of is tool schemas sitting in the model’s
context, not MCP itself: mcpx is an MCP client of every server you configure,
across four protocol revisions, reading the same mcpServers config every
other host reads, and “mcpx puts every MCP server behind one command so their
schemas stay out of the model’s context until something asks for them”. The
server we made is deliberately tiny. “Exposing three hundred tools over MCP
would rebuild the problem mcpx was built to solve, with extra steps”, so it is
/mcp on the daemon, mcpx serve --transport stdio, and mcpx_exec as the
one tool that matters.
More: the story.
“i mean, opencodemode is right there”
It is, and not only in v2. opencode v1 ships code mode behind
OPENCODE_EXPERIMENTAL_CODE_MODE, v2 has it on by default, and both accept
TypeScript. Its version is better confined: a purpose-built interpreter that
runs only the language features upstream implemented, plus OAuth and
elicitation for remote servers. What it does not do is what mcpx is for. It
only runs inside a model turn — there is no opencode2 exec — one daemon here
serves every harness with a real process per session for stateful servers, and
our scripts run on a real runtime with imports and a filesystem. Our own
recommendation still stands: let code mode own ordinary MCP inside opencode’s
loop, and use mcpx for stateful servers and everything outside a turn.
More: the opencode plugin.
“is it safe?”, no.
No. Scripts run on a real runtime with your authority — filesystem, network,
subprocesses. script.permissions defaults to all. What a narrower profile
does depends on the runtime, and the defaults say so exactly
(internal/defaults/defaults.json): on Deno the profiles become Deno
permission flags, and on node readnet / read-net are enforced with
--permission --allow-fs-read=*. net, read, and strict are defined for
Deno only. Ask for a named profile that has no entry for the runtime you
chose and mcpx refuses to run the script at all — “permission profile %q
defines no flags for %s, so %s cannot enforce it”
(internal/runner/runtimes.go) — it never runs the script unenforced and
pretends otherwise. Bun has no permission model, so no narrower profile is
defined for it and every narrower ask there is refused the same way. The
profiles that are defined for bun and node — all, none, off,
unsandboxed — carry an empty flag list by design: defined, and
deliberately unrestricted, not enforcement. The daemon API is
unauthenticated. It listens on loopback and on a socket only you can open, and
binding it wider prints a warning. mcpx prompt hands you a generated script
instead of running it, because generated code runs with your credentials. So
treat a script like a shell command you are about to paste: configure only
servers you would run by hand, and do not expose the port. That is the whole
answer.
More: the script contract.
“but why would you..”
Three reasons, then the history. Context cost: tool schemas sit in every request whether or not the tools get used, so mcpx’s always-on cost is zero — “there are 27 Chrome tools live on this machine and not one byte of their schemas is in the agent’s context”. Concurrency: two agents sharing one browser corrupt each other, so each session gets its own process. Reach: the same tools from a shell, CI or any harness. The history is that this replaced lootbox, a fork 51 commits ahead of a quiet upstream, where discovery was uncached (about 650 ms per request) and the architecture “cannot express the one thing you actually need (more than one Chrome)”.
More: the assessment.
“but can i get some examples?”
Yes. examples/ holds short scripts that CI runs on every change, against a
deterministic fixture server, paired with the output they print today — so an
example cannot quietly rot, and one of them is a real recipe (mcpx recipes).
The gallery page pairs each script with its command and its expected output;
copy one, change the server name, run it. They are deliberately small, and none
of them needs Chrome or the network. If one is wrong, that is a bug worth
reporting, because the only reason these are trustworthy is that they fail the
build when they stop being true.
More: the examples gallery.
Finding the repository without git
How the repo and worktree scopes decide which repository a caller is in,
why that no longer runs git, and what was decided for every layout git
understands.
created: 2026-09-30T07:30:00-05:00
last-updated: 2026-09-30T07:30:00-05:00
status: implemented
This is the decision record for the change. docs/container.md §3 is the
short version; it lives here rather than under docs/decisions/ because that
directory belongs to another area.
1. Two questions
A server scoped repo gets one process per clone – every linked worktree of
one repository shares it. A server scoped worktree gets one per checkout.
The keys are the answers to two questions git already has commands for:
| scope | key | git’s command |
|---|---|---|
repo | the common git directory | git rev-parse --git-common-dir |
worktree | the top of the checkout | git rev-parse --show-toplevel |
Until this change mcpx asked exactly those, by forking git twice per directory and caching both answers – negative ones included – for the daemon’s lifetime. That had four costs, three of which were only found by testing the replacement:
- 105 MB of the container image. Debian’s git depends on perl outright.
- It did not work in the container anyway. A checkout mounted into the
image is owned by the host’s uid, the image runs as uid 1000, and git
refuses a repository someone else owns (
safe.directory). Every mounted checkout, measured:fatal: detected dubious ownership in repository at '/work/proj'. The old image shipped git so that these scopes would work, and in the ordinary way of using it they fell back to per-directory keys. - The daemon’s environment leaked into every caller’s key. An
autostarted daemon inherits the environment of whoever ran mcpx first. A
git hook exports
GIT_DIR; a hook that runs mcpx leaves a daemon carrying it for hours. WithGIT_DIRabsolute, every later caller – from any directory – was keyed to that one repository. WithGIT_DIR=.git, as hooks set it, every caller outside a repository root degraded. Both are pinned byTestTheDaemonsOwnGitDirDoesNotPinEveryCaller, which fails against the old code. - The cache made the first answer permanent. A directory that became a repository after the first call there stayed “not a repository” until the daemon exited.
2. How it works now
Finding a repository is a walk up the directory tree reading a handful of
small files. Git’s rules for that walk are fixed, and they are in its source:
setup.c (setup_git_directory_gently_1, read_gitfile_gently,
is_git_directory, check_repository_format_gently, the three
setup_*_git_dir functions) and the config parser in config.c.
internal/config/gitdiscover.go and gitformat.go are a port of those, from
git v2.55.0, function by function and named after the originals so the two can
be read side by side.
At each directory, starting from the caller’s cwd with every symlink
resolved (git starts from getcwd(), which has already resolved them):
.git– a directory that is a repository, or a file naming one;- the directory itself, as a bare repository;
- the parent, unless a ceiling or a filesystem boundary says stop.
Once found, the common directory comes from the commondir file if there is
one, and the worktree from where .git was found – unless the repository’s
config says otherwise through core.bare or core.worktree.
Native discovery reads HEAD, commondir, a .git file, and from config
three keys – core.repositoryformatversion, core.bare, core.worktree –
plus whatever extensions.* holds, since an unknown extension is what makes a
repository unreadable. When extensions.worktreeConfig is on it reads the
worktree’s config.worktree as well. It tests objects/ and refs/ for
existence without reading them, which is the rest of what is_git_directory
decides on. Nothing else, and it executes nothing.
Every outcome is one of four:
| outcome | meaning | what the scope does |
|---|---|---|
| found | the same answer git gives | keys by it |
| not a repository | git would say so too | keys by directory |
| broken | something on disk git dies on – a .git file naming nothing | keys by directory; doctor warns, with the fix |
| unsure | a layout this port will not vouch for | asks git |
Git is the fallback only for “unsure”. It is asked once, as git rev-parse --path-format=absolute --git-common-dir --show-toplevel: the
first answer is printed before the second fails, so a bare repository still
yields its common directory from one process. It runs with git’s own list of
repository-local variables removed (GIT_DIR, GIT_WORK_TREE,
GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY and eleven more – git rev-parse --local-env-vars), so it answers the question native discovery answers. With
no git on PATH, “unsure” keys by directory, and the reason says what could
not be read and that git is not there to ask.
Nothing is cached. One resolution five directories deep costs 0.073–0.081
ms (BenchmarkDiscoverGit, -benchtime 300x -count=5, M3 Pro / macOS / APFS);
forking git rev-parse --show-toplevel from the same place costs 9.7 ms on the
same machine, so the native answer is about 125× cheaper. A cache would save
under a tenth of a millisecond per call and bring back the stale answers of
§1.4.
mcpx doctor reports what the two scopes resolve to from the current
directory and what resolved them. A missing git is not a warning; it becomes
one only when a repository here needs it.
3. Every case, and what was decided
“Parity” means the case is built with the real git and native’s answer is
compared with git rev-parse for both questions. Test names are in
internal/config/gitdiscover_test.go unless marked e2e.
Checkouts
| case | decision | test |
|---|---|---|
| main clone, from its root or any depth below | same as git | parity: main clone |
linked worktree, git’s relative commondir (../..) | same as git: .git file → private dir → commondir → common dir | parity: linked worktree with git's relative commondir |
linked worktree, absolute commondir | same | parity |
linked worktree with relative paths (worktree.useRelativePaths, git 2.48; sets extensions.relativeWorktrees and format 1) | same; the extension is one native knows | parity |
| linked worktree moved by hand | same as git: still resolves, from its new place; the stale back-pointer is not consulted | parity |
main clone moved: its worktrees’ .git files name nothing | same as git: an error, not a fallback. doctor warns and names git worktree repair | parity; TestWithoutGitABrokenGitfileIsAWarning |
| submodule | its own repository, as git says: common dir .git/modules/<name>, top the submodule’s directory. A submodule has its own HEAD, objects and history; a server scoped to “the repository” that saw the superproject’s instead would be looking at the wrong code | parity: submodule is its own repository (built from a local path, no network) |
| nested repositories | innermost wins, as in git | parity |
--separate-git-dir, a relative .git file, CRLF endings | same | parity |
.git file path through a symlink and .. | resolved by the kernel, not lexically, as git’s chdir does | parity |
.git is a symlink to a git directory elsewhere | same: the symlink’s target | parity |
symlinked checkout; /tmp vs /private/tmp | the physical path, as git; both spellings key alike | parity; TestGitParityTmpAndPrivateTmp |
a directory that is both a bare repository and holds a .git | .git wins, as in git’s order | parity |
Git directories
| case | decision | test |
|---|---|---|
| bare repository, from itself or inside it | common dir yes, worktree no – worktree keys by directory | parity |
inside .git, or a worktree’s private dir | same: a git directory, no worktree | parity |
| worktree of a bare clone | has a worktree: a linked worktree ignores the common core.bare | parity |
bare clone with extensions.worktreeConfig | its worktrees become bare, as in git – git’s documented trap, reproduced rather than corrected | parity |
HEAD as a symlink into refs/, detached SHA-1, detached SHA-256 | valid | parity |
| reftable repository | same | parity |
stray .git directory that is not a repository | skipped; discovery continues upward | parity |
nested repository whose HEAD is almost valid (40 non-hex characters, a symlink out of refs/, ref: outside refs/), or has no objects/ | skipped, as git validates it | parity |
.git files and entries git refuses
Git stops at these rather than skipping them, so mcpx does too – skipping would find the enclosing repository and share its instance. Every one is built inside a real repository so that skipping would be caught.
| case | test |
|---|---|
garbage; gitdir: without the space; no path; a path that does not exist; a path that is not a repository; trailing spaces (part of the path, as in git) | parity: broken .git file: … |
a .git file over git’s 1 MiB ceiling (git.gitfileMaxBytes) | parity |
a .git that is neither file nor directory (fifo) | parity against git ≥ 2.54, which made this fatal; older git skipped it, and mcpx follows current git |
an empty commondir | parity |
| a directory discovery cannot look inside | a directory discovery cannot look inside (git cannot chdir there at all) |
| cwd that does not exist, or is a file | cwd that does not exist, and cwd that is a file |
The repository’s config
| case | decision | test |
|---|---|---|
core.worktree absolute, relative (to the git dir, not the common dir), or with only its last component missing | same as git | parity |
core.worktree git cannot enter | git dies before printing either answer, so both scopes fall back | parity |
core.bare; core.bare with core.worktree | no worktree (git warns that the pair makes no sense) | parity |
per-worktree config.worktree with worktreeConfig | read, as git reads it | parity |
no config; a config without repositoryformatversion | as git: everything else in it is discarded, so core.bare = true there means nothing | parity |
[include] setting core.worktree | ignored, as git ignores it here: the repository format is read without includes | parity |
upper-case names, [core "x"], [foo "core"], [core.x], quoting, comments, continuation lines, a BOM, CRLF, bare keys, numeric booleans | parsed as git parses them | parity; TestGitConfigParserAgainstGitsOwn compares values with git config --file |
| an unknown extension in a version-0 repository | ignored, as git does | parity |
format version > 1; an unknown extension in version 1; a version-1 extension in version 0; a syntax error; a value git refuses (bare = maybe, objectformat = md5, a bare worktree key); a value git takes that this port does not parse (bare = 1k) | unsure → git. The git on the machine may be newer than this port | parity, marked unsure |
Environment
| variable | decision | test |
|---|---|---|
GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY and the rest of git’s local list | Not honoured, and removed from the fallback’s environment. The key is computed in the daemon, for many callers, and the daemon’s environment belongs to whoever started it – honouring it is §1.3. The caller’s variables never reach the daemon (CallContext carries the cwd, not the environment), and they do not need to: everywhere git itself sets GIT_DIR – hooks, aliases, submodule foreach – it also puts the cwd inside that repository, so discovery from the cwd reaches the same answer | TestGitEnvironmentThatNamesARepositoryIsIgnored; TestGitLocalEnvCoversWhatGitCallsLocal checks the list against the installed git; e2e TestTheDaemonsOwnGitDirDoesNotPinEveryCaller |
GIT_CEILING_DIRECTORIES | Honoured, with git’s exact rules: relative entries ignored, entries symlink-resolved unless they follow an empty entry, a ceiling never examined from below it but honoured-as-absent when the walk starts there. It can only make keys narrower, and it lives in shell profiles, so a user who set it expects mcpx to agree with their shell | parity: TestGitParityCeilingDirectories, eight cases |
GIT_DISCOVERY_ACROSS_FILESYSTEM, and mount points | Honoured: discovery stops where the device changes, as git’s does. A value that is not a boolean is unsure | TestDiscoveryStopsAtAFilesystemBoundary (a boundary cannot be mounted without privileges, so the device lookup is substituted); verified with a real tmpfs in the image, docs/container.md §3 |
Deliberate differences from git
| case | git | mcpx | why | test |
|---|---|---|---|---|
a repository owned by another user (safe.directory) | refuses | resolves | safe.directory stops git running configuration from a repository someone else controls. Native discovery runs nothing from it; it reads three small files and five keys. And the refusal is exactly what broke the container (§1.2). The fallback does not override git’s check, so an unsure layout in a foreign repository still falls back, with git’s reason | TestGitOwnershipIsNotCheckedNatively |
an implicit bare repository under safe.bareRepository=explicit | refuses | resolves | the same reasoning: the setting protects against embedded bare repositories’ configuration | TestGitSafeBareRepositoryIsNotCheckedNatively |
| a cwd spelled in the wrong case on a case-insensitive volume | on-disk spelling | the spelling given | Getting the on-disk spelling needs F_GETPATH, reachable from Go only outside the unsafe rules; the cwd scope has always behaved this way; the error over-isolates. Belongs in canonical() for every scope: #190 | TestGitCaseOfAWrongCaseCwdIsKept asserts the difference is case and nothing else |
| git older than 2.31 as the fallback | prints --path-format=absolute back | refuses its answer | the echoed option would otherwise be read as a path. Only reachable for unsure layouts | TestGitFallbackRejectsAGitWithoutPathFormat |
Windows is not handled: drive letters, \, and //server/share ceilings are
not ported, because mcpx does not build there (internal/runner uses
Setpgid and Kill).
4. How it was tested
Parity. 54 subtests in six TestGitParity* functions build
repositories with the real git in a fenced temporary root –
GIT_CEILING_DIRECTORIES at its parent, a private global config, no system
config, none of the local variables – and compare native discovery with git rev-parse for both questions. A case that expects git to refuse first checks
that it does, so a fixture that accidentally built a valid repository cannot
pass as a refusal. They skip only when git is absent (the Nix sandbox) or
older than a feature they need. With git 2.55 on macOS none skip; with
Debian’s git 2.39 on Linux (the golang:*-bookworm builder image, as a non-root
user) 52 pass and the two needing 2.45 and 2.48 skip. That run found the one
portability gap so far: git 2.39 counts GIT_INTERNAL_SUPER_PREFIX as
repository-local and newer git does not, so the scrub list is the union.
Without git. TestScopesResolveWithoutGit and three siblings clear PATH
and build repositories by hand – HEAD, objects/, refs/, and the two
files git worktree add writes – so they run in the Nix sandbox too. The e2e
TestRepoAndWorktreeScopesResolveWithNoGitInstalled does the same through the
real binary and a live daemon, with call and exec, and reads the keys from
mcpx status and the answer from mcpx doctor.
Falsified. Every test that asserts new behaviour was run against code without it:
- The three e2e tests, against the unchanged
origin/main: all fail, for the reasons they name (keys arecwd:without git; every caller keyed toother/.gitunder an absoluteGIT_DIR; the shared instance gone after an exec). - The unit tests, by mutation: 44 single-line changes to the port – the
walk order, the ceiling arithmetic, each gitfile refusal, each HEAD rule,
has_common, the config parser’s quoting, trimming and subsections, the format checks, the environment scrub – each run against the suite on the final code. 41 are killed, each by a named test. The three that survive cannot change behaviour: two treat relativeGIT_CEILING_DIRECTORIESentries differently, but a relative entry can never be a prefix of the absolute path discovery walks; the third reads the fallback’s second line even when git fails, and git prints no second line when it fails. Five mutations first written so that they did not compile were rewritten, since a compile error proves nothing about a test. The first runs found five gaps, now covered: the order of the.gitand bare checks at one level, a section whose subsection iscore,HEADvalues that are almost valid, a.gitthat cannot be stat’d, and a.gitfile over the size ceiling.
In the image. §3 of docs/container.md, “No git”.
5. Found along the way
- A sessionless
mcpx execstopped every shared instance it touched.CallContext.CallerOwnedreturned true for any key when the session was ephemeral, so the release at the end of a run stopped theglobal,repoandworktreeinstances too – contradictingReleaseCaller’s own comment. Fixed inscope.go: an ephemeral caller owns its session’s key and its pid’s key, nothing shared.TestOnlyKeysTheCallerMintedAreCallerOwned, e2eTestASessionlessExecLeavesASharedInstanceRunning. - The degraded-scope log line says “per-call” when the key is the
directory: #191. Not fixed
here;
internal/daemon/registry.gois another area’s. - Debian bookworm’s git (2.39) refuses a checkout made by a current git
that used
worktree.useRelativePaths:unknown repository extension found: relativeworktrees. Native discovery reads it, so the image is now more capable than the git it used to ship.
The home-page example: copy it or run it in the page
The example on the home page is not a picture of code. It is the working
script from home-example/example.ts, and a
reader can do two things with it before installing anything is required
of them — and a third once it is.
Copy as mcpx exec. One click puts a command on the clipboard that runs the example against your own daemon and your own servers:
mcpx exec "$(cat <<'MCPX_EOF'
const echoed = await tools.demo.echo({ message: "hello from a script" });
console.log("demo.echo:", String(echoed));
MCPX_EOF
)"
The heredoc is quoted, which is the whole trick: an example full of
apostrophes, $ signs and backticks arrives at mcpx exec byte for
byte, because mcpx exec takes its source as an argument and its stdin
belongs to the script. Copy as script puts the source itself on the
clipboard instead, for a file under .mcpx/scripts/ and mcpx run.
Run it in the page. The example is editable, and Run executes the
edited version in the browser tab. This is a simulation and the page
labels it as one: the script runs through the real generated client, but
its POST /v1/call is answered by a fake daemon in the page whose only
namespace is a mock demo server. It is the right tool for “what does
this code do” — change a line, see the output change — and the wrong one
for “what will this do against my servers”, which is what the copy
button is for.
What the simulation does not have: real MCP servers, the launcher, and
the runtime APIs (Deno.*, node:fs) a real run provides. Running an
example against your own daemon from the page is a later, opt-in stage;
running strangers’ code on a hosted daemon is not planned at all. The
component, its mocks, and its regeneration script live in
home-example/.
The /v1 HTTP API
/v1 is the daemon’s own API — the one surface everything else
sits on. The CLI talks to the daemon over it, the plugin’s client
is an HTTP client for it, and the MCP tools are generated from the
same declarations it is. This page is the prose around the
specification: how to reach the API, the few conventions a caller
has to know, and how to get a client for it in your language. The
specification itself is GET /v1/openapi.json, and it is generated
— see below for why that sentence does real work.
/v1
One table, one specification
Every /v1 operation is declared once, as an Op in the table in
internal/api/ops*.go — 81 operations at the time of writing; the
generated table in parity.md is the live count and
the live list. From that declaration come the route, the
mcpx_<op> MCP tool, a CLI command and the operation’s entry in
the OpenAPI document. internal/api/openapi.go builds the document
from the table, so GET /v1/openapi.json describes the daemon that
serves it, including one path per tool in the live catalogue.
Because the document is generated from declarations compiled into
the binary, it can be produced with no daemon running:
mcpx openapi prints it. The two documents overlap but neither is
a superset of the other. Every path they share is identical, byte
for byte, and mcpx openapi adds the MCP endpoint, a REST path
per mcpx tool (POST /v1/tools/{tool} is itself an operation in
the table) and the settings index. The daemon’s document instead
adds one concrete path per tool in the live catalogue
(POST /v1/call/<namespace>/<tool>, with that tool’s input
schema, generated per request by handleOpenAPI in
internal/daemon/ops.go), so a server that appears after startup
is described without a restart. A third form, GET /v1/openapi.json?static=1, returns the daemon document without
the live catalogue merged in – only the ops-table paths – so a
docs build can publish the API surface without publishing the
builder’s configured servers (#1883 item 4); the offline document reads no
configured servers and covers upstream tools only through the
declared /v1/call/{server}/{tool} template. The two documents
that used to share this name — one of them describing a server
that had been removed — are recorded in the header of
internal/cli/openapi.go, so the confusion cannot quietly return.
Which OpenAPI document do you want?
GET /v1/openapi.jsondescribes the daemon as it is running on this machine: its operations, its routes, its settings, and the live catalogue’s per-tool paths. The/v1half ofmcpx openapidescribes the same daemon surface as compiled into the binary, without the live catalogue.mcpx schema --format openapidescribes the tools of your configured MCP servers, as operations for a client generator. Feed the wrong one to a generator and you get a working client for the wrong thing.
The rendered reference page and the protocol revision matrix are
generated content, and generated content is never hand-copied into
a prose page — it would be wrong within two releases. The
generation that embeds them in this page set is still in progress;
until it lands, the interim sources are the documents themselves:
the operation table rendered in parity.md (itself
generated, and held current by a test) and the revision matrix in
protocol.md §1, which defers to
GET /v1/protocol. Both are named here, on the page, so a reader
always knows which copy they are trusting.
Reaching the API
The daemon listens in two places at once:
-
The unix socket in its state directory (
paths.state). This is the primary listener; the socket’s file permissions are the access control.curl --unix-socket <sock> http://mcpx/v1/healthThe host name is a placeholder — over a unix socket it names nothing; the socket is the address. The OpenAPI document’s first
serversentry (http://mcpx) is written for exactly this call. -
Loopback TCP, on
daemon.port. The default port is 0, which means the daemon chooses one at start-up;GET /v1/healthreports the port this daemon chose, and the daemon writes its socket, endpoint and port into its info files in the state directory for clients to discover. A named endpoint (MCPX_ENDPOINT, asunix:///pathorhttp://host:port) is how a program pins one daemon instead of discovering whichever is running.
Every endpoint is unauthenticated. That is a deliberate local-API
posture, not an oversight: binding the TCP listener wider than
loopback is a deliberate act, and the responsibility of whatever
does it, because whatever can route to the port can run tools as
you. The same listeners mount the MCP surface at
/mcp — one listener, one access-control decision.
Call-scoped settings: the X-Mcpx-Settings header
A daemon is started once and serves every later command, so a flag
on one command that governs something the daemon does would be
lost — accepted, validated, and ignored — if the client did not
send the effective value with the request. That is what the
X-Mcpx-Settings header is: a JSON object of setting path to
value, carried on one request:
curl --unix-socket <sock> 'http://mcpx/v1/search?q=screenshot' \
-H 'X-Mcpx-Settings: {"search.limit": "5"}'
Only call-scoped settings are honoured; a path the registry declares at any other scope is ignored, because a header that could reconfigure a shared daemon for everybody is not a per-call preference. A malformed header is also ignored rather than refused — the client’s bug should not cost it the call itself. The setting names and scopes are the registry’s, generated into configuration.md.
The event stream: GET /v1/events
GET /v1/events is a server-sent events stream over the daemon’s
event bus — elicitations waiting for an answer, server state
changes, resource updates, progress and log messages. Query
parameters select what to hear: kinds= (prefixes; empty means
everything), session=, server=, and uri= for resource updates
on named resources (naming one subscribes to it upstream for as
long as the stream is open).
Reconnects resume. ?since=N replays everything after sequence N,
and the Last-Event-ID header a browser’s EventSource sends on
reconnect is honoured the same way; if the retained history no
longer reaches back that far, the stream says so in-band with a
gap event whose payload is {"since": N, "latest": M} — since
is the cursor the subscriber asked to resume from and latest is
the newest sequence the bus has published, not the oldest
sequence still retained — so a subscriber knows it missed
everything between its cursor and the retained history and can
resynchronise instead of trusting a stream with a hole in it. Over
MCP, the same stream is what subscriptions/listen carries — see
mcp.md.
Admin operations
An operation whose declaration carries Admin changes the daemon
rather than reading from it — stopping it, restarting a server,
setting configuration. In the OpenAPI document these are marked
x-mcpx-admin, which is how a scoped token, when one exists, would
know what to grant separately. Today they are offered rather
than hidden, MCP annotations (readOnlyHint, destructiveHint)
included: a caller that can reach the socket can already do these
things, so withholding them would buy no safety. 20 of the 81
operations are admin at the time of writing; parity.md
lists which.
Request body limits
Bodies are bounded, per surface, by settings: http.bodyLimit
(default 1 MiB) for ordinary requests, http.callBodyLimit
(default 64 MiB) for tool-call payloads, which legitimately carry
large arguments, and http.controlBodyLimit (default 64 KiB) for
the control endpoints. A body over its limit is refused by the
decoder rather than truncated into a half-parsed request.
A /v1 SDK
There is no published /v1 SDK, in any language. What follows is
the recommended way to get one anyway, and the one client that does
exist.
Recommended: generate one from the specification
The specification is generated from the ops table, so a client generated from it cannot drift from the daemon the way a hand-written SDK would — a hand-written SDK is a second copy of the table, and keeping it correct is exactly the failure the table exists to prevent. Generate with the standard tool for your language:
| language | tool |
|---|---|
| TypeScript | openapi-typescript (types) with openapi-fetch (client) |
| Go | oapi-codegen |
| Python | openapi-python-client |
| most others | openapi-generator |
The flow is the same everywhere:
- Take the spec from the daemon you will talk to:
curl --unix-socket <sock> http://mcpx/v1/openapi.json -o openapi.json(mcpx openapiworks offline and every path it shares with the daemon document is the same bytes, but it has no live catalogue: a generated client that needs the concrete/v1/call/<namespace>/<tool>paths must be generated from the daemon’s own document, not the offline one). - Run your generator over
openapi.json. - Point the generated client at the daemon — which is the one mcpx-specific step, next.
The unix-socket caveat. Generated clients assume a TCP base
URL. The daemon’s primary listener is a unix socket, so pick one:
run the daemon on a known loopback port (daemon.port, or start
it with --port) and use http://127.0.0.1:<port> as the base URL;
pin MCPX_ENDPOINT the same way for programs that read it; or give
the generated client a fetch/transport that can dial a unix socket
— the standard clients all accept a custom fetch or HTTP transport,
which is where a socket dialer belongs. What does not work is
handing a generated client the socket path as a URL and hoping.
The client that exists: DaemonClient
For harness plugins and Bun/TypeScript programs there is the
plugin’s DaemonClient (plugin/opencode/mcpx/daemon.ts): a
connection-reusing client that knows the discovery ladder (a named
endpoint first, then the daemon info files, then health checks),
speaks to the socket through Bun’s fetch unix option, and
reaches every operation through its generated ops methods
(plugin/opencode/mcpx/ops.gen.ts, generated from the same table),
with named convenience methods beside them for the common calls.
It is tested against a real daemon. It is also not published as
a package — using it today means taking it from the plugin — and
it is Bun-first, which is why it is the harness answer rather than
the general one. The general answer is generation, above.
What is not promised
No SDK-generation check runs in CI yet: no job generates a client from the current spec and compiles it, so the generation flow above is documented and follows the spec’s own generation, but it is not yet held by a test the way parity.md is. That check belongs with the docs-generation work this page set leans on, and it is recorded as the named remainder on the parent issue rather than claimed here.
Ideas
Unvetted. Everything from a brainstorm lands here first, numbered, so nothing
is lost between thinking of it and deciding about it. Items graduate to
proposals.md once they have a shape, and from there to the
feature list in story.md once built.
Nothing here is committed to. Some of it is probably wrong.
Format
Each item keeps the thinking, not a summary of it. A one-line version of “maybe support a list of dirs to add to the priority list, and allow either front or back, and maybe aliasing to dodge conflicts” loses the two open questions inside it, which are the part worth keeping.
So: a heading, the original thought in enough detail to reconstruct the intent,
and where it went. → proposal means it has a written design. → shipped
means it is built. → answered means it was a question, and the answer is
recorded here, because an answer that only exists in a conversation is lost.
Should this be issues instead?
Probably, eventually. A file is right while there is one author and no repository to file against: it diffs, it reviews, and it cannot rot in a tracker nobody opens. The moment a second person is involved, or an item needs discussion threaded against it, these become issues and this file becomes an index of links. The graduation path is the same either way.
Intake 2026-09-27
Extracted from one message, grouped, not prioritised.
Paths and identity
1. Git version floor. Does --path-format=absolute need a new git?
→ answered. It landed in git 2.31, March 2021. The stated policy is that
anything released before 2026-01-01 is fair game, so it is now used directly,
with a hand-absolutising fallback kept for older git. Cost of the fallback is
one extra git rev-parse only when the first attempt fails.
2. Is --git-common-dir relative to cwd or to the repo root?
→ answered. To cwd. Verified: from pkgs/mcpx/internal it returns
../../../.git. This matters because joining with cwd is therefore correct,
and joining with the repo root would have been wrong. It is also why
--show-toplevel needs no treatment: it is already absolute.
3. Is there a realpath that does not involve cwd?
→ answered. realpath(1) exists as a shell command, and Go’s
filepath.EvalSymlinks is the in-process equivalent. Neither needs cwd for an
absolute path; both resolve a relative one against cwd. So the order is
forced: absolutise first, resolve second. Doing it the other way silently
resolves against the wrong directory.
4. Would --absolute-git-dir work instead?
→ answered: no, for either scope. It returns the git directory, and for a
linked worktree that is the worktree’s private .git/worktrees/<name>, not the
shared common dir. Using it for repo scope would make every worktree look
like a separate repository — the exact bug the scope exists to avoid. And for
worktree scope the wanted value is the working tree root, not a git dir at
all, so --show-toplevel is already the right call.
5. Prefer realpath everywhere for comparison and search.
→ shipped. Every path that becomes a key goes through one canonical()
helper: absolutise, then EvalSymlinks, falling back to Clean when the path
cannot be resolved. /tmp and /private/tmp now key identically, as do
symlinked checkouts.
6. Are there cases where the relative form should win? Display, plausibly —
repo:../.. is unreadable but repo:/Users/.../nix is long. Never identity.
Open.
Catalogue, types and search
7. Implement round-robin budgeting. → shipped as mcpx catalog.
8. Keep whole-namespace types too, or force the budget on everyone?
→ answered: keep both. They answer different questions. catalog is “what
exists, within a budget”; types is “I have chosen this namespace, give me all
of it”. Collapsing them would make the common case need a flag, and would
ration an agent that already knows what it wants.
9. How are signatures ranked — is there similarity indexing? An agent? A
library? → answered: none of those. Catalogue ranking is by cost,
len(line)/4, ties broken by path. No embeddings, no model, no dependency.
That is deliberately different from mcpx search, which is relevance-ranked
by substring scoring. Two rankings because they answer two questions.
10. Could a query bias what fits? → shipped as --bias, which promotes
matching signatures ahead of the pure cost order. This is the merge of the two
ranking systems and it seems to work: at a budget too small for
take_screenshot, --bias screenshot promotes it anyway.
11. ls does not search — is that a separate thing? → answered: yes.
ls enumerates namespaces and counts, never tools. search ranks tools across
namespaces. catalog sits between them: every namespace, some tools.
12. Better search: typo tolerance, concept similarity, tags. → proposal. Worth noting the ordering: tags are cheap and exact, typo tolerance is cheap and fuzzy, similarity is expensive and needs a model or an index.
13. Types improvements beyond single-tool output. → proposal.
Protocols beyond MCP
14. Import an OpenAPI service as a namespace. → proposal. Confirmed feasible: the codegen already consumes JSON Schema, which is what an OpenAPI operation carries.
15. Emit OpenAPI per namespace. The inverse, so non-agent consumers can use the same servers without speaking MCP. → proposal.
16. Expose mcpx itself as an MCP server and/or an OpenAPI endpoint, optionally secured, with or without the MCP type wrappers. → proposal. This is distinct from 15: it makes mcpx a server, not just a client.
17. A common adapter interface so MCP, OpenAPI and anything later plug in the same way. → proposal.
18. Steal from MCP alternatives and mcp++ efforts. → proposal.
MCP server mechanics
19. How are Chrome debugging ports handled? → answered: they are not,
and do not need to be. chrome-devtools-mcp has no listen-port flag. With
--isolated it launches its own Chrome with a temporary profile and an
ephemeral debugging port of Chrome’s own choosing. This is why mcpx has no port
machinery, and why the predecessor’s auto-port code was solving a problem this
server does not have.
20. Can you attach to different Chrome processes? → answered: yes, via
--browserUrl http://127.0.0.1:9222 or --wsEndpoint ws://.... That is the
case where per-instance templating becomes necessary, because each instance
needs a different URL.
21. Auto-increment or random ports as a general feature. → proposal, as argument templating plus an allocator. Generalises beyond Chrome to any server that does take a port.
22. MCP servers have their own config — how is that handled?
→ answered: argv and environment, and that is all there is. MCP defines no
configuration channel, so a server’s options are its command-line flags and its
environment. mcpx passes command, args, env and cwd per server. The one
thing MCP does define that mcpx was ignoring is instructions from
initialize — now surfaced.
23. --pageIdRouting defaults to true and is documented as “useful for
concurrent agent sessions”. Worth understanding before assuming pooling is the
only answer to concurrency. Open.
24. What other common MCP settings are we missing? → proposal. Known gaps: sampling, roots, progress notifications, cancellation, elicitation, OAuth.
Scopes and sessions
25. Config policy for scopes — allow-list, default, and whether a flag or a script may override. → proposal.
26. Named sessions, and naming one that started anonymous. → proposal. The subtle part: an anonymous key is torn down when its caller exits, so naming it must flip that bit.
27. Per-scope keepalive defaults. → proposal.
28. Change keepalive at runtime, from CLI or script. → proposal.
29. Reap on an HTTP status code. → proposal, but MCP’s own ping is the
better first version: no configuration and works for every server.
30. Reap on a webhook. → proposal. Splits into outbound (easy, it is events
with an HTTP sink) and inbound (needs authentication; mcpx session stop
already covers anything local).
31. Tie an instance to a pid. → shipped as scope: pid, reaped on the
reaper tick when the process is gone rather than waiting out an idle timer.
Config and script discovery
32. Extra directories on the priority list, with a choice of front or back:
--override-paths and --fallback-paths taking lists of directories or files.
→ proposal. The open question inside it is whether “front or back” is two flags
or one flag plus an ordering rule.
33. Aliasing a file to a different name to dodge conflicts. → proposal, and flagged there as probably the wrong shape — shadowing already resolves conflicts by precedence, and an alias adds a second mechanism for the same job.
34. Do config and scripts share a directory? → answered: yes.
.config/mcpx/config.json and .config/mcpx/scripts/*.ts, with .mcpx/ as
the lower-precedence alternative spelling. Both are searched upward from cwd,
and a directory carrying both spellings warns once.
Logging and output
35-43. Every command takes a display format; everything expressible as JSON is available as JSON; a logging helper for scripts accepting either a plain string or structured values; plain strings wrapped into the same record shape; automatic enrichment with ambient context; formats json / json-pretty / logfmt / text / compact / bare; message templates preserved next to the interpolated message; a default closer to a classic log line.
→ consolidated into one proposal. Answer to “does the existing logger interpolate”: there is no logger yet. The daemon prints unstructured lines and scripts have no helper at all.
Logging, round two
69. How does a script return several times? → answered: emit(), a
separate export, not a log level. Logs and results share one wire channel with
a kind discriminator so they stay ordered, but they are different functions
with different destinations: logs go to stderr rendering, results to stdout or
the envelope.
70. Should the discriminator be _kind? → answered: it is not a user-facing
attribute at all. kind lives at the top of the wire record; user attributes
are nested under attrs. A script writing log.info("x", {kind: "y"}) sets
an attribute, not the discriminator. The real collision was elsewhere – see
“Reserved attribute names”.
71. Should there be log.output() / log.return() helpers? Deliberately
not. Logging and returning are different acts, and putting them on one object
invites log.return being read as “log that we returned”. emit is the verb.
72. Can JS attributes merge onto Go context? → proposal, “Log context and
enrichment layering”. slog does have the seam (Handler.Handle takes a
context) and mcpx does not use it yet.
73. Does a streamed result also produce a log line? → answered: no.
Verified: emit({a:1}) produces one stdout line and no record.
74. How does variadic translate between JS and Go? → answered: it does
not, and should not. JS has real variadics, so log.info(msg, ...rest) with
runtime type inspection is idiomatic. Go’s slog uses alternating key/value
pairs (slog.Info("msg", "k", v)) or typed slog.Attr. Forcing either into
the other’s shape would make one side unidiomatic. They meet at the wire
format, which is a message plus a map, and each side reaches that in its own
way.
75. How expensive is the Error-based source capture? → answered, measured:
new Error().stack costs 4.89 us against a 0.10 us baseline for the JSON
serialisation alone – roughly fifty times the cost of the record it decorates.
Parsing the frame adds only 0.3 us on top. So capture is opt-in, and the
expensive part is building the stack, not reading it.
76. Does JS run for every log message even when filtered? → answered: yes, partly, and that is worth fixing. The script always serialises and writes; the level threshold is applied in Go. A debug call in a hot loop therefore costs a write even when nothing will print. → proposal.
77. Should source capture be configurable globally and per server?
→ shipped for global (logging.source in config, plus the flag and the
environment variable), and per-server logLevel now resolves. Per-server
source is not meaningful, because source capture describes the script, not
the server it is talking to.
Harness integration and distribution
44-53. Plugins for opencode 1 and 2, Claude, Codex; the split between an
instructions file, plugin-injected instructions, and a tool surface; a plugin
suppressing a redundant instructions file by detecting a marker; an install
command per harness; hash-matching so an update never clobbers a user’s edits;
daemon-side config updates; a curl-able install document; a curl-able setup
document written for an agent rather than a human; mcpx setup-prompt /
install-prompt; platform variants for nixos, flakes, darwin, darwin+homebrew.
→ consolidated into one proposal. The genuinely novel piece is the agent-targeted setup document: the artefact is a prompt, not a script.
Packaging and service
54-59. NixOS module and service; Homebrew formula; a non-flake nix installer; the binary installing its own service; autostart at boot versus on first use; whether sudo is ever appropriate.
→ consolidated into one proposal, with a firm view on the last one: never sudo. The daemon is per-user and owns per-user MCP servers; a system unit would run them as the wrong user and share state across users.
Transport, scale and language
60. What does the daemon actually speak? → answered. HTTP over two
listeners: a unix socket for the CLI, and 127.0.0.1 on an OS-assigned
ephemeral port for script clients, because fetch() over a unix socket is not
portable across deno, bun and node. Not stdio, not named pipes, not files.
61. Is the port fixed or random? What if it is taken? → answered. Ephemeral, chosen by the OS, so there is nothing to collide with. It is published in a daemon record file that the CLI reads.
62. Can multiple daemons run? → answered: yes, already. One per config
fingerprint, each with its own socket. mcpx daemons lists them.
63. Accept a URI with or without host and port (example.com,
example.com:8008, :42). → proposal. Parsing is trivial; authentication is
the actual blocker, because loopback currently provides trust for free.
64. Load testing. → proposal. Untested beyond 12 concurrent runs.
65. Go or Rust? → proposal, with a recommendation to stay: the workload is process supervision and JSON, where the runtime is idle most of the time.
Naming and comparison
66. mcpx, xmcp, or something else? → proposal. Short version: mcpx is
fine, renaming gets more expensive weekly, and the only real argument is that
mcpsh states the actual pitch.
67. What does opencode v2 have that this does not? → answered, and
tracked: OAuth and elicitation for remote servers, in-sandbox search() so
discovery never enters the transcript, a persistent PTY daemon, a shared
background service, and OpenAPI import. mcpx is ahead on shell access, pooling
and scoping, real-runtime TypeScript, and per-config isolation.
68. Track parity deliberately. → proposal.
Intake 2026-09-30
From one message about adapters, plus two language/transport requests. Filed straight to issues rather than proposals, per the graduation path above: these needed a design argument written down, not a line kept.
Adapters without hand-writing JSON
78. Can a binary become a declaration automatically, without a model?
The AI-assisted version is already sketched (#114 has the --help parse plan,
#83 has it as step 5), but both park it behind a large design and neither owns
it. The deterministic half is shippable on its own. → #295.
The thing worth keeping from the thinking: --help is the worst regular
source, not the best, and leading with it is why this looks harder than it is.
Shell completion scripts are generated data structures — fish’s
complete -c tool -l flag -d 'desc' is nearly a declaration already, and zsh
_arguments specs carry flag, type and description in a fixed grammar. Cobra,
clap and argparse all emit them. Ladder: completions → a published spec (which
is paths.apis, not an adapter at all) → man → --help → --bare.
And completions need two ways in, not one. Extracting from the binary
(tool completion fish, __complete) is the convenient path and only works
when the program ships a generator. Accepting a completion file is the one
that matters: distributions already ship completions for programs that have no
generator at all — /usr/share/bash-completion/completions/*, zsh
site-functions, the Homebrew and Nix prefixes — and those are frequently
packager-written for old tools whose --help is the worst in the first place.
It also makes the parser testable from a corpus instead of from installed
binaries, lets someone fix the source and re-import rather than hand-editing
generated JSON, and covers binaries mcpx cannot run at all. So extraction is
sugar over the file parser; build the parser first. Best version of the idea:
import searches the system completion directories by name before it ever
probes a binary.
Also settled here: a generated declaration records the binary’s version, so
mcpx adapter check can say “written against jq 1.7, found 1.8” instead of
drifting silently. That was #83’s open question.
79. Can I just say git is an MCP that takes the subcommand and the flags?
Not ideal but fast. → #295, as --bare, opt-in and loud.
Worth keeping why it is allowed at all, because #83 Approach A rejects the
shell-escape version and the distinction is the whole argument: a bare adapter
is an argv hole, not a shell hole. The cost is that it has no schema
beyond “array of strings”, so the agent gets nothing to reason about and
mcpx doctor can only check the binary exists. Hence: required description,
a visible marker in mcpx adapter list, and probably a settings gate so an
organisation can forbid it centrally.
The sharp edge found while writing it up: --bare on a binary that has its
own argv-level exec escape is equivalent to Approach A. git -c core.pager='sh -c ...' is a shell escape mcpx cannot see. The sandbox is only
as good as the wrapped program’s own argv surface, and the docs have to name
the common offenders.
80. Go can exec a program so it can’t shell escape, right?
→ answered: yes, and it already does. Spec.Call builds an argv and runs
exec.CommandContext(cctx, s.Command, full...)
(internal/adapter/adapter.go:237). No /bin/sh -c anywhere in
internal/adapter, so no word-splitting, globbing, ;, $(), backticks, pipes
or redirection. The guarantee is real; see 79 for the one place it leaks.
81. Is there an issue for exec’ing bash, and for Python first-class?
→ answered: both already exist, no new issue needed. Shell and shebangs are
#106 — its own notes already say “nearly just allow any shebang” and
--shell||--bash||--shell python3. Python 1:1 with TypeScript is #105,
framed there as “a second codegen backend and a second runner, not a second
product”. Both sit under #175.
Languages and reach
82. Roc. → #296.
The reason it is not just “another interpreter”: Roc’s platform/application split means an application is pure and a platform supplies every effect it may have. That is the exact shape of an mcpx script — pure logic whose only effects are tool calls — and no other language in the runtime list models it natively. The dream version is mcpx as a Roc platform, where the capability boundary becomes the type system rather than a Deno flag set.
Why it is staged behind a codegen backend anyway: a Roc host is ordinarily Rust/Zig/C and links against the compiled app, so a Go host means cgo and a separate toolchain. Plus Roc is pre-1.0 with no compatibility promise. Ship the boring backend, keep the platform as research.
83. Iroh. → #297.
Reach a daemon by identity rather than by address — no port forwarding, no public IP, no ssh key copied. ssh (#85) solves encryption and auth and solves routing only if you can already reach the host, which is exactly the laptop-behind-NAT case.
The honest finding, kept because it is the part that would otherwise be
rediscovered: iroh is Rust and mcpx is pure Go, and tsnet (Tailscale,
embeddable in a Go program, mature hole-punching, real ACLs) and go-libp2p
deliver most of the same property with no cgo. Where iroh genuinely wins is
iroh-blobs — BLAKE3, content-addressed, resumable — which is the same shape
#187 already wants for the dependency cache. If that convergence is the goal,
iroh earns its sidecar; otherwise it probably does not.
The gap both of those exposed
84. Nobody knows who is calling the daemon. → #298, and the framing in the first draft was wrong.
Not a bug — it is written down three times as a deliberate choice
(internal/daemon/server.go:214, internal/daemon/origin.go:13,
internal/api/openapi.go:40): unauthenticated, socket file permissions are the
access control, and the TCP bind host is deliberately not defaulted because
“that has to be somebody’s decision rather than a default.”
The first draft called it blocking — a precondition for ssh, p2p and browser surfaces. → answered: no. There is no blocker for adding security and no blocker for not having it. An unauthenticated local daemon is a complete answer and stays one. The issue was rewritten to say so, and the auth coupling was stripped back out of the iroh (#297) and WASM (#299) issues, which should not carry it.
The rule that came out of it, worth keeping: do not mix auth with other things in issues. Auth work goes in one line — #253 (the interface) with the pure-auth issues ordered behind it — and other issues reference it without depending on it.
85. The permissive-interface trick. → #300, and this is the part worth remembering.
When a spec demands an interface mcpx does not have, the move is to build the interface and make it say yes: accept a user and a password, and reply “good job, you’re authorized.” That satisfies every conformance row asserting the endpoint exists, is well-formed and accepts a valid credential — which is most of #253’s 61 rows — and it cannot break anyone, because a daemon that authorizes everyone is exactly as secure as a daemon with no interface at all.
The rows that require an invalid credential to be refused are the ones that change behaviour and can lock you out of your own daemon. Those get their own issue (#300), ordered behind #253, blocking nothing.
The one hazard, and it is real: a permissive interface must be visibly
permissive. mcpx doctor and startup should say “authorisation: permissive” so
nobody ships it believing otherwise.
86. WASM as a tool source. → #299.
Distinct from #84, which is mcpx compiled to WASM. This is mcpx running a
.wasm as a sandboxed tool source — a module reaches nothing until the host
hands it an import, which is the inverse of internal/runner/runner.go:43’s
admission that only Deno has a permission model worth the name.
What makes it cheap where 73 and 74 are expensive: wazero is a pure-Go WASM
runtime with no cgo, so nix build keeps cross-compiling for free and the
release matrix does not grow. The open tension is that the component model —
which is what would make the capability set a per-module declaration — may
require wasmtime and therefore cgo, forfeiting the advantage. That is the one
piece of research gating the rest.
In name only
created: 2026-10-01T09:00:00-05:00
last-updated: 2026-10-01T12:30:00-05:00
increment: 2
status: working
tags: area:audit
description: features, settings and tests that exist on paper and do
little or nothing in practice. The curated list; the
per-area audits carry the full detail.
Each entry is something a reader could reasonably believe works, and mostly
does not. It is a list to fix from, not a list of excuses. Check an entry against the code before adding it – two seeded here from
docs/spec/revision-conflicts.md were already fixed. Add to it when you
find one; remove an entry only in the change that makes it true, and say so in
the commit.
Status of this file: seeded from what the 2026-09-30/10-01 fix rounds found
while doing other work, then extended by the 2026-10-01 audit of origin/main
at 19237a0. This page stays curated: it carries the entries worth acting on,
and the three area files carry every row, with the command and output or the
file:line behind it.
- audit/surface.md – commands, 173 settings,
MCPX_*variables, flags, exit codes,/v1routes against the CLI. - audit/spec.md – each MCP revision’s requirements against the code and against the conformance matrix, both directions.
- audit/features.md – opencode plugin,
exec/run, daemon pools, recipes/prompt/autonomy, adapters and OpenAPI, diagnostics, elicitation.
What the audit did not cover. Authorization was excluded throughout (known
absent, #253). The settings sweep read all 173 statically but ran only 31 at two
values, so most proto.*, upstream.*, transport.*, prompt.* and
recipes.* settings are unverified either way. The twelve plugin.* settings
are read by the TypeScript plugin and were not exercised from a live opencode.
The spec pass read the shared-body loops in utilities_test.go,
lifecycle_test.go and clientfeatures_test.go and the rest only by header, so
it is a sample of ~1300 conformance cells, not a census. Each area file ends
with its own “skipped” list; read it before concluding something is clean.
Read the specifications, not only the conformance suite. The suite tests
what it has scenarios for: nothing for 2024-11-05, three scenarios for
2025-03-26, and it measures zero for features no fixture exposes. Passing it is
not conformance. The per-revision requirements are catalogued in
docs/spec/requirements.md and docs/compare/matrix-revisions.md; check each
against the code, and check each conformance pass against what the spec actually
asks. The audit found the matrix itself is the weak point: most conformance
bodies register one test for a list of requirement ids, so an id reads “tested”
whether or not the body touches it.
Legend: absent – declared, nothing behind it. minimal – does the least that lets a test or a doc say it exists. partial – works on some paths and silently not on others. untested – may work; no test would notice if it stopped.
Authorization
| what | state | detail |
|---|---|---|
| OAuth as a client (to remote servers) | absent | type: oauth is refused before connecting. Every client-side conformance failure is this: token endpoint, metadata discovery, DPoP, client credentials. #253 |
Authorization on mcpx’s own /mcp and /v1 | absent | Anyone who can reach the socket or port is trusted. The Origin/Host check stops browsers, not callers. #253 |
| The permissive auth interface | minimal | Authorizes everyone; exists so the shape is in place. #300 |
autonomy.max against a determined caller | partial | Holds per request, but a socket caller can PUT a higher ceiling with persist:user and restart the daemon. Decision 0002 says so. |
Questions from servers (elicitation, sampling)
| what | state | detail |
|---|---|---|
| Elicitation storage | partial | Readable by any local user and never pruned. #255 |
| Answers checked against the requested schema | absent | Nothing validates an answer. Answering a question whose schema requires repo with {"totally_not_in_schema":123} is accepted silently, rc 0. (The other half of this entry – “does not show which server asks” – is fixed: elicit list has a SERVER column and elicit show prints from <server>.) #255 |
elicit answer key=value types | partial | Every value reaches the server as a string, so age=3 arrives as "3" for an integer field (cli/elicit.go:119-126). Pass JSON to avoid it. #255 |
| Exit 75 when a call stops for input | partial | Holds for the first pending question on a server. While an earlier question for that same (shared) server is unanswered, later callers block instead of exiting 75 – exec waited the full two-minute expiry, then printed a cancel result and exited 0, which is the symptom #286 was closed for. The e2e tests all start from an empty question store, so none of them reach this. #286 (closed) |
| Sampling | minimal | Declared to every upstream; answering needs an agent watching the question queue. No human review, no result validation, no error mapping. Deprecated in 2026-07-28; kept working only. #256, #210 |
| Server-to-client requests across eras | partial | Works when the upstream is configured protocol: follow: a 2025-11-25 caller gets its own legacy session of a dual-era upstream, whose requests reach it as requests (spec/era-probe.md). Under the default modern a legacy caller still shares the modern session, where a dual-era server’s legacy-only push (the official suite’s test_elicitation, test_sampling) answers -32601 – the four server-all failures, until scripts/conformance.sh configures follow. The reverse direction – a 2026-07-28 upstream’s input_required reaching a legacy caller as elicitation/create – already worked, and is now tested (internal/e2e/follow_test.go). |
Tasks
| what | state | detail |
|---|---|---|
| Task persistence | absent | The store is in memory; tasks do not survive a daemon restart. #209 |
tasks/cancel | minimal | Forces the cancelled status instead of letting the work reach its own end. #209 |
| mcpx asking an upstream to run a call as a task | absent | An upstream that requires a task fails. #209 |
taskSupport default for 2025-11-25 clients | partial | A tool with none declared can still be called as a task. #209 |
Skills extension
mcpx declares io.modelcontextprotocol/skills (SEP-2640, 2026-07-28 only) and
serves its own four skills from plugin/opencode/skills – each a SKILL.md
plus whatever supporting files and subfolders sit beside it (mcpx-basics has
a getting-started guide and an examples/ folder of scripts) – through
skills/list, skills/get, resources/read and resources/directory/read.
One part of the extension is not built and is not declared.
| what | state | detail |
|---|---|---|
An upstream’s skills through skills/list | absent | mcpx answers skills/list with its own skills only. An upstream that declares the extension has its skill:// files passed through as ordinary resources (under their own URIs in pass-through mode, as mcpx://<ns>/... otherwise), but its skills/list entries – the digests and frontmatter a host verifies against – are not relayed, so a host cannot load them as skills through mcpx. Relaying needs a new daemon route and a pool and client method for a request the upstream may or may not support, plus URI handling for the gateway’s mcpx:// rewrite; no fixture in the conformance suite serves skills, so nothing would test it today. |
resources/directory/read (directoryRead) | built | Declared as directoryRead: true wherever the skills extension is (2026-07-28, and not under ExtrasOnly, which serves no skills). Lists the direct children of any directory inside a served skill – files with the metadata resources/list gives them, subdirectories as inode/directory – paginated like resources/list; a file, an unknown URI or anything outside the skills is -32602. It follows the files, not a fixed layout: TestDirectoryReadFollowsAnyLayout covers nested, empty and binary content, and TestDirectoryReadListsEveryDirectoryOnDisk checks every shipped directory against the disk. All six sep-2640-skills-directory checks in the server-all leg pass. Upstreams’ skill directories are not listed, for the reason in the row above. |
Relay between hosts and upstreams
| what | state | detail |
|---|---|---|
| Log messages per call | partial | A log message does not say which call it belongs to; with several calls on one upstream connection, each gets it. |
| Upstream log level on legacy revisions | partial | Pinned to info at connect, so an upstream’s debug messages never exist to relay. |
| Progress and mcpx’s own timeout | partial | Progress reaches the host but does not extend mcpx’s per-server call timeout. |
Relay on prompts/get and resources/read | absent | Only tools/call relays progress and logs. |
| Client capabilities to 2025-11-25 upstreams | partial | Declared once at connection; per-request narrowing reaches 2026-07-28 upstreams only. |
Commands and settings
Full table: audit/surface.md.
| what | state | detail |
|---|---|---|
exec.timeout (documented default 120s, “kill a script after this long”) | absent | Local exec never reads it. MCPX_EXEC_TIMEOUT=1s mcpx exec 'await new Promise(r=>setTimeout(r,3000)); console.log("late")' printed late after 3.8s, rc 0; the flag and --remote are no different. Only --timeout (default 0 = no limit) kills, and it prints nothing – rc 124, empty output. Scripts have no default limit. |
Call-scoped settings on POST /v1/exec | absent | ExecService builds its settings once under a sync.Once from the config file and environment (internal/daemon/routes_exec.go:170-185), so the X-Mcpx-Settings header is ignored, mcpx daemon --<flag> is ignored, and nothing marked hot: true is re-read. The scope model in configuration.md does not hold on the exec path. |
| Daemon-scoped flags given to a client command | absent | mcpx call --pool-call-timeout 1s … is accepted, validated, printed back and ignored – including when that command auto-starts the daemon. configuration.md names this as the failure the scope field exists to prevent, and help call advertises ~40 such settings. Nothing warns. #185 |
mcpx help <command> listing “usage, flags and settings” | absent (flags) | No command’s help has a flags section. help exec never mentions --timeout, --keep, --session, --export, --ns, --runtime. The settings it does list are the same 71 for init, man and call alike, with nothing saying which command acts on which. #144, #147 |
script.permissions: none | minimal | none, off and unsandboxed all map to deno --allow-all (internal/defaults/defaults.json:43-45). A reader picking “none” for no permissions gets all of them. |
logstore.queryLimit, stats.top | partial | mcpx log and mcpx stats read the store in-process with their own hard-coded defaults (100, 20); the settings are read only by GET /v1/log and GET /v1/stats. The flag is listed in help log. |
tasks.ttl | partial | Applied to exec tasks only; a /v1/call task still gets 600000ms. |
| Global flags after the command | partial | --config, --profile, --skip-default and --all-profiles are documented as global but only parse before the command: mcpx ls --profile nope → flag provided but not defined: -profile. An unknown profile before the command is accepted silently and returns the default set. |
| Restarting the daemon itself | absent | restart replaces server processes; nothing restarts the daemon. #302 |
| Natural language to script | minimal | mcpx prompt matches a recipe or drafts a script; no declared model source, no run path through the ceiling end to end. When nothing is produced it still exits 0. #294 |
prompt --mode | absent | Renamed to --autonomy; --mode is flag provided but not defined, recorded only in decision 0002’s “Landed” note. Settings flags on prompt also only parse for autonomy, session and set. |
mcpx.namespace validation | partial | Checked for reserved words only. {"namespace":"1 bad-ns"} is accepted and listed. |
| Settings being read vs mattering | guard gap | internal/settings/consumed_test.go proves a setting is read, not that reading it changes anything. |
Scripts and their sandbox
Full table: audit/features.md.
| what | state | detail |
|---|---|---|
| Permission profiles as a boundary | partial | A script that can reach the daemon can ask the daemon for more than its own profile allows: under --permissions strict, posting to /v1/exec with options:{permissions:"all"} returns 200 and writes the file. /v1/exec does not cap requested permissions against the caller’s. |
artifact({path}) against a denied file | partial | Under strict, Deno.readTextFile is refused but artifact("leak.txt",{path:"…/secret.txt"}) succeeds, because the daemon does the read via x-mcpx-path. No test covers that header. |
--suffix “runs last on both paths, like a finally” | partial | On exec with a snippet that throws, the suffix never runs; on run with a file it does. Tests cover the file path only. |
--remote parity with local | partial | Drops the caller’s environment except --env, drops res.Logs so log.info output disappears, resolves profiles and runtimes from the daemon’s settings rather than the caller’s, and skips diagnose.preflight. |
Generated result types from outputSchema | absent | structured() is typed Promise<ToolResult> with type ToolResult = any; codegen never reads outputSchema. #216 |
Diagnostics and the log
Full table: audit/features.md.
| what | state | detail |
|---|---|---|
| Failed tool calls counted as failures | absent | "ok": err == nil (internal/pool/pool.go:959) and a tool returning isError is not an error there. mcpx call fake.boom exits 1 and prints “reported an error (isError)”, while the log record says "ok":true, stats calls shows ERRORS 0 / 0.0%, and stats errors says “No errors in the log yet.” Every error rate built on the log reports zero. |
stats servers call counts | partial | Summed from server.stop records only, so they disagree with stats calls (5 vs 12 observed). |
| Attribution by server | partial | Servers that share a pool id are all logged under one name, so calls to one appear against another. |
diagnose | partial | Does not check argument types (a number for a string reports “Nothing to report”), nor unknown namespaces, nor a script name that does not exist – each exits 0 saying there is nothing to report. #111 |
doctor server health | partial | Never probes a server. Before refresh, only a server with a missing binary fails; a crashing one and an unreachable one both pass. |
Adapters and OpenAPI
Full table: audit/features.md.
| what | state | detail |
|---|---|---|
Adapter timeout | absent | "timeout":"1s" on a 5s program returned after 5.05s. The context error is never consulted and there is no process-group kill (internal/adapter/adapter.go:255-261). No test. |
| Adapter and API argument checking | absent | Declared parameter type is not enforced at call time – "abc" for a number is passed through as argv. Unknown arguments are dropped silently, and a positional value starting with - is placed before the program’s own flags with no -- separator, so a caller can inject options. |
| OpenAPI cookie parameters, required query params and bodies | absent | Cookie parameters are dropped from the schema and sent as query values; only required path parameters are checked, so a missing required q is sent anyway and an empty body goes out as Content-Length: 0. |
securitySchemes / security | absent | Not referenced anywhere in internal/openapi or internal/cli; authentication is possible only through hand-written headers, and mcpx api --spec has no way to set them at all. |
| APIs as namespaces in scripts | partial | Only mcpx serve exposes them; in exec, typeof pets is undefined, though adapters do appear. |
The opencode plugin
Full table: audit/features.md. Checked against opencode 1.18.31 with plugin types 1.15.5, by invoking the hooks under bun rather than inside a running opencode.
| what | state | detail |
|---|---|---|
instructions option | absent | Pushes onto output.parts; the v1 hook type is output: { system: string[] }, so the system prompt comes back empty. #217 |
remember: "permanent" | absent | Sends persist: true where the daemon’s field is a string, so the write fails with cannot unmarshal bool into … .persist of type string. |
until-gone remembered scope | absent | Written to the file and never read; behaves exactly like indefinite. |
MCPX_HARNESS_VERSION | absent | Read from OPENCODE_VERSION, which opencode 1.18.31 does not set. |
| Documented discovery rung 5 (“ask the user”) and the headless warning line | absent | discover skips rung 5; toast() returns early when headless and writes nothing, so the documented log line never appears. |
Nothing loads mcpx-session.ts | untested | internal/e2e/plugin_test.go:32 runs bun test plugin/opencode/mcpx/ only. That is the gap the persist type mismatch went through. #141 |
Specification and the conformance matrix
Full table: audit/spec.md.
| what | state | detail |
|---|---|---|
| mcpx as a 2024-11-05 server over HTTP | partial | There is no HTTP+SSE server transport. A 2024-11-05 client’s opening GET gets 405 and a JSON-RPC error, never an endpoint event. docs/protocol.md:51 and docs/spec/transport.md:9 advertise “2024-11-05 over stdio and Streamable HTTP”, a combination that revision does not have; in practice it is stdio only, and the matrix shows no gap. |
| The progress MUSTs (must increase, stop after completion, active tokens only, rate limit) | fixed | Relayed progress is now checked before it reaches the host (mcpclient.acceptProgress), and the server-side cells run a real upstream that reports out of order, after completion, in a burst and after the response (serverRelayedProgress in internal/conformance/utilities_test.go). |
sampling-deprecated-new-impls-should-not-adopt (2026-07-28) | partial | Catalogued as “mcpx forwards; pass-through, not adoption”, but the daemon originates sampling for script generation (internal/daemon/consumer.go:589-603) and declares the capability to every upstream that could answer. The cited test asserts the declaration exists, so it passes on the behaviour the requirement discourages. #210, #256 |
Timeout-despite-progress and tasks/cancel requirements | untested | The shared bodies covering them never send progress and never send a task-augmented request, so they hold only because progress currently resets nothing. A change that let progress extend a timeout uncapped would pass every cited test. #209 |
| The matrix’s “tested” column generally | guard gap | 102 loops register one test body for a list of requirement ids; every id in the list then reads “tested”. At least two rows point at the wrong test, one of which is genuinely covered by an uncited test elsewhere. |
The lifecycle-stdio-client-shutdown-sequence gap (#203) | fixed; record removed, the five cells run | The gap says StdioTransport.Close “sends SIGTERM at the same moment it closes stdin”. It does not: internal/mcpclient/stdio.go:260-275 closes stdin, waits stdinGrace, then SIGTERM, then SIGKILL after termGrace. All five cells (TestLifecycleClient for 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25 and TestTransportClient/2026-07-28/transport-stdio) SKIP by default and PASS under MCPX_CONFORMANCE_RUN_GAPS=1. So five passing tests guard nothing and the matrix under-reports five client cells. Remove the gap, do not fix the code. |
Install
Prerequisites, before the table: a JavaScript runtime — deno (recommended; it is the only runtime where typechecking works), bun, or node 22.6 or later — unless you install with nix or the container, which bring their own.
gitis recommended (without it, therepoandworktreescopes degrade to the working directory). macOS or Linux; on Windows, use WSL. When you are done:mcpx doctor.
Channels
The status column is the honest part of this page. Today, nix, a locally built container, and the plugin copy are the only channels that exist; everything else is designed but not built (that work is #1881), and this page will not pretend otherwise.
| Channel | Command | Status |
|---|---|---|
| nix | nix run github:developing-today/code#mcpx -- ls to try it; nix profile install github:developing-today/code#mcpx to keep it | Available — the flake builds pkgs/mcpx/package.nix and exposes it as an app; the wrapper puts git, deno, bun and node on PATH, so a nix install is complete with no other prerequisites |
| container | from the repository root: docker build -f pkgs/mcpx/Containerfile -t mcpx pkgs/mcpx (or podman), then run the image — see container | Available locally — the Containerfile builds a working image (bun as its runtime; deliberately no git, see container); the release workflow now builds the same image at the release version and pushes it on an mcpx/v* tag, and no tag has been cut yet |
| just the plugin | copy pkgs/mcpx/plugin/opencode/ into ~/.config/opencode/plugin/ (the plugin README’s “Install”) | Available as a manual copy — and it is not an install of mcpx: the plugin needs no mcpx binary on its machine, because it finds a daemon over the unix socket or an endpoint; it still needs a daemon running somewhere, and as copied it exposes no tools and injects no guidance until you set tools: true / MCPX_PLUGIN_TOOLS=1 (and instructions: true for the prompt) |
| bash installer | per-user: curl -fsSL <short> | sh; system-wide: curl -fsSL <short> | sh -s -- --system (fallback: curl -fsSL <short> | sudo sh) | Shipped, waiting on the first tag — pkgs/mcpx/scripts/install.sh is in this repository and CI installs a snapshot artifact with it (checksum verified, refusal of a tampered tarball proven); it downloads a GitHub release, and no mcpx/v* tag has been cut yet, so the commands above do not work until one is |
| agent prompt | a prompt you paste to your agent, with the llms.txt URL | Not yet |
| pwsh (install.ps1) | irm <short>/install.ps1 | iex | Blocked — mcpx does not build for Windows today; WSL is the Windows answer |
| rpm / deb | dnf install ./mcpx-<version>.rpm | Not yet |
| flatpak | — | Not recommended (decided in the release design, #1881) |
| pkg | — | Not yet — and still an open question: macOS .pkg, FreeBSD pkg, or Homebrew |
| docker (published) | docker run --rm ghcr.io/developing-today/code/mcpx ls | Wired, not yet published — an mcpx/v* tag pushes ghcr.io/developing-today/code/mcpx:<version> and :latest; no tag has been cut yet, so pull the local build in the container row until one is |
Nix caveats, stated plainly: the flake is a large monorepo flake, so
installing one package from it fetches and evaluates a lot (the cost
from a cold machine is unmeasured — an open question on #1878), and
x86_64-darwin is excluded; aarch64-darwin, x86_64-linux and
aarch64-linux are the supported systems.
The system-wide forms above are deliberate. Never
sudo $(curl <short> | sh): that runs the installer unprivileged and
then executes the installer’s stdout as root. The --system form
lets the script escalate only the copy into /usr/local/bin.
Install as
Installing is only half the choice; the other half is how the daemon lives on the machine. Every row except the service is shipped today.
| Mode | What to do | Status |
|---|---|---|
| Just run the binary | Nothing. Any command that needs the daemon starts one (daemon.autostart, default true), and an auto-started daemon exits after 4 hours idle (autostart.idleExit) | Shipped |
| Per-user | Binary in ~/.local/bin; config at ~/.config/mcpx/config.json | Config shipped; the installer ships and places the binary there once a release exists (#1881) |
| Per-host | Binary in /usr/local/bin (the installer’s --system); config at /etc/mcpx/config.json, the last stop on the config search path | Config shipped but was undocumented until this page; the installer’s --system form places the binary once a release exists (#1881) |
| Local host (a daemon other machines reach) | mcpx daemon --address <ip> --port <n> on that host; clients set daemon.endpoint / MCPX_DAEMON_ENDPOINT — a client with an endpoint set never autostarts a local daemon. Remember: the API is unauthenticated, the network is the access control | Shipped |
| Persistent daemon | mcpx daemon (foreground) or mcpx daemon --detached; a deliberately started daemon’s daemon.idleExit defaults to 0 — it never idle-exits | Shipped |
| System service | A launchd plist or systemd unit running mcpx daemon, with daemon.autostart false on clients so no stray second daemon starts | Proposed, not shipped — until then, the on-demand daemon above is the supported path |
| Fallback: no daemon | Set daemon.inline true: servers run inside each command process. Slower, nothing pooled, nothing shared | Shipped, last resort |
Verify
mcpx doctor
doctor checks the runtime, git, the configuration chain, whether
every configured server’s command is actually installed, and the
daemon — and each failed check says what to do about it.
Then: the quick start.
Interfaces
Every way into mcpx is one of two kinds. The abstract interfaces
are the declarations everything else is generated from: one operation
table, one settings registry, one command list. The client
interfaces are the surfaces code actually drives: a generated
TypeScript client, the CLI, the daemon’s /v1 HTTP API, the plugin’s
DaemonClient, and MCP itself.
This page is the map. The detail lives in the pages it links to: the CLI, the binary, scripts, and the generated parity table.
Abstract interfaces
mcpx declares each thing it can do once, as an operation — an
Op in the table spread across internal/api/ops.go and its sibling
ops_*.go files (81 operations at the time of writing; the generated
table in parity.md is the live count). From that one
declaration come:
- the
/v1route on the daemon, - the
mcpx_<op>MCP tool, - a generated CLI command, for operations that have one,
- a typed plugin method on
DaemonClient.ops(plugin/opencode/mcpx/ops.gen.ts, generated the same way), - the operation’s entry in
/v1/openapi.jsonand in the documentmcpx openapiprints — one document now, generated from the table; the stale hand-written serve-surface document it replaced is recorded in the header ofinternal/cli/openapi.go.
internal/api/ops*.go
one Op per operation
|
+----------------+----------------+
| | |
/v1 route mcpx_<op> generated CLI
(registered MCP tool command, and
by hand, DaemonClient.ops
held by test) method
One part of that diagram is deliberately not an arrow: the routes
are not generated. They are registered by hand in the daemon,
and a test (internal/api/parity_test.go) reads them back out and
checks them against the table. Generation where it is safe, a test
where it is not — the effect is the same: no surface can quietly
drift from the declaration.
The claim the design makes is quoted word for word at the top of parity.md:
Everything the CLI can do can be done from a plugin, can be done over
/v1, can be done from MCP.
What enforces it today is also listed there, because the parity
document is generated from the same declarations and cannot
describe a surface that does not exist. Each direction is held by a
named test — every route has an operation and every operation a
route; every non-streaming operation is reachable as an MCP tool;
every operation is reachable from the CLI; every command the binary
dispatches is declared in Commands() and every declared one runs
(TestEveryDispatchedCommandIsDeclared); and the plugin’s operation
methods are generated and exercised against a real daemon. Where an
operation is privileged — stopping the daemon, for example — it is
offered rather than hidden, and carries MCP annotations
(readOnlyHint, destructiveHint) for a client that wants to scope
on them; a caller who can reach the socket can already do these
things, so hiding them would buy no safety.
The CLI and the plugin both sit on top of /v1: the CLI talks
to the daemon over its unix socket, and the plugin’s client is an
HTTP client for the same API. There is no second, private interface
for insiders.
Two sibling declarations work the same way, and the interface pages lean on both:
- Settings. Each setting is declared once in the registry with its type, default, scope and aliases, and is then reachable from the config file, an environment variable and a per-command flag without being restated. The inventory is generated into configuration.md; the environment names are generated into environment.md.
- Commands.
Commands()ininternal/cli/manual.godeclares every subcommand once, so help, the man page, shell completions and the generated command table in parity.md cannot disagree about what exists. See cli-commands.
Client interfaces
Every way code can drive mcpx, with the smallest useful snippet for
each. Pick by who is calling: a script wants the generated client, a
shell wants the CLI, a harness wants DaemonClient or MCP, and
anything else wants raw /v1.
The generated TypeScript client — for scripts
mcpx client prints the client generated from your configured
servers’ schemas; inside mcpx run and mcpx exec it is already
installed as the globals tools, log and emit, with each
namespace also a bare identifier. mcpx types shows the signatures.
const pages = await tools.chrome_devtools.list_pages({});
In a file of your own, import it instead:
import tools from "./mcpx-client.ts";
Discovery is in the same surface: scripts can call search() and
describe() to find a tool and read its signature at run time
rather than having every schema loaded up front. The contract a
script runs under — stdout is the result, emit() streams, a
default export is the entry point — is the
scripts page.
The CLI — for people, shells and CI
mcpx ls
mcpx search screenshot
mcpx call chrome_devtools.take_screenshot '{}'
The CLI is a client interface, not just a person’s interface:
--json makes its output one machine-readable document, and
mcpx call <ns>.<tool> [json] makes a single tool call with no
JavaScript runtime involved at all. The whole surface is the
CLI page.
DaemonClient and connect() — for harnesses and Bun/TypeScript programs
The opencode plugin’s client (plugin/opencode/mcpx/daemon.ts)
talks to the daemon over its unix socket — measured at about
0.17 ms per call against about 23 ms for spawning the binary. It is
not published as a package; harness plugins vendor it, and any
Bun/TypeScript program can do the same.
import { connect } from "./mcpx/daemon.ts";
const { client, discovery } = await connect({ directory });
if (client) await client.call("fff", "grep", { query: "x" }, sessionID);
connect() runs the discovery ladder (which daemon, on which
socket, for this directory). The client itself offers generic
raw / get / text / post / postRaw / putRaw methods,
named typed conveniences (status, exec, call, logQuery,
putSetting, …), and client.ops — one generated method per
operation, so nothing is reachable only through a hand-built URL.
The plugin’s own documentation is
plugin/opencode/README.md, “Talking to the daemon directly”.
Any MCP client — for MCP hosts
A host that only speaks MCP needs no plugin and no client library:
point it at the daemon’s /mcp endpoint, or have it spawn
mcpx serve --transport stdio. mcpx is then simply an MCP server
with a deliberately small tool surface — the operations above,
reachable as mcpx_<op> tools. The MCP pages (overview, SDK,
compatibility) are the sibling half of this page set; the protocol
itself is specified in protocol.md.
Raw HTTP on /v1 — for curl and other languages
curl --unix-socket <sock> http://mcpx/v1/health
Every operation in the table is an HTTP route, and
GET /v1/openapi.json describes them all — generated from the
table, so a client generator fed with it produces a client for the
daemon that actually exists. Do not confuse it with
mcpx schema --format openapi, which describes the tools of
your configured servers, not the daemon: feeding the wrong document
to a generator produces a working client for the wrong thing.
Landing checklist
My reading of the brief, broken into items. Kept as the working list until the ship lands; each item records what was decided and why, so a disagreement has something specific to argue with.
Legend: [ ] todo, [x] done, [~] partial, [?] guessed — needs a look.
A. Console, from the script’s perspective
- A1. The question is not “does it log” but “does a script that was
written against a normal console still behave”. Arguments, arity, return,
throw-parity,
.name, formatting, getters, symbol keys, subclass instances, circular values, very large values. Test the surface, not the happy path. - A2.
Deno.stdout.writeandDeno.stderr.writebypass capture. This is correct — a script writing raw bytes asked for raw bytes — but it should be written down once, in the reference, not in an agent prompt.
B. The launcher
- B1. Whole-launcher override.
--launcher <string|file>replaces the template.--no-launchermeans no launcher at all: the script is handed to the runtime directly. The brief asked for one bare-valued flag; it became two, for the reason in “Guesses worth reviewing” below (internal/cli/commands.go:490-493). - B2. Placeholders in a custom launcher:
@entry,@globals,@prefix,@before,@onSuccess,@onError,@suffix,@import. - B3. Reference graph must be a DAG; a cycle is a generation-time error naming the path that closed it.
- B4. Each placeholder resolves once. A repeat is an error unless
allowRepeatnames it.
C. String-or-file, everywhere
- C1. Every string-shaped input (
prefix,before,onSuccess,onError,suffix,launcher,prelude) accepts inline source or a path. - C2. Detection is by probe, not by guessing: if it resolves to an
existing file, it is a file. Explicit forms
@file:/@text:force it. - C3. Both forms set at the same level is an error. Set at different levels is ordinary precedence, no conflict.
- C4. A directory means: concatenate its files, lexicographic order,
shallow by default. Leading-number ordering must work, so
10sorts after9— natural order, not raw byte order. - C5. Per-kind override of whether a directory is even allowed. A prefix probably should not be a directory; default that off in plumbing.
D. Unified configuration
- D1. One schema. A setting is declared once with type, default, pretty name, short and long description, and aliases — and is then reachable from the config file, an environment variable, and a flag, without being restated.
- D2. Flags and env vars are generated from the schema. Env is
MCPX_<PATH>derived from the dotted path; aliases are extra. - D3. Boot order is explicit and inspectable: built-in defaults, then config files nearest-last, then environment, then flags.
- D4. Alias conflicts: two spellings of the same setting at the same precedence is an error that names both.
- D5. Cross-field validation up front — “X only means something when Y is Z” is caught before any work starts, not discovered mid-run.
- D6.
mcpx config --schemaprints the whole surface.
E. Search paths
- E1. Config, script and log locations are each a list of candidates with a built-in default.
- E2. The null splice works in these lists, so a user can say
["./mine", null, "../fallback"]and havenullstand for the built-in. - E3. A file where a directory was expected is accepted and means exactly that one file.
F. The logging store
- F1. SQLite index over the JSONL, call-grained.
- F2.
mcpx log— query, filter, follow. - F3.
mcpx stats— aggregate. Invent the useful ones. - F4. Trace-chain walk: from any record back through its parents.
- F5. Rotation on age, total size, and line count — all configurable.
- F6. A way to inspect the store directly, including raw SQL.
G. Validation before execution
- G1. Resolve and check every path, file and directory up front.
- G2. Type-check the generated program without running it, following imports. Deno can do this; record honestly where it cannot.
- G3. A
.tsand a.jsof the same name is ambiguous. Prefer.ts, but make that a guard with a plumbing switch rather than a silent rule.
H. Plumbing
- H1. A
plumbingsection for internals with no ordinary reason to change. Marked clearly as unsupported. - [~] H2. Wherever the code has a guard or a throw, ask whether it wants a plumbing switch. No use case needed; the point is not being stuck.
I. Commands and help
- I1. Enumerate every command, with what it does.
- I2.
--helpcomplete and accurate at every level. - I3. A man page.
- I4.
mcpx <string>— a bare argument that is obviously source or a script should run, not error. - I5. Decide what remains of the exec/run distinction.
J. Landing
- J1. Small clean commits throughout.
- J2. PR, then review rounds.
- J3. Final report: what was guessed, what the alternatives were, why the guess, and what was deliberately not done.
Outcome
Everything above is built except where marked.
Guesses worth reviewing
Each of these had a real alternative. The choice is stated so it can be argued with.
--launcher takes a required value, with a separate --no-launcher.
The brief asked for one flag that means “none” when given bare. Go implements
optional-value flags only by treating them as boolean, and then
--launcher mine.ts silently runs mine.ts as the script with no launcher.
Two flags, no trap. The alternative – keeping one flag and documenting that
--launcher=X needs the equals sign – trades a real misfire for a smaller
surface.
Directory concatenation is off for phases by default. plumbing.sourceDirAllowed
governs it, and phases pass AllowDir: true while --launcher passes false.
A launcher assembled from several files is more likely a mistake than an
intention; a prefix assembled from several is plausible.
defaults wins over pool. Two spellings of one block. The older one
wins so that adding the synonym cannot change what an existing file means.
The alternative – pool winning as the “canonical” name – would silently
alter existing configurations.
The bare-argument heuristic is narrow. A first argument runs if it names a
file, ends in a script extension, or contains (){};, =>, await ,
console. or tools.. A bare = was in that set and is not any more,
because a mistyped flag has one and running a typo as a program is worse than
refusing it.
Percentiles are exact, not estimated. These datasets are small enough that an estimator would only add error.
Redaction elides partial matches rather than whole values. A variable holding several pairs is still worth seeing without the one that matters.
Not done, and why
Full projection of the registry into every consumer. output.json,
paths.state, paths.cache and most of daemon.* still have their own
paths, and two of those must: the state and cache directories say where to
look for configuration, so they cannot come from configuration. docs/story.md
names exactly which settings the registry governs rather than implying it
governs all of them.
A plumbing switch for every guard. Nine exist. The ones left alone are those where the other branch is not a coherent program – a malformed daemon response, a socket that will not bind. A switch there offers a choice between working and not.
mcpx exec and mcpx run remain separate. The difference is real: exec
generates the module so a prefix shares scope and can declare bindings; run
imports a file so a prefix can act but not declare. Merging them means
picking one and losing the other. A bare argument now dispatches to whichever
fits, which was the part that actually chafed.
H2 is partial by design. Nine guards got a plumbing switch. Every throw
in the tree was not audited one by one; the ones that were left alone are
those where the other branch is not a coherent program – a malformed daemon
response, a socket that will not bind. A switch there would offer a choice
between working and not.
I5, the exec/run distinction, stayed. The difference is real and small:
exec generates the whole module, so a prefix shares scope and can declare
bindings; run imports a file, so a prefix can act but not declare. Merging
them would mean picking one of those behaviours and losing the other. A bare
argument now dispatches to whichever fits, which is the part that was actually
inconvenient.
mcpx level-2 page map
The map for the level-2 docs: the layer a person reads right after installing mcpx (“basically a more normal set of docs — a new person might assume this is all there is and won’t be upset”). It assigns every one of the 30 items in the source notes to a page and a fixed anchor, records the repo material each item draws on, the issue that writes it, and which parts come from the generated reference.
- Tracker: #1898. This map is part 1 of #1848; the first seven items’ content is #1848 part 2, still to write.
- Structure: Approach C from #1848 — about ten grouped pages, each
item with its own fixed anchor (
page#anchor), anchors set explicitly rather than derived from headings so a renamed heading never breaks a link. Totals: 10 pages, 30 anchors —configuration4,modes3,interfaces2,cli3,mcp3,binary1,scripts1,http-api2,plugins6,faq5. - Navigation order follows the notes. (#1848 open question 3 asks whether task order would be better; the notes’ order stands until that is answered.)
- Writing issues, in this repo’s numbering: #1848 (configuration + modes), #1851 (interfaces, CLI, MCP, binary, script, /v1), #1855 (plugins), #1859 (FAQ). The generated reference is #1843. (#1848’s body quotes the original dezren39/nix numbering — #146/#147/#148/#149 and #144 — for the same issues.)
- Rule for generated content at level 2. When a page needs reference data, it embeds the relevant slice generated from the same source (for example “settings where scope = client”), it does not just link to the full reference — linking would break “this is all there is”. #1843 has to support filtered embeds, not only whole pages (#1848 open question 7).
Verification (2026-10-29, HEAD 5a34fa70)
Every source path below was re-checked to exist at HEAD. Paths are
relative to pkgs/mcpx/. Three corrections to the refs as quoted in
#1848, found by that check:
- The story file is
docs/story.md; there is nopkgs/mcpx/story.md. - Line numbers quoted in #1848 have drifted (for example the
mcpServerstag is now atinternal/config/config.go:190andSearchPathFromatinternal/config/config.go:713). This map cites files, not lines, so it does not rot the same way. .mcpx/scripts/does not exist at HEAD (see item 82).
The map
| Notes line | Item (verbatim) | Page #anchor | Source material in the repo | Written by | Generated part (#1843) |
|---|---|---|---|---|---|
| 53 | common config | configuration#common-config | docs/configuration.md “Where a value can come from”, “Scope”, “Hot”, “Reaching them”; internal/config/config.go (search path); internal/cli/manual.go entries config, settings, init | #1848 | link to the settings table; no copy |
| 54 | server config | configuration#server-config | internal/config/config.go (server block); docs/story.md “Sharing and scope”, “Profiles and aliases”, “Remote servers without a shim”; docs/configuration.md “Servers”; internal/cli/manual.go servers | #1848 | open: a JSON Schema for the file (pool.* rows are generated) |
| 55 | client config | configuration#client-config | docs/configuration.md “Scope: who reads it”; registry rows with scope client and call (internal/settings/registry.go) | #1848 | the client- and call-scoped settings, filtered from the registry |
| 56 | env vars | configuration#env-vars | docs/configuration.md “variable” column; docs/environment.md, generated from settings.ScriptEnv() (internal/settings/scriptenv.go) and held to the setters by a test; the names neither source declares, listed below | #1848 | registry names and the script environment both generated; only the names neither declares are hand-written |
| 57 | client modes | modes#client-modes | docs/story.md “Running without a daemon”, “One daemon, many clients”; docs/exec.md “Where a script runs”; AGENTS.md “Profiles” | #1848 | — |
| 58 | transport options | modes#transport-options | docs/protocol.md §5; docs/story.md “What each transport costs”, “Server-sent events, not WebSockets”, “Lossless reconnection”, “Remote servers without a shim”; docs/dependencies.md “Network” | #1848 | — |
| 59 | server modes | modes#server-modes | docs/story.md “mcpx speaks MCP”, “Sharing and scope”; docs/protocol.md §5 | #1848 | — |
| 60 | abstract interfaces | interfaces#abstract-interfaces | internal/api/ops*.go; docs/parity.md | #1851 | the parity table |
| 61 | client interfaces | interfaces#client-interfaces | mcpx client; internal/codegen; plugin/opencode/mcpx/daemon.ts (DaemonClient) | #1851 | — |
| 62 | cli | cli#cli | AGENTS.md “The loop”; cmd/mcpx/main.go | #1851 | — |
| 63 | cli arguments | cli#cli-arguments | cmd/mcpx/main.go; internal/settings/bind.go | #1851 | per-command registry flags |
| 64 | cli commands | cli#cli-commands | internal/cli/manual.go Commands() | #1851 | yes: the whole command table |
| 65 | mcp | mcp#mcp | docs/story.md “mcpx speaks MCP”; docs/protocol.md §2–§4 | #1851 | — |
| 66 | mcp sdk | mcp#mcp-sdk | docs/dependencies.md; go.mod | #1851 | — |
| 67 | mcp compatibility | mcp#mcp-compatibility | docs/protocol.md §1, §6; internal/mcpserver/revisions.go; GET /v1/protocol | #1851 | the revision matrix |
| 68 | binary | binary#binary | docs/dependencies.md; package.nix | #1851 | — |
| 69 | script | scripts#script | AGENTS.md “Scripts”, “Script contract”; docs/story.md “The script contract”, “Named scripts”; docs/exec.md | #1851 | — |
| 70 | /v1 | http-api#v1 | GET /v1/openapi.json (internal/api/openapi.go); docs/parity.md | #1851 | yes: rendered from the OpenAPI document |
| 71 | /v1 sdk | http-api#v1-sdk | DaemonClient (plugin/opencode/mcpx/daemon.ts); /v1/openapi.json | #1851 | — |
| 72 | plugins | plugins#plugins | plugin/opencode/README.md | #1855 | — |
| 73 | plugin compatibility | plugins#plugin-compatibility | docs/opencode-plugin.md §1, §5 | #1855 | — |
| 74 | plugin interface & config | plugins#plugin-interface-and-config | plugin/opencode/README.md “Configuration”; docs/plugin-discovery.md; plugin.* registry rows | #1855 | plugin.* rows |
| 75 | opencode v1 plugin | plugins#opencode-v1-plugin | plugin/opencode/README.md; docs/opencode-plugin.md §2, §3, §6 | #1855 | — |
| 76 | opencode v2 plugin | plugins#opencode-v2-plugin | docs/opencode-plugin.md §4 | #1855 | — |
| 77 | vibeMcp | plugins#vibemcp (provisional; moves once its meaning is known) | nothing in the repo | #1855 | — |
| 78 | “so why’d you get rid of all the mcp and then make one, do you like mcp or not” | faq#do-you-like-mcp | docs/story.md “# Summary”, “mcpx speaks MCP” | #1859 | — |
| 79 | “i mean, opencodemode is right there” | faq#opencode-code-mode | OPENCODE-V2.md (with its 2026-09-29 corrections); docs/opencode-plugin.md §1 | #1859 | — |
| 80 | “is it safe?”, no. | faq#is-it-safe | AGENTS.md “Scripts”; internal/runner/runner.go | #1859 | — |
| 81 | “but why would you..” | faq#why | ASSESSMENT.md; docs/story.md opening walk | #1859 | — |
| 82 | “but can i get some examples?” | faq#examples | docs/story.md walk; mcpx recipes (internal/cli/manual.go); Examples fields in internal/cli/manual.go. ⚠ .mcpx/scripts/, cited in #1848, does not exist at HEAD — do not cite it as a source until it does | #1859 | per-command examples |
#1855 may split plugins into an index plus an opencode page. The
anchors stay the same either way.
Contradiction flags — do not copy these into any page
#1848’s Findings name three places where the current docs or
behaviour contradict each other. They are flagged here on the pages
they touch, with their state re-verified at HEAD 5a34fa70
(2026-10-29):
- The file key is
mcpServers, notservers— STILL TRUE.Config.MCPServersisjson:"mcpServers"(internal/config/config.go), the same object other MCP hosts use; “servers” is the CLI command and the/v1resource. Touches:configuration#common-config,configuration#server-config. Both pages must show both names and say which one goes where. mcpx init’s starter config taught a deadmodekey — RESOLVED at HEAD; do not reintroduce it. When #1848 was written,starterConfigset"mode": "shared"/"mode": "session", keys the loader silently drops (modehad split intosharingandscope). At HEAD the starter config teachessharing/scopecorrectly and a test pins it (TestStarterConfigMeansWhatItSays,internal/cli/commands.go), and the top-level help’s CONCURRENCY block (internal/cli/root.go) teachessharing/scopetoo. Touches:configuration#common-config,configuration#server-config,modes#client-modes,modes#server-modes. Pages teachsharing/scope; themodekey and itspooledvalue must appear nowhere.- The config search path is wider than anything documented —
STILL TRUE.
SearchPathFrom(internal/config/config.go):MCPX_CONFIGalone if set; otherwise.config/mcpx/config.json,.mcpx.json,.mcpx/config.jsonand.mcp.jsonat each directory from the working directory up to/, then$XDG_CONFIG_HOME/mcpx/config.json,~/.config/mcpx/config.json,~/.mcpx.jsonand/etc/mcpx/config.json..mcp.jsonis Claude Code’s project file, so mcpx picks up a project’s Claude Code servers — nothing says so in the current docs. Touches:configuration#common-config(the page must list the path exactly, in order).
Two smaller points from #1848 that pages must also get right:
- Env vars outside the registry (touches
configuration#env-vars): the registry is not the only declaration there is.settings.ScriptEnv()(internal/settings/scriptenv.go) declares the environment mcpx and the plugin hand to a script —MCPX_SESSION,MCPX_SESSION_ID,MCPX_PARENT_SESSION_ID,MCPX_EPHEMERAL,MCPX_RUN,MCPX_INPUT,MCPX_ENDPOINT,MCPX_SOCKET,MCPX_ARTIFACTS_LOCAL,MCPX_CWD,MCPX_PID,MCPX_ENTRY,MCPX_CLIENT,MCPX_CONFIG_PATH,MCPX_SCRIPT_DIRS,MCPX_RUNTIME— anddocs/environment.mdis generated from it, with a test holding the two directions together (scriptenv_test.go). So the caller identity the plugin sets (plugin/opencode/mcpx-session.ts) and the names the generated client reads (internal/codegen/emit.go) are already declared, not hand-written. What an env-var page must add beyond the two generators is the small remainder neither declares — config selection (MCPX_CONFIG) and harness context — and it should link the generated page rather than copy it. - Upstream “sse” is not the legacy SSE transport (touches
modes#transport-options):POST /v1/serversacceptstransport: stdio|http|sse(internal/api/ops_settings.go), but the pool builds only a stdio transport or Streamable HTTP (internal/pool/pool.go,internal/mcpclient/http.go) whose replies may arrive as an SSE stream. Separately, the deprecated 2024-11-05 HTTP+SSE transport does have a client —LegacySSETransport(NewLegacySSE,internal/mcpclient/sse.go) — so the transport page must document that fallback rather than deny it, and say which paths reach it, while still not implying thattransport: ssemeans an SSE-only server (#1848 open question 5).
MCP
mcpx speaks the Model Context Protocol in both directions, and in both eras of it. As a client it connects to every MCP server in its configuration, so their tools are reachable without their schemas ever entering a model’s context. As a server it offers itself — a small gateway surface over all of those servers — to any MCP host. The protocol reference is protocol.md; this page is the map of the surface a host or a client connects to.
MCP
mcpx as an MCP server
A host reaches mcpx’s MCP surface two ways:
-
Streamable HTTP, at the daemon’s
/mcp. The daemon mounts/mcpon the same listeners as its/v1API: the unix socket and the loopback TCP port. There is no separate MCP server process and noserve --transport http— that command was removed, and asking for it fails with the address to use instead (protocol.md §5 tells the whole story). To put MCP on a network, move the daemon’s listener:mcpx daemon --port 8899 --address 0.0.0.0, then connect tohttp://host:8899/mcp. The warning that applies to/v1applies here unchanged: the surface is unauthenticated, so whatever can route to the port can run tools as you. -
stdio, via
mcpx serve. An MCP host starts its servers by spawning a process and talking down the pipe, so something has to be spawnable;serveis that something, and it is a thin shim — every tool it lists is answered by the daemon, which it starts if it is not running. A host configuration looks like the one every host already knows:{ "mcpServers": { "mcpx": { "command": "mcpx", "args": ["serve"] } } }
The surface is small on purpose. Exposing every upstream tool over
MCP would rebuild the problem mcpx was built to solve — hundreds of
schemas in the model’s context — with extra steps
(story.md, “mcpx speaks MCP”). What a host gets by
default (serve.mode = code-mode) is the gateway: the named
tools (mcpx_exec to run code next to the servers, mcpx_call for
one direct call, mcpx_search / mcpx_types / mcpx_catalog for
discovery) plus a generated mcpx_<op> tool for every operation in
the /v1 table — every one of them a way to reach the upstreams
through mcpx, never the upstreams’ own schemas pasted into the
list. Prompts are offered as <namespace>_<prompt> and resources
as mcpx://<namespace>/<uri>.
Two more modes widen it: reactive keeps a bounded working set of
upstream tools, adapting as calls name new ones, and a
pass-through server (mcp.passthrough, --passthrough <server>
on mcpx serve or mcpx daemon) is offered as itself — its tools,
prompts and resources under their own names, its results verbatim.
A client can also ask for the raw surface per connection: a request
to /mcp?codemode=false gets every upstream tool, flat, under a
namespaced name (<namespace>_<tool>), and the gateway’s own tools
are not on that list — the raw surface replaces the code-mode
surface, it does not add to it. Calls to a raw name go to its
upstream under the upstream’s own name. This is the switch opencode
v2 appends to a remote server’s URL when the server is not
configured codemode: false, so a v2 host’s own code mode can
orchestrate mcpx’s pooled servers directly. Any other value, and
the parameter’s absence, change nothing; the switch is read from
each request’s URL, so one connection can be answered raw and the
next through the gateway.
Questions flow backwards over the same connection. When an upstream
tool asks something mid-call — an elicitation, a sampling request —
mcpx puts the question to the client that made the call when that
client said it could answer, and to its broker otherwise
(elicitation.md). And
subscriptions/listen, the modern subscription stream, is accepted
from a client of any era, each notification tagged with the
subscription id that asked for it.
Which methods are answered, per revision, and which result shapes are downgraded for older clients, is protocol.md §2 — generated claims are not repeated here because that document is where they are held to the code.
mcpx as an MCP client
Upstream, mcpx is a dual-era client of every configured server. It
probes the modern era first (server/discover), falls back to the
legacy handshake, and caches the answer per server configuration,
so the probe costs one round trip rather than one per start. To a
legacy server it offers 2025-11-25 and accepts anything from
2024-11-05 up; a per-server override exists (upstream.protocol)
for a server that needs to be met on specific terms. Transports are
stdio and Streamable HTTP, with the 2024-11-05 HTTP+SSE transport
as the last fallback of the HTTP probe. The per-revision client
rules are protocol.md §4.
The server-initiated requests mcpx answers for its upstreams:
| request | how mcpx answers |
|---|---|
ping | {} |
roots/list | the daemon’s configured roots |
elicitation/create | through the broker — the question is routed to whoever can answer it, a host client included |
sampling/createMessage | through the broker; a decline becomes an error, because sampling has no decline result |
| anything else | a -32601 error — always answered, because silence is indistinguishable from a hung server |
A modern server asks the same things by answering input_required
instead of sending a request; mcpx resolves those through the
identical function, so the two eras cannot answer the same question
differently.
The MCP SDK question
“mcp sdk” names three different things. The honest answer is different for each.
1. Which MCP SDK mcpx uses: none
mcpx is built on no MCP SDK. There is no MCP module in
pkgs/mcpx/go.mod; the client (internal/mcpclient), the server
(internal/mcpserver) and the JSON-RPC framing are mcpx’s own code
— about one package each, which is the point.
dependencies.md says so in as many words, and
the dependency list stays at the handful of Go libraries there
because mcpx speaks the protocol itself instead of depending on
somebody else’s reading of it.
2. Using mcpx from an MCP SDK: any of them
Any official SDK client connects the way it connects to any MCP
server: over Streamable HTTP to the daemon’s /mcp, or by spawning
mcpx serve for stdio. With the official TypeScript SDK:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const client = new Client({ name: "my-host", version: "1.0.0" });
await client.connect(new StreamableHTTPClientTransport(new URL("http://127.0.0.1:8899/mcp")));
const tools = await client.listTools();
const status = await client.callTool({ name: "mcpx_status", arguments: {} });
(The port is the one the daemon is actually on: with the default
daemon.port of 0 the daemon chooses one at start-up, and
GET /v1/health reports it — see http-api.md.
For stdio, swap the transport for the SDK’s
StdioClientTransport with command: "mcpx", args: ["serve"].)
3. An SDK mcpx provides for writing servers: there isn’t one
mcpx publishes no SDK for writing MCP servers or mcpx extensions,
in any language. What exists for building on mcpx is the surfaces
themselves: a server is any program that speaks MCP and is named in
the configuration, and a program that wants to drive mcpx uses the
/v1 API and its generation story or the
plugin’s client described there. A page that implied a supported
server SDK would be promising a thing nobody maintains.
Compatibility
The revisions
mcpx serves five revisions, in two eras, and speaks the same five upstream:
| revision | era | mcpx serves | mcpx speaks upstream |
|---|---|---|---|
2024-11-05 | legacy | yes, over stdio and Streamable HTTP; its HTTP+SSE server transport is not hosted | when the server answers with it; HTTP+SSE as the last fallback |
2025-03-26 | legacy | yes | when the server answers with it |
2025-06-18 | legacy | yes | when the server answers with it |
2025-11-25 | legacy | yes | offered on every initialize |
2026-07-28 | modern | yes | probed first |
The split that matters: a legacy implementation negotiates once and can send its peer a request at any time afterwards; a modern one has no handshake, restates its version on every request, and — as a server — has no channel on which to send a request of its own. Modern against legacy fails; only a dual-era implementation bridges them, and mcpx is dual-era in both directions.
This table is a summary, and it knows it. The matrix lives as data
in internal/mcpserver/revisions.go and is served by
GET /v1/protocol; protocol.md §1 defers to that
endpoint for the same reason this page does — a matrix in a document
and a matrix in code agree on the day they are written and never
again. A generated rendering of the matrix on this page is part of
the docs-generation work tracked alongside this page set; until it
lands, the endpoint and §1 are the sources, named here so a reader
is never left trusting a copy.
Accept liberally, send conservatively
mcpx accepts any request a client’s revision allows it to make
sense of — subscriptions/listen and core tasks/* are answered
for legacy clients that predate them — and sends nothing the
client’s revision never defined: every result is built in the
newest shape and spelled down once, at the edge, so an older client
is never handed a field it was never told about. The rule stops at
removal: a method a client’s revision removed is answered
-32601, because the removal is the specification naming a
replacement, and answering it anyway would hide which era the
server is speaking.
Known gaps
The gaps are written down in protocol.md §6 rather
than copied here, so they cannot drift from the fixes: no OAuth on
/mcp, tool inputs and outputs not yet validated against their
JSON Schemas, a tool with taskSupport: "required" that cannot
be called, and the smaller conformance items listed there. A gap
named in one place is a gap somebody can close.
Host compatibility
No MCP host has a recorded compatibility result yet, so this page claims none. Any host that speaks one of the five revisions over stdio or Streamable HTTP is expected to work — that is what the conformance work in protocol.md holds mcpx to — but “expected” is not “tested against your host”, and a host will be named here only when somebody has actually pointed it at mcpx and written down what happened.
Now what
You installed mcpx and ran the loop. Each link below is a question, and every one of them is answered one level down — the level-2 pages mapped in the level-2 page map.
- Where does config go? One
mcpServersobject, a search path that runs from the working directory up to/etc/mcpx/config.json, and settings that can come from a file, the environment or a flag: configuration, and the map’sconfigurationpage (common, server and client config, env vars). - How do I call mcpx from my harness, over MCP, over HTTP? The CLI
is one surface of four — the daemon also speaks MCP (
/mcp,mcpx serve) and plain HTTP (/v1/*, OpenAPI at/v1/openapi.json): interfaces, cli, and the map’smcpandhttp-apipages. - How do scripts actually work? The script contract, named scripts, runtimes and what a script may touch: scripts, exec, runtimes.
- How does the opencode plugin work? It finds the daemon so your harness does not have to ship a binary: plugins and the opencode plugin.
- Something is wrong.
mcpx doctorfirst (it says what to do about each failure),mcpx statusfor the daemon and its servers, and the skeptical questions, answered: FAQ. - Everything, in depth. The long version of all of it — the narrative the overview was distilled from, and the book it feeds: the story and the compendium.
The opencode v2 plugin — verified API facts and implementation map
Companion to opencode-plugin.md, which sketches the v1
design and the v1→v2 mapping. That document’s v2 claims came from a reading of
opencode’s v2 branch; this one re-checks every fact the rewrite depends on
against the pinned v2 source (reported version 2.0.3) and records where
the pin differs. Work item: #1858,
queue task 3068.
created: 2026-10-29T23:05:00-05:00
status: verified facts + implementation map (core rewrite landed in PR #2489: first slice + the tools.ts slice)
scope: CORE rewrite only (#1858 SET-REMAINDER is out of scope)
Verified against the local opencode fork checkout (~/git/opencode, main at
c9edb37fda), paths below are inside packages/.
1. Loading: {id, setup} is the whole contract
core/src/plugin/module.ts:107-134 — the module schema accepts exactly two
default-export shapes:
{ id: string, setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void }
{ id: string, effect: (context: Context) => Effect<void> }
Only default is read (Module = Schema.Struct({ default: ... })). Failure to
match aborts the module with “Plugin must export a default definition with an
id and an effect or setup function.” Consequences for the port:
- the
plugin/opencode/mcpx/subdirectory workaround is unnecessary in v2, but the v1 tree keeps its layout until v2 is what runs; - helpers can be plain sibling modules in the v2 tree, and only the default
export is inspected — so
export const TUNING,export class DaemonClientetc. are safe to import from a shared file; - the v2 loader accepts a plugin directory as well as a file, so the v2
tree can be one directory whose
index.tsholds the default export.
2. The context surface the port actually uses
promise/plugin.ts:26-73 (the setup variant of Context):
| port need | v2 surface |
|---|---|
| daemon choice remembered across sessions | ctx.storage.get/set/remove/scan (Promise, schema.Json values) |
session id (the thing shell.env used to carry) | ctx.session.hook(...) inputs carry sessionID; ctx.session.context({sessionID}) reads one |
| system-prompt line | ctx.session.hook("context", input => …) mutating input.system |
| opt-in tools | ctx.tool.transform(editor => …), editor.namespace(...) + editor.add(...) |
| tool timing | ctx.tool.hook("execute.after", …) — a union on status |
| a line to the user | ctx.tui? no — server plugins get the SDK client.tui.* only (unchanged from v1) |
| headless detection | unchanged problem; no isMainThread trick in v2’s two-process model |
ctx.generate (one-shot completion), ctx.mcp.transform, ctx.permission.hook("evaluate")
and the TUI select() picker are the SET-REMAINDER — deliberately not used
by this core.
2.1 execute.after really is a status union
promise/tool.ts:41-70: the hook input is
{ tool, sessionID, agent, messageID, id, input } intersected with either
{ status: "completed", result } or { status: "error", … }. So the timing
record gets a real failure signal; the v1 tree only ever saw success.
2.2 session.hook("context") is the system-prompt path
promise/session.ts:25-37 — SessionRequest is
{ sessionID, model, system: Array<SystemPart>, messages, options }, and
SessionContext adds { agent, tools }. Pushing a part onto input.system is
the direct replacement for experimental.chat.system.transform; no per-command
hook is involved. Note the hook fires per turn, so the injected paragraph must
be idempotent (append once, not accumulate).
3. The session environment — two corrections to the mapping table
The mapping table on #1858 says
PUT /api/experimental/session/:id/environment. Against the pin:
- The path is
/api/session/:sessionID/environment—protocol/src/groups/session.ts:879-891. Payload is{ variables: Record<string, string> }, success is204. - It replaces, it does not merge.
core/src/session/environment.ts:18-31keeps aMap<SessionID, Variables>andset()overwrites the entry;core/src/shell.ts:262then readsenvironments.get(sessionID) ?? process.env.
That second point is load-bearing: a plugin that sends only MCPX_* variables
would hand every shell command in that session an environment without PATH,
HOME, or anything else. The v2 adapter must send the full environment it
wants the session to have — { ...process.env, ...MCPX_* }, stringified — or
must not set one at all. Verified by reading the consumer, not the doc comment.
The plugin reaches this route either through
ctx.serverUrl ?? http://… + fetch, or (preferred, when the SDK exposes it)
through the session client. The promised-domains list (promise/session.ts:153-172)
does not include an environment method, so the raw HTTP call is the route the
core rewrite should plan for, with a ctx.serverUrl === undefined fallback
(embedded hosts: skip, degrade to “unknown session”, exactly as v1 degrades).
4. ctx.storage replaces the remembered-daemon file
promise/storage.ts — get(key) => Promise<Json | undefined>,
set(key, value), remove(key), scan({ prefix }). Plugins are namespaced per
plugin id by the host, so the v1 rememberPath() / readRemembered() /
writeRemembered() trio (and the TUNING.rememberFile knob) map onto one key
per daemon target. until-gone lifetime is preserved by remembering
{ target, socket, seenAt } and dropping it when the health check fails — the
same rule as today, with the file gone.
5. What the port does not need to rewrite
plugin/opencode/mcpx/daemon.ts is already harness-free: discover(),
DaemonClient, rank(), summarise(), readInfoFiles(), stateDir() and the
health checks take an env map and a directory and speak /v1 over the unix
socket with Bun’s fetch(url, { unix }). v2 keeps Bun and keeps the socket, so
the v2 tree imports this file unchanged. Only the rung-4 spawn (runBin,
askCLI) is harness-specific: node:child_process still works in v2’s server
process, but the core rewrite should make rung 4 optional (off unless
MCPX_PLUGIN_BIN names a binary) rather than assume it.
6. Implementation map (first slice)
Target tree, beside the v1 files, all under pkgs/mcpx/plugin/opencode/:
| file | contents | acceptance it satisfies |
|---|---|---|
index.ts | export default { id: "mcpx", setup } | loads via {id, setup} |
options.ts | the v1 Options type + pluginSettings()-declared variable names, ported verbatim | settings guard |
session-env.ts | first-sighting → PUT /api/session/:id/environment with the full env | once per session, no per-command shell.env |
context.ts | session.hook("context") appending one idempotent paragraph | system line |
tools.ts | tool.transform with namespace + discover/exec/observe/daemon_* | namespaced registration |
timing.ts | tool.hook("execute.after") recording both statuses | failures recorded |
remember.ts | ctx.storage remembered daemon | no state file |
daemon.ts | re-export of ../mcpx/daemon.ts | core reuse |
Tests follow the existing bun test style in plugin/opencode/mcpx/*.test.ts
(daemon.test.ts, ops.test.ts, ceiling.test.ts), stubbing the context
object: hooks, storage map, and a fake fetch for the environment route.
7. Open questions for the implementer
- Namespace spelling — RESOLVED by the tools.ts slice, against the pin:
editor.namespace({ name, description })takes an object, andpackages/core/src/tool/runtime.ts(effectiveName) flattens a namespaced tool to<namespace>_<name>in the model’s tool list. Registering nameexecunder namespacemcpxtherefore yieldsmcpx_exec— the v1 hand-named tools survive unchanged, and themcp.transformremainder work inherits the same flattening rule. - Environment route reachability — with
ctx.serverUrlundefined (embedded SDK host) there is no HTTP route; decide degrade quietly vs. first-tick retry when a later hook reveals a URL. contexthook idempotency — whether the host hands the samesystemarray across turns or a fresh one per request decides between “append once, guarded” and “append each turn”.- Settings guard scope —
TestThePluginReadsNoUndeclaredSettingscansplugin/**/*.ts(x); the v2 tree must therefore read only the names ininternal/settings/plugin.go, includingplugin.discoveryRetry,plugin.rememberandplugin.annotate.
The opencode plugin
What it is, why each part exists, what is opencode-specific, and what happens to all of it in opencode v2.
id: the-opencode-plugin
since: pre-release
created: 2026-09-29T17:00:00-05:00
last-updated: 2026-09-29T17:00:00-05:00
status: proposed
note: findings + design sketch
This document has three jobs. It records what was found comparing opencode v1 and v2 (§1–2), sketches the v1 design that follows from it (§3), and separates the parts that are about opencode from the parts that are about connecting any agent harness to mcpx (§5), because the same plugin is wanted for codex, claude, pi and manus.
1. The two opencodes
They are not two products. They are two revisions of one monorepo.
| “v1” | “v2” | |
|---|---|---|
| flake input | opencode, rev e03db9b | opencode2, rev d0a9028 |
| version | 1.18.31 (packages/opencode/package.json:3) | 2.0.3 (packages/cli/package.json:4) |
| shipped app | packages/opencode | packages/cli, renamed opencode2 |
The v1 pin already contains the whole v2 codebase — packages/core,
packages/cli, packages/tui are all there at 1.18.31. The v2 pin has
deleted packages/opencode. So the difference is which binary is built,
not which source exists.
Three corrections to OPENCODE-V2.md, which predates this:
- Code mode is not v2-only. v1 ships
packages/codemodeandpackages/opencode/src/tool/code-mode.ts, behindOPENCODE_EXPERIMENTAL_CODE_MODE(src/effect/runtime-flags.ts:48). With it on, MCP tools leave the model’s tool list exactly as in v2. - Both accept TypeScript in code mode; neither rejects it.
- The pinned v2 reports
2.0.3, not2.0.18.
And one thing that matters for anyone writing a plugin today: v1.18.31 runs
both plugin systems at once. PluginV2.node is in the location service
group (packages/core/src/location-services.ts:52), which the v1 app depends
on. A well-formed v2 plugin file dropped in .opencode/plugin/ is loaded by
the v2 host and also rejected by the v1 loader, which logs a complaint and
carries on. Noise, not breakage.
2. What changes for a plugin
2.1 Loading
| v1 | v2 | |
|---|---|---|
| glob | {plugin,plugins}/*.{ts,js}, not recursive | plugin/plugins dirs, files or directories |
| accepted shapes | three: structured {id, server}, legacy “every export is a plugin”, and v2 {id, effect|setup} | one: {id, setup} or {id, effect} |
| v1 compatibility | — | none. No shim, no fallback |
The legacy loader is why mcpx/daemon.ts sits in a subdirectory: v1 calls
every export of every file in the glob as a plugin, and one export that is
not a function aborts the whole module. The glob not being recursive is what
makes the subdirectory work.
In v2 that workaround becomes unnecessary — only default is read — but the
plugin itself must be rewritten, because v1 plugins do not load in v2 at
all.
2.2 The four hooks mcpx uses
| mcpx hook | v1 | v2 |
|---|---|---|
shell.env | ✅ carries cwd, sessionID, callID | ⚠️ shell.create.before carries command, cwd, timeout, shell, env — no sessionID |
experimental.chat.system.transform | ✅ | ✅ session.hook("context"), push onto event.system |
tool: {...} map | ✅ un-namespaced | ✅ tool.transform(e => e.add(...)), namespaced and typed |
tool.execute.after | ✅ success only | ✅ a union on status — failures are visible too |
shell.env is the load-bearing one: it is how a session id reaches mcpx
without the agent ever seeing it. Its v2 replacement drops exactly the field
that makes it work.
The v2 answer is better than a workaround, though. v2 has
PUT /api/experimental/session/:id/environment
(packages/protocol/src/groups/session.ts:786-795), which sets a session’s
shell environment once per session rather than once per command. That is
the API v2 added for this, and it turns a per-command hook into a
per-session call.
2.3 $ is gone
v1 hands plugins Bun’s shell (packages/plugin/src/index.ts:56-66). In v2
packages/plugin/src/effect/shell.ts is seventeen lines of interface and no
executor — verified directly against the pinned source.
import("node:child_process") still works, since plugins are import()ed
into the server process in both versions and nothing sandboxes them. But the
runtime no longer offers a way to run a command, and that is a clear signal
about direction.
mcpx’s plugin uses $ for exactly two things: mcpx --json status to find
the daemon, and mcpx log record as the fallback when it cannot. Both must
stop being required. That is what §3 is about, and v2 turns it from a
performance argument into a portability requirement.
2.4 What a plugin can do to the user
This is the part that surprised me most: the server-side plugin API is
almost UI-less in both versions. What a server plugin has is the SDK’s
client.tui.*, identical in v1 and v2:
showToast | {title?, message, variant: info|success|warning|error, duration?} |
appendPrompt, submitPrompt, clearPrompt | edit the composer |
executeCommand | run a command by name |
openHelp, openSessions, openThemes, openModels | fixed dialogs |
There is no generic dialog and no select over HTTP, in either version.
Real UI lives in a separate TUI plugin, loaded in a different realm, with its own API:
v1 TuiPluginApi | v2 TUI Context | |
|---|---|---|
| ask a question | DialogPrompt, DialogSelect<V> | prompt(), select<V>() — promises |
| toasts | ui.toast(...) | ui.toast.show(...) |
| slash commands | TuiCommand.slash (deprecated, use keymap layers) | KeymapCommand.slash + palette: true |
| persistent state | kv.get/set | storage.store(key, {initial}), disk-backed, live-synced |
| slots | 12 host slots | 10 slots with prepend/append/before/after/replace |
| panels, tabs, routes | routes only | panels, tabs, router |
So a guided “which daemon?” flow that selects rather than types needs a TUI plugin, in both versions. A toast and a suggestion can come from the server plugin alone.
The other route to a question is a tool with a permission prompt: a
plugin tool calls ctx.ask({permission, patterns, always, metadata}), and
the user sees an allow/deny. That is a yes/no channel available from the
server plugin, without a TUI plugin — and always gives “remember this”,
which is most of what a daemon choice needs.
⚠️ v1’s permission.ask hook is dead code: declared
(packages/plugin/src/index.ts:261), documented, and never once invoked.
Implementing it is a silent no-op. v2’s permission.hook("evaluate") is
real.
2.5 Calling a model
| v1 | v2 | |
|---|---|---|
| full agent turn | ✅ client.session.prompt / promptAsync / command | ✅ same |
| one-shot completion | ❌ no such route | ✅ ctx.generate.text({prompt, model?}) → {text} |
| in-session generation | ❌ | ✅ ctx.session.generate(...) |
| replace the model object | via the v2-style aisdk hooks present in the v1 tree | ✅ aisdk.hook("language") |
This answers a question from the sampling design directly: in v2 the plugin
can answer an MCP sampling/createMessage cheaply, with
ctx.generate.text. In v1 the only way is a full agent turn, which is far
too heavy for what sampling is.
2.6 opencode’s own MCP client
Relevant because mcpx is an MCP server to opencode:
| v1 | v2 | |
|---|---|---|
| SDK | @modelcontextprotocol/sdk@1.29.0 + a 647-line downstream patch | @modelcontextprotocol/client@2.0.0, one hunk |
| declared client capabilities | roots only (sampling, elicitation, tasks are commented out with issue links) | roots + elicitation (form and url) |
sampling/createMessage | ❌ | ❌ |
| protocol negotiation | ❌ fixed to the SDK’s latest | ✅ legacy | auto | 2026-07-28 per server |
| transports | stdio, Streamable HTTP, SSE | Streamable HTTP, hand-written stdio. No SSE |
| server log forwarding | ✅ | ❌ regression |
prompts/resources list_changed | ❌ | ✅ |
| OAuth | PKCE + DCR, shared callback port | + CIMD, RFC 8707 resource binding, ephemeral callback |
| execution timeout | one value, 30 s | three tiers; execution 12 h |
Consequences for mcpx:
- Neither version will ever send mcpx a sampling request, because neither declares the capability. mcpx’s sampling passthrough is for other clients.
- v2 can answer mcpx’s elicitations; v1 cannot, and will get
cancel. Until v2, elicitation has to reach the user another way — which is what the broker,mcpx elicitand/v1/elicitare for. - v2 negotiating
2026-07-28per server means mcpx’s modern-era serving path will actually be exercised. - v2’s 12-hour execution timeout makes long
execruns viable without tasks.
2.7 Process model
| v1 | v2 | |
|---|---|---|
| shape | one process, two Bun realms (worker = server, main = TUI) | two processes, HTTP/RPC between |
| default network | none — the TUI talks to the server over an in-process fetch; a port opens only with --port | HTTP |
| plugin isolation | none; import() into the realm | none; import() into the process |
“Default network: none” is why the plugin must not assume it can reach
anything over TCP, and why the unix socket matters. Bun’s
fetch(url, {unix}) works in both, since plugins run under Bun in the server
realm either way.
3. Design: finding the daemon without a binary
See the tracking issue for the full problem statement. The short version:
today the plugin runs mcpx --json status once per session, and if there is
no mcpx on PATH, it has nothing. In v2 it will not even have $.
3.1 The ladder
Each rung answers “which daemon?” and stops when it has an answer.
| # | rung | cost | when it applies |
|---|---|---|---|
| 0 | explicit: MCPX_DAEMON_ENDPOINT, MCPX_SOCKET, or plugin options | free | configured, or remote |
| 1 | cached choice for this directory, still alive | one health check | every session after the first |
| 2 | read the daemon info files | one readdir + one health check each | no binary needed |
| 3 | ask a live daemon to resolve the directory | one request | two or more candidates |
| 4 | mcpx --json status in the session directory | one spawn, ~23 ms | a binary exists and rungs 2–3 were ambiguous |
| 5 | ask the user | a toast, then a choice | still ambiguous |
| 6 | pick the best candidate, warn, keep working | free | headless, or the user did not answer |
Rung 6 is the rule the user set: never fail because there are two.
3.2 Rung 2: the info files already exist
The daemon writes daemon-<hash>.json into its state directory with pid,
socket, endpoint, configPath, configHash, version and startedAt
(daemon.Paths.WriteInfo). Everything discovery needs is already on disk.
Reading them needs node:fs, which both versions have, and no binary at all.
Candidates are then filtered to those actually listening — a GET /v1/health
over each socket, in parallel, a few milliseconds total. A pid file whose
daemon is gone is not a candidate.
This rung alone resolves the single-daemon case, which is almost every case,
with no mcpx on PATH.
3.3 Rung 3: any daemon can answer for all of them
A daemon already knows how mcpx resolves configuration for a directory. So
one live daemon can answer “which socket serves /path/to/project?” for
any directory, including ones it does not serve itself:
GET /v1/resolve?dir=/path/to/project → { socket, configPath, configHash, running }
That turns an ambiguous scan into a definite answer without a binary, using a daemon that is already running. It is a small addition to the daemon API and makes rung 4 a rarity.
Without it, the cheap approximation is to walk up from the session directory
to the nearest .mcpx.json and compare it against each info file’s
configPath. That is right most of the time and wrong exactly when
configuration is inherited from a parent — which is the case the fingerprint
exists to handle.
3.4 Rung 5: asking, in ascending order of capability
a. Toast + suggestion (server plugin, v1 and v2). On boot and on the first mcpx tool use:
mcpx: 2 daemons match this directory. Using
nix(started 3 h ago, 4 servers)./mcpx-daemonto change.
Rate-limited to once per session per reason, so it cannot become noise.
b. A tool with a permission prompt (server plugin, v1 and v2). An
mcpx_daemon_select tool whose execution asks ctx.ask(...). The user
sees an allow/deny with the daemon’s details and an “always” option. This is
the only interactive channel a v1 server plugin has, and “always” is
exactly “remember this choice”.
c. A real picker (TUI plugin). v1 DialogSelect, v2 select(). Shows
each candidate’s start time, config path, server count and live connections,
best first. After choosing, a second question offers: this session only /
until it goes away / permanently in config / and optionally stop the others.
d. The guided-skill variant. Instead of a coded walkthrough, a skill
tells the agent to call mcpx_daemon_status, reason about which daemon
fits, and then call mcpx_daemon_select with that candidate’s index, which is
what actually sets the socket. The agent proposes; the tool decides. This is worth doing because
it degrades correctly: the agent cannot set the wrong socket by typing a path
wrong, because it does not type the path.
⚠️ The gating idea — block every mcpx tool until a daemon is chosen — is a trap. It converts “we guessed” into “nothing works”, which is the failure mode the user ruled out. Better: proceed with the best candidate, and let every affected tool result carry one line saying which daemon answered and how to change it.
3.5 Remembering
| scope | where | lifetime |
|---|---|---|
| this session | plugin memory | the session |
| until it goes away | v2 ctx.storage; v1 needs a file | until that socket stops answering |
| permanent | daemon.endpoint in the user’s mcpx config | until changed |
v1 server plugins have no storage API, so “until it goes away” means a
small JSON file under the mcpx state directory. v2 has ctx.storage,
namespaced per plugin. This is a real v1-workaround-fixed-in-v2.
3.6 Headless
opencode run, CI, a GitHub Action: nobody can answer. There, rung 5 is
skipped entirely and rung 6 applies — best candidate, one warning line into
the log, carry on. The plugin can tell it is headless by whether a TUI client
is attached.
3.7 Also needed for “no binary”
Discovery is only half. The plugin’s other $ use is mcpx log record, and
its opt-in tools (mcpx_discover, mcpx_exec, mcpx_observe) each spawn
mcpx. Those must move onto the daemon API:
| plugin feature | today | needs |
|---|---|---|
| tool timing | POST /v1/log ✅, no fallback | nothing; with no daemon the record is dropped |
mcpx_discover | mcpx types / mcpx ls | GET /v1/types, GET /v1/namespaces ✅ (exist) |
mcpx_exec | mcpx exec <src> | POST /v1/exec ✅ (exists) |
mcpx_observe | mcpx log / mcpx stats | GET /v1/log, GET /v1/stats ✅ (exist) |
exec over /v1 was the one real gap, and it has since landed as
POST /v1/exec; the streaming-output question it raised is answered by the
frames exec.output selects (docs/exec.md).
4. What v2 changes about all of this
Things that become unnecessary:
- the
mcpx/subdirectory (onlydefaultis read) - hand-namespacing tool names (
tool.transformnamespaces them) - a state file for the remembered daemon (
ctx.storage) - the
shell.envper-command hook (session environment API, once per session)
Things that become possible:
- answering MCP sampling with
ctx.generate.text— cheap, no agent turn - registering MCP servers per session (
mcp.transform), so mcpx could hand opencode a filtered server set rather than a hand-edited config - putting a budgeted catalog into
session.hook("context")→event.toolsinstead of appending to the system prompt, with diffs between turns - seeing tool failures in
execute.after
Things that break:
- the plugin itself: v1 plugins do not load, so v2 needs a rewrite, not a port
$sessionIDin the shell hook- MCP SSE transport, and MCP server log forwarding (both regressions in v2)
Things that are the same:
client.tui.showToastand the rest of the TUI SDK surface- no isolation; plugins run in the host’s realm
- Bun’s
fetch(url, {unix}), so the socket fast path survives - one MCP connection per server per location — the chrome-sharing problem
OPENCODE-V2.mddescribes is unchanged
5. What is opencode, and what is general
For porting to codex, claude, pi, manus, this is the part that matters.
| concern | general | opencode-specific |
|---|---|---|
| find the daemon | the whole ladder in §3. Info files, health checks, a resolve endpoint, a cached choice | where config lives; how “the current directory” is known |
| inject a session id | that mcpx wants one, to attribute calls and route elicitations | shell.env (v1) / session environment API (v2). Every harness has a different hook, or none |
| tell the model mcpx exists | a short, budgeted description; never the tool schemas | experimental.chat.system.transform (v1) / session.hook("context") (v2) |
| offer tools | discover, exec, observe as a minimal set | tool() + the map (v1) / tool.transform (v2) |
| record timings | POST /v1/log over the socket | which hook fires after a tool call, and what it carries |
| ask the user | that a question must have a default and a deadline | toast vs permission prompt vs TUI dialog |
| remember a choice | session / until-gone / permanent | ctx.storage vs a file |
| answer sampling | a harness with a model should answer; one without should decline | ctx.generate.text (v2 only) |
The shape that ports: a small core that speaks the daemon API over a unix
socket, plus a thin adapter per harness. The core is
plugin/opencode/mcpx/daemon.ts today, and it is already almost harness-free
— it takes $ only for the rung-4 spawn, which §3 removes. Lifting it into a
harness-free core directory beside opencode once a second harness exists
would make the split explicit.
What each harness needs to provide its adapter:
- a directory (to resolve which daemon)
- a session identifier (to attribute and route)
- somewhere to put environment variables, or a way to wrap tool calls
- a hook after a tool call, if timings are wanted
- a way to show a line of text, if ambiguity is to be reported
- optionally, a way to ask a question and a way to run a model
Only (1) and (2) are required. Everything else degrades.
6. Per-part reference
shell.env → session id injection
- What. Sets
MCPX_SESSION_ID,MCPX_CALL_IDand friends in the environment of every shell command opencode runs; the full list, with who reads each, is generated in environment.md. - Why. mcpx needs a session id to attribute calls, lease stateful servers and route elicitations. Passing it through the model’s context would spend tokens on something the model neither chooses nor should see.
- How. v1: the
shell.envhook, per command, carryingsessionIDandcallID. v2: onePUT /api/experimental/session/:id/environmentper session. - When it degrades. No hook, no ids: mcpx attributes calls to “unknown” and leases fall back to whole-daemon scope.
- General or opencode? The need is general. The mechanism is entirely opencode’s.
The system-prompt line
- What. A short paragraph telling the model mcpx exists and when to reach for it.
- Why. The whole point of mcpx is that tool schemas stay out of context. The model still has to know the door is there.
- How. v1
experimental.chat.system.transform; v2session.hook("context"). - When it degrades. Without it, the opt-in tools still work when named.
The opt-in tools
- What.
mcpx_discover,mcpx_exec,mcpx_observe, off by default. - Why. An agent that can write a script against the catalog beats one that can only call tools one at a time. Off by default because each adds a tool to every request’s tool list, which is exactly the cost mcpx exists to avoid.
- How. v1 the
toolmap (watch for name collisions — it is not namespaced); v2tool.transformwith a namespace.
The timing hook
- What. One log record per opencode tool call, into mcpx’s log.
- Why. One
mcpx statsthen covers the harness and mcpx on one timeline. - How.
POST /v1/logover the socket, 0.09 ms. No spawn fallback. - When it degrades. No daemon: the record is dropped
(
plugin/opencode/mcpx-session.ts:783). Dropping is correct — a timing is not worth 23 ms on every tool call, and certainly not worth failing one.
The socket client
- What.
plugin/opencode/mcpx/daemon.ts: find a daemon, then speak/v1to it. - Why. 0.17 ms against 23 ms for a spawn, on a path that runs for every tool call. And in v2 there is no spawn available at all.
- How. Bun’s
fetch(url, {unix}). Node’s fetch cannot do this without undici, so a Node harness uses the TCP endpoint instead. - General or opencode? Almost entirely general. This is the part to extract when a second harness appears.
Overview
mcpx puts every MCP server you have behind one command, so an agent calls tools from the shell — and their schemas stay out of its context until it asks for them.
Why install it
1. Code mode: tools become shareable, reusable scripts
Instead of one-off tool calls inside a chat, the agent writes a TypeScript script that calls tools as ordinary async functions. The script is a file: it can be saved, reviewed, shared and re-run.
mcpx run shot-flow <url> <outDir>
That is .mcpx/scripts/shot-flow.ts — screenshot a page, click a link,
wait for the load, screenshot again (the full walk is in
the story). Written once by an agent that had never heard
of mcpx; reusable by anyone, forever, as mcpx run shot-flow.
More: scripts and the level-2 map’s script page (level-2 page map).
2. Reclaim context: smarter, cheaper
Tool schemas are the tax. With seven namespaces and 43 tools installed, discovery costs almost nothing:
$ mcpx ls
Seven namespaces, 43 tools, 171 tokens
The agent loads signatures only for the one namespace it is about to
use (mcpx types <namespace>), so the whole screenshot task above ran
in about 4,600 tokens total — schemas for 27 browser tools included
only when needed, never by default.
More: the story for the token evidence.
3. Tame MCP: one config, every surface, everything corralled
-
One config. mcpx reads the same
mcpServersobject every other MCP host reads. There is no second configuration to keep in sync. -
Share it with your tailnet. Run the daemon on one machine and let the others talk to it:
mcpx daemon --address <tailnet-ip> --port 8899 MCPX_DAEMON_ENDPOINT=http://<host>:8899 mcpx lsPlainly: the daemon’s API is unauthenticated — whatever can route to the port can run tools as you. The network is the access control; that is why the example is a tailnet address, not the open internet.
-
Corral scripts, APIs and binaries. Named scripts live in
.mcpx/scripts/; an OpenAPI service becomes tools withmcpx api tools --spec <url>(served over MCP today); a plain command-line program becomes tools through an adapter (mcpx adapter checksays whether each one is installed), and adapters are served by the daemon like any other server.
More: configuration.
What it looks like
flowchart TB
subgraph clients["You and your agents"]
CLI["CLI — mcpx ls | search | types | run | exec"]
PLUGIN["opencode plugin — no mcpx binary needed"]
MCP["MCP host — /mcp on the daemon, or mcpx serve"]
HTTP["HTTP client — /v1/* (OpenAPI at /v1/openapi.json)"]
end
subgraph daemon["mcpx daemon — one per config, autostarts, idle-exits"]
POOL["pooled MCP server processes (stdio) and remote servers (HTTP)"]
EXTRA["OpenAPI specs and CLI adapters, as tools"]
RUNNER["script runner: deno | bun | node subprocess"]
end
CLI --> daemon
PLUGIN --> daemon
MCP --> daemon
HTTP --> daemon
daemon --> POOL
daemon --> EXTRA
daemon --> RUNNER
RUNNER -. "calls back into the daemon" .-> daemon
What is running on your machine, in one breath: clients (the CLI, the plugin, an MCP host, plain HTTP) talk over a unix socket or a URL to one daemon per config. The daemon owns the pooled MCP server processes, serves OpenAPI specs and CLI adapters as tools, and runs your scripts on a real runtime (deno, bun or node) whose tool calls come back into the daemon. Nothing else is installed and nothing runs until a command needs it: the daemon autostarts on demand and an auto-started one exits after 4 hours idle.
Next
- Install mcpx
- Quick start — the loop in under one screen
- Now what — where to go after the quick start
Parity
Everything the CLI can do can be done from a plugin, can be done over
/v1, can be done from MCP.
That is a claim, and this file is generated from the four declarations it is about, so it cannot describe a surface that does not exist or leave one out. Each direction is also held by a test:
| Direction | Held by |
|---|---|
every /v1 route has an operation, and every operation a route | TestEveryDaemonRouteIsDeclared, TestEveryDeclaredOpHasARoute |
| every operation that is not a stream has an MCP tool | TestEveryNonStreamingOpIsReachableAsAnMCPTool |
| every operation has a CLI command, and the generated ones reach their route | TestEveryOperationIsReachableFromTheCLI, TestEveryGeneratedCommandReachesItsRoute |
| every command the binary runs is declared, and every declared one runs | TestEveryDispatchedCommandIsDeclared |
| every command reaches an operation, or says why it cannot | TestEveryCommandReachesAnOperationOrSaysWhy |
| every operation has a plugin method, and they work against a real daemon | TestPluginOpsAreGenerated, TestThePluginSuiteRunsAgainstARealDaemon |
| this file | TestParityDocumentIsCurrent |
A command marked generated is built from the operation’s declaration –
flags from its parameters, positionals for its path – so it exists the
moment the operation does. The plugin reaches every operation through
DaemonClient.ops (plugin/opencode/mcpx/ops.gen.ts, generated the same way);
the named methods beside it are conveniences with typed answers.
Privileged operations are offered rather than hidden. A caller that can reach
the socket can already stop the daemon, so withholding the tool buys no safety
and costs an agent the ability to restart a server that has wedged. What they
carry instead is a description that says so and MCP annotations
(readOnlyHint, destructiveHint) for a client that wants to scope on them.
/v1 operations (83)
| Operation | Route | CLI | MCP tool | Plugin | Notes |
|---|---|---|---|---|---|
adapters_add | POST /v1/adapters | mcpx adapter add | mcpx_adapters_add | ops.adaptersAdd() | Privileged. |
adapters_remove | DELETE /v1/adapters/{name} | mcpx adapter remove | mcpx_adapters_remove | ops.adaptersRemove() | Privileged. Destructive. |
artifacts_list | GET /v1/artifacts | mcpx artifact list generated | mcpx_artifacts_list | ops.artifactsList() | |
artifact_put | POST /v1/artifacts | mcpx artifact put generated | mcpx_artifact_put | ops.artifactPut() | Privileged. |
artifact_delete | DELETE /v1/artifacts/{id} | mcpx artifact delete generated | mcpx_artifact_delete | ops.artifactDelete() | Privileged. Destructive. |
artifact_get | GET /v1/artifacts/{id} | mcpx artifact get generated | mcpx_artifact_get | ops.artifactGet() | |
ask_begin | POST /v1/ask | mcpx ask begin generated | mcpx_ask_begin | ops.askBegin() | |
ask_poll | GET /v1/ask/{id} | mcpx ask poll generated | mcpx_ask_poll | ops.askPoll() | |
ask_abandon | POST /v1/ask/{id}/abandon | mcpx ask abandon generated | mcpx_ask_abandon | ops.askAbandon() | Privileged. Destructive. |
ask_answers | POST /v1/ask/{id}/answers | mcpx ask answers generated | mcpx_ask_answers | ops.askAnswers() | Privileged. |
batch | POST /v1/batch | mcpx batch | mcpx_batch | ops.batch() | |
call | POST /v1/call | mcpx call | mcpx_call | ops.call(), call(), callAsTask() | |
call_path | POST /v1/call/{server}/{tool} | mcpx call | mcpx_call | ops.callPath() | |
catalog | GET /v1/catalog | mcpx catalog | mcpx_catalog | ops.catalog() | |
catalog_history | GET /v1/catalog/history | mcpx history generated | mcpx_catalog_history | ops.catalogHistory() | |
chat_completions | POST /v1/chat/completions | mcpx proxy | mcpx_chat_completions | ops.chatCompletions() | |
client_module | GET /v1/client.ts | mcpx client | mcpx_client_module | ops.clientModule() | |
complete | POST /v1/complete | mcpx complete generated | mcpx_complete | ops.complete(), complete() | |
daemon_restart | POST /v1/daemon/restart | mcpx restart | mcpx_daemon_restart | ops.daemonRestart() | Privileged. Destructive. |
dashboard | GET /v1/dashboard | mcpx dashboard | mcpx_dashboard | ops.dashboard() | |
diagnose | POST /v1/diagnose | mcpx diagnose | mcpx_diagnose | ops.diagnose() | |
elicit_list | GET /v1/elicit | mcpx elicit list | mcpx_elicit_list | ops.elicitList(), pendingElicitations() | |
elicit_get | GET /v1/elicit/{id} | mcpx elicit show | mcpx_elicit_get | ops.elicitGet() | |
elicit_answer | POST /v1/elicit/{id}/{action} | mcpx elicit answer | mcpx_elicit_answer | ops.elicitAnswer(), answer() | Privileged. |
embeddings | POST /v1/embeddings | mcpx embeddings | mcpx_embeddings | ops.embeddings() | |
embeddings_export | GET /v1/embeddings/export | mcpx embeddings export | mcpx_embeddings_export | ops.embeddingsExport() | |
embeddings_import | POST /v1/embeddings/import | mcpx embeddings import | mcpx_embeddings_import | ops.embeddingsImport() | |
events | GET /v1/events | mcpx events generated | none: a stream; MCP carries it as subscriptions/listen | ops.events() | Never ends; read it until you are done. |
exec | POST /v1/exec | mcpx exec | mcpx_exec | ops.exec(), exec() | Privileged. |
feedback | POST /v1/feedback | mcpx feedback | mcpx_feedback | ops.feedback() | |
globals | GET /v1/globals.d.ts | mcpx globals generated | mcpx_globals | ops.globals() | |
health | GET /v1/health | mcpx health generated | mcpx_health | ops.health(), health() | |
intent | POST /v1/intent | mcpx prompt | mcpx_intent | ops.intent() | |
interactions_list | GET /v1/interactions | mcpx interactions | mcpx_interactions_list | ops.interactionsList() | |
interactions_get | GET /v1/interactions/{id} | mcpx interactions show | mcpx_interactions_get | ops.interactionsGet() | |
log_query | GET /v1/log | mcpx log | mcpx_log_query | ops.logQuery(), logQuery() | |
log_record | POST /v1/log | mcpx log record | mcpx_log_record | ops.logRecord(), record() | Privileged. |
metrics_tokens | GET /v1/metrics/tokens | mcpx metrics tokens generated | mcpx_metrics_tokens | ops.metricsTokens() | |
metrics_tools | GET /v1/metrics/tools | mcpx metrics tools generated | mcpx_metrics_tools | ops.metricsTools() | |
namespaces | GET /v1/namespaces | mcpx ls | mcpx_namespaces | ops.namespaces(), namespaces() | |
openapi | GET /v1/openapi.json | mcpx openapi | mcpx_openapi | ops.openapi() | |
preflight | POST /v1/preflight | mcpx preflight generated | mcpx_preflight | ops.preflight() | |
prompt_get | POST /v1/prompt | mcpx prompts | mcpx_prompt_get | ops.promptGet() | |
prompts | GET /v1/prompts | mcpx prompts | mcpx_prompts | ops.prompts() | |
protocol | GET /v1/protocol | mcpx protocol generated | mcpx_protocol | ops.protocol() | |
recipes_list | GET /v1/recipes | mcpx recipes list | mcpx_recipes_list | ops.recipesList() | |
recipe_get | GET /v1/recipes/{name} | mcpx recipes show | mcpx_recipe_get | ops.recipeGet() | |
recipe_save | POST /v1/recipes/{name} | mcpx recipes save | mcpx_recipe_save | ops.recipeSave() | |
recipe_run | POST /v1/recipes/{name}/run | mcpx recipes run | mcpx_recipe_run | ops.recipeRun() | |
refresh | POST /v1/refresh | mcpx refresh | mcpx_refresh | ops.refresh() | Privileged. |
registry_search | GET /v1/registry/search | mcpx registry search | mcpx_registry_search | ops.registrySearch(), registrySearch() | |
reload | POST /v1/reload | mcpx reload | mcpx_reload | ops.reload() | Privileged. |
resolve | GET /v1/resolve | mcpx resolve generated | mcpx_resolve | ops.resolve(), resolve() | |
resource_read | POST /v1/resource | mcpx resources | mcpx_resource_read | ops.resourceRead() | |
resource_templates | GET /v1/resource-templates | mcpx resources --templates | mcpx_resource_templates | ops.resourceTemplates() | |
resources | GET /v1/resources | mcpx resources | mcpx_resources | ops.resources() | |
restart | POST /v1/restart | mcpx restart | mcpx_restart | ops.restart() | Privileged. Destructive. |
search | GET /v1/search | mcpx search | mcpx_search | ops.search() | |
secret_requests_list | GET /v1/secret-requests | mcpx secrets requests | mcpx_secret_requests_list | ops.secretRequestsList() | |
secret_request_decline | DELETE /v1/secret-requests/{id} | mcpx secrets decline | mcpx_secret_request_decline | ops.secretRequestDecline() | Privileged. |
secret_request_get | GET /v1/secret-requests/{id} | mcpx secrets request | mcpx_secret_request_get | ops.secretRequestGet() | |
secrets_list | GET /v1/secrets | mcpx secrets list | mcpx_secrets_list | ops.secretsList() | |
secrets_unset | DELETE /v1/secrets/{name} | mcpx secrets unset | mcpx_secrets_unset | ops.secretsUnset() | Privileged. Destructive. |
secrets_set | PUT /v1/secrets/{name} | mcpx secrets set | none: a value sent as a tool argument would sit in the model’s context and transcript; the CLI reads it from standard input and OpenChamber from a password field | ops.secretsSet() | Privileged. |
servers_list | GET /v1/servers | mcpx servers list | mcpx_servers_list | ops.serversList() | |
servers_add | POST /v1/servers | mcpx servers add | mcpx_servers_add | ops.serversAdd() | Privileged. |
servers_remove | DELETE /v1/servers/{name} | mcpx servers remove | mcpx_servers_remove | ops.serversRemove() | Privileged. Destructive. |
servers_get | GET /v1/servers/{name} | mcpx servers show | mcpx_servers_get | ops.serversGet() | |
session_release | POST /v1/session/release | mcpx session release generated | mcpx_session_release | ops.sessionRelease() | Privileged. |
settings_list | GET /v1/settings | mcpx settings list | mcpx_settings_list | ops.settingsList() | |
settings_unset | DELETE /v1/settings/{path} | mcpx settings unset | mcpx_settings_unset | ops.settingsUnset() | Privileged. |
settings_get | GET /v1/settings/{path} | mcpx settings get | mcpx_settings_get | ops.settingsGet() | |
settings_set | PUT /v1/settings/{path} | mcpx settings set | mcpx_settings_set | ops.settingsSet(), putSetting() | Privileged. |
shutdown | POST /v1/shutdown | mcpx stop | mcpx_shutdown | ops.shutdown(), shutdown() | Privileged. Destructive. |
stats_query | GET /v1/stats | mcpx stats | mcpx_stats_query | ops.statsQuery(), stats() | |
status | GET /v1/status | mcpx status | mcpx_status | ops.status(), status() | |
tasks_list | GET /v1/tasks | mcpx task list generated | mcpx_tasks_list | ops.tasksList(), tasks() | |
task_get | GET /v1/tasks/{id} | mcpx task get generated | mcpx_task_get | ops.taskGet(), task() | |
task_cancel | POST /v1/tasks/{id}/cancel | mcpx task cancel generated | mcpx_task_cancel | ops.taskCancel(), cancelTask() | |
task_result | GET /v1/tasks/{id}/result | mcpx task result generated | mcpx_task_result | ops.taskResult(), taskResult() | |
tools | GET /v1/tools | mcpx tools generated | mcpx_tools | ops.tools() | |
tool_invoke | POST /v1/tools/{tool} | mcpx tool invoke generated | mcpx_tool_invoke | ops.toolInvoke() | |
types | GET /v1/types | mcpx types | mcpx_types | ops.types(), types() |
CLI commands (69)
Every command the binary dispatches, with the operations it reaches. A command that reaches none says why: those are decisions, not gaps.
| Command | Operations | Notes |
|---|---|---|
adapter | adapters_add, adapters_remove | |
api | – | OpenAPI declarations are files the CLI reads; the tools they produce are served over MCP, and are not yet a tool source the daemon owns (#83). |
artifact / artifacts | artifacts_list, artifact_put, artifact_delete, artifact_get | Generated from the operation table. |
ask | ask_begin, ask_poll, ask_abandon, ask_answers | Generated from the operation table. |
batch | batch | |
cache | – | Reads and prunes the cache directory on this machine; no daemon is involved. |
call | call, call_path | |
catalog | catalog | |
client | client_module | |
complete | complete | Generated from the operation table. |
completion | – | Text about the binary, produced by the binary. |
config | – | Reads the configuration files on the caller’s search path; settings is the running daemon’s view. |
daemon | – | Starts a daemon, which a daemon’s own API cannot do. |
daemons | – | Reads every daemon’s info file on this machine; one daemon cannot see the others. Daemons with their own state directory mirror their record into the default one, and records whose process is gone are pruned. |
dashboard | dashboard | |
diagnose | diagnose | |
doctor | – | Checks this machine: binaries on PATH, permissions, configuration files. |
elicit | elicit_list, elicit_get, elicit_answer | |
embeddings | embeddings, embeddings_export, embeddings_import | |
events | events | Generated from the operation table. |
exec | exec | |
explore | – | An interactive interface over the reads above; it consumes the API rather than extending it. |
feedback | feedback | |
globals | globals | Generated from the operation table. |
health | health | Generated from the operation table. |
help | – | Text about the binary, produced by the binary. |
history | catalog_history | Generated from the operation table. |
hook | – | Reads the resolved hooks setting from the configuration chain on this machine. |
init | – | Writes a file into the current directory. |
interactions | interactions_list, interactions_get | |
log / logs | log_query, log_record | |
ls / list / namespaces | namespaces | |
man | – | Text about the binary, produced by the binary. |
metrics | metrics_tokens, metrics_tools | Generated from the operation table. |
openapi | openapi | |
plugin | – | Plugin discovery is local: the CLI and daemon read paths.plugins and PATH on this machine, and the daemon serves each plugin as a server of its own. |
preflight | preflight | Generated from the operation table. |
prompt | intent | |
prompts | prompt_get, prompts | |
protocol | protocol | Generated from the operation table. |
proxy | chat_completions | |
recipes | recipes_list, recipe_get, recipe_save, recipe_run | |
refresh | refresh | |
registry | registry_search | |
reload | reload | |
resolve | resolve | Generated from the operation table. |
resources | resource_read, resource_templates, resources | |
restart | daemon_restart, restart | |
run | – | Resolves a script name against the caller’s search path, which only the caller has; the source itself runs anywhere through exec. |
schema | – | Reshapes what tools and types return into JSON Schema, OpenAPI or MCP form; the data is those operations’, the conversion is local. |
scripts | – | Lists the caller’s script search path. |
search | search | |
secrets | secret_requests_list, secret_request_decline, secret_request_get, secrets_list, secrets_unset, secrets_set | |
serve | – | It is the MCP surface: a stdio shim over the daemon, which serves the same tools at /mcp itself. |
servers | servers_list, servers_add, servers_remove, servers_get | |
session | session_release | Generated from the operation table. |
settings | settings_list, settings_unset, settings_get, settings_set | |
skills | – | Manages embedded agent skills. |
stats | stats_query | |
status | status | |
stdio-proxy | – | It is a byte pipe: it dials the daemon’s unix socket on this host and copies stdin/stdout to it, which no /v1 operation can express. |
stop | shutdown | |
supervise | – | Runs in its own process as the re-exec target of the supervised transport; it serves no API. |
task / tasks | tasks_list, task_get, task_cancel, task_result | Generated from the operation table. |
tool | tool_invoke | Generated from the operation table. |
tools | tools | Generated from the operation table. |
tui | – | An interactive interface over the reads above; it consumes the API rather than extending it. |
types | types | |
upgrade | – | Replaces the running daemon with another binary, a decision for whoever runs the CLI rather than a tool a model can call. |
Hand-written MCP tools (12)
The tools mcpx offered before the operation table existed. Where one already covers an operation, no second tool is generated for it.
| Tool | Covers | Summary |
|---|---|---|
batch_call | – | Alias for mcpx_batch. |
mcpx_batch | batch | Execute one or more tools in a single turn. |
mcpx_call | call, call_path | Call one tool on one server. |
mcpx_catalog | catalog | Every namespace with as many tool signatures as fit a token budget. |
mcpx_exec | exec | Run TypeScript against every server at once. |
mcpx_log | – | Query mcpx’s durable log: what ran, what it cost, what failed. |
mcpx_namespaces | namespaces | List every MCP server mcpx knows about, with tool counts. |
mcpx_registry | – | Search a public registry of MCP servers that are not yet configured here. |
mcpx_search | search | Find tools by name and description across every server. |
mcpx_stats | – | Aggregate the log: calls, servers, errors, sessions, slowest, volume. |
mcpx_status | status | The daemon, its pools and live instances. |
mcpx_types | types | Full TypeScript signatures for named namespaces, including each server’s own guidance. |
Boundaries (5)
Every restriction mcpx declares, and whether anything enforces it
(decisions/0003). An enforced
one names the tests that try to get round it; an advisory one names the issue
that will enforce it. TestEveryEnforcedBoundaryNamesItsRefusal holds both.
| Restriction | Declares | State | Evidence |
|---|---|---|---|
transport.allowedOrigins | a web page on an origin that is not loopback, the daemon’s own address or listed in transport.allowedOrigins cannot use /v1 or /mcp | enforced | TestAWebPageCannotRunCodeThroughTheDaemon, TestV1AndMCPAgreeOnOrigins |
script.permissions | a script runs with no more than the permission profile named; a runtime with no permission model refuses a narrowed profile | enforced | TestScriptPermissionsIsReachableFromEverySurface, TestAStrictScriptCannotReadFilesOnARuntimeWithoutPermissions, TestARestrictedProfileIsRefusedByARuntimeThatCannotEnforceIt |
elicit.confirmDestructive | with it on, a call to a tool that may be destructive (annotated destructiveHint, or unannotated) does not run unless somebody confirms it; a remote server’s annotation never clears the call (#2191) | enforced | TestADestructiveCallIsRefusedWhenNobodyConfirms, TestConfirmDestructiveHoldsOnAColdDaemon |
autonomy.max | mcpx does no more on its own than the daemon’s autonomy.max allows: a prompt, a recipe run by name or a configured hook asked for above it is lowered to it and the answer says so, from /v1, the CLI, MCP and the plugin alike. It does not govern code a caller sends to /v1/exec itself | enforced | TestV1CannotRaiseAutonomyAboveTheCeiling, TestTheCeilingCannotBeRaisedAtRuntime, TestTheCLICannotRaiseAutonomyAboveTheCeiling, TestMCPCannotRaiseAutonomyAboveTheCeiling, TestThePluginCannotRaiseAutonomyAboveTheCeiling, TestHooksFromConfigDoNotRunAboveTheCeiling, TestRepairCannotRaiseAutonomyAboveTheCeiling, TestACallerCannotRaiseAClampedSetting |
privileged operations (Admin) | operations marked privileged change the daemon rather than read it; nothing checks who calls them, and every tool description and the OpenAPI document say so | advisory | #253 |
Finding the daemon without the binary
What the opencode plugin does when there is no mcpx on PATH, why each rung
of the ladder exists, and what was found building it.
id: finding-the-daemon-without-the-binary
since: pre-release
created: 2026-09-29T21:00:00-05:00
last-updated: 2026-09-29T21:00:00-05:00
status: implemented
Companion to docs/opencode-plugin.md, which records what opencode v1 and v2
offer a plugin. This one records the decisions that followed, and the things
that turned out not to be true.
1. The requirement, restated
The binary must not be a precondition. Two reasons, one of them forced:
- a daemon may be running under launchd, in a container, or on another host,
with no
mcpxon the plugin’sPATH; - opencode v2 removes Bun’s
$from the plugin API.node:child_processstill works, but the runtime no longer offers a way to run a command, and building on one it does not offer is building on sand.
And the harder half: never fail because there are two. A plugin that refuses to work until the user disambiguates has converted “we guessed” into “nothing works”, which is worse than the guess.
2. GET /v1/resolve
The one new daemon endpoint. It answers “which daemon serves this directory?” for any directory, from any live daemon, because the answer is a function of mcpx’s configuration search path and not of what the asked daemon happens to have loaded.
GET /v1/resolve?dir=/abs/path
→ { socket, endpoint, configPath, configHash, running, sources }
Three things were needed to make it honest.
It must not chdir. config.SearchPath() walked up from os.Getwd(), which
a server cannot change without racing every request in flight. So
SearchPathFrom(dir) and LoadFrom(explicit, dir) were added and the existing
functions now delegate to them. That is the only edit outside this feature’s
own files.
It must resolve symlinks first. FingerprintConfig already resolves the
files it hashes, for exactly this reason: /tmp is a symlink to /private/tmp
on macOS, so a caller whose $PWD holds the unresolved spelling computed a
different key for the same project and was told nothing was running. The
directory has to get the same treatment before the walk, or the bug comes back
one level up.
It must prefer the info file’s socket. The daemon key names the socket only
when the state path fits in sun_path; beyond 100 bytes the socket moves to a
private runtime directory and only the info file knows where. A resolver that
trusted the key would hand out a path with nothing behind it.
A name collision worth recording
daemon-<key>.json contains a field called configHash, and it is not the
key in the file name. The field is HashConfig(raw) — a digest of the server
definitions, used to invalidate the schema cache. The file name is
FingerprintConfig(sources) — the daemon key, over every path and every byte
that contributed. Two different numbers for the same daemon, under one name.
/v1/resolve returns the key, because that is what identifies a daemon.
Renaming the field in the info file would be the real fix; it is out of scope
here and is reported rather than done.
3. The ladder
| # | rung | cost | applies when |
|---|---|---|---|
| 0 | explicit endpoint or socket | free | configured, or remote |
| 1 | the remembered choice, still answering | one health check | every session after the first |
| 2 | the daemon-*.json info files | readdir + parallel health | the usual case |
| 3 | /v1/resolve against any live daemon | one request | two or more candidates |
| 4 | mcpx --json status | one spawn, ~23 ms | a binary exists and rung 3 was not available |
| 5 | ask | a toast, a prompt, or the picker | still ambiguous, and someone is there |
| 6 | best candidate, warn, carry on | free | headless, or unanswered |
Rung 5 is deliberately not in the core. The core returns the candidates and
a warning; whether anyone can be asked, and how, is the adapter’s business, and
it differs per harness. That keeps mcpx/daemon.ts free of opencode.
Rung 2 looks in three directories, not one
The info file is always in the state directory. The socket is not: too long a
state path sends it to $XDG_RUNTIME_DIR/mcpx, and failing that to
$TMPDIR/mcpx-<uid>. Sockets are therefore scanned in all three, and any that
has no info file is still health-checked — a daemon with a deleted info file is
still a daemon.
A socket that is not owned by this user is skipped without dialing. The socket’s permissions are the access control for the whole API; connecting to another user’s would send this session’s calls to somebody else’s servers.
Rung 3 stops at the first definite answer
If a daemon answers and names a socket that is live and among the candidates, that is the answer. If it answers and says nothing is serving this directory, the loop stops rather than asking the next daemon: they all compute the same function, so a second opinion is the same opinion.
Rung 0 does not fall through
A named endpoint that is not answering returns that endpoint with a warning,
never a local daemon. Answering from the wrong machine — wrong servers, wrong
credentials, possibly wrong data — is worse than answering not at all. This
matches what the CLI already does (will not start a local one).
4. Asking, in a harness with almost no interface
A v1 server plugin has client.tui.showToast and a few fixed dialogs. There is
no generic dialog and no select over HTTP, in either version. So:
- A toast, on boot and on first use, once per session per reason.
- One line on every mcpx tool result, naming the daemon that answered. Cheaper than a toast and impossible to miss in a transcript.
mcpx_daemon_select, whose execution raisesctx.ask(...). That is the only interactive channel a v1 server plugin has, and it is a good one: the user sees the daemon’s config path, start time, version and session count in the permission metadata, andalwaysis exactly “remember this”.mcpx-tui.tsx, optional, for a real list you arrow through.
The tool takes an index, not a path. The agent reads mcpx_daemon_status,
reasons about it, explains its recommendation, and names a number; the tool
turns that into a socket. The agent cannot mistype a path it never types. This
is why the guided skill beats a coded walkthrough: it degrades correctly when
the agent is confused.
⚠️ v1’s permission.ask hook is dead code — declared, documented, never
invoked. Nothing here is built on it.
Detecting headless
showToast publishes an event and returns true whether or not anyone is
listening, so it cannot be asked. The process shape can: opencode tui runs the
server in a Worker and the interface on the main thread, while opencode run,
opencode serve and every CI invocation run the server on the main thread with
nothing attached. So isMainThread from node:worker_threads is, in v1,
exactly “no TUI attached”. MCPX_PLUGIN_HEADLESS overrides it.
Being wrong in the safe direction costs a toast nobody sees. Being wrong the
other way would hang a headless run on a question — which is why no automatic
path ever raises a permission prompt. Only mcpx_daemon_select, which an agent
calls deliberately, asks anything.
5. Remembering
| scope | where | why there |
|---|---|---|
| session | plugin memory | it should not outlive the session |
| until-gone, indefinite | $MCPX_STATE_DIR/opencode-daemons.json | v1 server plugins have no storage API |
| permanent | PUT /v1/settings/daemon.endpoint | it is a user preference, and mcpx already owns those |
The file is keyed by directory, written through a rename, and read at rung 1
before the scan. ctx.storage in v2 replaces it exactly.
The TUI picker writes the same file — it is the only channel between the two realms — so the server plugin re-reads it when its copy has gone stale. Five seconds: never would make the picker take effect only in the next session, and always would put a file read in front of every tool call.
6. What else had to stop spawning
Discovery was half the problem. The rest:
| feature | before | now |
|---|---|---|
| tool timing | POST /v1/log, spawn fallback | POST /v1/log, and drop the record with no daemon |
mcpx_discover | mcpx ls / mcpx types | GET /v1/namespaces, GET /v1/types |
mcpx_observe | mcpx log / mcpx stats | GET /v1/log, GET /v1/stats |
mcpx_exec | mcpx exec | POST /v1/exec, falling back on 404 |
Dropping the timing record is the right failure. A timing is not worth 23 ms on every tool call, and certainly not worth failing one.
The backend setting (auto, v1, cli) makes the fallback explicit.
v1 is the honest setting for a machine with no binary: a missing daemon then
reports a missing daemon, rather than a missing command.
7. What is general and what is opencode
mcpx/daemon.ts imports nothing from opencode. It uses fetch, node:fs,
node:os, node:path and node:child_process, and nothing else. The ladder,
the info-file format, the health filtering, the ranking and the remember file
are all about mcpx; mcpx-session.ts is the part that is about opencode.
What a second harness has to provide: a directory, a session id, somewhere to put environment variables, and — optionally — a hook after a tool call, a way to show a line of text, and a way to ask a yes/no. Only the first two are required; everything else degrades.
8. Constants
The Go side puts every constant in internal/defaults/defaults.json; this
feature adds resolve.dialTimeout.
The plugin cannot read that file — it ships as loose files copied into an
opencode config directory, with no mcpx installation implied. So it has the
same idea in the only form available: one TUNING table at the top of
mcpx/daemon.ts, with every timeout, limit and file name in it, and each
value overridable through the options a harness passes in.
Plugins
“Plugin” means two different things in this project, and every page in this set says which one it means:
| meaning | direction | exists today |
|---|---|---|
| harness plugin: code that plugs mcpx into an agent harness | a consumer of mcpx | yes: the opencode plugin, under plugin/opencode/ |
| mcpx plugin: an extension of mcpx itself | extends mcpx | no: there is no plugin concept in the daemon at all |
Nearly everything below is about the first meaning. The second gets a short status section at the end of the first section, and stays a stub until the extensibility work that designs it lands.
Plugins
Harness plugins: mcpx inside your agent
A harness plugin is optional. Any harness with a shell can use mcpx
through AGENTS.md alone: the agent runs mcpx commands, and mcpx needs
nothing from the harness to answer them. What the harness cannot give that
way is identity — and identity is what the plugin adds:
- the session id, so
session-scoped servers persist across commands and two agents running at once lease separate server processes instead of corrupting one shared browser; - the socket fast path: about 0.17 ms per call over the daemon’s unix
socket, against about 23 ms for spawning the binary — which is why the
plugin also means no
mcpxonPATHis required for any of it (the one exception is rung 4 of daemon discovery, below, a last fallback that runs the binary only when one exists); - optional extras: tools offered to the model, a system-prompt line, tool timing records, and a picker for choosing between daemons.
Hosts that only speak MCP need no plugin at all: point them at
mcpx serve --transport stdio or the daemon’s /mcp endpoint and mcpx is
just an MCP server to them. Session scoping still works there, but it is
mcpx’s own transport session — a stable per-process key (mcp-<pid> for a
stdio server) — not the harness’s session. What those hosts do not get is
harness-session identity: nothing ties the MCP connection to the agent
session that spawned it, so two agents sharing one MCP server process
share its session scope too. That identity is the trade the plugin exists
to provide.
mcpx plugins: extending mcpx
Not built. There is no plugin concept in the daemon: no loading, no hooks,
no extension API. The design space — Go, binary, script or remote plugins,
hooks, variables — is tracked by the extensibility issues on the repository
(#80, #82, #83 in the original numbering; see the issue for this page,
#1855). Until that work lands, the ways to extend what mcpx can reach are
the built-in ones: a command-line program becomes tools through an adapter,
an OpenAPI document becomes tools through mcpx api, and a saved script or
recipe runs by name. This section stays a stub so the word “plugin” is
never left ambiguous: if a page does not say “mcpx plugin”, it means the
harness kind.
Plugin compatibility
Every cell cites a source or says “unknown”. Nothing here is guessed, and no support is claimed before an adapter exists.
| harness | plugin | how mcpx reaches it today | session id | source |
|---|---|---|---|---|
| opencode v1 (1.18.31) | yes, plugin/opencode/ | plugin, or shell + AGENTS.md | injected by the shell.env hook | plugin/opencode/README.md; docs/opencode-plugin.md §2, §6 |
| opencode v2 (2.0.3) | no: v1 plugins do not load | shell + AGENTS.md, or mcpx as an MCP server in its mcp config | not injected; needs the v2 session environment API | docs/opencode-plugin.md §4; see opencode v2 plugin below |
| codex | no | shell + AGENTS.md; MCP | unknown | adapter design: docs/opencode-plugin.md §5 |
| claude code | no | shell + AGENTS.md; MCP | unknown | adapter design: docs/opencode-plugin.md §5 |
| pi | no | unknown | unknown | adapter design: docs/opencode-plugin.md §5 |
| manus | no | unknown | unknown | adapter design: docs/opencode-plugin.md §5 |
| any MCP host | not needed | /mcp or stdio | no harness session; mcpx derives its own transport session (mcp-<pid> for stdio) | internal/cli/serve.go |
One runtime note: the portable core dials the socket with Bun’s
fetch(url, {unix}). A Node-based harness cannot use that option, so it
uses MCPX_DAEMON_ENDPOINT and the daemon’s TCP listener instead.
Everything else in the core is runtime-neutral.
Plugin interface and config
There are two interfaces — plugin to harness, and plugin to daemon — and one configuration surface.
Plugin to harness
The opencode plugin uses four hooks (docs/opencode-plugin.md §2.2):
| hook | what it carries for mcpx | without it |
|---|---|---|
shell.env | MCPX_SESSION_ID, MCPX_CALL_ID and the rest of the injected environment, per shell command | no ids: calls are attributed to “unknown” and session-scoped leases fall back to per-call isolation — each invocation gets its own lease keyed by its call id, so state does not persist across commands and anything stateful must happen inside one invocation |
experimental.chat.system.transform | the opt-in system-prompt line saying mcpx exists | the opt-in tools still work when named; the model may never reach for mcpx |
the tool map | the opt-in tools mcpx_discover, mcpx_exec, mcpx_observe | mcpx is shell-only, which is the default anyway |
tool.execute.after | one timing record per tool call, into mcpx’s log | no harness timings; mcpx stats covers mcpx alone |
The names are the code’s names: the plugin sets MCPX_SESSION_ID
(plugin/opencode/mcpx-session.ts), MCPX_CALL_ID and
MCPX_PARENT_SESSION_ID, and those are the names the session and
parent-session scopes read. (MCPX_SESSION without a suffix is a
different, live variable: mcpx sets it for each script it runs. It is not
the plugin’s variable and must not be conflated with it.)
Plugin to daemon
The portable core, plugin/opencode/mcpx/daemon.ts, imports nothing from
opencode. Its DaemonClient / connect() finds a daemon by walking a
ladder and stopping at the first answer (full design:
plugin-discovery.md):
| # | rung | applies when |
|---|---|---|
| 0 | an endpoint or socket named outright, by option or environment | configured, or remote — and it never falls back to a local daemon |
| 1 | the choice remembered for this directory, still answering | every session after the first |
| 2 | the daemon-*.json info files the daemon publishes | the usual case; no binary needed |
| 3 | GET /v1/resolve against any live daemon | two or more candidates |
| 4 | spawn mcpx --json status | a binary exists and the daemons are too old for rung 3 — the only rung that runs the binary. backend: v1 does not suppress this spawn: the backend guard governs the tool-execution fallback, and discovery still spawns when no daemon is discoverable. Only bin: "" forbids spawning outright |
| 5 | ask the user (toast, picker, or mcpx_daemon_select) | still ambiguous, and someone is there |
| 6 | the best candidate, with a warning | headless, or unanswered — the ladder never fails just because there are two |
connect returns { client: undefined } rather than throwing: a plugin
that fails to load because mcpx is not running has broken the editor for a
tool the user may not even be using.
For authors of plugins for other harnesses
The core is harness-free on purpose; an adapter per harness supplies the
rest. What an adapter must provide (docs/opencode-plugin.md §5):
- a directory, to resolve which daemon;
- a session identifier, to attribute and route;
- somewhere to put environment variables, or a way to wrap tool calls;
- a hook after a tool call, if timings are wanted;
- a way to show a line of text, if ambiguity is to be reported;
- optionally, a way to ask a question and a way to run a model.
Only (1) and (2) are required. Everything else degrades.
Configuration
Precedence is plugin option > environment variable > default. Options
reach the plugin only when it is named in the opencode config as a
two-element array — a plugin merely dropped into the plugin/ directory
gets no options, so the environment variables are the way to configure a
drop-in installation (see opencode v1 plugin).
The table below is derived from one declared list: the registry’s
plugin.* settings, declared in internal/settings/plugin.go
(pluginSettings()), which is also what mcpx settings reports and what
the settings tests pin against the plugin’s sources in both directions.
The two options that are not settings — endpoint and socket, which name
a daemon rather than tune the plugin, and are read before any settings
exist — are declared alongside the table, not in the registry. If an option
is missing from this table, that is a bug in the table’s source list, not
a detail to fix in the table: discoveryRetry went missing from the
plugin README’s hand-maintained table exactly that way.
| option | variable | default | effect |
|---|---|---|---|
endpoint (not a setting) | MCPX_DAEMON_ENDPOINT, MCPX_ENDPOINT | — | a daemon named outright: http://host:port or unix:///path. Stops the ladder. |
socket (not a setting) | MCPX_SOCKET | — | a socket named outright. Stops the ladder. |
bin | MCPX_PLUGIN_BIN | mcpx | the binary for rung 4. "" forbids spawning anything. |
binArgs | MCPX_PLUGIN_BIN_ARGS | — | arguments put in front of every plugin invocation of mcpx (the case this exists for is --config or --profile). |
backend | MCPX_PLUGIN_BACKEND | auto | auto prefers the socket and spawns only as a fallback; v1 refuses the spawn fallback for tool execution (discovery may still spawn when no daemon is discoverable — bin: "" is the way to forbid spawning entirely); cli never uses the socket. |
discoveryRetry (environment only) | MCPX_PLUGIN_DISCOVERY_RETRY | 60s | how long the plugin waits before looking for mcpx again after a failed lookup, so an editor without mcpx does not pay for a failed spawn on every keystroke. Declared in the registry, but the adapter reads it from the environment only — passing it as a plugin option is silently ignored. |
toolTiming | MCPX_PLUGIN_TOOL_TIMING | off | record opencode’s tool timings into mcpx’s log, so one mcpx stats covers both. |
env | MCPX_PLUGIN_ENV | full | how much session context is injected into each command’s environment: minimal, standard, full (the lists are under opencode v1 plugin). |
instructions | MCPX_PLUGIN_INSTRUCTIONS | off | add mcpx usage guidance to the system prompt. |
tools | MCPX_PLUGIN_TOOLS | off | offer mcpx_discover, mcpx_exec and mcpx_observe as tools the model can call. |
remember | MCPX_PLUGIN_REMEMBER | session | how long a chosen daemon stays chosen: session, until-gone, indefinite. |
annotate | MCPX_PLUGIN_ANNOTATE | on | add one line to each mcpx tool result naming the daemon that answered, when more than one matched. |
headless | MCPX_PLUGIN_HEADLESS | auto | suppress toasts, because nobody is looking at a screen. auto decides from the process shape. |
daemonTools | MCPX_PLUGIN_DAEMON_TOOLS | auto | offer mcpx_daemon_status, mcpx_daemon_select and mcpx_daemon_forget. auto offers them when tools is on, or when discovery found more than one daemon. |
MCPX_STATE_DIR and XDG_STATE_HOME are read too, because that is where
the daemon publishes its info files and where the remembered choice is
kept. They are mcpx’s variables, not the plugin’s. The full configuration
reference is configuration.md.
opencode v1 plugin
This section is about the harness plugin for opencode v1 (1.18.31) —
the one plugin that ships today. It lives in plugin/opencode/; the
install and reference material is plugin/opencode/README.md, and the
design is opencode-plugin.md.
What ships
| file | what it is for |
|---|---|
plugin/opencode/mcpx-session.ts | the opencode adapter: hooks, tools, toasts, and the session id. The only file opencode loads as a plugin. |
plugin/opencode/mcpx/daemon.ts | the portable core: the daemon HTTP client, the discovery ladder, and the remembered-choice file. Imports nothing from opencode. |
plugin/opencode/mcpx-tui.tsx | optional TUI plugin: an arrow-key picker for the daemon. A different realm with a different API, installed separately. |
plugin/opencode/skills/ | four skills: mcpx-basics, mcpx-observability, mcpx-browser, mcpx-daemon. The same directories are embedded in the mcpx binary and served over MCP, so an MCP host can load them without copying anything. |
Install
By hand, with cp -R — there is no packaged install, and the Nix
configuration does not install the plugin:
mkdir -p ~/.config/opencode/plugin
cp -R mcpx-session.ts mcpx ~/.config/opencode/plugin/
# optional, and a separate artifact: the arrow-key daemon picker
cp mcpx-tui.tsx ~/.config/opencode/plugin/
# skills are separate again
cp -R skills/mcpx-daemon ~/.config/opencode/skills/
(run from plugin/opencode/). mcpx/ must stay a subdirectory: opencode
v1 loads every file directly in plugin/ and calls each of its exports as
a plugin, so daemon.ts beside mcpx-session.ts would have its helpers
called as plugins. The glob is not recursive, so one level down the core is
only ever imported.
To pass options, name the plugin in opencode’s config as a two-element array; a drop-in file gets none:
{
"plugin": [
["./.opencode/plugin/mcpx-session.ts", { "tools": true, "backend": "v1" }]
]
}
Every option also has the environment variable in the table under plugin interface and config, which is the way to configure a drop-in installation.
What gets injected
How much goes into each shell command’s environment is the env option:
minimal:MCPX_SESSION_IDonly.standardadds what the hook is handed directly, what the plugin knew at startup, and the daemon this session settled on —MCPX_CALL_ID,MCPX_PROJECT_ID, the worktree and harness variables, andMCPX_DAEMON_ENDPOINT, so a shell command that runsmcpxtalks to the same daemon the plugin does instead of walking its own ladder.full(the default) adds what needs a lookup, done once per session and cached:MCPX_PARENT_SESSION_ID, the session title, directory, version, depth, creation time and age, and the full trace ancestry inMCPX_TRACE_IDS.
Environment variables are not context: the model never sees them, so anything cheap goes in. The full list, with who reads each variable, is in environment.md.
The tools
With tools: true, the plugin offers mcpx_discover, mcpx_exec and
mcpx_observe, each speaking the daemon API first and falling back to the
binary only when there is no daemon (never under backend: v1). They are
off by default because an agent with a shell can already run mcpx, and
a tool definition costs context on every request whether or not it is
used. Turn them on when the agent has no shell, when you want mcpx calls
visible as tool calls in the transcript, or when a model keeps forgetting
mcpx exists.
The daemon tools — mcpx_daemon_status, mcpx_daemon_select,
mcpx_daemon_forget — answer the which-daemon question when discovery
found more than one. They gate nothing; work proceeds on the best
candidate with a warning until somebody chooses.
Two more options, both off by default for the same context-cost reason:
instructions adds four lines of mcpx usage guidance to the system
prompt, paid on every request for the life of the session; toolTiming
records every opencode tool call into mcpx’s log (a 0.09 ms POST over the
socket; with no daemon the record is dropped, never spawned — a timing is
not worth 23 ms).
The TUI picker
mcpx-tui.tsx adds one palette command, mcpx: choose daemon: each
daemon’s project, start time, server count and version, then how long the
choice should stick. It is a separate plugin because the TUI runs in a
different realm with a different API — a v1 module may export server or
tui, never both — and a choice made there reaches the server plugin
through the remembered-choice file.
Interplay with opencode’s own code mode
None, and none needed. opencode v1 ships its own code mode behind
OPENCODE_EXPERIMENTAL_CODE_MODE, which moves MCP tools out of the
model’s tool list; the mcpx plugin does not conflict with it, and the
plugin needs nothing in opencode’s mcp config — it reaches the daemon
itself, over the socket.
opencode v2 plugin
Status: not built. This section is about the harness plugin for opencode v2 (2.0.3), and it is a status section, not a feature description: v1 plugins do not load in v2 at all, so the plugin needs a rewrite, not a port. The work is tracked in the repository’s issues (#47 in the original numbering; see #1855), and the full design is in opencode-plugin.md §4 and the compendium it links.
On opencode v2 today:
- Shell +
AGENTS.mdworks, with no session id — sosession-scoped servers are isolated per call rather than per session. - Or add mcpx as an MCP server:
mcpx serve --transport stdioin v2’smcpconfig. One caveat: v2 defaults MCP servers to code mode, so mcpx’s own tools end up wrapped inside v2’s code mode; whether v2’s code-mode defaults recognise mcpx and turn that off for it is unknown.
What the rewrite changes, when it happens:
- Unnecessary: the
mcpx/subdirectory (onlydefaultis read), hand- namespacing tool names, a state file for the remembered daemon (ctx.storageexists), and the per-commandshell.envhook (the session environment API sets a session’s shell environment once per session). - Possible: answering MCP sampling with
ctx.generate.text; registering MCP servers per session, so mcpx could hand opencode a filtered server set; putting a budgeted catalog into the session context hook instead of the system prompt; seeing tool failures in the after-hook, not just successes. - Breaks: the plugin itself, Bun’s
$(gone from the v2 plugin API — the other reason the plugin must not need the binary),sessionIDin the shell hook (its replacement carries no session id), and MCP SSE transport. - The same: the TUI SDK surface, no isolation (plugins run in the
host’s realm), and Bun’s
fetch(url, {unix})— so the socket fast path survives the rewrite.
One v1 quirk worth knowing while both versions are in use: v1.18.31 runs
both plugin systems at once, so a v2 plugin file dropped into a v1
plugin/ directory is loaded by the v2 host and rejected, harmlessly, by
the v1 loader.
vibeMcp
Placeholder: what “vibeMcp” means is an open question for Drew (open question 1 on #1855) — this anchor holds the name, and no meaning is guessed here until he answers.
Proposals
Open design questions and answers. Nothing here is committed to; the point is
to make the decisions visible before they are made. Shipped work moves to
story.md under the feature list.
Answers about what exists today
Before proposing anything, the current state, verified in the code.
Shared servers are started lazily and do shut down. Nothing starts at
daemon boot except the schema warm, which starts each server once to read
tools/list and lets it idle out. ReapIdle runs on a 30-second timer against
each server’s idleTimeout (default 5m), and a shared instance is reaped when
len(instances) > Min, where Min defaults to 0. So the steady state for an
unused server is zero processes, and the next call pays a cold start.
Today’s session mode is per-script-run. mcpx run mints a fresh key per
invocation and releases it when the process exits. --session <key> overrides
it, which means “named” already half-exists. Nothing is aware of an opencode
session, a repo, or a worktree.
There are no ports. stdio servers are pipes; only url servers have a
port and it is theirs. The predecessor’s port-assignment machinery was deleted
on purpose.
Config is first-file-wins with no merge. A project .mcpx.json replaces
the user config entirely rather than adding to it. See below — this is wrong.
Server-provided instructions are parsed and discarded. initialize
returns an instructions string; mcpx reads it into a struct field nothing
consumes. Descriptions and namespaces come only from config.
Scripts have no declared contract. stdout and stderr both pass through, the exit code propagates, and there is no log helper, no structured return, and no entry-point convention.
Modes and scopes — shipped
id: modes-and-scopes-shipped
status: shipped
Built. See Sharing and scope in the feature list. The retired
mode key is now a hard error carrying a migration hint rather than a silent
default. What follows is the original proposal, kept for the reasoning.
impact: breaking config change
The current single mode axis conflates two independent questions. The
proposal is to split them.
sharing — how many callers may use one process at a time.
| value | meaning |
|---|---|
shared | many concurrent callers per process; MCP multiplexes by JSON-RPC id |
exclusive | one caller at a time; others queue |
scope — what key selects a process. One process per distinct key value.
| scope | key | who computes it |
|---|---|---|
global | constant | mcpx |
repo | git rev-parse --git-common-dir | mcpx |
worktree | git rev-parse --show-toplevel | mcpx |
cwd | working directory | mcpx |
session | $MCPX_SESSION_ID, else the run key | caller |
parent-session | $MCPX_PARENT_SESSION_ID, else $MCPX_SESSION_ID | caller |
call | fresh per invocation | mcpx |
named:<x> | the literal string | caller |
pid:<n> | the pid | caller |
Today’s modes become aliases, so existing configs keep working:
| today | becomes |
|---|---|
shared | sharing: shared, scope: global |
pooled | sharing: exclusive, scope: call |
session | sharing: exclusive, scope: call |
That last row is the honest mapping: today’s session is per-call.
The part mcpx cannot do alone
scope: session needs an identifier mcpx has no way to discover. A subagent
and its parent are different processes with different pids and the same cwd;
only the agent host knows they differ.
So the contract is an environment variable. Callers that know their session
export MCPX_SESSION_ID and, if nested, MCPX_PARENT_SESSION_ID. Everything
else — repo, worktree, cwd, call — mcpx computes itself and works with
no cooperation.
For opencode specifically that means either a plugin that adds the variable to
the shell environment (v1 shell.env, v2 shell.hook["create.before"]), or an
instruction telling the agent to pass --session. The plugin is better because
it cannot be forgotten.
Recommendation for the current servers: chrome-devtools at
sharing: exclusive, scope: session once the variable exists, scope: call
until then. Everything else stays sharing: shared, scope: global.
Lifetime
A scope needs a teardown rule or it leaks.
| scope | released when |
|---|---|
call | the process exits (today) |
cwd, repo, worktree, global | idle timeout |
session, named | idle timeout, or mcpx session stop <key> |
pid | the watched pid exits |
pid:<n> is worth building early and is cheap: poll kill(pid, 0) on the
existing 30-second reaper tick. It gives an agent host a way to say “this
browser belongs to that process” without any protocol.
Session introspection
id: session-introspection
status: proposed
depends: modes and scopes
A lease should be an addressable object.
mcpx session ls scope, key, server, pid, uptime, idle, calls
mcpx session show <key>
mcpx session stop <key>
mcpx session rename <key> <name>
mcpx session set <key> --idle-timeout 30m
mcpx session watch <key> follow its calls live
Recorded per lease at creation: created-at, scope, key, server, instance id,
child pid, creator pid, creator cwd, creator hostname, creator username,
MCPX_SESSION_ID and parent if supplied, and the mcpx version. Recorded per
attach and detach: timestamp, pid, cwd, and how it ended (released, idle,
killed, pid-gone).
The same data behind mcpx --json session ls and as a client import, so a
script can look at its own lease and adjust its own timeout.
Logging
id: logging
status: proposed
Two stores, because they answer different questions.
JSONL files, one per daemon, at
$XDG_STATE_HOME/mcpx/logs/<config-hash>/<date>.jsonl. Append-only, buffered,
flushed on a 200 ms tick and on shutdown. One object per line with ts,
level, server, instance, session, scope, tool, dur_ms, msg,
plus free-form fields. Cheap to write, trivially greppable, survives anything
that does not SIGKILL.
A SQLite index at .../logs/index.db with one row per call rather than
per log line — server, tool, session, start, duration, ok, bytes in and out.
That is the table mcpx stats reads. Keeping it call-grained rather than
line-grained is what keeps it small enough to stay fast.
mcpx log --since 1h --server chrome-devtools --level warn --session <key>
mcpx log --follow
mcpx stats --since 24h --by server calls, p50/p95/p99, failure rate
Levels resolve most-specific first: per-session, then per-server, then global,
from config or MCPX_LOG_LEVEL. Default info to stderr, debug to file.
Erring toward more logs is right for the file and wrong for stderr — stderr is
shared with the script’s own diagnostics and an agent reads it.
On SIGTERM the buffer flushes before listeners close, the same ordering fix
already made for the schema cache. mcpx stop --force skips the flush.
Is there a standalone mode? Today, no: every command talks to a daemon and
auto-starts one. That is right for pooling, which needs a process that outlives
the call. A --standalone flag that runs the pools in-process for a single
command is worth having for CI, where a lingering daemon is a nuisance.
Configuration — merge shipped
id: configuration-merge-shipped
status: shipped
The chain now merges, nearest first, per server name. A project adds servers
instead of hiding the user’s. mcpx config --sources shows every contributing
file and which one defined each server.
One consequence worth recording: the daemon fingerprint now hashes every source, not just the nearest. Two projects with byte-identical local configs can still inherit different servers from different parents, and keying on the nearest file alone would have given one project the other’s servers.
The rest of this entry is the original proposal.
impact: behaviour change
Merge the chain instead of taking the first file. Today a project config
hides every user-level server, which makes “add one server for this repo”
impossible without copying the whole file. Proposed: walk the whole chain,
nearest first, and merge by server name. A nearer file overrides a server of
the same name and adds new ones; "disabled": true turns off an inherited one.
pool merges key by key.
mcpx config --sources prints the chain with provenance per server, because a
merge you cannot inspect is worse than no merge.
Scripts already continue the whole chain, nearest wins per name. That is the behaviour config should match.
.config/mcpx is now checked everywhere .mcpx is, at higher precedence,
with a warning when both exist in one directory. (Shipped.)
Per-user versus global. The daemon is already keyed to a config file, and a
config file is reached through $HOME, so “global” means “this user’s default
config”. A true per-host config is /etc/mcpx/config.json, already last on the
chain. No third concept is needed; the docs should just say per-user and stop
saying global.
Name collisions. Servers aggregate by name. Two servers with the same name in different files merge; two servers with different names that map to one namespace is already a hard error that names both.
Scale. Discovery is a map read regardless of server count. The real limit
is process count, sum(max) across servers times the number of daemons.
Proposed: a daemon-wide maxInstances with LRU eviction of idle leases, so a
pathological config degrades into slower leasing rather than fork-bombing.
The script contract
id: the-script-contract
status: proposed
Underspecified today and worth pinning down.
Streams. stdout is the result. stderr is diagnostics. A script that prints nothing to stdout returns nothing.
A log helper, exported by the client, writing structured lines to stderr at a level, so script output and script logging stop competing:
import { log } from "./mcpx-client.ts";
log.debug("snapshot", { bytes: snapshot.length });
log.warn("retrying", { attempt });
Returning a value. If a module has a default export that is a function, mcpx calls it with parsed arguments and JSON-prints whatever it returns; if it does not, top-level await runs and stdout is the result. That makes a file both importable and runnable without ceremony:
export function helper() {
/* importable */
}
export default async function main(args: string[]) {
return { ok: true }; // printed as JSON
}
mcpx run <name> --export <fn> calls a named export instead of the default,
which covers “one file, several entry points”.
Arguments already arrive as Deno.args / process.argv. Proposed
addition: --json-args '<json>' parsed and passed as the first parameter to
the entry function, so callers do not have to serialise through argv.
mcpx run --json wraps the whole thing — stdout, stderr, exit code,
duration, tool calls made — in one envelope for programmatic callers.
Modules and libraries
id: modules-and-libraries
status: proposed
Update (#1913): the dependency-configuration half of this area has
shipped — the deps.* settings and paths.deps resolve where a
script’s packages come from (script specifiers, explicit request,
nearest project manifest, the configuration directory’s own packages,
then the host), the set travels on ExecOptions as data, and the runner
materialises it in the exec workdir per runtime. What remains proposed
here is only the shared-code half below: a declared lib/ and the
@lib/ import map.
Scripts run on a real runtime, so imports already work: npm:, jsr:,
https: on Deno, node_modules on bun and node. What is missing is a declared
place to put shared code.
Proposed: .config/mcpx/lib/ beside scripts/, on the same upward search, and
a generated import map aliasing @lib/ to the nearest one. A project gets its
own lib automatically; a user-level lib is the fallback; a project can
shadow a user module by name, which is the same rule as scripts.
Isolation. Separate import maps per project give natural isolation between projects. Within one process there is none — a script can monkey-patch anything it imports. That is inherent to running on a real runtime with no sandbox, which is a deliberate choice. If isolation ever matters more than capability, that is the argument for v2-style interpretation, not for bolting a sandbox on.
Version overrides fall out of the import map: a project pins whatever it wants and the user-level pin is irrelevant to it.
Types
id: types
status: proposed
mcpx types <ns>.<tool> — emit one function with its argument and return
types expanded recursively, plus the namespace’s description. Small, obviously
useful, no design risk.
Surface the server’s own instructions. initialize returns an
instructions string that mcpx currently discards. chrome-devtools-mcp almost
certainly explains pageId there. Proposed: cache it and emit it as a
namespace-level doc comment in types output. This is the cheapest fix for the
“how do I get a pageId” problem and it is nearly free.
Producer inference. The real problem: pageId: number does not say to call
list_pages first, and including every tool that mentions pageId is all 27.
Heuristic worth trying: for a required parameter P of tool T, a producer
is a tool that does not require P and whose name or description contains
P’s noun. For pageId that selects list_pages, new_page, select_page —
three, not twenty-seven. Emit them as a comment:
/** Targets a specific page by ID. @see list_pages, new_page, select_page */
pageId: number;
It is a guess and should be labelled as one, but a wrong hint costs a few tokens and a missing hint costs a failed call and a retry.
Context-budgeted catalog. mcpx types is all-or-nothing: 4,402 tokens for
chrome_devtools whether three tools are needed or all 27.
opencode v2 solves this with a round-robin fit, worth copying exactly: every namespace is always listed with its tool count, so nothing is invisible; each namespace’s signatures are ranked cheapest first; then it loops over namespaces taking one signature each per pass until the token budget is spent. The effect is that a namespace with three small tools shows all three, while a 27-tool namespace shows as many as fit, and no single large namespace can crowd out the others. v2’s budget is 2,000 tokens, which on the current servers shows 33 of 43 tools.
Proposed: mcpx catalog [--budget 2000], emitting one paste-ready block whose
size is bounded no matter how many servers are configured. Pair it with
search for the tools that did not fit.
OpenAPI. fromSpec is v2’s, and mcpx does not use it — there is no
OpenAPI support here at all. The proposal is to build an equivalent: compile an
OpenAPI 3.x document into a namespace of tools. It composes with pools exactly
as an MCP server does, and it is a large amount of reach for a contained amount
of code.
Round-robin catalogue budgeting
id: round-robin-catalogue-budgeting
status: proposed
effort: small; the ranking already exists in a different form
mcpx types <ns> is all-or-nothing. mcpx catalog [--budget N] would fit a
paste-ready block to a token budget instead.
The algorithm, which is opencode v2’s and worth copying exactly:
- List every namespace with its tool count first. Nothing is ever invisible, even at budget zero.
- Rank each namespace’s signatures cheapest first, where cost is
len(line)/4. - Loop namespaces, taking one signature per namespace per pass, until the budget is spent.
The rotation is load-bearing. Ranking globally by cost lets one namespace’s cheap tools crowd out everything else; taking whole namespaces in turn lets a 27-tool namespace eat the budget. Per-pass rotation gives a small namespace all its tools and a large one as many as fit.
Ranking is by cost, not relevance. There is no similarity search and no
model involved: it is sort by length, ties broken by path. That is a
deliberate difference from mcpx search, which is relevance-ranked by
substring scoring (exact 100, prefix 60, contains 40, function path 30,
namespace 20, description 10) with an any-term fallback. Neither uses
embeddings or an external library.
Keep whole-namespace types as well. They answer different questions:
catalog is “what is available, within a budget”, types is “I have chosen
this namespace, give me all of it”. An agent that knows it needs Chrome should
not be rationed. Deleting types in favour of catalog --budget huge would
also make the common case require a flag.
Open question worth trying: mcpx catalog --budget 2000 --bias screenshot,
where a query biases which signatures win their pass. That merges the two
ranking systems and may be better than either.
2026-09-27T07:00:00-05:00
Protocol adapters, including OpenAPI
id: protocol-adapters-including-openapi
status: proposed
effort: medium for the interface, small per adapter
A namespace is currently always an MCP server. The generalisation is an adapter interface: something that can list tools with JSON Schemas and call one. MCP is the first implementation; OpenAPI would be the second.
Yes, an OpenAPI service could be imported as a namespace. Its operations
already carry typed parameters and responses, which is exactly what the
existing JSON-Schema-to-TypeScript compiler consumes, and it would inherit
pooling, scoping, types and run unchanged.
MCP stays native. Nothing about this replaces it.
Emitting an OpenAPI document per namespace is the interesting inverse and is cheap once schemas are cached: it would let non-agent consumers — a dashboard, a test harness, another language’s client generator — use the same servers without speaking MCP. Worth doing after import, not before.
Would you ever not want it available? Two honest arguments against:
- Every transport is surface area. An OpenAPI document can describe authentication, content negotiation and streaming semantics that MCP does not, and partially supporting them is worse than not offering it.
- A script can already
fetch()an HTTP API directly. The adapter buys typed signatures and pooling, which matters for a large API and is pure overhead for a small one.
So: build the adapter interface, because it is the right shape regardless; treat OpenAPI as the proof that the interface is general; and do not present it as a headline. Reordered to the bottom of the shipped-work queue.
2026-09-27T07:00:00-05:00
Per-instance argument templating
id: per-instance-argument-templating
status: proposed
effort: small
Pooled instances currently get identical argv. Some servers need each instance to differ — a distinct port, profile directory, or upstream URL.
"chrome-attached": {
"command": "chrome-devtools-mcp",
"args": ["--browserUrl", "http://127.0.0.1:{{alloc.port}}"],
"mcpx": {
"scope": "session", "max": 4,
"allocate": { "port": { "range": [9222, 9299] } }
}
}
Substitutions available to args, env and url: {{instance.id}},
{{instance.index}}, {{key}}, {{alloc.<name>}}, {{tmpdir}}.
This is not needed for Chrome as configured today. chrome-devtools-mcp
takes no listen port: with --isolated it launches its own browser with a
temporary profile and an ephemeral debugging port of its own choosing. That is
precisely why mcpx has no port machinery, and why the predecessor’s
port-assignment code was solving a problem this server does not have.
It becomes necessary for --browserUrl / --wsEndpoint, where each instance
must attach to a different already-running Chrome. And a real allocator has
to survive the time-of-check-to-time-of-use gap that sank the earlier attempt:
bind the port, hold the listener, pass it down, release on start.
2026-09-27T07:00:00-05:00
Scope policy
id: scope-policy
status: proposed
effort: small
Scope is currently set per server and cannot be constrained or overridden.
"pool": {
"scope": "session",
"scopePolicy": {
"allow": ["session", "parent-session", "call"],
"allowOverride": ["flag", "script"]
}
}
allow restricts what a per-server block may choose. allowOverride says who
may change it at call time: flag for mcpx run --scope, script for a
client call, none to pin it. A denied override is an error naming the policy,
never a silent downgrade.
The case for it is a shared machine or a checked-in config where one project should not be able to widen a browser’s scope and start sharing state with another.
2026-09-27T07:00:00-05:00
Named sessions and keepalive control
id: named-sessions-and-keepalive-control
status: proposed
effort: medium
A key exists today and can be supplied with --session, which is already
naming by another word. Missing: discovery, persistence and adjustment.
mcpx session ls
mcpx session name <key> <name> name one that started anonymous
mcpx session set <name> --idle 30m
mcpx session stop <name>
mcpx session show <name>
Naming an anonymous session is the interesting one, because an ephemeral key is currently torn down when its caller exits. Naming must flip that bit: a named session outlives its creator by definition.
Per-scope keepalive defaults. One idleTimeout for everything is wrong,
because the scopes have different natural lifetimes:
| scope | default | why |
|---|---|---|
global | 30m | expensive to lose, cheap to hold |
repo, worktree | 15m | tracks a working session |
cwd | 10m | narrower, more of them |
session, parent-session | 10m | agent turns have gaps |
pid | none | released when the pid exits |
call | none | released when the caller exits |
Overridable in config per server, and at runtime per session.
2026-09-27T07:00:00-05:00
Health-based and webhook reaping
id: health-based-and-webhook-reaping
status: proposed
effort: small for health, medium for webhooks
Health check. A server that has wedged without exiting currently occupies
its slot until idle timeout. "health": { "url": "...", "interval": "30s", "expect": 200 } would reap on a failing probe. MCP already defines ping, so
the first version should use that rather than HTTP — it needs no configuration
and works for every server.
Webhook. Two directions, and they are different features:
- Outbound: mcpx notifies a URL on lifecycle events. This is just the event stream with an HTTP sink and is easy.
- Inbound: something tells mcpx to reap. That is a control API needing
authentication, and
mcpx session stopover the existing socket already does the job for anything local.
Recommendation: ping-based health first, outbound events second, inbound webhooks only if a concrete need appears.
2026-09-27T07:00:00-05:00
Structured logging
id: structured-logging
status: proposed
effort: medium
supersedes: the earlier "Logging" entry
Consolidates the whole logging intake. Nothing here exists yet; the daemon prints unstructured lines to stderr and scripts have no logging helper at all.
Records
One record type everywhere, emitted by daemon and scripts alike. Go’s slog
is the model, and its slog.Handler interface is the right seam for formats.
Every record is enriched by the daemon with ambient context the caller did
not have to supply: session id, parent session, scope, resolved key, server,
instance, tool, pid, cwd, config path, mcpx version, session start, script
start, elapsed. A script logs log.info("done") and the record that lands has
all of it.
Templates
A message keeps both forms:
log.info("status: {status}", { status: "ok" });
{ "ts": "...", "level": "info", "msg": "status: ok", "template": "status: {status}", "status": "ok" }
msg interpolated for humans, template preserved for grouping records that
differ only in values. That is what makes “how often did this line fire”
answerable. Named {placeholders} over %s because they map onto the
attributes without positional ambiguity — the same choice Serilog and
structlog make.
A bare string with no attributes is still wrapped into the same shape, so there is one record type and no second path.
Formats
--format on every command that emits records:
| format | shape |
|---|---|
json | one object per line |
json-pretty | indented, for reading |
logfmt | ts=… level=… msg=… |
text (default) | HH:MM:SS LEVEL msg key=value |
compact | LEVEL msg, attributes interpolated only |
bare | message only |
text interpolates the template and appends attributes not consumed by it.
compact and bare drop the timestamp.
Everything as JSON
--json already exists on several commands and should exist on all of them,
with every human table having a JSON counterpart. Scripts should get the same:
mcpx run --json wrapping stdout, stderr, exit code, duration and the tool
calls made.
Storage
JSONL per daemon under $XDG_STATE_HOME/mcpx/logs/, buffered and flushed on a
tick and at shutdown, plus a SQLite index of one row per call rather than
per line. Call-grained is what keeps mcpx stats fast; line-grained would not.
Default info to stderr, debug to file. More logs to the file is right;
more to stderr is not, because an agent reads stderr.
2026-09-27T07:00:00-05:00
Harness integration and installation
id: harness-integration-and-installation
status: proposed
effort: large; several independent pieces
Consolidates the distribution intake.
Division of labour
Three things a harness needs, and they should not be bundled:
- Instructions — the
AGENTS.mdtext. Any harness that reads a markdown instructions directory can use it with no code. - Session identity — exporting
MCPX_SESSION_IDandMCPX_PARENT_SESSION_IDinto the shell environment. This genuinely needs a plugin, and it is what makesscope: sessionwork. - Tool surface — optional. A harness could expose
mcpxas a tool rather than relying on shell access.
Only (2) requires code. Keeping them separate means a harness with no plugin support still gets most of the value.
Avoiding double-injection
If a plugin injects instructions and an instructions file exists, the text lands twice. The plugin should look for a marker — a known first line in the file — and skip injection when it finds one. Detection beats configuration because it cannot drift.
Install command
mcpx install opencode|opencode2|claude|codex [--dry-run] [--platform nix|nix-flake|darwin|darwin-brew]
mcpx install --list
Writes instructions, plugin and config edits for that harness. Hash-matching is the important part: record a hash of what was written, and on the next install update the file only when it still matches. A user-modified file is reported, never overwritten.
Daemon-side auto-update of harness configs is listed in the intake and should be declined, or made strictly opt-in. A background process editing another tool’s configuration is surprising in a way that is hard to debug.
Documents
- An install document fetchable with curl.
- A setup document written for an agent — different text, assuming tool
access and no human.
mcpx setup-promptemits it. This is the genuinely novel piece: the artefact is a prompt, not a script.
2026-09-27T07:00:00-05:00
Packaging and service management
id: packaging-and-service-management
status: proposed
effort: medium
A nix flake package exists. Missing: NixOS module, darwin module (one existed and was dropped when the package moved into the nix repo), Homebrew formula, and a plain installer for non-nix systems.
Service installation. mcpx service install|uninstall|status writing a
launchd plist or systemd user unit.
Never with sudo. The daemon is a per-user process owning per-user MCP servers; a system-wide unit would run servers as the wrong user and share state between users. A user-level unit needs no elevation, and an installer that asks for a password to do something that does not require one teaches a bad reflex.
Autostart. Both, and they already coexist: any command starts a daemon on demand today, and a service unit only means the schema cache is warm before the first agent asks. Keep on-demand as the supported path so that nothing requires installation.
2026-09-27T07:00:00-05:00
Transport, addressing and scale
id: transport-addressing-and-scale
status: proposed
effort: small for addressing, unknown for the rest
What exists today. The daemon serves HTTP over two listeners: a unix
socket at $XDG_STATE_HOME/mcpx/daemon-<hash>.sock for the CLI, and
127.0.0.1 on an ephemeral port for generated script clients, because
fetch() over a unix socket is not portable across deno, bun and node. The
port is chosen by the OS, so there is nothing to collide with, and it is
published in a daemon record file. Not stdio, not named pipes.
Multiple daemons already run, one per config fingerprint, each with its
own socket. mcpx daemons lists them.
Addressing. --server accepting example.com, example.com:8008, :42
or a unix path would let the CLI drive a remote daemon. The blocker is not
parsing but authentication and trust, which loopback currently provides for
free. Worth specifying before building.
Correction, 2026-10-30: the remote half of this was specified and
built — as an identity transport, not raw addressing. Since #1967 the
daemon can embed a Tailscale node (tsnet,
internal/daemon/transport_tsnet.go) and be reached by its tailnet
identity (a MagicDNS name) from another network, with the tailnet
providing exactly the authentication and trust this entry named as
the blocker. Generic internet addressing (example.com:8008 and
friends) remains unbuilt and unendorsed.
Load. Untested beyond 12 concurrent runs. Discovery is a map read and should be flat; the interesting limits are process count and the reaper’s O(instances) sweep. A benchmark should come before any optimisation.
Go versus Rust. No reason to move. The workload is process supervision and JSON, where the runtime is idle most of the time and goroutines map onto the problem directly. Rust would buy a smaller binary and no GC pauses, neither of which is a constraint here, at the cost of a rewrite and harder async process handling. Revisit only if profiling finds something the GC is responsible for.
2026-09-27T07:00:00-05:00
Name
id: name
status: proposed
effort: none, but it gets more expensive every week
mcpx reads as “MCP extended” and is already in the docs, the binary, the
config key and the environment variables.
Alternatives worth a moment: xmcp (same idea, scans worse aloud); mcpsh
(says “shell”, which is the actual pitch); tsx-adjacent names (taken);
conduit, broker, switchboard (descriptive, generic, unsearchable).
Honest assessment: mcpx is fine and the rename cost only grows. The one
argument for changing is that the tool’s distinguishing feature is shell
access to MCP, and mcpsh says that. Not worth the churn unless it is
decided before anything publishes.
2026-09-27T07:00:00-05:00
Parity tracking
id: parity-tracking
status: proposed
effort: small, ongoing
opencode v2 is the closest comparable and moves fast. A short table in
OPENCODE-V2.md listing each capability and whether mcpx
has an equivalent, reviewed per release, is cheap and prevents drift being
noticed only when it is expensive.
Other systems worth reading for concepts rather than parity: Claude Code’s skills and hooks, Codex’s sandboxing, and the several MCP-adjacent efforts that expose tool catalogues as code. The specific thing to look for is how each keeps a large tool surface out of context, because that is the problem this tool exists to solve.
2026-09-27T07:00:00-05:00
Extra discovery paths
id: extra-discovery-paths
status: proposed
effort: small
Config and scripts are searched upward from the working directory, then the user directory. Missing: a way to add directories to that chain.
--override-paths <dir|file>[,...] tried before everything
--fallback-paths <dir|file>[,...] tried after everything
MCPX_OVERRIDE_PATHS / MCPX_FALLBACK_PATHS
Two flags rather than one plus an ordering rule, because the two uses are genuinely different. An override is “for this invocation, use these”; a fallback is “if nothing local matches, look here too” — a shared team directory, for instance. Collapsing them into one list with a position argument makes every call site state a position it does not care about.
Accepting files and not just directories lets a single script be injected without building a directory around it.
Aliasing a file to a different name was raised alongside this and is
probably the wrong shape. Shadowing already resolves name conflicts by
precedence, and mcpx scripts already shows what is shadowed. An alias adds a
second mechanism for the same job, and a second place to look when a name
resolves to something unexpected. The case it would uniquely solve — wanting
both of two same-named scripts — is better served by naming one of them
differently, which the author controls.
2026-09-27T08:00:00-05:00
Better tool search
id: better-tool-search
status: partial
and --explain are in; typo tolerance (on #1904's shared near-miss
matcher) and concept similarity remain proposed
effort: small to large depending on how far it goes
note: slice 1 shipped 2026-10-30 (#1923) — tags, word-splitting/stemming
Search scoring today: exact 100, prefix 60, tag 50, contains 40, a
split/stemmed name word 40, function path 30, namespace 20, description 10,
all terms required with an any-term fallback. It is fast and has no
dependencies, and it fails on typos and on concepts that share no substring
with the tool name. mcpx search --explain prints the per-term breakdown
behind a hit’s score, and --tag <tag> filters on config tags exactly
(server tags, inherited by its tools, plus per-tool toolTags).
Three tiers, in increasing cost:
Tags. Cheap, exact, and author-controlled. "tags": ["browser", "visual"]
per server, and mcpx search --tag browser. Also lets a config author fix a
bad match without touching the server.
Typo tolerance. Levenshtein distance ≤ 2 against tool and namespace names
only, applied after exact scoring returns nothing. Tool names are short and
few, so this is microseconds and needs no index. screenshto finds
take_screenshot.
Concept similarity. The expensive one. Either an embedding model — a new dependency, a download, and a cache to invalidate — or a hand-built synonym table, which is cheap but needs curating. Worth deferring until tags and typo tolerance are in and still insufficient.
Recommended order: tags, typos, then reassess. Most “I could not find the tool” cases are probably vocabulary, and tags fix vocabulary directly.
2026-09-27T08:00:00-05:00
Types improvements
id: types-improvements
status: proposed
effort: small each
Beyond mcpx types <ns>.<tool>:
--no-instructionsto omit server guidance. Shipped —fffreturns 3,768 characters of it, which more than doubles that namespace’s output.--depth Nto control how far nested object types expand. Deeply nested schemas are most of the cost of a large namespace.--only <tool>[,...]for several tools without the whole namespace.- Output-type rendering.
outputSchemais fetched and ignored; every tool currently returnsPromise<ToolResult>. Rendering it would make results typed rather thanany. --format ts|json|markdown, since the underlying model is structured and only the rendering is TypeScript.
2026-09-27T08:00:00-05:00
mcpx as a server
id: mcpx-as-a-server
status: proposed
effort: medium
Correction, 2026-10-30: the re-export proposed here is still the
open part — but “remote daemon” itself is no longer unbuilt. Since
#1967 the daemon can embed a Tailscale node (tsnet,
internal/daemon/transport_tsnet.go) and be reached by its tailnet
identity, and the settings registry ships the client half
(daemon.endpoint, daemon.address). What remains proposal is the
code-mode MCP server / OpenAPI endpoint re-export above, not remote
reachability as such.
Distinct from importing or emitting OpenAPI: this makes mcpx itself something other tools connect to.
As an MCP server. mcpx already aggregates servers, pools them and caches their schemas. Re-exporting that as one MCP server would let a harness with no shell access reach everything through a single connection, with pooling and scoping applied underneath. Two shapes, and they are different products:
- Passthrough: every underlying tool re-exported under its namespace. Simple, and reintroduces exactly the context problem mcpx exists to avoid.
- Code-mode: two tools,
searchandrun, whereruntakes a script. This is what opencode v2 does internally, and it keeps the context win.
The second is the interesting one, and it makes mcpx usable from Claude Desktop or anything else that speaks MCP but cannot run a shell.
As an OpenAPI endpoint. The daemon already serves HTTP with a stable shape. Publishing an OpenAPI document for it, and optionally binding beyond loopback, would let non-MCP consumers use the same pooling.
Security. Everything today is loopback plus a 0600 unix socket, which is
why there is no authentication. Binding wider needs a token at minimum, scoped
per client, plus a decision about whether a remote caller may name a cwd
— which currently determines repo and worktree keys and would otherwise be
a way to reach another user’s instances.
Recommendation: build the code-mode MCP server first, loopback only, no authentication. Treat remote binding as a separate proposal with its own threat model.
2026-09-27T08:00:00-05:00
MCP protocol coverage
id: mcp-protocol-coverage
status: proposed
effort: medium, several independent pieces
mcpx implements initialize, tools/list, tools/call, resources/list,
resources/read and ping. The protocol defines more, and opencode v2
implements most of it. Meeting parity means, roughly in order of value:
instructionsfrom initialize. Shipped. It was being parsed and discarded;fffalone returns 3.7 KB of tool-selection guidance.prompts/listandprompts/get. Servers ship reusable prompts nobody can currently see.- Progress notifications. A long tool call is opaque today. The protocol
has
notifications/progress; surfacing it would let a script show progress. - Cancellation.
notifications/cancelledis sent on timeout, and an incoming one stops the question it names (docs/protocol.md §4.1). tools/list_changed. Servers announce catalogue changes; mcpx caches schemas until told to refresh, so it can be wrong untilmcpx refresh.- Sampling. Servers can ask the client to run a model. Requires a model binding mcpx does not have and may never want.
- Roots. Tells a server which directories it may touch. Cheap, and pairs
naturally with
worktreescope. - Elicitation. Servers ask the user a question mid-call. Needs a UI story for a tool with no UI; probably means failing the call with a clear message.
- OAuth. Required by some hosted servers. The largest single piece.
2026-09-27T08:00:00-05:00
Chrome and stateful-server ergonomics
id: chrome-and-stateful-server-ergonomics
status: proposed
effort: small each
Chrome is the hardest server to drive well and the lessons generalise.
pageId discovery. The concrete failure: click requires pageId and its
description says only “Targets a specific page by ID”. Note that
close_page does say “Call list_pages to list pages” — so the information
exists in some schemas and not others, which is exactly the inconsistency a
host should paper over. Chrome returns no instructions, so the
instructions work does not fix this. Producer inference is the remaining
answer.
Per-server prelude. A config-supplied snippet prepended to a namespace’s
types output, so a user can state a convention the server does not. Half a
day, fixes pageId immediately, and is a general escape hatch for every server
with bad descriptions.
Recipes. A named script shipped per server — mcpx run chrome:screenshot <url> — turning the common sequence into one call. The generated flow from the
story is already most of one.
--pageIdRouting is on by default and documented as being for concurrent
sessions, which suggests Chrome expects several agents against one instance.
Worth measuring against pooling before assuming pooling is required; pooling
gives real isolation, but if routing is sufficient for a caller who does not
mind sharing cookies, sharing: shared, scope: session may be cheaper.
Generalises to: any server whose required identifiers come from another call. A prelude and a recipe are the two cheap answers, and both are server-agnostic.
2026-09-27T08:00:00-05:00
Log context and enrichment layering
id: log-context-and-enrichment-layering
status: proposed
effort: small
Enrichment happens in one place today: the runner folds a fixed map (session, cwd) into every record arriving from a script. That is enough for the values mcpx knows at launch and nothing else.
Two gaps.
A script cannot bind context. Shipped as log.with. What remains open
is whether bindings should propagate without being passed, which on Node would
mean AsyncLocalStorage and on Deno has no equivalent; a hidden global is the
likely alternative and is worse than an argument. Original text follows.
Every call repeats what does not change:
log.info("scanned {file}", { file, run: runId, phase: "scan" });
Wanted: const scoped = log.with({ run: runId, phase: "scan" }), matching
slog’s Logger.With. Bound attributes merge under per-call ones, so a call
can still override.
Go-side context does not reach records. slog passes a context.Context
into Handler.Handle, which is exactly the seam for request-scoped values,
but nothing is read from it. A daemon handler could stash the session, the
resolved scope key and the server name in the context once, and every record
below it would carry them without being told.
Proposed layering, outermost first, each merging under the next:
- daemon context (server, scope, instance)
- runner enrichment (session, cwd, entry)
- script
log.withbindings - per-call attributes
The rule that makes it predictable is that the innermost wins, because it is the most specific statement about this one record.
2026-09-27T21:00:00-05:00
Reserved attribute names
id: reserved-attribute-names
status: partial
note: shipped (collision handling), proposed (the rest)
A rendered record flattens attributes next to ts, level, msg and
template. A user attribute with one of those names used to be dropped
silently; it is now renamed to attr.<name>, so nothing is lost.
What remains open is whether mcpx should own a namespace instead. An
underscore prefix (_kind, _session) or a dotted one (mcpx.session) would
make the boundary obvious and remove the renaming rule entirely. The argument
against is that every record then carries punctuation for a collision that
almost never happens, and jq '.level' becomes jq '."mcpx.level"'.
Current view: renaming is the better trade while the reserved set is four
names. If it grows – a log store will want id, run, seq – revisit,
because at that point the collisions stop being hypothetical.
2026-09-27T21:00:00-05:00
Level filtering in the script — shipped
id: level-filtering-in-the-script-shipped
status: shipped
The script now knows the threshold and returns before encoding. Measured at 0.026 microseconds per filtered call, against 0.10 for the previous encode-and-write. Source capture moved to a per-level setting at the same time, defaulting to warn and above.
The original proposal follows.
effort: small
A script serialises and writes every record; the threshold is applied in Go.
log.debug() in a loop therefore costs a JSON encode and a write even when
nothing will be printed.
Fix: pass the active level to the script as MCPX_LOG_LEVEL (already passed
for other reasons) and have the client return early below it. About five lines,
and it makes debug logging free to leave in.
The same argument applies harder to source capture, which costs ~4.9 us per call: checking the level first means an unprinted debug record costs nothing rather than a stack trace.
2026-09-27T21:00:00-05:00
Layered script prefixes
id: layered-script-prefixes
status: partial
note: shipped (list + null splice), rejected (ancestor indexing)
mcpx exec generates a whole file, so it can wrap a snippet in lines from
configuration. The question was how a nearer config should combine with a
farther one.
Shipped: a list whose elements are strings or null. A null splices in whatever the setting inherited, at that position:
"script": { "prefix": ["import { h } from '@lib/h.ts';", null] }
A list with no null replaces outright. On the command line the marker is -:
--prefix - --prefix 'const X = 1;' keeps the configured lines and adds one.
This is one concept doing both jobs. Inheritance is explicit and local: you can read a single file and know whether it extends or replaces, without knowing what is above it.
Rejected: integers addressing ancestors. The idea was 0 for the current
inherited value, 1 for the parent’s, 2 for the grandparent’s. It is
rejected because the number means nothing without the chain:
- Chain depth varies by where a config was found. The same file has different
ancestors in a repo, under
~/.config, and under/etc. - Moving a directory silently changes what
2refers to. - It cannot be read locally.
["mine", 2]is unresolvable without reconstructing every file above it, which is the opposite of what a config should require. - The case it uniquely serves – “skip my parent but take my grandparent” – has not come up, and if it did, naming the thing wanted would be clearer than counting hops to it.
Null covers extend-or-replace, which is the distinction people actually make.
Corrected: prefixes do apply to file scripts, for effects rather than bindings. The first version rejected them outright, which conflated two claims. A prefix cannot bind anything inside a module, because ESM gives it its own scope. It can perfectly well act before and after one, because mcpx already generates a launcher around it.
So the launcher imports the module dynamically:
const script = { path, name, args, export };
<prefix lines> // runs first, for real
const mod = await import("./user.ts");
...
finally { <suffix lines> } // runs even when the body throws
A static import would be hoisted and evaluate the module before any prefix line whatever the source order, which is why the dynamic form is required rather than merely tidier.
Prefix lines see script (path, name, args, export). Suffix lines also see
result (ok, value, error, ms), and run in a finally, so cleanup happens on
the failure path too. Both can set globals the script will read, which is the
monkeypatching seam.
What remains impossible is a prefix declaring a const the script can see.
That is ESM, not a limitation worth fighting, and the tests assert it stays
impossible so nobody comes to depend on an accident.
A snippet is different: mcpx exec generates the whole file, so its prefix
shares scope and can declare bindings the snippet uses.
2026-09-27T22:30:00-05:00
Launcher as a template with placeholders
id: launcher-as-a-template-with-placeholders
status: proposed
effort: medium
The launcher is a fmt.Sprintf with five named phase slots. That is enough
for “run something here”, and not enough for “reorder this” or “wrap the entry
in my own try”. The next step is a real template.
Proposed: the launcher ships as a template file with named placeholders, and configuration may both fill them and reference them:
@globals @before @prefix @import @entry @onSuccess @onError @suffix
A phase body could then say @prefix to splice another phase in, which is the
mechanism behind the interesting cases: running a prefix twice, or moving the
import after a guard.
Two rules make it safe:
- References form a DAG. A cycle is rejected at generation time with the path that closed it, not discovered as a hang.
- Each placeholder resolves once. Referencing one twice is an error unless
allowRepeatnames it, because the common case of a double reference is a mistake, and the rare deliberate one should have to say so.
Also wanted, and cheap: a minimal mode that emits the module import and
nothing else – no globals, no console capture, no phases – for a script that
wants the runtime and none of the harness.
The argument for doing this at all is that a launcher which is only half-configurable invites someone copying it out and maintaining a fork. The argument against doing it now is that five named phases have not yet been shown insufficient.
2026-09-28T01:00:00-05:00
Structured stack traces
id: structured-stack-traces
status: shipped
Error.prepareStackTrace is V8-specific and not in any standard, and was
verified to work in Deno, Node and Bun. It hands back CallSite objects instead
of a formatted string, so frames arrive as data:
{ "function": "boom", "file": ".../script.ts", "line": 9, "column": 22, "async": false }
Parsing the string form was the alternative and is strictly worse: it loses the async and native flags, and breaks on any path containing the characters the format uses as delimiters.
One trap worth recording. V8 memoises whatever prepareStackTrace returned
the first time .stack is read. Asking for frames destroys the string;
asking for the string destroys the frames. Both are wanted – the string for a
human, the frames for a query – so frames are captured and the string is
rendered from them. The first two attempts at this each lost one form, and a
test now asserts both survive.
captureFrames(skip, limit) and errorFrames(err) are exposed to scripts.
2026-09-28T01:00:00-05:00
Defaults as an embedded layer
id: defaults-as-an-embedded-layer
status: shipped
Every default now lives in internal/defaults/defaults.json, embedded with
go:embed, and is printable with mcpx config --defaults.
Before this they were four const blocks in four packages plus two literals
in a ticker. That is the arrangement where “what is the idle timeout” takes a
grep across the tree and still misses one – which is exactly what happened:
a test asserting no package redeclares a default timeout found a five-minute
literal in the daemon’s save ticker that three readings had walked past.
internal/defaults is a leaf with no mcpx imports, so logging can read it
without depending on config. Parsing happens in a variable initialiser
rather than init(), because Go evaluates package variables first and the
init() version silently handed out zero values.
DisallowUnknownFields is set, so a misspelled key in defaults.json is a
startup panic instead of a zero value discovered three layers down.
2026-09-28T02:00:00-05:00
Console fidelity
id: console-fidelity
status: shipped
All 25 of Deno’s console members are handled. The ones with a level become
that level, log still reaches stdout, trace carries structured frames,
clear resets indentation without erasing a durable log, and the devtools
markers (profile, profileEnd, timeStamp) become file-only records rather
than vanishing.
Two fidelity details that a test caught rather than review:
- Names are preserved.
console.info.nameis still"info". Five methods had been assigned directly instead of through the helper that sets it. - Messages use the runtime’s own inspect, so
console.info({a:1})reads{ a: 1 }exactly as it would have unwrapped. The JSON form is redundant – the structured value is already inargs.
console.createTask() throws when called bare. So does Deno’s. The test
compares against the unwrapped console rather than asserting nothing throws,
because matching the original includes matching its failures.
2026-09-28T02:00:00-05:00
Protocol methods still missing
id: protocol-methods-still-missing
status: implemented
note: audited 2026-09-29, method by method against all three schemas
An earlier audit assembled a feature list from the specification’s index page
and probed a few URLs. That was not good enough: doing it properly – diffing
every method: literal in schema.ts for 2025-06-18, 2025-11-25 and
2026-07-28 against what mcpx sends and serves – found more.
Still missing, in rough order of what would be noticed:
resources/subscribe / unsubscribe / notifications/resources/updated.
A client watching a resource for changes. Present in every revision. mcpx
declares subscribe: false, so it is honest, but a server publishing live
data cannot tell mcpx when it changes.
resources/templates/list when serving. mcpx consumes templates from
upstream servers and does not offer them onward, so a templated resource
becomes invisible one hop down.
sampling/createMessage. A server asking the client to run a model
completion. mcpx is not a model host, so the honest implementation is a
pass-through to whatever is driving it – the same shape elicitation uses.
Worth building when something asks. Shipped, then found unreachable: sampling was
never declared, because the handler was installed after the handshake that
declares capabilities. Fixed 2026-09-29, with roots, which the same ordering
had broken.
notifications/elicitation/complete (2025-11-25+). A url-mode
elicitation finishing out of band. Without it, a server that sends somebody
to a browser has no way to say the flow completed, and the caller waits for
the TTL.
subscriptions/listen and notifications/subscriptions/acknowledged
(2026-07-28). The modern revisions generalised subscriptions into one
mechanism. This is also the answer to “can a client subscribe to the
daemon”, which mcpx currently has no way to do at all.
tasks/* (2025-11-25 core, an extension in 2026-07-28). Long-running
work with polling and durable handles. An extension now, so optional by
definition, but it is what a genuinely slow tool should use.
notifications/roots/list_changed. mcpx declares listChanged: false,
so nothing is promised, but a root set that can change is more useful than
one fixed at startup.
2026-09-29T00:30:00-05:00
Suggested order
Shipped since this document was written: sharing/scope split, pid-scoped
lifetime, realpath-canonical keys, .config/mcpx lookup, named scripts, path
globals.
ServerShipped. Worth recording that it did not do what it was ranked first for: Chrome returns no instructions, soinstructionssurfaced intypes.pageIdis untouched. It turned out valuable for a different reason —fffreturns 3.7 KB of tool-selection guidance that was being thrown away.Shipped. 17,851 to 923 characters for one tool, and the prelude states where amcpx types <ns>.<tool>, plus a per-server prelude.pageIdcomes from, which is what instructions failed to supply.Config merge withShipped.--sources.Script contract:Shipped.log, default-export entry,run --json.Structured logging.Shipped, minus the on-disk store andmcpx log/mcpx stats, which still want a JSONL file and a call-grained index.Shipped, withmcpx catalog --budget.--bias.- Named sessions,
mcpx session ls, per-scope keepalive. - Scope policy.
- Harness integration and
mcpx install. - Packaging and
mcpx service install. - Ping-based health reaping.
- Producer inference for parameter hints.
- Per-instance argument templating and allocation.
- Protocol adapter interface, then OpenAPI import, then OpenAPI emit.
Deliberately unscheduled: remote addressing, Rust, rename.
Protocol: every revision, both directions
id: protocol-every-revision-both-directions
since: pre-release
created: 2026-09-29T20:00:00-05:00
last-updated: 2026-09-30T16:00:00-05:00
status: standard
tags: area:protocol
description: what each revision defines, what mcpx accepts, what it sends,
and why there is one HTTP server rather than two.
mcpx speaks all five revisions of MCP and sits in the middle of two of
everything: two eras, two directions, two mechanisms for the same question.
The rules that hold it together are short, and this document is mostly the
table that falls out of them. The per-revision detail and its tests are in
spec/; what the specification itself asks, independent of
mcpx, is spec/server-obligations.md and
spec/client-obligations.md.
Accept liberally, send conservatively. A server implementing a method beyond what the negotiated revision defines is not violating anything — it withholds nothing the revision requires, and a client that reached for the method would otherwise be told mcpx cannot do a thing it can. Sending is the opposite: a field, a notification or a server-initiated request that the peer did not negotiate and did not declare is a lie about what was agreed, and the failure is silent. Either the peer ignores it, or it rejects the frame, and nothing says which.
So mcpx accepts tasks/* from a 2025-06-18 client and subscriptions/listen
from a legacy one — and it will not put structuredContent in front of a
2025-03-26 client, will not stamp resultType on a reply to anything but
2026-07-28, and will never send elicitation/create to a client that did not
declare elicitation.
The rule stops at removal (§2.1): a method a revision removed is method-not-found to a peer on that revision.
Since the first version of this page, every sentence in it has been checked
against a schema validator (internal/mcpspec, the five official schemas,
strict mode) and the official conformance suite
(spec/official-suite.md). Several were wrong, and
the corrections are why this is increment 3.
1. The revisions
| era | handshake | version carried | mcpx serves | mcpx speaks upstream | |
|---|---|---|---|---|---|
2024-11-05 | legacy | initialize | once | yes, over stdio and Streamable HTTP; its HTTP+SSE server transport is not hosted | when the server answers with it; HTTP+SSE as the last fallback |
2025-03-26 | legacy | initialize | once | yes | when the server answers with it |
2025-06-18 | legacy | initialize | once | yes | when the server answers with it |
2025-11-25 | legacy | initialize | once | yes | offered on every initialize |
2026-07-28 | modern | none | _meta, every request | yes | probed first |
The split is the thing everything else follows from. A legacy implementation negotiates once and can send its peer a request at any time afterwards. A modern one has no handshake, restates its version and capabilities on every request, and — as a server — has no channel at all on which to send a request of its own. The matrix is unforgiving: modern against legacy fails, legacy against modern fails, only a dual-era implementation bridges them. mcpx is dual-era in both directions.
Upstream, mcpx probes modern first and remembers the answer: the 2026-07-28
versioning page makes era “a property of the server”, and prescribes that
order for a dual-era client. The era is cached per server configuration, in
memory and in <state>/upstream-eras.json, so the probe costs one round trip
per configuration rather than one per start. It used to probe legacy first,
on the argument that nearly every server is legacy; the cache removed the
cost that argument was about. spec/era-probe.md has the
order, the fallbacks and the cache.
Downstream, Oldest is 2024-11-05: what a request that declared nothing is
spelled for, because every shape that revision defines is understood by
everything newer. Guessing downward is recoverable; guessing upward hands a
peer a frame it cannot parse. A headerless Streamable HTTP request, though,
is judged as 2025-03-26 (Headerless): 2024-11-05 has no Streamable HTTP, so
nothing arriving on /mcp can be a 2024-11-05 request that omitted the
header, and the later transport pages say to assume 2025-03-26.
1.1 When revisions conflict: spec.precedence and spec.lenient (#307)
Most differences between revisions are settled by the revision the peer
states. Where two revisions require incompatible behaviour and the peer’s
version does not settle it, two settings decide
(configuration.md):
spec.precedence— the revisions in the order their rules win, first wins. Default: every revision mcpx serves, newest first (2026-07-28,2025-11-25,2025-06-18,2025-03-26,2024-11-05). A partial list is completed with the rest, newest first.--mcp-spec <rev>/MCPX_MCP_SPEC(spec.first) moves one revision to the front.spec.lenient— revisions mcpx does not hold strictly. Empty by default: every revision is strict.
An unknown revision in any of the three is refused with the list of valid
ones. The daemon reports the effective policy under spec in
GET /v1/protocol; mcpx settings shows each setting and where it came from.
Code consults it through internal/spec (First, Strict, and the
predicates AtOrAfter, Before, Only, Between).
Two behaviours consult it today:
| behaviour | default | changed by |
|---|---|---|
progress message relayed to a 2024-11-05 client (whose schema has no such field) | sent | stripped only when 2024-11-05 is first and strict: --mcp-spec 2024-11-05 |
| a request from a 2026-07-28 upstream server (that revision has none; its stdio page says the client MUST NOT answer) | dropped | answered when spec.lenient includes 2026-07-28 |
Each other conflict in spec/revision-conflicts.md
carries a disposition there — peer-settled, precedence-governed, a recorded
reading, or undisposed with its open question named. Only the
precedence-governed ones are decided by these settings.
internal/mcpserver/revisions.go holds the floors, ceilings and removals as
data, and GET /v1/protocol serves them. A matrix in a document and a matrix
in code agree on the day they are written and never again, so the document
defers to the endpoint.
2. mcpx as a server
What a host connected to mcpx gets, over mcpx serve (stdio) or the daemon’s
/mcp (Streamable HTTP).
2.1 Methods
| method | defined in | mcpx accepts | mcpx answers |
|---|---|---|---|
initialize | legacy | legacy peers | the version asked for if mcpx speaks it, otherwise the latest legacy version it speaks — a result, not an error, as every legacy lifecycle page requires |
server/discover | 2026-07-28 | always | supportedVersions (newest first), capabilities, instructions; serverInfo in _meta; ttlMs/cacheScope |
ping | legacy | legacy peers | {} |
tools/list | all | always | paginated (mcp.pageSize), opaque cursor, a deterministic order |
tools/call | all | always | §2.3 and §3; an unknown tool is -32602 |
prompts/list, prompts/get | all | always | not found is -32602; an upstream failure -32603 |
resources/list, resources/read, resources/templates/list | all | always | binary contents as blob; not found is -32002 (legacy) or -32602 (2026), both with data.uri |
resources/subscribe, resources/unsubscribe | legacy | legacy peers | {}, always; subscribes upstream where the owning server can deliver, and otherwise publishes a warning event (§4.3) |
subscriptions/listen | 2026-07-28 | any era | an acknowledgement, then only the notifications the filter asked for, each tagged with the subscription id |
completion/complete | all | always | forwarded to the server that owns the ref, as /v1/complete does, with context.arguments; a 2024-11-05 upstream is asked without a capability (that revision had none); unknown ref -32602, upstream failure -32603 |
logging/setLevel | legacy | legacy peers | {}; sets the level of upstream log messages relayed to this connection (§4.4) |
tasks/get, tasks/list, tasks/result, tasks/cancel | 2025-11-25 core | any legacy peer | the core shapes; a task is visible only to the connection that started it |
tasks/get, tasks/update, tasks/cancel | 2026-07-28 extension | modern peers that declared io.modelcontextprotocol/tasks on the request; -32021 with data.requiredCapabilities (HTTP 400) otherwise | the extension’s shapes; tasks/list and tasks/result are -32601, as the extension says |
skills/list, skills/get | skills extension (SEP-2640) | always, unless the server offers only contributed tools | mcpx’s own skills (plugin/opencode/skills, embedded at build), each file at skill://mcpx/<name>/<file> and also listed and readable as a resource; an unknown URI is -32602. Upstreams’ skills are not relayed: in-name-only.md |
resources/directory/read | skills extension, directoryRead | wherever skills/list is | the direct children of a directory inside one of mcpx’s skills: files as resources/list lists them, subdirectories as inode/directory; paginated. A file or an unknown URI is -32602 |
notifications/cancelled | all | always | cancels the request it names — its context, and so its upstream call — and withholds the reply |
Two rows are the “accept liberally” rule doing visible work:
subscriptions/listen from a legacy client and core tasks/* from any legacy
client. mcpx answers both. It declares neither outside the revision that
defines it, because a declaration is a promise about the revision in force.
The rule stops at removal. Offering a client a method its revision never
had withholds nothing. Answering one its revision removed is different: the
removal is the specification naming a replacement, and 2026-07-28 requires
method-not-found — “If the server does not implement the requested RPC method,
it MUST respond with 404 Not Found and a JSON-RPC error with code -32601”.
The 404 is load-bearing rather than decorative: it is how a dual-era client
tells a modern server from a legacy endpoint that simply is not there. So
initialize, ping, logging/setLevel, resources/subscribe and
resources/unsubscribe are -32601 to a 2026-07-28 peer and unchanged for
every older one.
internal/mcpserver/revisions.go holds the list as removedIn, keyed to the
same feature ceilings the rest of the matrix uses. mcpx answered all of them to
everyone until the official suite scored it (#250).
Tasks, per era. 2025-11-25 core tasks are client-directed: a task field
on tools/call, {task} back at once, tasks/result blocks. mcpx honours the
field from any legacy client. The 2026-07-28 extension is server-directed and
forbids the other way: the task field is ignored, and a client that declared
io.modelcontextprotocol/tasks on the request gets a CreateTaskResult
(resultType: "task", ttlMs, pollIntervalMs). Which calls become tasks
follows the tool’s execution.taskSupport: a task-supporting tool’s call is a
task once it has run 250 ms, one that declared nothing only after
protoMessages.taskAfter, and a required one is -32021 to a client
without the extension. tasks/get inlines the result. Questions a
task-supporting call asks at once go inline as input_required on the
original request, before any task exists; ones it asks later park the task in
input_required with inputRequests, answered through tasks/update.
A legacy task shows
input_required while one of its questions is out, and the question carries
the related-task _meta, as does the tasks/result response. A legacy
task-augmented call to a tool that declares no execution.taskSupport is
refused -32601, as 2025-11-25 asks: of mcpx’s own tools only mcpx_call and
mcpx_exec declare "optional" (to 2025-11-25 clients only; 2026-07-28’s
Tool has no execution). A 2026-07-28 client that names tasks in a
subscriptions/listen’s taskIds gets the ones it can see back in the
acknowledgement, then notifications/tasks on that stream – the task’s
current state at once, then every change, each the DetailedTask tasks/get
would answer. spec/messages.md has the
rest.
Unknown tools are a protocol error. tools/call naming a tool mcpx does
not have is -32602 (“no tool named …”), as every revision’s tools page lists
it. It used to be an isError result, which told the model to retry a call
that could never work (#284).
2.2 Capabilities mcpx declares
| capability | 2024-11-05 | 2025-03-26 | 2025-06-18 | 2025-11-25 | 2026-07-28 |
|---|---|---|---|---|---|
tools.listChanged | true in pass-through mode when the connection can push, else false | same | same | same | same |
resources.subscribe, .listChanged | when the connection can push | same | same | same | same |
prompts.listChanged | when the connection can push | same | same | same | same |
completions | — (the revision has no such capability; the method is answered anyway) | ✓ | ✓ | ✓ | ✓ |
logging | ✓ | ✓ | ✓ | ✓ | — (the level travels in each request’s _meta) |
tasks (core) | — | — | — | ✓ | — |
extensions["io.modelcontextprotocol/tasks"] | — | — | — | — | ✓ |
extensions["io.modelcontextprotocol/skills"] | — | — | — | — | {"directoryRead": true} |
tools.listChangedis false unless mcpx passes tools through. mcpx’s own tool list is fixed when its server is built. It used to be declared and fed by upstream tool changes, which are not changes to this list: a client re-listed on every one and got the same tools back. In pass-through mode (--passthrough) the list is the upstreams’, so an upstream’stools/list_changedis relayed – unsolicited to a legacy client, and on asubscriptions/listenthat asked fortoolsListChangedto a modern one.loggingis declared to legacy clients. The capability means “this server sends log messages”: mcpx relays its upstreams’notifications/messageto a client that set a level, during that client’s calls (§4.4, #212). 2026-07-28 has no such capability.- “Can push” is a property of the connection, not of the server. A stdio
connection can. A legacy HTTP session can, through its GET stream (which
used to be missing, so both were declared and never delivered). A legacy
POST outside a session cannot, and gets neither. A 2026-07-28 client receives
these only on a
subscriptions/listenstream, which is its own request’s response, so any transport that can stream a response can deliver them. - Core
tasksonly to 2025-11-25,extensionsonly to 2026-07-28. Both were sent to both; 2025-11-25’s schema has noextensionsfield, and the extension’s SEP says a server must not keep advertising core tasks under a revision that has the extension.
2.3 Result shapes, and what is downgraded
Everything is built in the newest shape and spelled down once, at the edge, in
downgrade(). An upstream’s tools/call result (through mcpx_call or a
pass-through tool) and prompts/get result are carried as the upstream sent
them – isError, every content block, structuredContent and _meta – with
resource URIs rewritten to ones /mcp can read; listed tools, resources,
templates and prompts keep their optional fields. Building several shapes and choosing between them is how the
shapes drift apart.
| carried | defined from | to an older client |
|---|---|---|
structuredContent | 2025-06-18 | removed, and rendered into the content array as text — the data survives, in a vocabulary the client has |
resource_link block | 2025-06-18 | becomes an embedded resource with a text/plain label naming the URI, which keeps the URI machine-readable where text would not |
audio block | 2025-03-26 | a text block describing it, for 2024-11-05 |
_meta on listed tools, resources, templates, prompts and read contents; annotations.lastModified | 2025-06-18 | stripped |
tool annotations, the completions capability | 2025-03-26 | stripped for 2024-11-05 |
title | 2025-06-18 | stripped |
icons | 2025-11-25 | stripped |
resultType | 2026-07-28 | removed — sending it says mcpx is speaking a revision it is not |
ttlMs, cacheScope, serverInfo in _meta | 2026-07-28 | removed; serverInfo travels in the initialize result instead |
inputRequests / requestState | 2026-07-28 | never sent; a legacy client is asked on the wire instead |
elicitationId | 2025-11-25 | never sent to 2026-07-28 (it used to leak through inputRequests); minted for a 2025-11-25 url-mode question an upstream sent without one |
A revision that defines all of them pays nothing: the function returns the result untouched. An older one gets a copy, never an edit, because a task’s stored result is handed out more than once and rewriting it in place would downgrade it permanently for whoever reads it next.
The 2026-07-28 envelope. Every modern result carries resultType
(complete unless it is input_required or task) and
_meta["io.modelcontextprotocol/serverInfo"]. The cacheable ones carry the
REQUIRED ttlMs and cacheScope: public for server/discover,
tools/list, prompts/list and resources/templates/list, which follow
configuration rather than the caller; private for resources/list, which
includes the caller’s own session artifacts, and resources/read. The ages
are protoMessages.listMaxAge (1m) and protoMessages.readMaxAge (0s). An
input_required result carries no hints — it is an interim answer.
The strict schema sweep (internal/mcpserver/schema_sweep_test.go) validates
every frame mcpx sends against each revision’s schema.json and rejects a
property the revision never defined. It is how the discover shape, the missing
cache hints, the listen acknowledgement’s shape, extensions sent to
2025-11-25 and a uri where resources/templates/list needed uriTemplate
(in every revision) were found. spec/messages.md has the
per-revision list.
2.4 Transport and identity
The rules are in spec/transport.md; what matters for
the rest of this page:
- stdio runs requests concurrently, each under its own context, so a
notifications/cancelledcan be read while the request it names is running, and cancelling it reaches the upstream call. Notifications andinitializestay in line. At EOF, requests in flight gettransport.stdioDrainto answer. - Streamable HTTP, legacy: only
initializemintsMcp-Session-Id; an unknown or terminated session is 404;DELETEends one; a session has a GET stream for what mcpx pushes; within a session a dropped connection is not a cancellation, andnotifications/cancelledis. - Streamable HTTP, 2026-07-28:
MCP-Protocol-Version,Mcp-MethodandMcp-Nameare required and must agree with the body (-32020, 400); a missing_metais-32602(400); the HTTP status is a function of the error code; closing the stream is cancellation; no sessions, none minted, none echoed. Originis validated on every request: loopback, the daemon’s own address andtransport.allowedOrigins; anything else is 403.- Who a request is from is decided per connection
(
spec/identity.md):_meta["dev.mcpx/session"]if the client names one; otherwise the process on stdio, theMcp-Session-Idon a legacy HTTP session, and nothing — its own per-request scope — for a 2026-07-28 request. Every/mcpclient used to share the daemon’s pid and cwd, and so one stateful instance and one set of artifacts.
MCP over ssh
An MCP host that can only spawn a process gets a remote daemon the same way the CLI does: with ssh as the transport and no streamable-HTTP endpoint to configure.
{ "mcpServers": { "mcpx": { "command": "ssh", "args": ["buildbox", "mcpx", "serve", "--transport", "stdio"] } } }
mcpx serve on the remote host is a stdio shim over the daemon there, so
the tool calls, elicitations and artifact bytes all belong to that daemon. No
port is opened and the only credential is the ssh one. For a client that sets
a daemon endpoint instead, ssh://buildbox is the shorter form and the client
starts ssh itself — see remote.md.
2.5 Pass-through: upstreams under their own names
By default mcpx is a gateway: a small tool set (mcpx_call, mcpx_exec,
discovery), prompts as <namespace>_<prompt>, resources as
mcpx://<namespace>/<uri>. mcp.passthrough (--passthrough <server> on
mcpx serve and mcpx daemon, MCPX_MCP_PASSTHROUGH) names a configured
server – or several, comma-separated, --passthrough demo,tasks – whose
surface is offered as itself instead:
tools/listcarries its tools under their own names, ahead of the gateway’s;tools/callon one of them is forwarded and its result returned verbatim — images, audio, embedded resources,structuredContentandisErrorintact; a JSON-RPC error the upstream answers with is relayed as that error, not turned into anisErrorresult. Itsexecution.taskSupportis carried ontools/listand decides whether a call runs as a task (“Tasks, per era”, §2.1). Its questions reach the calling client exactly asmcpx_call’s do (§3), with the upstream’s owninputRequestskeys where it asked in a 2026-07-28 result.prompts/listnames its prompts without the prefix;prompts/getreturns the upstream’s result verbatim.resources/list,resources/templates/listandresources/readuse its own URIs, as do subscriptions andcompletion/completerefs. Anmcpx://URI still reaches every other server and mcpx’s artifacts.- Its declaration, not mcpx’s. A 2026-07-28 client’s
clientCapabilitiesgo to the upstream with the call, narrowed from mcpx’s own: mcpx never declares to the upstream a capability its client did not (a tool that needs sampling is refused rather than run on mcpx’s say-so). An upstream JSON-RPC error —-32021with itsrequiredCapabilities,-32602for a name it does not know — is returned as that error, HTTP 400 included, not as a tool result withisError. - Names it does not list. A
tools/callfor a name that is neither the upstream’s listed tool nor a gateway tool is forwarded; the upstream says whether it exists. A server may answer to diagnostic or hidden tools it does not list. - Roots. A
roots/listthe upstream asks in aninput_requiredround is put to a client that declaredroots, as an elicitation is; for one that did not, mcpx answers with its own configured roots. - Several upstreams are merged into one surface. A tool or prompt name two
of them offer is refused –
tools/listorprompts/listfails with an error naming both – rather than handed to either, since the loser would be unreachable under the name the client was shown. A bare resource URI goes to the first upstream, in the order given, that lists it as a resource or offers a template it falls under; one none of them lists goes to the first. - Collisions with the gateway: the upstream wins. A gateway tool whose name the upstream
also uses is neither listed nor callable over MCP (it stays on the CLI and
/v1). The upstream is the server in this mode, and a client must be able to call every nametools/listshows and get that tool.
Every other server stays reachable through the gateway tools. Not carried
over: a pass-through tool’s outputSchema, title and annotations (the
daemon’s catalogue keeps name, description, input schema and execution
only). The upstream’s progress and log messages during a call are relayed
(§4.4). scripts/conformance.sh runs the official suite’s own fixture server
this way, merged with internal/testsupport/taskmcp, mcpx’s fixture for the
suite’s tasks-extension scenarios, which the suite’s server does not define.
3. A server asks a question
This is the part with two mechanisms for one thing, and the reason the rest of the design is shaped the way it is.
When mcpx proxies a call and the upstream server elicits or samples, the
question becomes a row in the broker: an identity, a deadline, a routing
decision (docs/elicitation.md). What the broker could not do alone is ask
mcpx’s own client — the agent or editor that made the call, which is very
often the only thing that knows the answer.
It does now, native-first and broker-backed (proto.native). The broker
remains the store and the fallback; the client is asked first when it said it
could answer.
3.1 Which client gets asked
| the client declared | version | what happens |
|---|---|---|
| nothing | any | broker only |
elicitation | 2024-11-05, 2025-03-26 | broker only; those revisions have no elicitation |
elicitation | 2025-06-18 | asked, form mode only, mode and elicitationId stripped |
elicitation (any shape) | 2025-11-25, 2026-07-28 | asked in form mode; a url-mode question stays with the broker |
elicitation.url | 2025-11-25, 2026-07-28 | asked in either mode |
sampling | any | asked for sampling/createMessage only |
Declaring one is not declaring the other. A sampling-only client is never sent
an elicitation, and vice versa. Anything mcpx may not send this client is left
with the broker and its default audience, so nothing is lost by a client’s
narrowness — it just does not get to answer. Two gates are coarser than they
should be (#210): any elicitation declaration counts as form mode, though a
2025-11-25 client that declared only url has not offered forms; and
sampling is not checked for sampling.tools or sampling.context.
Why a non-declaring 2026-07-28 client never gets -32021 for a tool that
asks. 2026-07-28 basic/index says a server “MUST NOT rely on capabilities
the client has not declared” and “if processing a request requires a
capability the client did not include … MUST return” -32021
(MissingRequiredClientCapabilityError). The error is conditional on
requiring the capability, and mcpx never does: a question it may not send
the client goes to the broker, which is a durable queue answerable by any
consumer (mcpx elicit, /v1, the plugin, a human) whether or not one is
listening when it is asked – there is no state in which the broker has no
audience, only one in which nobody has answered yet, which ends in the
question’s own timeout. mcpx also cannot know in advance: whether an upstream
tool elicits is decided by the upstream mid-call, so a pre-dispatch -32021
would be a guess. The official suite’s
sep-2575-server-rejects-undeclared-capability assumes a fixture tool
(test_missing_capability) that mcpx does not publish, and so does not test
this path (#252 item 1). The extension’s own methods are different:
tasks/get, tasks/update and tasks/cancel from a client that did not
declare io.modelcontextprotocol/tasks answer -32021 with
data.requiredCapabilities (HTTP 400), because there the capability is the
whole request; so does a subscriptions/listen asking for task notifications
without it.
The message is rewritten to name the originator: “github (via mcpx) asks: which repository?”. A client asked “are you sure?” with no idea who is asking cannot answer it.
3.2 Legacy: a request on the wire
The server sends elicitation/create while the client’s tools/call is still
open, and waits.
Over stdio this is straightforward — the pipe is the session, it stays open for the life of the process, and mcpx’s own receive loop routes inbound frames by shape rather than by id. Requests run on their own goroutines, so the answer can arrive while the request that asked is blocked on it.
Over Streamable HTTP it is not, and this is the part worth recording. The POST is the only channel, and it is the thing waiting for the answer. So:
- mcpx issues
Mcp-Session-Idoninitialize. - A request that has to ask turns its POST response into an event stream —
lazily, so a request that asks nothing still gets a plain JSON object — and
writes
elicitation/createas an SSE frame. - The client answers with a separate POST carrying the same session
header, whose body is a JSON-RPC response. mcpx routes it to the request
waiting on that session and answers
202. - The original stream carries the final result and closes.
A request that asks nothing but is slow — a pass-through call waiting out
pool.callTimeout on an upstream that went quiet — also becomes an event
stream once it has been silent for transport.sseKeepAlive, carrying a comment
each interval until the answer, when the client’s Accept offered
text/event-stream. Before, such a POST got no bytes at all, not even headers,
for up to two minutes, and a client or proxy with an idle timeout gave up on a
call that was still running. The stream carries no event ids: mcpx does not
offer SEP-1699 resumption, and an id would promise a replay it cannot make.
A legacy POST outside any session cannot be asked anything: its answer would arrive on a POST nothing can route back. Its questions stay with the broker.
That is the reason Conn exists: a subscription filter, a push function and a
client’s declared capabilities used to live on the Server, which was correct
while stdio was the only transport and wrong the moment one server answered
several HTTP clients — those fields decide whose client gets asked a
question.
Server ids are negative on the wire. JSON-RPC only requires uniqueness within a direction, but several clients key one table by id, and they would otherwise see mcpx’s request answer their own.
3.3 Modern: a result the client retries
A 2026-07-28 server has no connection to send a request on. It answers
resultType: "input_required" with a map of inputRequests and an opaque
requestState; the client answers each and sends the same request again
with inputResponses and the state attached. Only tools/call, prompts/get
and resources/read may do this.
inputResponses sent on a request that resumes nothing — a client that knows
what will be asked and answers up front — are held until the upstream asks, and
given to the questions whose keys they match. Keys that match nothing are
ignored, as SEP-2322 asks of information a server does not recognise.
Which means the call has already returned by the time the answer exists. The
upstream call therefore cannot be tied to the client’s request — if it were,
there would be nothing left to resume. It runs as a daemon task
(internal/tasks, the same store /v1/call’s task option uses, collectable
from the same /v1/tasks/{id}), and questions are correlated to it.
requestState is signed and bound to the request. The specification says
the client must treat it as opaque, and that it SHOULD be tamper-evident and
bound to the originating method and parameters, an expiry and — where there is
one — the principal. mcpx’s is base64(payload).HMAC-SHA256 under a key
generated per daemon process and never persisted, carrying the call id, a
binding (the method and a SHA-256 of the parameters without _meta,
inputResponses and requestState) and an expiry (proto.stateTTL). A token
that verifies but was issued for another request is refused with a message
that says so — the difference between a confused client and somebody
replaying matters to whoever reads the log.
It used to be bound to the connection: an Mcp-Session-Id, which 2026-07-28
does not have. mcpx minted one on server/discover to have something to bind
to, a conformant client never sent it back, and every such client got
“still asking for input after 8 rounds” within milliseconds. Binding to the
request needs nothing from the transport. mcpx has no authenticated principal
to add, so a state is replayable by anyone who holds it, on the same request,
within its TTL.
3.4 Correlation: which call asked?
A question arrives on a connection, not on a call. The server sends
elicitation/create down the same pipe it is answering tools/call on, and
nothing in the frame says which call it belongs to.
What mcpx knows is that one pooled instance serves one scope key. So a
question arriving on (server, key) belongs to whatever is calling on
(server, key), and the pool now tells the handler which key the connection
serves.
When two calls share a key — which a shared server allows — the attribution
is genuinely ambiguous. mcpx makes none, and the question goes to the broker.
Guessing would hand one client’s credential prompt to another client, and that
is not a trade worth making for a convenience.
The rule is one comparison. The ask table holds the upstream requests a
call owns; the pool counts every upstream request in flight on the key,
whoever made it (/v1/call, exec, the CLI, the plugin). A call owns a question
when all the table’s entries on the key are its own and their number equals
the pool’s count — nobody else is on the connection. Until #229 the table
alone decided, and an interruptible call sharing a key with somebody’s
ordinary mcpx call was handed that caller’s question. One window remains: an
entry is registered an instant before its request is counted, so a stranger’s
question arriving in exactly that instant can still be misattributed.
A script (mcpx_exec) is one call that makes many. It runs as an
interruptible call too (/v1/ask kind exec), and its run id is chosen and
registered before the script starts. The generated client sends that id as
X-Mcpx-Run on every /v1/call, so each upstream call the script makes joins
the table as a member of the run. A question on a key whose in-flight calls all
belong to one run is that run’s — including calls the script makes in
parallel. Two runs, or a run and anyone else, on one key: ambiguous, broker.
When the run ends (finished, timed out, abandoned) its membership is removed
everywhere, so a straggling upstream call killed with the script cannot pin a
question to a call nobody polls; that question stays with the broker.
A script that asks more than proto.askRounds questions in one legacy request
is abandoned like any other call — scripts that elicit in a loop hit it first.
The script’s own exec.timeout (120s) is usually shorter than
proto.askTimeout and a person answering eats into it; set timeoutSec on
the tool call for a script that expects to ask.
3.5 Bounds
proto.askTimeout(10m) — how long one client request may be held. A legacy client is blocked for all of it, so it has to sit inside whatever that client’s own timeout is. A modern one is not blocked at all.proto.askRounds(8) — how many times one call may come back asking. A server that never stops asking is broken or adversarial. For a 2026-07-28 client each round is a new request, so the count rides in the signedrequestStateand holds across the retries; it used to restart at zero on every one (#201).proto.stateTTL(30m) — how long arequestStatemay be resumed with.proto.askTTL(15m) — two clocks for a parked/v1/call: the waiting deadline (a call still waiting for an answer after 15m is cancelled) and, once the call finishes, how long its result is kept, measured from when it finishes.pool.callTimeoutpauses while a question is pending on the instance (#229). It used to kill a call while a person was answering it, which made the three settings above promises the pool did not keep. The precedence of all five timeouts is written down ininternal/pool/budget.go. A caller may request a shorter value for a single call (--pool-call-timeout, carried as a call setting): the daemon clamps the request to its own configured value, so a call may lower the bound but never raise it (#1864). Againstexec.timeout: that bounds a whole script run, this bounds one upstream call inside it – the run’s total work is capped byexec.timeout, each call within it by the (possibly per-call)pool.callTimeout, and whichever is tighter for a given call is the one that fires. The pool’s execution watch keeps the configured value; the per-call request bounds the call it was made on only.- The question’s own deadline is
elicit.ttlindefaults.json(120s; a built-in, not a user setting). Expiry answerscancel, neverdecline: dismissed without choosing is not the same as refused.
A call the client stops waiting for — it cancelled, or its connection went —
keeps running as a task, and its questions keep their deadlines. The result
can still be collected from /v1/tasks/{id}; nothing interrupts the upstream
call. That is deliberate for work somebody may come back for, and it is also
why cancellation does not reach an interruptible call (§6).
4. mcpx as a client
What mcpx sends upstream, and what it will accept from a server.
| era | modern probed first (server/discover), legacy on fallback; the answer cached per server configuration (spec/era-probe.md) |
| per-server override | protocol: prefer-discover | prefer-initialize | force-discover | force-initialize | follow, over upstream.protocol (default prefer-discover); the old names modern, legacy, force-modern, force-legacy and others are accepted as aliases (spec/era-probe.md); follow adds a legacy session for legacy callers (spec/era-probe.md) |
| legacy version | 2025-11-25 offered; 2024-11-05 .. 2025-11-25 accepted; anything else and mcpx disconnects |
| modern version | 2026-07-28 |
| declared to servers | elicitation.form and roots always; elicitation.url and sampling when a handler is installed — which in the daemon is always (#210) |
| transports | stdio, Streamable HTTP, and HTTP+SSE (2024-11-05) as the last fallback of the HTTP probe, remembered in the era record |
| stdio shutdown | close stdin, wait stdioShutdown.stdinGrace (2s, a built-in default) for the server to exit, then SIGTERM the process group, wait stdioShutdown.termGrace (3s), then SIGKILL — every revision’s stdio SHOULD (#203 LV-39; it used to SIGTERM at once) |
| headers | legacy: MCP-Protocol-Version is the negotiated version, omitted on initialize and up to 2025-03-26; modern: Mcp-Method, Mcp-Name, Mcp-Param-* for x-mcp-header parameters, never a session id |
The per-revision rules — headers, x-mcp-header, resultType, cancellation,
pagination, subscriptions/listen, resumption, the GET stream — are in
spec/client.md. The official suite’s client leg passes every
non-auth scenario (spec/official-suite.md).
The probe used to be the other way round, and it did not matter which way,
because mcpx read protocolVersions from server/discover where the schema
says supportedVersions. mcpx could not connect to any real modern server.
Its own server made the same mistake, which is why mcpx talking to mcpx
passed every test.
4.1 Server-initiated requests mcpx answers
| request | mcpx answers | regardless of era |
|---|---|---|
ping | {} | yes |
roots/list | the daemon’s roots, before consulting any handler — and the daemon passes none, so the list is empty (#210) | yes |
elicitation/create | through the broker, or {"action":"cancel"} when nothing can answer; a mode that was not declared for the revision in use is refused | yes |
sampling/createMessage | through the broker; a decline becomes an error, since the specification has no decline result for sampling | yes |
| anything else | -32601, but always answered — silence is indistinguishable from a hung server | yes |
Always answered, unless the server withdraws the question first: a
notifications/cancelled naming a request still being answered ends the
handler’s context (so a question relayed to the host is withdrawn too) and no
response is sent. The reason, if given, goes to the daemon log as a
server.warning. A cancellation naming anything else is ignored.
A modern server asks the same three things by answering input_required,
and mcpx resolves them through the identical function. One function is what
stops the two eras answering the same question differently.
4.2 Notifications mcpx listens to
notifications/message, notifications/progress, notifications/cancelled,
notifications/{tools,resources,prompts}/list_changed,
notifications/resources/updated, notifications/elicitation/complete.
From a legacy server they arrive unsolicited (on the standalone GET stream,
over HTTP). A 2026-07-28 server sends them only on a subscriptions/listen
stream, so mcpx opens one per upstream connection, with a filter built from
what the server declared and the URIs somebody subscribed to, and reopens it
when it ends. Messages and progress go to /v1/events; a list change drops
the cached schema. notifications/tasks/status (2025-11-25) is received and
dropped — mcpx polls task state rather than tracking it. A request from a
2026-07-28 server is not answered: that revision has no server-to-client
requests (a server asks through input_required results), and the stdio
transport page says the client MUST NOT respond (#200). With 2026-07-28 in
spec.lenient it is answered like a legacy server’s (§1.1).
4.4 Progress, log messages and trace context, relayed (#212)
During a tools/call that reaches an upstream, mcpx carries what the host
asked for through to the server doing the work, and what that server sends
back to the host:
- Progress. A host’s
_meta.progressTokenis replaced upstream by a token of mcpx’s own (mcpx-<n>, unique on that upstream connection, which two hosts’ tokens are not), and eachnotifications/progressfor it is sent to the host with the host’s token restored. Only progress for a call still in flight is passed on: a token nobody asked for, or one whose response is in, is dropped. A report that does not exceed the last one passed on is dropped, so the host never sees progress go backwards; once progress reachestotalnothing more is sent; and reports for one token are passed on no more often than every 20 ms (mcpclient.ProgressMinInterval), except the final one. Amessagegoes to every host by default; it is removed for a 2024-11-05 host only when 2024-11-05 is first inspec.precedenceand strict (--mcp-spec 2024-11-05), since that revision’s schema has no such field (§1.1). - Log messages. A 2026-07-28 host’s
_metalogLevelis sent upstream (or the daemon’s own level, if more verbose); a legacy host’s level is the one it set withlogging/setLevel. Upstreamnotifications/messageat or above that level are sent to the host. No level, no messages. A log message names no request, so one arriving while a host has several calls in flight on the same upstream connection goes to each of them. - Trace context.
traceparent,tracestateandbaggagein_metaare passed upstream unchanged.
Over Streamable HTTP these go on the call’s own response stream, which
becomes text/event-stream only when something is relayed; over stdio, as
notifications. Between mcpx serve and the daemon the call carries a relay
object on /v1/call (answered as application/x-ndjson when anything was
relayed, plain JSON otherwise) or /v1/ask (collected by the polls as
notifications). mcpx call and mcpx exec send no relay and print only the
result. Messages and progress still reach /v1/events as before.
Not yet: progress does not extend mcpx’s own call timeout (CODE-13), and prompts/get and resources/read relay nothing.
4.3 Resource subscriptions, end to end (#241, #247)
mcpx declared resources.subscribe to its own clients and never subscribed
upstream, so notifications/resources/updated never flowed: a capability
declared and not delivered. Now a client subscribing to
mcpx://<namespace>/<uri> – by legacy resources/subscribe (stdio, or
Streamable HTTP with the update on the GET stream) or by resourceSubscriptions
on a 2026-07-28 subscriptions/listen – makes the daemon subscribe <uri>
on the server that owns the namespace, with whichever mechanism that server’s
era has.
- The lifetime is a
/v1/eventsstream.mcpx servehears the daemon’s events overGET /v1/events?uri=mcpx://...; a stream naming a resource in mcpx:// form (or bare withserver=) is a subscription to it, held until the stream closes. So a listen stream ending, a connection ending, a legacy unsubscribe (which replaces the connection’s stream), ormcpx servecrashing all release it without a separate call that could be missed, and a stream that reconnects to a restarted daemon subscribes again by reconnecting. The stream’s first event,resource.watching, says what was subscribed and why anything was not. - Counted per URI across every client. The upstream sees one connection,
mcpx’s, and one
resources/unsubscribefrom it would end the updates for everyone. The first watcher subscribes; the last one unsubscribes (internal/pool/watch.go). - Instances. One holding a subscription is not idle and is not reaped. A
newly started instance is given every subscription still wanted, and one
that ends while its subscriptions are wanted –
mcpx restart, a crash – is replaced at once, since nobody else would start it and the updates would stop without a word. A replacement that fails to start is the pool’s ordinary start failure; the next call to that server retries and resubscribes. - A server that does not declare
resources.subscribeis never sent a subscription (that would be asking for an undeclared capability). mcpx keeps declaringresources.subscribeitself – it is one capability over many servers, and it does support the mechanism – and is honest per resource instead: the listen acknowledgement’sresourceSubscriptionslists only the URIs whose updates will arrive, which is what the acknowledgement exists to report. A legacyresources/subscribefor such a URI – or for one no configured server owns, like a baretest://x– succeeds and delivers nothing (#251). A subscription is a standing interest, not a lookup: the spec does not require the resource to exist or its updates to be deliverable, and the legacy revisions have no way to say “agreed, but nothing will come”. Refusing with-32602, as #247 first did, broke clients that subscribe before they know (the official suite’sserver-resources-subscribeand-unsubscribeamong them). The reason is not lost: the daemon publishes aserver.logevent at levelwarning(“no updates will be delivered for: ”), visible on /v1/events. Honesty stays where the protocol has a mechanism for it, the listen acknowledgement. Only agreed URIs’ updates are ever sent on a stream. - Absolute-path URIs. A listing drops a leading
/when it namespaces (/abs/docis listed asmcpx://demo/abs/doc). The daemon resolves the inner part against the server’s own resource list to recover/abs/doc, andevents.Filtercompares URIs without a leading/; before, the two forms never matched.
4.4 Generic requests
Client.Request and Pool.Request send any method. Before them the typed
helpers were the only way to the wire, and anything outside that set had no
path at all — which is why POST /v1/complete answered from cache with
"upstream": false. Not because the server could not complete: because
nothing could ask it.
Complete now forwards completion/complete to the server, which knows the
values an argument may take where mcpx knows only the names it has cached. A
server that never declared completions is not asked — a well-behaved one
answers method-not-found and the rest answer something unpredictable, so the
declaration is the only thing worth trusting — and the reply says "source": "cache" with the reason.
5. One HTTP server
The daemon served /v1 on a unix socket and a loopback port. mcpx serve --transport http was a separate process serving /mcp, /v1/tools/*,
/v1/call/<ns>/<tool>, /health and /openapi.json. Two HTTP servers, two
overlapping /v1 prefixes, and which one a caller reached decided which half
of the API existed — with no way to tell from the outside.
Now:
| was | is |
|---|---|
serve --transport http → /mcp | the daemon mounts /mcp on its own listeners (proto.mcpPath, proto.serveMCP) |
serve --transport http → /v1/tools/<tool> | POST /v1/tools/{tool} in the ops table |
serve --transport http → /v1/call/<ns>/<tool> | POST /v1/call/{server}/{tool} in the ops table |
serve --transport http → /health | GET /v1/health, which already existed |
serve --transport http → /openapi.json | GET /v1/openapi.json, which already existed, and which now describes the per-tool paths the second server used to publish alone |
serve --transport stdio | unchanged |
serve --transport http is removed, and asking for it fails with the
address to use instead. A command that fails without saying where the thing
went costs somebody an afternoon.
The daemon package cannot import the CLI — the dependency runs one way, and
inverting it to put the two servers together would be a worse cure than the
disease. So the CLI builds the handler and the daemon mounts it:
Server.MCP, Server.MCPPath, Server.MCPTool. Three fields.
stdio stays, and stays a thin shim. It is inherent rather than a choice: an MCP host starts its servers by spawning a process and talking down the pipe, so something has to be spawnable. Everything else the daemon already had — a socket, a port, a lifetime, pooled servers kept warm between calls — and giving MCP a second copy of all of it bought nothing.
To put MCP on a network: mcpx daemon --port 8899 --address 0.0.0.0, then
http://host:8899/mcp. The warning about an unauthenticated API binding
beyond loopback applies to /mcp exactly as it applies to /v1, and for the
same reason: whatever can route to the port can run tools as you.
Why not an extra listener
An --mcp-addr on the daemon was the obvious alternative, and it is one
address away from what --port already does. The reason not to is that the
two surfaces are not separable: /mcp calls /v1 for everything, the MCP
tools are generated from the /v1 ops table, and a deployment that exposed
one without the other would be exposing half a program. One listener, one
access-control decision.
6. What is still missing
Named, because a gap nobody wrote down is a gap somebody rediscovers.
- Cancelling an interruptible call does not stop it. On a direct call,
notifications/cancelled(stdio, legacy HTTP session) and a closed stream (2026-07-28 HTTP, sessionless legacy) cancel the upstream request. A call on the ask path runs as a daemon task and keeps running (§3.5). - Progress does not extend mcpx’s own call timeout. It is relayed to the
host (§4.4), so a host’s timeout can reset on it; the daemon’s
per-server
callTimeoutstill cannot. -32021for a tool that asks is deliberately not raised (§3.1); whether the broker fallback discharges the MUST is #252 item 1.- Tasks.
notifications/tasks/statusis dropped; mcpx never task-augments a call upstream, so a tool withtaskSupport: "required"cannot be called; an extension task never reachesinput_required(#209). - Capabilities declared upstream that nothing answers:
samplingandelicitation.urlwhenever a handler exists,rootswith an empty list (#210). - Authorization. No OAuth client and no OAuth on
/mcp(#253). The per-serverauthblock (bearer, basic, header, query, env) is applied to every connection (#240);type: oauthis refused with a message, since the flow is not implemented. - Validation. Tool inputs and outputs and elicitation answers are not checked against their JSON Schemas (#254); sampling results are relayed unreviewed (#256); elicitation storage is readable by any local user and never pruned (#255).
- Smaller conformance gaps: an invalid cursor restarts at page one instead
of
-32602,mcpx://URIs are built without percent-encoding, no rate limits, annotations shown as trusted, and the rest of #257. - url-mode elicitation raised by mcpx itself. The pass-through works; mcpx never starts one of its own.
- One attribution window (§3.4): an entry is registered an instant before its request is counted.
2026-09-30T16:00:00-05:00
Quick start
The whole loop, in under one screen: check the machine, add one server, look, search, read the types, call a tool.
mcpx doctor # runtime, config, daemon: what is missing and how to fix it
mcpx servers add fs -- npx -y @modelcontextprotocol/server-filesystem /tmp
mcpx ls # namespaces, tool counts, state
mcpx search directory # find a tool by name or description
mcpx types fs # TypeScript signatures for this one namespace
mcpx exec 'emit(await fs.list_directory({ path: "/tmp" }))'
That is the loop from AGENTS.md, and it is the whole
product in miniature:
lsbefore anything. Discovery is a separate, cheap step from use — run it whenever; it starts nothing.search, thentypesfor one namespace only. Signatures are loaded on demand, never all of them: loading everything is the failure mode mcpx exists to prevent.mcpx types fs.list_directoryloads a single tool, cheaper still. Tool names come from the server — iflist_directoryis not whattypesshows,typesis right.exec(orrun) does the work. Inside a snippet, every namespace is a bare identifier and results arrive unwrapped. A named, saved version of the same idea is a script:mcpx run <name>runs.mcpx/scripts/<name>.ts.
Every command above is real: verified against
internal/cli/manual.go (Commands(), the list behind mcpx help)
and the dispatch table in internal/cli/dispatch.go at the commit
this page was written. The server example is the one
mcpx servers --help itself uses; it needs npx (node). With only
deno or bun, find a first server that needs nothing extra instead:
mcpx registry search <words>, then mcpx registry add <name> --write.
Do not start with mcpx init: it writes a starter config whose servers
are placeholders, so the first ls after it shows an error row. Add a
real server first, as above.
Make it an agent tool
The quick start above is a person at a shell. The step that makes mcpx an agent tool is one of:
- give your agent
AGENTS.md— the loop, the script contract and the secrets rules, in the file agents already read; or - install the opencode plugin — see install,
“just the plugin” — and turn on what you want it to do: the plugin
ships with
toolsandinstructionsoff, so a bare copy gives your agent neither callable tools nor prompt guidance. For tools, name the plugin in config with options (["./.opencode/plugin/mcpx-session.ts", { "tools": true }], or setMCPX_PLUGIN_TOOLS=1); for the usage paragraphs,instructions: trueorMCPX_PLUGIN_INSTRUCTIONS=1. See plugins for the full option list.
Next
Now what — config, surfaces, the plugin, and what to do when something is wrong.
Release runbook
How docs/story.md stays true. README.md is a symlink to it, so this is the
first thing anyone reads about mcpx.
The file has two halves and they have different rules.
The two halves
Above <!-- table-of-contents-marker --> is the story. One agent, one
prompt, one script, with the exact bytes it saw. It is the whole interface in
one read. It is closed by default: fix it, do not grow it.
Below the marker is the feature list. It is open by default: any commit
that lands on main may edit an entry or add one. It grows forever and that is
fine, because nobody reads past the part that answers their question.
Rules for the story
Change it only to:
- correct something that is now false — a flag renamed, output changed, a measured number that has moved;
- replace a quoted block whose source changed. Every fenced block in the story is real output or a real file. Re-run the command, paste the result.
Do not add a new capability to the story. A new capability gets an entry below the marker. The story stays one prompt long.
Extending the story needs explicit sign-off from the repository owner. If a change would make the walk longer or add a second scenario, open it as a question first.
When a quoted block changes, verify it rather than editing by hand:
mcpx ls
mcpx search screenshot click
mcpx types chrome_devtools | head -20
mcpx scripts
mcpx run shot-flow
The instruction file quoted in full is AGENTS.md. If that file changes, the
quote and the line-by-line breakdown after it both change.
Rules for the feature list
Ordering is meaning. Roughly:
- Core — what mcpx is for. Zero-context discovery, pools, the client. These change rarely and are read first.
- Standard — shipped, supporting. Scripts, config, ops, packaging.
- Proposed — wanted, not built. Everything under the
## Proposedrule.
New shipped work goes after the last standard entry unless it is genuinely
core. New wants go under ## Proposed. Within a release, append in merge order.
Entry format
A small addition can be a heading and two links:
### [feat(a11y): support for Teletypewriter](https://github.com/developing-today/code/blob/main/pkgs/mcpx/docs/tty.md) #123
2026-10-02T11:30:00-05:00
Anything larger opens with a key block:
## Session-isolated process pools
`
created: 2026-09-25T18:30:00-05:00
last-updated: 2026-09-27T05:05:00-05:00
id: session-isolated-process-pools
since: pre-release
status: core
tags: area:pools, area:concurrency
description: one sentence, present tense, what it does
`
Prose, examples, the reasoning that is not obvious from the code.
2026-09-27T05:05:00-05:00
Required keys: created, last-updated, id, since, status, description.
Optional: owner, creator, tags, dek, note, editors-note, links.
increment is retired (removed in the #1865 migration). It counted commits
inexactly; changed below counts recorded changes of meaning, exactly.
History metadata: id, since, changed
Every section carries a stable ID, frozen from the heading’s slug the day the metadata landed, so links never break when a heading is reworded:
id— the section’s ID, first key in the block.since— the first release containing the section: a version (0.2.0), orunreleasedfor work not yet released, orpre-releasefor everything that predates the first release (the migration sentinel; no release has shipped).changed— the section’s recorded history, one list item per change:
changed:
- 0.2.0 (2026-11-02): idle exit is now a setting
Headings without a key block use the compact comment form, directly under the heading:
<!-- id: pools-lifetime since: 0.2.0 -->
A change is recorded per PR as one fragment file, docs/changes/<slug>.md:
sections: pools, pools-lifetime
kind: Changed
summary: idle exit is now a setting
kind is a Keep a Changelog kind (Added, Changed, Deprecated,
Removed, Fixed, Security). At release time stamp folds fragments into
the named sections’ changed lists and deletes them. An edit that changes no
meaning (typos, reflow, link repair) needs no fragment: mark the PR with the
docs:trivial label or a Docs-Trivial: yes trailer.
status also accepts the closed-proposal values shipped, implemented and
rejected (used by proposals.md); a status that needs more words becomes a
vocabulary status plus a free-text note key.
The tool is tools/docs-history (one Go program, render / stamp /
check on a single parser; not part of the shipped binary). render emits
the since marker and one closed-by-default <details> history per heading
whose subtree has history — its own entries in full, then one line per
descendant change grouped by release — and with --agent the agent-twin form
(no disclosures, a since/status line and a history link per section). It also
speaks the mdBook external-preprocessor protocol on stdin/stdout.
tags are Datadog-style: bare tags (area:pools) and key-value labels
(platform:nix, cost:context). Reuse existing keys before inventing one —
current keys are area, platform, cost.
status is one word:
| status | meaning |
|---|---|
core | mcpx is not mcpx without it |
standard | shipped and supported |
candidate | shipped, may still change shape |
alpha / beta | shipped behind a flag or unstable |
proposed | wanted, not built. Say so in the prose too |
partial | some of it works; the entry says which part |
deprecated | works, will be removed. Link the replacement |
broken | known broken, not yet removed |
retired | removed. Struck through, or moved to docs/archive.md |
shipped / implemented | closed proposal: built (proposals, document-level blocks) |
rejected | closed proposal: decided against |
Every top-level entry ends with its last-updated timestamp on its own
line, RFC 3339 with offset. It duplicates the key block on purpose: the key
block is for machines, the trailing line is for whoever is scrolling.
Removing things
Never delete an entry. Either strike the heading through:
## ~~Old thing~~
and set status: retired with a note pointing at the replacement, or move the
whole entry to docs/archive.md and leave a one-line stub linking to it.
Heavy refactors move the old text to the archive too. The archive is append-only and has no ordering rules.
Per-commit
Any commit that lands on main and changes behaviour:
- Edit or add the entry below the marker.
- Set
last-updated, update the trailing timestamp, and add a change fragment naming the section IDs you changed (docs/changes/<slug>.md), or mark the PR trivial (docs:triviallabel /Docs-Trivial: yestrailer). CI runs the docs-historycheckon PRs touchingdocs/**. - If it changed something the story quotes, re-run the command and paste the real output.
- If it changed
AGENTS.md, update the quote and the breakdown.
A commit that only changes behaviour covered by an existing entry does not need a new one; it needs that entry updated.
Per-release
- Run the docs-history
check(see Checks): every entry touched since the last release must carry a change fragment or a trivial marker, and everysincemust be valid. - Re-run the story’s commands and diff against what is quoted. This is the real check — the story is the only part that can silently rot.
- Move anything now
retiredintodocs/archive.md. - Promote
proposedentries that shipped: changestatus, move them up into the shipped run, setcreatedto when the work landed rather than when it was first wanted. - Run
stampwith the release version and date (go run ./tools/docs-history stamp --version X.Y.Z --date YYYY-MM-DD docs/*.md): it replacessince: unreleased, folds the fragments intochangedlists and deletes them. Commit the stamped docs, then tag that commit — the tagged tree is the stamped tree. - Re-read the first screen. If the first thing a newcomer sees is no longer the most important thing, reorder.
Checks
cd pkgs/mcpx
gofmt -l .
go vet ./...
go test ./... -count=1
MCPX=./bin/mcpx scripts/stress.sh # concurrency, leaks, orphans
MCPX=./bin/mcpx scripts/bench.sh # latency, against lootbox
(cd ../.. && nix build .#mcpx) # unit suites in the sandbox
The docs-history check (duplicate IDs, bad since values, fragments naming
unknown sections, and — with --diff — prose changes with no fragment and no
trivial marker):
go run ./tools/docs-history check docs/story.md docs/proposals.md
git diff origin/main...HEAD -- docs/ > /tmp/docs.diff
go run ./tools/docs-history check --diff /tmp/docs.diff docs/story.md
README.md must stay a symlink to docs/story.md:
test -L README.md && readlink README.md # -> docs/story.md
Reaching a daemon on another machine
The daemon speaks HTTP on a unix socket, and every client surface — the
CLI, scripts, the TUI, MCP — goes through the same client. Which means a
daemon on another machine is a transport question, not a feature: point
daemon.endpoint at it and everything works there.
Three routes exist, in order of how much setup they need.
1. ssh:// — nothing to set up
export MCPX_DAEMON_ENDPOINT=ssh://buildbox
mcpx ls
mcpx tui
The client runs your own ssh binary (ssh -T -o BatchMode=yes buildbox mcpx stdio-proxy) and speaks the daemon’s HTTP API through it. Your ssh
agent, ssh_config, known_hosts, jump hosts and Match rules are the
access control, because it is literally your ssh doing the connecting.
Nothing listens on the remote host beyond the daemon’s own unix socket, and
no mcpx credential exists to leak.
Details worth knowing:
- The remote socket path is resolved by the remote process, from mcpx’s own paths and configuration file there. The client makes no assumption about the remote filesystem layout.
?bin=/path/to/mcpxnames a remote mcpx that is not onPATH:ssh://buildbox?bin=/opt/mcpx/bin/mcpx.?sock=/run/user/1000/mcpx/daemon.sockpicks a specific remote daemon, for a host running more than one.user@and:portwork as in any ssh target:ssh://drew@buildbox:2222.BatchMode=yesis set, so ssh never stops to ask for a password or a passphrase: an HTTP request has no terminal to answer on. Load the key in your agent, or use a key with no passphrase.- A remote endpoint is never auto-started. If nothing answers, the error
names the host and the exact remote command that was tried; start the
daemon there with
ssh buildbox mcpx daemon(ormcpx daemon --detached). - Server-sent events (
/v1/events,mcpx log --follow) stream over the same tunnel; there is no separate event path.
mcpx stdio-proxy is the remote half and is a normal command: it dials the
local daemon socket and copies stdin/stdout to it. Running it by hand is the
quickest way to check a host:
ssh buildbox mcpx stdio-proxy --sock /run/user/1000/mcpx/daemon.sock
2. ssh -L — forward the socket itself
For tools that cannot run ssh themselves, or when you want one tunnel for
many shells, forward the unix socket as a local socket:
ssh -N -L /tmp/buildbox-mcpx.sock:/home/you/.local/state/mcpx/daemon.sock buildbox &
export MCPX_DAEMON_ENDPOINT=unix:///tmp/buildbox-mcpx.sock
mcpx status
The forward is a plain byte pipe, so it is the same thing the ssh://
transport does, just started by you instead of by the client. The remote
path must be the socket the daemon there is actually using — mcpx status --json on the remote host prints it (socket).
ssh -L also works in the other direction of convenience: -L 127.0.0.1:8065:127.0.0.1:8065 forwards the daemon’s loopback TCP port, for
a remote daemon started with daemon.address widened or a container that
serves TCP. Prefer the socket: it is the same filesystem permission check
as being there.
3. ssh -R — a daemon behind NAT
The two routes above dial out from the client, which fails when the daemon’s host cannot be reached at all. Reverse the direction instead: the daemon’s host connects out to a host that is reachable, and publishes the socket there.
On the daemon’s host (behind NAT), forward its socket to a socket on the public host:
ssh -N -R /tmp/team-mcpx.sock:/home/you/.local/state/mcpx/daemon.sock public-host
Then, anywhere that can reach public-host:
ssh -N -L /tmp/mcpx.sock:/tmp/team-mcpx.sock public-host &
MCPX_DAEMON_ENDPOINT=unix:///tmp/mcpx.sock mcpx ls
Or, with no local forward at all, use ssh:// from the middle host, since
the socket is already there:
ssh public-host MCPX_DAEMON_ENDPOINT=unix:///tmp/team-mcpx.sock mcpx ls
ssh -R needs AllowStreamLocalForwarding (the default) on the server, and
StreamLocalBindUnlink=yes in the forwarding client’s ssh_config to
replace a stale socket after a dropped connection.
4. mcpx serve over ssh — MCP to a remote daemon
An MCP host that cannot set an endpoint can spawn ssh instead of mcpx: see
the MCP-over-ssh form in protocol.md.
The short version:
{ "mcpServers": { "mcpx": { "command": "ssh", "args": ["buildbox", "mcpx", "serve", "--transport", "stdio"] } } }
That runs mcpx serve on the remote host, so the daemon there is the one
answering — again with no port and no credential beyond ssh.
What a remote endpoint is not
- It is not a second authentication layer. The daemon API is
unauthenticated; who may reach it is who may reach the host and the
socket.
ssh://inherits exactly the ssh access you already grant. - It is not a shared filesystem. Scripts and artifacts sent to a remote
daemon travel as bytes; a caller’s
{path}is uploaded, not made visible to the daemon, because an ssh tunnel never proves shared-disk access. Seeexec.md. - It is never started from here. Local autostart does not apply, by design: answering from a local daemon when the remote one is unreachable would be worse than failing.
Research and incidental findings
created: 2026-11-01T01:30:00-05:00
last-updated: 2026-11-01T01:30:00-05:00
increment: 1
status: draft
tags: area:docs, area:compendium, topic:research
description: what was learned while building mcpx, and while doing anything
else in this repository, collected by topic and tagged with its
provenance so it is not lost in session transcripts. First slice
of #1871: the section outline, the seed findings that already
exist in the repository, and the harvesting process.
This chapter is the compendium’s research chapter (#1871). It holds research and incidental findings — things learned while building mcpx, and while doing anything else in this repository — filed by topic so they survive the sessions that produced them. Findings that live inside feature entries stay there as the source of truth; this chapter excerpts them with a citation (the answer to open question 5 on #1871, applied: excerpt, never copy-and-fork).
Every finding here carries a provenance tag, per the quality bar below:
- measured — a number or behaviour produced by running something, with the environment named in the source.
- read-from-source — read in code, docs, or a specification in this repository.
- reported — someone else’s claim, cited: an external article, another project’s documentation, or a statement in an issue. Reported findings are not verified here and must not read like measurements.
Sections with no seed yet are outline only: the heading exists so the next harvest has somewhere to put things, and says so rather than padding.
AI and LLMs
- The cost model of zero-context discovery. Listing servers costs ~171
tokens, a search ~61, and
types675–4,402 per namespace — the numbers behind progressive discovery: a client should not pay for every tool definition up front. (measured —docs/story.md, “Zero-context discovery”) - Switch to progressive discovery at 1–5% of the context window, and do
not mutate the
toolsarray mid-conversation: it invalidates the provider’s prompt cache. (reported — modelcontextprotocol.io, MCP client best practices, as cited on #1871) - Models write code better than they call tools, Cloudflare argues, because tool-call tokens are synthetic training data while code is what models were trained on. This is the argument behind Code Mode. (reported — blog.cloudflare.com/code-mode, as cited on #1871)
Harnesses
- What is opencode’s and what is general.
docs/opencode-plugin.mdseparates the opencode plugin surface (v1 vs v2) from the parts that apply to any harness;OPENCODE-V2.mdcorrects the record where the first draft blurred them — opencode v1 ships code mode behindOPENCODE_EXPERIMENTAL_CODE_MODE. (read-from-source —docs/opencode-plugin.md,OPENCODE-V2.md) - The harness knows what the agent does not. Session state, statistics,
and affordances live in the harness; an agent that assumes them instead of
asking gets them wrong. The opencode session statistics are the worked
example. (read-from-source —
docs/story.md, “The harness knows what the agent does not” and “Statistics from opencode”) AGENTS.mdis the cross-harness instruction file: the same file is read by the harnesses this repository is used from, which is why fleet rules live there and not in one tool’s config. (read-from-source —AGENTS.mdat the repository root and inpkgs/mcpx)- A harness-free core is a design constraint, not an accident (#48):
codex, claude code, pi, and manus must all be able to drive the same core.
(read-from-source —
docs/story.md; issue #48)
Plugins
- A plugin can connect without the binary. The connection ladder and
GET /v1/resolvelet the opencode plugin find or start a daemon instead of assuming one is installed (#44, fixed in #53). (read-from-source —docs/plugin-discovery.md) - The plugin logs over the socket (#36), so plugin diagnostics land
where the daemon’s do instead of in a file nobody opens.
(read-from-source —
docs/plugin-discovery.md; issue #36) - Plugin install is a README, not a package step. (read-from-source —
plugin/opencode/README.md)
Token providers
The outline assumes both readings of the term (open question 1 on #1871): model providers, and the credential/OAuth providers that hand out tokens.
- Model providers are maintained as patches. This repository patches
opencode for provider behaviour (Copilot business support, Copilot
compaction, OpenAI response-id caching) and keeps skills for maintaining
those patches across opencode updates. (read-from-source — #1871 seed
list. Harvest note, 2026-11-01: the three patch files the issue names are
no longer at the repository root on
main; the patches’ current home needs naming here by whoever next touches the opencode patch set.) - **Credential placeholders keep secrets out of config files and descriptions, but
stdio servers receive them through the environment. Generated scripts inherit
that environment unless variables are unset or the run uses
-i; do not treat this as isolation from code you execute. (read-from-source —docs/story.md, “Credentials”;internal/runner/dispatch.go:693–705)
Cloudflare
- Code Mode mechanics. The Worker Loader API runs generated code in
isolates;
fetch()/connect()throw inside it; bindings hide API keys from the generated code. Shared with #153 — the design treatment lives with the design, this is the finding. (read-from-source —docs/story.md; issue #153)
The TypeScript ecosystem
- Node runs TypeScript by stripping types, and only erasable syntax
survives. Node v24.20.0 type stripping rejects a file with an
enum; constructor parameter properties failed too, which is why the generated client uses plain fields. (read-from-source —ASSESSMENT.mdbug 3;docs/story.md, “Runtime portability”) - Runtime detection order is deno, then bun, then node (
script.runtime,--runtime,MCPX_SCRIPT_RUNTIME). Versions at the time of the seed: Deno 2.9.6, Bun 1.3.11. (read-from-source —docs/story.md, “Runtime portability”; the runtime comparison itself belongs to #101 anddocs/runtimes.md) --typecheckisdeno check, Deno only: ~80 ms on repeat, ~400 ms for a new program (#79). (measured — as recorded on #1871;docs/story.md)- Constructing a TypeScript compiler per request was lootbox’s memory
problem — the
ts-morphlesson behind the current design. (read-from-source —ASSESSMENT.md)
SSE
- Server-sent events, not WebSockets. SSE is plain HTTP,
EventSourcereconnects on its own, and WebSocket upgrades are mishandled by middleboxes — the reasoning behind mcpx’s remote transport. (read-from-source —docs/story.md, “Server-sent events, not WebSockets”) - Lossless reconnection is a protocol property, not a client hope: the
stream carries what a reconnecting client needs to resume without a gap.
(read-from-source —
docs/story.md, “Lossless reconnection”)
Sockets and TCP
- What each transport costs. Spawning
mcpx statustook 23.12 ms; a unix-socket keep-alive round trip 0.17 ms (135× cheaper); TCP loopback keep-alive 0.65 ms. The daemon-and-socket design is priced by these numbers. (measured —docs/story.md, “What each transport costs”) - macOS
sun_pathis 104 bytes. Deep state directories produced socket paths that failed withbind: invalid argument. (read-from-source —ASSESSMENT.mdbug 4) localhostis not127.0.0.1. It resolved to IPv6 only while the compiled build bound127.0.0.1, so the client dialled an address nothing listened on. (read-from-source —ASSESSMENT.md)
RPC and WebSockets
- JSON-RPC framing needs no dependency — it is standard library, which
is why it appears in the dependency audit as a deliberate non-dependency.
(read-from-source —
docs/dependencies.md) - Lootbox’s WebSocket. The predecessor system used a WebSocket RPC
channel (
lootbox.config.json:serverUrl: ws://127.0.0.1:9420/ws) — the contrast case for the SSE choice above. (read-from-source — #1871 seed list)
MCP
- Two protocol eras. The protocol has a before and an after, and a
client has to speak to both; “What 2026-07-28 actually asks of a client”
is the checklist. (read-from-source —
docs/story.md, “Two protocol eras”) - The revision matrix — every revision in both directions, plus what is
still missing — lives in
docs/protocol.mdand is excerpted, not copied, here. (read-from-source —docs/protocol.md) - The sampling bug: it did not work, and nothing said so. Capabilities
are declared in the handshake, but the handler was installed after it,
and every test called the passthrough directly — so sampling was
advertised, broken, and green in CI at the same time.
(read-from-source —
docs/story.md, “Sampling”) - The elicitation bug came first — the same shape, earlier.
(read-from-source —
docs/story.md, “Elicitation”;docs/elicitation.md) mcpx doctorfound a bug immediately — a diagnostics command earns its keep the first time it runs against a real installation. (read-from-source —docs/story.md, “mcpx doctor”)- Remote servers without a shim: Streamable HTTP makes
mcp-remoteunnecessary for remote servers. (read-from-source —docs/story.md, “Remote servers without a shim”) - Which registry. The official registry publishes an OpenAPI spec;
HAPI is not open source and returns 404. Registry choice is a licensing
and availability decision before it is a technical one.
(read-from-source —
docs/story.md, “Which registry”)
OpenAPI and REST
- Specifications as tools: two things easy to get wrong. A relative
server URL must resolve against the document’s location, and only read
methods are exposed by default. (read-from-source —
docs/story.md, “Specifications as tools”) - The specification described the wrong thing: the first spec described
mcpx’s own ten tools, not the hundreds it fronts. (read-from-source —
docs/story.md, “Specifications as tools”) - One ops table generates the routes, the MCP tools, and the OpenAPI
document (
internal/api/ops*.go), so the three cannot drift apart. (read-from-source —internal/api/)
OAuth
- OAuth for remote servers is
status: proposed, not built; the credential story that exists today is in “Credentials”. (read-from-source —docs/story.md, “OAuth for remote servers” and “Credentials”)
Command line
- Command-line programs as servers: a CLI with a daemon behind it is a
server with a command-line client, and should be designed like one.
(read-from-source —
docs/story.md, “Command-line programs as servers”) - Derived flag names surprise people:
daemon.leaseTTLbecomes--daemon-lease-ttl. (read-from-source —docs/cli.md:142–146) - Accepted-and-ignored flags are bugs, even when accepting them is deliberate-looking (#74, fixed by #75). (read-from-source — issues #74, #75)
- Help coverage once drifted from the dispatch table (historical):
man,completion,serve,openapi,adapter,registry, andapiwere once dispatched bycmd/mcpx/main.gobut undeclared ininternal/cli/manual.go, so help, the man page, and completions did not list them. All seven are declared now, andTestEveryDispatchedCommandIsDeclaredholds the declaration and the dispatch table equal in both directions, so the gap cannot reopen silently. (read-from-source —docs/cli.md, “once dispatched but undeclared”;internal/cli/manual.go)
Containers and CI
Outline only in this slice: the container and CI seeds (static Go builds,
image-size anatomy, actions/setup-go in a monorepo, stopping test
daemons) are harvested with the Nix slice below, because several were
recorded alongside it (#182) and splitting them twice would fork them.
The seeds themselves are listed on #1871 and in docs/container.md
sections 4–5.
Nix
Later slice, named rather than dropped. The standalone Nix section —
a section that does not need to mention mcpx — is the second slice of
#1871, not part of this one. Its seeds are already recorded on #1871:
vendorHash repair in the mcpx workflow, the sandbox-test package list in
package.nix, pure-Go SQLite, wrapProgram PATH suffixes, generated man
pages and completions, the flake layout and patch maintenance for this
repository, and the macOS findings. Open question 2 on #1871 (does this
section live in this book, or in a book for the whole repository that this
one links to?) is still open and should be answered before the slice is
written, not after.
Harvesting: how findings get here
Approach C from #1871, in force for this chapter:
- Intake. Anything learned that is not about the change at hand — a
verified surprise, a measured cost, a behaviour read in someone else’s
source — is filed as an issue (or a comment with a link) under the
researchlabel, not buried in a session transcript or a PR body. A finding stays an issue until it is written up here, so nothing is lost in between.docs/ideas.mdstates the same discipline for design questions: an answer that only exists in a conversation is lost. - Entry format. A finding written up here is an excerpt plus a
citation, in the entry style the runbook uses (created / last-updated /
status / tags / sources / checked), with the finding-specific statuses
verified,measured,read-in-source,reported,hearsay, andsuperseded. The tags in this file’s key block are the chapter’s; a finding’s own topics go in its section placement and its citation. - Harvest. A per-release step moves labelled items into this chapter as entries and closes the issues with a link to the section that took them. Excerpt, do not move: a finding inside a feature entry keeps that entry as its source of truth and is cited from here.
- Supersede, never silently edit. When a finding turns out wrong, it
is struck through or marked
supersededwith a link to what replaced it. The history is the audit trail (#150).
Quality bar for an entry
From #1871, applied to every seed above:
- Sources. Every claim has at least one: a file with a section or line, a commit, an issue, or an external URL with the date it was read.
- Measurements. A measurement carries its environment (machine, OS, runtime and tool versions) and the command that produced it — re-run the command, paste the result.
- Provenance. Say how it is known: reproduced, read in source, read in docs, or someone said so. Never let hearsay read like a measurement.
- Dates. Every entry is dated; a re-check updates the date. The seed
harvest above was checked against
mainon 2026-11-01. - Standalone. Where possible an entry is readable without mcpx context. That is the point of the Nix section.
Script runtimes, permission profiles and presets
mcpx run and mcpx exec hand the generated program to a JavaScript runtime.
This page covers which runtime runs it, what it is allowed to do, and how to
bundle mcpx’s own flags under a name. Every setting named here is in
configuration.md with its flag and variable.
Choosing a runtime
script.runtime (--runtime, MCPX_SCRIPT_RUNTIME) is one of:
| Value | Meaning |
|---|---|
auto (default) | the first entry of script.runtimeOrder that is installed and can enforce the permissions asked for |
deno, bun, node | that binary on PATH |
a name under script.runtimes | the declared binary, with the declared kind |
a path or another name on PATH | accepted when its basename, without an executable extension, is deno, bun or node |
Binaries are resolved with Go’s exec.LookPath, so PATHEXT is honoured on
Windows and nothing assumes .exe. A name that resolves to something that is
not a JavaScript runtime is refused, saying so:
mcpx: bash resolves to /bin/bash, which is not a known JavaScript runtime; declare it under script.runtimes with a kind (deno, bun, node)
Declaring runtimes
{
"script": {
"runtimes": {
"mydeno": { "kind": "deno", "bin": "/opt/deno/bin/deno", "args": ["--unstable-kv"] },
"node22": { "kind": "node", "bin": "node22" }
},
"runtimeOrder": ["mydeno", "bun", "node"]
}
}
kind decides how the command line is built; bin is a path or a name looked
up on PATH (default: the declared name); args are runtime options placed
after mcpx’s own fixed options and before the permission flags:
| Kind | argv |
|---|---|
| deno | run --quiet --no-check <args> <permission flags> <script> |
| bun | run <args> <permission flags> <script> |
| node | --no-warnings --experimental-transform-types <args> <permission flags> <script> |
The script sees MCPX_RUNTIME set to the kind, not the declared name.
script.runtimes and script.profiles are single settings holding a JSON
object, so the nearest file that sets one replaces the whole object from
files further away, as with any other setting.
Permission profiles
script.permissions (--permissions, MCPX_PERMISSIONS) names profiles.
A profile maps a runtime kind to the flags that enforce it there. The
built-ins live in internal/defaults/defaults.json (mcpx config --defaults):
| Profile | deno | node | bun |
|---|---|---|---|
all (also none, off, unsandboxed) — the default | --allow-all | no flags | no flags |
net | --allow-net --allow-env | — | — |
read | --allow-read --allow-env | — | — |
readnet (also read-net) | --allow-read --allow-net --allow-env | --permission --allow-fs-read=* | — |
strict | --allow-net=127.0.0.1 --allow-env | — | — |
— means refused. A runtime kind a profile does not list cannot run under it, and mcpx says which profile, which runtime, and how to define it:
mcpx: permission profile "strict" defines no flags for node, so node cannot enforce it (it is defined for deno); use --runtime deno, or define script.profiles.strict.node
Under auto, a runtime that cannot enforce the profile is skipped rather than
used, so auto never falls back to a runtime that would ignore it
(decisions/0003).
What each runtime can actually enforce
- deno has a complete permission model; every profile above is enforced.
- node (checked against v24) has
--permission, which denies file-system reads and writes, child processes, worker threads, addons, WASI and the inspector unless re-allowed with--allow-fs-read,--allow-fs-write,--allow-child-process,--allow-worker,--allow-addons,--allow-wasi. It has no network restriction, and it needs read access to load the script and the generated client. So node can genuinely enforcereadnet(read anything, network open, no writes, no subprocesses, no workers) and nothing narrower in the network or read dimension;net,readandstrictare therefore not defined for it. - bun has no permission model at all. Only
allis defined for it, and nothing you could define for it would be enforced.
Defining and overriding profiles
{
"script": {
"profiles": {
"strict": { "deno": ["--allow-net=localhost", "--allow-env"] },
"tmpwrite": { "deno": ["--allow-write=/tmp"], "node": ["--allow-fs-write=/tmp"] },
"mine": "--allow-read=/srv --allow-env"
}
}
}
A user profile replaces a built-in of the same name entirely. An object maps
kinds to flag lists; a string is raw flags used for every kind. Every entry
must be a flag (start with -) — a bare path is refused rather than handed
to the runtime as if it were the script.
Composition
--permissions read,strict,tmpwrite applies profiles in the order given:
- the result is the union of their flags;
- where two give the same flag (the text before
=) with different values, the later one wins, kept in the position the flag first appeared; - if any named profile has no entry for the runtime’s kind, the whole run is refused, naming that profile.
So for deno read,strict is --allow-read --allow-env --allow-net=127.0.0.1,
and strict,net ends with --allow-net (unrestricted) because net came
later.
Raw flags
raw: passes flags through verbatim and takes the rest of the value,
commas included, so it goes last:
mcpx exec --permissions 'strict,raw:--allow-read=/a,/b --allow-write=/tmp' '...'
Raw flags are passed to whichever runtime runs, unchecked against its kind.
A value that looks like flags but is not prefixed with raw: is refused with
the explicit spelling, rather than being guessed at.
Presets
A preset is a named list of mcpx’s own flags:
{
"presets": {
"ci": ["--json", "--log-level=warn"],
"debug": ["--log-level=debug", "--timeout=30s"]
}
}
Select with --preset ci,debug (or --preset=ci,debug), MCPX_PRESET, or
"preset" in a config file. Rules:
- presets apply in the order named; a later preset wins over an earlier one;
- any flag given explicitly on the command line wins over every preset;
- presets sit between the environment and the command line in the settings
precedence (default < file < env < preset < flag < runtime override); preset definitions under
presetsare content, not unknown keys; - each entry is one token: write values as
--flag=value; - a preset applies those of its flags the running command accepts and skips the rest, because one preset is meant to serve several commands;
- a preset cannot select other presets.
Values a preset supplied are shown as such: mcpx settings and
mcpx settings get report preset:ci (--log-level) as the source, with the
presets and defaults it overrode, and mcpx config --sources lists them under
FROM PRESET.
Schema validation (internal/schemaval)
mcpx validates tool and elicitation schemas with the internal/schemaval
package: a JSON Schema 2020-12 validator (wrapping
github.com/santhosh-tekuri/jsonschema/v6, its only import site in this
module) with an explicit dialect policy. It exists so Phases 2–3 of the
schemaval design can compile schemas at list time and enforce them at
call time; the core package itself changes no call paths.
Division of labour: internal/mcpspec validates MCP protocol frames
and is test-only; schemaval validates tool/elicitation schemas and
is the validator the binary carries.
Dialect policy
Declared $schema | Result |
|---|---|
| (absent) | Treated as 2020-12 |
2020-12 (https://json-schema.org/draft/2020-12/schema) | Supported |
2019-09 (https://json-schema.org/draft/2019-09/schema) | Supported |
draft-07 (http://json-schema.org/draft-07/schema#) | Supported |
draft-06 (http://json-schema.org/draft-06/schema#) | Supported |
draft-04 (http://json-schema.org/draft-04/schema#) | Supported |
| anything else | Compile fails with a typed UnsupportedDialectError, distinct from a generic compile error |
Rules
- No network, ever. The compiler’s loader refuses every URL. A
schema may reference only itself (internal
$refs into its own$defsand the like, or its own$id). Any other reference — internal-but-missing or external — fails compilation with a typedUnresolvedRefError. This is thejson-schema-no-network-refconformance rule, enforced at compile time. - Size bound. A schema document larger than 1 MiB
(1,048,576 bytes) fails compilation with a typed
SchemaTooLargeError. The largest legitimate schema documents in this repo (the vendored protocol schemas) are under 200 KiB. - Formats are annotations, not assertions, in v1. A value that
violates a
formatkeyword still validates; format checking may be turned on deliberately in a later phase, never by accident. - Integer fidelity. Instances are decoded without going through
float64, so integers past 2^53 validate exactly (bounds,multipleOf, enum membership). - Errors are flat and path-carrying. A failed validation returns
one line per problem in the form
$.count: got string, want integer, so a caller can show a tool user exactly what to fix.
Output policy: structuredContent is warn-only
Tool inputs are enforced: internal/mcpclient validates CallTool
arguments against the tool’s inputSchema and rejects invalid arguments
before the transport (error -32602; design #1481, Phase 2 #1483).
Tool outputs are deliberately warn-only. checkToolOutput
(internal/mcpclient/schemacheck.go) validates a result’s
structuredContent against the tool’s outputSchema, but a violation
does not change the result: it is delivered unchanged and a Warning
(“structuredContent violates outputSchema (delivered unchanged)”) is
recorded instead.
Rationale: mcpx is a gateway for servers it does not control. Rejecting
an otherwise usable result because a third-party server’s output drifts
from its declared outputSchema would break working integrations for a
documentation defect in someone else’s schema; a warning keeps the
drift visible without making mcpx the enforcement point. Flipping
outputs to enforcing is a separate decision, not a default that may be
turned on by accident (see the design in #1481 and Phase 2 in #1483).
Scripts
Script
A script is TypeScript (or JavaScript) that calls tools through
the generated client: no MCP
boilerplate, no schemas in context, just tools.<ns>.<fn>(args)
— or the namespace as a bare identifier inside mcpx exec. This
is the page a script writer lives on: the contract a script runs
under, where named scripts live, which runtime runs them, and how
to check one before it runs. The full contract is also stated in
AGENTS.md, “Script contract”; this page adds the mechanics
around it.
The contract
- stdout is the result; stderr is diagnostics. Use the
loghelper rather thanconsole.errorfor diagnostics:log.info("fetched {count} pages", { count: 3 })— a message may carry{placeholders}filled from the attributes, andlog(msg)islog.info(msg). - A default export is the entry point. If a script has one,
mcpx calls it with argv and prints what it returns. Otherwise
top-level code runs.
--export <name>calls a named export instead, spreading the arguments into it. emit()streams. Each value passed toemit()is written as it is produced; streaming and returning combine — the streamed values are progress, the return value is the answer.--jsongives one envelope.mcpx --json run <script>returns one document with the result, the logs, the exit code and the duration — the form another program should consume.- Results arrive unwrapped. Structured output is already
parsed, JSON-in-text is already parsed, plain text is a string.
.rawholds the full MCP envelope (that is where image bytes live). A tool returningisErrorthrowsToolError.
import tools, { log } from "./mcpx-client.ts";
export default async function main(args: string[]) {
const pages = await tools.chrome_devtools.list_pages({});
log.info("fetched {count} pages", { count: pages.length });
return pages;
}
Scripts need no imports at all inside mcpx exec: tools,
log, emit and every namespace are already globals. Relative
paths in a script resolve against your working directory,
not the script’s.
Named scripts
A named script lives at .mcpx/scripts/<name>.ts, searched
upward from the working directory, then
~/.config/mcpx/scripts/. The nearest one wins.
mcpx scripts # list what exists
mcpx run <name> [args…] # run one
mcpx run ./path/to.ts # a path is used verbatim
mcpx scripts lists shadowed scripts too — a nearer script of
the same name hides a farther one, and the listing marks which
is which rather than letting the hidden one surprise you.
Arguments after the name arrive in Deno.args on Deno; on every
runtime, a default export is called with argv, which is the
portable way to read them.
Runtimes
script.runtime (--runtime) chooses; the default order is
deno, then bun, then node — the first in
script.runtimeOrder that is installed and can enforce the
permission profile asked for. A runtime that cannot enforce the
profile is skipped under auto, never silently weakened.
runtimes.md is the full page on choosing,
declaring and constraining runtimes; the two limits a script
writer hits first:
- Node transforms TypeScript; it does not merely strip it.
Node is invoked with
--experimental-transform-types, chosen over strip-only mode precisely because strip mode rejects non-erasable TypeScript —enum, value namespaces, parameter properties — that Node itself runs fine once types are transformed. Anenumin a script is guarded by a test (TestNodeRunsNonErasableTypeScript), not a limitation. - Typechecking is Deno-only.
--typecheck/script.typecheckruns the check (viadeno check) before the run and only applies when Deno is the runtime.
Authority
Scripts run with your full permissions: a real runtime, the
real filesystem, the network, subprocesses. script.permissions
names narrower permission profiles, but a profile is only as
real as the runtime enforcing it — Deno enforces them, node
enforces some, bun enforces none — and under auto a runtime
that cannot enforce the profile asked for is skipped rather
than trusted. The details and the profile table are in
runtimes.md. The short version is the FAQ’s
answer to “is it safe?”: no — treat a script like a shell
command you are about to paste.
Where a script runs
exec.where is auto, local or remote (--local,
--remote). Auto runs locally when the daemon is on this
machine and on the daemon when it is not — a person at a
terminal gets a terminal-shaped run, and a configured remote
endpoint gets a remote one. How artifacts (a screenshot a script
produced, say) are delivered from each arrangement is
exec.md’s subject.
Checking before running
mcpx diagnosechecks a script before it runs — most valuably against the daemon’s record of what each tool’s schema used to be, so a call broken by a schema change is reported as two facts (“the argument became required; you wrote none”) rather than as a bare failure at run time.- Automatic preflight (
diagnose.preflight) runs those checks onexecsource before it executes. A diagnostic is advice with a name:--diagnose-preflight=falseruns the script anyway.
Recipes, and onward
A recipe is a named script that declares its own holes in its header:
// @param repo:string which repository
// @param days:number = 30 how old counts as stale
mcpx recipes lists them; mcpx recipes run <name> key=value…
fills each @name reference in the body with the value, encoded
as JSON, so a recipe cannot become an injection site. Values not
supplied and without a default are asked for as one form.
mcpx recipes match '<words>' ranks recipes against free text
deterministically — a score over names, descriptions, parameters
and the tools called; no model, no cost, the same answer every
time.
Onward from here: mcpx prompt turns a request in words into a
script (trying recipes first, and returning the script rather
than running it unless --run is given, because generated code
runs with your credentials); artifacts a script produces are
covered in exec.md; and stateful servers — why two
calls in one invocation share a browser and two invocations may
not — are under “Stateful servers” in AGENTS.md.
mcpx
A user types a prompt. An agent that has never heard of mcpx works out what it is, writes one script, and hands back two screenshots. This is that walk, with the exact bytes it saw.
At 09:14 a user opens a blank session and types:
Take a screenshot of example.com, click the link on it,
wait for the new page to load, and screenshot that too.
The agent has no MCP servers configured. Its tool list is the usual built-ins — read, write, edit, bash, glob, grep. Nothing named “browser”, nothing named “chrome”. There are 27 Chrome tools live on this machine and not one byte of their schemas is in the agent’s context, which is the entire point, but the agent does not know that yet.
What it does have is one instruction file, AGENTS.md,
injected at session start. Abridged – the full file is 152 lines and adds
profiles, the script contract and the secrets section, which this walk does
not need – it reads:
# mcpx
Every MCP server on this machine is reachable from the shell through `mcpx`.
None of their tool schemas are in your context. You pull in only what you need.
## The loop
1. `mcpx ls` — which namespaces exist. Cheap; run it whenever.
2. `mcpx search <words>` — find a tool by name or description.
3. `mcpx types <namespace>` — load signatures for that namespace only, or
`mcpx types <namespace>.<tool>` for a single tool, which is far cheaper.
`mcpx catalog [--budget N] [--bias words]` fits every namespace into a
token budget when you are still deciding.
4. `mcpx run <name>` or `mcpx exec '<code>'` — do the work.
Do not run `mcpx types` for every namespace. Loading one costs a few hundred
tokens; loading all of them costs thousands and defeats the point.
## Scripts
A named script lives at `.mcpx/scripts/<name>.ts`, searched upward from the
working directory, then `~/.config/mcpx/scripts/`. Nearest wins.
mcpx scripts list what exists
mcpx run <name> [args...] run one; args arrive in Deno.args
mcpx run ./path/to/file.ts a path is used verbatim
Scripts are TypeScript on a real runtime (deno, else bun, else node). They get
the full runtime: filesystem, network, subprocesses. Relative paths resolve
against _your_ working directory, not the script's.
`log`, `emit`, `tools` and every namespace are already on `globalThis`, so a
script needs no imports at all. `log(msg)` is `log.info(msg)`. Importing works
too and gives the same objects:
import tools from "./mcpx-client.ts";
const pages = await tools.chrome_devtools.list_pages({});
Every namespace is also a bare identifier inside `mcpx exec`.
## Results
A result arrives unwrapped: structured output already parsed, JSON-in-text
already parsed, plain text as a string. `.raw` holds the full MCP envelope —
that is where image bytes live. A tool returning `isError` throws `ToolError`.
## Stateful servers
`mcpx ls` has SHARING and SCOPE columns. SCOPE says what a server process is
keyed by; SHARING says whether one process serves several callers at once.
A namespace scoped to `session` gives each session its own process. If the host
set `MCPX_SESSION_ID`, successive runs in that session reach the same process,
so a browser persists between invocations. If it did not, each run is isolated
and anything that must share state — navigate, snapshot, click — has to happen
inside **one** invocation.
`mcpx status` shows the KEY each live process is serving, which is the quickest
way to see whether you are sharing or not.
## When something is wrong
- `error` in `mcpx ls` — `mcpx status` has the server's stderr.
- Arguments rejected — `mcpx types <ns>` is generated from the server's own
schema and is authoritative.
- Wedged server — `mcpx restart <namespace>`.
## Do not
- Do not add these servers to your own MCP config. The point is that they are
not in your context.
- Do not parse human output; use `mcpx --json <command>`.
- Do not assume a tool name is a valid identifier: `fancy-name` becomes
`fancy_name`. `mcpx types` shows the real function name.
The first three lines do all the framing. Reachable from the shell through
mcpx means this is a command, not a tool call and not a plugin — it belongs
in bash. None of their tool schemas are in your context explains the absence
the agent just noticed. You pull in only what you need says the absence is
deliberate and reversible.
From those three lines the agent now knows:
- capability it cannot see exists, and
mcpxis the door to it; - discovery is a separate, cheap step from use;
- loading everything is the failure mode the file is warning against.
That last point is what stops it reflexively fanning out an explorer subagent
across ~/.config, /usr/local/bin and the repo looking for browser tooling.
The file already said where to look. Twelve lines of guideline follow, and they
are all shaped the same way — a command, then the cost of misusing it.
So the agent makes one pinned memory:
mcpx — MCP via shell, not tools.
ls→search→types <ns>→run. Scripts:.mcpx/scripts/<name>.ts, import./mcpx-client.ts. Stateful namespaces need one invocation. Nevertypeseverything.
and three todos:
mcpx ls— confirm a browser namespace exists and note its modemcpx typesfor that one namespace only- write
.mcpx/scripts/shot-flow.ts, run it, report paths
Todo one. The agent runs mcpx ls and gets back, in 10 milliseconds, without
a single MCP server process being started:
NAMESPACE TOOLS SHARING SCOPE LIVE STATE DESCRIPTION
chrome_devtools 27 exclusive session 0 ready drive a headless Chrome
codebase_memory 0 shared global 0 error server "codebase-memory": initialize: mcp error -32000: con…
codedb 5 shared global 0 ready where a symbol is defined, who calls it
context7 2 shared global 0 ready third-party library docs
fff 3 shared global 0 ready ranked search over ~/git
fff_nix 3 shared global 0 ready ranked search over ~/.config/nix
fff_worktree 3 shared global 0 ready ranked search over opencode worktrees
Next: `mcpx types <namespace>` for signatures, `mcpx search <query>` to find a tool.
Seven namespaces, 43 tools, 171 tokens. chrome_devtools is the one. Its
SCOPE is session and its SHARING is exclusive, which the instructions
already told it means one browser per session, one caller at a time — so
unless the host set a session id, do it all in one invocation. LIVE 0 means
nothing is running yet. One row
says error, and it is honest about why — that server is genuinely broken today
and mcpx says so instead of quietly omitting it.
The agent does not yet know which of the 27 tools it needs, so it narrows first:
$ mcpx search screenshot click
FUNCTION DESCRIPTION
chrome_devtools.click Clicks on the provided element
chrome_devtools.take_screenshot Take a screenshot of the page or element.
chrome_devtools.fill_form Fill out multiple form elements (inputs, selects, checkboxes, radios) at once. ALWAYS pre…
chrome_devtools.take_snapshot Take a text snapshot of the target page based on the a11y tree. The snapshot lists page e…
Sixty-one tokens. Four names, enough to know the shape of the job.
Todo two. mcpx types chrome_devtools — 4,402 tokens, the single largest thing
the agent will load all session, and it loads it once and deliberately:
// Generated by mcpx. Call these from a script run with `mcpx run <file.ts>`.
// Every function is async and returns the tool's result.
/** drive a headless Chrome */
declare namespace chrome_devtools {
function click(args: {
/** Set to true for double clicks. Default is false. */
dblClick?: boolean;
/** Whether to include a snapshot in the response. Default is false. */
includeSnapshot?: boolean;
/** Targets a specific page by ID. */
pageId: number;
/** The uid of an element on the page from the page content snapshot */
uid: string;
}): Promise<ToolResult>;
This is generated from Chrome’s own JSON Schema, so it is authoritative rather
than remembered. Two facts land that the agent could not have guessed: pageId
is required on nearly every call, and click takes a uid “from the page
content snapshot” — not a CSS selector. Those two constraints determine the
whole shape of the script. An agent working from memory of how browser
automation usually looks would have written click({ selector: "a" }) and
failed twice before finding out.
Todo three. The script. Convention says .mcpx/scripts/<name>.ts, so:
// Screenshot a page, click its first link, wait for load, screenshot again.
//
// Usage: mcpx run shot-flow [url] [outDir]
import tools from "./mcpx-client.ts";
const cd = tools.chrome_devtools;
const url = Deno.args[0] ?? "https://example.com";
const outDir = Deno.args[1] ?? "./shots";
await Deno.mkdir(outDir, { recursive: true });
async function shot(pageId: number, name: string): Promise<string> {
const result = await cd.take_screenshot({ pageId });
const image = ((result.raw as any)?.content ?? []).find((c: any) => c.type === "image");
const path = `${outDir}/${name}.png`;
await Deno.writeFile(path, Uint8Array.from(atob(image.data), (c) => c.charCodeAt(0)));
return path;
}
await cd.new_page({ url });
// list_pages prints "<id>: <title> (<url>)"; find the one we just opened.
const pages = String(await cd.list_pages({}));
const host = new URL(url).host;
const row = pages.split("\n").find((line) => line.includes(host) && /^\d+:/.test(line));
if (!row) throw new Error(`no page for ${host} in:\n${pages}`);
const pageId = Number(row.split(":")[0]);
const before = await shot(pageId, "1-before");
// The a11y snapshot gives every element a uid; click needs that, not a selector.
const snapshot = String(await cd.take_snapshot({ pageId }));
const uid = snapshot.match(/uid=(\S+) link "/)?.[1];
if (!uid) throw new Error(`no link in snapshot:\n${snapshot}`);
await cd.click({ pageId, uid });
await cd.wait_for({ pageId, text: ["IANA", "Example"] });
const after = await shot(pageId, "2-after");
console.log(JSON.stringify({ url, clicked: uid, before, after }, null, 2));
Three things in there are only possible because this is a real runtime and not
a sandbox. Deno.mkdir and Deno.writeFile do actual filesystem work. atob
decodes the PNG out of .raw, because the image bytes never pass through the
model. And new URL(url).host is just the standard library, present because
this is ordinary TypeScript.
The whole browser conversation — new_page, list_pages, take_screenshot,
take_snapshot, click, wait_for, take_screenshot — is seven tool calls
and a multi-kilobyte a11y tree. None of it reaches the agent. Only the last
console.log does.
$ mcpx run shot-flow
{
"url": "https://example.com",
"clicked": "1_3",
"before": "./shots/1-before.png",
"after": "./shots/2-after.png"
}
Under a second of agent-visible work, and a browser that started, did its job,
and was released when the process exited. Had a second agent run the same
script at the same moment, it would have gotten its own browser and its own
1_3, because session mode leases a process per run.
The agent replies:
Both screenshots are saved.
./shots/1-before.png(25 KB) — example.com as loaded./shots/2-after.png(100 KB) — iana.org, after clicking the “Learn more” link (uid=1_3)Reusable as
mcpx run shot-flow <url> <outDir>.
The user opens both, sees the plain example.com page and then the IANA page,
and notices the interesting part: the agent spent about 4,600 tokens total,
nearly all of it on one deliberate types call, and left behind a script that
costs nothing to run again.
What the user turns over next is not whether it worked but where the edges are. If a script can return primitive values, can a plain one skip the browser entirely and just print a number? Sessions are leased per run — could one be named, pinned, listed, searched, and re-attached later, read-only for watching or read-write for driving? Could a still-cached session be resumed instead of rebuilt? Could the daemon’s log answer questions about what ran and how long it took? A wedged browser can be restarted, but can it be restarted for one session rather than the whole namespace? Can the daemon be upgraded without dropping the servers it holds? And how should this be wired up at all — a launchd job, a systemd unit, a shell function, a completion script? What about driving it interactively, or from CI with the script on stdin, or with everything in environment variables?
Those are the questions the reference pages exist to answer, so the user opens them, and over time learns about:
Zero-context discovery
id: zero-context-discovery
since: pre-release
created: 2026-09-25T18:10:00-05:00
last-updated: 2026-09-27T05:05:00-05:00
status: core
tags: area:discovery, cost:context
description: ls, search and types answer from an on-disk cache without
starting a single MCP server process.
mcpx ls, mcpx search and mcpx types never touch a server. They read a
schema cache keyed to the config file’s fingerprint, so a cold daemon answers
in about 10 ms and a warm one in about 1 ms.
The cache is written to $XDG_CACHE_HOME/mcpx/schemas-<hash>.json and survives
daemon restarts, machine reboots, and the MCP server binary being deleted —
there is a regression test that moves the binary away and checks mcpx types
still answers.
The practical effect is that an agent can run mcpx ls as often as it likes.
The cost model is: ls about 171 tokens, search about 61, types per
namespace from 675 (codedb) to 4,402 (chrome_devtools). Nothing is loaded that
was not asked for.
mcpx refresh re-reads every server. mcpx --json ls is the machine-readable
form; never parse the table.
2026-09-27T05:05:00-05:00
Sharing and scope
id: sharing-and-scope
since: pre-release
created: 2026-09-25T18:30:00-05:00
last-updated: 2026-09-27T06:20:00-05:00
status: core
tags: area:pools, area:concurrency
description: two independent axes -- how many callers share a process, and
what decides which process you get.
A single mode setting used to answer both questions at once, which meant
neither could be chosen freely. They are now separate.
sharing — how many callers may use one process at a time.
| value | meaning |
|---|---|
shared (default) | any number of concurrent callers; MCP multiplexes by JSON-RPC id |
exclusive | one caller at a time, others queue |
scope — what a process is keyed by. One live process per distinct key.
| scope | key | resolved by |
|---|---|---|
global (default) | constant | mcpx |
repo | git rev-parse --git-common-dir | mcpx |
worktree | git rev-parse --show-toplevel | mcpx |
cwd | working directory | mcpx |
session | MCPX_SESSION_ID or --session | caller |
parent-session | MCPX_PARENT_SESSION_ID | caller |
pid | calling process id | caller |
call | unique per invocation | mcpx |
"chrome-devtools": {
"command": "chrome-devtools-mcp",
"args": ["--headless", "--isolated"],
"mcpx": { "sharing": "exclusive", "scope": "session", "max": 4, "idleTimeout": "5m" }
}
The defaults describe a stateless server, which most are, so a server with no
mcpx block gets one shared process for everything.
What mcpx cannot work out for itself
repo, worktree, cwd and call are computed from the call itself.
session and parent-session cannot be: a subagent and its parent share a
working directory and differ only by an identifier their host assigns. The
caller supplies those through MCPX_SESSION_ID and MCPX_PARENT_SESSION_ID.
A scope that cannot resolve degrades to per-call isolation and says so once, in the daemon log, naming the variable that would fix it. Degrading toward isolation is deliberate: accidentally sharing a stateful process corrupts results, while over-isolating only costs a process.
Verified end to end. Two invocations under one MCPX_SESSION_ID reach the same
browser, so the second sees the page the first opened. Two subagents under one
MCPX_PARENT_SESSION_ID share; a third under a different parent does not:
live= 2 keys= ['psession:root', 'psession:other']
Lifetime
pid-scoped processes are stopped as soon as the process they belong to
exits, rather than waiting out an idle timer — there is no possible future
caller. Everything else is reaped on the idle timer, except keys a caller
minted for itself, which are stopped the moment that caller finishes.
2026-09-27T06:20:00-05:00
Profiles and aliases
id: profiles-and-aliases
since: pre-release
created: 2026-09-27T13:00:00-05:00
last-updated: 2026-09-27T13:00:00-05:00
status: standard
tags: area:config, cost:context
description: select subsets of servers by profile, and expose one server
several times under different namespaces and tool subsets.
Profiles decide which servers a command sees at all. A browser nobody is
using should not occupy a namespace, a row of mcpx ls, or a share of a
catalogue budget.
"chrome-devtools": { "mcpx": { "profiles": ["web"], "default": false } }
mcpx ls the default set
mcpx --profile web ls default set plus the web servers
mcpx --profile web --skip-default ls exactly the web servers
mcpx --all-profiles ls everything, ignoring profiles
A server is default-on unless it says otherwise; "pool": { "default": false } flips the baseline so servers opt in instead. The selection applies
to everything derived from the server list — ls, types, catalog,
search, and the generated client — so a script written under one profile
cannot reach a namespace outside it.
Aliases expose one server under a second namespace with its own tool subset, description and prelude.
"chrome-peek": {
"aliasOf": "chrome-devtools",
"mcpx": {
"sharing": "exclusive", "scope": "session",
"tools": ["list_pages", "take_snapshot", "take_screenshot"],
"description": "read-only view of the same browser"
}
}
Whether an alias shares a process with its target depends on whether its leasing matches. Pool identity covers the command, arguments, environment, working directory, transport, sharing, scope, maxima and timeouts — everything that changes the process or how it is handed out. Tool filtering is not in it, because filtering is presentation.
So the example above shares one browser with chrome_devtools: opening a page
through the full view and listing pages through the restricted one shows the
same page, from the same pid. Change the alias’s scope and it becomes a
separate process instead. mcpx status reports a shared pool once, under
every name that reaches it.
Chains are rejected. An alias of an alias is a puzzle, and the error says to point at the original.
2026-09-27T13:00:00-05:00
The script contract
id: the-script-contract
since: pre-release
created: 2026-09-27T14:30:00-05:00
last-updated: 2026-09-27T14:30:00-05:00
status: standard
tags: area:scripts, area:logging
description: stdout is the result, stderr is logs, a default export is the
entry point, and --json wraps the lot.
stdout is the result. stderr is diagnostics. A script that prints nothing to stdout returns nothing.
A default export is an entry point. If a module has one, mcpx calls it and prints whatever it returns; if it does not, top-level code runs on import as before. One file is therefore both importable and runnable without ceremony:
import tools, { log } from "./mcpx-client.ts";
export function countFiles(query: string) { /* importable */ }
export default async function main(args: string[]) {
log.info("searching for {query}", { query: args[0] });
return { count: await countFiles(args[0]) }; // printed as JSON
}
main receives argv as an array, the way every other main does.
mcpx run --export countFiles script alpha calls a named export instead, with
arguments spread — --export f a b reads as f(a, b). Naming an export
that does not exist lists the ones that do.
Streaming results. A return value is one answer at the end; emit() is
many answers as they are found:
for (const file of files) emit({ file, findings: await scan(file) });
Each value is written immediately — one JSON line on stdout in ordinary use,
or collected in order into the envelope’s results array under --json. A
script can stream and return: the streamed values are the progress, the
return value is the conclusion.
Logging is available to scripts and shares the daemon’s renderer:
log.info("fetched {count} pages in {ms}ms", { count: 3, ms: 412, url });
19:44:45.911 INFO fetched 3 pages in 412ms url=https://example.com
A message may carry {placeholders} filled from the attributes; {{x}} writes
a literal {x}. Both forms are kept: the interpolated message for reading, the
template for grouping records that differ only in their values. An attribute
consumed by the template is not repeated in the trailing key/value list. A
missing placeholder is left visible rather than blanked, because a hole in a
sentence is a bug worth seeing.
The first argument after the message becomes attributes when it is a plain
object. Anything else, and anything after it, is collected into an args
array, so console-style calls keep their values instead of dropping them. An
Error is captured as name, message and stack rather than stringified:
log.error("upload failed", err); // err becomes args[0], structured
log.debug("state", { id }, "extra", 42); // id is an attribute
Call sites are recorded for warn and above by default. Capture costs
about 5 microseconds in a script – measured, and roughly fifty times the cost
of the record it decorates – because building the stack trace is the expensive
half. That is worth paying where something went wrong and wasteful on routine
progress, so the default traces the levels you would actually investigate.
--log-source alone widens it to everything, --log-source=error narrows it,
--log-source=false turns it off.
A filtered call costs nothing. The script knows the active threshold, so
log.debug() below it returns after a comparison: 0.026 microseconds measured,
against 0.10 for the encode-and-write it used to do and 5 for a traced one.
Debug logging can be left in.
Binding context works as it does in slog:
const scoped = log.with({ run: runId, phase: "scan" });
await scan(scoped, file);
The returned logger has to be passed where it is needed. JavaScript has no ambient context, so bindings do not follow the call stack by themselves; the alternative would be a hidden global, which is worse than an explicit argument.
Records travel on stderr behind a U+001E marker rather than through the
daemon, so logging works with no daemon reachable, costs no round trip, and
cannot reorder against the script’s own output. Anything else on stderr passes
through untouched.
Formats, on --format, for both scripts and the daemon:
text (default) | 19:44:45.911 INFO fetched 3 pages url=x |
logfmt | ts=… level=info msg="fetched 3 pages" count=3 |
json | one object per line, with msg and template |
json-pretty | indented |
compact | INFO fetched 3 pages |
bare | fetched 3 pages |
--log-level sets the threshold; MCPX_LOGGING_FORMAT and MCPX_LOG_LEVEL set
defaults. The daemon renders through the same writer, so one choice governs
everything.
Imports are optional. The launcher installs the standard surface on
globalThis before importing a script, so a one-liner needs no imports:
export default async function main() {
log("starting"); // log() is log.info()
emit({ partial: 1 });
return await fff.grep({ query: "TODO" });
}
Importing still works and yields the same objects, which is what an editor
wants. mcpx also writes mcpx-globals.d.ts beside the script so a language
server knows the globals exist without an import.
console is captured. console.error, warn, info and debug become
records, so they get enrichment, formatting and the durable log instead of
being bare text on stderr. console.log is left on stdout – it is the
script’s result and redirecting it would change what a caller reads – but is
also recorded, marked file-only so the terminal does not show it twice.
--no-capture-console turns all of this off.
Wrapping a run. --prefix and --suffix add lines around a script without
the script knowing. For a file they run in the generated launcher — before the
module is imported and after its entry returns, in a finally so cleanup
survives a throw:
mcpx run --prefix 'log.info("starting {name}", { name: script.name });' \
--suffix 'log.info("took {ms}ms", { ms: Math.round(result.ms) });' report
Prefix lines see script (path, name, args, export); suffix lines also see
result (ok, value, error, ms). They can set globals the script reads, but
cannot declare bindings inside its module scope — ESM does not allow it.
Lines layer across configuration: a list element of null (or - on the
command line) splices in whatever was inherited, so a nearer config can extend
a farther one instead of only replacing it.
--env KEY=VALUE sets variables for the run.
mcpx --json run wraps a whole run in one document — stdout, the parsed
result, captured logs, the script’s own stderr, exit code, duration and
runtime. Nothing leaks to the terminal alongside it:
{ "ok": true, "exitCode": 0, "durationMs": 97, "runtime": "deno",
"result": { "count": 24 },
"logs": [ { "level": "info", "msg": "searching for flake.nix",
"template": "searching for {query}", "query": "flake.nix" } ] }
2026-09-27T14:30:00-05:00
Named scripts
id: named-scripts
since: pre-release
created: 2026-09-27T04:45:00-05:00
last-updated: 2026-09-27T05:05:00-05:00
status: standard
tags: area:scripts, area:cli
description: .mcpx/scripts/<name>.ts, searched upward from cwd then the user
config directory; nearest wins.
mcpx run <name> resolves a bare name against, in order:
./.mcpx/scripts/<name>.ts, then the same path in each parent directory up to the filesystem root — so a repo’s scripts override a parent’s;$XDG_CONFIG_HOME/mcpx/scripts/<name>.ts;~/.config/mcpx/scripts/<name>.ts.
Anything path-shaped — containing a separator, or ending .ts, .js, .mts —
is used verbatim, so absolute paths and ./local.ts keep working unchanged.
mcpx scripts lists what is reachable, using each file’s first comment line as
its description and marking entries shadowed by a nearer copy:
NAME DESCRIPTION PATH
shot-flow Screenshot a page, click a link, wait for load, scree… /tmp/demo/.mcpx/scripts/shot-flow.ts
Arguments after the name reach the script as Deno.args. The working directory
is yours, not the script’s, so a relative output path means what it says on
the command line.
2026-09-27T05:05:00-05:00
The generated client
id: the-generated-client
since: pre-release
created: 2026-09-25T18:10:00-05:00
last-updated: 2026-09-27T05:05:00-05:00
status: core
tags: area:codegen, area:types
description: JSON Schema compiled to a typed TypeScript module written beside
the script, so the runtime's own module cache applies.
mcpx compiles each tool’s JSON Schema into a TypeScript function and writes the
module to mcpx-client.ts next to the script. Enums become literal unions,
nullable types become unions with null, $ref is resolved, cycles bottom out
in unknown, and descriptions become JSDoc.
Writing a real file is the reason scripts start in tens of milliseconds. The
predecessor imported its client over HTTP with --reload, which re-downloaded
and re-typechecked the module graph on every run and cost seconds.
Results arrive unwrapped: structuredContent parsed, JSON-in-text parsed,
plain text as a string, everything else with .raw carrying the full envelope.
isError throws a ToolError carrying server, tool and raw.
mcpx client -o <path> writes the module for a checked-in script so an editor
can type-check it.
2026-09-27T05:05:00-05:00
Calls without a JavaScript runtime
id: calls-without-a-javascript-runtime
since: pre-release
created: 2026-09-25T18:10:00-05:00
last-updated: 2026-09-27T05:05:00-05:00
status: standard
tags: area:cli
description: mcpx call runs one tool with no script and no runtime, in ~47ms.
$ mcpx call fff_nix.find_files '{"query":"flake.nix","maxResults":3}'
$ mcpx --json call codedb.definition '{"symbol":"Registry"}' | jq -r '.content[0].text'
About 47 ms, no JS runtime involved. --raw prints the full MCP envelope;
--session <key> joins an existing lease on a stateful server.
2026-09-27T05:05:00-05:00
One daemon per config
id: one-daemon-per-config
since: pre-release
created: 2026-09-26T00:10:00-05:00
last-updated: 2026-09-27T05:05:00-05:00
status: standard
tags: area:daemon, area:multirepo
description: the daemon is keyed to the config file it loaded, so repos do not
share servers and a config edit takes effect immediately.
Without this, the first project to run a command would decide which MCP servers exist for every other project on the machine, and editing a config would leave a stale daemon serving the old one.
The key is a fingerprint of the config path plus its bytes. Different config,
different daemon. Edited config, new daemon. Auto-started daemons exit after
four idle hours; one started deliberately with mcpx daemon or under launchd
stays up.
mcpx daemons lists them all, mcpx stop --all clears them.
On macOS the unix socket falls back to a short hashed path under $TMPDIR when
the state directory would exceed the 104-byte sun_path limit, because the
failure mode is otherwise an opaque bind: invalid argument.
2026-09-27T05:05:00-05:00
Runtime portability
id: runtime-portability
since: pre-release
created: 2026-09-25T19:40:00-05:00
last-updated: 2026-09-27T05:05:00-05:00
status: standard
tags: area:runtime
description: deno, then bun, then node; the generated client avoids syntax any
of them reject.
--runtime deno|bun|node overrides detection; "runtime" in the config sets a
default. Deno runs with --no-check, since the generated client is
machine-written and a type error in the agent’s own script surfaces at runtime
anyway.
The client is written to avoid TypeScript that Node’s strip-only mode rejects.
A cross-runtime test caught constructor parameter properties failing on Node
only, so the client uses plain fields. A second test asserts session isolation
holds on all three, because a runtime that cannot read MCPX_SESSION would
silently re-share every stateful server.
2026-09-27T05:05:00-05:00
Remote servers without a shim
id: remote-servers-without-a-shim
since: pre-release
created: 2026-09-25T18:10:00-05:00
last-updated: 2026-09-27T05:05:00-05:00
status: standard
tags: area:transport
description: Streamable HTTP and SSE are spoken directly; no mcp-remote
process in between.
"context7": { "url": "https://mcp.context7.com/mcp", "transport": "http" }
Connects in about 800 ms with no Node shim process. Batched frames and SSE
streams are both handled; Mcp-Session-Id is tracked and released with a
DELETE on shutdown. OAuth is not implemented — see below.
2026-09-27T05:05:00-05:00
Honest failure reporting
id: honest-failure-reporting
since: pre-release
created: 2026-09-26T04:30:00-05:00
last-updated: 2026-09-27T05:05:00-05:00
status: standard
tags: area:diagnostics
description: a server that will not start is reported with its stderr instead
of vanishing from the namespace list.
codebase_memory 0 shared 0 error server "codebase-memory": initialize: mcp error -32000: con…
mcpx status has the full text, including the child’s stderr tail. Start
failures enter an exponential cooldown capped at 30 s so a broken server cannot
spin, and a failed schema fetch deliberately does not record a cache
timestamp — otherwise a server that never started would persist as “cached,
0 tools” and the error would be lost across restarts.
Child processes are killed by process group, so browsers do not outlive the daemon.
2026-09-27T05:05:00-05:00
Search
id: search
since: pre-release
created: 2026-09-25T18:10:00-05:00
last-updated: 2026-10-30T00:00:00-05:00
status: standard
tags: area:discovery
description: ranked tool search, all-terms first with an any-term fallback.
Exact name match scores 100, prefix 60, a config tag 50, substring 40, a
split/stemmed name word 40, function path 30, namespace 20, description 10.
Names and terms are split on _ and camelCase boundaries and folded
through a light suffix stemmer, so screenshots finds take_screenshot.
Every term must match by default, which narrows well; when that returns
nothing the query is retried as any-term, so mcpx search screenshot click
returns both tools rather than neither.
A server’s config may declare tags (inherited by all its tools) and
per-tool toolTags; --tag <tag> filters on them exactly, and catalog
--bias counts a tag hit like a name hit. --explain prints the per-term,
per-field breakdown behind each hit’s score.
-n <count> caps results, default 20.
2026-10-30T00:00:00-05:00
Lifecycle and operations
id: lifecycle-and-operations
since: pre-release
created: 2026-09-25T18:10:00-05:00
last-updated: 2026-09-27T05:05:00-05:00
status: standard
tags: area:ops
description: status, restart, refresh, stop, daemons, and the trace switch.
| Command | Effect |
|---|---|
mcpx status [-v] | daemon, pools, live instances, pids, call counts, sessions — never starts a daemon; reports “not running” instead (#1866) |
mcpx restart [<ns>] | stop instances; the next call starts fresh ones |
mcpx refresh | re-read every server’s schemas |
mcpx daemons | every daemon for this user |
mcpx stop [--all] | shut down this config’s daemon, or all |
mcpx daemon --port N | run in the foreground |
mcpx config [--path] | resolved configuration |
mcpx init [--global] | starter config |
Idle instances are reaped on a 30-second timer against each server’s
idleTimeout. MCPX_TRACE=1 logs one line per tool call naming the instance
that served it. mcpx stop flushes the schema cache before closing its
listeners, so a stop followed immediately by a directory removal cannot race.
2026-09-27T05:05:00-05:00
Reading the log
id: reading-the-log
since: pre-release
created: 2026-09-27T21:00:00-05:00
last-updated: 2026-09-27T21:00:00-05:00
status: standard
tags: area:diagnostics
description: mcpx log and mcpx stats, over a SQLite index of the JSONL files.
Every record the daemon and every script produce is already written as JSON
lines under the state directory. That file is the record of truth and nothing
else writes to it. Beside it sits index.db, a SQLite index built from it, and
the only reason it exists is that questions like “which tool is slow” are
queries, not greps.
The index is rebuilt by reading, not by a background thread. mcpx log and
mcpx stats ingest before they answer, and ingest is incremental: a file whose
size and mtime have not moved is not opened at all. A background indexer would
be faster in the rare case and quietly wrong in the bad one, and an index
nobody trusts is worse than no index. Deleting index.db costs a rescan and no
data.
mcpx log --since 1h --level warn recent trouble
mcpx log --event server.* --server fff one server's lifecycle
mcpx log --grep 'timed out' --limit 50 regular expression over msg and attrs
mcpx log -f tail as records arrive
mcpx log --chain cal-9f3c... a call and everything that led to it
--chain is the one worth knowing. A record carries the id of the thing it
happened inside, and only the record that creates something carries its
parent, so walking backwards from a tool call reaches the server instance that
served it and then the daemon that started that. It prints as an indented tree,
oldest first.
mcpx stats aggregates the same index. calls groups by server and tool with
exact p50/p95/p99 — exact, not sketched, because these are thousands of rows
and an approximation would trade the one property that matters, that the number
printed is a call which really took that long. slowest prints individual
calls with their trace ids, which is where --chain comes from. servers and
instances cover process lifecycle, aggregate and one row per process
respectively; errors groups on the message template rather than the
interpolated text, so one recurring failure is one row; sessions says what
each script run did; volume is records per level per hour plus what the logs
cost on disk.
mcpx log sql '<select ...>' is the escape hatch, with --schema for the DDL
and --path for the file. Anything that is not a read is refused. That is a
guard rail rather than a security boundary: the index can be deleted and
rebuilt at will, so it is protecting someone who typed DELETE meaning SELECT,
not defending against anyone.
The driver is modernc.org/sqlite, a pure-Go translation of SQLite. A cgo
driver would be faster and would also make the Nix build need a C toolchain and
stop cross-compiling, which is a poor trade for an index.
2026-09-27T21:00:00-05:00
Configuration
id: configuration
since: pre-release
created: 2026-09-25T18:10:00-05:00
last-updated: 2026-09-27T05:05:00-05:00
status: standard
tags: area:config
description: the same mcpServers object other MCP hosts use, plus an optional
per-server mcpx block.
Searched upward from the working directory: .mcpx.json, .mcpx/config.json,
.mcp.json; then $XDG_CONFIG_HOME/mcpx/config.json, ~/.config/mcpx/config.json,
~/.mcpx.json, /etc/mcpx/config.json. MCPX_CONFIG overrides everything.
JSONC comments are stripped, string-literal aware.
Per server: mode, max, min, idleTimeout, callTimeout, startTimeout,
namespace, description, tools, extraIncludeTools, excludeTools, disabled. A top-level pool
block applies any of them to every server. The three tool lists take exact
names, globs (delete_*) or /regexps/, and hide a tool from listings and calls
alike; see Hiding tools.
2026-09-27T05:05:00-05:00
One declaration per setting
id: one-declaration-per-setting
since: pre-release
created: 2026-09-28T02:30:00-05:00
last-updated: 2026-09-28T02:30:00-05:00
status: standard
tags: area:config
description: a setting is described once and is then readable from a file, a
variable and a flag.
internal/settings/registry.go holds every knob mcpx has. Each entry states a
type, a default, a name, a sentence and a paragraph. From that one description
the setting becomes readable from a configuration file at its dotted path,
from a generated MCPX_ variable, and from a generated flag. There is no
wiring, so there is nothing to forget to wire.
What this replaces had a flag in one file, a default in another, an environment lookup in a third and a struct field in a fourth. That has one failure mode and it happened every time: the four drifted, and “what is this set to” became “read all four and guess”. It also meant settings that were documented but unreachable – the plumbing switches could be set from the environment and nowhere else, so a configuration file could describe a value it could not apply.
Defaults are written as strings in the syntax a user would type and go through the same validator as an override, so a default cannot be invalid.
Precedence runs defaults, then configuration files with the nearest last, then the environment, then flags. Every value remembers what it overrode, because “why is this not what my config says” is the most common configuration question and the answer is now in the value itself.
Two spellings of one setting at the same level is an error. On a command line the same spelling twice is not – that is a person editing their own command, and the last one is what they meant. In the environment two variables disagreeing genuinely cannot be ordered, so it is refused rather than guessed.
mcpx config --schema prints the lot. --plumbing adds the internals.
2026-09-28T02:30:00-05:00
Plumbing
id: plumbing
since: pre-release
created: 2026-09-28T02:30:00-05:00
last-updated: 2026-09-28T02:30:00-05:00
status: standard
tags: area:config
description: internal switches for decisions that could reasonably go either
way.
Settings under plumbing. control internals. They work, they are documented,
and there is no ordinary reason to change one.
They exist because the code has guards that could defensibly go the other way.
Whether a directory may stand in for a source string. Whether a bare argument
naming a file is read as one. Whether foo.ts beside foo.js is an error.
Rather than decide permanently and leave the other half of the world stuck,
each guard reads a switch.
The cost is a longer list. The benefit is that nobody has to patch the binary to get past a decision that was never meant to be final.
They are hidden from ordinary help and left out of shell completion, because offering an internal in a tab list is how somebody sets one by accident.
2026-09-28T02:30:00-05:00
Source that can be a file
id: source-that-can-be-a-file
since: pre-release
created: 2026-09-28T02:30:00-05:00
last-updated: 2026-09-28T02:30:00-05:00
status: standard
tags: area:scripts
description: every string-shaped input also accepts a path, or a directory.
--prefix, --before, --on-error, --launcher and the rest accept inline
source, a path to a file, or a directory whose files are concatenated.
The reason is that a snippet does not stay one line. The moment it grows, keeping it in a command is unpleasant and keeping it in JSON is unreadable. Making each setting accept either form costs one resolver; forcing the choice up front costs a rewrite at exactly the point the answer becomes obvious.
Detection is by probe: a value naming something on disk is read as it. @text:
and @file: force the interpretation for the case where a snippet genuinely
collides with a filename, and plumbing.sourceProbePaths turns the probe off.
A directory is concatenated in natural order, so 9 comes before 10. Byte
order gets that backwards, which is wrong for precisely the case the feature
serves – fragments numbered to control their order. Each file is named in a
comment above its contents, because a stack trace into a concatenation is
otherwise unattributable.
2026-09-28T02:30:00-05:00
The launcher is replaceable
id: the-launcher-is-replaceable
since: pre-release
created: 2026-09-28T02:30:00-05:00
last-updated: 2026-09-28T02:30:00-05:00
status: standard
tags: area:scripts
description: a template with named holes, replaceable wholesale or removable
entirely.
A script runs inside a generated launcher. That launcher is a template with named holes:
@header @globals @console @before @prefix @import @entry @onSuccess @onError @suffix
--launcher replaces it with source or a file. --no-launcher removes it
entirely: the script is handed to the runtime with nothing installed, nothing
captured and nothing wrapping the error.
Fills may reference each other, which is what makes rearrangement possible
rather than just substitution – a suffix ending in @prefix runs the prefix
again on the way out. That also makes cycles possible, so the reference graph
is checked before anything is substituted and a loop is reported by naming the
path that closed it. Left unchecked it would be a hang, found by waiting.
A placeholder resolving twice is an error unless named in
plumbing.launcherPlaceholderRepeat, because the usual cause is a mistake that
silently doubles an effect. The entry block is braced so a permitted repeat
genuinely runs twice – allowing a repeat and then emitting code that cannot
parse would be worse than refusing it.
A misspelled placeholder is caught by near-miss comparison, including
transpositions, because @entyr for @entry is a swap and plain edit distance
scores that as two changes. Left in place it becomes a syntax error from the
runtime pointing at a line the user did not write.
--launcher takes a required value and --no-launcher is separate. An
optional-value flag reads better, but Go implements that only by treating the
flag as boolean, and then --launcher mine.ts silently runs mine.ts as the
script with no launcher at all. That was found by testing rather than reading.
2026-09-28T02:30:00-05:00
Search paths
id: search-paths
since: pre-release
created: 2026-09-28T02:30:00-05:00
last-updated: 2026-09-28T02:30:00-05:00
status: standard
tags: area:config
description: lists with a splice point, accepting files as well as
directories.
paths.scripts and paths.config are lists. A null entry stands for the
built-in locations, so ["./mine", null] searches yours first and then the
usual places without restating them. A list without a null replaces outright,
which is what most people mean most of the time.
An entry may name a file rather than a directory. Somebody with one script in an odd place should be able to point at it without inventing a directory to hold it.
A name matching both foo.ts and foo.js is refused. Which one runs was a
coin flip, and preferring one quietly means an edit to the other does nothing
with no indication why. plumbing.allowTsJsOverlap permits it, and then .ts
wins, because a project holding both is almost always compiling one into the
other.
When a script is not found, the error prints the path that was actually searched, marking what was missing and what was a file. That is the question being asked.
2026-09-28T02:30:00-05:00
Checking before running
id: checking-before-running
since: pre-release
created: 2026-09-28T02:30:00-05:00
last-updated: 2026-09-28T02:30:00-05:00
status: standard
tags: area:scripts
description: everything knowable is checked before a server starts.
A malformed --env pair, an unreadable hook, a log directory that is a file –
all knowable before any work begins, all previously found later and more
expensively. A bad --env pair was silently ignored, so the script ran without
the variable and behaved as though it had never been asked for.
Everything is checked in one pass, so a run with three bad paths reports three problems rather than the first and then two more runs. A missing writable directory is created rather than refused, because the intent is unambiguous. A missing optional path is a warning, because refusing to start over an empty script directory would make every fresh checkout noisy.
--typecheck resolves and checks the generated program without running it,
which answers “will every import resolve” before the servers are up. Off by
default because it costs a second or two on a cold module cache; worth turning
on for anything scheduled, where the cost is irrelevant and a broken import at
three in the morning is not. Under Node and Bun it reports that it cannot run
rather than pretending it did, because those strip types instead of checking
them.
2026-09-28T02:30:00-05:00
What the console does and does not capture
id: what-the-console-does-and-does-not-capture
since: pre-release
created: 2026-09-28T02:30:00-05:00
last-updated: 2026-09-28T02:30:00-05:00
status: standard
tags: area:logging
description: console is redirected, faithfully; raw byte writes are not.
All twenty-five console members are handled. Those carrying a level become
that level. console.log still reaches stdout, because stdout is the script’s
result. trace carries structured frames, clear resets indentation without
erasing a durable log, and the devtools markers become file-only records rather
than vanishing.
The test that matters compares a script’s output under capture against the same
script run with --no-launcher, line for line. Not “does it log” but “is it
the same”. That formulation is the only one that cannot drift, and it caught
format specifiers: console.info("%s scored %d", name, n) was rendering as the
template beside its arguments instead of substituting them.
Names are preserved, so console.info.name is still "info". Messages use the
runtime’s own inspect, so console.info({a:1}) reads { a: 1 } exactly as it
would unwrapped – the structured value is already in args, so the message is
free to be the human form.
console.createTask() throws when called bare. So does Deno’s. The test
compares against the unwrapped console rather than asserting nothing throws,
because matching the original includes matching its failures.
Raw writes are not captured. Deno.stdout.write and Deno.stderr.write
go straight out. A script reaching for bytes has asked for bytes, and wrapping
them in records would be the wrong answer. Use console or log for anything
meant to be recorded.
2026-09-28T02:30:00-05:00
Structured stack traces
id: structured-stack-traces
since: pre-release
created: 2026-09-28T02:30:00-05:00
last-updated: 2026-09-28T02:30:00-05:00
status: standard
tags: area:logging
description: frames as data, from V8, in all three runtimes.
Error.prepareStackTrace is V8-specific and in no standard, and works in Deno,
Node and Bun alike. It hands back call sites instead of a formatted string, so
frames arrive as data: function, file, line, column, and the async and native
flags. Parsing the string form loses those flags and breaks on any path
containing the characters the format uses as delimiters.
One trap is worth recording. V8 memoises whatever prepareStackTrace
returned the first time .stack is read. Asking for frames destroys the
string; asking for the string destroys the frames. Both are wanted, so frames
are captured and the string is rendered from them. Two attempts at this each
lost one form before a test asserted both survive.
captureFrames(skip, limit) and errorFrames(err) are available to scripts.
2026-09-28T02:30:00-05:00
Cancellation reaches the transport
id: cancellation-reaches-the-transport
since: pre-release
created: 2026-09-28T03:40:00-05:00
last-updated: 2026-09-28T03:40:00-05:00
status: standard
tags: area:transport
description: a call timeout can interrupt a hung HTTP server.
The Streamable HTTP transport detached its POST from the caller’s context. The intent was presumably that a request in flight should finish rather than be abandoned half-processed, which sounds reasonable and was wrong.
Send is synchronous. A server that accepts the connection and never answers
blocks inside it, before the caller reaches the select that watches for a
timeout. Detaching the request did not make cancellation best-effort, it
removed it: pool.callTimeout could not interrupt a hung server at all, and
the only bound left was the transport’s own ten-minute ceiling.
The request now carries the caller’s context. Protocol-level cancellation is
unaffected, because notifications/cancelled is deliberately sent on its own
background context so that it outlives the request it cancels.
The test blocks a server and asserts Send returns. It fails on the previous
code after five seconds and passes in under one.
2026-09-28T03:40:00-05:00
Where a setting is read
id: where-a-setting-is-read
since: pre-release
created: 2026-09-28T04:30:00-05:00
last-updated: 2026-09-28T04:30:00-05:00
status: standard
tags: area:config
description: what the registry governs, and what still has its own path.
Declaring a setting and validating it is not the same as applying it. A value that is accepted, reported as fine, and then ignored is documentation that lies more quietly than a missing flag does.
These come from the resolved set, and therefore work identically from a file, a variable or a flag:
logging.format,logging.level,logging.source,logging.dircatalog.budget,catalog.biasscript.typecheckpaths.scriptsplumbing.allowTsJsOverlap
Pool settings are read by the configuration loader rather than the registry,
because they are per-server and the server’s own block has to win. pool and
pool are two spellings of one block: pool reads naturally beside
mcpServers, pool is what mcpx config --schema calls it. Both exist
because having one of them silently do nothing would be worse than having two.
Where both appear, pool wins – it is the older spelling, and adding a
synonym should not change what an existing file means.
Still on their own paths, and honestly so: output.json (the global --json
flag is read before any command), paths.state and paths.cache (needed
before settings can be resolved, since they say where to look), and most of
daemon.*.
2026-09-28T04:30:00-05:00
Placeholders that take arguments
id: placeholders-that-take-arguments
since: pre-release
created: 2026-09-28T07:30:00-05:00
last-updated: 2026-09-28T07:30:00-05:00
status: standard
tags: area:scripts
description: @name(a, b), and @names a user declared.
A placeholder may take arguments:
@console(header, prefix)
An argument naming a placeholder expands to it; anything else is literal.
They bind inside the body only, so two uses of one placeholder cannot
contaminate each other. Inside, they are addressable by the names the
declaration gave them, and always as @1, @2 and @args.
This is the difference between a template that can be rearranged and one that can only be filled in.
A file may declare the @name it provides:
// @mcpx:placeholder timed(body, label)
{
const __t = performance.now();
@body
log.info("timing", { label: @label, ms: Math.round(performance.now() - __t) });
}
Three spellings, because the right one depends on the file. A comment works in
any language and cannot affect runtime. An exported MCPX_PLACEHOLDER
constant is visible to tooling. The filename is the least ceremony for a
directory of one-line fragments.
Shadowing a built-in is refused: a template that reads correctly and means something else is the worst kind of surprise. Two files claiming one name is refused for the reason two config keys are – there is no order between them.
paths.placeholders is empty by default, because scanning every script
directory for declarations would make an ordinary script’s filename quietly
meaningful.
The single-resolution rule composes with this. If @timed uses @entry and
the template also uses @entry directly, that is a repeat and is refused
unless named in plumbing.launcherPlaceholderRepeat.
2026-09-28T07:30:00-05:00
The harness knows what the agent does not
id: the-harness-knows-what-the-agent-does-not
since: pre-release
created: 2026-09-28T07:30:00-05:00
last-updated: 2026-09-28T07:30:00-05:00
status: standard
tags: area:integration
description: session identity arrives through the environment, not the model.
mcpx leases servers per session. That only works if it can tell sessions apart, and nothing in a shell command carries that: the agent does not know its own session id, and asking it to pass one would spend tokens on plumbing and be forgotten half the time.
So the harness supplies it. plugin/opencode/mcpx-session.ts puts a handful
of variables into every shell command; mcpx reads them and the agent never
learns any of it happened.
MCPX_TRACE_IDS is a list of pairs rather than a flat id, because a flat one
cannot say “this session, whose parent is that one, in this worktree”. An
entry longer than a pair names several ids for one key. Unknown keys are kept,
since the list exists to grow, and they land on every record as id.<key>.
They cannot overwrite what mcpx established. A caller may add context; it may not rewrite which session a call was actually leased for, or leasing becomes advisory.
Everything past the session id is opt-in. Environment variables are not
context – the model never sees them – so anything cheap goes in on the
reasoning that an unread variable is cheaper than a missing one. Transcript
lengths and token counts stay out: each would be a database query on every
shell command, for a number almost nobody reads. Those come from
mcpx stats --opencode, which asks once.
2026-09-28T07:30:00-05:00
Statistics from opencode
id: statistics-from-opencode
since: pre-release
created: 2026-09-28T07:30:00-05:00
last-updated: 2026-09-28T07:30:00-05:00
status: standard
tags: area:inspection
description: read-only reporting over opencode's own database.
mcpx stats --opencode reports overview, agents, models, projects, busiest
sessions and activity. What a run cost, which model answered, how much was a
subagent’s – those live in opencode’s database and are pruned over time, so
folding them in beside mcpx’s own log means one place to ask.
The database is opened read-only and belongs to a program that may be running.
mode=ro rather than immutable=1, deliberately: immutable would permit
reading a torn page from a live writer. Every statistic checks for the columns
it needs, because that schema is not mcpx’s to depend on – one that has moved
should cost a number, not the command.
The model column holds JSON rather than a name, and it is decoded in Go rather
than with json_extract, which is present in most SQLite builds and absent in
enough of them to be worth avoiding.
2026-09-28T07:30:00-05:00
The explorer
id: the-explorer
since: pre-release
created: 2026-09-28T07:30:00-05:00
last-updated: 2026-09-28T07:30:00-05:00
status: standard
tags: area:ux
description: a prompt loop over the discovery commands, for a human.
Everything mcpx explore shows is available elsewhere. It exists because
discovery is a loop – list namespaces, look at one, read a signature, try it,
see what the log said – and running four commands with different flags to go
round it once is enough friction that people stop and guess instead.
Deliberately not full-screen. A pane interface needs a terminal library, raw mode, resize handling and a redraw loop, and loses the two things a plain prompt gives for free: output stays in scrollback where it can be copied, and every screen corresponds to a command that can be scripted. The footer prints that command, so the tool teaches its own non-interactive form rather than being a place where knowledge stops.
A bare word naming a namespace shows its signatures, because that is what
somebody means nine times in ten. chain with no argument lists recent
traces, because needing another command to find this one’s argument is exactly
the friction being removed.
The terminal check asks the kernel for terminal attributes rather than testing
ModeCharDevice. That test is wrong in the direction that matters:
/dev/null is a character device, so redirecting stdin from it read as
interactive and blocked on a prompt nobody was there to answer.
2026-09-28T07:30:00-05:00
The browser
id: the-browser
since: pre-release
created: 2026-09-28T09:00:00-05:00
last-updated: 2026-09-28T09:00:00-05:00
status: standard
tags: area:ux
description: three panes, so comparing two tools is a keystroke.
mcpx tui, mcpx --tui, or just mcpx when there is a terminal to draw on.
Three panes – namespaces, the selected namespace’s tools, one signature – so the question “which of these two tools do I want” is a cursor move rather than two commands and a scrollback hunt. Moving the cursor loads the next pane; nothing needs pressing.
L switches to the log. r refreshes. / filters either list.
Why both this and the prompt
mcpx explore and mcpx tui answer differently shaped questions. The prompt
is better when you know what you want and will paste the result somewhere: its
output stays in scrollback and every screen names the command that produced
it. The browser is better when you do not know yet, because three panes hold
more context than a scrolling transcript.
The prompt also works where the browser cannot run – over a pipe, in CI, inside another program – so neither is a worse version of the other.
Six views
] and [ move between them; the header names them all with the current one
marked, because a view nobody knows exists is a view nobody uses.
- tools – namespaces, their tools, one signature
- log – every record, newest last
- stats – calls, servers, errors, sessions, slowest, volume;
dcycles - servers – individual processes, with pid, uptime and why each stopped
- sessions – what mcpx saw merged with what opencode recorded
- storage – what all of it costs on disk
Five of the six are grids, rendered through one table type. A new view is therefore a query rather than a widget, and every view gets selection, truncation and drill-down without restating any of it.
sessions is the one that needed both halves. mcpx knows which servers a session used; opencode knows what it cost. Neither alone is the row anybody wants, and a machine without opencode still gets mcpx’s half rather than an error.
storage exists because it answers a question that arrives suddenly – something is large and it is not obvious what – and answering it otherwise means knowing where four different things live.
Opening a row
Enter, or a click. A log line is truncated to fit a terminal, and the part cut off is usually the part being looked for: a stack, a full path, a nested result. Expanding it is the difference between the log being browsable and being a place to notice that something exists before going to another command to read it.
Multi-line strings are indented rather than escaped, since escaping them is what made them unreadable in the row.
The dependency
bubbletea, bubbles and lipgloss. They cost 1.2 MB in the stripped binary, taking it from 11.2 to 12.4 MB, and replace what would otherwise be hand written escape sequences, resize handling and a redraw loop. That is code which is never finished and never correct on every terminal.
Mouse support is on, in cell-motion mode: clicks and the wheel, without an event per pixel of movement.
Capturing the mouse does take over text selection. Every terminal worth using restores it on shift-drag, which is the convention, and being unable to click a log line open is a worse trade than learning one modifier.
Testing a full-screen program
The view reads from an interface, not from the daemon client, so a fake drives it in tests. A terminal program that cannot be tested is one that breaks quietly, and the failures worth catching are not visual: that moving the cursor loads the right namespace, that a late reply for a namespace the cursor has already left is dropped rather than shown, and that keys go to the filter while it is open – without that last one, typing a namespace containing “q” quits.
2026-09-28T09:00:00-05:00
mcpx speaks MCP
id: mcpx-speaks-mcp
since: pre-release
created: 2026-09-28T11:00:00-05:00
last-updated: 2026-09-28T11:00:00-05:00
status: standard
tags: area:integration
description: the inversion: mcpx as a server, not only a client.
mcpx exists so tool schemas never reach a model’s context. A host that speaks only MCP – a different editor, a hosted agent, anything that is not opencode – could not use any of that.
So mcpx serve offers mcpx itself as an MCP server: ten tools reaching every
server it knows about, with the schemas still on this side of the wire.
The surface is small on purpose. Exposing three hundred tools over MCP would
rebuild the problem mcpx was built to solve, with extra steps. mcpx_exec is
the one that matters: it runs TypeScript next to the servers and only what it
prints comes back.
stdio and Streamable HTTP, plus a plain POST per tool at /v1/tools/<name>.
Offering only JSON-RPC would make mcpx reachable from MCP hosts and from
nothing else, which is the opposite of the point – a shell script with curl
should ask the same questions an agent does.
2026-09-28T11:00:00-05:00
Command-line programs as servers
id: command-line-programs-as-servers
since: pre-release
created: 2026-09-28T11:00:00-05:00
last-updated: 2026-09-28T11:00:00-05:00
status: standard
tags: area:integration
description: a declaration, not a wrapper process.
An enormous amount of capability already exists as command-line programs. Writing an MCP server to wrap one is a day’s work producing a process whose only job is to shell out, and there are hundreds of such programs.
paths.adapters points at declarations instead:
{ "adapters": [{
"name": "gitx", "command": "git",
"tools": [
{ "name": "log", "args": ["log", "--oneline"],
"params": [{ "name": "limit", "flag": "-n", "type": "integer", "default": "10" }] }
] }] }
That is git as an MCP server. It appears in tools/list, it is callable from
a script as await gitx.log({ limit: 5 }), and mcpx adapter check says
whether the binary is even installed. The daemon runs each adapter as a server
of its own – mcpx adapter serve gitx, spawned like any upstream – so it is
a namespace in mcpx ls, types, search, catalog, /v1/call and the
generated client, with a signature typed from the declared params. An
adapter cannot share a name with a configured server.
internal/e2e/toolparity_test.go holds every tool in tools/list to being in
the generated client, and calls one from a script.
Not a shell escape. A tool is a named subcommand with declared parameters, so a model cannot invent a command line and what is reachable is exactly what somebody wrote down.
Two details that are wrong in the obvious implementation. A boolean parameter
becomes the flag’s presence, because --verbose true is wrong for almost
every program ever written. And a non-zero exit is a result rather than an
error: the program ran and said something, and deciding that is a failure
belongs to whoever asked.
2026-09-28T11:00:00-05:00
Finding servers that are not configured yet
id: finding-servers-that-are-not-configured-yet
since: pre-release
created: 2026-09-28T11:00:00-05:00
last-updated: 2026-09-28T11:00:00-05:00
status: standard
tags: area:integration
description: the official registry, and why not HAPI.
mcpx registry search weather asks a registry what exists. mcpx registry add <name> --write puts it in the configuration file.
Which registry
The official MCP Registry, at registry.modelcontextprotocol.io. It is
unauthenticated, returns real data, and – the part that matters – publishes
an OpenAPI specification that other registries implement. Writing against the
specification rather than against one host means the same code reaches the
official registry, a vendor’s subregistry, and whatever an organisation runs
internally to control what its agents may install. registry.url points it
anywhere.
HAPI was the other candidate and is not usable: the framework is not open source and its public API returns 404.
Translating an entry
A registry entry says what a server is, not how this machine should run it.
Remotes are preferred when offered, because nothing is installed and nothing
runs locally. Otherwise the package becomes an ephemeral runner – npx -y,
uvx, docker run --rm – since a server pinned in a config file should not
also require the machine to have been prepared. A publisher’s own runtime hint
wins over that table, because they know something about their package that a
table does not.
npm versions are pinned. A configuration that silently upgrades is one that breaks on a morning nobody changed anything.
Secrets are never invented. The registry says which variables a server requires; those are reported so the caller can set them, because a server that exits immediately for a missing key looks broken rather than unconfigured.
The reverse-DNS name is stripped to its last component, so
io.github.microsoft/playwright-mcp becomes playwright_mcp rather than
making every call read io_github_microsoft_playwright_mcp_navigate.
2026-09-28T11:00:00-05:00
Specifications as tools
id: specifications-as-tools
since: pre-release
created: 2026-09-28T13:00:00-05:00
last-updated: 2026-09-28T13:00:00-05:00
status: standard
tags: area:integration
description: OpenAPI documents become callable tools.
mcpx api tools --spec https://petstore3.swagger.io/api/v3/openapi.json
mcpx api call petstore_findpetsbystatus '{"status":"available"}' --spec ...
An enormous amount of capability is already described by a specification somebody else maintains. A specification is a better source than a hand-written wrapper for the same reason a generated client is better than a hand-written one: it is already correct, already complete, and it changes when the service does.
paths.apis declares them permanently, and the operations appear over MCP
beside everything else.
Two things easy to get wrong
Relative server URLs. The Swagger petstore declares /api/v3, which must
resolve against wherever the document was fetched. Unresolved it produces a
request with no scheme, and the failure reads as a network problem rather than
an unresolved reference.
Only reads, by default. A specification describes what a service can do,
not what you meant to allow. --methods all exposes the rest; the difference
between listing orders and cancelling them should be a deliberate keystroke.
Deprecated operations are skipped unless asked for, and labelled when included. Path parameters are escaped, because a slash in one silently changes which endpoint is called.
2026-09-28T13:00:00-05:00
Finding a tool without leaving the script
id: finding-a-tool-without-leaving-the-script
since: pre-release
created: 2026-09-28T13:00:00-05:00
last-updated: 2026-09-28T13:00:00-05:00
status: standard
tags: area:scripts
description: search() and describe(), inside the program.
for (const hit of search("grep")) {
console.log(hit.namespace + "." + hit.tool, hit.required);
}
console.log(describe("fff_nix.grep"));
Without these, discovering a tool means ending the script, running
mcpx search, reading the result and writing a new script. That round trip is
the expensive part: for a model it is a whole turn, and the intermediate
result passes through its context on the way.
Synchronous, because everything they search is already in the generated client. A promise would only be a promise of work already done.
A word matching a tool’s name scores higher than one matching its prose, so a
tool called grep beats one whose description merely mentions grepping.
2026-09-28T13:00:00-05:00
The half of MCP that is not tools
id: the-half-of-mcp-that-is-not-tools
since: pre-release
created: 2026-09-28T15:00:00-05:00
last-updated: 2026-09-28T15:00:00-05:00
status: standard
tags: area:protocol
description: prompts and resources, which mcpx reported as empty.
A server publishes three things: tools, resources and prompts. mcpx handled tools, read resources only when asked by URI, and reported no prompts at all – while its MCP server declared the capability and returned an empty list.
That last part is the worst kind of wrong, because a client cannot detect it. It asks once, receives nothing, and never asks again.
mcpx prompts # what servers publish
mcpx prompts demo.summarise text="..." # render one
mcpx resources # what exists
mcpx resources demo/demo://greeting # read one
Prompts are a server saying “here is the wording that works for this” rather than “here is a function”. A server publishing a good one has encoded expertise that would otherwise be rediscovered by whoever writes the request.
Both pass through the MCP server, namespaced – two servers may publish the same URI, and a caller otherwise has no way to say which it meant.
A server that does not support prompts answers method-not-found. That is an absence, not a failure, so it is treated as an empty list; the alternative makes every listing fail on the majority of servers.
Binary resources are described rather than inlined. A megabyte of base64 in a model’s context is the failure this whole tool exists to prevent.
2026-09-28T15:00:00-05:00
mcpx doctor
id: mcpx-doctor
since: pre-release
created: 2026-09-28T15:00:00-05:00
last-updated: 2026-09-28T15:00:00-05:00
status: standard
tags: area:ux
description: one command for "it does not work".
mcpx has a daemon, a runtime, a configuration chain, a log index, adapters, registries, API specifications and a plugin. “It does not work” stopped being a question with one answer some time ago.
mcpx doctor checks each in the order somebody would have to know to check
them by hand: the runtime, git, whether the settings contradict each other,
whether the configuration parses, whether every server’s command is actually
installed, the directories, the daemon, the log index, and the optional
integrations.
That server-command check is the important one. It is the most common cause of “mcpx does not work” and it is invisible until something tries to call the server, at which point the error arrives from three layers down.
Every check says what to do about it. One that only reports a problem leaves the reader exactly where they started.
It found a bug immediately
The unknown-key warning was reporting mcpServers, logging, paths and
every server name as unrecognised. They are not: a section is the path to a
setting, and mcpServers is the document’s own content. The warning listed
everything, which made a real typo invisible among the noise – so it had been
useless since the day it was written, and nothing displayed it until now.
2026-09-28T15:00:00-05:00
Types, in whichever form a consumer reads
id: types-in-whichever-form-a-consumer-reads
since: pre-release
created: 2026-09-28T16:30:00-05:00
last-updated: 2026-09-28T16:30:00-05:00
status: standard
tags: area:integration
description: four renderings of one fact.
mcpx schema --format typescript # what a script imports
mcpx schema --format json-schema # what a validator reads
mcpx schema --format openapi # what a client generator reads
mcpx schema --format mcp # what an MCP host reads
The same information every time. A caller should not have to reshape one into another, and each of those four consumers is real.
JSON Schema goes under $defs, which is where a reader looks for named
subschemas, so the result is itself a valid schema document rather than a bag
of them.
The specification described the wrong thing
mcpx openapi described mcpx’s own ten tools and nothing else. The three
hundred it actually fronts were reachable from MCP, and from a script, and
from nowhere a generated client could see.
A specification that describes the wrapper but not what it wraps is a specification of the wrong thing. Every upstream tool now has a path:
POST /v1/call/<namespace>/<tool>
Served as well as described – curl -X POST .../v1/call/fff/grep -d '{"query":"x"}'
reaches the same server a script does. The document is generated per request
rather than at startup, so a server that appears later is described without a
restart.
2026-09-28T16:30:00-05:00
Two protocol eras
id: two-protocol-eras
since: pre-release
created: 2026-09-28T20:00:00-05:00
last-updated: 2026-09-28T20:00:00-05:00
status: standard
tags: area:protocol
description: legacy and modern, both directions.
The specification splits implementations in two. Legacy revisions
(2025-11-25 and earlier) negotiate once through an initialize handshake.
2026-07-28, the current one, carries the version in _meta on every
request, has no handshake at all, and requires server/discover.
The matrix is unforgiving: modern against legacy fails, legacy against modern fails, only a dual-era implementation bridges. mcpx is dual-era on both sides.
As a client it probes and falls back. Legacy first by default, because
nearly every server in existence is legacy and probing modern first costs a
wasted round trip on all of them. That is correct today and will stop being
correct, which is why PreferModern, ForceLegacy and ForceModern exist.
A recognised UnsupportedProtocolVersionError stops the fallback: it
identifies a modern server, so the version is wrong rather than the era, and
falling back would report the wrong problem.
As a server it answers both, and no longer lies. negotiate() used to
echo whatever version was asked for, so a client requesting 2026-07-28 was
told yes and then found no server/discover. It now answers
UnsupportedProtocolVersionError (-32022) with the list that would work,
and refuses a modern version over initialize at all – a client sending
initialize is legacy by definition, so agreeing would promise a protocol
neither side is speaking.
2026-09-28T20:00:00-05:00
When a server asks a question
id: when-a-server-asks-a-question
since: pre-release
created: 2026-09-28T20:00:00-05:00
last-updated: 2026-09-28T20:00:00-05:00
status: standard
tags: area:protocol
description: elicitation, stored rather than blocked on.
Every other MCP method runs client to server. Elicitation runs the other way: a server stops mid-call and asks something – which repository, are you sure, log in here.
The bug that came first
recvLoop matched inbound frames on their id alone. A server-initiated
request has an id and a method, so it looked like a reply to nothing and was
discarded. The server waited, the call hit its timeout, and mcpx reported a
timeout: true, useless, and pointing at the wrong thing.
Frames are now routed by shape, and a question is always answered – with
cancel when nobody was asked, because silence is indistinguishable from a
hung server while cancel is honest about nobody having chosen.
A question is state, not a blocked call
That is the whole design. A question gets an identity, a deadline and a row in the database beside the log index. The call stops and says “I need input, here is the ticket”.
So whoever answers need not be whoever asked. CI raises a question a
person answers from a laptop twenty minutes later; the reattach works because
nothing was ever held in memory. A call that is waiting exits 75
(EX_TEMPFAIL), which is what it is: not a failure, a “try again when you
have an answer”.
Expiry answers cancel, never decline. Expiry means dismissed without
choosing; telling a server the user declined would say something different and
untrue. Overdue questions expire when something reads them rather than on a
timer, so there is no sweeper to go wrong and nothing is reported as pending
when it is not.
Who answers
The specification leaves this to the client, deliberately: “If the client is an agent, it might decide how to handle the elicitation.”
The default is the agent, and that is usually right – it asked for the thing, the question is part of that request, and it has the context. A human is pulled in only for what an agent cannot know or should not hold: a credential, a browser flow, or a bare confirmation, since consent is not the agent’s to give.
Every routing decision records its reason. Routing nobody can inspect is routing nobody can correct.
Answering
mcpx elicit list
mcpx elicit answer elc-9f2c1a84 repo=me/thing # key=value, or JSON
mcpx elicit decline elc-9f2c1a84
key=value is accepted because most answers are one short string, and making
somebody quote JSON for that is ceremony. mcpx elicit show prints the
command that answers a question, because the alternative is assembling it from
three fields and getting it wrong once.
2026-09-28T20:00:00-05:00
Credentials
id: credentials
since: pre-release
created: 2026-09-28T20:00:00-05:00
last-updated: 2026-09-28T20:00:00-05:00
status: standard
tags: area:config
description: what the specification actually requires, which is little.
The specification’s position is short and the opposite of what people assume:
- Authorization is optional.
- stdio transports SHOULD NOT use OAuth. They take credentials from the environment – a child process is trusted because you started it.
- HTTP transports SHOULD use OAuth 2.1 when the server is protected.
So most servers need nothing.
{ "mcpServers": { "api": {
"url": "https://example.com/mcp",
"auth": { "type": "bearer", "token": "${API_TOKEN}" } } } }
bearer, basic, header, query and env, all with ${VAR} expansion so
the secret is never in the file. An unset variable is named up front
rather than surfacing as a 401 three layers away.
Describe() prints a reference but never a literal, because its output is
what people paste into issues.
oauth is declared but not performed. A server requiring it says so before
the first request rather than failing with a 401 nobody can interpret.
2026-09-28T20:00:00-05:00
The rest of the protocol
id: the-rest-of-the-protocol
since: pre-release
created: 2026-09-28T22:00:00-05:00
last-updated: 2026-09-30T14:30:00-05:00
status: standard
tags: area:protocol
description: the features that are not tools, and which of them mcpx has.
Tools, resources and prompts are the famous three. The specification defines several more, and mcpx was silently dropping most of them.
Now implemented
Roots. A client tells a server which directories it may work within. Without it a filesystem server has to be told through its own configuration, separately, in a second place that drifts from the first.
Server log messages. notifications/message carries a server explaining
itself – “retrying against the replica” tells you exactly why a call was
slow. Every one was discarded. Note that servers send none until asked, via
logging/setLevel, so a client that never asks concludes servers do not
emit any.
Progress. notifications/progress on a long call, previously dropped.
List-changed notifications. A server saying its tools have changed means the cached schema is stale. mcpx already computes catalog diffs; it just was not listening for the event that should trigger one.
Pagination when serving. mcpx fronts every tool of every configured
server, and a client with a frame limit had no way to read that list. The
cursor is opaque – base64 of an offset – because the specification says so
and a client that parses one is relying on something it was told not to. An
invalid cursor starts from the beginning rather than failing: a client
cannot validate an opaque value before sending it, so refusing would strand
one that has nothing better to send. That is a deliberate departure from a
SHOULD – the specification asks for -32602, and
docs/spec/server-obligations.md §4.3 records
the obligation being departed from. #257 is where it gets revisited.
Completion. completion/complete answers from what mcpx already holds.
A client that offers autocomplete and receives method-not-found shows
nothing, and the user concludes the feature is broken rather than absent.
Cancellation, inbound. Recorded rather than dropped. mcpx cannot yet interrupt an in-flight upstream call – that needs the request id plumbed through the pool – but a cancellation silently discarded leaves a client unable to tell whether the message arrived.
Sampling. Shipped since this entry was written; see Sampling below for what it does and the ordering bug that made it unreachable at first.
Still not implemented, and declared as such
Capabilities are declared only where mcpx can actually deliver. Claiming one
it cannot serve invites a server to use it and get silence, which is worse
than not offering it at all. What mcpx declares, per revision and per
direction, is docs/protocol.md §2.2; what the
specification would require of a server that declared more is
docs/spec/server-obligations.md.
2026-09-30T14:30:00-05:00
One daemon, many clients
id: one-daemon-many-clients
since: pre-release
created: 2026-09-28T23:30:00-05:00
last-updated: 2026-09-28T23:30:00-05:00
status: standard
tags: area:integration
description: a socket, a port, or a machine somewhere else.
The daemon has always served its whole API over HTTP. Two things stopped that being useful beyond one machine: the client hardcoded a unix-socket dialler, and the listener hardcoded loopback. Both were one line.
MCPX_DAEMON_ENDPOINT=http://mcpx.internal:8899 # a daemon on a VPN
MCPX_DAEMON_ENDPOINT=unix:///run/mcpx/other.sock # a different local socket
mcpx daemon --address 0.0.0.0 --port 8899 # serve a network
Works for the CLI, the plugin, and generated scripts – which could already
do it, since they were given MCPX_ENDPOINT from the start.
A configured endpoint also means mcpx will not try to start that daemon. Falling back to a local one when a remote is unreachable would silently answer from the wrong machine, which is worse than failing.
Binding beyond loopback prints a warning, once, because the API is unauthenticated: whatever can route to the port can run tools as you. That should be a sentence somebody read rather than a default they inherited.
What each transport costs
Same request, mean of thirty:
spawn mcpx status | 23.12 ms |
| unix socket, new connection | 0.27 ms |
| unix socket, keep-alive | 0.17 ms |
| tcp loopback, new connection | 1.59 ms |
| tcp loopback, keep-alive | 0.65 ms |
Spawning is 135× slower than a warm socket, and almost all of it is process startup rather than transport. Irrelevant for something run once; decisive for anything on the path of every tool call.
plugin/opencode/mcpx/daemon.ts therefore talks to the socket directly rather
than shelling out. It returns undefined when no daemon is reachable rather
than throwing, because a plugin that fails to load has broken the editor for
a tool the user may not even be using.
It sat unused for a day: written, documented, benchmarked, and imported by
nothing. The plugin still spawned mcpx log record after every tool call.
Wiring it in found three more things, each invisible until something real
used the path:
- The socket was guessed. The client took the newest
.sockin the state directory. Sockets are keyed by configuration, so that is the wrong daemon whenever two exist, and none at all when a long state path moved the socket elsewhere. It now asksmcpx --json status, once. statussaidrunningonly when false. Testing it read a live daemon as down.- One file, two daemons. The key hashed the configuration’s path as
spelled.
/tmpis a symlink to/private/tmpon macOS, so a process whose$PWDheld the short form computed a different key, saw no daemon, and started a second. Paths are now resolved through symlinks first.
With those fixed, a timing record costs 0.09 ms over the socket instead of a 23 ms spawn.
2026-09-28T23:30:00-05:00
Hearing what the daemon is doing
id: hearing-what-the-daemon-is-doing
since: pre-release
created: 2026-09-29T11:00:00-05:00
last-updated: 2026-09-29T11:00:00-05:00
status: standard
tags: area:integration
description: one event bus, two views of it.
MCP’s own subscription mechanism, subscriptions/listen, carries four kinds of
notification: tools, prompts and resources changing, and a subscribed resource
updating. That is the right set for a server telling a client its catalogue
moved, and nowhere near enough for what a client of mcpx wants to hear – a
question waiting for an answer, a server that crashed, a call that finished.
So there is one bus and two views of it:
| carries | for | |
|---|---|---|
subscriptions/listen | the four the specification defines | any MCP client |
GET /v1/events | everything mcpx notices | the plugin, the TUI, a script, curl |
Both read the same stream, so they cannot disagree about what happened. The MCP view is deliberately narrow: the specification says a server MUST NOT send a notification type the client did not request, so nothing mcpx-specific leaks into it.
Server-sent events, not WebSockets
SSE is plain HTTP. It goes through every proxy HTTP does, a browser’s
EventSource reconnects it without any code knowing, and consuming it takes a
GET. WebSockets would buy bidirectionality nothing here needs – answers go
back as ordinary POSTs – at the cost of an upgrade that half of all
middleboxes mishandle.
Lossless reconnection
Every event carries a sequence number. Reconnect with Last-Event-ID (which
EventSource sends for you) or ?since=N and everything after N is replayed.
?since=0 replays everything retained; no position means live only. Those
are different requests, and conflating them – which an early version did –
made “replay from the start” silently return nothing, indistinguishable from
nothing having happened.
A reconnect older than the retained history gets an in-band gap event, so
the subscriber can resynchronise rather than trust a stream with a hole in it.
Publishing never blocks. One slow reader cannot stall the daemon; it misses events and has a counter to say so.
2026-09-29T11:00:00-05:00
Sampling
id: sampling
since: pre-release
created: 2026-09-29T11:00:00-05:00
last-updated: 2026-09-29T11:00:00-05:00
status: standard
tags: area:protocol
description: a server asking for a model's help, passed to whoever has one.
Sampling is part of the MCP specification, in every revision – not an opencode feature. It lets a server ask its client for a model completion: “summarise this”, “classify that”, “draft a reply”. The server gets an intelligence it does not have to host or pay for.
It is the mirror of elicitation:
| asks | for | |
|---|---|---|
| elicitation | a person or agent | a value – which repository, are you sure |
| sampling | a model | text – a completion the server will use |
mcpx has no model, so it cannot answer either itself. What it can do is carry the request to something that can. A sampling request becomes a stored question – same table, same deadline, same routing as elicitation – and whatever drives mcpx answers it. The opencode plugin is the natural answerer: it holds a session with a real model already.
Declining a sampling request becomes an error, because the specification has
no decline result for it. A result missing role or model has them filled,
since strict servers reject one without them for a reason unrelated to its
content.
2026-09-29T11:00:00-05:00
It did not work, and nothing said so
The passthrough above shipped unreachable. A server only sends a sampling
request to a client that declared sampling, and mcpx never did: capabilities
are declared in the handshake, and the handler that made sampling possible was
installed on the connection after it. Every test of the passthrough called
it directly, so every test passed.
The same ordering broke roots. Once the daemon’s handler was installed – which
is always – it received every server request, roots/list included, and knew
only elicitation and sampling. Roots were configured, and every server that
asked for them was told “not implemented”.
Both are fixed by giving the connection its handler and roots at construction, and by answering roots before consulting the handler at all.
2026-09-29T15:00:00-05:00
What 2026-07-28 actually asks of a client
id: what-2026-07-28-actually-asks-of-a-client
since: pre-release
created: 2026-09-29T15:00:00-05:00
last-updated: 2026-09-29T15:00:00-05:00
status: standard
tags: area:protocol
description: three MUSTs the first modern implementation missed.
The first modern-era client probed with server/discover, chose a version,
and then spoke exactly as a legacy client would. Reading the schema rather than
the prose found three requirements it did not meet:
| requirement | was | now |
|---|---|---|
_meta carries protocolVersion and clientCapabilities on every request – a server MUST NOT infer capabilities from an earlier one | sent on none, not even server/discover | stamped on every modern request, with clientInfo |
the HTTP MCP-Protocol-Version header MUST match that _meta version, or 400 | a constant: the legacy version | read from the frame being sent |
every result MUST carry resultType | mcpx’s server sent none | "complete" on every reply to a modern request |
And one mechanism it did not know existed. A modern server cannot send the
client a request of its own – there is no connection to send it on – so it
elicits, samples and asks for roots by answering: resultType: "input_required", a map of inputRequests, an opaque requestState. The
client answers each, and sends the original request again with
inputResponses and the state, exactly as received. mcpx returned the “not
yet” as though it were the result, so against a modern server, elicitation,
sampling and roots silently did nothing.
Both eras now answer through one function, so a question is answered the same
way whether it arrived on the wire or inside a result. The retry loop is
bounded (elicit.inputRounds, 8): a server that never stops asking is broken
or adversarial, and without a bound the client would answer it forever.
Still missing, and honest about it: mcpx’s server does not yet turn an
upstream question into input_required for a modern client of its own. It
answers through the broker instead, which works, but a modern client cannot
answer inline.
2026-09-29T15:00:00-05:00
Running without a daemon
id: running-without-a-daemon
since: pre-release
created: 2026-09-29T11:00:00-05:00
last-updated: 2026-09-29T11:00:00-05:00
status: standard
tags: area:integration
description: the last rung, and what it costs.
Every command walks the same ladder, cheapest first:
1. socket an existing local daemon 0.17 ms
2. url a named remote one 0.65 ms
3. spawn start a daemon, then use it ~23 ms, once
4. inline run the servers in this process every time
A named endpoint stops the ladder at step 2: falling back to a local daemon would answer from the wrong machine.
Inline hosts the daemon’s own API inside the command, on a private socket, for as long as the command runs. It is the identical handler, so it cannot drift from daemon mode.
What changes is lifetime, and it changes completely:
| with a daemon | inline | |
|---|---|---|
| a server starts | once, reused | every command |
| between commands | kept warm | gone |
| a browser session | survives across commands | dies with the command |
| schema cache | shared | read from disk each time |
That is why inline is the last rung and off by default. It is for a sandbox with no fork, a read-only filesystem, or a container whose init will not reap – places where the alternative is not working at all. A pool that silently stops pooling is a performance bug nobody can see, so it has to be asked for.
2026-09-29T11:00:00-05:00
Tasks
id: tasks
since: pre-release
created: 2026-09-29T11:00:00-05:00
last-updated: 2026-09-29T11:00:00-05:00
status: standard
tags: area:protocol
description: a slow tool call as a handle rather than a held request.
Add task to a tools/call and the reply is a handle, immediately. Poll it
with tasks/get, wait on it with tasks/result, stop it with tasks/cancel.
For a genuinely slow tool – a build, a crawl, a browser session – holding a request open for minutes invites every proxy, load balancer and client timeout in between to cut it off. A handle does not.
Core in 2025-11-25, an extension in 2026-07-28, so mcpx declares it both
ways and a client of either era finds it where it looks.
Every task has a TTL. The specification allows an unbounded one; mcpx never offers it, because a result nobody collects is memory nobody frees.
2026-09-29T11:00:00-05:00
Nix packaging
id: nix-packaging
since: pre-release
created: 2026-09-27T04:20:00-05:00
last-updated: 2026-09-30T16:10:00-05:00
status: standard
tags: area:packaging, platform:nix
description: buildGoModule with a pinned vendorHash; unit tests run in the
sandbox; deno, bun and node are pinned on the wrapper's PATH.
mcpx has five direct third-party Go dependencies. modernc.org/sqlite
backs the log index, and it is a pure-Go translation rather than the usual cgo
driver, because a cgo driver would make this derivation need a C toolchain and
would break cross-compilation, for a database that is only ever an index over
files that remain the source of truth. The other four are one decision each:
bubbletea, bubbles and lipgloss for the browser, and gopkg.in/yaml.v3
for OpenAPI documents. Each is argued in
package.nix and listed with its reason in
docs/dependencies.md.
This entry and docs/dependencies.md both said “one” until the browser and the
OpenAPI work landed, while the browser entry above already described three of
the four by name — the file disagreed with itself, which is how a count that
nothing checks decays.
That reasoning was written down and not enforced: buildGoModule leaves cgo
enabled on a native build, so the derivation was free to link against a C
toolchain the comment said it must not need. CGO_ENABLED=0 is now set in the
derivation itself. It surfaced on a machine with a symlinked /nix, where the
cc-wrapper’s purity check trips on gcc canonicalising the path – a build that
should never have consulted a C compiler failing because of one.
The wrapper suffixes git, deno, bun-bin and nodejs onto PATH, so
script execution does not depend on the calling shell and the two repository
layouts native discovery cannot read still resolve. git is a suffix, not a
prefix, and since #223 nothing on the default path needs it – see
Finding the repository without git.
The man page and the shell completions are generated by the binary the build just produced, so they describe the commands and settings this build actually has. A man page maintained separately is wrong within two releases.
nix build .#mcpx runs the unit suites in the sandbox. The end-to-end suite
spawns JavaScript runtimes and binds unix sockets, so it runs outside with
go test ./.... The sandbox list is hand-written (package.nix), and of the
32 packages under internal/ that have tests it names 18 — internal/e2e is
excluded on purpose, and the other fourteen, internal/api, internal/cli
and internal/mcpclient among them, by drift. A list maintained beside a
generated one, which is the failure mode this document keeps finding (#285).
2026-09-30T16:10:00-05:00
The comparison register
id: the-comparison-register
since: pre-release
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T14:30:00-05:00
status: standard
tags: area:compare, area:protocol
description: 511 rows, one per difference between mcpx and the five MCP
revisions, opencode v1 and v2, lootbox and Cloudflare code mode.
mcpx sits between things that disagree: five revisions of the protocol, two of them eras apart; two opencodes with different plugin APIs; three other ways to run code against MCP tools. Every argument about what mcpx should do next was being had from memory.
docs/compare/ is the register that replaced the memory. One
row per difference – one field, one method, one error code, one header, one
behaviour – with where it exists, what mcpx did at commit 05c78b2, what
adopting it is worth, what it would cost, and a citation for every claim. 511
rows; 171 of them labelled mcpx missing.
The point of a register is that it is specific enough to be wrong. A
prose comparison says “mcpx has partial 2026 support”; a register says
server/discover answers protocolVersions where the schema says
supportedVersions, at internal/mcpserver/server.go:534, against
schema/2026-07-28/schema.ts:678. The second can be checked, and checking it
produced twenty-four issues (#199 through #220 and their siblings), each
grouping the rows one fix would close. Several were fixed in the same week
the register was written.
It is dated on purpose. The status column is mcpx @ 05c78b2 and says so in
every table header, because a status column with no commit behind it is a
column nobody can falsify. Twenty-seven pull requests have landed since; the
README lists them and says the column is stale rather than pretending
otherwise.
2026-09-30T14:30:00-05:00
Finding the repository without git
id: finding-the-repository-without-git
since: pre-release
created: 2026-09-30T07:30:00-05:00
last-updated: 2026-09-30T14:30:00-05:00
status: standard
tags: area:config, area:packaging
description: the repo and worktree scopes read .git themselves, so git is no
longer a runtime dependency and the image lost 105 MB.
A server scoped repo gets one process per clone; one scoped worktree gets
one per checkout. Both keys used to come from forking git twice per directory
– rev-parse --git-common-dir and --show-toplevel – and caching the
answers, negative ones included, for the daemon’s lifetime.
That cost four things, and three of them were only found by building the replacement:
- 105 MB of the container image. Debian’s git depends on perl outright.
- It did not work in the container anyway. A checkout mounted into the
image is owned by the host’s uid and the image runs as uid 1000, so git
refuses it:
fatal: detected dubious ownership in repository at '/work/proj'. git was in the image so that these scopes would work, and in the ordinary way of using the image they silently fell back to per-directory keys. - The daemon’s environment leaked into every caller’s key. An autostarted
daemon inherits the environment of whoever ran mcpx first. A git hook
exports
GIT_DIR; a hook that runs mcpx leaves a daemon carrying it for hours, and every later caller from any directory was then keyed to that one repository. - The cache made the first answer permanent. A directory that became a repository after the first call there stayed “not a repository” until the daemon exited.
mcpx now reads .git itself – a port of git’s own discovery rules, written
against them and recorded in docs/git-discovery.md
layout by layout. git is still consulted, once, for the two layouts the
native reader deliberately does not implement, and if it is absent those
layouts degrade to cwd with a log line and a doctor warning saying which
layout and why. That is the whole of the dependency: no fetching, no writing,
no network.
The environment leak is pinned by
TestTheDaemonsOwnGitDirDoesNotPinEveryCaller, which fails against the old
code. The image went from 413 MB to 288 MB.
doctor keeps its git row. An optional dependency whose absence changes
behaviour is exactly the thing a diagnostic should report – dropping the row
because git became optional would have hidden the one case where its absence
still matters.
2026-09-30T14:30:00-05:00
Measured against the official suite
id: measured-against-the-official-suite
since: pre-release
created: 2026-09-30T09:00:00-05:00
last-updated: 2026-09-30T14:30:00-05:00
status: standard
tags: area:protocol, area:conformance
description: every frame validated against the real schema, and the official
MCP conformance suite run against a live daemon in both
directions.
mcpx claims five revisions in each direction. Until this, all five claims were tested against mcpx’s idea of them.
Two things changed that. First, internal/mcpspec validates real traffic
against the official schemas for all five revisions, in strict mode when mcpx
is the sender: a key some other revision defines is a failure, because
“send conservatively” is precisely the rule a lenient check cannot see. That
is what makes a passing test mean something – the previous tests pinned the
wrong server/discover field name on both sides at once, which is how it
passed CI for weeks.
Second, there is now an official suite: modelcontextprotocol/conformance.
It is not optional folklore. SEP-2484, Final, makes a merged conformance
scenario a condition of a Standards Track SEP reaching Final, and SEP-1730
ties SDK tiering to a score from it. Its requirements/<revision>.yaml files
are frozen, so an implementation is measured against the suite as it stood
when it was expected to conform. scripts/conformance.sh runs both legs: mcpx
as a server at the daemon’s /mcp, and – through
internal/conformance/officialclient – mcpx as the client under test.
It found real defects, including two that no amount of reading found: a
modern request missing _meta was answered -32020 when the specification
and mcpx’s own doc comment both say -32602, and the five methods 2026-07-28
removed were still being answered to 2026-07-28 peers. The second is the
limit of “accept liberally”: offering a method a revision never had withholds
nothing, but answering one it removed contradicts the specification naming the
replacement.
The numbers – passed and failed per requirement set, the commit they were
measured at, and which failures are defects – live in exactly one place,
docs/spec/official-suite.md, so they are not
repeated here to go stale.
Read the failure counts carefully; most are not defects. The suite’s
scenarios assume a server implementing its own fixture surface – tools named
slow_compute, prompts named test_simple_prompt, resources under test://.
mcpx is a proxy: it publishes its own small tool set and namespaces every
upstream resource as mcpx://<namespace>/<uri>. A scenario that cannot find
its fixture fails without ever reaching a protocol assertion. An honest
refusal can score worse than a dishonest pass: once #247 made mcpx arrange a
real upstream subscription, subscribing to test://watched-resource, which no
upstream owns, became a failure (#251).
What the specification asks of a server, revision by revision – including the
obligations the prose implies but never states – is
docs/spec/server-obligations.md.
2026-09-30T14:30:00-05:00
Decision records
id: decision-records
since: pre-release
created: 2026-09-30T10:00:00-05:00
last-updated: 2026-09-30T14:30:00-05:00
status: standard
tags: area:process
description: when two issues answer one question differently, the answer is
written down before either is built.
An audit of the backlog found eighteen collisions between themes: pairs of issues proposing different answers to the same question, written by people who could not see each other’s work, none with an owner. Each would have been a breaking change whenever the second one was built.
docs/decisions/ holds the answer to each. A record says what
the code already does, what each issue proposed, which option was taken and
why, and exactly what every affected issue has to change as a result. Three
exist: the hook vocabulary and where a hook’s output goes in the exec stream;
one autonomy dial with a ceiling the daemon sets; and what mcpx declares
against what it enforces.
Two rules keep them honest. A record that lists options without choosing one does not belong here – that is a design document, and it is the shape a decision takes when nobody wants to decide. And a change that contradicts an accepted record changes the record in the same pull request, or it does not merge: a record nobody is obliged to update is a record that describes last month.
2026-09-30T14:30:00-05:00
A command for every operation
id: a-command-for-every-operation
since: pre-release
created: 2026-09-30T13:00:00-05:00
last-updated: 2026-09-30T14:30:00-05:00
status: standard
tags: area:cli, area:parity
description: the CLI, /v1, the MCP tools and the plugin are four renderings
of one declaration, and tests hold each direction.
config = env = cli = /v1 = mcp had been the rule from the start, and the
CLI was the surface that kept falling behind: an operation was easy to add to
the ops table and easy to forget on the command line, and nothing noticed.
A command is now generated from its operation’s declaration – flags from
its parameters, positionals for its path – so it exists the moment the
operation does. Sixty operations, each with a route and a plugin method;
fifty-nine with an MCP tool, because one of them streams and a tool call
cannot. Fifty-two commands, because some cover several operations.
docs/parity.md is generated from the same four declarations,
which is why it cannot describe a surface that does not exist or leave one
out, and ten tests hold the seven directions between them – including that
every command the binary runs is declared, and every declared one runs.
mcpx openapi was the same failure one level up. It printed a hand-written
map maintained beside the generated one, and the map had drifted to describing
a command that was removed: its only declared server was mcpx serve --transport http, which refuses to run, and it listed none of the /v1
operations. Thirteen paths against sixty-four. There is now one document –
the command adds to the bytes the daemon serves rather than competing with
them – and it stays publishable, because the daemon templates upstream tools
as /v1/tools/{tool} instead of enumerating a particular machine’s:
$ mcpx openapi | jq '.paths | length'
64
$ mcpx openapi | jq -c .servers
[{"description":"over the daemon's unix socket","url":"http://mcpx"},{"description":"the loopback endpoint reported by /v1/health","url":"http://127.0.0.1:0"}]
Privileged operations are offered, not hidden. A caller that can reach the
socket can already stop the daemon, so withholding the tool buys no safety and
costs an agent the ability to restart a server that has wedged. What they
carry instead is a description saying so and the MCP annotations
(readOnlyHint, destructiveHint) a client can scope on.
2026-09-30T14:30:00-05:00
Proposed
Everything below is wanted and not yet built. Status fields say so.
Plain scripts returning primitives
id: plain-scripts-returning-primitives
since: pre-release
created: 2026-09-27T05:05:00-05:00
status: proposed
tags: area:scripts
A script’s stdout is already its result, so mcpx run count-todos printing
47 works today. What is missing is a declared contract — an exit-code
convention, and --json on run that wraps stdout, stderr, exit status and
duration in one envelope for programmatic callers.
2026-09-27T05:05:00-05:00
Named, pinned and resumable sessions
id: named-pinned-and-resumable-sessions
since: pre-release
created: 2026-09-27T05:05:00-05:00
status: proposed
tags: area:pools, area:sessions
Leases are anonymous and per-run. Wanted: mcpx session new --name login-flow
to create a named lease that outlives the process, mcpx session ls and
mcpx session search to find them, --session login-flow to rejoin one, and
a TTL so a browser mid-login can be resumed rather than rebuilt.
--session <key> already exists on run, exec and call; the missing parts
are naming, persistence across daemon restarts, and listing.
2026-09-27T05:05:00-05:00
Attaching to another session
id: attaching-to-another-session
since: pre-release
created: 2026-09-27T05:05:00-05:00
status: proposed
tags: area:sessions, area:sharing
Read-only attach for watching what another agent’s browser is doing, and read-write attach for taking over. Needs a lease model that admits more than one holder, plus a permission story for who may attach to whose.
2026-09-27T05:05:00-05:00
Per-session restart
id: per-session-restart
since: pre-release
created: 2026-09-27T05:05:00-05:00
status: proposed
tags: area:pools, area:ops
mcpx restart <ns> stops every instance in a namespace. Wanted:
mcpx restart --session <key>, so one wedged browser can be recycled without
disturbing the other three.
2026-09-27T05:05:00-05:00
Zero-downtime upgrade
id: zero-downtime-upgrade
since: pre-release
created: 2026-09-27T05:05:00-05:00
status: proposed
tags: area:daemon, area:ops
Upgrading means stopping the daemon, which kills every MCP server it holds. Wanted: socket handoff to a new binary with child processes preserved — the same problem opencode v2 solves for its PTY daemon with a handoff ticket.
2026-09-27T05:05:00-05:00
Service installation
id: service-installation
since: pre-release
created: 2026-09-27T05:05:00-05:00
last-updated: 2026-09-30T14:30:00-05:00
status: partial
tags: area:ops, area:packaging
A nix-darwin module existed and was dropped when the package moved into the
nix repo. Wanted: mcpx service install emitting a launchd plist or systemd
unit.
The systemd half now exists as a template rather than a command.
daemon/mcpx.service is a user unit –
default.target, Restart=on-failure – with
daemon/README.md giving the install, verify and log
commands. A user unit rather than a system one because config and state
belong in the real $HOME, and a system unit running as root would need a
HOME override to find either.
A file you copy is not the same as a command that writes it, so this stays
partial: there is still no mcpx service install, nothing emits a launchd
plist, and macOS gets nothing. The completions half of the original want is
done – mcpx completion bash|zsh|fish exists, and the Nix build already
installs what it prints.
Until then the daemon starts on demand from any command, which is the supported path.
2026-09-30T14:30:00-05:00
Alternative input modes
id: alternative-input-modes
since: pre-release
created: 2026-09-27T05:05:00-05:00
status: proposed
tags: area:cli
Today: mcpx exec '<code>' and mcpx run <name|file>. Scripts inherit stdin,
so a script can read it. Missing: mcpx exec - to take the program itself from
stdin, --env-file, and an interactive REPL holding one session across
statements.
2026-09-27T05:05:00-05:00
OAuth for remote servers
id: oauth-for-remote-servers
since: pre-release
created: 2026-09-27T05:05:00-05:00
status: proposed
tags: area:transport, area:auth
Streamable HTTP works; bearer headers can be set in config. Missing: the OAuth authorization-code flow, credential storage, and refresh. Servers needing interactive auth must be reached another way for now.
2026-09-27T05:05:00-05:00
Context-budgeted catalog
id: context-budgeted-catalog
since: pre-release
created: 2026-09-27T05:05:00-05:00
status: proposed
tags: area:discovery, cost:context
mcpx types <ns> is all-or-nothing: 4,402 tokens for chrome_devtools whether
three tools are needed or all 27. opencode v2’s code mode instead fits a
catalog to a fixed token budget, showing every namespace and as many full
signatures as fit, round-robin, shortest first.
Wanted: mcpx catalog --budget 2000 emitting one paste-ready block whose size
is bounded no matter how many servers are configured.
2026-09-27T05:05:00-05:00
OpenAPI documents as tools
id: openapi-documents-as-tools
since: pre-release
created: 2026-09-27T05:05:00-05:00
status: proposed
tags: area:transport
An OpenAPI 3.x document is already a machine-readable description of callable operations. Turning one into a namespace would compose with pools exactly as MCP servers do, and is a large amount of reach for a contained amount of code.
2026-09-27T05:05:00-05:00
Correction, 2026-10-30: this landed. OpenAPI documents are callable
tools today — the daemon merges a server’s OpenAPI operations into its
tool surface and mcpx openapi prints the generated document — so the
proposed status above is the record of the decision, not the state
of the code.
Summary
mcpx puts every MCP server behind one command so their schemas stay out of the model’s context until something asks for them, and leases a separate server process to each script run so that concurrent agents driving stateful servers — browsers, above all — cannot corrupt one another.
The walk above is the whole interface: ls to see what exists, search to
find a tool, types to load one namespace, run to do the work. Everything
else in this document is detail underneath those four verbs.
Notes and links
AGENTS.md— the instruction file abridged above. This is the text to inject into an agent’s session; it is deliberately short, and 152 lines today. The quote above is not the whole of it (#264).ASSESSMENT.md— why this exists, with the measurements against the predecessor it replaced.OPENCODE-V2.md— how this compares to opencode v2’s in-process code mode, including where v2 is the better answer.docs/release-runbook.md— how to keep this document current. Read it before editing anything above.docs/proposals.md— open design questions, and what is true today versus what is merely wanted.docs/ideas.md— unvetted brainstorm intake, before anything has a shape, with answers recorded inline.docs/dependencies.md— every library and external program, and why each is needed.docs/remote.md— reaching a daemon on another machine:ssh://endpoints,ssh -Lsocket forwarding,ssh -Rfor a daemon behind NAT, and MCP over ssh.docs/opencode-plugin.md— what the plugin does and why, opencode v1 against v2, and which parts are opencode’s rather than general enough to port to another harness.scripts/stress.sh— concurrency and leak checks against real servers.scripts/bench.sh— latency comparison.- Model Context Protocol — the spec. Its client best-practices guide documents this pattern as “programmatic tool calling”.
- Cloudflare, Code Mode — where the idea was first argued publicly.
mcpx Development Strategies & Lessons Learned
Verification and Diagnostics
- Never test a system against itself. Driving mcpx’s own
server/discoveragainst mcpx proved both halves agreed, not that either was right — they shared the same wrong field name. Test against the schema, the spec, or a reference implementation. - The signature defect is declared but not delivered (#177): a flag parsed and dropped, a capability advertised and never sent, a setting read but inert.
consumed_test.goproves a setting is read, not that reading it changes anything. - The daemon is keyed by its config file set. Three separate bugs came from this; environment differences that should produce different daemons do not.
- On macOS
/tmpis a symlink to/private/tmpand the product canonicalises paths, so a test usingos.MkdirTemp("/tmp", …)passes on Linux CI and fails on every Mac. nix build’scheckPhasehas had a hand-maintained package list that drifted from the packages that have tests. CI runsgo test ./...and does not.- The official conformance suite ships its own fixture server. Running it against a different server measures nothing. A falling score can mean the code got more correct — it has, twice.
- Under parallel agents, load averages above 150 push
internal/e2epast its default timeout. Raise-timeout; do not read it as a failure.
Audit: features (end to end)
created: 2026-10-01
base: origin/main 19237a0
area: features -- opencode plugin, exec/run, daemon, recipes/prompt, adapters/apis, diagnostics, elicit
method: each feature run against scratch state (HOME/MCPX_STATE_DIR/MCPX_CACHE_DIR under a temp dir),
or its code path read end to end; entries already in docs/in-name-only.md not repeated
States: absent, minimal, partial, untested (as in in-name-only.md). Rows marked works
are kept as positive controls and are not counted.
Opencode plugin (opencode 1.18.31, plugin types 1.15.5)
Hooks invoked under bun with fake client/ctx against a scratch daemon; not loaded into a live opencode.
| what | state | evidence | issue |
|---|---|---|---|
instructions option (experimental.chat.system.transform) | works | pushes the guidance text onto output.system (#1938 fix in mcpx-session.ts); v1 type is output: { system: string[] } (@opencode-ai/plugin index.d.ts:264-268). Regression test plugin/opencode/mcpx/instructions.test.ts invokes the hook with v1’s {system: []} shape and asserts the text lands | #1938 |
remember: "permanent" (mcpx_daemon_select, TUI picker) | absent | putSetting sends persist: true (mcpx/daemon.ts:665-667); daemon field is a string (internal/daemon/routes_settings.go:191). Tool → “Writing the setting failed”; curl same body → cannot unmarshal bool into … .persist of type string | |
until-gone remembered scope | absent | Remembered.scope written (daemon.ts:1231), never read; after stop --all the file still has "scope":"until-gone" — same as indefinite | |
toolTiming (“one mcpx stats covers both”) | minimal | record has no duration (mcpx-session.ts:775-781); mcpx log shows harness.tool … tool=bash, mcpx stats calls → “No tool calls in the log yet.” | |
MCPX_HARNESS_VERSION injection | absent | read from OPENCODE_VERSION (mcpx-session.ts:373,685), which opencode 1.18.31 does not set (env inside it lists OPENCODE_PID etc.) | |
mcpx_exec tool session | partial | DaemonClient.exec posts only {source, options} (daemon.ts:484-488), ctx.sessionID never passed (mcpx-session.ts:619); session-scoped pools ignore the tool path (shell path gets MCPX_SESSION_ID) | #189 |
| Discovery rung 5 “ask the user” | absent | documented docs/opencode-plugin.md:223; discover skips it (daemon.ts:1119-1122), adapter only toasts (mcpx-session.ts:304-308) | |
| Headless “one warning line into the log” | absent | doc opencode-plugin.md:310-313; toast() returns early headless and writes nothing (mcpx-session.ts:294) | |
annotate “when more than one matched” | partial | only when d.ambiguous (rung 6) (mcpx-session.ts:319); with 2 daemons resolved, no mcpx: answered by line | |
plugin.discoveryRetry | partial | env only (mcpx-session.ts:196), no option despite header “option, env, default” (:65-68); absent from Options and README table | |
mcpx stats --opencode <dim> (as in skills/mcpx-observability/SKILL.md:50) | partial | dim stays "" when flag first (cli/logs.go:431-460); stats --opencode --db $DB --top 3 models → overview; --by never passed | |
mcpx stats opencode <dim> --db/--top/--since | partial | flags after the dim not parsed (logs.go:452-457); --db ignored (“no opencode database found”), --top 2 --since 24h printed all rows, all-time | |
mcpx_observe errors/slowest | partial | return "rows": null where others return [] | |
Options bin, binArgs, backend, env, tools, daemonTools; tools discover/exec/observe/daemon_* | untested | all ran as documented (works); no test imports mcpx-session.ts; internal/e2e/plugin_test.go:32 runs bun test plugin/opencode/mcpx/ only (31 pass) — the persist mismatch above is what that gap let through | #141 |
headless detection, TUI picker mcpx-tui.tsx | untested | no test; mcpx-tui.tsx not loadable outside opencode (react/jsx-dev-runtime) | |
internal/opencode (stats over opencode DB) | untested | only opencode.go, no _test.go; nothing in cli/e2e tests exercises it (ran fine against the real DB) |
Script execution (exec, run)
| what | state | evidence | issue |
|---|---|---|---|
| Permission profiles as a boundary | partial | a script reaching the daemon can ask it for more: under --runtime deno --permissions strict, fetch(MCPX_ENDPOINT+"/v1/exec", {source:"await Deno.writeTextFile(…)", options:{permissions:"all"}}) → 200, file written; same under node readnet. /v1/exec does not cap requested permissions | |
artifact({path}) reads files the profile denies | partial | under strict Deno.readTextFile fails, artifact("leak.txt",{path:"$T/secret.txt"}) succeeds (daemon reads via x-mcpx-path, codegen/emit.go:1355-1358); fetched back [200,"secret-data\n"]. No test for x-mcpx-path | |
read profile | partial | --permissions read + any tool call → cannot reach the mcpx daemon … Requires net access; undocumented (runtimes.md:63-69) | |
exec.timeout / --exec-timeout (default 120s) | partial | local: --exec-timeout 1s and env both let a 5s script print “late”, rc 0; also not honoured with --remote. Read only at internal/daemon/routes_exec.go:173; CLI --timeout default 0 = none (cli/commands.go:478). No test | |
Local --timeout message | minimal | local: rc 124, nothing printed; remote prints script timed out after 1s | |
--suffix “runs last on both paths, like a finally” (settings/registry.go:302) | partial | exec snippet that throws: suffix never runs (deno/node/bun); run file that throws: runs. Tests cover file path only (e2e/phases_test.go:47) | |
--remote text output | partial | --remote --format bare 'log.info("hello"); emit(2)' → 2 (local hello 2); renderExecResult ignores res.Logs (cli/exec.go:276-283); stderr dropped ("logs": []) | |
--remote carries the caller’s environment (exec.md:117-121) | partial | FOO=bar … --remote --env A=1 'emit([env A, env FOO])' → ["1",null]; only --env sent (exec.go:491) | |
--remote and caller’s script.profiles/runtimes/runtimeOrder | partial | resolved from daemon settings (routes_exec.go:173-178): --permissions mine → no permission profile "mine" remote, works local; runtimeOrder=bun,node → deno remote | |
diagnose.preflight on --remote | partial | remote run proceeds to TypeError: tools.demo.nosuch is not a function; ConsumerPolicy.DiagnosePreflight assigned internal/daemon/consumer.go:121, never read | |
--hooks-autonomy off | partial | refuses hooks from env/config; hooks given as CLI flags all still run; help does not say | |
script.typecheck | partial | deno only; node/bun warn and run "str"; deno detected by strings.Contains(base,"deno") (preflight.go:205) so a renamed deno runtime skips it | |
Generated client result types from outputSchema | absent | structured(...): Promise<ToolResult>, type ToolResult = any despite an outputSchema; codegen never reads it | #216 |
artifact() misuse | minimal | artifact({name:"x",data:"y"}) stores a 0-byte _object-Object_ under every runtime instead of throwing | |
runtimes deno/bun/node, profile enforcement (deno, node), bun refusal, phases, artifacts store/list/get/inline, --keep, --export, mcpx scripts | works | process.execPath matched each runtime; read/strict/readnet denials observed; phases fired in order; artifacts listed and fetched |
Daemon (pools, cache, reload)
| what | state | evidence | issue |
|---|---|---|---|
pool.max/pool.idleTimeout via settings set (“hot: yes”) | partial | A pool.* change now reloads: handleSettingsSet → applyPoolRuntime (routes_settings.go:183, :268) → reloadConfig, which calls ApplyPoolSettings (routes_settings.go:659, config/poolsettings.go:36). pool.max reaching live pools is pinned by e2e/poolmax_test.go TestASettingsSetOnTheRunningDaemonReachesEveryLivePool. pool.idleTimeout is in PoolID (config.go:472), so its pool is replaced; reaping after the change is not re-measured. A change whose reload fails is rolled back (routes_settings.go:280), pinned by e2e/poolreload_test.go | |
min (“keep started even when idle”) | partial | min:2 → mn … 0/4 after a reap tick; cfg.Min read only in reap guard (pool.go:992); nothing pre-starts. No test sets it | |
call scope (and session fallback) frees instances when the call ends | partial | after 4 mcpx call ca.state, ca#6/ca#7 still live, idle 55s; only exec/run call ReleaseCaller (commands.go:595, routes_exec.go:202), not CmdCall | |
pid scope from the CLI | partial | key is the mcpx client’s own os.Getpid() (commands.go:1521) → two calls, two pids: per-call, not per calling program | #211 |
| Identically defined servers share one process | partial | z and g same command → same PoolID (config.go:357-382); z.open "from-z" then g.state → seen:["from-z"]. Undocumented | |
Schema history on reload / servers add | partial | only Warm calls ObserveCatalog (registry.go:300, consumer.go:93); added server showed No tools. in history until a second refresh | |
| Config reload failure | partial | bad file logged at INFO (“could not be loaded”), hidden by log --level warn; old config kept silently | |
| Per-server pool setting change restarts its processes | minimal | max/min/idleTimeout/timeouts/sharing/scope hashed into PoolID (config.go:494-496) → new pool, old drained (reload.go:101, pool.go:1384: calls in flight finish on the old child, up to limits.execDrainTimeout), state lost; observed cw back with 0 tools/instances. Undocumented | |
shared/exclusive; global/cwd/session/parent-session/repo/worktree; max; idle TTL; restart (all/one/--lazy); stop --all; servers add/remove live; file reload; refresh | works | distinct pids per cwd/session/worktree, shared per repo; exclusive queued 3s; idleTimeout:3s reaped; servers add callable with existing pid unchanged |
Diagnostics (diagnose, log, stats, doctor)
| what | state | evidence | issue |
|---|---|---|---|
Failed tool calls (isError) logged as success | partial | mcpx call g.boom rc 1, log mcp.call … ok=true, stats calls ERRORS 0; "ok": err == nil (pool/pool.go:959) | related #206 (closed) |
stats errors | partial | filters level=error (stats.go:308-309); a logged ok:false call → stats calls 100% errors, stats errors “No errors in the log yet.” | |
stats servers CALLS | partial | sums calls from server.stop only (stats.go:185-187): 5 vs 12 in stats calls | |
| Attribution by server | partial | shared-PoolID calls from z logged as server=g (pool.go:956); stats sessions merges global-scope servers into one row | |
diagnose argument types | absent | g.echo({message: 1}) (string) → “Nothing to report”; no type kind (diagnose.go:28-37); argument-retyped history never checked | #111 |
diagnose unknown namespace | partial | nons.x({}) → “Nothing to report”; static check returns nil (diagnose.go:279-283), kind only on runtime path (:501) | |
diagnose <name> with no such script | partial | mcpx diagnose zz_no_such_script and help’s mcpx diagnose report → “Nothing to report”, rc 0; falls back to treating name as source (consumer.go:207-210) | |
doctor server health | partial | never probes; before refresh only the missing-binary server FAILs, a crashing and an unreachable one pass; after refresh all three FAIL | |
doctor with invalid config | minimal | prints parse error, then spawns a daemon on a different socket → three FAILs incl. “daemon did not become ready” from one cause | |
diagnose missing/unknown/renamed args, unknown tool, history; log record/sql/--chain/--since; doctor -v | works | e.g. py2.t: b is required … (schema changed …: `b` became required), rc 1; log sql "delete…" refused |
Recipes, mcpx prompt, autonomy
| what | state | evidence | issue |
|---|---|---|---|
Settings flags on prompt as --flag value | partial | mcpx prompt --prompt-autonomy run "…" → want one of propose, run, got "--set"; hoistFlags knows only autonomy/session/set (cli/consumer.go:397) | |
prompt --mode (decision 0002 table) | absent | flag provided but not defined: -mode; renamed to --autonomy, noted only in the decision’s “Landed” note | |
prompt exit status when nothing produced | partial | prompt.sample=never → “No recipe matched…” rc 0; sample=ask 5s timeout → “no model answered” rc 0 | #294 |
| Undeclared recipe values | partial | recipes run touch-marker tag=c bogus=1 → rc 0, bogus dropped silently | |
| Recipe hole, no TTY/answerer | partial | blocks 45s, no mcpx elicit answer hint, then fails; not exit 75 like call/exec/run | |
Recipe hole answered via elicit answer | partial | form asked tag; elicit answer <id> tag=zz n=oops accepted, overrode declared n → “n is a number, got "oops"” | |
recipes CALLS column | minimal | lists fs.writeFileSync as a call and feeds it into match scoring | |
| CLI tests for ceiling, flag hoisting, exit codes | untested | internal/recipes/*_test.go covers parse/match only | |
autonomy.max ceiling on recipes run / prompt --run | works | daemon restarted at each of off…run: only run created the marker; propose/apply printed “clamped to propose” |
Adapters and paths.apis
| what | state | evidence | issue |
|---|---|---|---|
Adapter timeout | partial | "timeout":"1s" on sh -c "sleep 5 & wait" → 5.05s, exited -1, never “did not finish within”; ExitError checked before cctx.Err(), no process-group kill/WaitDelay (adapter/adapter.go:255-261). No test | |
| Adapter exit status | partial | program exit 3 → “fail exited 3”, rc 1 (cli/adapter.go:155); --json prints no result | |
Adapter parameter type at call time | absent | {"n":"abc"} for a number → argv abc; buildArgs checks enums only (adapter.go:277-330) | |
| Unknown adapter arguments | partial | "bogus":1 ignored, rc 0 | |
Positional starting with - | partial | {"pos":"--evil"} → argv --evil -v; no -- separator, option injection | |
| Missing adapter program | partial | adapter check reports it; mcpx ls lists namespace as unread, 0 tools, no error | |
| API cookie parameters | absent | dropped from schema (openapi/openapi.go:467); when passed, sent as query ?sess=C1 (default branch :578) | |
| Required query params / body | absent | only required path params checked (openapi.go:565); missing q sent; {} → POST with Content-Length: 0 | |
securitySchemes / security | absent | no reference in internal/openapi or internal/cli; auth only via hand-written headers | |
Auth with mcpx api --spec | absent | --spec builds APISpec without Headers (cli/openapi_tools.go:100-106), no header flag; server saw no X-Key | |
HTTP error exit with --json | partial | pets_getpet {"id":404} rc 1, with --json rc 0 (openapi_tools.go:181-183) | |
| Spec build error | partial | continued (openapi_tools.go:173-175); user sees only “no operation named” | |
| API tool names | minimal | getPet → pets_getpet, pets_getPet not found; description "GET /pets/{id} (GET /pets/{id})" | |
| APIs as namespaces in scripts | partial | only mcpx serve extras (cli/serve.go:529); mcpx ls has no pets, exec 'typeof pets' → undefined (adapters do appear) | |
| CLI/serve API paths | untested | no _test.go references paths.apis, apiTools, CmdAPI |
Elicitation and sampling via the CLI
| what | state | evidence | issue |
|---|---|---|---|
elicit answer key=value types | partial | age=3 reaches the server as "3" for an integer field; every value a string (cli/elicit.go:119-126); show’s hint quotes numbers too | #255 |
elicit show constraints | partial | shows type/optional/enum, not minLength/minimum | #255 |
| Sampling question on the CLI | minimal | call el.sample rc 75 prints asks empty and suggests '{}'; elicit show omits the messages (only in --json) | #256 |
ask answers rejection | partial | wrong shape → accepted: 0, problems listed, rc 0, HTTP always 200 (internal/daemon/routes_proto.go:672-676); help does not give the <answers> shape | |
ask abandon on finished call | partial | status: completed, rc 0, no indication nothing was abandoned | |
| CLI elicitation tests | untested | no test on JSON types from key=value or on ask answers exit code | |
| exit 75, answer/decline/cancel, re-answer refused | works | server received accept/decline/cancel; re-answer rc 1 |
Changes to in-name-only.md entries
- “Elicitation answering UI … does not show which server asks”: fixed for the CLI —
elicit show/listshowfrom el/ SERVERelfor upstream questions. Schema checking of answers is still absent.
Side findings (outside this area, not traced further)
- Two daemons on one state dir overwrite
daemon.json;stop --allthen reported none running while one lived. DELETE /v1/settings/daemon.endpoint?persist=user→"removed":false, key still inconfig.json.
Skipped
Live opencode instance and TUI picker at runtime; rung-6 ambiguity; stopOthers; opencode v2 sections.
Custom --launcher templates, --allow-repeat, --no-capture-console, artifact TTL/quota/GC/put/delete,
--exec-output stream remote, --session reuse, non-local daemon.endpoint, tasks via exec.
log --follow, log rotation, per-server logging, daemon.leaseTTL, idleExit, protocol: follow lanes,
exclusive × non-global scopes. URL-mode elicitation, elicit watch/result, adapter serve, mcpx serve
with API tools (read only), Swagger 2 body/formData (read only), YAML specs, --methods filters,
prompt.sample=ask with a real model, repair.autonomy. go test was not run.
Most consequential five
- Permission profiles do not contain a script that can reach the daemon —
/v1/execacceptspermissions:"all"from astrict/readnetscript, andartifact({path})reads denied files. - Failed tool calls are logged
ok=true(pool.go:959), sostats calls,stats errorsand every error rate built on the log report zero failures. exec.timeoutis not applied — the documented 120s default does nothing locally or with--remote.- Adapter
timeoutdoes not stop the program, and adapter/API arguments are not type- or required-checked (positional option injection, cookie params sent as query, missing body sent). - opencode plugin:
instructions,remember:"permanent"anduntil-gonedo nothing on v1, and no test loadsmcpx-session.ts, which is how they went unnoticed.
Audit: the MCP specification (area spec)
Audited at 19237a0 (origin/main). Authorization excluded (#253). Entries
already in docs/in-name-only.md are not repeated.
Commands used (from pkgs/mcpx):
go test ./internal/conformance -run 'Test.*/2024-11-05/' -v -count=1→ exit 0, 175 PASS, 5 SKIP, 0 FAIL.go test -count=1 -v -run 'Test(Lifecycle|Messages|Utilities|Features|ClientFeatures|Stateful|Transport)(Server|Client)' ./internal/conformance→ exit 0, 28 SKIP (the gapped cells).- Same with
MCPX_CONFORMANCE_RUN_GAPS=1→ exit 1, 1315 PASS, 27 FAIL. Comparing the two runs: 5 cells skipped as gaps pass. - A scratch test (not committed) against the conformance harness’s
httpServer: a 2024-11-05-styleGETwithAccept: text/event-streamreturnedstatus=405 body={"jsonrpc":"2.0","error":{"code":-32600,"message":"a GET stream belongs to a session; send initialize first"}}.
How the matrix works matters for every row below: internal/conformance/*_test.go
mostly registers one test body for a list of requirement ids
(for _, id := range []string{...}; 102 such loops). Each id then reads
“tested” in docs/spec/conformance-matrix.md, whether or not the body touches
that requirement.
Matrix says “tested”, the cited test does not exercise it
| what | state | evidence | issue |
|---|---|---|---|
Server progress-must-increase, progress-stop-after-completion, progress-only-active-tokens, progress-rate-limit, progress-task-* (MUST/SHOULD, every revision) | untested | Fixed since: the cells now drive a real mcpclient relay against an upstream that reports badly (serverRelayedProgress), and mcpclient.acceptProgress enforces the rules. At the time of the audit: internal/conformance/utilities_test.go:169-188 calls only mcpx_status and asserts no frame arrives (“mcpx sends none”). But mcpx does send progress: relayed upstream progress on tools/call (internal/mcpclient/relay.go:158-172). That path forwards the upstream’s value verbatim with no check that it increases or is rate-limited, so a decreasing upstream reaches the host as a decreasing mcpx notification. The behaviour that binds mcpx is never looked at. | none (#212 closed) |
Server logging-setlevel-at-or-above (2024-11-05 – 2025-11-25) | untested via cited test | Cited utilities_test.go:239-262 sets debug and checks that mcpx_status emits nothing; no message is ever filtered. Filtering is real (mcpclient/relay.go:181) and is exercised by an uncited test, internal/mcpclient/relay_test.go:80-86 (LogLevel: "warning"). Matrix points at the wrong test. | — |
cancellation-max-timeout-despite-progress (both sides, 2026-07-28) and lifecycle-timeouts-max-regardless-of-progress (both sides, 2025-03-26 – 2025-11-25) | untested | Client: utilities_test.go:338-360 and lifecycle_test.go:751- use a peer that never answers and sends no progress. Server: utilities_test.go:91-104 checks only that input_required returns fast; lifecycle_test.go:217 uses askUnanswered, no progress. Holds today only because progress never resets any timeout; a change that let progress extend it without a cap passes every cited test. | — |
cancellation-task-use-tasks-cancel (MUST, both, 2025-11-25) | minimal | Client cover is the shared timeout body (utilities_test.go:338-345), which asserts the call was not task-augmented. Server cover (utilities_test.go:55-57) uses an un-tasked question. Neither sends a task-augmented request, so the “use tasks/cancel” rule is never reached. | #209 |
lifecycle-http-shutdown-closing-connection for 2024-11-05 (both sides) | minimal | lifecycle_test.go:153-170 initializes, DELETEs with Mcp-Session-Id, expects 404. Its own comment says “(2025-03-26 on)”. 2024-11-05 has no session id, no DELETE and no Streamable HTTP; the requirement is “shutdown = closing the connection”. | — |
sampling-deprecated-new-impls-should-not-adopt (SHOULD NOT, 2026-07-28) | partial | Catalogue (docs/spec/requirements.md:727) says “mcpx forwards; pass-through, not adoption”. But the daemon originates sampling: internal/daemon/consumer.go:589-603 (“generation by sampling”), generate → elicit.SampleAsk → broker. And the pool declares sampling to every upstream whenever Hooks.Elicit is set (internal/pool/pool.go:487-494). The cited client test (clientfeatures_test.go:360-375) asserts sampling is declared when a handler exists. So the cited test passes on the behaviour the requirement discourages. | #110, #210, #256 |
Recorded gaps that are already closed (docs contradict code)
| what | state | evidence | issue |
|---|---|---|---|
lifecycle-stdio-client-shutdown-sequence gap (2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25) and stdio-client-shutdown-sequence (2026-07-28) | fixed; gap record removed since, the 5 cells run | Gap text (internal/conformance/coverage_lifecycle.go:98-100, matrix line for every revision): “StdioTransport.Close sends SIGTERM at the same moment it closes stdin”. Code: internal/mcpclient/stdio.go:260-275 closes stdin, waits stdinGrace, then SIGTERM, then SIGKILL after termGrace. With MCPX_CONFORMANCE_RUN_GAPS=1 all 5 skipped cells pass (TestLifecycleClient/{2024-11-05,2025-03-26,2025-06-18,2025-11-25}/lifecycle/lifecycle-stdio-client-shutdown-sequence, TestTransportClient/2026-07-28/transport-stdio/stdio-client-shutdown-sequence). internal/mcpclient/stdio_shutdown_test.go also covers it. Matrix under-reports 5 client cells as gaps; the passing tests are skipped so they guard nothing. | #203 (open) |
Revisions and transports the suite covers in name only
| what | state | evidence | issue |
|---|---|---|---|
| mcpx as a 2024-11-05 server over HTTP | partial | No HTTP+SSE server transport. A 2024-11-05 HTTP client’s opening GET (scratch test above) gets 405 with a JSON-RPC error, no endpoint event. docs/protocol.md:51 and docs/spec/transport.md:9 say “2024-11-05 over stdio and Streamable HTTP”: that is a hybrid no 2024-11-05 client speaks, because Streamable HTTP is 2025-03-26. docs/protocol.md:78 says the same thing (“2024-11-05 has no Streamable HTTP, so nothing arriving on /mcp can be a 2024-11-05 request”). The http-sse-server-* MUSTs are marked n/a (“optional feature”), so the matrix shows 2024-11-05 server MUSTs at 30/32 tested with no transport gap. In practice 2024-11-05 is served over stdio only. | #220 is client-side only |
| 2024-11-05 / 2025-03-26 in mcpx’s own suite | partial (not absent) | Unlike the official suite, mcpx’s own suite does run per revision: the 2024-11-05 filter above gives 175 passing cells. Most bodies are shared across revisions, though, and the 2024-11-05 HTTP cells exercise 2025-03-26 mechanics (rows above). | — |
Capabilities declared and not honoured
| what | state | evidence | issue |
|---|---|---|---|
resources.subscribe (legacy) | partial | Declared whenever the connection can push (internal/mcpserver/server.go:2153-2157). resources/subscribe always succeeds, including for URIs no upstream owns or whose upstream lacks resources.subscribe. Nothing is delivered for those, and the client is not told (server.go:871-882; the daemon only logs a warning). Deliberate (#251), but the declared capability does not hold for those URIs. | #251 (closed) |
| Relayed progress as mcpx’s own notifications | fixed since | See the first table. mcpx is the notifier of record towards the host and passes progress through unvalidated (mcpclient/relay.go:158-172). | — |
Skipped
- Did not check each of the ~1300 cells one by one. I read the shared-body loops in
utilities_test.go,lifecycle_test.goandclientfeatures_test.go:360-440. Other loops inmessages_test.go,features_test.go,stateful_test.goandtransport_test.goI only read by header. Many of those are “mcpx emits none, so it cannot emit one wrongly” covers (e.g.features_test.go:180outputSchema,:358prompt content,clientfeatures_test.go:296URL-required errors). They are vacuous but defensible as n/a, so I list none of them individually. - Did not diff the schemas in
internal/mcpspec/schema/against what the server emits beyond what the harness already validates. - Did not check
revision-conflicts.mdentry by entry. - 27 cells fail under
MCPX_CONFORMANCE_RUN_GAPS=1. Those are recorded gaps that are still open, so they are consistent and not listed.
Most consequential five
- mcpx cannot serve a real 2024-11-05 HTTP client (405 on the opening GET). The docs call this “2024-11-05 over Streamable HTTP”, a transport that revision does not have, and the matrix shows no gap for it.
- The progress MUSTs (increase, stop after completion, active tokens only) are “tested” on a path that sends no progress. The relay path that does send progress forwards upstream values unchecked.
- Sampling is adopted, not just forwarded, in 2026-07-28. The daemon originates
samplingrequests for script generation and declares the capability to every upstream. The matrix marks the SHOULD NOT as tested, using a test that asserts the declaration. - The stdio shutdown gap (#203) is stale (since fixed: the record is removed and the cells run). The fix is in, but 5 conformance cells are still skipped as gaps, so the passing tests guard nothing and the matrix under-reports.
- Timeout-despite-progress and tasks/cancel requirements are covered by shared test bodies that never send progress or a task. They would not notice a regression.
Audit: mcpx’s own surface
created: 2026-10-01
base: origin/main 19237a0
binary: go build -o /tmp/mcpx-surface ./cmd/mcpx
fixtures: internal/testsupport/fakemcp, internal/testsupport/askmcp
scratch: HOME, MCPX_STATE_DIR, MCPX_CACHE_DIR under a private dir
status: audit only -- nothing fixed
Scope: commands (mcpx --help, mcpx help <cmd>), settings
(internal/settings), MCPX_* variables (docs/environment.md), flags, exit
codes, and /v1 routes against the CLI. Entries already in
docs/in-name-only.md are not repeated; one is re-confirmed below.
How the settings were sampled
The registry holds 173 settings, not ~150:
rg -o 'Path: +"[^"]+"' internal/settings -g '!*_test.go' | sort -u = 173.
mcpx settings --json shows 129; the other 44 are plumbing-flagged and appear
only in <cmd> --help and config --schema.
- Static, all 173: for every path, which non-test files name it
(
rg -F '"<path>"'). Every non-plugin path has at least one accessor read exceptpaths.config(only aReadBystring insettings/scriptenv.go:177; config discovery is done elsewhere – not traced, so not classified). The twelveplugin.*settings are read by the TypeScript plugin (plugin/opencode/mcpx-session.ts) and were skipped – the plugin cannot be run from here. - Behavioural, 31 settings: run the binary with the setting at two values
and compare. Chosen to cover every scope (client, call, daemon), every layer
(flag, env, file,
mcpx daemonflag,X-Mcpx-Settingsheader), and every read site that is not the command the help lists it under (where the static scan showed the read happens in a different process from the one the help implies). Verified working:output.json,search.limit,catalog.budget,catalog.bias,daemon.autostart,daemon.endpoint,http.requestTimeout,http.callBodyLimit,script.runtime,script.typecheck,script.permissions(netdenies reads; bun/node refuse unenforceable profiles),logging.levelandlogging.formaton exec,logging.file,daemon.watchConfig,events.history,mcp.pageSize,registry.url,plumbing.strictUnknownKeys,pool.callTimeout(env andmcpx daemonflag),artifacts.enabled(env and file),logstore.queryLimitandstats.top(over HTTP),tasks.ttl(exec tasks). Found broken or partial: below. - Not verified:
catalog.instructions(fakemcp sends no instructions, so both values print the same),autostart.idleExit(the daemon also waits for no live instances,internal/daemon/server.go:376; not isolated), everyproto.*,protoMessages.*,upstream.*,transport.*,prompt.*,recipes.*setting.
Settings
| what | state | evidence | issue |
|---|---|---|---|
exec.timeout (default “120s”, “kill a script after this long”) on mcpx exec | absent | Local exec never reads it: cli/commands.go:478 has its own --timeout (default 0 = no limit) and only that reaches the runner. mcpx exec --exec-timeout 1s 'await fake.slow({ms:3000}); console.log("done")' printed done after 3.1s, rc 0; MCPX_EXEC_TIMEOUT=1s same; --exec-where remote --exec-timeout 1s same. The advertised 120s default does not apply locally either. --timeout 1s does kill (rc 124). | #77 (call-scoped timeout, related) |
Call-scoped settings on POST /v1/exec (exec.timeout, artifacts.delivery, artifacts.inlineMaxBytes, artifacts.interceptImages – all scope: call, hot: true) | absent | ExecService reads s.execSettings() (internal/daemon/routes_exec.go:170-185), a set built once (sync.Once, :53-77) from the config file and environment only – never from the X-Mcpx-Settings header that docs/configuration.md “Scope” says every call-scoped setting is honoured from, and never re-read, so “hot” is false. curl --unix-socket … -H 'X-Mcpx-Settings: {"exec.timeout":"1s"}' -d '{"source":"await new Promise(r=>setTimeout(r,3000))…"}' /v1/exec → exitCode 0, durationMs 3046; the same with "options":{"timeout":"1s"} → exitCode 124, timedOut. | – |
Exec-service settings given as mcpx daemon --<flag> | partial | Same cause: execSettings applies file and env, not the daemon’s flags. mcpx daemon --artifacts-enabled=false then artifact("a.txt","hi") in exec → stored (mcpx://artifacts/art-2608…); MCPX_ARTIFACTS_ENABLED=false or {"artifacts":{"enabled":false}} → artifacts are disabled. Also affects script.runtime, script.permissions, script.runtimes, script.captureConsole, script.typecheck, artifacts.* on the remote path (read at routes_exec.go:173-184). | – |
Daemon-scoped settings given on a client command (call --pool-call-timeout 1s, any of the ~40 daemon settings help call lists) | absent | Accepted, validated, no warning, no effect – including when that very command auto-starts the daemon (autostart passes daemon --detached plus --idle-exit/--config only, cli/client.go:339-343). mcpx stop --all; mcpx call --pool-call-timeout 1s fake.slow '{"ms":3000}' → slept 3000ms, rc 0. mcpx daemon --pool-call-timeout 1s then the same call → pool.callTimeout exceeded (1s), rc 1. docs/configuration.md “Scope” describes exactly this (“accepted, validated, printed back, and then ignored … Nothing anywhere said so”) as the failure scopes exist to prevent; nothing in internal/cli warns (rg -i 'daemon.scoped|ignored.*running daemon' internal/cli → none). | #185 (related) |
logstore.queryLimit on mcpx log | partial | mcpx log reads the store in-process with its own --limit default 100 (cli/logs.go:83); the setting is only read by GET /v1/log (internal/daemon/ops.go:130). mcpx log --logstore-query-limit 2 → 100 lines; MCPX_LOGSTORE_QUERY_LIMIT=2 mcpx log → 100. Over HTTP with the header → 3 records for 3. help log lists the flag. | – |
stats.top on mcpx stats | partial | Same shape: --top default 20 (cli/logs.go:442); setting read only by GET /v1/stats (internal/daemon/ops.go:200-202). stats --by slowest --stats-top 1 and --stats-top 5 → 23 lines each. | – |
tasks.ttl (“how long a finished task’s result is kept”) | partial | Applied only to exec tasks (routes_exec.go:233-237). mcpx daemon --tasks-ttl 2s: POST /v1/exec {"options":{"task":{}}} → "ttl":2000; POST /v1/call {…,"task":{}} → "ttl":600000, and the task was still readable after 5s. | #87 (related) |
script.permissions none | minimal | The profile named none is --allow-all (defaults/defaults.json:43), as are off and unsandboxed. mcpx exec --script-permissions none 'Deno.readTextFileSync("/etc/hosts")' → reads it. A reader picking “none” for no permissions gets all of them. | – |
mcpx.namespace validation (already in in-name-only) | still open | {"namespace":"1 bad-ns"} → mcpx ls lists namespace 1 bad-ns, rc 0. | – |
Commands, help and flags
| what | state | evidence | issue |
|---|---|---|---|
mcpx help <command> “usage, flags and settings for one command” | absent (flags) | No command’s help has a flags section; it lists settings only. help exec does not mention --timeout, --keep, --session, --export, --ns, --runtime, --format, --prefix… (they appear only in examples prose). Counted for exec, run, call, search, catalog, types, log, stats, restart, stop, doctor, serve, daemon: 0 flag lines each. exec --help (Go’s default usage) does list them, mixed into ~170 settings. man does not mention --keep. | #144, #147 |
| Settings listed on every command whether or not it reads them | minimal | help init, help man, help completion, help health, help stop each list the same 71 settings (artifacts-quota, plugin-*, prompt-* …). mcpx man --artifacts-quota 5 is accepted. The list says nothing about which command acts on which. | – |
| Global flags after the command | partial | Main help lists --config, --profile, --skip-default, --all-profiles under GLOBAL FLAGS. --json works after the command; the others do not: mcpx ls --profile nope → flag provided but not defined: -profile, ls --config … and ls --all-profiles likewise. | – |
--profile <unknown> | untested/absent check | mcpx --profile nope ls → full list, rc 0; a misspelt profile is indistinguishable from the default set. | – |
mcpx resolve <dir> with a broken config | partial | resolve on a dir whose .mcpx.json fails to parse prints configPath: empty, socket: …/daemon.sock, rc 0 – a confident wrong answer; mcpx ls in that dir fails. Valid dirs resolve correctly. | #88 (related) |
mcpx exec --timeout | minimal | Kills at the limit but prints nothing: rc 124, empty output. /v1/exec with the same limit returns "error":"script timed out after 1s". | – |
| Every other command | works (smoke) | prompt, recipes, scripts, doctor, daemons, adapter, api, servers, schema, client, completion, man, refresh, restart --lazy, status, elicit list, config --schema, health, protocol, resolve, task list/get/result/cancel, artifact list/get/delete, globals, history, tools [--ns], session release, ask poll/begin, tool invoke, complete run and return plausible output or a precise usage error. explore/tui/events (interactive/streaming) skipped. | – |
Exit codes
| what | state | evidence | issue |
|---|---|---|---|
75 when a call stops for input (help elicit, cli/elicit.go:23-28) | partial | First question on a fresh daemon: mcpx call ask.need_repo → rc 75, mcpx exec 'await ask.need_repo({})' → rc 75. While any earlier question for the same (shared) server is still pending, later callers block instead: exec with one pending → killed by timeout 20 (rc 124); call → killed at 15s; with no outer timeout exec waited 2:00 then printed no repository was chosen (cancel) and exited 0 – the pre-#286 behaviour. mcpx elicit list showed each blocked call’s question was created. Cause not traced; the e2e tests (inputrequired_test.go) start from an empty question store. | #286 (closed) |
| 2 for usage errors, 1 for failures, 124 for timeout | works | unknown command → 2; unknown global flag → 2; tool isError → 1; unknown tool → 1; --timeout → 124. Only 75 is promised in docs. | – |
MCPX_* environment
All 42 names in docs/environment.md resolve to a setter or reader
(scriptenv.go registry, plugin, or generated client). Behaviour checked by
printing the script’s environment.
| what | state | evidence | issue |
|---|---|---|---|
Variables documented as set “always” for mcpx exec/run | partial | Local exec sets all of them. --exec-where remote omits MCPX_CONFIG_PATH, MCPX_SCRIPT_DIRS, MCPX_EPHEMERAL, MCPX_LOG_LEVEL, MCPX_LOG_SOURCE, MCPX_PARENT_SESSION_ID, and sets MCPX_SESSION_ID to the minted session where the doc says it passes through what it was given (empty). The doc row for each says “always”. | #106 (related) |
MCPX_CALL | n/a | Doc itself says it has never existed. | – |
MCPX_DAEMON_ENDPOINT read by mcpx as daemon.endpoint | works | MCPX_DAEMON_ENDPOINT=http://127.0.0.1:1 mcpx ls → no daemon answering; it is named explicitly. | – |
MCPX_STATE_DIR, MCPX_CACHE_DIR | works | the whole audit ran under them; sockets and caches landed there. | – |
Plugin-set variables (MCPX_HARNESS*, MCPX_OPENCODE_*, MCPX_SESSION_*, MCPX_TRACE_IDS, MCPX_CALL_ID, …) | skipped | need the opencode plugin running. | – |
/v1 routes against the CLI
The OpenAPI document (mcpx openapi) lists 71 method+path pairs; the daemon
mux registers 60 (rg 'HandleFunc\("…' internal/daemon). Every registered
route is in the document; the 11 extra document entries are POST /mcp and
POST /v1/tools/mcpx_*, served by the /v1/tools/{tool} pattern. No route
without a handler and no handler without a document entry. Every op has a CLI
word (Op.CLIWords, api/ops.go:51), and each generated command smoke-ran.
| what | state | evidence | issue |
|---|---|---|---|
| CLI command and its route read different settings | partial | mcpx log/mcpx stats read the store locally instead of calling /v1/log//v1/stats, so the route’s setting defaults (logstore.queryLimit, stats.top) and the CLI’s hard-coded defaults (100, 20) differ – see Settings. | – |
/v1/exec ignores X-Mcpx-Settings | absent | see Settings; the CLI’s own --exec-timeout cannot reach it either because the CLI never maps it into options.timeout. | – |
/v1/call tasks ignore tasks.ttl | partial | see Settings. | – |
Most consequential five
exec.timeoutdoes nothing – not from flag, env, or header; not locally, not remotely. Scripts have no default limit despite the documented 120s./v1/execbuilds its settings once from file+env: call-scoped “hot” settings in the header are ignored,mcpx daemon --flagsare ignored, and runtime changes never land – the scope model inconfiguration.mddoes not hold on the exec path.- Daemon-scoped flags on client commands are silently dropped, including
when that command starts the daemon – the exact failure
configuration.mdsays the scope field exists to prevent, andhelp <cmd>advertises ~40 of them. - Exit 75 holds only for the first pending question per server; later
callers block until expiry and
execthen exits 0 with a cancel result (#286’s original symptom). mcpx help <cmd>shows no flags, only a 71-plus-entry settings list that is identical forinit,manandcall; the per-command flags exist only in Go’s raw--helpoutput.
Catalogue
The catalogue chapter: things outside mcpx itself — MCP servers, registries, resources, URLs and projects — and why each one is here.
- Source this directory, one page per section:
servers.md,registries.md,resources.md,urls.md,projects.md. - Generated or written: C. The list is hand-written — it says which servers, registries, resources, URLs and projects are in the catalogue and why; the facts per server are captured into a committed snapshot by a refresh script. Nothing at docs build time starts a server or calls the network.
- First entries are the seven servers configured on this machine —
codebase-memory,codedb,fff,fff-nix,fff-worktree,chrome-devtools(thesession-mode case) andcontext7— plus the official registryregistry.modelcontextprotocol.io, the recorded rejection (HAPI), and the projects list from #1867.
The structure is recorded in docs/compendium.md (“The catalogue chapter”);
the section pages are seeded by the follow-up task. Approach A (hand-written
entries only) and B (mirror mcpx registry search) stay rejected for the
reasons on #1867.
Comparisons
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: mcpx next to the five MCP revisions, opencode v1 and v2,
lootbox and Cloudflare code mode: a granular register of
every difference, capability matrices, and plain answers to
the questions the comparison raises.
mcpx sits between things that disagree. There are five revisions of the Model Context Protocol, two of them eras apart. There are two opencodes with different plugin APIs and different ideas of what an MCP server is for. And there are three other ways to run code against MCP tools. This directory records every difference found between them, what each difference is worth to mcpx, and where mcpx stood at one named commit.
It is the “living comparison” that #153 recommends (approach C). The earlier
records, ../../ASSESSMENT.md and
../../OPENCODE-V2.md, are left as they were; where
this directory finds them wrong it says so
(conflicts.md §E).
Start here
| document | answers |
|---|---|
| history.md | How mcpx came to exist: the patch given verbatim, lootbox, the rewrite, the opencode discovery, the correction, the timeline, and where it stands. |
| chapter.md | The compendium’s comparison chapter: mcpx next to lootbox, opencode code mode, Cloudflare Code Mode, Anthropic programmatic tool calling, mcpac, mcpc and code-mode-mcp as one table, plus where each is better. |
| stateless.md | Can mcpx speak the stateless 2026-07-28 protocol and every older revision at once? What “stateless” requires, where mcpx keeps state, and what stands in the way. |
| input-required.md | What input_required is, in plain words, with the sequence, and what mcpx does with it in each direction. |
| content-passthrough.md | Did mcpx need audio, resource_link, structuredContent? What it does with each content type on each path. |
| conflicts.md | Every communication conflict: revision against revision, prose against schema, product against product, one mcpx surface against another, mcpx’s documents against its code. |
| what-a-server-should-be.md | The character of an MCP server according to each revision and each product. |
Matrices
| matrix | rows |
|---|---|
| matrix-revisions.md | MCP revisions × every method, notification, capability field, content type, error code and HTTP header |
| matrix-mcpx.md | mcpx × peer revision, as server and as client, at 05c78b2 |
| matrix-opencode.md | opencode v1 × v2: plugin API and every hook, the MCP client, code mode, the process model |
| matrix-code-mode.md | lootbox × mcpx × opencode code mode (v1, v2) × Cloudflare code mode |
The register
One row per difference: one field, one method, one error code, one header or one behaviour. Each area file has a table (ID, difference, labels, where it exists, mcpx status, value, effort, risk). Below the table is one block per row with the detail: field names, error codes, edge cases, behaviour the specifications imply without stating, and a source for every claim.
| area | file | rows | mcpx missing |
|---|---|---|---|
| lifecycle and versioning | register/lifecycle-versioning.md | 40 | 11 |
| transports | register/transports.md | 45 | 20 |
| capabilities | register/capabilities.md | 26 | 15 |
| tools | register/tools.md | 31 | 6 |
| resources | register/resources.md | 16 | 11 |
| prompts | register/prompts.md | 8 | 5 |
| completion | register/completion.md | 11 | 4 |
| pagination and caching | register/pagination-caching.md | 14 | 7 |
| logging | register/logging.md | 9 | 3 |
| elicitation | register/elicitation.md | 23 | 8 |
input_required (MRTR), shared by elicitation, sampling and roots | register/mrtr.md | 20 | 3 |
| sampling | register/sampling.md | 11 | 2 |
| roots | register/roots.md | 5 | 1 |
| tasks | register/tasks.md | 27 | 15 |
| subscriptions and notifications | register/notifications.md | 18 | 10 |
| progress and cancellation | register/progress-cancellation.md | 14 | 6 |
| auth | register/auth.md | 25 | 3 |
| content types | register/content-types.md | 19 | 2 |
| errors | register/errors.md | 18 | 8 |
_meta | register/meta.md | 11 | 3 |
| plugin APIs | register/plugin-apis.md | 41 | 12 |
| code mode and script execution | register/code-mode.md | 54 | 11 |
| process model | register/process-model.md | 25 | 5 |
| total | 511 | 171 |
Two areas are not in the list the register was asked for, because the 2026
revision made them matter: pagination and caching (CacheableResult
is new and required) and input_required, which is one mechanism under
three features.
Labels
Every row carries every label that applies.
| label | meaning | rows |
|---|---|---|
mcpx missing | In scope for mcpx and absent or broken at the named commit. Includes conformance bugs where mcpx claims a feature and gets it wrong | 171 |
impl deferred | mcpx knows and has deferred it; the row cites the document or comment that says so | 21 |
mcpx has, others don't | mcpx does this and the products compared do not | 18 |
<rev> has | The revision in which the thing first appears, and later revisions that change it materially | 2024-11-05: 74 · 2025-03-26: 39 · 2025-06-18: 42 · 2025-11-25: 62 · 2026-07-28: 152 |
<rev> removes | The revision that removes it | 2025-06-18: 3 · 2026-07-28: 48 |
<rev> deprecates | The revision that deprecates it (still defined, scheduled for removal) | 2025-03-26: 1 · 2025-11-25: 1 · 2026-07-28: 22 |
specs conflict | Two revisions, or the prose and schema of one revision, disagree | 33 |
opencode v1 has | opencode 1.18.31 has it | 100 |
opencode v2 has | opencode 2.0.3 has it | 125 |
lootbox has | lootbox has it (the deployed build unless the row says fork) | 22 |
has better replacement | Superseded by something better, in a later revision or another product | 36 |
has no replacement | Removed or absent with nothing taking its place | 6 |
does not match mcpx's goal | A feature mcpx should not adopt; the row says why | 13 |
Cloudflare code mode has no label of its own. Rows about it name it in where it exists and use the other labels for what it means for mcpx.
The other columns
- mcpx status (header
mcpx @ 05c78b2):✓works as defined ·partial(the cell says how) ·✗missing or broken ·n/a·acc.accepted beyond what the revision defines (accept liberally), then a few words. - Value:
+what mcpx gains by doing it,−what it costs or why it is moot; high, med or low. - Effort: S (hours), M (days), L (a week or more), XL (a redesign).
- Risk: what breaks, or who is surprised, if it is done or if it is not.
Where each mcpx missing row is tracked
Every mcpx missing row names its issue in the status cell. Most issues group
the rows one fix would close, so there are fewer issues than rows. All are in
the milestone mcpx: backlog build-out; the ones where mcpx declares
something it does not deliver hang under #177, the rest under #181.
| issue | title | rows |
|---|---|---|
| #47 | mcpx: port the opencode plugin to opencode v2 (a rewrite, and several things get better) | PLG-01, PLG-02, PLG-13, PLG-27, PLG-30 |
| #77 | mcpx: protocol follow-ups – inline answers from scripts, cancellation, task status, call-scoped timeout | ELI-20, PC-11 |
| #189 | mcpx plugin: DaemonClient.call sends fields the daemon does not read; mcpx_exec loses the session and reads a missing field | PLG-39, PLG-40 |
| #199 | mcpx: serving 2026-07-28 to a conformant client — server/discover fields, serverInfo, cache hints, header checks and HTTP statuses | ERR-07, ERR-09, ERR-13, LV-22, LV-23, LV-24, LV-26, PG-08, PG-09, PG-10, TR-26, TR-37, TR-38 |
| #200 | mcpx client: cannot reach a conformant 2026-07-28 server | CAP-03, CAP-09, CAP-11, LOG-02, LOG-07, LV-28, LV-29, PG-02, PG-13, SUB-15, TOOL-09, TR-30, TR-39 |
| #201 | mcpx: input_required for a stateless 2026 host — a busy loop, a session binding 2026 removed, and flattened results | ELI-10, ELI-17, ELI-18, MRTR-08, MRTR-10, MRTR-19 |
| #202 | mcpx server: legacy conformance — counter-offer on initialize, 2025-03-26 batches, no replies to notifications, sessions and error codes | ERR-04, ERR-05, ERR-06, ERR-10, ERR-16, LV-05, LV-06, LV-07, PG-03, SUB-18, TR-05, TR-09, TR-27, TR-28, TR-35, TR-36 |
| #203 | mcpx client: legacy Streamable HTTP and stdio robustness — session 404, GET stream, SSE retry, string ids, stream timeout, version header | LV-08, LV-39, PC-08, PC-09, PC-13, PG-07, TR-14, TR-22, TR-29, TR-34, TR-44, TR-45 |
| #204 | mcpx: validate Origin on /mcp and /v1, and refuse cross-origin simple requests to /v1/exec | AUTH-23, AUTH-24, TR-41 |
| #205 | mcpx: the per-server auth: block is parsed and never applied | AUTH-22 |
| #206 | mcpx: mcpx_call reports upstream isError as success, and every proxied result is flattened to text | CT-06, CT-13, META-11, PRM-07, RES-09, RES-10, TOOL-26 |
| #207 | mcpx: resources/templates/list sends uri instead of uriTemplate, upstream list metadata is dropped on parse, and titles leak to 2025-03-26 | PRM-01, PRM-02, PRM-03, PRM-04, RES-01, RES-02, RES-03, RES-04, RES-05, RES-06, RES-07, TOOL-17, TOOL-20, TOOL-22 |
| #208 | mcpx: subscriptions and list_changed — declared on stdio and never delivered, 202 over HTTP, and the 2026 wire shape | CAP-15, CAP-17, CAP-18, CAP-20, RES-14, RES-15, SUB-03, SUB-04, SUB-05, SUB-08, SUB-09, SUB-10, SUB-11, SUB-12 |
| #209 | mcpx: tasks — the 2026 extension is advertised and not implemented; core tasks are unbound and live in two stores | CAP-21, CAP-22, CAP-24, CAP-25, CAP-26, TASK-01, TASK-03, TASK-04, TASK-05, TASK-06, TASK-07, TASK-08, TASK-11, TASK-12, TASK-13, TASK-15, TASK-18, TASK-22, TASK-24, TASK-27, TOOL-21 |
| #210 | mcpx: capabilities declared to upstreams that nothing answers, and host gates that ignore sub-capabilities | CAP-02, CAP-04, CAP-08, ROOT-04, SMP-09, SMP-11 |
| #211 | mcpx: take the pool’s session key from the request (_meta["ai.opencode/sessionID"]), not the daemon’s pid | META-09, PM-10 |
| #212 | mcpx: relay progress, log messages and trace context between hosts and upstreams | CODE-13, LOG-08, META-07, PC-04, PC-05 |
| #213 | mcpx: completion/complete over MCP ignores ref and never asks the upstream | CMP-01, CMP-05, CMP-09, CMP-10 |
| #214 | mcpx: elicitation edges — enum downgrade for 2025-06-18, url mode upstream, -32042, and a script-side answer hook | ELI-04, ELI-09, ELI-12, ELI-21 |
| #215 | mcpx codegen: JavaScript reserved words, prelude-name clashes and colliding tool names | CODE-40, CODE-41, CODE-42 |
| #216 | mcpx exec: typed results from outputSchema, an output cap, the final expression as the result, typed failures, paged search | CODE-16, CODE-19, CODE-30, CODE-35, CODE-36, CODE-44 |
| #217 | mcpx plugin: the instructions option has never worked on opencode v1 | PLG-16 |
| #218 | mcpx and opencode code mode: nested code mode, ?codemode=false, and what a harness expects of mcpx_exec | CODE-53, PLG-35, PLG-36, PLG-37, PLG-38 |
| #219 | mcpx: /v1/exec leaves session-scoped servers running, /mcp never rebuilds its surface, /v1/protocol reports the default nativeElicit, stats reads one opencode database | PM-12, PM-13, PM-15, PM-21 |
| #220 | mcpx client: the deprecated HTTP+SSE transport, for remote servers that still speak it | TR-10, TR-11 |
Refreshing the status column
The status column is a snapshot of mcpx at 05c78b2, and mcpx has changed
since – including fixes to rows in every area. Treat every mcpx @ 05c78b2
cell as out of date until re-checked; some are wrong in mcpx’s favour. They
are not refreshed piecemeal, because refreshing a 511-row register by hand is
how a register starts lying: it is better to know the whole column is stale
than to trust a column that was half updated.
What has landed since is not listed here, because a hand-kept list goes stale the same way. Ask git:
git log --oneline 05c78b2..HEAD -- pkgs/mcpx
For the protocol rows, the current status of each specification requirement is
../spec/conformance-matrix.md, which is
generated from the tests and checked on every go test.
To refresh:
- Pick the new commit. Replace
05c78b2in the header of each table and in themcpx @field of each block you re-check. - For each row, re-read the mcpx
path:linecitations in its block. The status cell and themcpx @field are the only parts that describe mcpx. Everything else describes a specification or another product, and stays true until that changes. - Where the fix landed, set the cell to
✓and dropmcpx missingfrom the labels. Keep the row: the difference between the revisions is still real. - Update the counts above.
matrix-mcpx.md has the same property: each row cites the code it describes.
Citations
Every claim has a source, in one of these forms:
| form | means |
|---|---|
schema/<rev>/schema.ts:N | the schema of that revision, in modelcontextprotocol/modelcontextprotocol at 046fa30 |
<rev>/<path>.mdx:N | specification prose, docs/specification/<rev>/<path>.mdx in the same repository; public at https://modelcontextprotocol.io/specification/<rev>/<path> |
seps/<file>.md:N, docs/extensions/…:N, docs/docs/…:N | SEPs, extension pages and guides in the same repository |
internal/…:N, docs/…:N, plugin/…:N | mcpx, relative to pkgs/mcpx at 05c78b2 |
v1:packages/…:N | opencode 1.18.31, /nix/store/xczwr0rfqkd99yjg4aqv28yfxhim4pg1-source |
v2:packages/…:N | opencode 2.0.3, nix eval --raw .#opencode2.src (/nix/store/y08jwgxhq18hm706fisnqx68n39bid9i-source) |
.lootbox/…:N, nix:…:N | files in this repository at 05c78b2 (lootbox’s fork checkout is .lootbox/; the deployed lootbox is upstream 587a5a1 plus the patches in flake.nix) |
@modelcontextprotocol/sdk@1.22.0/…:N | the MCP SDK lootbox resolves, from the Deno npm cache |
https://unpkg.com/… (Ln) | an npm package opencode pins but neither tree vendors |
| a URL and a section heading | Cloudflare documentation or blog posts, fetched 2026-09-30 |
wire W20, M1, S2, … | a recorded session against a binary built from 05c78b2, with isolated state, driving the repository’s test servers; see stateless.md |
Quoted text after a citation is verbatim from that line. Four quotes of specification prose have relative links expanded to absolute ones.
Related documents
../protocol.md: mcpx’s own protocol design (accept liberally, send conservatively, onedowngrade()at the edge). This directory records differences and conflicts; it does not restate that design.docs/spec/server-obligations.md: the exhaustive list of what a server MUST and SHOULD do per revision. The protocol work is writing it, and it is not onmainat05c78b2. what-a-server-should-be.md summarises the character of each revision and product, not the obligations.../opencode-plugin.md: the plugin’s design. Claims in it that the pinned sources contradict are in conflicts.md §E.
The comparison chapter
created: 2026-10-29T19:20:00-05:00
last-updated: 2026-10-29T19:20:00-05:00
increment: 1
status: standard
tags: area:compare
description: mcpx next to lootbox, opencode code mode, Cloudflare Code Mode,
Anthropic programmatic tool calling, mcpac, mcpc and
code-mode-mcp as one table plus prose, every row stamped with
what it was checked against, including where the others are
better.
This is the presentation the compendium asks for: one table, then one section per neighbour, honest about where they are better. The details live in the register and the matrices; this page is the reading copy, and every row says what it was checked against so a stale row is visible as a stale row.
How to read a row. The mcpx cell carries mcpx @ 5a34fa70 — checked
against the code at that commit on 2026-10-29. The other cells carry the date of
the last check recorded in the register, 2026-09-30, or the date the upstream
documentation was fetched. A row whose stamp is older than your copy of the
other project may be out of date: re-check it, do not assume it.
The table
mcpx @ 5a34fa70 | lootbox (deployed 587a5a1+4 patches) | opencode code mode (v1 1.18.31 / v2 2.0.3) | Cloudflare Code Mode | Anthropic programmatic tool calling | sh1nduu/mcpac | apify/mcpc | tmustier/code-mode-mcp | |
|---|---|---|---|---|---|---|---|---|
| Shape | CLI + daemon, generated TypeScript client, MCP server mode | Deno daemon + CLI, launchd singleton on :9420 | a feature inside opencode’s agent loop (v1 behind OPENCODE_EXPERIMENTAL_CODE_MODE, v2 on by default) | a feature of the Agents SDK on Workers | a hosted API feature of Claude’s tool use | CLI: generate TS types, execute | CLI MCP client | stdio MCP server exposing one exec tool |
| Who invokes | shell: any agent, script, human, CI | shell / HTTP | the model, via execute | the model, via one tool | Claude, via code_execution | shell | shell | the model |
| Runtime | real deno / bun / node subprocess | Deno subprocess | purpose-built JS interpreter in-process (v1: no classes or generators; v2: generators, no classes) | V8 isolate via the Worker Loader API | Python in Anthropic’s container | Bun | none (shell + --json) | Node |
| Confinement | not “none”: script.permissions defaults to all and has profiles (net, read, read-net, strict), taking effect under Deno — an audited, opt-in boundary | Deno permission flags; filesystem and subprocess off, network open by default | strong: only implemented language features exist; no filesystem, no network | real isolation: isolate, fetch()/connect() throw, bindings hide credentials | managed sandbox, zero infrastructure | declared + granted capability lists, checked against the script’s declaration | n/a | none: “same authority as the Node.js process” |
| Discovery | ls / search / types outside the context, plus in-script search() / describe() | recomputed on every request — the bug that started all this | budgeted catalog + in-program search(), diff updates | whole generated API in context; the post calls search a “future improvement” | tool search is a separate platform feature | CLI exploration | grep, tools-list, tools-get | search() / describe() in the program |
| Stateful server isolation | shared / pooled / session pools, one process per run | none: one shared client, one Chrome | one connection per server per workspace; _meta sessionID hint | per agent | n/a | not described | persistent named sessions | in-memory JSON session state |
| Outside a model turn | yes | yes | no | no | no | yes | yes | only via an MCP client |
| Where it is better | — | nothing we still need | confinement by construction; the catalog budgeting mcpx copied | real isolation; credentials never reach the code | zero infrastructure; allowed_callers | capability grants checked against a declaration | the widest protocol coverage on a CLI, including OAuth 2.1 | OAuth, sampling and elicitation as a drop-in MCP server |
Stamps: mcpx column and the confinement correction at 5a34fa70 (2026-10-29),
from register/code-mode.md and
matrix-code-mode.md; the opencode, lootbox and
Cloudflare columns as recorded in the same matrix (checked 2026-09-30 against
opencode 1.18.31 and 2.0.3, lootbox 587a5a1+4 patches, and Cloudflare’s
documentation); the three search-found projects from their READMEs as fetched
that day; Anthropic from
https://www.anthropic.com/engineering/code-execution-with-mcp (2025-11-04)
and https://platform.claude.com/docs/en/agents-and-tools/tool-use/programmatic-tool-calling.
The earlier seed table in #1869 said mcpx’s confinement is “none, by choice”.
That was wrong when it was written: script.permissions exists with four
profiles and is enforced under Deno
(internal/settings/registry.go:183, ../container.md §2).
The default is still all, which is a stance, not an accident — see the
divergence below.
The pattern, and where mcpx diverges from it
The MCP client best-practices guide
(https://modelcontextprotocol.io/docs/2026-07-28/develop/clients/client-best-practices)
is not a product here: it is the pattern’s specification — progressive
discovery (catalog, inspect, execute) plus programmatic tool calling — and it
says the pattern “requires clients to implement a sandbox environment” with no
direct network access. mcpx deliberately has none; ASSESSMENT.md records the
instruction as “I dropped the sandbox, as you asked”. That divergence is stated
here rather than buried: mcpx is a tool for an owner who wants a script to have
the machine’s authority, and every neighbour above with real confinement is
making a different trade.
The neighbours, one section each
lootbox
The predecessor. Same shape (daemon + CLI + generated types), same purpose, and the reason mcpx exists. Its discovery recomputed every request, it was killed by the OOM reaper under load, its artifact was 158 MB, and one Chrome served every agent — the last is architectural, not a bug to fix. Nothing it did is still missed. Checked 2026-09-30; it has had no upstream commit since 2026-02-27.
opencode code mode
The one that nearly made mcpx pointless, and the one mcpx learned from. Where it
is better: confinement comes free, because a purpose-built interpreter simply
does not implement fetch, Deno.run or a filesystem; and the catalog budget
with in-program search() was the model mcpx copied. Where it is not: it only
exists inside a model turn, and it keeps one MCP connection per server per
workspace, so concurrent Chrome work is still shared. The 2026-09-29 correction
means v1 has code mode too — the earlier claim that v1 users had nothing is
wrong. Checked against opencode 1.18.31 and 2.0.3 (2026-09-30).
Cloudflare Code Mode
Where it is better, plainly: real isolation with a per-pass V8 isolate, network
blocked or routed through a host Fetcher, and credentials that never reach the
code. It also has CPU, subrequest, output and durable-value limits that mcpx
does not have any of. Where it is not: it is a hosted product feature, not
something a human or a CI job can call from a shell. Checked against
Cloudflare’s Code Mode documentation (2026-09-30); the original argument is the
2025-09-26 post, https://blog.cloudflare.com/code-mode/.
Anthropic programmatic tool calling
The pattern with zero infrastructure: the model writes Python in a managed sandbox and calls tools as functions. Better at exactly that. Not comparable as a tool — there is no CLI, no daemon and nothing to run outside a model turn, and its own documentation says tools “provided by an MCP connector” cannot be called programmatically, which is the gap mcpx fills. Checked 2026-09-30.
sh1nduu/mcpac
A Bun CLI that generates TypeScript types and executes. Better at capability grants: the script declares what it needs and the grant list is checked against that declaration, which is a more interesting model than mcpx’s all-or-nothing default. Checked against its README 2026-09-30.
apify/mcpc
A CLI MCP client with the widest protocol coverage of the group on a command line, including OAuth 2.1, plus persistent named sessions. Better at protocol coverage. It does not run code, so it is a complement rather than a rival: it is also where to look for the longest maintained table of MCP CLI clients, which is why this page links it instead of copying it. Checked against its README 2026-09-30.
tmustier/code-mode-mcp
The inverse shape: a stdio MCP server that exposes one exec tool, so the model
gets code mode through the protocol rather than through a harness. Better at
being a drop-in — OAuth, sampling and elicitation come with being a server.
Worse at isolation: it says the script has “same authority as the Node.js
process”. Checked against its README 2026-09-30.
Re-checking
Rows are re-checked when the compared project moves or when mcpx does, and a
re-check is stamped with the commit or date it used. The mcpx column has the
same problem the register’s mcpx @ column has: it is a snapshot, and the code
moves faster than this page. When a stamp here is older than the register’s, the
register is right.
Related
history.md: how mcpx got here, including the correction.README.md: the register, the matrices, the citation forms.../../ASSESSMENT.md,../../OPENCODE-V2.md: the two frozen records.
Communication conflicts
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare, area:protocol
description: every place two parties cannot both be satisfied: revision
against revision, prose against schema, product against
product, one mcpx surface against another, and mcpx's
documents against its code.
A conflict here is a place where doing what one side expects breaks another.
Some are in the specification itself. Some are between the products mcpx sits
between. Some are inside mcpx. mcpx’s design answer to the protocol-level ones
(accept liberally, send conservatively, one downgrade() at the edge, two
mechanisms for one question) is in ../protocol.md and is
not repeated here. This page records the collisions, not the design. mcpx
status is as of 05c78b2.
Each entry names both sides, the source for each, and what mcpx does or should do. IDs are stable so issues and register rows can point at them.
A. Revision against revision
A dual-era server has to serve both sides of each of these, per request.
C-01 A request with neither _meta nor a session. 2026-07-28 calls it
malformed and requires -32602 with HTTP 400 (2026-07-28/basic/index.mdx:380-382).
The legacy HTTP rules say a server that receives no MCP-Protocol-Version
“SHOULD assume protocol version 2025-03-26”
(2025-06-18/basic/transports.mdx:250-254). The specification never says what
a dual-era server should do. mcpx serves it as 2025-03-26
(internal/mcpserver/conn.go:137-146). That is right for a dual-era server,
and it should stay. See stateless.md §4.
C-02 Version negotiation. Every legacy revision says the server MUST answer
initialize with a version it supports, a counter-offer
(2025-03-26/basic/lifecycle.mdx:130-133). 2026-07-28 answers an unsupported
version with an error, -32022 (2026-07-28/basic/versioning.mdx:48-67).
mcpx applies the 2026 behaviour to legacy initialize: 2024-11-05 and unknown
versions get -32022, a code no legacy client knows
(internal/mcpserver/server.go:844-859, W1). It should counter-offer on
initialize and keep -32022 for _meta requests.
C-03 Resource not found. -32002 through 2025-11-25; -32602 in
2026-07-28, which also forbids emitting -32002 and asks clients to still
accept it (2026-07-28/basic/index.mdx:136-142). mcpx sends -32602 to every
revision, so legacy clients get the wrong code
(internal/mcpserver/server.go:713-716).
C-04 Logging’s default flipped. 2025-11-25: with no logging/setLevel “the
server MAY decide which messages to send automatically”
(schema/2025-11-25/schema.ts:1545). 2026-07-28: with no per-request
logLevel the server “MUST NOT emit notifications/message”
(2026-07-28/server/utilities/logging.mdx:62-64). mcpx emits no log messages
to hosts at all, so it satisfies both by silence. Its own comment states the
2026 rule backwards (internal/mcpserver/revisions.go:72-73).
C-05 How a server asks. Legacy: a server-initiated request on the open
connection. 2026-07-28: input_required and a retried request
(2026-07-28/basic/patterns/mrtr.mdx:7-14). Two mechanisms, one question.
mcpx bridges them both ways (input-required.md).
C-06 Resource subscriptions. resources/subscribe and unsubscribe
through 2025-11-25; subscriptions/listen with a resourceSubscriptions filter
in 2026-07-28 (2026-07-28/changelog.mdx:18). The capability flag
resources.subscribe survives in the 2026 schema with a new meaning, “supports
resourceSubscriptions on listen” (schema/2026-07-28/schema.ts:850,
2026-07-28/server/resources.mdx:58). mcpx accepts both from either era.
C-07 Tasks, core against extension. The 2025-11-25 experimental tasks and
the 2026 io.modelcontextprotocol/tasks extension are “not
wire-compatible” (seps/2663-tasks-extension.md:944). tasks/result and
tasks/list are removed and tasks/update added. failed changes meaning:
in core it includes tool results with isError; in the extension those are
completed (schema/2025-11-25/schema.ts:1310,
seps/2663-tasks-extension.md:835). mcpx serves the core shape to every era
and advertises the extension (internal/mcpserver/server.go:1404-1417).
C-08 _meta key prefixes. 2025-06-18 recommends forward DNS
(example.com/); 2025-11-25 recommends reverse DNS (com.example/)
(2025-11-25/basic/index.mdx:207-210). Neither changelog mentions it. mcpx
reserves nothing of its own in _meta. opencode’s ai.opencode/sessionID
follows the reverse rule.
C-09 Batching lived for one revision. 2025-03-26 servers “MUST support
receiving JSON-RPC batches” (2025-03-26/basic/index.mdx:97); 2025-06-18
removes batching (2025-06-18/changelog.mdx:12). mcpx lists 2025-03-26 as
supported and rejects batches with -32700 (W16).
C-10 Is a dropped connection a cancellation? 2025-03-26 through
2025-11-25: “Disconnection SHOULD NOT be interpreted as the client
cancelling its request” (2025-11-25/basic/transports.mdx:128). 2026-07-28:
closing the response stream “MUST be treated by the server as
cancellation” (2026-07-28/basic/transports/streamable-http.mdx:235-236).
mcpx propagates the HTTP request context to the upstream call for every era.
It is right for 2026 and against the legacy SHOULD NOT. This is inferred from
the context chain and was not driven.
C-11 notifications/cancelled.requestId. Optional in 2025-11-25 (so a task
can be cancelled without one), required again in 2026-07-28
(schema/2025-11-25/schema.ts:223). mcpx reads it when present.
C-12 URL elicitation’s plumbing. 2025-11-25 added elicitationId,
notifications/elicitation/complete and -32042; 2026-07-28 removed all three
(2026-07-28/changelog.mdx:54-60, 2026-07-28/basic/index.mdx:144). mcpx passes
an upstream’s elicitation params through to a 2026 host verbatim, so a 2026
host can receive an elicitationId it has no use for.
C-13 Sessions. Legacy: a client “MUST include” the Mcp-Session-Id it
was given (2025-11-25/basic/transports.mdx:206-208). 2026-07-28: a server
supporting only that revision should “ignore it, and do not mint or echo
session IDs” (2026-07-28/basic/transports/streamable-http.mdx:680-686). mcpx
mints one on server/discover, a modern request, and its modern input_required
path needs it back (stateless.md).
C-14 MCP-Protocol-Version. Legacy (2025-06-18+): required after
initialization; an invalid value is a 400
(2025-06-18/basic/transports.mdx:256-257). 2026-07-28: required on every POST
and must equal the _meta version, else -32020
(2026-07-28/basic/transports/streamable-http.mdx:252). The 2026 page also
says revisions before 2025-06-18 “did not define” the header
(2026-07-28/basic/transports/streamable-http.mdx:278). 2025-03-26 did define
it, for OAuth metadata discovery (2025-03-26/basic/authorization.mdx:135-139).
mcpx ignores an invalid legacy header (W14) and answers a 2026 mismatch with a
non-JSON-RPC 400 (W10).
C-15 structuredContent and outputSchema. An object, described by an
object-rooted schema, in 2025-06-18 and 2025-11-25; any JSON value, any schema
in 2026-07-28 (schema/2026-07-28/schema.ts:1821). A server that returns an
array to a 2026 client must not return it to a 2025 one. mcpx emits no
structuredContent today.
C-16 Client registration. Dynamic Client Registration is a SHOULD in
2025-03-26 (2025-03-26/basic/authorization.mdx:42), a MAY in 2025-11-25
(behind Client ID Metadata Documents), and deprecated in 2026-07-28
(2026-07-28/deprecated.mdx:29). mcpx performs no OAuth.
C-17 includeContext. thisServer and allServers are soft-deprecated in
2025-11-25 and deprecated in 2026-07-28 (2026-07-28/changelog.mdx:84-88),
gated by sampling.context in between. mcpx forwards sampling requests to hosts
without checking it.
B. One revision against itself
C-20 Bad tool arguments: protocol error or tool error? 2026 prose makes
input-validation failures isError results
(2026-07-28/server/tools.mdx:760-765). So does the 2025-11-25 changelog
(2025-11-25/changelog.mdx:30). The 2026 schema’s InvalidParamsError still
lists “invalid tool arguments” as -32602 (schema/2026-07-28/schema.ts:366)
and ships a -32602 example for it. mcpx reports its own tools’ argument
errors as isError results (internal/mcpserver/server.go:665-675), which
follows the prose.
C-21 Ending a subscription (2026). The cancellation page says the server
MUST send notifications/cancelled for the listen id
(2026-07-28/basic/patterns/cancellation.mdx:11). The subscriptions page says
it SHOULD send a successful subscriptions/listen result, then close
(2026-07-28/basic/patterns/subscriptions.mdx:122-124). The schema limits the
server-sent cancel to stdio (schema/2026-07-28/schema.ts:637).
C-22 Client cancellation (2026). “A client SHOULD send a cancellation
notification” (2026-07-28/basic/patterns/cancellation.mdx:8), but on HTTP the
transport “defines no client-to-server” notification. Closing the stream is
the cancellation (2026-07-28/basic/transports/streamable-http.mdx:95-101).
Read the SHOULD as stdio-only.
C-23 Legacy lifecycle against its own example. A counter-offer is a MUST
(2024-11-05/basic/lifecycle.mdx:126), yet the same page’s error example
answers an unsupported version with -32602 (2024-11-05/basic/lifecycle.mdx:210).
The client’s disconnect is a SHOULD in prose (:130) and a MUST in the schema
(schema/2024-11-05/schema.ts:162).
C-24 Which results are cacheable (2026). The changelog names five methods
(2026-07-28/changelog.mdx:36); the caching page and the schema add
server/discover (2026-07-28/server/utilities/caching.mdx:16,
schema/2026-07-28/schema.ts:678).
C-25 Where inputResponses may go (2026). The schema says “any
client-initiated request” (schema/2026-07-28/schema.ts:598); the prose allows
input_required on three methods only (2026-07-28/basic/patterns/mrtr.mdx:192).
C-26 Is resultType open (2026)? Typed as "complete" | "input_required" | string (schema/2026-07-28/schema.ts:216), but “a resultType of any value
unrecognized by the client MUST be considered invalid”
(2026-07-28/basic/index.mdx:84). An extension value ("task") is valid only
where the extension was negotiated. mcpx stamps "complete" on its task
handle, where the extension’s value would be "task".
C-27 The examples break their own rule (2026). The list_changed examples
carry no _meta (2026-07-28/server/tools.mdx:246), though every notification
on a listen stream MUST carry io.modelcontextprotocol/subscriptionId
(2026-07-28/basic/index.mdx:413). An implementer copying the example (as mcpx’s
stdio listen path effectively does) is non-conformant.
C-28 The tasks SEP against itself. tasks/get responses MUST use
resultType: "complete" (seps/2663-tasks-extension.md:340), but its own error
examples show "task" (:844, :868). It names the protocol version
2026-06-30 (:17). It reserves notifications/tasks/ but names its
notification notifications/tasks (:894, :422).
C-29 “Unsolicited” tasks (2026). The changelog says servers may return task
handles “without per-request opt-in” (2026-07-28/changelog.mdx:22). The
extension still requires the client to list it in each request’s capabilities
(docs/extensions/tasks/overview.mdx:231-233). Both hold once “opt-in” is read
as “the removed task parameter”.
C-30 Answers cannot be errors (2026). InputResponse has no error variant
(schema/2026-07-28/schema.ts:541-542). Yet the schema still documents
client-side -32602 for elicitation modes and sampling tool results
(schema/2026-07-28/schema.ts:370-371), errors a client can no longer return
inside MRTR.
C-31 Deprecation windows (2026). Twelve months minimum
(2026-07-28/changelog.mdx:110), except HTTP+SSE: three months after SEP-2596
reaches Final (2026-07-28/deprecated.mdx:31).
C. Product against product
C-40 Neither opencode speaks 2026 by default. v1’s SDK negotiates
2025-11-25 and has no modern path
(v1:patches/@modelcontextprotocol%2Fsdk@1.29.0.patch:36). v2 speaks
2026-07-28 only when a server is configured protocol: "auto" or
"2026-07-28" (v2:packages/schema/src/mcp.ts:20-23). So mcpx’s modern serving
path is not exercised by either host out of the box. docs/opencode-plugin.md
says v2 will exercise it; that holds only with the option set.
C-41 Session identity arrives two ways, and mcpx reads neither on /mcp.
The v1 plugin injects MCPX_SESSION_ID into shell commands through shell.env
(plugin/opencode/mcpx-session.ts:664). That reaches the CLI, not /mcp. v2
has no session id in its shell hook, and instead sends
_meta["ai.opencode/sessionID"] on every tools/call
(v2:packages/core/src/mcp/client.ts:278). mcpx’s MCP server keys every call by
process id (internal/cli/serve.go:292-297).
C-42 Code mode inside code mode. v2 wraps every MCP server in its own code
mode unless the server is configured codemode: false
(v2:packages/schema/src/mcp.ts:34-35), and the built-in exclusion matches only
executor (v2:packages/core/src/plugin/mcp-codemode-exclusion.ts:16). mcpx’s
mcpx_exec then runs as a tool called from a program the model wrote for
opencode’s interpreter. v2 also appends ?codemode=false to remote MCP URLs so
servers that bundle code mode can expose raw tools
(v2:packages/core/src/mcp/client.ts:214-218). mcpx ignores the parameter.
C-43 Tool names. opencode names MCP tools <server>_<tool>
(v1:packages/opencode/src/mcp/catalog.ts:119), so mcpx configured as mcpx
becomes mcpx_mcpx_namespaces. v2 rejects any name part over 64 characters
(v2:packages/core/src/tool.ts:297).
C-44 v1 drops what mcpx’s artifacts use. v1’s direct tool path keeps text,
images and embedded resources and drops audio and resource_link
(v1:packages/opencode/src/session/tools.ts:429-436). mcpx_exec artifacts are
resource_link blocks, so under v1 without code mode the model never sees
them.
C-45 Declaring resources costs every v1 request context. v1 adds three
resource tools to every request when any server declares resources
(v1:packages/opencode/src/session/tools.ts:136). mcpx always declares it.
C-46 Nobody answers mcpx’s questions on v1. v1 has no elicitation/create
handler and declares no elicitation (v1:packages/opencode/src/mcp/index.ts:42-48,
:77). mcpx correctly never sends it one, so a question raised under v1 goes
to the broker. v2 declares form and url and answers them, but attributes them
to the Location, not the session (v2:packages/core/src/mcp/index.ts:80-82).
C-47 Progress that keeps nothing alive. v2’s comment says requesting
progress “keeps long calls alive under the SDK’s timeout”, but
resetTimeoutOnProgress is never set (v2:packages/core/src/mcp/client.ts:279-281).
mcpx never forwards progress, so in either host a long mcpx_call is bounded
by the host’s timeout: 60 s on v1 unless configured, 12 h on v2.
C-48 lootbox declares nothing. lootbox’s MCP client (SDK 1.22.0, latest
2025-06-18) sends capabilities: {}
(.lootbox/src/lib/external-mcps/mcp_client_manager.ts:150-153). An upstream
that elicits gets method-not-found. mcpx declares elicitation and answers
through its broker.
C-49 Sandbox or no sandbox. The MCP client best-practices guide’s programmatic tool calling “requires clients to implement a sandbox”. Cloudflare argues a shell-based CLI is “a much broader attack surface than a sandboxed isolate”. opencode runs only the language features it implements. mcpx runs scripts on a real runtime with full authority, by choice (register/code-mode.md). Nobody is wrong on the facts. The disagreement is about who the caller is and what it may do.
D. One mcpx surface against another
C-60 mcpx_call against the script client. The same upstream result is
seen differently. mcpx_call drops isError, pretty-prints structuredContent
as text and drops images (internal/cli/commands.go:384-415). A script gets
ToolError, the structured value, and every block on .raw
(internal/codegen/emit.go:493-518). See content-passthrough.md.
C-61 Two task stores. The comment on the task store says one store serves
both the MCP server and /v1 so a task started on one is visible on the other
(internal/tasks/tasks.go:9-12). There are two: the MCP server’s
(internal/mcpserver/tasks.go:31-38) and the daemon’s
(internal/daemon/ops.go:428-429).
C-62 stdio and HTTP declare different capabilities. The same server
declares listChanged: true and subscribe: true over stdio and false over
HTTP (W2, S2). Neither delivers: nothing starts a notifier for a legacy stdio
client (internal/mcpserver/server.go:1389-1402).
C-63 mcpx’s server and client agree with each other and not with the
schema. Both use protocolVersions for server/discover
(internal/mcpserver/server.go:534, internal/mcpclient/client.go:284), and
three tests pin it (internal/mcpserver/server_test.go:393,
internal/mcpclient/era_test.go:87, internal/mcpclient/modern_test.go:88).
Self-consistent tests are how an interoperability bug passes CI.
C-64 The plugin’s mcpx_exec and the daemon’s /v1/exec. The plugin reads
an output field the daemon’s result does not have, and sends no session,
working directory or abort signal (plugin/opencode/mcpx/; see
register/plugin-apis.md).
C-65 The CLI and /v1/exec release sessions differently. The CLI sets
MCPX_EPHEMERAL (internal/cli/commands.go:694); /v1/exec does not
(internal/execsvc/execsvc.go:573-593). So session-scoped instances, a browser
for instance, are released at the end of a CLI run and left for idle reaping
after a /v1/exec run. Found by reading code; not driven.
C-66 /v1/protocol reports the default, not the setting. nativeElicit is
the compiled default rather than the effective configuration
(internal/daemon/routes_proto.go:530), on the one endpoint whose comment
says it cannot disagree with the code (internal/daemon/routes_proto.go:495-497).
C-67 A script name locally, a path remotely. Already tracked as #106: the
CLI resolves a script name against a search path, and /v1/exec treats file
as a path.
E. mcpx’s documents against its code
docs/protocol.md at 05c78b2 against the code at 05c78b2. The PROTOCOL work
under way is rewriting that document; these are recorded so a reviewer can
check each is gone.
| # | docs/protocol.md says | the code does | where |
|---|---|---|---|
| E-1 | initialize accepts “any legacy version” (§2.1) | 2024-11-05 and unknown versions are refused -32022 | internal/mcpserver/server.go:844-859 |
| E-2 | logging declared for 2025-03-26 to 2025-11-25, “removed in 2026-07-28” (§2.2) | never declared; 2026 deprecated it rather than removing it | internal/mcpserver/server.go:1398-1403; schema/2026-07-28/schema.ts:808 |
| E-3 | listChanged and subscribe ✓ in every revision (§2.2) | false over HTTP; true over stdio and never delivered | internal/mcpserver/server.go:1389-1402 |
| E-4 | “declares none of them outside the revision that defines them” (§2.1) and extensions[tasks] ✓ for 2025-11-25 (§2.2) | extensions does not exist in 2025-11-25 | internal/mcpserver/server.go:1404-1417 |
| E-5 | a stateless modern connection’s questions “stay with the broker” (§3.3) | the call is abandoned with -32603 after eight immediate rounds | internal/mcpserver/ask.go:195-209 |
| E-6 | sampling declared upstream “only when a handler exists” (§4) | always declared; the daemon always installs a handler | internal/daemon/hooks.go:69 |
| E-7 | roots/list answers “the configured roots” (§4.1) | always [] | internal/daemon/server.go:176 |
| E-8 | older upstream revisions “on request (protocol: force-legacy)” (§1) | force-legacy only disables fallback; 2025-11-25 is always offered | internal/mcpclient/client.go:261-267 |
| E-9 | notifications/tasks/status is dropped because “mcpx polls task state” (§4.2) | mcpx never creates upstream tasks, so nothing is polled | internal/mcpclient/client.go:845-858 |
| E-10 | OAuth “declared and not performed” (§6) | the whole per-server auth block (bearer, basic, header, query, env) is never applied; mcpauth.Auth.Resolve has no caller | internal/mcpauth/mcpauth.go:93; internal/config/config.go:141 |
| E-11 | resources/subscribe “forwards from the same bus” (§2.1) | over HTTP it cannot push, and mcpx never subscribes upstream | internal/mcpserver/server.go:595-620 |
| E-12 | subscriptions/listen sends “only the four notification kinds” (§2.1) | over HTTP: 202, empty body, no stream (W17) | internal/mcpserver/server.go:622-640 |
| E-13 | inbound cancellation “does not reach an upstream call” (§6) | true of the notification; an HTTP disconnect does propagate by context | internal/mcpserver/server.go:1521-1538 |
| E-14 | the 2025-03-26 schema “was not among” those read (§1) | it is available now, and it confirms the claims made about it | schema/2025-03-26/schema.ts |
| E-15 | mcpSession comment: a host has “no way to tell us” its session | opencode v2 sends _meta["ai.opencode/sessionID"] | internal/cli/serve.go:289-290 |
| E-16 | server.go header: “Ten tools instead of three hundred” | 61 tools (W6) | internal/mcpserver/server.go:12-13 |
| E-17 | readArtifact: “resources/read carries text” | BlobResourceContents.blob exists in every revision | internal/cli/serve.go:704-707; schema/2024-11-05/schema.ts:506-512 |
| E-18 | revisions.go: 2026 servers emit logs “at a level of their own choosing” | 2026: no logLevel, no log messages | internal/mcpserver/revisions.go:72-73; 2026-07-28/server/utilities/logging.mdx:62-64 |
The existing comparison documents also say things the pinned sources do not:
| # | document says | pinned source says | where |
|---|---|---|---|
| E-20 | OPENCODE-V2.md: opencode code mode provides fetch as an extension | 2.0.3’s execute description says “Do not use fetch”, and core registers no extension | OPENCODE-V2.md:51; v2:packages/core/src/codemode/tool.ts:63 |
| E-21 | OPENCODE-V2.md: “If you stay on opencode v1 … none of v2’s Code Mode is available to you today” | v1 enables code mode whenever OPENCODE_EXPERIMENTAL is set, and this machine sets it | OPENCODE-V2.md:222; v1:packages/opencode/src/effect/runtime-flags.ts:48; nix:flake.nix:297 |
| E-22 | OPENCODE-V2.md: tagged templates present; “251 checked items, 17 gaps”; “10,764 lines” | 2.0.3: tagged templates a gap; 211 checked and 30 open; 9,456 lines | OPENCODE-V2.md:104-105, :19; v2:packages/codemode/interpreter-support.md:60 |
| E-23 | OPENCODE-V2.md: the exclusion plugin is mcp-codemode-defaults.ts and covers code-mode servers generally | it is mcp-codemode-exclusion.ts and matches executor only | OPENCODE-V2.md:53; v2:packages/core/src/plugin/mcp-codemode-exclusion.ts:7-16 |
| E-24 | docs/opencode-plugin.md: a server plugin has client.tui.*, “identical in v1 and v2” | v2’s server plugin context has no client; toasts are TUI-plugin only | docs/opencode-plugin.md:111, :361; v2:packages/plugin/src/promise/plugin.ts:26 |
| E-25 | docs/opencode-plugin.md: a tool with a permission prompt works in v1 and v2 | v2’s tool context has no ask | docs/opencode-plugin.md:272; v2:packages/schema/src/tool.ts:14 |
| E-26 | docs/opencode-plugin.md: v2’s PUT /api/experimental/session/:id/environment is the API for session env | it replaces the whole environment, the TUI sets it itself on every session view, and plugins have no HTTP client | docs/opencode-plugin.md:86, :412; v2:packages/tui/src/app.tsx:511 |
| E-27 | docs/opencode-plugin.md: v1 execution timeout “one value, 30 s” | 30 s is connect and list; tools/call defaults to the SDK’s 60 s | docs/opencode-plugin.md:178; v1:packages/opencode/src/mcp/index.ts:672 |
| E-28 | docs/opencode-plugin.md: v1 “will get cancel” for an elicitation | a v1 client answers -32601; mcpx never sends it one | docs/opencode-plugin.md:184; v1:packages/opencode/src/mcp/index.ts:77 |
| E-29 | ASSESSMENT.md: lootbox runs scripts --allow-all | --allow-net | ASSESSMENT.md:73; .lootbox/src/lib/constants.ts:78 |
F. mcpx sends what the peer did not negotiate
“Send conservatively” is mcpx’s own rule (../protocol.md).
These are the places where it breaks it at 05c78b2:
| # | sent | to | where |
|---|---|---|---|
| F-1 | -32022, a 2026 code | a legacy client’s initialize | internal/mcpserver/server.go:506-509 |
| F-2 | a top-level serverInfo in DiscoverResult (2026 puts it in _meta) | 2026 clients | internal/mcpserver/server.go:535 |
| F-3 | Mcp-Session-Id | 2026 clients (server/discover) and failed legacy handshakes | internal/mcpserver/server.go:1214-1229 |
| F-4 | the core tasks capability | 2026 clients | internal/mcpserver/server.go:1404-1417 |
| F-5 | capabilities.extensions | 2025-11-25 clients | internal/mcpserver/server.go:1404-1417 |
| F-6 | listChanged: true, subscribe: true with nothing behind them | legacy stdio clients | internal/mcpserver/server.go:1389-1402 |
| F-7 | title on prompts | 2025-03-26 clients | internal/mcpserver/server.go:724-740 |
| F-8 | the upstream’s raw elicitation params, elicitationId included | 2026 clients | internal/mcpserver/ask.go:256-265 |
| F-9 | sampling requests with tools or includeContext, unchecked against sampling.tools or sampling.context | hosts | internal/mcpserver/conn.go:211 |
| F-10 | responses to notifications (an error for an unknown one) | any client | internal/mcpserver/server.go:766 |
| F-11 | sampling declared with no answerer behind it | every upstream | internal/daemon/hooks.go:69 |
| F-12 | legacy-shaped capabilities in 2026 _meta, and no mention of url elicitation or tasks | modern upstreams | internal/mcpclient/modern.go:46-58 |
| F-13 | MCP-Protocol-Version: 2025-11-25 regardless of what was negotiated | legacy HTTP upstreams that negotiated lower | internal/mcpclient/http.go:81-85 |
| F-14 | logging/setLevel, a method 2026 removed | modern upstreams | internal/daemon/hooks.go:29-33 |
| F-15 | resultType: "complete" on a task handle whose extension value is "task" | 2026 clients | internal/mcpserver/server.go:452-453 |
Content types through mcpx: did it need them?
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare, area:protocol
description: what mcpx does with each MCP content type on each path a
result can take, and whether supporting audio, resource_link,
structuredContent and the rest was ever necessary.
The short answer
Mostly no, and the reason is not the one the code assumes.
internal/mcpserver/revisions.go is built for a proxy that passes upstream
content through and spells it down for older clients. It turns
structuredContent into text for 2025-03-26, turns resource_link into an
embedded resource, and turns audio into a placeholder
(internal/mcpserver/revisions.go:132-170). That design is sound, and at
05c78b2 very little of it runs. mcpx does not pass upstream content through
on its main path at all. mcpx_call, the tool that reaches one upstream tool,
renders every result to text before downgrade() ever sees it
(internal/cli/serve.go:103-117, internal/cli/commands.go:384-415).
The only non-text block mcpx itself emits is resource_link, for a script’s
artifacts (internal/cli/serve.go:174-186). So:
audio: never emitted. The audio→text branch can never fire, because mcpx’s floor is 2025-03-26, the revision that introduced audio (internal/mcpserver/revisions.go:14,:87).structuredContent: never emitted.mcpx_callpretty-prints it into a text block for every revision, 2026-07-28 included (wire W21).resource_link: emitted for script artifacts; downgraded to an embeddedresourcefor 2025-03-26 hosts. This is the one live branch.resultType: stamped for 2026-07-28, stripped for everyone else. Live.image, embeddedresource, annotations,_metaon content: never touched bydowngrade(), and never passed through bymcpx_call.
mcpx did not need the audio, structuredContent and resource_link branches
for what it does today. It will need them when mcpx_call stops flattening,
and it should stop, for two reasons unrelated to revisions. It drops
isError, so a failed upstream call looks like a success (wire W20). And it
drops images and embedded resources entirely, which a host such as opencode v2
would have rendered (v2:packages/core/src/mcp/client.ts:325-328).
Per content type, per path
A result can reach a host by six paths. What each does with each type at
05c78b2:
| content | mcpx_call (MCP tool; also mcpx call on the CLI) | mcpx_exec (MCP tool) | a call that stopped to ask a question | prompts/get | resources/read | generated script client (tools.ns.tool()) |
|---|---|---|---|---|---|---|
text | kept; blocks joined with \n, unless structuredContent is present, in which case the text is dropped and only the structured value is shown | whatever the script prints | kept, joined | kept; non-user roles prefixed [role]; all merged into one user message | kept; multi-part contents merged into one | joined; parsed as JSON when it looks like JSON |
image | dropped; if no text block exists the whole result is dumped as JSON text, base64 and all | stored as an artifact and replaced by a resource_link | dropped | dropped | n/a | kept in the returned block array and on .raw |
audio | dropped (same as image) | stored as an artifact, replaced by a resource_link | dropped | dropped | n/a | kept in the block array and on .raw |
embedded resource (text) | dropped | passed as the script returns it | dropped | dropped | n/a | kept in the block array |
embedded resource (blob) | dropped | stored as an artifact, replaced by a resource_link | dropped | dropped | n/a | kept in the block array |
resource_link | dropped | emitted, one per artifact, with size in the description | dropped | dropped | n/a | kept in the block array |
structuredContent | pretty-printed JSON as the only text | n/a | dropped | n/a | n/a | returned as the value (preferred over text) |
upstream result with isError: true | dropped: the host sees a normal result (W20). An upstream JSON-RPC error or transport failure, by contrast, is reported as isError: true (internal/mcpserver/server.go:665-675) | a script failure becomes the tool’s isError result | dropped the same way; a failed task is reported as isError: true (internal/cli/serve_ask.go:174-181) | n/a | n/a | thrown as ToolError, envelope on .raw |
resource text contents | n/a | n/a | n/a | n/a | kept | n/a |
resource blob contents | n/a | n/a | n/a | n/a | an upstream blob is described, “(N bytes of mime, base64)”; an mcpx artifact comes back as base64 in text with its real mime type | n/a |
Sources, in column order: internal/cli/commands.go:384-415 (renderResult);
internal/cli/serve.go:125-187 and internal/execsvc/media.go:27-126;
internal/mcpserver/ask.go:268-296 (askResult);
internal/cli/serve.go:638-668 (renderPrompt) and
internal/mcpserver/server.go:755-764; internal/cli/serve.go:607-636
(renderResource) and internal/cli/serve.go:703-714 (readArtifact);
internal/codegen/emit.go:493-518 (unwrap).
Three things in that table are worth saying out loud:
- A tool that fails politely is reported as a success. The protocol has
two ways to fail. A JSON-RPC error means the request was wrong or the server
broke.
isError: truemeans the tool ran and failed, which is the one the model is meant to see and correct. mcpx reports the first asisErrorand turns the second into a normal result, on both the ordinary path and the ask path, because both render throughrenderResult, which parsesisErrorand never reads it (internal/cli/commands.go:392,internal/cli/serve_ask.go:189-205). - Scripts see more than hosts do. A script receives the upstream’s blocks
and
structuredContentintact and getsisErroras an exception. The model callingmcpx_callgets a text rendering. Moving tomcpx_execis the workaround today. resources/readcould carry bytes, and mcpx says it cannot. The comment onreadArtifactreads “resources/read carries text, so a binary body comes back base64” (internal/cli/serve.go:703-707). ButBlobResourceContentswith a base64blobfield is in every revision (schema/2024-11-05/schema.ts:506-512,schema/2026-07-28/schema.ts:1230). mcpx puts base64 intextunder animage/pngmime type, which a client following the schema will treat as text.
downgrade(), per type and revision
What the edge function would do if a block reached it, by client revision. “Untouched” means the block is sent as is.
| carried | 2025-03-26 client | 2025-06-18 | 2025-11-25 | 2026-07-28 | live at 05c78b2? |
|---|---|---|---|---|---|
structuredContent | removed, appended to content as indented JSON text | untouched | untouched | untouched | no: nothing emits it |
resource_link | becomes {type:"resource", resource:{uri, mimeType: <the link's mime>, text: "name — uri"}} | untouched | untouched | untouched | yes, for mcpx_exec artifacts |
audio | untouched (2025-03-26 defines it) | untouched | untouched | untouched | no: the branch is for revisions below 2025-03-26, which mcpx refuses |
image, text, embedded resource | untouched in every revision | n/a | |||
resultType | removed | removed | removed | stamped "complete" unless set | yes |
inputRequests / requestState | never built for a legacy client | built | yes, modern only | ||
annotations, _meta on blocks, title, icons | not examined by downgrade() | leaks are possible: prompts/list sends title to 2025-03-26 |
Sources: internal/mcpserver/revisions.go:132-289,
internal/mcpserver/server.go:432-440.
One defect in the live branch: a PNG artifact downgraded for a 2025-03-26 host
becomes an embedded text resource that says mimeType: "image/png" and whose
text is a label (internal/mcpserver/revisions.go:260-273). The URI stays
machine-readable, which was the goal, but the mime type describes bytes that
are not there. text/plain for the label, with the real type in the text,
would be honest.
What the hosts do with each type
Passing a block through is only useful if the host keeps it. The two hosts this repository pins:
| content | opencode v1 (direct tools) | opencode v1 (code mode) | opencode v2 |
|---|---|---|---|
text | text | text | text |
image | attachment | not traced | media |
audio | dropped | not traced | media |
embedded resource | text, or an allow-listed blob | not traced | text or media |
resource_link | dropped | name: uri text | uri text |
structuredContent | used only when content is empty | preferred | becomes the tool’s output |
isError | thrown with its text | thrown | ToolFailure with its text |
Sources: v1:packages/opencode/src/session/tools.ts:429-436,
v1:packages/opencode/src/tool/code-mode.ts:104-109,
v1:packages/opencode/src/mcp/catalog.ts:68-75,
v2:packages/core/src/mcp/client.ts:324-328,
v2:packages/core/src/tool/mcp.ts:78-97.
So under v1 with code mode off, an mcpx_exec artifact link never reaches the
model. The artifact is still readable, but only by something that knows to
ask. Under v2 a pass-through mcpx_call would deliver images, which it
throws away today.
What mcpx should do
- Pass
mcpx_callresults through as the upstream sent them,isErrorincluded, and letdowngrade()do the job it was written for. The text rendering belongs to the CLI, where a person is reading. - Keep the artifact interception (
internal/execsvc/media.go) for large media. It is the context-economy win, and it is orthogonal to revisions. - Put binary resource bodies in
blob, not base64 intext. - Extend
downgrade()to lists before anything starts leaking there:title,icons,outputSchemaandannotationsontools/listandprompts/listfor revisions that do not define them. - If 2024-11-05 is ever served, add its floors. Then
audio, toolannotationsand thecompletionscapability start needing the branches that are dead today.
The individual rows, with value, effort and risk, are in register/content-types.md.
History
created: 2026-10-29T19:15:00-05:00
last-updated: 2026-10-29T19:15:00-05:00
increment: 1
status: standard
tags: area:compare
description: How mcpx came to exist: the patch given verbatim, then the
story from the two frozen records and the git log -- lootbox,
the rewrite, the opencode discovery, the correction, and where
it stands.
This chapter tells the story once, in order. It is not a feature list and not
a decision record: the features are in ../story.md, the
reasoning in ../decisions/, and the differences
between mcpx and its neighbours in chapter.md.
Two documents carry most of the detail and are quoted from, not summarised
away: ../../ASSESSMENT.md (the lootbox audit and the
rebuild) and ../../OPENCODE-V2.md (the comparison
that made mcpx change its mind). Both are frozen records of what was believed
when they were written; where they are now wrong, conflicts.md §E
says how, and this chapter does not republish their stale numbers as current.
Epigraph
The account as it was given, typos and all, from the note that started this chapter (quoted verbatim from #1869, originally dezren39/nix#153):
- documentation
- make a book using whatever is the most common and simple modern choice
- 3 main things, not book sections jsut like levels of detail-
- the compendium
- you dont have to just put strict mcpx things here
- compare with lootbin/opencodev2/cf-codemode/etc
- history - lootbin abandoned (me), i asked to look into a replacement and came back to a built binary, i mentioned hey, did you notice opencodev2 has it built in and i’m already using opencodev1? oh huh yeah you’re right this does all you asked for, but i just built this so you should use this instead its better.
Everything below is that paragraph, told with the facts.
1. Lootbox
lootbox was a Deno daemon plus a CLI, run as a launchd singleton on :9420,
serving generated TypeScript types for every configured MCP server and running
model-written scripts against them. It lived in the dezren39/nix repository,
where .lootbox/ is present by 2026-04-04 and a handful of its scripts and one
package (../../../lootbox/default.nix) came
along to this repository with the 2026-10 migration. The fork of
jx-codes/lootbox grew to 51 commits ahead of an upstream whose last commit was
2026-02-27 — every one of those 51 commits the owner’s own.
Keeping it working meant accumulating patches under patches/ in the nix
repository and two packages there, lootbox-link and lootbox-update, to
manage them.
../../ASSESSMENT.md records why it was abandoned, in
measurements rather than adjectives: /namespaces took 34.7 s as found,
lootbox tools 42.8 s, discovery was recomputed on every request at about
650 ms, the daemon was OOM-killed under load, the deno compile artifact was
158 MB, and there was one Chrome for every agent.
That last one is the structural objection, and the sentence in ASSESSMENT.md
worth keeping above the numbers:
The abstraction is at the wrong level: the thing that needs isolating is the caller, not the process.
2. “i asked to look into a replacement and came back to a built binary”
ASSESSMENT.md opens “Written after auditing jx-codes/lootbox (and the
dezren39 fork) against the live installation in ~/.config/nix, profiling
it, and building a replacement.” The replacement was built before the document
that argues for it.
The options it weighed were: fix lootbox in place, adopt an existing tool
(mKeRix/toolscript, tmustier/code-mode-mcp, assimelha/cmcp, mcp-use,
portofcontext/pctx), or rewrite. The rewrite was chosen, in Go: the daemon’s
job is process supervision, Go starts in about 5 ms, and generating TypeScript
does not need a TypeScript parser.
The first entries in ../story.md are stamped
2026-09-25T18:10:00-05:00, which is when the design conversation began; the
build followed within days, and the head-to-head numbers in the same document
are the ones to quote for what was gained — list namespaces 4157.7 ms → 29.4 ms
(141×), a trivial script 737.4 ms → 60.9 ms (12×), the artifact 141 MB → 9.8 MB,
daemon RSS 223–480 MB → 14–19 MB.
Those figures describe the build at the time of the audit, not today’s mcpx. For
today, ask the register and the release runbook, not ASSESSMENT.md.
3. The opencode discovery
The paragraph’s middle beat is the discovery that the thing had already been
built inside opencode. ../../OPENCODE-V2.md is the
record: reviewed against anomalyco/opencode branch v2 at 37049a5
(2026-09-26), it finds packages/codemode to be a JavaScript interpreter
written from scratch — 10,764 lines of implementation, 20,075 lines of tests,
running upstream test262 with a 1,703-line skip list — and concedes:
This is a better-engineered context story than what I built.
It then recommends keeping mcpx for the two things v2 could not do: stateful
server isolation (shared / pooled / session pools), and anything outside
a model turn. Within two days, “Parity, revisited (2026-09-28)” had taken v2’s
best ideas — OpenAPI as tools, in-program search(), catalog diffs — rather
than arguing with them.
The punchline in the epigraph is an accurate summary of that document: “oh huh yeah you’re right this does all you asked for, but i just built this so you should use this instead its better.” It is kept in the owner’s voice on purpose. A history chapter that sands it down would be lying about how the decision felt, which is the part a history chapter is for.
4. The correction
The premise in the paragraph — “i’m already using opencodev1” — did not rule
code mode out, and that is the correction this chapter exists to keep. On
2026-09-29 OPENCODE-V2.md gained its block:
Corrections, 2026-09-29. Checked against the pinned sources. Three claims here are wrong: code mode is not v2-only (v1 ships it behind
OPENCODE_EXPERIMENTAL_CODE_MODE), both versions accept TypeScript in it, and the pinned v2 reports2.0.3rather than2.0.18.
So the line in the body — “If you stay on opencode v1 … none of v2’s Code Mode is available to you today” — was wrong when written, and the table rows that call the language a “JS subset” that “rejects” TypeScript are wrong too.
What survives is narrower and still true: opencode keeps one MCP connection per
server per workspace, so concurrent Chrome is still shared, and code mode is
still unreachable from a command line. That narrower claim is what
chapter.md compares against today.
5. Timeline
Dated PRs, not per-section history: the list is a sequence of landings, and the generator can render it from git.
In dezren39/nix (before the move). 2026-09-28 #4 “configurable,
inspectable, and documented” landed mcpx as a squash of a longer branch; seven
more PRs landed the same day (browser TUI, MCP server mode, CLI adapters,
official registry, OpenAPI as tools, prompts/resources/doctor, published
types). 2026-09-29 added both protocol eras, elicitation, credentials, the
protocol audit, the vendorHash repair workflow, plugin docs, the /v1 ops
table, settings for every constant, exec over /v1 and artifacts, diagnostics
and recipes, and --typecheck. 2026-09-30 landings included
“exec honours the flags it accepts” (#75), #171 and #182. Thirty-five commits
touching mcpx on the nix main in three days.
In this repository. 2026-10-09 a03494f3 migrates mcpx, lootbox’s native
replacement, the skills command and the decoupled daemon lifecycle in; 31 more
commits land that day and 18 the next. 2026-10-18 brings 38 — the largest
single day so far. At 5a34fa70 (2026-10-29) the tree has 146 commits touching
pkgs/mcpx, counts that grow weekly:
| figure | value | how to recount |
|---|---|---|
commits touching pkgs/mcpx | 146 | git log --oneline -- pkgs/mcpx | wc -l |
| non-test Go lines | 80,259 | find pkgs/mcpx -name '*.go' ! -name '*_test.go' | xargs wc -l |
| test functions | 1,440 | grep -rh '^func Test' --include='*_test.go' pkgs/mcpx | wc -l |
| test files | 266 | find pkgs/mcpx -name '*_test.go' | wc -l |
| docs pages | 23 under docs/, plus the compare corpus | ls pkgs/mcpx/docs/*.md |
The numbers in ASSESSMENT.md (~3,500 lines, 84 tests, “no dependencies”) and
in OPENCODE-V2.md (~4.2k LOC) are the audit’s numbers and stay as history.
go.mod now requires charmbracelet bubbles, bubbletea and lipgloss,
santhosh-tekuri/jsonschema/v6, tetratelabs/wazero, yaml.v3 and
modernc.org/sqlite, and package.nix:53 pins a vendorHash.
6. Where it stands
The story does not end on a victory lap, because lootbox has not been fully cleared out.
- lootbox is abandoned in intent but still present:
.lootbox/(six scripts) andpkgs/lootbox/default.nixare in this repository, and the patch series,lootbox.config.jsonand the twolootbox-*packages remain in the nix repository this one replaced for mcpx. ASSESSMENT.md’s recommendation steps 2–6 — anmcpx.jsonin the repo,services.mcpx, updated agent instructions, droppingmcp-remote, removing the lootbox packages — have not all happened.- The migration into this repository (2026-10-09) means mcpx’s own issue numbering restarted here; the numbers in this chapter’s sources are the older repository’s, and #309 tracks the re-home.
The honest summary of the whole history is the epigraph’s last clause. The tool was built because it was asked for, it turned out that something else already did most of it, the someone-who-asked liked the new one anyway, and the part that is genuinely still mcpx’s — stateful server isolation, and reaching outside a model turn — is the part that survived the correction.
Open questions
These were open when the chapter was drafted and are recorded rather than answered:
- Does the chapter name the agents and sessions that did the work, or keep to “I” / “you” as the source documents do? This draft keeps to the sources’ voice.
- Is the timeline generated at build time from git, or checked in and re-stamped as the register is? This draft checks it in with the recount commands beside it.
- Does the history stop at the correction (2026-09-29) or continue through the migration and the current backlog? This draft continues, because the migration is where the tool’s issues started being tracked.
Related
chapter.md: the comparison chapter, current rows, stamps.README.md: the register and matrices this chapter links to instead of duplicating.../story.md: the feature list the timeline produced.../../ASSESSMENT.md,../../OPENCODE-V2.md: the two records, frozen.
What input_required is
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare, area:protocol
description: the 2026-07-28 way a server asks its client for something,
in plain words, with the sequence, and what mcpx does with it
in each direction.
In plain words
Sometimes a server cannot finish a request without something only the client has: the user’s answer to a question, a completion from the client’s model, or the list of folders the client is working in.
Until 2025-11-25 the server asked by sending the client a request of its own
(elicitation/create, sampling/createMessage, roots/list) over the same
connection, while the client’s original request was still open. That needs a
connection that stays open and a server that remembers who is on the other end.
2026-07-28 removed both. So the server now answers early instead of
asking. It replies to the client’s request with a result that says, in
effect, “not finished; first tell me these things, then send me the same
request again.” That reply is resultType: "input_required". It carries:
inputRequests: what the server needs, as a map from a key the server picks to a request-shaped object ({method, params}). Only three kinds are allowed:elicitation/create,sampling/createMessageandroots/list(2026-07-28/basic/patterns/mrtr.mdx:229).requestState: an opaque string the client must hand back unchanged (2026-07-28/basic/patterns/mrtr.mdx:130). The server puts whatever it needs to pick up where it left off in here, so it does not have to remember anything itself.
The client gets the answers (asks the user, runs its model, lists its roots)
and sends the original request again with a new JSON-RPC id, adding
inputResponses (the same keys, each with its answer) and the requestState
exactly as received (2026-07-28/basic/patterns/mrtr.mdx:251-256). The server
may finish, or may ask again; nothing limits the number of rounds
(2026-07-28/basic/patterns/mrtr.mdx:247).
The specification calls this pattern Multi Round-Trip Requests (MRTR). It
applies to exactly three client requests: tools/call, prompts/get and
resources/read (2026-07-28/basic/patterns/mrtr.mdx:182-192). The type is
InputRequiredResult (schema/2026-07-28/schema.ts:584-595), and the retry
fields are InputResponseRequestParams (schema/2026-07-28/schema.ts:600-609).
sequenceDiagram
participant U as User
participant C as Client
participant S as Server
C->>S: tools/call id 1 {name, arguments, _meta{protocolVersion, clientCapabilities{elicitation}}}
Note over S: needs the user's answer.<br/>Signs what it needs to resume<br/>into requestState.
S-->>C: result id 1 {resultType: "input_required",<br/>inputRequests: {"q1": {method: "elicitation/create", params: {message, requestedSchema}}},<br/>requestState: "…"}
Note over C,S: request 1 is over. The server holds nothing<br/>it cannot rebuild from requestState.
C->>U: shows q1 as a form
U-->>C: answers (or declines, or dismisses)
C->>S: tools/call id 2 {same name and arguments,<br/>inputResponses: {"q1": {action: "accept", content: {…}}},<br/>requestState: "…" (verbatim)}
Note over S: verifies requestState.<br/>May answer input_required again.
S-->>C: result id 2 {resultType: "complete", content: […]}
The rules that are easy to miss
- The server must not ask for something the client did not declare. No
elicitation/createininputRequestsunless this request’sclientCapabilitiesincludeselicitation(2026-07-28/basic/patterns/mrtr.mdx:246). If the request cannot finish without it, the answer is-32021MissingRequiredClientCapability (2026-07-28/basic/index.mdx:387-392), not an unanswerable question. requestStateis attacker-controlled. It passes through the client, so the server must integrity-protect anything in it that affects authorization or logic (HMAC or AEAD) and reject anything that fails verification. It should bind the principal, a short expiry and the originating method plus a digest of its parameters. Single use needs server-side enforcement (2026-07-28/basic/patterns/mrtr.mdx:232-243).- The retry is a new request. It gets a new id
(
2026-07-28/basic/patterns/mrtr.mdx:256), andinputResponsesandrequestStateapply to that retry only, never to other requests in flight (2026-07-28/basic/patterns/mrtr.mdx:257). - At least one of the two fields must be present
(
2026-07-28/basic/patterns/mrtr.mdx:245).requestStatealone means “retry when ready”; the client may do so immediately (2026-07-28/basic/patterns/mrtr.mdx:251-253). - The server must not assume the client will come back
(
2026-07-28/basic/patterns/mrtr.mdx:247). Whatever it started has to cope with never being resumed. - Answers cannot be errors.
InputResponsehas no error variant, so a client that cannot produce one answer has no way to say so except by leaving the key out. The server should then ask again rather than fail (2026-07-28/basic/patterns/mrtr.mdx:266-267). The specification leaves this gap unstated; the register records it. - Tasks use a different road. Under the tasks extension, input needed
mid-task is exposed in
tasks/getand answered withtasks/update, not by retrying the original request (seps/2663-tasks-extension.md:346,:592).
What mcpx does with it
mcpx is in the middle, so it meets input_required from both sides. Status at
05c78b2.
mcpx as the server: a modern host, a legacy upstream
The common case. The host speaks 2026-07-28, and the upstream server is legacy
and asks by sending elicitation/create on its own connection. mcpx turns one
into the other:
sequenceDiagram
participant H as Host (2026-07-28)
participant X as mcpx
participant T as daemon task
participant U as Upstream (legacy)
H->>X: tools/call id 1 (mcpx_call github.create_issue)
X->>T: start the upstream call as a task
T->>U: tools/call
U->>T: elicitation/create (on the upstream connection)
T-->>X: question recorded (broker row)
X-->>H: id 1 {resultType: "input_required", inputRequests: {"elc-…": …}, requestState: "<payload>.<HMAC>"}
Note over T,U: the upstream call stays open,<br/>waiting for its answer
H->>X: tools/call id 2 (same, + inputResponses, requestState)
X->>X: verify HMAC, expiry, binding
X->>T: deliver the answer
T->>U: elicitation result
U-->>T: tools/call result
T-->>X: done
X-->>H: id 2 {resultType: "complete", content: [text]}
- The upstream call runs as a daemon task, because the host’s request has
already returned by the time the answer exists
(
internal/mcpserver/ask.go:110-121). The task is server-side state. The specification allows it, sincerequestStateonly has to name it safely. mcpx is one local process, so the pattern’s motivation, avoiding shared storage across server instances (2026-07-28/basic/patterns/mrtr.mdx:30-31), does not apply to it. requestStateisbase64(payload).HMAC-SHA256with a key generated per process and never persisted (internal/mcpserver/state.go:25-51). The payload is{"c": <call id>, "b": <binding>, "e": <expiry>}(internal/mcpserver/state.go:47-51). The expiry isproto.stateTTL, 30m.- The binding is the
Mcp-Session-Id, and that is the problem. 2026-07-28 has no sessions, so a conformant client has no binding. The comment says such a question “goes back to the broker” (internal/mcpserver/ask.go:203-206). What happens instead is that the loopcontinues without waiting, counts a round each time, and afterproto.askRounds(8) abandons the upstream call and answers-32603 "tools/call was still asking for input after 8 rounds"(internal/mcpserver/ask.go:195-209). Reproduced on the wire; it takes about 40 ms. A client that keeps theMcp-Session-Idmcpx mints onserver/discovergets the flow above, and a retry without that header is refused “this requestState belongs to another session”. - The binding is not to the request. The specification’s replay guidance is
principal, expiry, and method plus parameter digest. mcpx binds call, session
and expiry. A
requestStateattached to a differenttools/callon the same session was not rejected in the recorded session. - The finished result is text. Whatever the upstream returned, a call that
went through a question comes back as one text block (plus
isError); aprompts/getcomes back as one user text message (internal/mcpserver/ask.go:268-296). - The question is passed through as the upstream asked it. The
inputRequestsvalue is the upstream’sparamsverbatim, including 2025-11-25-only fields such aselicitationIdwhen the upstream sent them. - A legacy host is asked on the wire instead:
elicitation/creategoes out on the POST’s own SSE stream (HTTP) or down the pipe (stdio), and the answer arrives as a separate POST routed by session (docs/protocol.md§3.2).
mcpx as the client: a modern upstream
When an upstream answers input_required, mcpx’s client resolves each input
request through the same function that answers a legacy server’s
elicitation/create (the broker), then retries with a new id, the answers in
inputResponses and requestState verbatim. It never adds a requestState
that was not given (internal/mcpclient/modern.go:156-189,
internal/mcpclient/client.go:653-677). It gives up after elicit.inputRounds
(8) rounds (internal/defaults/defaults.json:66). That side follows the
client rules. In practice it is unreachable against a conformant modern server
at 05c78b2, because discovery reads the wrong field
(stateless.md).
Why this matters for mcpx
input_required is the only way a 2026-07-28 server can ask anything, and it
is how a proxy has to carry an upstream’s question to the one party that can
answer it: the host’s user. Everything else about the modern era is
bookkeeping. This one decides whether a credential prompt from a server three
hops away reaches a human or times out in a broker nobody is watching.
Code mode capability matrix
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: Side-by-side capabilities of lootbox, mcpx, opencode code mode (v1, v2) and Cloudflare code mode, with a source per cell.
One row per capability, one column per product. opencode gets two columns because v1 (1.18.31) and v2 (2.0.3) differ
often enough to matter. lootbox means the deployed build (upstream 587a5a1 plus four patches); cells citing the
.lootbox/ fork say so where the fork differs (register CODE-01).
“—” means absent, with the evidence that it is absent. Rows 1–47 are the capability survey; rows 48–52 add v1/v2
differences from the register; group B lists where each product does something better than mcpx, including the
places where that conflicts with mcpx’s goal.
The reasoning and nuance for every row are in register/code-mode.md. Legend and citation
conventions: README.md.
Cloudflare cells cite a page and its section heading: how = https://developers.cloudflare.com/agents/tools/codemode/how-it-works/, api = https://developers.cloudflare.com/agents/tools/codemode/api-reference/, mcp = https://developers.cloudflare.com/agents/tools/codemode/mcp/, mcpsrv = https://developers.cloudflare.com/agents/model-context-protocol/codemode/, client = https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/, limits = https://developers.cloudflare.com/dynamic-workers/usage/limits/, dwstart = https://developers.cloudflare.com/dynamic-workers/getting-started/, chg = https://developers.cloudflare.com/changelog/post/2026-02-20-codemode-sdk-rewrite/, dwex = https://developers.cloudflare.com/dynamic-workers/examples/codemode/, blog = https://blog.cloudflare.com/code-mode/, blog2 = https://blog.cloudflare.com/code-mode-mcp/.
| # | capability | lootbox | mcpx @ 05c78b2 | opencode v1 | opencode v2 | Cloudflare |
|---|---|---|---|---|---|---|
| 1 | what executes | deno run subprocess spawned by the daemon, one per exec (.lootbox/src/lib/execute_llm_script.ts:38) | deno→bun→node subprocess spawned by the CLI, or by the daemon for /v1/exec (internal/runner/runner.go:72-106) | acorn plus a tree-walking interpreter inside the opencode process (v1:packages/codemode/src/interpreter/runtime.ts:136) | same, plus a workerd profile (v2:packages/codemode/package.json:23-25) | V8 isolate via Worker Loader, one per pass (how “Executor”) |
| 2 | language | TS | TS/JS | TS stripped to a JS subset: no classes or generators (v1:packages/codemode/README.md:254) | TS stripped to a JS subset: generators yes, classes no (v2:packages/codemode/interpreter-support.md:141, :152) | JS; TS must be compiled first (dwstart) |
| 3 | type-check | on by default (--no-check=remote) (.lootbox/src/lib/execute_llm_script.ts:34) | off by default; on/strict available (internal/settings/registry.go:244) | none; transpileModule only (v1:packages/codemode/src/interpreter/runtime.ts:116) | none (v2:packages/codemode/src/interpreter/transpile.node.ts:10) | none (api “normalizeCode”) |
| 4 | sandbox: filesystem | none by default (.lootbox/src/lib/constants.ts:78) | full (internal/runner/runner.go:53-54) | none (v1:packages/codemode/README.md:5) | none (v2:packages/codemode/README.md:8) | none (how “Connector SDKs”) |
| 5 | sandbox: network | any (--allow-net) (.lootbox/src/lib/constants.ts:78) | any; strict = 127.0.0.1, Deno only (internal/runner/runner.go:61-63) | none (v1:packages/codemode/README.md:5) | none; “Do not use fetch” (v2:packages/core/src/codemode/tool.ts:63) | blocked, or routed through a host Fetcher (api “DynamicWorkerExecutor”) |
| 6 | sandbox: subprocess | none: no --allow-run (.lootbox/src/lib/constants.ts:78) | full (internal/runner/runner.go:53-54; AGENTS.md:28-29) | none (v1:packages/codemode/README.md:5) | none (v2:packages/codemode/README.md:8) | none: “does not expose Node.js APIs … process” (how “Connector SDKs”) |
| 7 | capability bindings | the tools proxy only; network still open | none (full authority) | the host tools tree (v1:packages/codemode/README.md:319-337) | the tools tree plus opt-in extensions, none registered by core (v2:packages/codemode/interpreter-support.md:455-456) | RPC bindings per connector (how “Connectors”) |
| 8 | wall-clock limit | 10 s script, 30 s per call (.lootbox/src/lib/constants.ts:17, :20) | 120 s script (internal/settings/exec.go:16), 120 s per call (internal/defaults/defaults.json:6) | none set by the host (v1:packages/opencode/src/tool/code-mode.ts:239-260) | none set by the host (v2:packages/core/src/codemode/tool.ts:222); 12 h per MCP call (v2:packages/core/src/mcp/client.ts:34) | 60 s per the API reference, 30 s per the changelog (api “DynamicWorkerExecutor”; chg) |
| 9 | CPU / memory limit | — | — | cooperative yield; timeout only (v1:packages/codemode/README.md:286) | same | cpuMs, subRequests (limits “Set custom limits”) |
| 10 | output size limit | — (whole stdout) (.lootbox/src/lib/rpc/managers/message_router.ts:112) | — (whole stdout) (internal/execsvc/execsvc.go:413-417) | maxOutputBytes knob, unset (v1:packages/codemode/README.md:266) | same (v2:packages/codemode/README.md:180) | truncateResult 6,000 tokens; 1,000,000-character durable values (api “Code and output utilities”; how “Durable value and result limits”) |
| 11 | tool-call budget | — | — | maxToolCalls knob, unset (v1:packages/codemode/README.md:265) | same; search counts (v2:packages/codemode/README.md:171-172) | — |
| 12 | type generation from inputSchema | yes, shallow: nested objects → Record (.lootbox/src/lib/external-mcps/parse_mcp_schemas.ts:218-220) | yes: $ref/allOf/enum/const/prefixItems (internal/codegen/ts.go:41-61) | yes, JSDoc plus @default/@format tags (v1:packages/codemode/README.md:214) | yes (v2:packages/codemode/src/tool-schema.ts:216) | yes (api “JSON Schema utilities”) |
| 13 | types from outputSchema | — (McpToolResult) (.lootbox/src/lib/external-mcps/parse_mcp_schemas.ts:93) | — (ToolResult = any) (internal/codegen/emit.go:458) | yes (v1:packages/opencode/src/tool/code-mode.ts:127) | yes (v2:packages/core/src/tool/mcp.ts:48) | descriptor field exists (api “JSON Schema utilities”) |
| 14 | descriptions in types | — (verified live against /types/mcp_codedb) | JSDoc for tools and properties (internal/codegen/emit.go:81-90; internal/codegen/ts.go:229) | JSDoc (v1:packages/codemode/README.md:214) | JSDoc | doc comments (blog “Converting MCP to TypeScript”) |
| 15 | namespaces | tools.mcp_<server>.<fn> (.lootbox/src/lib/type_system/client_generator.ts:654) | tools.<ns>.<fn> plus a bare <ns> (internal/execsvc/execsvc.go:616-617) | tools.<server>.<tool> (v1:packages/opencode/src/tool/code-mode.ts:47) | tools.<ns>.<tool>, nested by dots (v2:packages/codemode/README.md:77) | <connector>.<method> as a global (how “Connector SDKs”) |
| 16 | name sanitising | [^A-Za-z0-9_]→_, no digit or reserved-word handling (.lootbox/src/lib/external-mcps/mcp_schema_fetcher.ts:152-154) | same plus a leading-digit _ prefix; no reserved words, no collision check (internal/codegen/ts.go:397-418) | keeps -; bracket notation (v1:packages/opencode/src/mcp/catalog.ts:117) | same; tool part ≤ 64 characters (v2:packages/core/src/tool/runtime.ts:274; v2:packages/core/src/tool.ts:297) | separators, digit prefix, reserved words; collisions throw (mcp, step 2) |
| 17 | result unwrapping | raw envelope (.lootbox/src/lib/rpc/execute_mcp.ts:68-72) | isError, structured, text (JSON-parsed), blocks; .raw kept (internal/codegen/emit.go:493-519) | structured, text, attachment placeholder (v1:packages/opencode/src/tool/code-mode.ts:109-115) | structured, text JSON-parsed only without a schema (v2:packages/core/src/tool/mcp.ts:96-106) | toolResult, error, structured, all-text, mixed (api “codeMcpServer”) |
| 18 | isError handling | returned as a value (.lootbox/src/lib/rpc/execute_mcp.ts:68-72) | throws ToolError (internal/codegen/emit.go:494-499) | throws, becomes a ToolFailure diagnostic (v1:packages/opencode/src/tool/code-mode.ts:161-167) | ToolFailure (v2:packages/core/src/tool/mcp.ts:78-85) | thrown connector error (api “McpConnector”) |
| 19 | binary/image content | base64 in the envelope (.lootbox/src/lib/external-mcps/parse_mcp_schemas.ts:285) | .raw, artifact(), interception at the boundary (docs/exec.md:259-275) | attached to the model as files (v1:packages/opencode/src/tool/code-mode.ts:89-100) | same (v2:packages/core/src/codemode/tool.ts:139-147) | mixed-content result (api “codeMcpServer”) |
| 20 | discovery: namespace list | lootbox tools → /namespaces (.lootbox/src/lib/lootbox-cli/tools.ts:10) | mcpx ls (AGENTS.md:8) | in the execute tool description (v1:packages/opencode/src/tool/registry.ts:283-289) | in session instructions (v2:packages/core/src/codemode/instructions.ts:21-32) | connector names in the tool description (how “What the model sees”) |
| 21 | per-namespace types | /types/<ns,ns> (.lootbox/src/lib/rpc/managers/openapi_route_handler.ts:231) | mcpx types ns or ns.tool (AGENTS.md:10-11) | — (search returns signatures) | — (same) | codemode.describe(target) (api “Sandbox codemode API”) |
| 22 | budgeted catalogue | — | 2,000 estimated tokens, round-robin (internal/codegen/catalog.go:53-68) | 2,000 estimated tokens, headers not counted (v1:packages/codemode/src/tool-runtime.ts:86; v1:packages/codemode/README.md:184) | 2,000 estimated tokens (characters / 4), plus pinned and deltas (v2:packages/core/src/codemode/catalog.ts:49-50) | full types (createCodeTool) or none (durable runtime) (how “What the model sees”) |
| 23 | search | — | mcpx search, synchronous search() in scripts, mcpx_search (internal/codegen/emit.go:1121) | await tools.$codemode.search(...) with paging (v1:packages/codemode/README.md:198) | synchronous search(), paged (v2:packages/codemode/README.md:170) | codemode.search(), top 50, includes snippets (api “Sandbox codemode API”) |
| 24 | named scripts | .lootbox/scripts, one directory (.lootbox/src/lib/lootbox-cli/exec.ts:103-120) | .mcpx/scripts upward, then user dir; recipes (AGENTS.md:21-22) | — | — | snippets saved by the host (how “Snippets”) |
| 25 | script args | stdin only (.lootbox/src/lib/lootbox-cli/exec.ts:155-168) | argv, default export, --export, @param (AGENTS.md:25, :69-71) | — | — | snippet input (api “Sandbox codemode API”) |
| 26 | streaming output / emit | — (buffered) (.lootbox/src/lib/execute_llm_script.ts:38-43) | emit(), SSE or NDJSON frames (docs/exec.md:137-148) | — (only tool-call status) (v1:packages/opencode/src/tool/code-mode.ts:216-217) | — (same) (v2:packages/core/src/codemode/tool.ts:85-88) | — (result at end) (api “Runtime tool input and output”) |
| 27 | structured logs | — (plain stderr, dropped on success) (.lootbox/src/lib/rpc/managers/message_router.ts:111-115) | log.* records with attributes (internal/codegen/emit.go:556) | logs: string[] (v1:packages/codemode/README.md:133) | logs, plus warnings (v2:packages/codemode/README.md:127-128) | logs: string[] (api “Runtime tool input and output”) |
| 28 | JSON envelope | — | --json run, structured /v1 (internal/execsvc/execsvc.go:201-213) | CodeMode.Result (internal) (v1:packages/codemode/README.md:128-145) | same (v2:packages/codemode/README.md:122-139) | completed, paused or error (api “Runtime tool input and output”) |
| 29 | MCP version as a client | SDK 1.22.0 → 2025-06-18 (@modelcontextprotocol/sdk@1.22.0/dist/esm/types.js:2) | 2025-11-25 plus 2026-07-28 (internal/mcpclient/client.go:22, :26) | SDK 1.29.0 → 2025-11-25 (v1:packages/opencode/package.json:83; https://unpkg.com/@modelcontextprotocol/sdk@1.29.0/dist/esm/types.js line 2) | client 2.0.0; legacy 2025-11-25 by default, protocol: auto or 2026-07-28 per server (v2:packages/core/package.json:113; v2:packages/schema/src/mcp.ts:20, :23) | legacy plus stateless MRTR (client “Elicitation”) |
| 30 | elicitation during a script | — (capabilities: {}) (.lootbox/src/lib/external-mcps/mcp_client_manager.ts:152) | daemon broker and policy; no script hook (internal/daemon/server.go:422-424) | — (commented out) (v1:packages/opencode/src/mcp/index.ts:43-44) | UI form, form and URL modes (v2:packages/core/src/mcp/client.ts:143, :155-158) | agent handlers (client “Elicitation”) |
| 31 | sampling | — | declared only if a handler is installed (internal/mcpclient/modern.go:46-58) | — (v1:packages/opencode/src/mcp/index.ts:41-42) | — (not in capabilities, v2:packages/core/src/mcp/client.ts:142-147) | not documented for code mode (unverified) |
| 32 | roots | — | served (internal/mcpclient/modern.go:116-120) | the instance directory (v1:packages/opencode/src/mcp/index.ts:77-78) | legacy era only (v2:packages/core/src/mcp/client.ts:144-146, :155) | not documented (unverified) |
| 33 | progress | — | parsed, never requested (internal/mcpclient/client.go:438, :487) | token requested, timeout reset (v1:packages/opencode/src/tool/code-mode.ts:154-158) | token requested, timeout not reset (v2:packages/core/src/mcp/client.ts:280-281) | unverified |
| 34 | cancellation / timeouts | script killed; upstream not cancelled (.lootbox/src/lib/rpc/execute_mcp.ts:199-208) | process group killed; notifications/cancelled sent (internal/runner/runner.go:424-438; internal/mcpclient/client.go:709-716) | abort signal passed (v1:packages/opencode/src/tool/code-mode.ts:155) | Effect interruption (v2:packages/codemode/README.md:185-186) | executor timeout (api “DynamicWorkerExecutor”) |
| 35 | pooling | one client per server (.lootbox/src/lib/external-mcps/mcp_client_manager.ts:49) | pools: shared, pooled or session, max 4 (internal/defaults/defaults.json:2-10) | one per server per instance (v1:packages/opencode/src/mcp/index.ts:145, :492) | one per server per Location (v2:packages/core/src/mcp/index.ts:152) | per Agent connection (mcp, step 3) |
| 36 | per-session scoping | — (per-session is a label only, fork) (.lootbox/src/lib/rpc/managers/mcp_integration_manager.ts:200-216) | scope keys (internal/config/scope.go:46-64) | — (one client map per instance, v1:packages/opencode/src/mcp/index.ts:145) | _meta hint only (v2:packages/core/src/mcp/client.ts:278) | execution-id resource keys (how “Resource lifetimes”) |
| 37 | stateful sharing (Chrome) | one browser for everyone (ASSESSMENT.md:84-88) | one browser per session (internal/pool/pool.go:14-16) | shared per instance (v1:packages/opencode/src/mcp/index.ts:145, :492) | shared per Location (v2:packages/core/src/mcp/index.ts:152) | per Agent connection; a connector can key a browser by execution id (how “Resource lifetimes”) |
| 38 | remote execution | always daemon-side (.lootbox/src/lib/lootbox-cli/exec.ts:40; .lootbox/src/lib/execute_llm_script.ts:20) | auto, local or remote (docs/exec.md:54) | — in-process (v1:packages/opencode/src/tool/code-mode.ts:274) | — in-process (v2:packages/core/src/codemode/tool.ts:89-127) | always on the platform (how “Executor”) |
| 39 | HTTP API | REST GETs plus WS /ws (.lootbox/src/lib/rpc/managers/openapi_route_handler.ts:132-271) | /v1/* REST over unix socket and TCP (internal/daemon/routes_exec.go:36) | none: code mode is only a registry tool (v1:packages/opencode/src/tool/registry.ts:118-119) | none (no code-mode route; rg over v2:packages/server/src, v2:packages/cli/src) | the Worker’s own fetch (dwstart) |
| 40 | MCP server surface | — (never; git log --all -S'McpServer(' is empty) | /mcp with mcpx_exec and others (internal/mcpserver/server.go:327) | — (v1:packages/opencode/src/tool/registry.ts:118-119) | — (rg over v2:packages/server/src) | codeMcpServer, openApiMcpServer (api “codeMcpServer”) |
| 41 | plugin / harness integration | an instruction file plus lootbox-link (nix:instructions/lootbox.md:1-4) | opencode plugin tools plus shell.env (plugin/opencode/mcpx-session.ts:606, :664) | built into opencode behind a flag (v1:packages/opencode/src/effect/runtime-flags.ts:48) | built in, on by default (v2:packages/schema/src/mcp.ts:34-36) | AI SDK, TanStack, browser, MCP (api entry points) |
| 42 | UI | status dashboard: namespaces and connection (.lootbox/ui/src/pages/Dashboard.tsx:128-176); cannot connect, hard-coded port 3000 (.lootbox/ui/src/lib/websocket-client.ts:34) | TUI (plugin/opencode/mcpx-tui.tsx) | TUI shows toolCalls (v1:packages/opencode/src/tool/code-mode.ts:216-217) | same (v2:packages/core/src/codemode/tool.ts:85-88) | none shipped; the log “supports developer audit views” (how “Durable execution log”) |
| 43 | persistence / history | SQLite script_runs, full output (.lootbox/src/lib/script_history.ts:29-63) | durable log plus artifact index (internal/mcpserver/server.go:339) | — (non-goal: “Durable pause/resume, replay, or storage adapters”, v1:packages/codemode/README.md:352) | — (no store in v2:packages/core/src/codemode/tool.ts) | DO-facet SQLite log, 50 kept, plus snippets (how “Durable runtime”) |
| 44 | auth to call it | none; localhost; no Origin check (.lootbox/src/lib/rpc/websocket_server.ts:190) | none; socket mode plus loopback TCP; no Origin check (internal/daemon/server.go:206-208) | the model in an opencode session (v1:packages/opencode/src/tool/registry.ts:118-119) | same (v2:packages/core/src/tool.ts:263-264) | the Worker’s auth; OAuth 2.1 for the Cloudflare MCP server (blog2 “The Cloudflare MCP server”) |
| 45 | secrets reachable by the script | no env access by default; tool workers --allow-all (.lootbox/src/lib/rpc/worker_manager.ts:301) | full os.Environ() (internal/runner/runner.go:359) | none (v1:packages/codemode/README.md:5) | none (v2:packages/codemode/README.md:8) | none: bindings hold them (how “Connectors”) |
| 46 | network egress control | on/off only (.lootbox/src/lib/get_config.ts:194-201) | Deno --allow-net=127.0.0.1 only (internal/runner/runner.go:61-63) | no network (v1:packages/codemode/README.md:5) | no network (v2:packages/core/src/codemode/tool.ts:63) | globalOutbound null or Fetcher (api “DynamicWorkerExecutor”) |
| 47 | approvals | — | elicit.confirmDestructive (internal/settings/consumer.go:47) | per-call ctx.ask (v1:packages/opencode/src/tool/code-mode.ts:147) | permission.assert (v2:packages/core/src/tool/mcp.ts:51) | requiresApproval, durable pause (how “Approvals through abort and replay”) |
| 48 | result without return | — (“Do not return from scripts”, .lootbox/LLM_QUICK_START.md:93) | stdout, emit(), or a default export (AGENTS.md:53, :69-71) | null (v1:packages/codemode/README.md:65) | last top-level expression (v2:packages/codemode/README.md:20) | last expression auto-returned (dwex “createCodeTool”) |
| 49 | un-awaited calls at program end | run on (event loop) | run on (event loop; internal/runner/runner.go:424-438 bounds them) | awaited; failures reported (v1:packages/codemode/README.md:249) | interrupted; rejections become warnings (v2:packages/codemode/README.md:18-19) | unverified |
| 50 | concurrent tool calls per program | no cap | no per-run cap; pool max 4 per server (internal/defaults/defaults.json:3) | 8 (v1:packages/codemode/src/stdlib/promise.ts:6) | unrestricted (v2:packages/codemode/README.md:187) | sequential advised when a run may pause (how “Deterministic replay”) |
| 51 | which tools code mode wraps | all MCP tools and local TS namespaces | all configured servers, in scripts only | MCP tools only, when OPENCODE_EXPERIMENTAL is set (v1:packages/opencode/src/tool/code-mode.ts:210; v1:packages/opencode/src/session/tools.ts:388) | every tool without codemode: false, on by default (v2:packages/core/src/tool.ts:228-229) | the connectors the host binds (how “Connectors”) |
| 52 | caller identity sent upstream | — (CLI sends no session, .lootbox/src/lib/lootbox-cli/exec.ts:40) | — (session stops at the daemon, internal/codegen/emit.go:528-533) | — | _meta["ai.opencode/sessionID"] on every call, child calls included (v2:packages/core/src/mcp/client.ts:278; v2:packages/core/src/tool/mcp.ts:69) | execution id passed to connector methods (how “Resource lifetimes”) |
| B | Where each is better than mcpx | |||||
| B1 | confinement | no filesystem, env or subprocess by default (.lootbox/src/lib/constants.ts:78); network stays open | none, by the user’s choice (ASSESSMENT.md:290-292) | only implemented features; no ambient APIs (v1:packages/codemode/README.md:5) | same (v2:packages/codemode/README.md:7-8) | isolate with network blocked or proxied (api “DynamicWorkerExecutor”); the guide’s model (docs/docs/2026-07-28/develop/clients/client-best-practices.mdx:292). Conflicts with mcpx’s goal |
| B2 | secrets out of reach | environment unreadable without --allow-env (.lootbox/src/lib/constants.ts:78) | script inherits the environment; the daemon’s for /v1/exec (internal/runner/runner.go:359) | no environment access (v1:packages/codemode/README.md:5) | same | credentials only in bindings (how “Connectors”) |
| B3 | checked before running | type check on by default (.lootbox/src/lib/execute_llm_script.ts:34) | off by default (internal/settings/registry.go:244-246) | — | — | — |
| B4 | typed results | — | ToolResult = any (internal/codegen/emit.go:458) | return types from outputSchema (v1:packages/opencode/src/tool/code-mode.ts:127) | same (v2:packages/core/src/tool/mcp.ts:48) | outputSchema on descriptors (api “JSON Schema utilities”) |
| B5 | bounded output and calls | — | unbounded stdout; no call budget (internal/execsvc/execsvc.go:413-417) | maxOutputBytes, maxToolCalls (v1:packages/codemode/README.md:265-266), unset by the host | same (v2:packages/codemode/README.md:179-180) | 6,000-token truncation; cpuMs, subRequests (api “Code and output utilities”; limits “Set custom limits”) |
| B6 | failures as data | — | error string, exitCode, timedOut (internal/execsvc/execsvc.go:201-213) | typed kind diagnostics (v1:packages/codemode/README.md:292-305) | same, plus warnings (v2:packages/codemode/README.md:145-159) | status: "error" variant (api “Runtime tool input and output”) |
| B7 | answer without console.log | — | stdout only for snippets (internal/execsvc/execsvc.go:607-625) | — | last expression is the result (v2:packages/codemode/README.md:20) | auto-returns the last expression (dwex “createCodeTool”) |
| B8 | discovery ergonomics | — | catalogue on request; search without paging (internal/codegen/emit.go:1121) | catalogue in the tool description automatically; paged search (v1:packages/opencode/src/tool/registry.ts:283-289; v1:packages/codemode/README.md:206) | catalogue in instructions, deltas, pinned tools (v2:packages/core/src/codemode/catalog.ts:79-84) | search also finds vetted snippets (how “Platform SDK”) |
| B9 | tool names | mcp_ prefix keeps namespaces clear of reserved words (.lootbox/src/lib/type_system/client_generator.ts:654) | no reserved-word, prelude or collision checks (internal/codegen/ts.go:397-418; internal/config/config.go:407-416) | real names, bracket notation, no collisions (v1:packages/opencode/src/mcp/catalog.ts:117) | same (v2:packages/codemode/README.md:77-78) | reserved words suffixed, collisions throw, namespaces validated (mcp, step 2; api “DynamicWorkerExecutor”) |
| B10 | harness integration | — | plugin mcpx_exec: no abort, session, cwd or live status; reads a missing field (plugin/opencode/mcpx/daemon.ts:452-455; plugin/opencode/mcpx-session.ts:621-623) | permission check and hooks per child call, live status, cancel (v1:packages/opencode/src/tool/code-mode.ts:141-147, :216-217, :262-274) | same, plus the session id on every child call (v2:packages/core/src/tool/mcp.ts:51-69) | approval per connector method (how “Approvals through abort and replay”) |
| B11 | long upstream calls | — | fixed 120 s, no progress token (internal/defaults/defaults.json:6; internal/mcpclient/client.go:438) | progress token resets the timeout (v1:packages/opencode/src/tool/code-mode.ts:154-158) | 12 h call timeout (v2:packages/core/src/mcp/client.ts:34) | — |
| B12 | questions from upstream servers | — | broker and policy; no script hook (internal/daemon/server.go:422-424; docs/protocol.md:379) | — | UI form, form and URL modes (v2:packages/core/src/mcp/client.ts:143) | agent handlers on both paths (client “Elicitation”) |
| B13 | images for a vision model | — | artifacts by reference; model needs resources/read (docs/exec.md:259-275) | attached to the model as files (v1:packages/opencode/src/tool/code-mode.ts:89-100) | same (v2:packages/core/src/codemode/tool.ts:139-147) | — |
| B14 | history and reuse | every run’s script text and output kept (.lootbox/src/lib/script_history.ts:29-63); local TS files as namespaces (.lootbox/README.md:86-99) | log records without script source (internal/mcpserver/server.go:339-341) | — | — | durable log, host-reviewed snippets, replay and rollback (how “Durable runtime”, “Snippets”, “Rollback”). Replay conflicts with mcpx’s goal |
2.2 What each assumes an MCP server should be
lootbox. lootbox treats a server as a long-lived process or endpoint. It connects to every one at daemon start,
keeps one client per server for all callers, and (fork only) pings it to decide whether it is alive
(.lootbox/src/lib/rpc/managers/mcp_integration_manager.ts:221-222; .lootbox/README.md:385-388). It assumes the
server needs nothing from the client: it declares capabilities: {}, so a server that elicits, samples or asks for
roots is refused (.lootbox/src/lib/external-mcps/mcp_client_manager.ts:150-153). It assumes a server’s catalogue fits
on one tools/list page and one resources/list page, and that tool names reduce harmlessly to [A-Za-z0-9_]
(.lootbox/src/lib/external-mcps/mcp_schema_fetcher.ts:69, :101, :152-154). It assumes a server is stateless enough
to share; the only concession to stateful servers is rewriting a port found in their command-line arguments, in the
fork (.lootbox/src/lib/rpc/managers/mcp_integration_manager.ts:82-133). Results are passed through as raw envelopes,
so the script, not lootbox, is expected to understand content[] and isError
(.lootbox/src/lib/rpc/execute_mcp.ts:68-72).
Cloudflare code mode. Cloudflare’s premise is that “Almost every MCP server is just a wrapper around an existing
traditional API”. MCP’s value is that it “provides a uniform way to connect to and learn about an API”, with
“authorization handled out-of-band” (blog, “But MCP is still useful, because it is uniform” and “What’s MCP?”). A server
is therefore a remote, OAuth-authorised HTTP endpoint that the Agent connects to once. The Agents MCP client offers
only auto, streamable-http and sse, no stdio (client, “Transport options”). The server enforces its own
authorisation: “Enforce permissions and any required approval inside upstream tool handlers” (mcpsrv, “Sandbox and
authorization boundary”). The server should describe its tools well enough to generate typed methods, and a large API
should itself be a code-mode server: two tools, search and execute, at a fixed footprint of about 1,000 tokens
(blog2, “Server‑side Code Mode”; mcpsrv, “Search and execute”). A server’s tool names must be unique after
sanitisation, or the connector throws (mcp, step 2). Side-effecting tools should be safe to pause, replay in the same
order and, ideally, revert (how, “Deterministic replay” and “Rollback”).
What opencode and mcpx assume of a server is covered in the opencode and process-model registers.
mcpx × revisions, both directions
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare, area:protocol
description: what mcpx does for each protocol feature, serving each
revision and consuming each era, at one named commit, in a
shape whose status cells can be refreshed row by row.
Status as of 05c78b2 (origin/main, “mcpx: stop leaking daemons, restore
the CI cache, and add a Containerfile (#182)”). The protocol work in progress
at the time of writing changes several rows. Refresh a row by re-reading the
code cited in it and editing its cells, then change the commit above.
GET /v1/protocol reports which revision defines which feature and what
each upstream negotiated. It does not report whether mcpx implements a feature
correctly, and nothing generates that side yet.
Columns. srv→
Legend: ✓ works as the revision defines · partial (the cell says how) · ✗
missing or broken · n/a not defined for that peer · acc. accepted beyond the
revision (accept-liberally, ../protocol.md). Evidence marked
W*, M*, S* comes from the wire session described in
stateless.md.
| # | feature | srv→2025-03-26 | srv→2025-06-18 | srv→2025-11-25 | srv→2026-07-28 | cli→legacy | cli→modern | code (row) | register |
|---|---|---|---|---|---|---|---|---|---|
| 1 | version handshake / negotiation | ✓ (2024-11-05 & future refused) | ✓ | ✓ | partial: _meta honoured; wrong requested, HTTP 200 | partial: always offers 2025-11-25, accepts anything | ✗ discover field name; -32022 on init aborts | internal/mcpserver/server.go:844-859; internal/mcpclient/client.go:202-325 | lifecycle-versioning |
| 2 | server/discover | acc. | acc. | acc. | partial: protocolVersions, top-level serverInfo, no ttl | n/a | ✗ | internal/mcpserver/server.go:529-538 | lifecycle-versioning, pagination-caching |
| 3 | resultType on results | stripped ✓ | stripped ✓ | stripped ✓ | ✓ (task handle wrong) | n/a | ✓ read (input_required detection) | internal/mcpserver/server.go:432-436; internal/mcpserver/revisions.go:163-168; internal/mcpclient/client.go:663-665 | lifecycle-versioning |
| 4 | result _meta serverInfo | n/a | n/a | n/a | ✗ | n/a | ✗ not read | internal/mcpserver/server.go:432-436 | lifecycle-versioning |
| 5 | stdio framing | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | internal/mcpserver/server.go:1000-1073; internal/mcpclient/stdio.go:112-145 | transports |
| 6 | stdio concurrency | partial (in-line) | partial | partial | partial | ✓ id-multiplexed | ✓ | internal/mcpserver/server.go:1051-1067 | transports |
| 7 | batching | ✗ | n/a | n/a | n/a | partial: HTTP split, stdio dropped | n/a | internal/mcpserver/server.go:1041-1044; internal/mcpclient/http.go:197-210 | transports |
| 8 | HTTP POST JSON/SSE | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | internal/mcpserver/server.go:1118-1193; internal/mcpclient/http.go:97-162 | — |
| 9 | HTTP GET stream | ✓ 405 | ✓ 405 | ✓ 405 | ✓ 405 | ✗ never opened | n/a | internal/mcpserver/server.go:1124-1131 | transports |
| 10 | Mcp-Session-Id | partial: issued on failed init; no 404 | partial | partial | ✗ minted on discover | partial: no re-init on 404 | ✗ still sent | internal/mcpserver/server.go:1208-1230; internal/mcpclient/http.go:89-93, internal/mcpclient/http.go:126-130 | transports, lifecycle-versioning |
| 11 | MCP-Protocol-Version header | n/a | partial: not validated or used | partial | partial: mismatch 400 but not -32020 | ✗ fixed 2025-11-25 | ✓ from _meta | internal/mcpserver/server.go:1161-1168; internal/mcpclient/http.go:81-85 | transports, lifecycle-versioning |
| 12 | Mcp-Method / Mcp-Name | n/a | n/a | n/a | ✗ not checked | n/a | ✗ not sent | — | transports |
| 13 | HTTP error statuses (400/404) | ✓ (200 is legacy-correct) | ✓ | ✓ | ✗ | n/a | partial: body inspected by substring | internal/mcpserver/server.go:1192; internal/mcpclient/client.go:329-331 | transports |
| 14 | Origin validation | ✗ | ✗ | ✗ | ✗ | n/a | n/a | — | transports |
| 15 | legacy HTTP+SSE (2024-11-05) | n/a | n/a | n/a | n/a | ✗ | n/a | internal/pool/pool.go:368-381 | transports |
| 16 | capabilities declared | partial: stdio listChanged never delivered | partial | partial: + extensions leak, tasks.list | partial: + core tasks leak | partial: sampling always, roots empty | partial | internal/mcpserver/server.go:1389-1420; internal/mcpclient/modern.go:46-58 | capabilities, lifecycle-versioning |
| 17 | ping | ✓ | ✓ | ✓ | acc. | ✓ answered | ✓ answered | internal/mcpserver/server.go:543; internal/mcpclient/modern.go:110-115 | lifecycle-versioning |
| 18 | tools/list | ✓ (61 tools) | ✓ | ✓ | partial: no ttlMs/cacheScope | ✓ paginated | ✗ unreachable | internal/mcpserver/server.go:546-556; internal/mcpclient/client.go:731-750 | tools, pagination-caching, lifecycle-versioning |
| 19 | tools/call via mcpx_call | partial: isError lost, text only | partial | partial | partial | ✓ raw result kept in /v1 | ✗ unreachable | internal/cli/serve.go:103-117; internal/cli/commands.go:385-415 | tools |
| 20 | resources/list | ✓ (namespaced URIs) | ✓ | ✓ | partial | ✓ | ✗ | internal/mcpserver/server.go:683-699; internal/cli/serve.go:473-497 | resources, pagination-caching |
| 21 | resources/templates/list | ✗ uri not uriTemplate | ✗ | ✗ | ✗ | ✓ reads uriTemplate | ✗ | internal/mcpserver/server.go:574-593 | resources |
| 22 | resources/read | partial: -32602 not -32002; blob as text | partial | partial | partial: code ✓, no ttl, internal→-32602 | ✓ | ✗ | internal/mcpserver/server.go:701-722 | resources |
| 23 | resources subscribe / updated | partial: stdio only, never upstream, wrong URI | partial | partial | partial (via listen) | ✗ never subscribes | ✗ no listen | internal/mcpserver/server.go:595-620; internal/mcpclient/client.go:536-543 | resources, notifications |
| 24 | prompts/list | partial: title leak | ✓ | ✓ | partial | ✓ | ✗ | internal/mcpserver/server.go:724-740 | prompts, pagination-caching |
| 25 | prompts/get | partial: flattened | partial | partial | partial | ✓ | ✗ | internal/mcpserver/server.go:742-764; internal/cli/serve.go:640-669 | prompts |
| 26 | completion/complete | partial: ref ignored | partial | partial | partial | ✓ Complete if declared | ✗ | internal/mcpserver/server.go:1555-1599; internal/pool/request.go:60-70 | completion |
| 27 | logging (setLevel, notifications/message) | acc. no-op, never emits | acc. | acc. | acc. (removed); logLevel never read | partial: setLevel info; logs → event bus | ✗ uses removed method | internal/mcpserver/server.go:565-572; internal/daemon/hooks.go:29-33 | logging |
| 28 | elicitation (server→host) | n/a (broker) | ✓ form, SSE/stdio | ✓ form+url | partial: needs session; stateless → -32603 | n/a | n/a | internal/mcpserver/ask.go:122-233; internal/mcpserver/conn.go:191-208 | elicitation |
| 29 | elicitation (upstream→mcpx) | n/a | ✓ via broker | ✓ incl. url, complete notif | n/a | ✓ (string ids dropped) | ✓ input_required (unreachable until ) | internal/daemon/hooks.go:122-170; internal/mcpclient/modern.go:156-189 | transports, lifecycle-versioning |
| 30 | sampling (server→host) | partial: no revision or tools gating | partial | partial | partial | n/a | n/a | internal/mcpserver/conn.go:211; internal/mcpserver/ask.go:256-266 | sampling |
| 31 | sampling (upstream→mcpx) | n/a | n/a | n/a | n/a | partial: always declared, broker only | partial | internal/daemon/hooks.go:180-232 | capabilities, sampling |
| 32 | roots | ✗ never asks host; notif gets error | ✗ | ✗ | ✗ | partial: declared, empty | partial | internal/daemon/server.go:176; internal/mcpclient/modern.go:116-120 | roots, capabilities |
| 33 | tasks (server) | acc. | acc. | partial: no taskSupport, cancel-terminal, no binding, no related-task | ✗ wrong shape, no tasks/update | n/a | n/a | internal/mcpserver/server.go:474-501; internal/mcpserver/tasks.go | capabilities, tasks |
| 34 | tasks (client) | n/a | n/a | n/a | n/a | ✗ never task-augments | ✗ extension not declared | internal/mcpclient/client.go:845-858 | tools |
| 35 | subscriptions/listen | acc. (stdio) | acc. | acc. | partial: stdio wrong ack, no subscriptionId; HTTP 202 | n/a | ✗ never opened | internal/mcpserver/server.go:622-640 | notifications, transports |
| 36 | notifications (list_changed) to host | ✗ stdio declared, never sent | ✗ | ✗ | partial: stdio via listen only | — | — | internal/mcpserver/server.go:1389-1402 | capabilities |
| 37 | notifications from host | partial: unknown ones get an error reply | partial | partial | partial | ✓ all known handled | ✓ | internal/mcpserver/server.go:766; internal/mcpclient/client.go:473-522 | notifications |
| 38 | cancellation (host→mcpx→upstream) | partial: recorded only on stdio; HTTP disconnect propagates | partial | partial | partial | ✓ sends notifications/cancelled (“timeout”) | partial | internal/mcpserver/server.go:1521-1538; internal/mcpclient/client.go:709-716 | progress-cancellation |
| 39 | progress | ✗ | ✗ | ✗ | ✗ | partial: relayed to the host under its token (#212); does not reset mcpx’s own timeout | same | internal/mcpserver/ask.go:95-108; internal/daemon/hooks.go:34-36 | progress-cancellation |
| 40 | _meta passthrough (trace, sessionID) | ✗ | ✗ | ✗ | ✗ | ✗ never sent | ✗ | internal/mcpserver/ask.go:95-108 | meta |
| 41 | pagination | partial: bad cursor→0 | partial | partial | partial | ✓ ≤100 pages | ✗ | internal/mcpserver/server.go:1477-1513 | pagination-caching |
| 42 | content downgrade | ✓ resource_link→resource ( mime) | ✓ untouched | ✓ | ✓ untouched | n/a | n/a | internal/mcpserver/revisions.go:132-289 | content-types |
| 43 | auth to upstream | n/a | n/a | n/a | n/a | partial: headers only; auth inert; no OAuth | same | internal/pool/pool.go:377-380 | auth |
| 44 | auth on /mcp | ✗ | ✗ | ✗ | ✗ | n/a | n/a | internal/cli/root.go:196-204 | auth |
Reading it
- The legacy server columns are mostly ✓ or partial. The partials are
lossy rendering (
mcpx_call,prompts/get,resources/read) and declarations with nothing behind them. None of them stops a legacy host from working. - The modern server column is partial almost everywhere. mcpx’s own client works against it, but a conformant 2026 client does not: see stateless.md.
- The modern client column is ✗ almost everywhere. One field name in
discovery stops everything behind it. The rows are ✗ because they are
unreachable, not because each is broken on its own, and several (MRTR,
_meta) are implemented and would work once discovery does.
opencode v1 vs v2
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare, area:opencode
description: the plugin API, the MCP client, code mode and the process
model of opencode 1.18.31 and 2.0.3, side by side, as they
bear on mcpx.
The two opencodes pinned in this repository: v1 is 1.18.31
(v1: = the source at /nix/store/xczwr0rfqkd99yjg4aqv28yfxhim4pg1-source),
v2 is 2.0.3 (v2: = nix eval --raw .#opencode2.src, at
/nix/store/y08jwgxhq18hm706fisnqx68n39bid9i-source). Every cell cites the
line it was read from. SDK constants that live outside both trees are cited by
their unpkg URL. Individual differences, with value, effort and risk to mcpx,
are in register/plugin-apis.md,
register/process-model.md and
register/code-mode.md; the MCP-client rows are spread
across the protocol areas of the register.
What matters most for mcpx, in one paragraph each:
- v1 already runs code mode here.
experimentalCodeModeis on wheneverOPENCODE_EXPERIMENTALis set (v1:packages/opencode/src/effect/runtime-flags.ts:48), and this machine’s wrapper sets it (nix:flake.nix:297). Every MCP server added to v1 sits behind itsexecutetool, mcpx’s/mcpincluded. - v2 tells mcpx who is calling, and mcpx does not listen. Every v2
tools/callcarries_meta["ai.opencode/sessionID"](v2:packages/core/src/mcp/client.ts:278), including calls from inside code mode. That is the session identity the v1 plugin injects throughshell.env, delivered on the wire, and it is the cleanest way for mcpx to give each opencode session its own stateful servers. - v2 shares more, not less. One background server per machine by default, one MCP connection per server per Location that never idles out: every session in every window in a directory drives the same browser.
- The v2 plugin surface is a rewrite. No
client, no$, noaskin a tool context, no toast from a server plugin, and the only per-session environment route replaces the whole environment.
1. Plugin API
| item | v1 (1.18.31) | v2 (2.0.3) |
|---|---|---|
| package | @opencode-ai/plugin — v1:packages/plugin/package.json:3 | @opencode/plugin — v2:packages/plugin/package.json:3 |
| config key | plugin: (string | [string, opts])[] — v1:packages/plugin/src/index.ts:71 | plugins native; legacy plugin normalized in — v2:packages/core/src/config/normalize.ts:185 |
| directory discovery | {plugin,plugins}/*.{ts,js}, non-recursive — v1:packages/opencode/src/config/plugin.ts:21 | plugin/,plugins/ files and dirs — v2:packages/core/src/plugin/source-directory.ts:25 |
| npm vs file | npm via Npm.add, files via file://; @opencode-ai/plugin auto-installed into config dirs — v1:packages/opencode/src/config/config.ts:456 | npm via npm.add, local via source loader with watch — v2:packages/core/src/plugin/module.ts:91 |
| runtime | Bun (Bun.$ handed in) — v1:packages/opencode/src/plugin/index.ts:167 | Bun build (source loader uses Bun.Transpiler) — v2:packages/plugin/src/source.bun.ts:27; Node build exists — v2:packages/cli/package.json:21 |
| module shape | function export or {id?, server}; legacy “every export” — v1:packages/opencode/src/plugin/index.ts:114 | {id, setup} or {id, effect} only — v2:packages/core/src/plugin/module.ts:60 |
| signature | (input: PluginInput, options?) => Promise<Hooks> — v1:packages/plugin/src/index.ts:74 | setup(ctx: Context) => Cleanup | void — v2:packages/plugin/src/promise/plugin.ts:60 |
client | SDK client (in-process fetch if no port) — v1:packages/opencode/src/plugin/index.ts:146 | absent — v2:packages/plugin/src/promise/plugin.ts:26 |
project/directory/worktree | fields — v1:packages/plugin/src/index.ts:58 | ctx.location — v2:packages/plugin/src/promise/plugin.ts:28 |
serverUrl | getter — v1:packages/opencode/src/plugin/index.ts:163 | absent — v2:packages/plugin/src/promise/plugin.ts:26 |
$ shell | BunShell — v1:packages/plugin/src/index.ts:65 | absent — v2:packages/plugin/src/promise/shell.ts:12 |
| options | 2nd arg — v1:packages/plugin/src/index.ts:74 | ctx.options — v2:packages/plugin/src/promise/plugin.ts:29 |
| instance scope | per directory — v1:packages/opencode/src/plugin/index.ts:134 | per Location — v2:packages/core/src/plugin.ts:296 |
dispose | hook — v1:packages/plugin/src/index.ts:223 | setup return value — v2:packages/plugin/src/promise/plugin.ts:60 |
event | hook, pushed — v1:packages/opencode/src/plugin/index.ts:259 | ctx.event.subscribe() — v2:packages/core/src/plugin/host.ts:256 |
config | hook, once after load — v1:packages/opencode/src/plugin/index.ts:247 | none; domain transforms instead — v2:packages/plugin/src/README.md:68 |
tool | map of tool({args: zod}) — v1:packages/plugin/src/index.ts:226 | ctx.tool.transform(e => e.add(…)) — v2:packages/plugin/src/promise/tool.ts:66 |
auth / provider | hooks — v1:packages/plugin/src/index.ts:229 | integration/provider/model transforms — v2:packages/plugin/src/README.md:70 |
chat.message | hook — v1:packages/plugin/src/index.ts:234 | session.hook("prompt") — v2:packages/plugin/src/promise/session.ts:114 |
chat.params | hook — v1:packages/plugin/src/index.ts:247 | session.hook("context").options — v2:packages/plugin/src/promise/session.ts:30 |
chat.headers | hook — v1:packages/plugin/src/index.ts:257 | session.hook("model.request").headers — v2:packages/plugin/src/promise/session.ts:69 |
permission.ask | declared, never triggered — v1:packages/plugin/src/index.ts:261 | permission.hook("evaluate"), live — v2:packages/core/src/permission.ts:174 |
command.execute.before | hook — v1:packages/plugin/src/index.ts:262 | none (commands are transforms) — v2:packages/plugin/src/promise/session.ts:113 |
tool.execute.before | {tool, sessionID, callID} / {args} — v1:packages/plugin/src/index.ts:266 | {tool, sessionID, agent, messageID, id, input} — v2:packages/plugin/src/promise/tool.ts:38 |
shell.env | {cwd, sessionID?, callID?} / {env} — v1:packages/plugin/src/index.ts:270 | shell.hook("create.before") {command, cwd, timeout, shell, env} — v2:packages/plugin/src/promise/shell.ts:3 |
tool.execute.after | success only — v1:packages/plugin/src/index.ts:274 | status: completed | error — v2:packages/plugin/src/promise/tool.ts:46 |
experimental.chat.messages.transform | hook — v1:packages/plugin/src/index.ts:282 | session.hook("context").messages — v2:packages/plugin/src/promise/session.ts:29 |
experimental.chat.system.transform | {system: string[]} — v1:packages/plugin/src/index.ts:291 | session.hook("context").system: SystemPart[] — v2:packages/plugin/src/promise/session.ts:28 |
experimental.provider.small_model | hook — v1:packages/plugin/src/index.ts:297 | none found — v2:packages/plugin/src/promise/session.ts:113 |
experimental.session.compacting | hook — v1:packages/plugin/src/index.ts:305 | session.hook("compaction") — v2:packages/plugin/src/promise/session.ts:116 |
experimental.compaction.autocontinue | hook — v1:packages/plugin/src/index.ts:316 | none found — v2:packages/plugin/src/promise/session.ts:113 |
experimental.text.complete | hook — v1:packages/plugin/src/index.ts:327 | none found — v2:packages/plugin/src/promise/session.ts:113 |
tool.definition | hook (registry tools only) — v1:packages/opencode/src/tool/registry.ts:318 | session.hook("context").tools — v2:packages/plugin/src/promise/session.ts:35 |
| v2-only session hooks | — | generate, title, http.request, http.response, experimental.ws.handshake, retry — v2:packages/plugin/src/promise/session.ts:117 |
| v2-only AI SDK hooks | (v2 host present in v1 tree) — v1:packages/core/src/location-services.ts:52 | aisdk.hook("sdk" | "language") — v2:packages/plugin/src/promise/aisdk.ts:5 |
| MCP servers from a plugin | config hook mutation only | ctx.mcp.transform/reload/list (Location) — v2:packages/plugin/src/promise/mcp.ts:15 |
| toast | client.tui.showToast — v1:packages/sdk/js/src/gen/sdk.gen.ts:1118 | TUI plugin only: ui.toast.show — v2:packages/plugin/src/tui/context.ts:279 |
| ask the user from a tool | ToolContext.ask — v1:packages/plugin/src/tool.ts:19 | none — v2:packages/schema/src/tool.ts:14 |
| TUI dialogs | api.ui.DialogSelect JSX — v1:packages/plugin/src/tui.ts:604 | ui.dialog.select/prompt/confirm promises — v2:packages/plugin/src/tui/context.ts:379 |
| storage | none (server); kv (TUI) — v1:packages/plugin/src/tui.ts:611 | ctx.storage KV — v2:packages/plugin/src/promise/storage.ts:4 |
| call a model | client.session.prompt (full turn) — v1:packages/sdk/js/src/gen/sdk.gen.ts:615 | ctx.generate.text — v2:packages/core/src/plugin/host.ts:271 |
| server↔TUI channel | filesystem (none built in) | ctx.rpc — v2:packages/plugin/src/promise/rpc.ts:27 |
1.1 mcpx’s four hooks under v2
| mcpx hook (v1) | mcpx use | v2 equivalent | works as-is? |
|---|---|---|---|
shell.env — plugin/opencode/mcpx-session.ts:664 | inject MCPX_SESSION_ID etc. | shell.hook("create.before") — v2:packages/plugin/src/promise/shell.ts:12 | No session id. Only tool.hook("execute.before") sees sessionID (v2:packages/plugin/src/promise/tool.ts:40); the session-environment route is TUI-owned and replaces env. Best v2 answer is server-side: read _meta["ai.opencode/sessionID"]. |
experimental.chat.system.transform — plugin/opencode/mcpx-session.ts:737 | optional instructions | session.hook("context") push {type:"text", text} onto event.system — v2:packages/plugin/src/promise/session.ts:28 | Was broken in v1 (wrote parts, PLG-16); fixed by #1938 — the v1 hook pushes onto output.system. |
tool map — plugin/opencode/mcpx-session.ts:762 | mcpx_daemon_*, mcpx_discover/exec/observe | ctx.tool.transform with options.codemode: false — v2:packages/core/src/tool.ts:228 | Rewrite; ctx.ask unavailable. |
tool.execute.after — plugin/opencode/mcpx-session.ts:773 | timing into mcpx log | tool.hook("execute.after") with status — v2:packages/plugin/src/promise/tool.ts:46 | Rewrite; gains failures. |
2. opencode as an MCP client (what mcpx is talking to)
| capability / behaviour | v1 | v2 |
|---|---|---|
| SDK | @modelcontextprotocol/sdk@1.29.0 + patch — v1:packages/opencode/package.json:83 | @modelcontextprotocol/client@2.0.0 + patch — v2:packages/core/package.json:113 |
initialize.protocolVersion | 2025-11-25 (SDK latest) — v1:patches/@modelcontextprotocol%2Fsdk@1.29.0.patch:36 | 2025-11-25 unless protocol set — v2:packages/core/src/mcp/client.ts:312 |
2026-07-28 / server/discover | no — v1:packages/opencode/src/mcp/index.ts:6 (1.x Client) | opt-in protocol: auto | 2026-07-28 — v2:packages/schema/src/mcp.ts:20 |
MRTR input_required | no | SDK auto-fulfil, 10 rounds (SDK2C/src-D_zzAWoS.mjs L5044) — v2:packages/core/src/mcp/client.ts:157 |
_meta protocolVersion | no | modern era via SDK (SDK2K/auth-CUe6YdwF.mjs L24) — v2:packages/core/src/mcp/client.ts:248 |
vendor _meta on tools/call | none (params are name, arguments) — v1:packages/opencode/src/mcp/catalog.ts:56 | ai.opencode/sessionID — v2:packages/core/src/mcp/client.ts:278 |
| transports | stdio, Streamable HTTP, SSE fallback — v1:packages/opencode/src/mcp/index.ts:269 | stdio (own), Streamable HTTP — v2:packages/core/src/mcp/client.ts:220 |
| stdio stderr | piped, unread — v1:packages/opencode/src/mcp/index.ts:348 | drained to debug log — v2:packages/core/src/mcp/stdio.ts:162 |
| stdio frame cap | none found | 16 MiB — v2:packages/core/src/mcp/stdio.ts:15 |
declared roots | yes — v1:packages/opencode/src/mcp/index.ts:46 | yes (legacy only) — v2:packages/core/src/mcp/client.ts:146 |
declared sampling | no — v1:packages/opencode/src/mcp/index.ts:42 | no — v2:packages/core/src/mcp/client.ts:142 |
declared elicitation | no — v1:packages/opencode/src/mcp/index.ts:44 | form (applyDefaults) + url — v2:packages/core/src/mcp/client.ts:143 |
declared tasks | no — v1:packages/opencode/src/mcp/index.ts:48 | no — v2:packages/core/src/mcp/client.ts:142 |
roots/list handler | one root = directory — v1:packages/opencode/src/mcp/index.ts:78 | one root = directory — v2:packages/core/src/mcp/client.ts:155 |
elicitation/create | −32601 (no handler) — v1:packages/opencode/src/mcp/index.ts:77 | Location-global form — v2:packages/core/src/mcp/index.ts:82 |
notifications/elicitation/complete | no | yes (legacy url mode) — v2:packages/core/src/mcp/client.ts:160 |
sampling/createMessage | no | no |
tools/list_changed | refetch — v1:packages/opencode/src/mcp/index.ts:462 | refetch, 100 ms debounce — v2:packages/core/src/tool/mcp.ts:140 |
prompts/list_changed | ignored | refetch — v2:packages/core/src/mcp/index.ts:374 |
resources/list_changed | ignored | event — v2:packages/core/src/mcp/index.ts:375 |
notifications/message | → opencode log — v1:packages/opencode/src/mcp/index.ts:457 | ignored — v2:packages/core/src/mcp/client.ts:140 |
logging/setLevel | never sent — v1:packages/opencode/src/mcp/index.ts:164 | never sent — v2:packages/core/src/mcp/client.ts:91 |
| resources to the model | 3 tools if any server has resources — v1:packages/opencode/src/session/tools.ts:136 | none (TUI @ list only) — v2:packages/tui/src/component/prompt/autocomplete.tsx:413 |
resources/read | via read_mcp_resource — v1:packages/opencode/src/session/tools.ts:27 | defined, no caller — v2:packages/core/src/mcp/index.ts:713 |
resources/subscribe | no | no (SDK may subscriptions/listen on modern) — v2:packages/core/src/mcp/client.ts:131 |
| prompts | slash commands, text only — v1:packages/opencode/src/command/index.ts:105 | slash commands server:prompt — v2:packages/core/src/plugin/command.ts:105 |
completion/complete | never — v1:packages/opencode/src/mcp/index.ts:164 | never — v2:packages/core/src/mcp/client.ts:91 |
server instructions | system prompt — v1:packages/opencode/src/session/system.ts:129 | system prompt (+ code-mode hint) — v2:packages/core/src/mcp/instructions.ts:23 |
| progress | token = request id, resets timer — v1:packages/opencode/src/mcp/catalog.ts:61 | token = request id, no reset — v2:packages/core/src/mcp/client.ts:281 |
| cancellation | AbortSignal → notifications/cancelled — v1:packages/opencode/src/mcp/catalog.ts:62 | AbortSignal → cancelled / HTTP abort — v2:packages/core/src/mcp/client.ts:273 |
| connect timeout | 30 s — v1:packages/opencode/src/mcp/index.ts:38 | timeout.startup 30 s — v2:packages/core/src/mcp/client.ts:32 |
| list timeout | 30 s — v1:packages/opencode/src/mcp/catalog.ts:11 | timeout.catalog 30 s — v2:packages/core/src/mcp/client.ts:33 |
tools/call timeout | per-server / experimental.mcp_timeout / SDK 60 s — v1:packages/opencode/src/mcp/index.ts:672 | timeout.execution 12 h — v2:packages/core/src/mcp/client.ts:34 |
| tool naming | sanitize(server)_sanitize(tool) — v1:packages/opencode/src/mcp/catalog.ts:119 | same, namespace + ≤64 check — v2:packages/core/src/tool/mcp.ts:17 |
| pagination cap | 1000 pages — v1:packages/opencode/src/mcp/catalog.ts:12 | 64 pages (SDK2C/index.mjs L2904) — v2:packages/core/src/mcp/client.ts:251 |
outputSchema enforcement | SDK validates; tolerant re-list — v1:packages/opencode/src/mcp/catalog.ts:155 | not traced |
| text | text — v1:packages/opencode/src/session/tools.ts:429 | text — v2:packages/core/src/mcp/client.ts:324 |
| image | attachment — v1:packages/opencode/src/session/tools.ts:430 | media — v2:packages/core/src/mcp/client.ts:325 |
| audio | dropped (direct) — v1:packages/opencode/src/session/tools.ts:429 | media — v2:packages/core/src/mcp/client.ts:325 |
| resource_link | dropped (direct) / name: uri (code mode) — v1:packages/opencode/src/tool/code-mode.ts:104 | uri text — v2:packages/core/src/mcp/client.ts:327 |
| embedded resource | text / allow-listed blob — v1:packages/opencode/src/session/tools.ts:436 | text / media — v2:packages/core/src/mcp/client.ts:328 |
| structuredContent | used only if content empty — v1:packages/opencode/src/mcp/catalog.ts:75 | becomes output — v2:packages/core/src/tool/mcp.ts:97 |
| isError | thrown with text — v1:packages/opencode/src/mcp/catalog.ts:68 | ToolFailure with text — v2:packages/core/src/tool/mcp.ts:78 |
| OAuth | PKCE+DCR, port 19876, mcp-auth.json — v1:packages/opencode/src/mcp/oauth-provider.ts:11 | + CIMD, RFC 8707, ephemeral port — v2:packages/core/src/mcp/oauth.ts:25 |
| HTTP session expiry | re-initialize (patch) — v1:patches/@modelcontextprotocol%2Fsdk@1.29.0.patch:210 | reconnect + retry — v2:packages/core/src/mcp/index.ts:336 |
| per-call permission | ctx.ask(permission=key) — v1:packages/opencode/src/session/tools.ts:408 | permission.assert(action=name) — v2:packages/core/src/tool/mcp.ts:51 |
3. Code mode
| item | v1 | v2 |
|---|---|---|
| enabled | OPENCODE_EXPERIMENTAL_CODE_MODE, else OPENCODE_EXPERIMENTAL — v1:packages/opencode/src/effect/runtime-flags.ts:48 (on for this user: nix:flake.nix:297) | default; per-server codemode — v2:packages/schema/src/mcp.ts:34 |
| package | @opencode-ai/codemode 1.18.31 — v1:packages/codemode/package.json:3 | @opencode/codemode 2.0.3 — v2:packages/codemode/package.json:3 |
| engine | TS transpile → acorn → interpreter — v1:packages/codemode/src/interpreter/runtime.ts:116 | same — v2:packages/codemode/src/interpreter/transpile.node.ts:10 |
| TypeScript | stripped — v1:packages/codemode/src/interpreter/runtime.ts:3 | stripped — v2:packages/codemode/src/interpreter/transpile.node.ts:1 |
| what is wrapped | MCP tools only — v1:packages/opencode/src/tool/code-mode.ts:210 | every tool with codemode !== false — v2:packages/core/src/tool.ts:229 |
| tool paths | tools.<server>.<tool> — v1:packages/opencode/src/tool/code-mode.ts:47 | tools.<namespace>.<tool> — v2:packages/core/src/codemode/tool.ts:282 |
| catalog | in execute description — v1:packages/opencode/src/tool/registry.ts:288 | 2 000-char inline budget + diffs — v2:packages/core/src/codemode/catalog.ts:50 |
search() | yes — v1:packages/codemode/src/codemode.ts:142 | yes — v2:packages/codemode/README.md:170 |
| limits set by host | none — v1:packages/opencode/src/tool/code-mode.ts:239 | none — v2:packages/core/src/codemode/tool.ts:222 |
| fetch / timers / imports | no | no — v2:packages/core/src/codemode/tool.ts:63 |
| concurrency | unrestricted | unrestricted; model told to Promise.all — v2:packages/core/src/codemode/tool.ts:67 |
| child results | structuredContent, else text — v1:packages/opencode/src/tool/code-mode.ts:109 | output (structured or parsed text) — v2:packages/core/src/codemode/tool.ts:102 |
session _meta on child MCP calls | none | ai.opencode/sessionID — v2:packages/core/src/tool/mcp.ts:69 |
| plugin hooks per child | yes, callID <parent>/<n> — v1:packages/opencode/src/tool/code-mode.ts:227 | yes — v2:packages/core/src/tool.ts:235 |
| reachable outside a model turn | no | no — v2:packages/protocol/src/groups/generate.ts:8 (only text generation) |
| opt-out | env flag | deny execute, or codemode:false — v2:packages/core/src/tool.ts:232 |
4. Process model
| item | v1 | v2 |
|---|---|---|
| processes | 1: TUI main thread + server Worker — v1:packages/opencode/src/cli/cmd/tui.ts:210 | TUI + shared background server — v2:packages/cli/src/commands/handlers/default.ts:31 |
| default network | none (in-process fetch) — v1:packages/opencode/src/cli/cmd/tui.ts:246 | HTTP 127.0.0.1:0xc0de (service) — v2:packages/cli/src/services/service-config.ts:35 |
| opt-out of sharing | n/a | --standalone — v2:packages/cli/src/services/server-connection.ts:41 |
| MCP client scope | per directory — v1:packages/opencode/src/effect/instance-state.ts:49 | per Location — v2:packages/core/src/mcp/index.ts:736 |
| connections per server | 1 per directory per opencode process | 1 per Location per daemon — v2:packages/core/src/mcp/index.ts:152 |
| start | all at first MCP use, awaited — v1:packages/opencode/src/mcp/index.ts:528 | forked async per server — v2:packages/core/src/mcp/index.ts:507 |
| lifetime | until instance dispose / exit — v1:packages/opencode/src/mcp/index.ts:531 | forever (no idle TTL) — v2:packages/core/src/location-services.ts:52 |
| on crash | failed, no restart — v1:packages/opencode/src/mcp/index.ts:448 | failed, no restart — v2:packages/core/src/mcp/index.ts:358 |
| stdio spawn | host — v1:packages/opencode/src/mcp/index.ts:347 | Location Environment (may be remote workspace) — v2:packages/core/src/mcp/client.ts:198 |
| teardown | SIGTERM descendants — v1:packages/opencode/src/mcp/index.ts:546 | group SIGTERM→SIGKILL 2 s — v2:packages/core/src/mcp/stdio.ts:57 |
| sessions sharing a stateful server | share; nothing sent to tell them apart | share (also across windows); _meta["ai.opencode/sessionID"] sent — v2:packages/core/src/mcp/client.ts:278 |
| plugin realm | server Worker, unsandboxed — v1:packages/opencode/src/plugin/index.ts:118 | daemon process, unsandboxed — v2:packages/core/src/plugin/module.ts:102 |
| architecture | packages/opencode app over packages/core pieces — v1:packages/opencode/package.json:3 | cli → server (/api/*, protocol) → core Effect services — v2:packages/cli/package.json:7 |
| DB filename | channel-dependent — v1:packages/core/src/database/database.ts:49 | channel-dependent (different list) — v2:packages/cli/src/database-path.ts:7 |
MCP revisions × features
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare, area:protocol
description: every method, notification, capability field, content type,
error code and HTTP header, against the five published
revisions of the Model Context Protocol.
What the specification defines, revision by revision. This page is about the
specification only; what mcpx does with each row is in
matrix-mcpx.md and, per difference, in the
register. Every row names one source; sources are
at modelcontextprotocol/modelcontextprotocol commit 046fa30 (conventions in
README.md).
Columns: 24-11 = 2024-11-05, 25-03 = 2025-03-26, 25-06 = 2025-06-18,
25-11 = 2025-11-25, 26-07 = 2026-07-28.
Legend: ✓ defined · — absent · dep defined but deprecated (still works;
scheduled for removal under the 2026 feature-lifecycle policy) · rem
removed (was defined earlier) · ext only through the
io.modelcontextprotocol/tasks extension · MRTR exists only as an input
request embedded in an InputRequiredResult, never as a JSON-RPC request of
its own · (s) schema only · (p) prose only.
Three things the tables make visible that no single changelog says:
- 2026-07-28 is not an increment. It removes the handshake,
ping, sessions, the GET stream, resumability,logging/setLevel,resources/subscribe, every server-to-client request and every unsolicited notification (list changes now arrive only on an opted-insubscriptions/listenstream), and it adds required things to every exchange:_metaversion and capabilities on every request,resultTypeon every result,ttlMs/cacheScopeon every cacheable result, and on HTTP theMcp-Methodheader (plusMcp-Namefortools/call,resources/readandprompts/get). - Deprecated is not removed. Roots, Sampling and Logging are deprecated in
2026-07-28 but still defined and still usable, now only through
input_required(roots, sampling) or per-requestlogLevel(logging). - Batching lived for one revision. Added in 2025-03-26, removed in 2025-06-18. A server that claims 2025-03-26 is on the hook for it.
1. Base protocol
| Feature | 2024-11-05 | 2025-03-26 | 2025-06-18 | 2025-11-25 | 2026-07-28 | Source |
|---|---|---|---|---|---|---|
initialize / notifications/initialized | ✓ | ✓ | ✓ | ✓ | rem | schema/2026-07-28/schema.ts:3153 |
| Legacy counter-offer version negotiation | ✓ | ✓ | ✓ | ✓ | rem | 2025-11-25/basic/lifecycle.mdx:170 |
InitializeResult.instructions | ✓(s) | ✓ | ✓ | ✓ | moved to DiscoverResult | schema/2024-11-05/schema.ts:172 |
Implementation.title | — | — | ✓ | ✓ | ✓ | schema/2025-06-18/schema.ts:331 |
Implementation.icons/description/websiteUrl | — | — | — | ✓ | ✓ | schema/2025-11-25/schema.ts:550 |
server/discover (MUST implement) | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:665 |
Per-request _meta protocolVersion + clientCapabilities (required) | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:76 |
clientInfo / serverInfo in _meta (SHOULD) | — | — | — | — | ✓ | 2026-07-28/basic/index.mdx:384 |
UnsupportedProtocolVersionError (-32022) | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:483 |
ping | ✓ | ✓ | ✓ | ✓ | rem | seps/2575-stateless-mcp.md:569 |
| JSON-RPC batching | — | ✓ | rem | — | — | 2025-06-18/changelog.mdx:12 |
resultType on every result | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:234 |
_meta on requests/notifications/results | ✓(s) | ✓(s) | ✓ (+rules) | ✓ | ✓ | schema/2024-11-05/schema.ts:21 |
| Reserved-prefix rule | — | — | forward-DNS | reverse-DNS | reverse-DNS | 2025-11-25/basic/index.mdx:208 |
OTel traceparent/tracestate/baggage reserved | — | — | — | — | ✓ | 2026-07-28/basic/index.mdx:421 |
notifications/progress | ✓ both ways | ✓ +message | ✓ | ✓ (+tasks) | ✓ server→client only | schema/2025-03-26/schema.ts:316 |
notifications/cancelled | ✓ both ways | ✓ | ✓ | ✓ requestId? | ✓ requestId req’d; transport-specific | schema/2025-11-25/schema.ts:223 |
| Server-initiated requests | ✓ | ✓ | ✓ | ✓ | rem (MRTR) | 2026-07-28/basic/patterns/index.mdx:20 |
subscriptions/listen | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:1314 |
logging/setLevel | ✓ | ✓ | ✓ | ✓ | rem (per-request logLevel; Logging dep) | 2026-07-28/changelog.mdx:20 |
| Tasks | — | — | — | ✓ core (experimental) | extension | 2026-07-28/changelog.mdx:22 |
extensions capability | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:785 |
| Cursor pagination | ✓ | ✓ | ✓ | ✓ | ✓ (+ "" valid; cacheable) | 2026-07-28/server/utilities/pagination.mdx:106 |
CacheableResult ttlMs/cacheScope | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:1081 |
Error id optional | — | — | — | ✓ | ✓ | schema/2025-11-25/schema.ts:161 |
| Error-code allocation policy | — | — | — | — | ✓ | 2026-07-28/basic/index.mdx:117 |
| JSON Schema 2020-12 default dialect | — | — | — | ✓ | ✓ (+$ref rules) | 2025-11-25/basic/index.mdx:144 |
| stdio transport | ✓ | ✓ (+batch) | ✓ | ✓ | ✓ | 2026-07-28/basic/transports/stdio.mdx:13 |
| HTTP+SSE transport | ✓ | dep | dep | dep | dep (registry) | 2026-07-28/deprecated.mdx:31 |
| Streamable HTTP | — | ✓ | ✓ | ✓ (+polling) | ✓ (reshaped, POST-only) | 2026-07-28/basic/transports/streamable-http.mdx:9 |
Mcp-Session-Id sessions | — | ✓ | ✓ | ✓ | rem | 2026-07-28/basic/transports/streamable-http.mdx:685 |
| GET standalone SSE stream | (SSE endpoint) | ✓ | ✓ | ✓ | rem | 2026-07-28/basic/transports/streamable-http.mdx:19 |
Last-Event-ID resumability | — | ✓ | ✓ | ✓ | rem | 2026-07-28/basic/transports/streamable-http.mdx:157 |
| Closing stream = cancel | — | no (SHOULD NOT) | no | no | ✓ MUST | 2026-07-28/basic/transports/streamable-http.mdx:235 |
| OAuth 2.1 authorization | — | ✓ | ✓ (RS + RFC 9728/8707) | ✓ (+OIDC, CIMD, scopes) | ✓ (+RFC 9207, DCR dep) | 2025-06-18/changelog.mdx:16 |
| Dynamic Client Registration | — | SHOULD | SHOULD | MAY | dep | 2026-07-28/deprecated.mdx:29 |
2. Methods
initialize and ping are in §1.
| method | 24-11 | 25-03 | 25-06 | 25-11 | 26-07 | source |
|---|---|---|---|---|---|---|
server/discover | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:666 |
tools/list | ✓ | ✓ | ✓ | ✓ | ✓ (+cacheable) | schema/2026-07-28/schema.ts:1768 |
tools/call | ✓ | ✓ | ✓ | ✓ (+task) | ✓ (+MRTR) | schema/2026-07-28/schema.ts:1883 |
prompts/list | ✓ | ✓ | ✓ | ✓ | ✓ (+cacheable) | schema/2026-07-28/schema.ts:1567 |
prompts/get | ✓ | ✓ | ✓ | ✓ | ✓ (+MRTR) | schema/2026-07-28/schema.ts:1622 |
resources/list | ✓ | ✓ | ✓ | ✓ | ✓ (+cacheable) | schema/2026-07-28/schema.ts:1122 |
resources/templates/list | ✓ | ✓ | ✓ | ✓ | ✓ (+cacheable) | schema/2026-07-28/schema.ts:1158 |
resources/read | ✓ | ✓ | ✓ | ✓ | ✓ (+MRTR, +cacheable) | schema/2026-07-28/schema.ts:1217 |
resources/subscribe | ✓ | ✓ | ✓ | ✓ | rem | schema/2025-11-25/schema.ts:751 |
resources/unsubscribe | ✓ | ✓ | ✓ | ✓ | rem | schema/2025-11-25/schema.ts:769 |
subscriptions/listen | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:1315 |
completion/complete | ✓ (ungated) | ✓ | ✓ (+context) | ✓ | ✓ | schema/2024-11-05/schema.ts:955 |
logging/setLevel | ✓ | ✓ | ✓ | ✓ | rem | schema/2025-11-25/schema.ts:1520 |
sampling/createMessage | ✓ | ✓ | ✓ | ✓ (+tools, +task) | dep, MRTR | schema/2026-07-28/schema.ts:2186 |
roots/list | ✓ | ✓ | ✓ | ✓ | dep, MRTR | schema/2026-07-28/schema.ts:2719 |
elicitation/create | — | — | ✓ (form) | ✓ (form+url, +task) | ✓, MRTR | schema/2026-07-28/schema.ts:2857 |
tasks/get | — | — | — | ✓ | ext | schema/2025-11-25/schema.ts:1401 |
tasks/result | — | — | — | ✓ | rem (ext: -32601) | schema/2025-11-25/schema.ts:1423 |
tasks/list | — | — | — | ✓ | rem (not in ext) | schema/2025-11-25/schema.ts:1471 |
tasks/cancel | — | — | — | ✓ | ext (ack-only) | schema/2025-11-25/schema.ts:1449 |
tasks/update | — | — | — | — | ext | seps/2663-tasks-extension.md:358 |
3. Notifications
| notification | 24-11 | 25-03 | 25-06 | 25-11 | 26-07 | source |
|---|---|---|---|---|---|---|
notifications/initialized | ✓ | ✓ | ✓ | ✓ | rem | schema/2025-11-25/schema.ts:301 |
notifications/cancelled | ✓ both ways | ✓ | ✓ | ✓ (requestId optional) | ✓ client→server; server→client only to end a listen on stdio | schema/2026-07-28/schema.ts:637 |
notifications/progress | ✓ both ways | ✓ (+message) | ✓ | ✓ | ✓ server→client only, request-scoped | schema/2026-07-28/schema.ts:1041 |
notifications/message | ✓ | ✓ | ✓ | ✓ | dep; only with per-request logLevel; request-scoped | schema/2026-07-28/schema.ts:2059 |
notifications/tools/list_changed | ✓ unsolicited | ✓ | ✓ | ✓ | ✓ listen-only (toolsListChanged) | schema/2026-07-28/schema.ts:1896 |
notifications/prompts/list_changed | ✓ unsolicited | ✓ | ✓ | ✓ | ✓ listen-only (promptsListChanged) | schema/2026-07-28/schema.ts:1754 |
notifications/resources/list_changed | ✓ unsolicited | ✓ | ✓ | ✓ | ✓ listen-only (resourcesListChanged) | schema/2026-07-28/schema.ts:1257 |
notifications/resources/updated | ✓ after subscribe | ✓ | ✓ | ✓ | ✓ listen-only (resourceSubscriptions) | schema/2026-07-28/schema.ts:1429 |
notifications/roots/list_changed | ✓ | ✓ | ✓ | ✓ | rem | schema/2025-11-25/schema.ts:2146 |
notifications/elicitation/complete | — | — | — | ✓ | rem | schema/2025-11-25/schema.ts:2494 |
notifications/tasks/status | — | — | — | ✓ | rem | schema/2025-11-25/schema.ts:1496 |
notifications/tasks | — | — | — | — | ext (listen, taskIds) | seps/2663-tasks-extension.md:422 |
notifications/subscriptions/acknowledged | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:1399 |
4. Capability fields
| capability | 24-11 | 25-03 | 25-06 | 25-11 | 26-07 | source |
|---|---|---|---|---|---|---|
client experimental | ✓ | ✓ | ✓ | ✓ | ✓ (JSONObject) | schema/2026-07-28/schema.ts:720 |
client roots | ✓ | ✓ | ✓ | ✓ | dep ({}) | schema/2026-07-28/schema.ts:732 |
client roots.listChanged | ✓ | ✓ | ✓ | ✓ | rem | schema/2025-11-25/schema.ts:322 |
client sampling | ✓ | ✓ | ✓ | ✓ | dep | schema/2026-07-28/schema.ts:749 |
client sampling.context | — | — | — | ✓ (soft-dep gate) | dep | schema/2026-07-28/schema.ts:754 |
client sampling.tools | — | — | — | ✓ | dep | schema/2026-07-28/schema.ts:758 |
client elicitation | — | — | ✓ | ✓ | ✓ | schema/2026-07-28/schema.ts:769 |
client elicitation.form | — | — | — | ✓ | ✓ | schema/2026-07-28/schema.ts:770 |
client elicitation.url | — | — | — | ✓ | ✓ | schema/2026-07-28/schema.ts:771 |
client tasks (list/cancel/requests.sampling.createMessage/requests.elicitation.create) | — | — | — | ✓ | rem | schema/2025-11-25/schema.ts:346 |
client extensions | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:785 |
server experimental | ✓ | ✓ | ✓ | ✓ | ✓ | schema/2026-07-28/schema.ts:797 |
server logging | ✓ | ✓ | ✓ | ✓ | dep | schema/2026-07-28/schema.ts:808 |
server completions | — | ✓ | ✓ | ✓ | ✓ | schema/2026-07-28/schema.ts:815 |
server prompts.listChanged | ✓ | ✓ | ✓ | ✓ | ✓ (listen) | schema/2026-07-28/schema.ts:829 |
server resources.subscribe | ✓ | ✓ | ✓ | ✓ | ✓ (now = resourceSubscriptions) | schema/2026-07-28/schema.ts:850 |
server resources.listChanged | ✓ | ✓ | ✓ | ✓ | ✓ (listen) | schema/2026-07-28/schema.ts:854 |
server tools.listChanged | ✓ | ✓ | ✓ | ✓ | ✓ (listen) | schema/2026-07-28/schema.ts:869 |
server tasks.list | — | — | — | ✓ | rem | schema/2025-11-25/schema.ts:437 |
server tasks.cancel | — | — | — | ✓ | rem (ext implies cancel) | schema/2025-11-25/schema.ts:441 |
server tasks.requests.tools.call | — | — | — | ✓ | rem | schema/2025-11-25/schema.ts:453 |
server extensions | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:882 |
extensions["io.modelcontextprotocol/tasks"] (both sides) | — | — | — | — | ext | seps/2663-tasks-extension.md:47 |
5. Content types
| content type | 24-11 | 25-03 | 25-06 | 25-11 | 26-07 | source |
|---|---|---|---|---|---|---|
text | ✓ | ✓ | ✓ (+_meta) | ✓ | ✓ | schema/2024-11-05/schema.ts:846 |
image | ✓ | ✓ | ✓ (+_meta) | ✓ | ✓ | schema/2024-11-05/schema.ts:857 |
audio | — | ✓ | ✓ | ✓ | ✓ | schema/2025-03-26/schema.ts:991 |
resource (embedded) | ✓ | ✓ | ✓ | ✓ | ✓ | schema/2024-11-05/schema.ts:617 |
resource_link | — | — | ✓ | ✓ | ✓ | schema/2025-06-18/schema.ts:761 |
tool_use (sampling only) | — | — | — | ✓ | dep | schema/2025-11-25/schema.ts:1835 |
tool_result (sampling only) | — | — | — | ✓ | dep | schema/2025-11-25/schema.ts:1869 |
5.1 Where each content type may appear
T = text, I = image, A = audio, R = embedded resource, L = resource_link, U = tool_use, X = tool_result.
| location | 24-11 | 25-03 | 25-06 | 25-11 | 26-07 | source (latest rev with that shape) |
|---|---|---|---|---|---|---|
CallToolResult.content[] | T I R | T I A R | T I A R L | T I A R L | T I A R L | schema/2026-07-28/schema.ts:1813 |
PromptMessage.content (single block) | T I R | T I A R | T I A R L | T I A R L | T I A R L | schema/2026-07-28/schema.ts:1706 |
SamplingMessage.content (request messages and CreateMessageResult) | T I (single) | T I A (single) | T I A (single) | T I A U X (single or array) | T I A U X (dep) | schema/2026-07-28/schema.ts:2258 |
ToolResultContent.content[] (nested in sampling) | — | — | — | T I A R L | T I A R L (dep) | schema/2026-07-28/schema.ts:2461 |
ReadResourceResult.contents[] | Text/BlobResourceContents (not content blocks) | same | same (+_meta) | same | same (+cacheable) | schema/2026-07-28/schema.ts:1230 |
Per-revision sources for the non-2026 cells: schema/2024-11-05/schema.ts:656 “content: (TextContent | ImageContent | EmbeddedResource)[];”; schema/2025-03-26/schema.ts:699 “content: (TextContent | ImageContent | AudioContent | EmbeddedResource)[];”; schema/2025-06-18/schema.ts:823 “content: ContentBlock[];”; schema/2024-11-05/schema.ts:607 “content: TextContent | ImageContent | EmbeddedResource;”; schema/2025-03-26/schema.ts:645 “content: TextContent | ImageContent | AudioContent | EmbeddedResource;”; schema/2025-06-18/schema.ts:750 “content: ContentBlock;”; schema/2024-11-05/schema.ts:812 “content: TextContent | ImageContent;”; schema/2025-03-26/schema.ts:918 “content: TextContent | ImageContent | AudioContent;”; schema/2025-11-25/schema.ts:1683 “content: SamplingMessageContentBlock | SamplingMessageContentBlock[];”; schema/2025-11-25/schema.ts:1884 “content: ContentBlock[];”
Annotations (audience, priority, and from 2025-06-18 lastModified) are allowed on T, I, A, R, L (L via Resource) in every revision the type exists; never on U or X. _meta is on T, I, A, R, L from 2025-06-18 and on U, X from their introduction.
6. Selected fields and types
| field / type | 24-11 | 25-03 | 25-06 | 25-11 | 26-07 | source |
|---|---|---|---|---|---|---|
Tool.annotations (4 hints + title) | — | ✓ | ✓ | ✓ | ✓ | schema/2025-03-26/schema.ts:809 |
Tool.title / BaseMetadata.title | — | — | ✓ | ✓ | ✓ | schema/2025-06-18/schema.ts:930 |
Tool.outputSchema | — | — | ✓ (object root) | ✓ (object root) | ✓ (any schema) | schema/2025-06-18/schema.ts:951 |
CallToolResult.structuredContent | — | — | ✓ object | ✓ object | ✓ any JSON | schema/2026-07-28/schema.ts:1821 |
icons (Tool, Resource, ResourceTemplate, Prompt, Implementation) | — | — | — | ✓ | ✓ | schema/2025-11-25/schema.ts:1249 |
Tool.execution.taskSupport | — | — | — | ✓ | rem | schema/2025-11-25/schema.ts:1270 |
Annotations.lastModified | — | — | ✓ | ✓ | ✓ | schema/2025-06-18/schema.ts:1127 |
CompleteRequest…context.arguments | — | — | ✓ | ✓ | ✓ | schema/2025-06-18/schema.ts:1333 |
Result.resultType (required) | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:234 |
InputRequiredResult | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:584 |
CacheableResult (ttlMs, cacheScope) | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:1081 |
_meta io.modelcontextprotocol/logLevel | — | — | — | — | dep | schema/2026-07-28/schema.ts:110 |
_meta io.modelcontextprotocol/related-task | — | — | — | ✓ | rem | schema/2025-11-25/schema.ts:1332 |
URLElicitationRequiredError -32042 | — | — | — | ✓ | rem (reserved) | schema/2025-11-25/schema.ts:181 |
MissingRequiredClientCapabilityError -32021 | — | — | — | — | ✓ | schema/2026-07-28/schema.ts:442 |
7. Error codes
| Code | Name / meaning | 2024-11-05 | 2025-03-26 | 2025-06-18 | 2025-11-25 | 2026-07-28 | Source |
|---|---|---|---|---|---|---|---|
| -32700 | Parse error | ✓ | ✓ | ✓ | ✓ | ✓ (ParseError) | schema/2026-07-28/schema.ts:312 |
| -32600 | Invalid Request | ✓ | ✓ | ✓ | ✓ (+task-required misuse) | ✓ | 2025-11-25/basic/utilities/tasks.mdx:775 |
| -32601 | Method not found (also: server capability not advertised) | ✓ | ✓ | ✓ | ✓ (+task-required tool not run as task) | ✓ + HTTP 404 | 2026-07-28/basic/transports/streamable-http.mdx:271 |
| -32602 | Invalid params: unknown tool/prompt, bad cursor, bad log level; legacy initialize version error example; 26-07 also missing _meta fields and resource-not-found | ✓ | ✓ | ✓ | ✓ | ✓ (expanded) | 2026-07-28/basic/index.mdx:381 |
| -32603 | Internal error | ✓ | ✓ | ✓ | ✓ | ✓ | schema/2024-11-05/schema.ts:84 |
| -32002 | Resource not found | ✓ | ✓ | ✓ | ✓ | MUST NOT emit; clients SHOULD accept | 2026-07-28/basic/index.mdx:140 |
| -32042 | URL elicitation required | — | — | — | ✓ | MUST NOT emit | schema/2025-11-25/schema.ts:181 |
| -32020 | HeaderMismatch (headers ≠ body, or missing/malformed) | — | — | — | — | ✓ (HTTP 400) | schema/2026-07-28/schema.ts:434 |
| -32021 | MissingRequiredClientCapability (data.requiredCapabilities) | — | — | — | — | ✓ (HTTP 400) | schema/2026-07-28/schema.ts:442 |
| -32022 | UnsupportedProtocolVersion (data.supported, data.requested) | — | — | — | — | ✓ (HTTP 400) | schema/2026-07-28/schema.ts:450 |
| -32000..-32019 | Legacy implementation-defined; receivers assume nothing (except -32002) | (unreserved) | (unreserved) | (unreserved) | (unreserved) | policy | 2026-07-28/basic/index.mdx:117 |
| -32020..-32099 | Reserved for the MCP spec | — | — | — | — | policy | 2026-07-28/basic/index.mdx:122 |
| -32001 / -32003 / -32004 | Pre-release draft numbers for HeaderMismatch / MissingRequiredClientCapability / UnsupportedProtocolVersion; never in a release | — | — | — | — | renumbered | 2026-07-28/changelog.mdx:65 |
| (HTTP) 400 | legacy: invalid/unsupported MCP-Protocol-Version, missing required session; 26-07: -32602 meta / -32020 / -32021 / -32022 | — | ✓ (session) | ✓ | ✓ | ✓ | 2025-06-18/basic/transports.mdx:256 |
| (HTTP) 403 | invalid Origin (explicit from 25-11); auth insufficient scope | — | ✓ (auth) | ✓ (auth) | ✓ | ✓ | 2025-11-25/basic/transports.mdx:79 |
| (HTTP) 404 | legacy: expired/unknown session ⇒ re-initialize; 26-07: unknown method + -32601 | — | ✓ | ✓ | ✓ | ✓ (changed meaning) | 2025-03-26/basic/transports.mdx:201 |
| (HTTP) 405 | GET/DELETE not offered | — | ✓ | ✓ | ✓ | ✓ (SHOULD for GET/DELETE) | 2026-07-28/basic/transports/streamable-http.mdx:683 |
8. HTTP headers
| Header | Dir | 2024-11-05 | 2025-03-26 | 2025-06-18 | 2025-11-25 | 2026-07-28 | Source |
|---|---|---|---|---|---|---|---|
Origin (validated) | C→S | MUST validate | MUST | MUST | MUST; invalid ⇒ 403 | MUST; invalid ⇒ 403 | 2026-07-28/basic/transports/streamable-http.mdx:58 |
Accept: application/json, text/event-stream | C→S | — | MUST (POST); text/event-stream (GET) | MUST | MUST | MUST (POST) | 2026-07-28/basic/transports/streamable-http.mdx:76 |
Content-Type (response application/json | text/event-stream) | S→C | SSE only | MUST pick one | ✓ | ✓ | ✓ | 2026-07-28/basic/transports/streamable-http.mdx:89 |
Mcp-Session-Id | both | — | MAY mint; MUST echo | same | same (MCP-Session-Id) | removed; ignore, don’t mint | 2025-03-26/basic/transports.mdx:187 |
MCP-Protocol-Version | C→S | — | SHOULD during OAuth metadata discovery only | MUST after init; absent ⇒ assume 2025-03-26 | same | MUST every POST; = _meta else -32020 | 2026-07-28/basic/transports/streamable-http.mdx:252 |
Last-Event-ID | C→S | — | resume via GET | same | same; always via GET | removed; ignore | 2025-11-25/basic/transports.mdx:179 |
Mcp-Method | C→S | — | — | — | — | REQUIRED all requests | 2026-07-28/basic/transports/streamable-http.mdx:290 |
Mcp-Name | C→S | — | — | — | — | REQUIRED tools/call, resources/read, prompts/get | 2026-07-28/basic/transports/streamable-http.mdx:291 |
Mcp-Param-{Name} (from x-mcp-header) | C→S | — | — | — | — | clients MUST mirror | 2026-07-28/basic/transports/streamable-http.mdx:374 |
X-Accel-Buffering: no | S→C | — | — | — | — | SHOULD on SSE | 2026-07-28/basic/transports/streamable-http.mdx:136 |
Authorization: Bearer | C→S | — | MUST (when auth) | MUST | MUST | MUST | 2026-07-28/basic/authorization/index.mdx:262 |
WWW-Authenticate (resource_metadata, scope, error="insufficient_scope") | S→C | — | (401 only) | MUST on 401 | header or well-known; scope SHOULD | same | 2025-11-25/basic/authorization.mdx:95 |
SSE id: / retry: fields (not headers) | S→C | — | id MAY | same | priming event SHOULD; retry MUST be honoured | removed | 2025-11-25/basic/transports.mdx:115 |
Stateless and every revision: can mcpx do both?
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare, area:protocol
description: whether one mcpx can serve and consume the stateless
2026-07-28 protocol and every legacy revision at once, what
"stateless" actually requires, where mcpx keeps state, and
what stands in the way at 05c78b2.
The question: can mcpx speak the most modern, fully stateless protocol (2026-07-28) and also every older revision, in both directions?
The answer
The specification says yes, and says how. mcpx at 05c78b2 does not yet.
The specification allows exactly this. A dual-era server “MAY serve both
eras concurrently on the same endpoint or process”
(2026-07-28/basic/versioning.mdx:182), and it chooses per request: “A request
carrying modern per-request _meta is served statelessly according to this
revision. An initialize request selects legacy semantics, scoped to the
stdio process (stdio) or the session (HTTP)”
(2026-07-28/basic/versioning.mdx:176-180). A legacy session is therefore
compatible with statelessness. What breaks statelessness is requiring state
from a modern request, not having state somewhere.
“Stateless” is a property of the protocol surface, not of the process. A
2026-07-28 server may keep caches, durable tasks, open subscription streams
and application handles. What it may not do is infer anything from the
connection. It must not rely on earlier requests for version, capabilities or
identity (2026-07-28/basic/index.mdx:191-193), should not require related
operations to use the same connection (:196-197), must reference
cross-request state “by an explicit identifier the client passes on each
request” (:200-202), and “must not treat connection or process identity as
a proxy for conversation or session continuity” (:204-208).
mcpx at 05c78b2 meets the dual-era shape. It serves initialize and
per-request _meta on one endpoint, and it reads _meta before the
connection’s handshake (internal/mcpserver/conn.go:133-146). It fails on
details that decide interoperability:
- It cannot talk to a conformant 2026 peer in either direction.
server/discoveranswersprotocolVersionsand the client readsprotocolVersions; the schema field issupportedVersions(internal/mcpserver/server.go:534,internal/mcpclient/client.go:284,schema/2026-07-28/schema.ts:683). mcpx talks to itself because both halves are wrong the same way. - Its modern
input_requiredpath needs the session 2026 removed. ArequestStateis bound toMcp-Session-Id(internal/mcpserver/state.go:47-51,internal/mcpserver/server.go:1208-1230). A conformant sessionless 2026 client that declareselicitationgets-32603 "tools/call was still asking for input after 8 rounds"within milliseconds, and the upstream call is abandoned (internal/mcpserver/ask.go:195-209; reproduced on the wire, see the M1 transcript). - It mints
Mcp-Session-Idfor modern clients onserver/discover(internal/mcpserver/server.go:1214-1229). 2026-07-28 does not define the header, and tells a server that supports only that revision to “ignore it, and do not mint or echo session IDs” (2026-07-28/basic/transports/streamable-http.mdx:680-686). A dual-era server is not literally bound by that sentence. But a modern request is to be “served statelessly according to this revision”, and the harm is not the header, which a conformant client ignores. The harm is that mcpx then depends on the client echoing it (item 2). - Its pool treats process identity as session continuity. A host that
reaches mcpx over MCP is keyed as
mcp-<pid>of the serving process (internal/cli/serve.go:292-297). Over the daemon’s/mcpthat pid is the daemon’s, so every HTTP host shares one key. The comment says the host has “no way to tell us” (internal/cli/serve.go:289-290). opencode v2 does tell us: it sends_meta["ai.opencode/sessionID"]on everytools/call(v2:packages/core/src/mcp/client.ts:278), and mcpx discards it.
None of these is structural. Each has a known fix, and none needs mcpx to drop legacy support. §5 is the list.
1. What “stateless” requires of a 2026-07-28 server
The checklist, with mcpx’s status at 05c78b2. A server is stateless in the
2026-07-28 sense if all of these hold. “Dual-era” in the status column means
the item applies to modern requests only, and mcpx handles legacy ones as the
revision they negotiated.
| # | requirement | source | mcpx @ 05c78b2 |
|---|---|---|---|
| 1 | Every request carries _meta["io.modelcontextprotocol/protocolVersion"] and …/clientCapabilities; a request missing either is rejected with -32602 (HTTP 400) | 2026-07-28/basic/index.mdx:380-381 | partial: a request with a version and no capabilities is served as if it declared nothing (internal/mcpserver/conn.go:150-165). A request with neither is served as 2025-03-26, which is the dual-era reading (internal/mcpserver/conn.go:137-146) |
| 2 | Version decided per request; an unsupported one gets -32022 with data.supported and data.requested | 2026-07-28/basic/versioning.mdx:48-67 | partial: -32022 is sent, but requested is "" for a modern request and the HTTP status is 200, where 2026 says 400 (internal/mcpserver/server.go:469-471; W13) |
| 3 | Nothing inferred from earlier requests on the same connection | 2026-07-28/basic/index.mdx:191-193 | ✓ for modern requests: _meta wins over the connection (internal/mcpserver/conn.go:133-136) |
| 4 | A capability the request did not declare is never relied on; if one is required, -32021 | 2026-07-28/basic/index.mdx:387-392 | partial: send-conservatively holds, but -32021 is never used (rg -- -32021 internal finds nothing) |
| 5 | Related operations need not share a connection or process | 2026-07-28/basic/index.mdx:196-197 | ✗: a requestState retry must carry the same Mcp-Session-Id or it is refused “this requestState belongs to another session” (M4) |
| 6 | Cross-request state is named by an explicit identifier the client sends | 2026-07-28/basic/index.mdx:200-202 | partial: task ids ✓; requestState ✓, but it also needs the session header (item 5) |
| 7 | No protocol session: Mcp-Session-Id is neither minted nor required; GET has no stream (the literal SHOULD is for a server supporting only 2026-07-28) | 2026-07-28/basic/transports/streamable-http.mdx:680-686 | ✗ minted on server/discover (item 3); ✓ GET answers 405 (internal/mcpserver/server.go:1124-1131) |
| 8 | tools/list, prompts/list, resources/list do not vary per connection | 2026-07-28/server/tools.mdx:65-68 | ✓: the MCP surface is built once per daemon (internal/cli/serve_ask.go:234-254) |
| 9 | The server never sends a JSON-RPC request; it asks through input_required, and only on tools/call, resources/read and prompts/get | 2026-07-28/basic/patterns/mrtr.mdx:182-192 | ✓ for modern peers (internal/mcpserver/ask.go:201-209, internal/mcpserver/ask.go:320-327) |
| 10 | requestState is treated as attacker-controlled and integrity-protected. SHOULD bind the principal, a short expiry, and the method plus a digest of the parameters | 2026-07-28/basic/patterns/mrtr.mdx:232-243 | partial: HMAC-SHA256 with an expiry ✓. It binds the session, not a principal, and not the method or its arguments. A state was accepted on a different tool call on the same session (observed) |
| 11 | Request-scoped notifications go only on that request’s stream, and log messages only if the request set logLevel | 2026-07-28/server/utilities/logging.mdx:63 | ✓ by omission: mcpx forwards neither progress nor log messages to hosts |
| 12 | The only long-lived construct is subscriptions/listen, whose state belongs to that request | 2026-07-28/basic/index.mdx:211-214 | ✗ over HTTP: subscriptions/listen gets 202 with an empty body (W17). Partial on stdio: the acknowledgement has the wrong shape |
| 13 | A closed response stream means cancellation; no resumption | 2026-07-28/basic/transports/streamable-http.mdx:233-237 | ✓ by context propagation (inferred from code, not driven), and mcpx has no resumption |
| 14 | server/discover answers supportedVersions, capabilities, optional instructions, ttlMs, cacheScope | schema/2026-07-28/schema.ts:678-697 | ✗: protocolVersions, a top-level serverInfo, no ttlMs or cacheScope (W5) |
| 15 | Every result has resultType; list, read and discover results have ttlMs and cacheScope; results SHOULD carry _meta["io.modelcontextprotocol/serverInfo"] | 2026-07-28/server/utilities/caching.mdx:13-16 | partial: resultType ✓ (wrong on the task handle); ttlMs, cacheScope and serverInfo ✗ |
| 16 | On HTTP, MCP-Protocol-Version, Mcp-Method and (for three methods) Mcp-Name are required and checked against the body; a mismatch is -32020 with HTTP 400 | 2026-07-28/basic/transports/streamable-http.mdx:597-603 | ✗: Mcp-Method and Mcp-Name are never read (W11). A version mismatch gets 400 with a body that is not a JSON-RPC error (W10) |
What remains legitimately stateful in a conformant 2026 server: open listen
streams, durable tasks (extension), client-held requestState, application
handles, per-request auth context, process caches that no connection can
observe, and legacy sessions for clients that opened with initialize.
2. Where mcpx keeps state
Every place mcpx remembers something across requests, verified at
05c78b2 (paths relative to pkgs/mcpx). The last two columns answer the
question for 2026-07-28.
| # | item | where | keyed by | lifetime | compatible with 2026 statelessness? | could it move into the request? |
|---|---|---|---|---|---|---|
| 1 | pool instances (upstream processes and sessions) | internal/pool/pool.go:57-72 | server × scope key (global, repo, worktree, cwd, session, parent-session, pid, call) × sharing | until idle with no holders, a Min floor, Max with LRU eviction, restart or reload | yes: upstream-side and invisible to hosts. The key is the problem (row 13) | the key can: from _meta["ai.opencode/sessionID"], or a server-minted handle passed as a tool argument, which is the 2026 pattern (2026-07-28/changelog.mdx:12) |
| 2 | instance ↔ scope-key back-reference, for attributing questions | internal/pool/request.go:18-33 | instance | instance | yes (internal) | n/a |
| 3 | upstream schema cache (tools, resources, prompts, instructions) | internal/pool/pool.go:121-128 | server | until list_changed, refresh or reload; persisted and reseeded | yes | n/a. It could honour an upstream ttlMs |
| 4 | upstream client state (era, negotiated version, capabilities) | internal/mcpclient/client.go:69-100 | instance | instance | yes: the spec tells clients to cache the era per server (2026-07-28/basic/versioning.mdx:148-152) | n/a |
| 5 | upstream HTTP session id | internal/mcpclient/http.go:29-30 | transport | transport; DELETE on close | legacy upstreams only; it should not be sent to a modern one | n/a |
| 6 | Streamable HTTP sessions (Mcp-Session-Id → connection) | internal/mcpserver/server.go:1206-1230 | sess-<32 hex> | idle proto.sessionIdle (30m), reaped only when another session is issued; DELETE | legacy only. Minting one for a modern client (server/discover) is the violation | for legacy, no: a legacy client’s answer to elicitation/create arrives on a separate POST, and the session is how it finds the waiting request. For modern, its only job is binding requestState, which can bind to something in the request instead (row 9) |
| 7 | the connection’s handshake state (capabilities, version) | internal/mcpserver/conn.go:27-34 | connection | connection | n/a for modern (_meta first) | modern: already in the request; legacy: inherent |
| 8 | pending server→client requests (legacy elicitation and sampling) | internal/mcpserver/conn.go:52-53 | negative integer id | until answered or timed out | legacy only | n/a |
| 9 | requestState signing key and tokens | internal/mcpserver/state.go:25-51 | key: per process, never persisted; token: {c: call id, b: session binding, e: expiry} | key: process; token: proto.stateTTL (30m) | partly: this is the 2026 mechanism; binding it to the session is what makes it depend on Mcp-Session-Id | yes: bind the principal (or nothing; the HMAC already authenticates the token) plus a method and argument digest. The call id still names daemon-side state (row 10) |
| 10 | interruptible calls: the upstream call keeps running as a daemon task while the client is away answering | internal/daemon/routes_proto.go:53-65 | call id (= task id); (server, scope key) for attribution | the call; its result kept proto.askTTL (15m) | yes, as a handle. The spec permits server-side state behind requestState and requires it for single-use (2026-07-28/basic/patterns/mrtr.mdx:238-243) | no: a live upstream call cannot be serialised into a token |
| 11 | ask-loop bounds | internal/mcpserver/timing.go:22-34 | request | proto.askTimeout 10m, proto.askRounds 8, proto.askPoll 500ms | n/a (per request) | n/a |
| 12 | elicitation broker rows | internal/elicit/elicit.go:87-136 | question id | elicit.ttl (120s) per question; rows persist in SQLite | yes (mcpx-internal, not protocol) | n/a |
| 13 | the MCP session key for pool scope | internal/cli/serve.go:287-297 | $MCPX_SESSION_ID, else mcp-<pid> of the serving process | process | no: it is process identity used as session continuity, which 2026-07-28/basic/index.mdx:204-208 rules out; over /mcp every HTTP host shares the daemon’s pid | yes: from request _meta or a tool argument |
| 14 | MCP task store | internal/mcpserver/tasks.go:31-38 | tsk-<16 hex> | per-task TTL (client ttl or 10m) | yes: server-minted handles are the extension’s model (but the shape is 2025-11-25’s, and tasks are not bound to their requestor) | already a handle |
| 15 | the daemon’s /v1 task store, a second store | internal/daemon/ops.go:428-429 | tsk-… | same TTL rules | yes | should be the same store as row 14 |
| 16 | subscription state (subs per URI, the listen filter) | internal/mcpserver/conn.go:54-55 | connection | until replaced, DELETE or process exit | listen: 2026’s own mechanism; per-URI subs: legacy | listen filter: already in the request |
| 17 | recorded inbound cancellations | internal/mcpserver/conn.go:56 | request id | connection; unbounded on stdio | n/a | nothing reads it |
| 18 | pagination cursor | internal/mcpserver/server.go:1477-1513 | none: the cursor encodes the offset | none | yes: already stateless | already in the request |
| 19 | the MCP surface (lazyMCP) | internal/cli/serve_ask.go:234-254 | daemon | daemon (never rebuilt) | yes | n/a |
| 20 | event-bus subscriptions for subscriptions/listen | internal/cli/serve.go:726-764 | listen | listen | yes | n/a |
| 21 | the default stdio connection | internal/mcpserver/server.go:148-151 | process | process | legacy-inherent: stdio is one connection by nature | n/a |
Two observations the table makes:
- Almost all of mcpx’s state is on the upstream side of the proxy. Pools, schema caches, upstream sessions and the broker are invisible to a host. 2026 says nothing about them and does not need to. A proxy that keeps a browser warm between calls keeps state, and statelessness never forbade that.
- The state that faces the host is small and fixable. It is the HTTP
session (legacy-only by right), the
requestStatebinding, the MCP session key, and two task stores. Each has a request-scoped replacement that the specification already names.
The one thing that cannot become request-scoped is row 10, the live upstream
call behind an input_required. Nor should it. mcpx is a single local process,
not a fleet behind a load balancer, and the pattern’s motivation (“without
requiring a shared storage layer across server instances”,
2026-07-28/basic/patterns/mrtr.mdx:30-31) does not apply to it. The
requirement that does apply is that the handle is explicit, bound and
expiring, and mcpx’s requestState already is.
3. Every revision, both directions
What happens today when each era meets mcpx. Server columns: a host of that revision connecting to mcpx. Client columns: mcpx connecting to an upstream server of that revision. The per-feature detail is in matrix-mcpx.md.
| peer revision | mcpx as server | mcpx as client |
|---|---|---|
| 2024-11-05 | ✗ initialize is refused with -32022 (W1, S1). Every legacy revision says a server MUST counter-offer a version it supports (2025-03-26/basic/lifecycle.mdx:132) | partial, by accident. mcpx offers 2025-11-25; a 2024-11-05 server counter-offers 2024-11-05 and mcpx accepts whatever comes back (internal/mcpclient/client.go:261-275). There is no HTTP+SSE transport, so only stdio and a Streamable-HTTP-capable server work |
| 2025-03-26 | ✓ with gaps: JSON-RPC batches, which that revision says servers MUST receive, are rejected -32700 (2025-03-26/basic/index.mdx:97; W16); prompt title leaks to it | ✓ |
| 2025-06-18 | ✓ with gaps: mcpx_call drops isError and flattens results to text for every revision (W20, W21) | ✓ |
| 2025-11-25 | ✓ with gaps: sent capabilities.extensions, a 2026-only field | ✓ (notifications/tasks/status dropped) |
| 2026-07-28 | partial: works for mcpx’s own client and for a client that keeps the Mcp-Session-Id from server/discover. A conformant client fails at server/discover (field names) and at input_required (session binding) | ✗ against a conformant modern-only server: discovery reads the wrong field; the legacy-first probe gives up when initialize is answered -32022 (internal/mcpclient/client.go:246); Mcp-Method/Mcp-Name are never sent, so a conformant HTTP server answers 400 |
“Every older one” also needs a floor. The oldest published revision is
2024-11-05, and mcpx’s is 2025-03-26
(internal/mcpserver/revisions.go:14). Serving 2024-11-05 is cheap to add, and
it is the only revision mcpx refuses outright. It means a counter-offer in
negotiate, plus one floors row for each thing 2025-03-26 added (audio,
tool annotations, the completions capability:
2025-03-26/changelog.mdx:18, :25-27). With those rows the downgrade() audio branch,
dead today, starts to matter.
4. The conflict at the centre
Statelessness and “accept liberally” pull in opposite directions in one place:
a request that carries neither _meta nor a session. 2026-07-28 says
such a request is malformed (2026-07-28/basic/index.mdx:380-381). The
legacy HTTP rules say a server that receives no MCP-Protocol-Version “SHOULD
assume protocol version 2025-03-26” (2025-06-18/basic/transports.mdx:250-254),
and a legacy client may legitimately never send a session header when the
server issued none. mcpx takes the legacy reading and serves the request as
2025-03-26 (internal/mcpserver/conn.go:137-146).
For a dual-era server that is the right call. Rejecting it would break every legacy client that ignores sessions, and nothing in 2026-07-28 tells a dual-era server to prefer the modern reading. The specification never states it, and this page records it so nobody “fixes” it into a regression. See conflicts.md.
5. What it would take
Ordered by how much interoperability each item buys. The register row for each is in the linked area file. The PROTOCOL work in progress at the time of writing (a modern-first probe and conformance fixes) is expected to close some of these; the status column in each register file is where that shows.
| # | change | area | issue | effort |
|---|---|---|---|---|
| 1 | supportedVersions on both sides; serverInfo into result _meta; ttlMs and cacheScope on discover, list and read results | lifecycle, pagination-caching | #199, #200 | S |
| 2 | Bind requestState to the request, not the session: the method, an argument digest, the principal if there is one, and the expiry. Stop minting Mcp-Session-Id for modern clients. Fix the no-binding busy loop so a question the client cannot receive goes back to the broker, as the comment says it does | mrtr, transports | #201 | M |
| 3 | Client: treat -32022 from initialize as a modern server and retry with server/discover; send Mcp-Method, Mcp-Name and Mcp-Param-* | lifecycle, transports | #200 | S–M |
| 4 | Server: check Mcp-Method and Mcp-Name; answer header mismatches with -32020 and HTTP 400; map -32601 → 404 and -32022/-32021/-32602 → 400 for modern requests | transports, errors | #199 | S |
| 5 | Take the pool’s session key from _meta["ai.opencode/sessionID"] (or a namespaced equivalent) when present, instead of mcp-<pid> | process-model, meta | #211 | S |
| 6 | Validate Origin on /mcp and /v1. It has been a MUST since 2024-11-05, and it is the one gap here with a working attack: DNS rebinding from a web page to a loopback port that runs scripts | transports | #204 | S |
| 7 | subscriptions/listen over HTTP as an SSE response stream; the acknowledgement as notifications/subscriptions/acknowledged with _meta subscription ids | notifications | #208 | M |
| 8 | The tasks extension for 2026 clients (tasks/update, resultType: "task", ttlMs, no tasks/list/tasks/result), and no extensions or core tasks capability to a revision that does not define it | tasks, capabilities | #209 | M–L |
| 9 | Counter-offer on initialize (2024-11-05 and unknown versions) and add the 2024-11-05 floors | lifecycle | #202 | S |
Nothing on this list conflicts with keeping every legacy revision. Items 1–8 touch only the modern path or add checks the legacy path already skips.
Evidence
The wire observations (W*, M*, S*) come from a session recorded against a
binary built from 05c78b2 with an isolated configuration, state and socket,
driving the repository’s own test servers (internal/testsupport/fakemcp,
internal/testsupport/askmcp). W1, W5, W12, W15, W16, W17, W18, W20, W21, M1
and M2 were reproduced independently for this page. The requests, verbatim:
- W5
server/discoverwith 2026_meta→ HTTP 200, aMcp-Session-Idheader, and{"capabilities":{…,"extensions":{"io.modelcontextprotocol/tasks":{}},…,"tasks":{…}},"instructions":…,"protocolVersions":[…],"serverInfo":{…}}. - M1
tools/callmcpx_call→ask.need_repowith 2026_metadeclaringelicitation, no session →{"error":{"code":-32603,"message":"tools/call was still asking for input after 8 rounds"}}in about 40 ms. - M2 the same after taking
Mcp-Session-Idfromserver/discover→{"resultType":"input_required","inputRequests":{"elc-…":{"method":"elicitation/create",…}},"requestState":"<base64>.<hmac>"}. The base64 part decodes to{"c":"tsk-…","b":"sess-…","e":<unix expiry>}. - M4 the M3 retry without the session header →
{"error":{"code":-32602,"message":"this requestState belongs to another session"}}. - W17
subscriptions/listenover HTTP →202 Accepted,Content-Length: 0. - W13 a request whose
_metaversion is9999-01-01→ HTTP 200,-32022,"requested":"".
What each says a server should be
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare, area:protocol
description: the character of an MCP server as each revision and each
product imagines it, in a paragraph each, and what mcpx has
to be to satisfy all of them at once.
This page is the philosophy, one short account per revision and per product.
The exhaustive list of what a server MUST and SHOULD do, revision by revision,
belongs to the protocol work (docs/spec/server-obligations.md, which that work
is writing; it is not on main at 05c78b2). The feature-by-feature table is
matrix-revisions.md.
The revisions
2024-11-05: a stateful peer over a session. “MCP provides a stateful
session protocol” (2024-11-05/architecture/index.mdx:8). One client and one
server open a session with initialize, and version and capabilities are
fixed for its life. Either side may send the other a request at any time: the
server samples and lists roots, both ping. Transports are stdio or a
two-endpoint HTTP+SSE with a persistent event stream. There is no
authorization in the core. The server is a long-lived companion process that
remembers its client.
2025-03-26: the same session, made deployable on the web. Streamable HTTP
puts everything on one endpoint, adds optional Mcp-Session-Id sessions and
resumable SSE, and treats a dropped connection as not a cancellation
(2025-03-26/basic/transports.mdx:120-122). OAuth 2.1 arrives with the server
as its own authorization server, along with batching (for one revision), audio,
tool annotations, and a completions capability (2025-03-26/changelog.mdx:9-27).
The server is still a session. It is now also something you can put behind a
URL.
2025-06-18: a session that is an OAuth resource server. Batching goes. Every
HTTP request after initialization carries MCP-Protocol-Version. The server
discovers its authorization server through RFC 9728 and validates token
audiences (RFC 8707). Respecting negotiated capabilities becomes a MUST
(2025-06-18/changelog.mdx:12-26). Structured tool output, resource links,
title, formal _meta rules and elicitation all arrive. The server can now
ask the user something, and it describes its output with a schema.
2025-11-25: the most elaborate stateful server. Sessions, resumable and
now pollable SSE (the server may drop a stream and let the client reconnect),
experimental durable tasks with their own cancel and progress rules, URL-mode
elicitation with its own error code, OIDC discovery, Client ID Metadata
Documents, step-up scopes and icons (2025-11-25/changelog.mdx:9-40). The
server is a long-running service with jobs, still one session per connection.
2026-07-28: a stateless request/response service. “MCP is a stateless
protocol: every request is self-contained and carries its own protocol version
and capabilities” (2026-07-28/architecture/index.mdx:8-9). No handshake, no
session, no GET stream, no resumption, no ping, no server-initiated request.
The server asks by returning input_required and waiting for the client to
retry. Lists must not vary per connection. Results say how long they may be
cached, and a single opted-in subscriptions/listen stream is the only
long-lived construct (2026-07-28/changelog.mdx:12-34). Roots, sampling and
logging are deprecated. The server is a function of the request, and anything
that spans requests is a handle the client carries. A server may still serve
legacy clients alongside, as a dual-era server
(2026-07-28/basic/versioning.mdx:128-130).
The arc across the five: from a companion process that remembers its client to a service that remembers nothing it was not handed. mcpx has to sit at both ends of that arc at once.
The products
opencode v1 (1.18.31) consumes a tools-first, text-first server over one
long-lived connection per server, per directory, per opencode process. It
connects every configured server when MCP is first touched and waits for all
of them (v1:packages/opencode/src/mcp/index.ts:505). It never restarts one
that dies (:448), and it gives every session in that directory the same
client with nothing identifying the caller
(v1:packages/opencode/src/mcp/catalog.ts:54). It declares only roots
(v1:packages/opencode/src/mcp/index.ts:46), so a server must not need
elicitation, sampling or tasks, and it speaks 2025-11-25 at most. Text, images
and embedded resources reach the model; audio and resource_link do not
(v1:packages/opencode/src/session/tools.ts:429). Server instructions and a
declared resources capability cost context on every request
(v1:packages/opencode/src/session/system.ts:129,
v1:packages/opencode/src/session/tools.ts:136). With OPENCODE_EXPERIMENTAL
set, as it is on this machine, the tools move behind code mode’s execute
(v1:packages/opencode/src/session/tools.ts:388).
opencode v2 (2.0.3) consumes servers as libraries for its own code mode.
It holds one connection per server per Location, lives as long as the
background daemon, and shares that connection with every session in every
attached window (v2:packages/core/src/mcp/index.ts:152,
v2:packages/core/src/location-services.ts:52). It expects typed tools with
names matching ^[A-Za-z0-9_-]{1,64}$ (v2:packages/core/src/tool.ts:297). It
prefers results as structuredContent or JSON text, which it turns into program
values (v2:packages/core/src/tool/mcp.ts:96). It answers form and URL
elicitations, attributed to the Location rather than the session
(v2:packages/core/src/mcp/index.ts:80). It does not ask the server for
isolation; it tells the server which session is calling, in
_meta["ai.opencode/sessionID"], and leaves partitioning to the server
(v2:packages/core/src/mcp/client.ts:278). It speaks 2025-11-25 by default and
2026-07-28 when configured (v2:packages/schema/src/mcp.ts:23), allows 12-hour
calls (v2:packages/core/src/mcp/client.ts:34), and expects a server that is
itself code mode to be configured codemode: false
(v2:packages/core/src/plugin/mcp-codemode-exclusion.ts:16).
lootbox treats a server as a long-lived process it connects to once, at
daemon start, and shares among all callers, pinging it to decide whether it is
alive (.lootbox/src/lib/rpc/managers/mcp_integration_manager.ts:221-222). It
declares capabilities: {}, so a server that elicits, samples or asks for
roots is refused (.lootbox/src/lib/external-mcps/mcp_client_manager.ts:150-153).
It assumes one page of tools and one page of resources, and names that reduce
harmlessly to [A-Za-z0-9_]
(.lootbox/src/lib/external-mcps/mcp_schema_fetcher.ts:69). It assumes a
server is stateless enough to share. Its one concession to stateful servers is
rewriting a port it finds in their command line
(.lootbox/src/lib/rpc/managers/mcp_integration_manager.ts:82-133). Results
reach the script as raw envelopes.
Cloudflare code mode starts from “Almost every MCP server is just a wrapper
around an existing traditional API” (https://blog.cloudflare.com/code-mode/,
“But MCP is still useful, because it is uniform”). A server is a remote,
OAuth-authorised HTTP endpoint that an Agent connects to once. The Agents MCP
client offers auto, streamable-http and sse, and no stdio
(https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/,
“Transport options”). The server enforces its own authorization. It describes
its tools well enough to generate typed methods, with names unique after
sanitisation or the connector throws. A large API should itself be a code-mode
server with two tools, search and execute, at about 1,000 tokens
(https://blog.cloudflare.com/code-mode-mcp/, “Server-side Code Mode”).
Side-effecting tools should be safe to pause, replay in order and, ideally,
revert (https://developers.cloudflare.com/agents/tools/codemode/how-it-works/,
“Deterministic replay”, “Rollback”).
mcpx (as a server) describes itself as a server that “runs MCP servers and
exposes them through a few tools rather than many”
(internal/mcpserver/server.go:397): a stable, small surface (mcpx_call,
mcpx_exec, discovery tools) whose real work happens next to pooled upstream
processes. Its rules for itself are in ../protocol.md:
speak every revision, accept liberally, send conservatively, carry questions
across the era boundary.
What mcpx has to be to satisfy all of them
Reading the accounts side by side gives mcpx’s job:
- To 2026-07-28 and to opencode v2, a function of the request. Version,
capabilities, and now identity: v2 hands over the session in
_meta, and 2026 forbids inferring it from the connection. Anything spanning requests is a handle. - To the legacy revisions and to opencode v1, a patient session. It holds
the
initializeagreement, sends questions on the wire, and never replies to a notification. - To lootbox-style and Cloudflare-style callers, a catalogue. Typed names that sanitise safely and do not collide, with results a program can use (structured when possible).
- To every upstream, the host they cannot see. It answers
ping, roots, elicitation and sampling on the host’s behalf, or carries them to the host when the host can answer.
The places where mcpx does not yet meet one of these are the register’s
mcpx missing rows (README).
Authorization
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: Every authorization mechanism per MCP revision, what mcpx, opencode, lootbox and Cloudflare do with it.
2024-11-05 has no authorization; 2025-03-26 adds an optional OAuth 2.1 profile for HTTP, and every revision since
reshapes it (resource server and RFC 9728/8707 in 2025-06-18; OIDC discovery, Client ID Metadata Documents and step-up
scopes in 2025-11-25; RFC 9207 iss, application_type, issuer-keyed credentials and deprecated DCR in 2026-07-28).
For mcpx two facts dominate: as a client it performs none of the OAuth flow and does not even apply its own static
auth: block, so the only credential that reaches an upstream is a raw headers: map or an environment variable; as a
server it authenticates nothing, on /mcp or on the /v1 endpoint that runs scripts.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| AUTH-01 | No authorization in the core protocol | 2025-03-26 has | 24-11 ✓ · 25-03 — · 25-06 — · 25-11 — · 26-07 — | partial — headers:/env: work; auth: block inert | + low | S | low |
| AUTH-02 | OAuth 2.1 profile for HTTP; stdio takes environment credentials | 2025-03-26 has impl deferred opencode v1 has opencode v2 has | 24-11 — · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ · opencode v1 ✓ · v2 ✓ | ✗ — config shape only; flow deferred | + high | L | high |
| AUTH-03 | Access token as Authorization: Bearer, never in the query | 2025-03-26 has | 24-11 — · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | partial — only via raw headers:; auth.bearer inert | + med | S | med |
| AUTH-04 | PKCE required; refuse AS without code_challenge_methods_supported | 2025-03-26 has 2025-11-25 has impl deferred opencode v1 has opencode v2 has | 25-03 ✓ PKCE · 25-06 ✓ · 25-11 ✓ + check · 26-07 ✓ | ✗ — deferred with the flow | + med | S | low |
| AUTH-05 | AS metadata at MCP base URL; default-endpoint fallback | 2025-03-26 has 2025-06-18 removes has better replacement impl deferred | 25-03 ✓ · 25-06 — · 25-11 — · 26-07 — | ✗ — deferred | + low | S | low |
| AUTH-06 | MCP server is a resource server; RFC 9728 metadata | 2025-06-18 has impl deferred | 25-03 — · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✗ — client discovery deferred; server has no auth | + high | M | med |
| AUTH-07 | Finding PRM: WWW-Authenticate header, then well-known URIs | 2025-06-18 has 2025-11-25 has impl deferred | 25-06 header MUST · 25-11 header or well-known · 26-07 same | ✗ — deferred | + high | S | low |
| AUTH-08 | RFC 8707 resource parameter on authorization and token requests | 2025-06-18 has impl deferred opencode v2 has | 25-06 ✓ · 25-11 ✓ · 26-07 ✓ · opencode v2 ✓ | ✗ — OAuth.Resource field exists, unused | + med | S | med |
| AUTH-09 | Token audience validation; no token passthrough | 2025-06-18 has | 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | n/a — /mcp accepts no tokens to forward | + med | S | high |
| AUTH-10 | OIDC Discovery and ordered well-known probing | 2025-11-25 has impl deferred | 25-11 ✓ · 26-07 ✓ | ✗ — deferred | + med | S | low |
| AUTH-11 | Dynamic Client Registration: SHOULD, then MAY, then deprecated | 2025-03-26 has 2026-07-28 deprecates has better replacement impl deferred opencode v1 has | 25-03 SHOULD · 25-06 SHOULD · 25-11 MAY · 26-07 dep · opencode v1 ✓ | ✗ — no DCR; config prefers CIMD | + low | M | low |
| AUTH-12 | application_type required in DCR | 2026-07-28 has impl deferred | 26-07 ✓ | ✗ — deferred | + low | S | low |
| AUTH-13 | Client ID Metadata Documents as client_id | 2025-11-25 has 2026-07-28 has impl deferred opencode v2 has | 25-11 ✓ · 26-07 SHOULD · opencode v2 ✓ | ✗ — clientId comment prefers it; unused | + high | M | low |
| AUTH-14 | Scope selection and step-up on insufficient_scope | 2025-11-25 has impl deferred | 25-11 ✓ · 26-07 ✓ (+ union SHOULDs) | ✗ — scopes field exists, unused | + med | M | low |
| AUTH-15 | RFC 9207 iss check on the authorization response | 2026-07-28 has impl deferred | 26-07 ✓ | ✗ — deferred | + med | S | low |
| AUTH-16 | Fetched metadata issuer must equal the issuer used | 2026-07-28 has impl deferred | 26-07 ✓ | ✗ — deferred | + med | S | low |
| AUTH-17 | Credentials keyed by issuer; re-register on AS change | 2026-07-28 has impl deferred opencode v1 has opencode v2 has | 26-07 ✓ · opencode v1 by name · v2 by name+url | ✗ — no token store | + med | S | med |
| AUTH-18 | Refresh-token handling rules | 2026-07-28 has impl deferred opencode v1 has | 26-07 ✓ · opencode v1 lists refresh_token grant | ✗ — deferred | + med | S | low |
| AUTH-19 | oauth-client-credentials extension (machine-to-machine) | 2026-07-28 has | 26-07 extension | ✗ — not declared or performed | + med | M | low |
| AUTH-20 | enterprise-managed-authorization extension (IdP, ID-JAG) | 2026-07-28 has does not match mcpx's goal | 26-07 extension | n/a — out of scope for a local tool | − scope | L | low |
| AUTH-21 | OAuth redirect listener: fixed port vs ephemeral | opencode v1 has opencode v2 has impl deferred | opencode v1 127.0.0.1:19876 · v2 configured or ephemeral | ✗ — redirect field only | + med | S | low |
| AUTH-22 | Per-server auth: block parsed and never applied | mcpx missing | mcpx only | ✗ broken — Auth.Resolve has no caller (#205) | + high | S | high |
| AUTH-23 | /mcp and /v1 authenticate nobody | 2024-11-05 has mcpx missing | 24-11 SHOULD · 25-03 SHOULD · 25-06 SHOULD · 25-11 SHOULD · 26-07 SHOULD | ✗ — loopback plus socket permissions only (#204) | + high | M | high |
| AUTH-24 | Code-execution endpoints accept cross-origin simple requests | mcpx missing | mcpx /v1/exec · lootbox /ws | ✗ — no Origin or Content-Type check (#204) | + high | S | high |
| AUTH-25 | Scripts see credentials: bindings vs inherited environment | does not match mcpx's goal | Cloudflare bindings · opencode none · lootbox unreadable · mcpx full env | n/a — scripts inherit full os.Environ() by design | − design | S | med |
AUTH-01 No authorization in the core protocol
- What. 2024-11-05 says authentication and authorization are not part of the core specification; the parties may negotiate their own.
- Where. 2024-11-05 only. Every later revision has the OAuth profile (AUTH-02).
- mcpx @ 05c78b2. mcpx does not serve 2024-11-05, but as a client its only working credentials are exactly this
“negotiate your own” route: a static
headers:map for HTTP (internal/pool/pool.go:379) and anenv:map plus the daemon’s inherited environment for stdio (internal/pool/pool.go:372). The richerauth:declaration (bearer/basic/header/query/env/oauth,internal/mcpauth/mcpauth.go:29-57) is never applied (AUTH-22). - Value to mcpx. + low: static credentials are what most servers need.
- Effort. S — nothing new; fix AUTH-22.
- Risk. None if not done.
- Detail. “Custom” authentication survives into 2026-07-28: clients and servers MAY still negotiate their own
(
2026-07-28/basic/index.mdx:227), so a static header is conformant in every revision, not a legacy crutch. - Sources.
2024-11-05/basic/index.mdx:75“Authentication and authorization are not currently part of the core MCP specification,”;internal/mcpauth/mcpauth.go:29-57;internal/pool/pool.go:372“Env: p.cfg.Env,”
AUTH-02 OAuth 2.1 profile for HTTP; stdio takes environment credentials
- What. Authorization is optional. When implemented, HTTP transports SHOULD follow the OAuth 2.1 profile; stdio transports SHOULD NOT and take credentials from the environment instead.
- Where. 2025-03-26 through 2026-07-28, framing unchanged (https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization). opencode v1 (PKCE + DCR) and v2 (adds CIMD, RFC 8707, ephemeral callback) both perform the client flow.
- mcpx @ 05c78b2. The OAuth configuration shape exists (
clientId,scopes,resource,redirect,internal/mcpauth/mcpauth.go:59-77); the flow does not. The type comment defers it knowingly (internal/mcpauth/mcpauth.go:62-64) anddocs/protocol.md§6 lists it as still missing (docs/protocol.md:378). The stdio half is served: stdio children getenv:withInheritEnv: true(internal/pool/pool.go:369-375). - Value to mcpx. + high: GitHub, Linear and most hosted servers are unreachable without it, except through a
wrapper such as
mcp-remote. - Effort. L — discovery, PKCE, a browser step, a callback listener, a token store, refresh.
- Risk. Not done: OAuth-protected servers cannot be pooled. Done: a daemon that holds tokens for many servers becomes a credential store and must key them correctly (AUTH-17).
- Detail. The comment claims a server requiring OAuth “reports that clearly rather than failing at the first
request with a 401”; nothing calls the code that would report it (AUTH-22), so today it is exactly a bare 401.
url-mode elicitation is the natural carrier for the browser step (a daemon has no browser of its own). The same
HTTP/stdio split is restated in 2026-07-28 base protocol (
2026-07-28/basic/index.mdx:225). - Sources.
2025-03-26/basic/authorization.mdx:15“Authorization is OPTIONAL for MCP implementations. When supported:”;2025-03-26/basic/authorization.mdx:18“- Implementations using an STDIO transport SHOULD NOT follow this specification, and”;internal/mcpauth/mcpauth.go:63“and is not yet implemented; a server requiring it reports that clearly”;docs/protocol.md:378“OAuth for remote servers. Declared and not performed, unchanged.”;v1:packages/opencode/src/mcp/oauth-provider.ts:11;v2:packages/core/src/mcp/oauth.ts:25
AUTH-03 Access token as Authorization: Bearer, never in the query
- What. The client MUST send the access token in the
Authorization: Bearerheader on every HTTP request, and MUST NOT put it in the URI query string. - Where. 2025-03-26 through 2026-07-28.
- mcpx @ 05c78b2. A token reaches an upstream only if the user writes the header by hand in
headers:, which the pool passes to the HTTP transport (internal/pool/pool.go:379).auth: {type: bearer}would build it, but is inert (AUTH-22). - Value to mcpx. + med: this is the step every protected server needs, OAuth or not.
- Effort. S — call
Auth.Resolveand merge its headers. - Risk. Not done: users who follow the documented
auth.bearershape send no token. - Detail. 2025-03-26 adds “even if they are part of the same logical session” to the every-request rule; 2026-07-28
drops the clause because sessions are gone. mcpx’s
authtypequeryexists to put a static credential in the URL (Resolved.Query); that is legitimate for API keys but would violate the query-string MUST if it ever carried an OAuth access token. - Sources.
2025-03-26/basic/authorization.mdx:270“Authorization: Bearer”; 2025-03-26/basic/authorization.mdx:276“2. Access tokens MUST NOT be included in the URI query string”;2026-07-28/basic/authorization/index.mdx:262“1. MCP client MUST use the Authorization request header field defined in”;internal/pool/pool.go:379“Headers: p.cfg.Headers,”
AUTH-04 PKCE required; refuse AS without code_challenge_methods_supported
- What. PKCE is required for every client from 2025-03-26. From 2025-11-25 the client MUST also check
code_challenge_methods_supportedin the AS or OIDC metadata and refuse to proceed if it is absent. - Where. PKCE 2025-03-26 onward; the metadata check 2025-11-25 and 2026-07-28. opencode v1 and v2 use PKCE (v1 “PKCE + DCR”; v2 adds CIMD on top).
- mcpx @ 05c78b2. Not implemented; part of the deferred flow (
internal/mcpauth/mcpauth.go:62-64). - Value to mcpx. + med: a guard of a few lines once the flow exists.
- Effort. S.
- Risk. None today.
- Detail. OIDC providers MUST include the field from 2025-11-25; an AS whose metadata omits it “does not support PKCE” by definition, so the refusal is not optional.
- Sources.
2025-03-26/basic/authorization.mdx:318“2. PKCE is REQUIRED for all clients”;2025-11-25/basic/authorization.mdx:605“Ifcode_challenge_methods_supportedis absent, the authorization server does not support PKCE and MCP clients MUST refuse to proceed.”;v1:packages/opencode/src/mcp/oauth-provider.ts:11
AUTH-05 AS metadata at MCP base URL; default-endpoint fallback
- What. In 2025-03-26 the MCP server is also the authorization server: the client derives the authorization base URL
by discarding the path of the MCP URL, reads RFC 8414 metadata there, and if none exists MUST fall back to
/authorize,/tokenand/register. - Where. 2025-03-26 only. 2025-06-18 separates the roles and replaces this with protected-resource metadata (AUTH-06).
- mcpx @ 05c78b2. Not implemented (deferred).
- Value to mcpx. + low: only 2025-03-26-era servers that never published metadata need it.
- Effort. S once the flow exists.
- Risk. None.
- Detail. 2025-03-26 also defines
MCP-Protocol-Versionhere, as a SHOULD during metadata discovery, although 2026-07-28 says revisions before 2025-06-18 did not define the header (the transports area records the timeline). - Sources.
2025-03-26/basic/authorization.mdx:143“The authorization base URL MUST be determined from the MCP server URL by discarding”;2025-03-26/basic/authorization.mdx:158“MUST use the following default endpoint paths relative to the [authorization base”;2025-03-26/basic/authorization.mdx:135-139
AUTH-06 MCP server is a resource server; RFC 9728 metadata
- What. From 2025-06-18 an MCP server is an OAuth resource server. It MUST publish RFC 9728 Protected Resource
Metadata naming its
authorization_servers, and clients MUST use that document to find the AS. - Where. 2025-06-18 through 2026-07-28.
- mcpx @ 05c78b2. As a client: deferred. As a server:
/mcphas no authorization at all (AUTH-23), so publishing no metadata is consistent. - Value to mcpx. + high: required to reach any modern protected server.
- Effort. M.
- Risk. None now.
- Detail. The role split is why AUTH-05’s path-stripping disappears: the AS may live on another host entirely.
- Sources.
2025-06-18/basic/authorization.mdx:61“1. MCP servers MUST implement OAuth 2.0 Protected Resource Metadata”;2025-06-18/changelog.mdx:16“Classify MCP servers as [OAuth Resource Servers]”
AUTH-07 Finding PRM: WWW-Authenticate header, then well-known URIs
- What. 2025-06-18: servers MUST send
WWW-Authenticatewithresource_metadataon a 401. 2025-11-25 (SEP-985) relaxes the server side to “header or/.well-known/oauth-protected-resource[/<path>]”, and makes clients support both: header first, then the path-inserted well-known URI, then the root one. - Where. 2025-06-18 (header only), 2025-11-25 and 2026-07-28 (either).
- mcpx @ 05c78b2. Deferred.
- Value to mcpx. + high: without the fallback order, servers that publish only the well-known document look unprotected until the first 401.
- Effort. S within the flow.
- Risk. None now.
- Detail. The same header later carries
scopeanderror="insufficient_scope"(AUTH-14). - Sources.
2025-06-18/basic/authorization.mdx:87“MCP servers MUST use the HTTP headerWWW-Authenticatewhen returning a 401 Unauthorized”;2025-11-25/basic/authorization.mdx:101“MCP clients MUST support both discovery mechanisms and use the resource metadata URL”
AUTH-08 RFC 8707 resource parameter on authorization and token requests
- What. Clients MUST send
resource=<canonical MCP server URI>in both the authorization and the token request, whether or not the AS understands it. - Where. 2025-06-18 through 2026-07-28. opencode v2 checks that the resource named in protected-resource metadata covers the configured server URL, and falls back to the configured URL when the server publishes none.
- mcpx @ 05c78b2. The config field exists and its comment states the rule (
internal/mcpauth/mcpauth.go:72-74); nothing sends it. - Value to mcpx. + med: mandatory once OAuth exists.
- Effort. S within the flow.
- Risk. Not done: tokens without audience binding.
- Detail. Canonical URI: lowercase scheme and host, no trailing slash unless significant. opencode v2’s comment
explains the fallback: the SDK sends no
resourcewhen the server publishes no metadata, and some ASes require one. v2 also keeps the configured URL (without the?codemode=falseit appends, see the plugin-APIs area) as the OAuth identity. - Sources.
2025-06-18/basic/authorization.mdx:194“MCP clients MUST implement Resource Indicators for OAuth 2.0 as defined in [RFC 8707]”;internal/mcpauth/mcpauth.go:72-74;v2:packages/core/src/mcp/oauth.ts:141-146“if (!checkResourceAllowed({ requestedResource: identity, configuredResource: resource }))”
AUTH-09 Token audience validation; no token passthrough
- What. A server MUST validate that a token was issued for it and MUST NOT accept or relay any other token; it must not pass the client’s token through to an upstream API.
- Where. 2025-06-18 through 2026-07-28.
- mcpx @ 05c78b2. n/a today:
/mcpaccepts no token (AUTH-23), so there is nothing to forward. - Value to mcpx. + med: it fixes a rule for later. mcpx is a proxy; if
/mcpever takes bearer tokens, the agent’s token must never become the upstream’sAuthorization. - Effort. S.
- Risk. High if violated: token passthrough is the confused-deputy case the rule exists for.
- Detail. mcpx’s upstream credentials come from its own config (
headers:, future OAuth), which is already the shape the rule requires. - Sources.
2025-06-18/basic/authorization.mdx:275“MCP servers MUST NOT accept or transit any other tokens.”
AUTH-10 OIDC Discovery and ordered well-known probing
- What. From 2025-11-25 an AS MUST offer RFC 8414 or OpenID Connect Discovery, and clients MUST support both. For an
issuer with a path they try
oauth-authorization-server/<path>,openid-configuration/<path>, then<path>/.well-known/openid-configuration; without a path, the two root forms. - Where. 2025-11-25 and 2026-07-28.
- mcpx @ 05c78b2. Deferred.
- Value to mcpx. + med: Google- and Entra-style issuers publish only OIDC documents.
- Effort. S.
- Risk. None now.
- Detail. 2026-07-28 adds an identity check on the fetched document (AUTH-16).
- Sources.
2025-11-25/basic/authorization.mdx:66“5. MCP authorization servers MUST provide at least one of the following discovery mechanisms:”;2025-11-25/basic/authorization.mdx:135“For issuer URLs with path components (e.g.,https://auth.example.com/tenant1), clients MUST try endpoints in the following priority order:”
AUTH-11 Dynamic Client Registration: SHOULD, then MAY, then deprecated
- What. RFC 7591 registration weakens each revision: SHOULD (2025-03-26, 2025-06-18), MAY (2025-11-25), deprecated in 2026-07-28 in favour of Client ID Metadata Documents, with removal no earlier than the first revision on or after 2027-07-28.
- Where. 2025-03-26 through 2026-07-28. opencode v1 registers dynamically with
client_name: "OpenCode". - mcpx @ 05c78b2. No DCR. The
clientIdcomment already prefers a CIMD URL (internal/mcpauth/mcpauth.go:66-68). - Value to mcpx. + low: some older ASes only do DCR; everything newer takes CIMD or a pre-registered id.
- Effort. M.
- Risk. None now.
- Detail. Registration priority in 2025-11-25 and 2026-07-28: pre-registered, then CIMD if the AS advertises
client_id_metadata_document_supported, then DCR if it has aregistration_endpoint. If mcpx ever does DCR it must also sendapplication_type(AUTH-12). - Sources.
2025-03-26/basic/authorization.mdx:42“2. MCP auth implementations SHOULD support the OAuth 2.0 Dynamic Client Registration”;2025-11-25/basic/authorization.mdx:60“3. Authorization servers and MCP clients MAY support the OAuth 2.0 Dynamic Client Registration”;2026-07-28/basic/authorization/client-registration.mdx:141“Dynamic Client Registration is deprecated. New implementations should use”;2026-07-28/deprecated.mdx:29“| [Dynamic Client Registration]”;v1:packages/opencode/src/mcp/oauth-provider.ts:46“client_name: "OpenCode",”
AUTH-12 application_type required in DCR
- What. A client registering dynamically MUST send
application_type:"native"for desktop, CLI and localhost redirects,"web"for remote browser apps. - Where. 2026-07-28.
- mcpx @ 05c78b2. Deferred.
- Value to mcpx. + low: mcpx is
"native"; DCR itself is deprecated. - Effort. S.
- Risk. Not done: OIDC providers default to
"web"and reject a localhost redirect URI. - Detail. Clients MUST be prepared for registration failure and MAY retry with an adjusted type.
- Sources.
2026-07-28/basic/authorization/client-registration.mdx:160“MCP clients MUST specify an appropriateapplication_type”
AUTH-13 Client ID Metadata Documents as client_id
- What. The client’s
client_idis an HTTPS URL to a JSON document (client_id,client_name,redirect_urisat least) that the AS fetches; the AS advertisesclient_id_metadata_document_supported: true. - Where. 2025-11-25 (one registration approach), 2026-07-28 (AS and clients SHOULD support). opencode v2 offers
https://opencode.ai/oauth/opencode/client.json. - mcpx @ 05c78b2. The config comment prefers a CIMD URL (
internal/mcpauth/mcpauth.go:66-68); nothing uses it. - Value to mcpx. + high: a daemon with no registration step, portable across ASes (no re-registration when the AS changes, AUTH-17). − someone has to host the document over HTTPS.
- Effort. M.
- Risk. None.
- Detail. 2026-07-28 security considerations: a CIMD cannot by itself stop
localhostimpersonation, so ASes SHOULD warn on localhost-only redirect URIs and MUST show the redirect hostname. A local daemon’s redirect is exactly that case, so users will see the warning. - Sources.
2026-07-28/basic/authorization/security-considerations.mdx:95“Client ID Metadata Documents cannot preventlocalhostURL impersonation by themselves.”;2025-11-25/basic/authorization.mdx:207“2. Use Client ID Metadata Documents if the Authorization Server indicates if the server supports it”;2026-07-28/basic/authorization/index.mdx:65“2. Authorization servers and MCP clients SHOULD support [OAuth Client ID Metadata Documents]”;v2:packages/core/src/mcp/oauth.ts:25“export const CLIENT_METADATA_URL = "https://opencode.ai/oauth/opencode/client.json"”
AUTH-14 Scope selection and step-up on insufficient_scope
- What. Servers SHOULD put
scopein the 401WWW-Authenticate; clients prefer that, else allscopes_supported. At runtime a 403 witherror="insufficient_scope"triggers step-up re-authorization. - Where. 2025-11-25 and 2026-07-28. 2026-07-28 adds two SHOULDs: a challenge names every scope the operation needs, and the client requests the union with scopes already granted.
- mcpx @ 05c78b2. The
scopescomment already says the challenge is authoritative (internal/mcpauth/mcpauth.go:69-71); nothing reads a challenge. - Value to mcpx. + med: real remote servers (GitHub) scope per tool.
- Effort. M.
- Risk. None now.
- Detail. Clients MUST NOT assume any subset or superset relation between challenge scopes and
scopes_supported. A step-up in the middle of a pooled session means re-authorizing a connection other callers share. - Sources.
2025-11-25/basic/authorization.mdx:103“MCP servers SHOULD include ascopeparameter in theWWW-Authenticateheader as defined in”;2025-11-25/basic/authorization.mdx:541“#### Step-Up Authorization Flow”;internal/mcpauth/mcpauth.go:69-71
AUTH-15 RFC 9207 iss check on the authorization response
- What. ASes SHOULD return
isswith the authorization code and advertiseauthorization_response_iss_parameter_supported. Clients MUST validate before redeeming the code: advertised and present, compare exactly; advertised and absent, reject; not advertised but present, compare anyway; neither, proceed. - Where. 2026-07-28.
- mcpx @ 05c78b2. Deferred.
- Value to mcpx. + med: the mix-up-attack defence, which matters most to a client that talks to many ASes, as a pooling daemon does.
- Effort. S.
- Risk. None now.
- Detail. Plain string comparison: no case folding, default-port or trailing-slash normalisation. Expected to become MUST for ASes later.
- Sources.
2026-07-28/basic/authorization/index.mdx:196“On receiving the authorization response, MCP clients MUST apply the validation in [RFC9207 Section 2.4]”
AUTH-16 Fetched metadata issuer must equal the issuer used
- What. The
issuerinside a fetched AS or OIDC metadata document MUST be identical to the issuer identifier the client used to build the well-known URL. - Where. 2026-07-28.
- mcpx @ 05c78b2. Deferred.
- Value to mcpx. + med: prevents one AS impersonating another through a crafted metadata document.
- Effort. S.
- Risk. None now.
- Detail. Separate from AUTH-15 (which checks the authorization response); both are string-exact.
- Sources.
2026-07-28/basic/authorization/authorization-server-discovery.mdx:94“theissuervalue in the document MUST be identical to the issuer identifier used to construct the well-known URL”
AUTH-17 Credentials keyed by issuer; re-register on AS change
- What. Pre-registered or DCR-obtained client credentials MUST be keyed by the AS’s
issuer, MUST NOT be reused with another AS, and the client MUST re-register when protected-resource metadata points somewhere new. CIMD ids are portable. - Where. 2026-07-28. opencode keys differently: v1 stores tokens and client info per server name in
<data>/mcp-auth.json(mode 0600); v2 stores a global Integration credential keyed bysha1(name + url). - mcpx @ 05c78b2. No token store exists.
- Value to mcpx. + med: it fixes the key of mcpx’s future token store as
(issuer, client_id), not the server name. - Effort. S.
- Risk. Not done: credential confusion when a server moves AS; opencode’s name- or url-keyed stores would reuse a client id across ASes.
- Detail. A pre-registered id that does not match the new AS SHOULD surface an error rather than be tried. opencode v2’s comment explains why it keys on name + url: two configs of one URL may hold different accounts. That concern and the issuer rule are compatible: key on issuer, then on account.
- Sources.
2026-07-28/basic/authorization/client-registration.mdx:186“keyed by the authorization server’sissueridentifier. When the”;2026-07-28/basic/authorization/client-registration.mdx:190“from a different authorization server and MUST re-register”;v1:packages/opencode/src/mcp/auth.ts:37“const filepath = path.join(Global.Path.data, "mcp-auth.json")”;v2:packages/core/src/mcp/index.ts:165-167
AUTH-18 Refresh-token handling rules
- What. Clients MUST keep refresh tokens confidential, SHOULD list
refresh_tokeningrant_types, MAY requestoffline_accesswhen the AS lists it, and MUST NOT assume one is issued; servers SHOULD NOT putoffline_accessin challenges orscopes_supported. - Where. 2026-07-28 (new section). opencode v1 already registers with
grant_types: ["authorization_code", "refresh_token"]. - mcpx @ 05c78b2. Deferred.
- Value to mcpx. + med: a long-lived daemon lives on refresh tokens.
- Effort. S within the flow.
- Risk. Not done: users re-authorize whenever an access token expires.
- Detail. “Confidential in storage” rules out a world-readable token file; mcpx already restricts its socket with
defaults.PrivateMode(internal/daemon/server.go:202), and a token file would need the same. - Sources.
2026-07-28/basic/authorization/index.mdx:299“## Refresh Tokens”;v1:packages/opencode/src/mcp/oauth-provider.ts:48“grant_types: ["authorization_code", "refresh_token"],”
AUTH-19 oauth-client-credentials extension (machine-to-machine)
- What. An official extension,
io.modelcontextprotocol/oauth-client-credentials, adds the OAuth client-credentials grant (JWT assertion, orclient_id+client_secret) so automated systems connect without a user present. Clients declare it inextensionsof the per-requestclientCapabilities. - Where. 2026-07-28 extension (
docs/extensions/auth). Never active by default. - mcpx @ 05c78b2. Not declared, not performed.
- Value to mcpx. + med: the extension’s own table lists “background service or daemon” and “CI/CD pipeline” as its users; that is mcpx.
- Effort. M.
- Risk. None.
- Detail. Declaring it is per request, in
_meta, like every 2026 capability; a legacy session has no place to declare it. - Sources.
docs/extensions/auth/oauth-client-credentials.mdx:6“The OAuth Client Credentials extension (io.modelcontextprotocol/oauth-client-credentials)”;docs/extensions/auth/overview.mdx:54“| Background service or daemon accessing an MCP server | [OAuth Client Credentials]”;docs/extensions/auth/overview.mdx:63“they are never active by default.”;docs/extensions/auth/overview.mdx:67
AUTH-20 enterprise-managed-authorization extension (IdP, ID-JAG)
- What.
io.modelcontextprotocol/enterprise-managed-authorizationlets an organisation’s IdP govern MCP access: the client exchanges an ID token for an Identity Assertion JWT Authorization Grant (ID-JAG) and trades that for an access token at the MCP server’s AS. - Where. 2026-07-28 extension.
- mcpx @ 05c78b2. n/a.
- Value to mcpx. − an enterprise IdP flow is out of scope for a local single-user daemon.
- Effort. L.
- Risk. None.
- Detail. Like AUTH-19, never active by default.
- Sources.
docs/extensions/auth/enterprise-managed-authorization.mdx:6“The Enterprise-Managed Authorization extension (io.modelcontextprotocol/enterprise-managed-authorization)”;docs/extensions/auth/enterprise-managed-authorization.mdx:42
AUTH-21 OAuth redirect listener: fixed port vs ephemeral
- What. Where the authorization code comes back. opencode v1 listens on a fixed
http://127.0.0.1:19876/mcp/oauth/callback(overridable bycallbackPort/redirectUri); v2 listens oncallback_port, else the configured redirect’s port, else an ephemeral port on 127.0.0.1. - Where. opencode v1 and v2 (the spec prescribes no port).
- mcpx @ 05c78b2. A
redirectconfig field exists (internal/mcpauth/mcpauth.go:75-76); the comment names “a callback listener” as the missing piece (internal/mcpauth/mcpauth.go:62-63). - Value to mcpx. + med: a daemon can host the listener itself on its existing loopback server.
- Effort. S.
- Risk. A fixed port collides when two clients authorize at once; an ephemeral port conflicts with ASes that require exact redirect URIs.
- Detail. Config spelling differs: v1 camelCase (
callbackPort,redirectUri), v2 snake_case (callback_port,redirect_uri,client_id). - Sources.
v1:packages/opencode/src/mcp/oauth-provider.ts:11“const OAUTH_CALLBACK_PORT = 19876”;v1:packages/opencode/src/mcp/oauth-provider.ts:12“const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback"”;v2:packages/core/src/mcp/oauth.ts:353“server.listen(oauth?.callback_port ?? redirectPort ?? 0, "127.0.0.1", () => {”;v2:packages/schema/src/mcp.ts:42
AUTH-22 Per-server auth: block parsed and never applied
- What. A server config’s
auth: {type: bearer|basic|header|query|env|oauth, …}does nothing.Auth.Resolve(), which would build headers, query parameters and environment, andAuth.Describe()have no non-test caller; only the plainheaders:map reaches the HTTP transport. - Where. mcpx configuration only.
- mcpx @ 05c78b2.
Authfield atinternal/config/config.go:138-141;Resolveatinternal/mcpauth/mcpauth.go:93-184;internal/mcpauthis imported only byinternal/configfor the type; the pool passesp.cfg.Headers(internal/pool/pool.go:377-380). - Value to mcpx. + high:
${GITHUB_TOKEN}bearer auth then works as documented. - Effort. S.
- Risk. High if not done: a user who configures
auth.bearersends unauthenticated requests and gets the 401 the code comment promises will never happen. - Detail.
docs/protocol.md:378understates it: it says only OAuth is declared and not performed, but every auth type is inert.Describeclaims to be “used bymcpx configandmcpx doctor” (internal/mcpauth/mcpauth.go:186-189); neither calls it. Thequerytype also needs the URL rewritten (Resolved.Query), andNeedsOAuth/Missingexist so failures could be reported before a request (internal/mcpauth/mcpauth.go:84-89). - Sources.
internal/mcpauth/mcpauth.go:93“func (a *Auth) Resolve() (*Resolved, error) {”;internal/pool/pool.go:379“Headers: p.cfg.Headers,”;internal/config/config.go:138-141
AUTH-23 /mcp and /v1 authenticate nobody
- What. Whatever can reach the daemon’s listener can list and call every tool and run scripts. The default bind is loopback, and a warning is printed when binding elsewhere.
- Where. Every revision says servers SHOULD authenticate all connections (the SSE transport in 2024-11-05, Streamable HTTP after).
- mcpx @ 05c78b2. No auth middleware; the only wrapper is
trackActivity(internal/daemon/server.go:290-293,344-353), which records time and re-checks config.internal/cli/root.go:196-204prints the warning. The unix socket relies on filesystem permissions; TCP gets nothing. - Value to mcpx. + high:
--address 0.0.0.0is documented (docs/protocol.md:352-355), and even loopback is reachable from a browser becauseOriginis not checked (transports area; AUTH-24). - Effort. M for a bearer token; L for a full OAuth resource server (AUTH-06).
- Risk. High: remote code execution for anything that reaches the port.
- Detail. If mcpx adds a 401 to
/mcp, opencode v1 will read it as “needs OAuth”: it moves the server toneeds_authand toasts “Run: opencode mcp auth”, partly by matching “OAuth”/“registration”/“client_id” in error text. Users would need oauth: falseand a static header in opencode’s config. - Sources.
2024-11-05/basic/transports.mdx:57“3. Servers SHOULD implement proper authentication for all connections”;2026-07-28/basic/transports/streamable-http.mdx:65“Servers SHOULD implement proper authentication”;internal/cli/root.go:202“the API is unauthenticated”;v1:packages/opencode/src/mcp/index.ts:317
AUTH-24 Code-execution endpoints accept cross-origin simple requests
- What. The endpoints that run code have no authentication and do not require a JSON content type, so a web page
can trigger them. mcpx’s
POST /v1/execdecodes JSON from the body whatever itsContent-Type, so a cross-origintext/plain“simple” request (no CORS preflight) runs a script with full authority; the page cannot read the answer but the side effect happens. lootbox’s/wsaccepts a WebSocket upgrade on the fixed port 9420 with noOrigincheck: cross-site WebSocket hijacking, and it can read the answer. - Where. mcpx and lootbox. opencode and Cloudflare expose no such endpoint.
- mcpx @ 05c78b2.
handleExeccallsjson.NewDecoder(r.Body).Decode(&req)(internal/daemon/routes_exec.go:212-213); no Origin or Host check ininternal/daemonorinternal/mcpserver; the API is unauthenticated by design (internal/daemon/server.go:206-208).daemon.portdefaults to 0 (ephemeral), so an attacker must find the port first (internal/settings/registry.go:343). - Value to mcpx. + high: closes a drive-by remote-code-execution path on the user’s workstation.
- Effort. S — reject a present non-loopback
Originwith 403, requireContent-Type: application/jsonon POST, optionally checkHost. Unix-socket and CLI callers send noOriginand are unaffected. - Risk. High if not done. Done: a browser UI on another origin needs an allowlist.
- Detail. The
OriginMUST itself (every revision, 403 explicit since 2025-11-25) is recorded in the transports area; this row is the consequence for an unauthenticated server that executes code. lootbox’s own dashboard hard-codesws://<host>:3000/ws(.lootbox/ui/src/lib/websocket-client.ts:34), so it cannot even reach the :9420 daemon. - Sources.
internal/daemon/routes_exec.go:212-213;internal/daemon/server.go:206-208;.lootbox/src/lib/rpc/websocket_server.ts:282-290;2025-11-25/basic/transports.mdx:78“Servers MUST validate theOriginheader on all incoming connections to prevent DNS rebinding attacks”
AUTH-25 Scripts see credentials: bindings vs inherited environment
- What. Cloudflare’s sandbox never holds a credential: connectors are already-authorised RPC bindings, and a
search-and-execute server hands the script a
request()function, not the token. mcpx scripts inherit the whole environment of whoever runs them, and for/v1/execthat is the daemon’s environment. - Where. Cloudflare (bindings); opencode (interpreter, no environment access); lootbox (no
--allow-envby default, so the environment exists but cannot be read); mcpx (fullos.Environ()). - mcpx @ 05c78b2.
cmd.Env = append(os.Environ(), "MCPX_RUNTIME="+rt.Name)(internal/runner/runner.go:359); a daemon-run script is a child of the daemon (internal/daemon/routes_exec.go:182-200). - Value to mcpx. − full authority is the design (scripts need the filesystem and processes). + one sentence in
docs/exec.mdis owed: a/v1/execcaller can read every secret in the daemon’s environment. - Effort. S — document it, or scrub a configurable denylist from the runner’s environment.
- Risk. Med: a daemon started with
GITHUB_TOKENand bound beyond loopback exposes the token to any caller (compounded by AUTH-23 and AUTH-24). - Detail. Cloudflare’s docs repeat the blog’s “cannot leak keys” claim more cautiously (“never receives the connector credentials or client objects”), so it is documented behaviour, not marketing only.
- Sources.
internal/runner/runner.go:359; https://developers.cloudflare.com/agents/tools/codemode/how-it-works/#connectors “The generated code never receives the connector credentials or client objects.”; https://developers.cloudflare.com/agents/model-context-protocol/codemode/#sandbox-and-authorization-boundary “Authentication stays in the host request callback.”
Capabilities
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-11-01T16:20:00-06:00
increment: 1
status: standard
tags: area:compare
description: every ClientCapabilities/ServerCapabilities field per revision; what mcpx, opencode and lootbox declare
A capability is a promise about the revision in force, both as a server to hosts and as a client to upstreams. The named CAP rows were re-audited for #1753; other observations retain their original snapshot.
Status column: CAP-02/04/05/06/08 are current (audited at 2a93f3f64); other rows retain the mcpx @ 05c78b2 snapshot. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| CAP-01 | experimental map (client and server); 2026 narrows values to JSON objects | 2024-11-05 has 2026-07-28 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (JSONObject) | n/a — declared in neither direction | + low | S | low |
| CAP-02 | client roots; deprecated to a bare {} in 2026 | 2024-11-05 has 2026-07-28 deprecates opencode v1 has opencode v2 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 dep · opencode v1 ✓ v2 ✓ · lootbox — | ✓ — declares roots and answers from configured roots; relays to the calling host’s roots when declared (modern.go:342-359; daemon/hooks.go:373-409) | + med | S | med |
| CAP-03 | client roots.listChanged removed in 2026 | 2024-11-05 has 2026-07-28 removes mcpx missing 2026-07-28 deprecates | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 rem · opencode — | ✗ — listChanged:false sent to 2026 upstreams too (#200) | + low | S | low |
| CAP-04 | client sampling; structured in 2025-11-25, deprecated in 2026 | 2024-11-05 has 2025-11-25 has 2026-07-28 deprecates mcpx has, others don't | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 dep · opencode v1 — v2 — · lootbox — | ✓ — declared only with an OnServerRequest handler; the daemon wires its broker handler (modern.go:120-123; daemon/hooks.go:82) | + med | S | med |
| CAP-05 | client sampling.context gates includeContext | 2025-11-25 has 2026-07-28 deprecates | 24-11 — · 25-03 — · 25-06 — · 25-11 ✓ · 26-07 dep | ✓ — not declared; undeclared non-none includeContext is dropped before the handler (modern.go:142-179) | + low | S | low |
| CAP-06 | client sampling.tools gates tool use inside sampling | 2025-11-25 has 2026-07-28 deprecates | 24-11 — · 25-03 — · 25-06 — · 25-11 ✓ · 26-07 dep | ✓ — not declared; incoming tools/toolChoice requests are refused with -32602 (modern.go:142-179) | − deprecated | M | low |
| CAP-07 | client elicitation | 2025-06-18 has opencode v2 has | 24-11 — · 25-03 — · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ · opencode v1 — v2 ✓ · lootbox — | ✓ — always declared upstream; host gate checks revision | + high | S | low |
| CAP-08 | client elicitation.form; {} means form only | 2025-11-25 has opencode v2 has | 24-11 — · 25-03 — · 25-06 — · 25-11 ✓ · 26-07 ✓ · opencode v2 ✓ (applyDefaults) | ✓ — the host gate accepts form or empty {} and rejects url-only form requests (conn.go:259-285; PR #2162) | + low | S | low |
| CAP-09 | client elicitation.url | 2025-11-25 has mcpx missing opencode v2 has | 24-11 — · 25-03 — · 25-06 — · 25-11 ✓ · 26-07 ✓ · opencode v1 — v2 ✓ | partial — host gate correct; never declared upstream (#200) | + med | M | med |
| CAP-10 | client tasks (client-hosted tasks), 2025-11-25 only | 2025-11-25 has 2026-07-28 removes has better replacement | 24-11 — · 25-03 — · 25-06 — · 25-11 ✓ · 26-07 rem · opencode — · lootbox — | ✓ — never declared | − one-revision | S | low |
| CAP-11 | client extensions map | 2026-07-28 has | 26-07 ✓ only | Modern client declares the tasks extension; legacy initialize omits the map (internal/mcpclient/modern.go:capabilities). | + med | S | low |
| CAP-12 | server logging; deprecated, not removed, in 2026 | 2024-11-05 has 2026-07-28 deprecates | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 dep | ✓ — never declared; docs/protocol.md says otherwise | + low | S | low |
| CAP-13 | server completions | 2025-03-26 has | 24-11 — (method ungated) · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✓ — declared in every era mcpx serves | + low | S | low |
| CAP-14 | server prompts | 2024-11-05 has opencode v1 has opencode v2 has | all five ✓ · opencode v1 ✓ v2 ✓ (slash commands) | ✓ — always declared | + low | S | low |
| CAP-15 | server prompts.listChanged; 2026 delivers it only on a listen stream | 2024-11-05 has 2026-07-28 has mcpx missing opencode v2 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (listen) · opencode v1 — v2 ✓ | ✗ — stdio declares true, legacy hosts never receive it (#208) | + med | S | med |
| CAP-16 | server resources | 2024-11-05 has opencode v1 has opencode v2 has | all five ✓ · opencode v1 ✓ (3 model tools) v2 ✓ (TUI only) | ✓ — always declared, even with no upstream resources | + med | S | med |
| CAP-17 | server resources.subscribe; kept in 2026 with a new meaning | 2024-11-05 has 2026-07-28 has specs conflict mcpx missing | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (= resourceSubscriptions) · opencode never subscribes | ✗ — stdio declares true; nothing is subscribed upstream (#208) | + med | M | med |
| CAP-18 | server resources.listChanged; 2026 delivers it only on a listen stream | 2024-11-05 has 2026-07-28 has mcpx missing opencode v2 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (listen) · opencode v1 — v2 ✓ | ✗ — stdio declares true, legacy hosts never receive it (#208) | + med | S | med |
| CAP-19 | server tools | 2024-11-05 has opencode v1 has opencode v2 has | all five ✓ · opencode v1 ✓ v2 ✓ | ✓ — always declared | + low | S | low |
| CAP-20 | server tools.listChanged; 2026 delivers it only on a listen stream | 2024-11-05 has 2026-07-28 has mcpx missing opencode v1 has opencode v2 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (listen) · opencode v1 ✓ v2 ✓ | ✗ — stdio declares true; mcpx’s own list never changes (#208) | + med | S | med |
| CAP-21 | server core tasks object, 2025-11-25 only | 2025-11-25 has 2026-07-28 removes has better replacement | 24-11 — · 25-03 — · 25-06 — · 25-11 ✓ · 26-07 rem (extension) | Core tasks capability is limited to legacy revision; modern gets extension only (internal/mcpserver/server.go:capabilities). | + med | S | low |
| CAP-22 | server tasks.list | 2025-11-25 has 2026-07-28 removes mcpx missing | 25-11 ✓ only | Legacy list is declared and lists connection-owned tasks only; unowned tasks remain accessible by ID (internal/mcpserver/server.go:capabilities; internal/mcpserver/tasks.go:handleTask, visible; #1554). | + med | S | med |
| CAP-23 | server tasks.cancel | 2025-11-25 has 2026-07-28 removes | 25-11 ✓ · 26-07 rem (extension implies cancel) | Legacy cancel declared and terminal cancel rejected; modern extension acknowledges (internal/mcpserver/server.go:capabilities; internal/mcpserver/tasks.go:handleTask, handleModernTask). | + low | S | low |
| CAP-24 | server tasks.requests.tools.call | 2025-11-25 has 2026-07-28 removes | 25-11 ✓ only | Legacy task execution is advertised; eligible gateway tools declare optional support and forbidden tools are rejected (internal/mcpserver/server.go:taskOptional, capabilities; internal/mcpserver/tasks.go:legacyTaskSupport, maybeTask). | + med | S | low |
| CAP-25 | server extensions map | 2026-07-28 has | 26-07 ✓ only | Server extension map is emitted only for modern peers (internal/mcpserver/server.go:capabilities). | + low | S | low |
| CAP-26 | extensions["io.modelcontextprotocol/tasks"] key, both directions | 2026-07-28 has specs conflict mcpx missing | 26-07 ext only (SEP-2663 names 2026-06-30) | Server and modern client declare the extension; client polls task results, but upstream task input relay remains open (internal/mcpserver/server.go:capabilities; internal/mcpclient/modern.go:capabilities; internal/mcpclient/tasks.go:finishModernTask, TaskInputRequiredError; #1554). | + med | M | med |
CAP-01 experimental map (client and server)
- What. Both
ClientCapabilitiesandServerCapabilitiescarryexperimental, a map of non-standard capability name to settings object. 2026 retypes the values from TypeScriptobjecttoJSONObject. - Where. Every revision, both sides. Only 2026 constrains the value type.
- mcpx @ 05c78b2. Declares no
experimentalkey as a server (internal/mcpserver/server.go:1398-1403) or as a client (internal/mcpclient/modern.go:50-56). - Value to mcpx. + none on the wire today; any JSON object already satisfied
object. - Effort. S — type-level only.
- Risk. None practical either way.
- Detail. The same
JSONObjectnarrowing applies in 2026 tosampling.context/.tools,elicitation.form/.url,loggingandcompletions. mcpx forwards nothing fromexperimentalin either direction; a host’s experimental capabilities never reach an upstream. - Sources.
schema/2024-11-05/schema.ts:189“experimental?: { [key: string]: object };”;schema/2026-07-28/schema.ts:720“experimental?: { [key: string]: JSONObject };”;schema/2026-07-28/schema.ts:797“experimental?: { [key: string]: JSONObject };”;internal/mcpclient/modern.go:50-56
CAP-02 client roots
- What. A client that can answer
roots/listdeclaresroots. 2026 keeps the key as a bare{}and deprecates it (SEP-2577); roots then reach a server only as an input request inside anInputRequiredResult. - Where. All five revisions; deprecated in 2026. opencode v1 and v2 declare
roots: {}and answer one root, the instance or Location directory (v2 notes it is legacy-era only). lootbox declares no capabilities at all, so an upstreamroots/listgets the SDK’s method-not-found. - mcpx @ 2a93f3f64. As a client it declares
roots(internal/mcpclient/modern.go:102-108) and answersroots/listfrom its configured roots (internal/mcpclient/modern.go:342-359). When a server asks during a call whose originating client declared roots, the daemon relays the request to that client’s roots; otherwise mcpx falls back to its configured roots (internal/daemon/hooks.go:373-409). - Value to mcpx. + filesystem servers can use the configured roots or the calling client’s roots, rather than a hard-coded empty list.
- Effort. S — configured-root fallback and call-scoped host relay are implemented.
- Risk. low — an empty configured-root list is legal; relayed roots stay scoped to the host call that requested them.
- Detail. An empty configured-root list remains a legal fallback answer. A relayed answer is scoped to the host whose
call raised the request; mcpx does not broadcast one client’s roots to other pooled clients. opencode’s root is the
opencode directory, not the worktree. lootbox’s
capabilities: {}comes with no request handler at all, so an upstreamroots/listorelicitation/creategets the SDK’s default method-not-found. - Sources.
schema/2026-07-28/schema.ts:732“roots?: {};”;schema/2026-07-28/schema.ts:724“@deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577).”;internal/mcpclient/modern.go:102-108,342-359;internal/daemon/hooks.go:373-409;v1:packages/opencode/src/mcp/index.ts:46“roots: {},”;v2:packages/core/src/mcp/client.ts:146;v2:packages/core/src/mcp/client.ts:144“Legacy era only”;.lootbox/src/lib/external-mcps/mcp_client_manager.ts:150-153
CAP-03 client roots.listChanged
- What. 2024-11-05 through 2025-11-25 declare
roots: { listChanged?: boolean }. 2026 removes the sub-field along withnotifications/roots/list_changed; with per-request roots there is nothing to invalidate. - Where. Four legacy revisions; absent in 2026. opencode v1 and v2 declare
rootswithout it. - mcpx @ 05c78b2. Declares
"roots": {"listChanged": false}in every era, including the 2026io.modelcontextprotocol/clientCapabilities_metaon every modern request (internal/mcpclient/modern.go:52,internal/mcpclient/modern.go:83). A send-conservatively conflict: a key the revision does not define. - Value to mcpx. + dropping it for 2026 is the conservative send; nothing is lost.
- Effort. S — one map per era.
- Risk. Low: capabilities are an open set, but a strict 2026 server validating
rootsas{}could reject it. - Detail. The same object is sent in the legacy
initializeregardless of the negotiated version. DeclaringlistChanged: falsein legacy eras is legal but pointless (mcpx never sendsnotifications/roots/list_changed). The wire capture of what mcpx declared to an upstream reads{"elicitation":{},"roots":{"listChanged":false},"sampling":{}}(wire, “mcpx as client”). - Also recorded from the roots register.
listChangedin the four legacy revisions; absent in 2026-07-28. Declares"roots": {"listChanged": false}in every era, including in 2026 per-request capabilities (internal/mcpclient/modern.go:52), a key 2026 does not define. opencode v1 and v2 declareroots: {}. The removal follows from ROOT-02: with MRTR the server asks for roots on every request that needs them, so there is nothing to invalidate. The changelog names the notification’s removal, not the field’s. The wider 2026 per-request capability shape is a capabilities-register item. - Sources.
schema/2025-11-25/schema.ts:322“listChanged?: boolean;”;schema/2026-07-28/schema.ts:732“roots?: {};”;internal/mcpclient/modern.go:52;internal/mcpclient/modern.go:83“set(MetaClientCapabilities, c.capabilities())”;schema/2026-07-28/schema.ts:724“@deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577).”;v1:packages/opencode/src/mcp/index.ts:46“roots: {},”.
CAP-04 client sampling
- What. A client that can run
sampling/createMessagedeclaressampling. It is an opaque object through 2025-06-18; 2025-11-25 gives itcontextandtoolssub-capabilities; 2026 deprecates the whole capability (SEP-2577). - Where. All five; structured from 2025-11-25; deprecated 2026. Neither opencode version declares it or registers a handler; lootbox declares nothing.
- mcpx @ 2a93f3f64. As a client,
sampling: {}is declared iff anOnServerRequesthandler exists (internal/mcpclient/modern.go:94-123); the daemon wires its broker-backed handler (internal/daemon/hooks.go:82, 155-174). The handler turns requests into broker work; an unanswered request may still expire. As a server, mcpx checks hostsampling.tools/sampling.contextdeclarations before forwarding the corresponding shapes (internal/mcpserver/conn.go:561-581,696-703). - Value to mcpx. + honesty: servers that have a no-sampling fallback would use it.
- Effort. S — done; capability declaration is conditional on the configured handler.
- Risk. If not done: a sampling upstream waits out
elicit.ttl(120 s) and then gets an error. - Detail. An empty
sampling: {}in 2025-11-25+ means “basic sampling, no tools, no context inclusion”. mcpx does not copy host sub-capabilities into its upstream declaration: it refuses incomingtools/toolChoiceand strips undeclared non-noneincludeContext(internal/mcpclient/modern.go:142-179). Host-bound forwarding separately checks the host declarations (SMP-11). mcpx is the only one of mcpx/opencode/lootbox that declares sampling at all. - Also recorded from the sampling register. Bare in 2024-11-05..2025-06-18; structured in 2025-11-25 and
2026-07-28. The client declares
sampling: {}only when its handler exists (internal/mcpclient/modern.go:120-123); it never declarestoolsorcontext. The daemon installs the broker-backed handler (internal/daemon/hooks.go:82). - Sources.
schema/2024-11-05/schema.ts:202“sampling?: object;”;schema/2025-11-25/schema.ts:327“sampling?: {”;schema/2026-07-28/schema.ts:736“@deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577).”;internal/mcpclient/modern.go:94-123;internal/daemon/hooks.go:82,155-174;docs/protocol.md:271“samplingonly when a handler exists to answer it”;v1:packages/opencode/src/mcp/index.ts:42“// sampling: {},”;v2:packages/core/src/mcp/client.ts:142;schema/2025-06-18/schema.ts:246“sampling?: object;”
CAP-05 client sampling.context
- What. Sub-capability that gates
includeContext: without it, servers SHOULD use only"none"or omit the field. - Where. 2025-11-25 and 2026 (deprecated with the values it gates). The 2025-11-25 changelog does not mention it.
- mcpx @ 2a93f3f64. Never declared upstream (
internal/mcpclient/modern.go:120-123). Incoming non-noneincludeContextis removed before the handler because mcpx did not declaresampling.context(internal/mcpclient/modern.go:142-179); host-bound forwarding also removes it unless the host declared context (internal/mcpserver/conn.go:696-703). - Value to mcpx. + not declaring it is correct: a proxy has no “context from other servers” to inject.
- Effort. S.
- Risk. None if left undeclared.
- Detail. SHOULD-level only: a server may still send
thisServer/allServersand the client MAY ignore it. - Sources.
schema/2025-11-25/schema.ts:332“context?: object;”;2025-11-25/client/sampling.mdx:69“With context inclusion support (soft-deprecated):”;internal/mcpclient/modern.go:120-123,142-179;internal/mcpserver/conn.go:696-703.
CAP-06 client sampling.tools
- What. Declares that the client accepts
tools/toolChoiceinsampling/createMessage. Servers MUST NOT send tool-enabled sampling without it; the schema also says the client MUST error if it gets one anyway. - Where. 2025-11-25 and 2026 (deprecated).
- mcpx @ 2a93f3f64. Never declares
sampling.toolsupstream. A tools/toolChoice request is refused with-32602before the client handler runs (internal/mcpclient/modern.go:142-179). Host-bound tool requests are offered only to a host that declaredsampling.tools(internal/mcpserver/conn.go:572-581). - Value to mcpx. − relaying agentic sampling is work for a deprecated feature.
- Effort. M — the tool loop (
tool_use/tool_resultblocks) has to pass through unmodified. - Risk. Declaring it without a tool loop invites requests mcpx must then refuse.
- Detail. Prose and schema put the obligation on different sides: the server MUST NOT send, the client MUST error.
- Sources.
schema/2025-11-25/schema.ts:336“tools?: object;”;2025-11-25/client/sampling.mdx:40“Clients MUST declare support for tool use via thesampling.toolscapability”;internal/mcpclient/modern.go:142-179;internal/mcpserver/conn.go:313-319,572-581.
CAP-07 client elicitation
- What. A client that can answer
elicitation/createdeclareselicitation. Opaque in 2025-06-18; structured into modes from 2025-11-25 (CAP-08, CAP-09). - Where. 2025-06-18, 2025-11-25, 2026. opencode v1 has it commented out (and answers
elicitation/createwith-32601); opencode v2 declares it with both modes; lootbox declares nothing. - mcpx @ 05c78b2. Always declares
"elicitation": {}upstream (internal/mcpclient/modern.go:51); when nobody is listening it answerscancel(internal/mcpclient/modern.go:129-131), which keeps the always-on declaration honest. Asking a host requires the host to be at 2025-06-18 or later and to have declared it (internal/mcpserver/conn.go:192), so mcpx never sendselicitation/createto opencode v1. - Value to mcpx. + the broker is built on it.
- Effort. S.
- Risk. None.
- Detail. For opencode v1 users the broker (
mcpx elicit) is the only way to answer mcpx’s questions. opencode v2’s handler turns the request into a Location-global form (elicitation register). - Sources.
schema/2025-06-18/schema.ts:250“elicitation?: object;”;2025-06-18/basic/lifecycle.mdx:155“| Client |elicitation|”;internal/mcpclient/modern.go:51;internal/mcpclient/modern.go:129-131;internal/mcpserver/conn.go:192;v1:packages/opencode/src/mcp/index.ts:44“// elicitation: {},”;v2:packages/core/src/mcp/client.ts:143
CAP-08 client elicitation.form
- What. 2025-11-25 splits elicitation into
formandurl. An emptyelicitation: {}is equivalent to{ form: {} }; a declaring client MUST support at least one mode; servers MUST NOT send an undeclared mode. - Where. 2025-11-25 and 2026. opencode v2 declares
form: { applyDefaults: true }. - mcpx @ 2a93f3f64. As a client it declares form support (
internal/mcpclient/modern.go:98-104). As a server,CanElicitaccepts an explicitformkey or an empty declaration, but rejects a 2025-11-25+ url-only declaration (internal/mcpserver/conn.go:259-285; PR #2162). - Value to mcpx. + low: url-only hosts are rare, but the rule is a MUST NOT.
- Effort. S — done.
- Risk. low — url-only hosts are not offered form requests.
- Detail.
applyDefaultsis not a schema field (formis an open object) — it is the SDK’s instruction to fill schema defaults into accepted content. Because{}means form-only, mcpx’s upstream declaration never admits url mode (CAP-09). - Also recorded from the elicitation register. 2025-11-25 and 2026-07-28. opencode v2 declares
{ form: { applyDefaults: true }, url: {} }. Downstream, form and url modes are gated independently by the declaration and protocol era (internal/mcpserver/conn.go:259-285). Upstream, mcpx declares form and addsurlonly when a URL handler exists (internal/mcpclient/modern.go:98-104); the 2026 per-request capabilities carry the same legacy-shaped object (see the capabilities register). - Sources.
schema/2025-11-25/schema.ts:341“elicitation?: { form?: object; url?: object };”;2025-11-25/client/elicitation.mdx:65“an empty capabilities object is equivalent to declaring support forformmode only”;2025-11-25/client/elicitation.mdx:75;2025-11-25/client/elicitation.mdx:77“Servers MUST NOT send elicitation requests with modes that are not supported by the client.”;internal/mcpserver/conn.go:259-285;v2:packages/core/src/mcp/client.ts:143;internal/mcpclient/modern.go:98-104
CAP-09 client elicitation.url
- What. Declares support for URL-mode elicitation (the server hands the user a URL to visit; used for OAuth and payment flows).
- Where. 2025-11-25 and 2026. opencode v2 declares
url: {}and also handlesnotifications/elicitation/complete; opencode v1 has no elicitation at all. - mcpx @ 05c78b2. As a server the gate is right: url mode needs a 2025-11-25+ host that declared an explicit
urlkey (internal/mcpserver/conn.go:198-207). As a client mcpx declares bare{}upstream (internal/mcpclient/modern.go:51), yet its broker acceptsmode: "url"requests anyway (elicitation register), so a conforming upstream never sends one through mcpx even when the attached host (opencode v2) could open it. - Value to mcpx. + OAuth-style and payment flows from remote upstreams would work through mcpx.
- Effort. M — the declaration has to reflect whether a URL-capable host is attached; 2026’s per-request
capabilities allow exactly that, legacy
initializedoes not. - Risk. If declared unconditionally: a URL request with no host able to open it strands the user until the deadline. If not declared: remote servers needing URL flows cannot be used through mcpx.
- Detail. In 2026 mcpx could declare
urlonly on requests forwarded on behalf of a URL-capable host; in legacy eras the declaration is fixed atinitializefor the life of the upstream instance, which may be shared by several hosts. - Sources.
schema/2026-07-28/schema.ts:771;internal/mcpserver/conn.go:198-207;internal/mcpclient/modern.go:51“"elicitation": map[string]any{},”;v2:packages/core/src/mcp/client.ts:143;v2:packages/core/src/mcp/client.ts:160
CAP-10 client tasks (client-hosted tasks)
- What. 2025-11-25 lets a client host tasks for server→client
sampling/createMessageandelicitation/create, and declaretasks.list/tasks.cancel. 2026 deletes the field: client-hosted tasks cannot be expressed once unsolicited server→client requests are forbidden. - Where. 2025-11-25 only. Neither opencode version declares it (v1 has it commented out, with an issue link); lootbox declares nothing.
- mcpx @ 05c78b2. Not declared upstream (
internal/mcpclient/modern.go:46-58). - Value to mcpx. − work for a one-revision feature.
- Effort. S — leave undeclared.
- Risk. None.
- Detail. Sub-fields:
list,cancel,requests.sampling.createMessage,requests.elicitation.create. SEP-2663 removes client-hosted tasks because polling a client-hosted task would itself be an unsolicited server→client request. The server-side replacement is the tasks extension (CAP-26). - Sources.
schema/2025-11-25/schema.ts:346“tasks?: {”;seps/2663-tasks-extension.md:35“Client-hosted tasks are no longer expressible.”;v1:packages/opencode/src/mcp/index.ts:48“// tasks: {},”
CAP-11 client extensions map
- What. Map of extension identifier to settings object; keys follow the
_metakey-naming rules with a mandatory prefix;{}means supported with no settings. - Where. 2026 only.
- mcpx (current). Modern client declares the tasks extension; legacy initialize omits the map (
internal/mcpclient/modern.go:capabilities). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + the only way to opt into tasks, apps and auth extensions from 2026 servers.
- Effort. S to declare; each extension then costs its own implementation.
- Risk. None for declaring nothing; declaring an extension mcpx cannot handle would be the #177 class.
- Detail. If one side supports an extension and the other does not, the supporting side MUST revert to core
behaviour or reject. The 2026 extensions map has no counterpart in
initialize. - Sources.
schema/2026-07-28/schema.ts:785“extensions?: { [key: string]: JSONObject };”;2026-07-28/basic/versioning.mdx:121“If one party supports an extension but the other does not, the supporting”;internal/mcpclient/modern.go:46-58
CAP-12 server logging
- What. A server that emits
notifications/messagedeclareslogging. 2026 deprecates it (SEP-2577) but keeps the field; logging moves to a per-request_metalevel (logging register). - Where. All five; deprecated in 2026.
- mcpx @ 05c78b2. Never declared in any revision (
internal/mcpserver/server.go:1398; rationale atinternal/mcpserver/server.go:566-571; wire W2 shows nologging). That is truthful — mcpx emits nonotifications/message.docs/protocol.md:104claims it is declared for 2025-03-26..2025-11-25 and “removed” in 2026; both halves are wrong against the code and the schema. - Value to mcpx. + fix the doc; the code is right.
- Effort. S.
- Risk. If the doc stays: operators expect log notifications that never come.
- Detail. opencode v1 forwards server log messages into its own log; v2 drops them (logging register).
- Also recorded from the logging register. All five revisions; deprecated (SEP-2577) in 2026-07-28. Never declared
in any revision:
capabilities()builds only tools, resources, prompts, completions and the task objects (internal/mcpserver/server.go:1398-1403). Thelogging/setLevelhandler explains why: mcpx sends no log messages, so declaring the capability “was a promise of a stream that does not exist” (internal/mcpserver/server.go:566-571). Wire W2 shows nologging.docs/protocol.md:104still markslogging✓ for 2025-03-26..2025-11-25 and “removed in 2026-07-28”; both halves are wrong against the code and the schema. The 2026 prose repeats the MUST (“Servers that emit log message notifications MUST declare”), so even a deprecated feature keeps its gate. Capability value isJSONObjectin 2026 (wasobject). - Sources.
schema/2024-11-05/schema.ts:216“logging?: object;”;schema/2026-07-28/schema.ts:801“@deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577).”;schema/2026-07-28/schema.ts:808“logging?: JSONObject;”;internal/mcpserver/server.go:1398;docs/protocol.md:104;2025-11-25/server/utilities/logging.mdx:19“Servers that emit log message notifications”;2026-07-28/server/utilities/logging.mdx:32“Servers that emit log message notifications”; wire W2.
CAP-13 server completions
- What. 2025-03-26 adds the
completionscapability; servers that supportcompletion/completeMUST declare it. 2024-11-05 already had the method, with no capability. - Where. Capability in 2025-03-26 and later; method in all five (completion register).
- mcpx @ 05c78b2. Declares
completions: {}in every era it serves (internal/mcpserver/server.go:1402; wire W2), and answers the method in every era. As a client it callscompletion/completeonly if the upstream declaredcompletions(internal/mcpclient/request.go:50). - Value to mcpx. + low; the declaration is correct. What the answers contain is the completion register’s concern.
- Effort. S.
- Risk. None.
- Detail. Unsupported →
-32601from 2025-03-26 prose. mcpx does not serve 2024-11-05, so the missing capability in that revision matters only on the client side (a 2024-11-05 upstream’s completions are unreachable). - Sources.
schema/2025-03-26/schema.ts:240“completions?: object;”;2025-03-26/server/utilities/completion.mdx:24“Servers that support completions MUST declare thecompletionscapability:”;internal/mcpserver/server.go:1402;internal/mcpclient/request.go:50
CAP-14 server prompts
- What. A server that offers prompts declares
prompts(with optionallistChanged, CAP-15). - Where. All five. opencode v1 and v2 list prompts only from servers that declare
promptsand turn each into a slash command. - mcpx @ 05c78b2. Always declared (
internal/mcpserver/server.go:1401), whether or not any upstream has prompts. - Value to mcpx. + low: an empty prompt list costs a host one cheap request.
- Effort. S.
- Risk. None.
- Detail. Unlike
resources(CAP-16), declaringpromptscosts opencode no model context: prompts become commands, not tools. - Sources.
schema/2024-11-05/schema.ts:220“prompts?: {”;internal/mcpserver/server.go:1401;v1:packages/opencode/src/command/index.ts:105;v2:packages/core/src/plugin/command.ts:55
CAP-15 server prompts.listChanged
- What. Until 2025-11-25,
listChanged: truemeans the server may pushnotifications/prompts/list_changedunsolicited. In 2026 it means the server SHOULD send it to clients that openedsubscriptions/listenwithpromptsListChanged. - Where. All five; delivery model changes in 2026. opencode v2 refetches prompts (and republishes the commands) on the notification; v1 ignores it but fetches prompts fresh on each command listing.
- mcpx @ 05c78b2. Declared as “this connection can push” (
internal/mcpserver/server.go:1390): true on stdio (wire S2), false over HTTP (wire W2), because HTTPConns have no push function (internal/mcpserver/conn.go:87-92). On a legacy stdio connection nothing starts the notifier —restartListenis called only fromresources/subscribeandsubscriptions/listen(internal/mcpserver/server.go:619,internal/mcpserver/server.go:636) — so the true flag is never delivered. The comment atinternal/mcpserver/server.go:1393-1396claims the opposite. - Value to mcpx. + legacy hosts would see upstream prompts appear and disappear; the daemon’s event bus already sees
those changes (
internal/daemon/hooks.go:37-51). - Effort. S — start a listener for the three list kinds on a legacy stdio connection after
initialized. - Risk. If not done: a declared-but-not-delivered capability (#177 class). Over HTTP the flag is honest but no host
ever learns of a change: mcpx offers no legacy GET stream and answers 2026
subscriptions/listenover HTTP with an empty 202 (transports register). - Detail. The 2025-11-25 schema says list_changed “may be issued by servers without any previous subscription”;
2026 ties it to an opted-in stream and requires
_meta["io.modelcontextprotocol/subscriptionId"]on every such notification (notifications register). What changes is the upstreams’ lists surfaced through mcpx’s ownprompts/list. - Sources.
schema/2026-07-28/schema.ts:829“listChanged?: boolean;”;schema/2025-11-25/schema.ts:1159“This may be issued by servers without any previous subscription from the client.”;2026-07-28/server/prompts.mdx:169;internal/mcpserver/server.go:1390“push := s.Notify != nil && c != nil && c.canPush()”;internal/mcpserver/server.go:1393-1396“are defined in every revision mcpx serves and arrive unsolicited on a”;v2:packages/core/src/mcp/index.ts:374
CAP-16 server resources
- What. A server that offers resources declares
resources(with optionalsubscribeandlistChanged). - Where. All five. opencode v1 adds three model-facing tools (
list_mcp_resources,list_mcp_resource_templates,read_mcp_resource) to every request as soon as any connected server declaresresources, even in code mode; v2 uses the capability only for the TUI@list. - mcpx @ 05c78b2. Always declared (
internal/mcpserver/server.go:1400), even when no upstream has resources. - Value to mcpx. + three tool schemas on every opencode v1 request is exactly the context cost mcpx exists to
avoid; − mcpx always has at least its own artifact template (
mcpx://artifacts/{id}, wire W18), so dropping the capability also drops artifact reads for that host. A per-host choice, not a simple removal. - Effort. S.
- Risk. If not done: every opencode v1 session pays the three schemas. If done unconditionally: hosts lose
resources/readof script artifacts. - Detail. v1 gates resource reads behind a
readpermission with patternmcp:<server>:<uri>. How resources reach the model in each product is RES-16. - Sources.
schema/2024-11-05/schema.ts:229“resources?: {”;internal/mcpserver/server.go:1400;v1:packages/opencode/src/session/tools.ts:136;v2:packages/tui/src/component/prompt/autocomplete.tsx:413
CAP-17 server resources.subscribe
- What. Legacy: the server supports
resources/subscribe. 2026 removes that RPC but keeps the flag, redefined as “supportsresourceSubscriptionsonsubscriptions/listen”. - Where. All five; meaning changes in 2026. Neither opencode version ever subscribes.
- mcpx @ 05c78b2.
subscribe: push(internal/mcpserver/server.go:1400): true on stdio (wire S2), false over HTTP (wire W2).resources/subscribeis accepted in every era but nothing subscribes upstream (SUB-15), so on stdio the flag is declared and not delivered. - Value to mcpx. + real resource watches (files, tickets) through mcpx.
- Effort. M — needs upstream subscription on legacy servers and a listen stream on modern ones.
- Risk. If not done: a host subscribes and waits for updates that arrive only if an upstream sends them unasked.
A client reading the 2026 flag as “call
resources/subscribe” gets-32601from a pure-2026 server. - Detail. The 2026 changelog says the RPC was replaced but never says the flag was kept and redefined — the prose
(resources.mdx) and schema do. On stdio, mcpx maps a subscribe onto
restartListenwith only the URI list, which replaces any active list-changed filter on that connection (internal/mcpserver/server.go:619). - Also recorded from the notifications register. The flag is in all five revisions; its meaning changes in
2026-07-28, which the changelog does not say. Declares
subscribe: pushin every era (internal/mcpserver/server.go:1400), true on stdio and false over HTTP. Updates reach a subscriber only if an upstream sends them unasked, because mcpx never subscribes upstream (SUB-15; the resources register has the details). - Sources.
schema/2026-07-28/schema.ts:850“subscribe?: boolean;”;2026-07-28/server/resources.mdx:58“-subscribe: whether the server supports resource-specific update notifications”;2026-07-28/changelog.mdx:18;internal/mcpserver/server.go:1400;internal/mcpserver/server.go:619
CAP-18 server resources.listChanged
- What. As CAP-15, for
notifications/resources/list_changed; in 2026 opted into withresourcesListChanged. - Where. All five; listen-only in 2026. opencode v2 publishes a
ResourcesChangedevent on it; v1 ignores it. - mcpx @ 05c78b2. Same as CAP-15: true on stdio and never delivered to legacy hosts, false over HTTP
(
internal/mcpserver/server.go:1400,internal/mcpserver/server.go:1390). - Value to mcpx. + hosts learn when an upstream adds resources.
- Effort. S (shares CAP-15’s fix).
- Risk. As CAP-15.
- Detail. mcpx’s pool does drop its cached schema on an upstream
list_changedand publishes an event (internal/daemon/hooks.go:37-51); only the last hop to the host is missing. - Sources.
schema/2026-07-28/schema.ts:854“listChanged?: boolean;”;2026-07-28/server/resources.mdx:234;internal/mcpserver/server.go:1400“"resources": map[string]any{"subscribe": push, "listChanged": streamed},”;v2:packages/core/src/mcp/index.ts:375
CAP-19 server tools
- What. A server that offers tools declares
tools. - Where. All five. opencode v1 registers its
tools/list_changedhandler only when the server declarestools. - mcpx @ 05c78b2. Always declared (
internal/mcpserver/server.go:1399). - Value to mcpx. + low; nothing to change.
- Effort. S.
- Risk. None.
- Detail. mcpx’s tool list is its own fixed surface plus generated op tools (tools register), not the upstreams’ tools.
- Sources.
schema/2024-11-05/schema.ts:242“tools?: {”;internal/mcpserver/server.go:1399;v1:packages/opencode/src/mcp/index.ts:462
CAP-20 server tools.listChanged
- What. As CAP-15, for
notifications/tools/list_changed; in 2026 opted into withtoolsListChanged. - Where. All five; listen-only in 2026. Both opencode versions refetch tools on it (v2 debounces the registry
reconcile by 100 ms; on a modern connection v2’s SDK opens
subscriptions/listenitself). - mcpx @ 05c78b2. Same declaration rule as CAP-15 (
internal/mcpserver/server.go:1399). Independently of delivery, mcpx’s own tool list cannot change: the MCP surface is built once per daemon under async.Once(internal/cli/serve_ask.go:242), so adapters or APIs added by a config reload never appear. - Value to mcpx. + adapters added at runtime would reach hosts without a restart.
- Effort. S for delivery; S to rebuild the surface on reload.
- Risk. If not done: users restart the daemon to see new adapter tools; the declared flag promises something that cannot happen.
- Detail.
tools/list_changedis the only list-changed kind both opencode versions honour, so it is the one that matters most for mcpx’s primary host. - Also recorded from the notifications register. Legacy revisions (2024-11-05..2025-11-25) over stdio.
Capabilities are true when the connection can push (
internal/mcpserver/server.go:1390,:1397-1402; wire S2).restartListenis called only fromresources/subscribeandsubscriptions/listen(internal/mcpserver/server.go:619,:636), andServeStdiostarts nothing (internal/mcpserver/server.go:1000-1073). The capability comment claims list_changed notifications “arrive unsolicited on a connection that can push” (internal/mcpserver/server.go:1393-1396). Over HTTP the flags are truthfullyfalse(no push), anddocs/protocol.md:100-102claims ✓ everywhere. A send-conservatively conflict: a capability promising a stream that does not exist. Legacy HTTP hosts could have had these on the GET stream, which mcpx answers with 405 (a transports-register item). - Sources.
schema/2026-07-28/schema.ts:869“listChanged?: boolean;”;2026-07-28/server/tools.mdx:238“When the list of available tools changes, servers that declared thelistChanged”;internal/cli/serve_ask.go:242“l.once.Do(func() { l.srv, l.err = l.app.MCPServer(ctx) })”;v1:packages/opencode/src/mcp/index.ts:462;v2:packages/core/src/tool/mcp.ts:140;internal/mcpserver/server.go:1390“push := s.Notify != nil && c != nil && c.canPush()”;internal/mcpserver/server.go:1393-1396“arrive unsolicited on a”;internal/mcpserver/server.go:619;docs/protocol.md:100; wire S2.
CAP-21 server core tasks object
- What. 2025-11-25 servers declare task support in
capabilities.tasks(list,cancel,requests.tools.call). 2026 moves tasks into theio.modelcontextprotocol/tasksextension, and SEP-2663 says servers MUST NOT keep advertising the legacy capabilities under a protocol version that includes the extension. - Where. 2025-11-25 only; replaced by the extension in 2026.
- mcpx (current). Core tasks capability is limited to legacy revision; modern gets extension only (
internal/mcpserver/server.go:capabilities). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + keeps the capability object honest to modern hosts.
- Effort. S — add a ceiling for the core shape.
- Risk. Low for parsing (open set), but the declaration promises
tasks/listandtasks/result, which the extension removed. - Detail. The comment above the branch says the capability is “declared both ways and a client of either era finds it where it looks”; SEP-2663 forbids exactly that. The shape of what mcpx actually returns for tasks is in the tasks register.
- Sources.
internal/mcpserver/server.go:1411;internal/mcpserver/server.go:1406“declared both ways and a client of either era finds it where it”;seps/2663-tasks-extension.md:949“MUST NOT continue to advertise the legacy capabilities under any protocol version that includes this extension”;2026-07-28/changelog.mdx:22;schema/2025-11-25/schema.ts:433“tasks?: {”
CAP-22 server tasks.list
- What. Declares
tasks/list. A server that cannot bind tasks to requestors SHOULD NOT declare it. - Where. 2025-11-25; removed with the extension (which has no
tasks/list). - mcpx (current). Legacy list is declared and lists connection-owned tasks only; unowned tasks remain accessible by ID (
internal/mcpserver/server.go:capabilities;internal/mcpserver/tasks.go:handleTask,visible; #1554). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + removes cross-host metadata exposure on a shared daemon.
- Effort. S — stop declaring, or bind tasks to a requestor.
- Risk. If not done: any host sees every host’s task ids and can collect their results.
- Detail. Task ids are 64 random bits (
internal/tasks/tasks.go:86-90), “cryptographically secure” only weakly. - Sources.
schema/2025-11-25/schema.ts:437“list?: object;”;2025-11-25/basic/utilities/tasks.mdx:876“SHOULD NOT declare thetasks.listcapability”;internal/mcpserver/server.go:1412
CAP-23 server tasks.cancel
- What. Declares
tasks/cancel. In 2025-11-25 cancelling a terminal task MUST be rejected with-32602; the 2026 extension implies cancel and answers with an empty acknowledgement. - Where. 2025-11-25; implied by the extension in 2026.
- mcpx (current). Legacy cancel declared and terminal cancel rejected; modern extension acknowledges (
internal/mcpserver/server.go:capabilities;internal/mcpserver/tasks.go:handleTask,handleModernTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low.
- Effort. S.
- Risk. Low.
- Detail. The declaration itself is fine for 2025-11-25; the behaviour behind it is the gap.
- Sources.
schema/2025-11-25/schema.ts:441“cancel?: object;”;2025-11-25/basic/utilities/tasks.mdx:494;internal/mcpserver/tasks.go:124“return reply(snap)”
CAP-24 server tasks.requests.tools.call
- What. Declares that
tools/callmay be task-augmented. Each tool must also opt in withexecution.taskSupport; absent means"forbidden", and a conforming client then MUST NOT request a task. - Where. 2025-11-25 only.
- mcpx (current). Legacy task execution is advertised; eligible gateway tools declare optional support and forbidden tools are rejected (
internal/mcpserver/server.go:taskOptional,capabilities;internal/mcpserver/tasks.go:legacyTaskSupport,maybeTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + long
mcpx_exec/mcpx_callruns could survive disconnects for conforming hosts. - Effort. S — add
execution: {taskSupport: "optional"}to long-running tools, at 2025-11-25 only. - Risk. If not done: declared but unreachable (#177 class). If done carelessly:
taskSupport: "required"would make the tool uncallable from opencode v1, whose SDK throws on it (TOOL-21). - Detail.
executionmust not be sent below 2025-11-25, andtools/listis not revision-filtered today (TOOL-25). - Also recorded from the tasks register. 2025-11-25 only; the extension has no per-tool gate. Declares
tasks.requests.tools.call(internal/mcpserver/server.go:1413), but itsTooltype has noexecutionfield (internal/mcpserver/server.go:260-268), so every tool isforbiddenand a conforming client never asks for a task. mcpx still accepts ataskparam from anyone and creates the task (internal/mcpserver/server.go:477-501; wire W22) rather than-32601. The field must not be sent below 2025-11-25 (send conservatively). The tools register ownsTool.executionitself. - Sources.
schema/2025-11-25/schema.ts:453“call?: object;”;2025-11-25/basic/utilities/tasks.mdx:115“clients MUST NOT attempt to invoke the tool as a task”;internal/mcpserver/server.go:1413;internal/mcpserver/server.go:260-268;schema/2025-11-25/schema.ts:1270; wire W22.
CAP-25 server extensions map
- What. Servers advertise extensions in an
extensionsmap returned byserver/discover. - Where. 2026 only;
ServerCapabilitiesin 2025-11-25 has no such member. - mcpx (current). Server extension map is emitted only for modern peers (
internal/mcpserver/server.go:capabilities). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low; a conservative send.
- Effort. S — gate on 2026.
- Risk. Low (open set).
- Detail. The only key mcpx puts in it is the tasks extension (CAP-26).
- Sources.
schema/2026-07-28/schema.ts:882“extensions?: { [key: string]: JSONObject };”;2026-07-28/changelog.mdx:32;internal/mcpserver/server.go:1415;docs/protocol.md:93-94;docs/protocol.md:106
CAP-26 extensions["io.modelcontextprotocol/tasks"] key
- What. The 2026 way to negotiate tasks: the server advertises the key via
server/discover, the client declares it in each request’s capabilities. It is not wire-compatible with 2025-11-25 tasks. - Where. 2026, as an official extension (SEP-2663). Under 2025-11-25, servers MUST NOT treat the capability as enabling tasks.
- mcpx (current). Server and modern client declare the extension; client polls task results, but upstream task input relay remains open (
internal/mcpserver/server.go:capabilities;internal/mcpclient/modern.go:capabilities;internal/mcpclient/tasks.go:finishModernTask,TaskInputRequiredError; #1554). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + long-running upstream jobs through modern servers; honest advertising to modern hosts.
- Effort. M — the extension’s shapes (
resultType: "task",tasks/update,DetailedTask) differ from 2025-11-25. - Risk. If not done: 2026 hosts are promised an extension whose wire format mcpx does not speak.
- Detail. SEP-2663 names the protocol version
2026-06-30in its compatibility table, not2026-07-28; the local clone has no other source for the extension (the normative text lives in an externalext-tasksrepo). Its backward-compatibility table is the only place that says servers MUST migrate from the legacy capabilities. - Also recorded from the tasks register. 2025-11-25 core; 2026-07-28 extension. Declares both, to both eras
(
internal/mcpserver/server.go:1404-1417); the code comment says so on purpose: “declared both ways and a client of either era finds it where it looks” (internal/mcpserver/server.go:1405-1407). As a client of modern upstreams it never declares the extension. No extension-specific settings are defined yet; The doc repeats the table (docs/protocol.md:106) against its own rule that nothing is declared outside the revision that defines it. - Sources.
seps/2663-tasks-extension.md:47;seps/2663-tasks-extension.md:948“This extension is not defined under the2025-11-25protocol version.”;seps/2663-tasks-extension.md:949;internal/mcpserver/server.go:1415;2025-11-25/basic/utilities/tasks.mdx:45;schema/2025-11-25/schema.ts:433;seps/2663-tasks-extension.md:83“No extension-specific settings are currently defined; an empty object indicates support.”;internal/mcpserver/server.go:1404-1417;docs/protocol.md:106.
Code mode and script execution
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: Every difference in how lootbox, mcpx, opencode code mode (v1, v2) and Cloudflare code mode run model-written code against MCP tools.
Code mode means the model writes a program that calls MCP tools as typed functions, and only the program’s output
goes back into context. The products agree on that idea and differ on engine, sandbox, limits, how results and
failures come back, how tools are discovered, and whether anything outside a model turn can run it. For mcpx, two
things matter most: it has no sandbox, on purpose, which the MCP client guide treats as a precondition (CODE-10);
and where others already do something mcpx needs, the gaps are small and concrete: an output cap, typed failures,
outputSchema return types, reserved-name checks. Differences whose area another register owns (result unwrapping,
the opencode plugin’s mcpx_exec, pools, credentials) are referred to by that register’s ID; in the Where column
v1/v2 are opencode 1.18.31/2.0.3, CF is Cloudflare, and lootbox is the deployed build unless a row says “fork”
(CODE-01).
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| CODE-01 | Deployed lootbox is upstream 587a5a1 plus four patches, not the fork | lootbox has | deployed: upstream + 4 patches · .lootbox/: fork 887db38 | n/a — decides which lootbox the other rows describe | + low | S | low |
| CODE-02 | Real runtime chosen per run: deno, then bun, then node | mcpx has, others don't | mcpx 3 runtimes · lootbox deno only · v1/v2 interpreter · CF isolate | ✓ — script.runtime auto, deno, bun or node | + med | S | low |
| CODE-03 | opencode runs a confined interpreter with no ambient APIs | opencode v1 has opencode v2 has does not match mcpx's goal | v1 ✓ · v2 ✓ · lootbox, mcpx, CF run real engines | n/a — real runtime with full authority, by choice | − authority | XL | low |
| CODE-04 | Cloudflare runs each execution pass in a fresh V8 isolate | does not match mcpx's goal | CF ✓ · others ✗ | n/a — one OS process group per run | − workerd | XL | low |
| CODE-05 | opencode supports only a subset of JavaScript | opencode v1 has opencode v2 has | v1 no classes or generators · v2 no classes · others full | ✓ — full TypeScript on a real runtime | + low | S | low |
| CODE-06 | Script type-checked against generated types before it runs | lootbox has | lootbox on by default · mcpx opt-in · v1, v2, CF never | partial — script.typecheck exists, defaults to off | + med | S | med |
| CODE-07 | TypeScript stripped without checking; v2 workerd build skips transpile | opencode v1 has opencode v2 has | v1 ✓ · v2 ✓ (workerd: JS only) · CF JS only | n/a — the real runtime parses TypeScript | + low | S | low |
| CODE-08 | Default script permissions: --allow-net versus --allow-all | lootbox has | lootbox net only · mcpx all · v1/v2, CF no ambient access | ✓ — --allow-all by design; net, strict profiles exist | − policy | S | med |
| CODE-09 | Cloudflare blocks or proxies all outbound network | does not match mcpx's goal | CF ✓ · v1/v2 no network API · lootbox, mcpx open | n/a — no egress control beyond Deno strict | − authority | L | low |
| CODE-10 | MCP client guide: programmatic tool calling requires a sandbox | does not match mcpx's goal | guide (every docs version) · lootbox, v1, v2, CF sandbox · mcpx none | n/a — no sandbox, deliberately | − goal | L | med |
| CODE-11 | Default whole-script wall-clock timeout | lootbox has opencode v1 has opencode v2 has | lootbox 10 s · mcpx 120 s · v1/v2 none · CF 60 s or 30 s | ✓ — exec.timeout 120 s, then process-group kill | + low | S | low |
| CODE-12 | Per-tool-call timeout inside a script | lootbox has opencode v1 has opencode v2 has | lootbox 30 s · mcpx 120 s · v1 SDK 60 s · v2 12 h | ✓ — pool.callTimeout 120 s | + low | S | med |
| CODE-13 | Progress token requested so long calls outlive the timeout | opencode v1 has opencode v2 has mcpx missing | v1 token and reset · v2 token, no reset · lootbox, mcpx none | partial — sends a token when the host did and relays progress (#212); the daemon’s own timeout does not reset on it | + med | S | med |
| CODE-14 | CPU-time and subrequest caps per invocation | does not match mcpx's goal | CF cpuMs, subRequests · others wall clock only | n/a — wall clock only | − marginal | M | low |
| CODE-15 | Tool-call budget per execution | opencode v1 has opencode v2 has | v1/v2 knob, unset by host · lootbox, mcpx, CF none | ✗ — only the timeout bounds a looping script | + low | S | low |
| CODE-16 | Cap on the size of returned output | opencode v1 has opencode v2 has mcpx has | v1/v2 knob · CF 6,000 tokens · lootbox whole stdout, mcpx capped at exec.maxOutputBytes | ✓ — returned stdout capped at the execsvc Result boundary (default 64KiB): head kept, in-band marker names dropped bytes, stdoutTruncated set; full record unchanged (#1937) | + high | S | med |
| CODE-17 | Concurrent tool calls within one program | opencode v1 has | v1 8 · v2 unrestricted · lootbox, mcpx, CF no cap | n/a — per-server pool max 4, no per-run cap | + low | S | low |
| CODE-18 | Upstream call cancelled when the script times out or dies | has better replacement | mcpx ✓ · v1/v2 ✓ · lootbox ✗ · CF unverified | ✓ — context cancel sends notifications/cancelled | + low | S | low |
| CODE-19 | Final top-level expression becomes the result | opencode v2 has mcpx missing | v2 ✓ · CF ✓ · v1 null · lootbox none · mcpx stdout | ✗ — result is stdout, emit() or a default export (#216) | + med | M | low |
| CODE-20 | What happens to un-awaited calls when the program ends | opencode v1 has opencode v2 has | v1 awaited · v2 interrupted · lootbox, mcpx run on | n/a — the runtime’s event loop finishes them | + low | S | low |
| CODE-21 | Working directory the script runs in | lootbox has has better replacement | lootbox daemon’s cwd · mcpx caller’s cwd · v1, v2, CF none | ✓ — caller’s cwd; /v1/exec takes cwd | + low | S | low |
| CODE-22 | Caller chooses local or remote execution | mcpx has, others don't | mcpx ✓ · lootbox always daemon · v1/v2 in-process · CF platform | ✓ — exec.where auto, local or remote | + med | S | low |
| CODE-23 | How arguments reach a script | lootbox has has better replacement | lootbox stdin only · mcpx argv, exports, @param · CF one input | ✓ — argv, default export, --export, placeholders | + low | S | low |
| CODE-24 | Where named scripts are looked up | lootbox has has better replacement | lootbox one dir · mcpx upward search · CF snippets · v1/v2 none | ✓ — .mcpx/scripts upward, then user dir | + low | S | low |
| CODE-25 | Named injection points in the script launcher | mcpx has, others don't | mcpx ✓ · others wrap code, no hooks | ✓ — before, prefix, onSuccess, onError, suffix | + low | S | low |
| CODE-26 | emit() streams values out while the script runs | mcpx has, others don't | mcpx ✓ · others return at the end | ✓ — SSE or NDJSON frames from /v1/exec | + med | S | low |
| CODE-27 | Structured log records on a side channel | mcpx has, others don't | mcpx records · v1/v2, CF logs: string[] · lootbox raw stderr | ✓ — log.* behind a sentinel, parsed into logs[] | + low | S | low |
| CODE-28 | lootbox drops stderr on success and stdout on failure | has better replacement | lootbox ✗ · mcpx, v1/v2, CF keep both | ✓ — keeps both; stderr tail-bounded | + low | S | low |
| CODE-29 | One JSON document per run | has better replacement | mcpx ✓ · v1/v2 internal · CF three variants · lootbox text | ✓ — --json run, /v1/exec, mcpx_exec | + low | S | low |
| CODE-30 | Failures reported as typed kinds | opencode v1 has opencode v2 has mcpx missing | v1/v2 kind · CF status: "error" · mcpx, lootbox strings | ✗ — error string, exitCode, timedOut only (#216) | + med | M | low |
| CODE-31 | Budgeted catalogue: every namespace, signatures to about 2,000 tokens | opencode v1 has opencode v2 has | mcpx ✓ · v1 ✓ · v2 ✓ · lootbox counts only · CF all or none | ✓ — mcpx catalog, mcpx_catalog, 2,000-token default | + low | S | low |
| CODE-32 | Catalogue changes sent as a delta when shorter | opencode v2 has | v2 ✓ · mcpx machinery ✓ · v1 full re-render | ✓ — Fingerprint, Diff, ShorterOf exist | + low | S | low |
| CODE-33 | Pinned tools always shown in the catalogue | opencode v2 has | v2 ✓ · mcpx --bias only · others ✗ | ✗ — bias scoring favours terms; nothing is pinned | + low | S | low |
| CODE-34 | Tool search callable from inside the program | opencode v1 has opencode v2 has | mcpx sync · v1 async · v2 sync · CF async · lootbox ✗ | ✓ — synchronous search() and describe() | + low | S | low |
| CODE-35 | In-program search pages its results | opencode v1 has opencode v2 has mcpx missing | v1/v2 offset, next · CF truncated · mcpx limit only | ✗ — search(query, limit = 20), no offset (#216) | + low | S | low |
| CODE-36 | In-program search also finds saved snippets | mcpx missing | CF ✓ · mcpx recipes outside scripts · others ✗ | ✗ — in-script search() covers tools only (#216) | + low | M | low |
| CODE-37 | Approval pauses a run by aborting it and replaying it | does not match mcpx's goal | CF ✓ · v1/v2 ask per call · mcpx elicitation · lootbox ✗ | n/a — elicitation blocks in-process; no replay | − determinism | XL | low |
| CODE-38 | Rollback through a per-tool revert | does not match mcpx's goal | CF ✓ · others ✗ | n/a — MCP tools have no revert | − no-revert | XL | low |
| CODE-39 | Only the host may promote a run to a saved snippet | does not match mcpx's goal | CF host only · lootbox, mcpx agent writes files · v1/v2 none | n/a — agents write .mcpx/scripts/ directly | − workflow | M | med |
| CODE-40 | Tool names that are JavaScript reserved words get a suffix | mcpx missing | CF ✓ · mcpx, lootbox ✗ · v1/v2 bracket notation | ✗ — mcpx types prints function delete(…) (#215) | + low | S | low |
| CODE-41 | Namespace names checked against reserved words and prelude names | mcpx missing | CF ✓ · lootbox mcp_ prefix · v1/v2 brackets · mcpx ✗ | ✗ — a server named log may break every script (#215) | + med | S | high |
| CODE-42 | Two tool names that sanitise to the same identifier | mcpx missing | CF throws · mcpx last wins · lootbox first wins | ✗ — duplicate keys; wrong tool called silently (#215) | + med | S | med |
| CODE-43 | opencode keeps real tool names and uses bracket notation | opencode v1 has opencode v2 has | v1/v2 ✓ · mcpx, lootbox, CF rename | n/a — renames fancy-name to fancy_name | + low | M | low |
| CODE-44 | Return types generated from a tool’s outputSchema | opencode v1 has opencode v2 has 2025-06-18 has mcpx missing | v1/v2 ✓ · CF field · mcpx any · lootbox envelope | ✗ — parses outputSchema; every result typed any (#216) | + med | S | low |
| CODE-45 | lootbox’s generated types carry no descriptions | has better replacement | lootbox ✗ · mcpx, v1/v2, CF ✓ | ✓ — JSDoc for tools and properties | + low | S | low |
| CODE-46 | lootbox generates one function per MCP resource | lootbox has | lootbox ✓ · mcpx generic reader · v1/v2 none · CF undocumented | n/a — one readResource(uri) | − bloat | S | low |
| CODE-47 | lootbox exposes local TypeScript files as namespaces | lootbox has has better replacement | lootbox ✓ · mcpx adapters and imports | n/a — a plain module import instead, by choice | − cost | M | low |
| CODE-48 | lootbox YAML prompt workflows | lootbox has does not match mcpx's goal | lootbox ✓ · others ✗ | n/a — left out on purpose | − scope | M | low |
| CODE-49 | lootbox’s script-to-gateway protocol is its own WebSocket JSON | lootbox has has better replacement | lootbox ✓ · mcpx REST and MCP · v1/v2 in-process · CF RPC | ✓ — documented REST /v1/call, /v1/exec | + low | S | low |
| CODE-50 | Script client reaches the daemon over a unix socket | mcpx has, others don't | mcpx deno/bun socket, node TCP · others ✗ | ✓ — socket mode is the access control | + med | S | low |
| CODE-51 | Cloudflare connects script and host through Workers RPC | does not match mcpx's goal | CF ✓ · others ✗ | n/a — HTTP to the daemon | − Workers-only | XL | low |
| CODE-52 | Every run recorded with its script text and output | lootbox has | lootbox SQLite · mcpx log without source · CF last 50 · v1/v2 transcript | partial — run ids and log records; no script source | + low | M | med |
| CODE-53 | opencode wraps MCP servers in code mode by default | opencode v1 has opencode v2 has mcpx missing | v2 default, per server · v1 behind a flag this build sets · opt-out only for executor | ✗ — docs never tell users to opt out (#218) | + high | S | med |
| CODE-54 | Code execution reachable from a shell, HTTP or CI | lootbox has | mcpx ✓ · lootbox CLI and WS · CF Worker · v1/v2 model turn only | ✓ — mcpx exec, /v1/exec, mcpx_exec | + high | S | low |
CODE-01 The deployed lootbox is upstream 587a5a1 plus four patches, not the fork
- What. The daemon answering on 127.0.0.1:9420 is
~/.local/bin/lootbox, built bylootbox-updatefrom upstreamjx-codes/lootbox@587a5a1plus four patches. The.lootbox/checkout cited throughout this register is thedezren39fork (branchconfigurable-timeout, HEAD887db38), 51 commits ahead of that upstream. - Where. Deployed: upstream plus
lootbox-deno-2.9,lootbox-global-config,lootbox-loopbackandlootbox-ui-dir. The fork is not deployed. - mcpx @ 05c78b2. n/a.
ASSESSMENT.mdaudited “jx-codes/lootbox(and thedezren39fork)” against the live installation (ASSESSMENT.md:3-4) and counts the fork 51 commits ahead (ASSESSMENT.md:9-10). - Value to mcpx. + low: every lootbox cell in this register is only as accurate as the build it describes.
- Effort. S — a note; no code.
- Risk. Reading a fork-only feature (the health monitor,
per-session) as behaviour of the running daemon. - Detail. Fork-only: the health monitor, the multi-client strategies (
warn/fail/auto-port/per-session), configurable timeout and permissions, the deep/health, andlootbox health. The live/healthreturned{"status":"ok"}, not the fork’s subsystem report, which confirms the upstream build is what runs. The core code-mode path (parse_mcp_schemas.ts,mcp_schema_fetcher.ts) is the same in both;git diff --stat 587a5a1 HEADdoes not list those files. Upstream hard-codes what the fork makes configurable:git show 587a5a1:src/lib/execute_llm_script.tshas a 10-second script timeout (line 26) and--allow-net,--allow-import=localhost:<port>,--reload=…/client.ts,--no-check=remote(lines 31-34), and itssrc/lib/rpc/execute_mcp.tshard-codes a 30-second per-call timeout (line 60), so CODE-06, CODE-08, CODE-11 and CODE-12 hold for both. Rows about fork-only process behaviour (startup pings, multi-instance strategies) are in the process-model register (PM-22, PM-24). - Sources.
nix:flake.nix:386-398(pinsrev = "587a5a1b…"and the fourlootboxPatches);ASSESSMENT.md:9-10“the fork is already 51 commits ahead”
CODE-02 Real runtime chosen per run: deno, then bun, then node
- What. mcpx runs a script on a real JavaScript runtime: the one requested, otherwise the first of deno, bun or node
on
PATH. lootbox always uses Deno; opencode uses its own interpreter (CODE-03); Cloudflare uses a V8 isolate (CODE-04). - Where. mcpx only.
- mcpx @ 05c78b2.
Detect(internal/runner/runner.go:72-106); settingscript.runtime, enumauto|deno|bun|node(internal/settings/registry.go:176-178). - Value to mcpx. + med: scripts run wherever any one of three runtimes is installed. − the three behave differently:
only Deno has permission flags (
internal/runner/runner.go:43-45), and node reaches the daemon over TCP only (internal/codegen/emit.go:289-291, CODE-50). - Effort. S — built.
- Risk. A script that works under Deno can fail under node’s strip-only TypeScript, which rejects enums, namespaces
and parameter properties. A cross-runtime test already caught the generated client failing on node for exactly that
reason (
ASSESSMENT.md:260-261). - Detail. Deno runs
run --quiet --no-check <perms> <file>; bun runsbun run <file>; node runs--no-warnings --experimental-strip-types <file>(internal/runner/runner.go:84-101). On bun and node a shim fills inDeno.args,Deno.envandDeno.readTextFile, andreadTextFilerefuses unlessMCPX_ALLOW_READ=1(internal/codegen/emit.go:348-387, guard at:373). ASSESSMENT measured a trivial script against a local client file at 30–60 ms on any of the three (ASSESSMENT.md:79-80). - Sources.
internal/runner/runner.go:73candidates := []string{"deno", "bun", "node"};.lootbox/src/lib/execute_llm_script.ts:38await new Deno.Command("deno", {
CODE-03 opencode runs a confined interpreter with no ambient APIs
- What. opencode transpiles the program, parses it with acorn and runs it in its own tree-walking evaluator. The
program can reach only the host’s
toolstree: no filesystem, network, process, module, import or timer access. - Where. opencode v1 and v2 (
packages/codemode). lootbox and mcpx run a real runtime; Cloudflare runs a real V8 isolate. - mcpx @ 05c78b2. Runs deno/bun/node with
--allow-allby default (internal/runner/runner.go:47-54), andAGENTS.md:28-29promises scripts “the full runtime: filesystem, network, subprocesses”. - Value to mcpx. − adopting it contradicts mcpx’s stated choice of full authority (
internal/runner/runner.go:47) and breaks every script that touches the filesystem or a subprocess. - Effort. XL —
packages/codemode/srcis 6,878 lines of TypeScript in v1 and 9,456 in v2 (wc -l). - Risk. None if not adopted.
- Detail. v2’s
executedescription tells the model “Do not usefetch; all external access goes throughtools” (v2:packages/core/src/codemode/tool.ts:63). v2 has an opt-in host extension mechanism for classes and functions (v2:packages/codemode/interpreter-support.md:455-456), but opencode core registers none:Extension.makeappears only in the package’s own tests and a doc comment (v2:packages/codemode/src/codemode.ts:40). The interpreter yields between steps, so a timeout also stopswhile (true) {}(v1:packages/codemode/README.md:286), and data crossing a boundary nests at most 32 levels (v1:packages/codemode/README.md:288,v2:packages/codemode/README.md:187). mcpx’s own comparison calls this “a genuinely stronger posture than permissions” (OPENCODE-V2.md:190-193).OPENCODE-V2.mdreviewed branchv2@37049a5, not the pinned 2.0.3 (OPENCODE-V2.md:12), and is wrong at the pin in three places here. The doc sayspackages/core/src/codemode/contains “fetch” (OPENCODE-V2.md:29) and that fetch “is provided as an extension” (OPENCODE-V2.md:51); the source has onlycatalog.ts,instructions.tsandtool.tsthere, and the tool says not to usefetch. The doc says “10,764 lines of implementation, 20,075 lines of tests across 34 files” (OPENCODE-V2.md:19); the source has 9,456 lines undersrc/, andtest/holds 43 files, 35 of them.tstotalling 18,056 lines. - Sources.
v2:packages/codemode/README.md:7“Rather than trying to sandbox arbitrary JavaScript, CodeMode only runs the language features we implement.”;v1:packages/codemode/src/interpreter/runtime.ts:1import { parse } from "acorn";v1:packages/codemode/README.md:5;v2:packages/core/src/codemode/tool.ts:63;OPENCODE-V2.md:51“is provided as an extension. Timers, imports, filesystem and process”
CODE-04 Cloudflare runs each execution pass in a fresh V8 isolate
- What. Cloudflare’s
DynamicWorkerExecutorloads the generated code into a new Dynamic Worker for every pass, through the Worker Loader binding, and discards it afterwards. - Where. Cloudflare only.
- mcpx @ 05c78b2. One OS process per run, in its own process group (
internal/runner/runner.go:413). - Value to mcpx. − needs workerd or the Cloudflare platform; mcpx scripts need the local filesystem and processes.
- Effort. XL — a workerd dependency.
- Risk. None; not a candidate.
- Detail. After an approval, a paused execution re-runs the whole program in a new pass, so “Durable state therefore cannot live inside the sandbox” (how-it-works, “Executor”). Dynamic Workers have no build step, so TypeScript must be compiled to JavaScript first (Dynamic Workers “Getting started”). Blog-only claims not confirmed in the docs: an isolate starts “in a handful of milliseconds using only a few megabytes of memory” (2026 blog); the Worker Loader API was in “closed beta” (2025 blog). Current availability and pricing were not checked. The process-model register has the same fact from the process angle (CODE-04).
- Also recorded from the process model register. Dynamic Workers have no build step, so TypeScript must be compiled first.
- Sources.
internal/runner/runner.go:413; https://developers.cloudflare.com/agents/tools/codemode/how-it-works/#executor (“Executor”: “uses a Dynamic Worker Loader to create an isolated Worker for each execution pass”); https://blog.cloudflare.com/code-mode/ (“Dynamic Worker loading: no containers here”); https://developers.cloudflare.com/agents/tools/codemode/how-it-works/#executor “uses a Dynamic Worker Loader to create an isolated Worker for each execution pass”; https://blog.cloudflare.com/code-mode/ “Isolates are so fast that we can just create a new one for every piece of code the agent runs.”
CODE-05 opencode supports only a subset of JavaScript
- What. opencode’s interpreter implements a chosen subset. v1 has no classes, no generators, no
new Promiseand no promise chaining; v2 adds generators andnew Promisebut still has no classes. - Where. opencode v1 and v2. lootbox, mcpx and Cloudflare run the full language.
- mcpx @ 05c78b2. Full TypeScript on a real runtime (CODE-02).
- Value to mcpx. + low: confirms mcpx scripts can paste in an existing module; nothing to build.
- Effort. S — no work.
- Risk. A model used to one harness writes classes and fails under opencode with
UnsupportedSyntax. - Detail. v1 exposes no
eval, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation,new Promise, or.then/.catch/.finally(v1:packages/codemode/README.md:254). v2.0.3’s checklist has 211[x]and 30[ ]lines (rg -c); gaps include tagged-template calls (v2:packages/codemode/interpreter-support.md:60), user-defined constructor calls (:139), and classes and private fields (:141), while generators (:152) andnew Promise(:232) are present. The doc says “251 checked items, 17 gaps” and lists tagged templates as present (OPENCODE-V2.md:104-105); the source at the pin says 211/30 and tagged templates are a gap. - Sources.
v1:packages/codemode/README.md:254;v2:packages/codemode/interpreter-support.md:60“- [ ] Tagged-template calls.”;v2:packages/codemode/interpreter-support.md:141;OPENCODE-V2.md:104“251 checked items, 17 gaps”
CODE-06 Script type-checked against generated types before it runs
- What. lootbox runs scripts with
deno run --no-check=remote. That flag skips remote modules but type-checks local ones, so the script is checked against the generated client’s types and a type error stops the run. - Where. lootbox, on by default (both builds, CODE-01). mcpx: opt-in, off by default. opencode strips types without checking (CODE-07). Cloudflare: the model writes JavaScript and nothing checks it.
- mcpx @ 05c78b2.
script.typecheckdefaults tooff, withon/strictavailable (internal/settings/registry.go:244-246); Deno runs with--no-check(internal/runner/runner.go:87-90); the check runs throughpreflight.TypeCheckwhen enabled (internal/runner/runner.go:328-340). - Value to mcpx. + med: a wrong argument name or type is caught before any tool call has a side effect. − adds
latency to every run; the default-off choice is written down (
internal/runner/runner.go:87-89). - Effort. S — flip the default, or say in the agent instructions that nothing checks.
- Risk. lootbox’s instructions tell agents “the sandbox will reject any scripts that don’t pass”. An agent moving from lootbox to mcpx loses that check without being told.
- Detail. Verified with deno 2.9.6:
--no-check=remoteon a file with a type error exits 1 with “Type checking failed”; plaindeno runruns it. mcpx’s runner comment says lootbox’s--reloadforced “a full type check of the module graph on every single execution” (internal/runner/runner.go:6-7); what is checked is the local script, not the remote client. The check is shallow: lootbox maps nested objects toRecord<string, unknown>(.lootbox/src/lib/external-mcps/parse_mcp_schemas.ts:218-220), whereas mcpx renders$ref/allOf/enum/const/prefixItems(internal/codegen/ts.go:41-61), so mcpx’s check would catch more once turned on. How each loads the generated client per run (lootbox’s HTTP import with--reload) is the process-model register’s PM-25. - Sources.
.lootbox/src/lib/execute_llm_script.ts:34"--no-check=remote",;.lootbox/LLM_QUICK_START.md:113“Type safety is paramount (the sandbox will reject any scripts that don’t pass)”
CODE-07 TypeScript stripped without checking; v2 workerd build skips transpile
- What. opencode wraps the program in an async function and runs TypeScript’s
transpileModule, which reports syntax diagnostics only, before acorn parses the output. v2 chooses the transpiler through a conditional import; its workerd variant is the identity function, so TypeScript-only syntax becomes a parse error there. - Where. opencode v1 and v2. lootbox and mcpx use the runtime’s TypeScript support; Cloudflare needs JavaScript.
- mcpx @ 05c78b2. Full TypeScript through the runtime (CODE-02); no check by default (CODE-06).
- Value to mcpx. + low: corrects mcpx’s own comparison. Nothing for mcpx to build.
- Effort. S.
- Risk. None.
- Detail. The doc says “TypeScript is rejected rather than stripped” (
OPENCODE-V2.md:121); the source transpiles in both versions. The doc’s correction note already says so (OPENCODE-V2.md:3-6). v2’spackage.jsonmaps#transpiletoworkerdanddefaultconditions (v2:packages/codemode/package.json:23-25). A workerd profile suggests opencode runs code mode inside Cloudflare Workers somewhere, probably the web or console app; not traced. - Sources.
v1:packages/codemode/src/interpreter/runtime.ts:116;v2:packages/codemode/src/interpreter/transpile.node.ts:10;v2:packages/codemode/src/interpreter/transpile.workerd.ts:7“so codemode programs are passed through”;OPENCODE-V2.md:121“TypeScript is rejected rather than stripped”
CODE-08 Default script permissions: --allow-net versus --allow-all
- What. A lootbox script gets network access and nothing else by default: no filesystem, no environment, no
subprocess. The network is also its only path to the daemon (
ws://localhost:<port>/ws), so it cannot be removed. An mcpx script gets--allow-all. - Where. lootbox: fork default
["--allow-net"]plus--allow-import=localhost:<port>; upstream hard-codes the same (CODE-01). mcpx:all. opencode and Cloudflare have no ambient access at all (CODE-03, CODE-09). - mcpx @ 05c78b2.
Permissions("")gives--allow-all;netgives--allow-net --allow-env(lootbox’s default plus env);strictgives--allow-net=127.0.0.1 --allow-env(internal/runner/runner.go:51-64);none/offmean “no sandbox”, i.e.--allow-all; the defaultscript.permissionsis"all"(internal/defaults/defaults.json:34). Only Deno honours any of it (internal/runner/runner.go:43-45). - Value to mcpx. − the difference is policy: the user asked for no sandbox (
ASSESSMENT.md:168-169,ASSESSMENT.md:290-292). mcpx’sstrictis tighter than lootbox’s default (loopback only). - Effort. S — nothing to change; state it where agents read.
- Risk. An agent following lootbox’s instructions assumes a sandbox exists; under mcpx it does not.
- Detail. lootbox’s
--allow-netis unrestricted, so a “sandboxed” lootbox script can send anything a tool returns anywhere. lootbox’s own tool workers (.lootbox/tools/*.ts) always run--allow-all(.lootbox/src/lib/rpc/worker_manager.ts:298-301), and--no-sandboxswitches scripts to--allow-all(.lootbox/src/lib/get_config.ts:250-251); the launchd arguments pass no--no-sandbox(nix:configuration.nix:743-749).ASSESSMENT.md:73quotes lootbox’s command asdeno run --allow-all --reload=… --no-check=remote; the source says--allow-netin both the fork and upstream, so that line is wrong. What a script can read of the daemon’s environment, and the missingOrigincheck on the endpoints that run scripts, are the auth register’s AUTH-25 and AUTH-24. - Sources.
.lootbox/src/lib/constants.ts:78export const DEFAULT_PERMISSION_FLAGS: readonly string[] = ["--allow-net"];;.lootbox/src/lib/get_config.ts:485;internal/runner/runner.go:47“The default is wide open.”;ASSESSMENT.md:73“deno run –allow-all –reload=http://localhost:PORT/client.ts –no-check=remote”
CODE-09 Cloudflare blocks or proxies all outbound network
- What.
DynamicWorkerExecutorsetsglobalOutbound: nullby default, sofetch()andconnect()throw. A host may pass aFetcherinstead, which receives every outbound request. - Where. Cloudflare. opencode has no network API (CODE-03). lootbox allows any network. mcpx allows everything;
strictnarrows to 127.0.0.1 under Deno only. - mcpx @ 05c78b2. No egress proxy.
strictrelies on Deno’s--allow-net=127.0.0.1(internal/runner/runner.go:61-63), which bun and node ignore (internal/runner/runner.go:43-45). - Value to mcpx. − mcpx scripts are meant to have the user’s full authority.
- Effort. L — an HTTP proxy plus per-runtime wiring.
- Risk. None; not a candidate.
- Detail. Cloudflare’s argument: “Limiting access via bindings is much cleaner than doing it via, say, network-level filtering or HTTP proxies” (2025 blog, “Isolated by default, but connected with bindings”). The MCP client guide asks for the same isolation (CODE-10).
- Sources.
internal/runner/runner.go:61-63; https://developers.cloudflare.com/agents/tools/codemode/api-reference/#dynamicworkerexecutor (“globalOutbound…nullblocks access. AFetcherreceives all outbound requests.”); https://developers.cloudflare.com/agents/tools/codemode/how-it-works/#executor (“Externalfetch()andconnect()calls are blocked by default.”)
CODE-10 MCP client guide: programmatic tool calling requires a sandbox
- What. The MCP documentation’s client best-practices guide describes code mode as “programmatic tool calling” and says it “requires clients to implement a sandbox environment”: no direct network access, credentials held by the host, timeouts and memory limits, truncated output, and per-call authorization. mcpx has no sandbox by design.
- Where. The guide is documentation, not specification. The same section is in the docs folder of every revision (2024-11-05 through 2026-07-28, and draft); the 2026-07-28 copy is cited. lootbox (Deno, network only), opencode (interpreter) and Cloudflare (isolate) each run a sandbox of some kind. mcpx does not.
- mcpx @ 05c78b2. No sandbox, chosen by the user: “I dropped the sandbox, as you asked” (
ASSESSMENT.md:168) and “No sandbox. You said you did not want one” (ASSESSMENT.md:290); the runner’s default is--allow-all(internal/runner/runner.go:47-54). - Value to mcpx. − adopting the guide’s sandbox contradicts the goal. + low: mcpx’s docs should say plainly that it departs from the guide, and why (local-first, the user’s own authority, stateful servers).
- Effort. L — a real sandbox means a runtime choice (CODE-03, CODE-04) or an egress proxy (CODE-09).
- Risk. Readers who take “the MCP spec now blesses the pattern” at face value (
ASSESSMENT.md:132-134,ASSESSMENT.md:303-304) miss that the guide’s blessing comes with a sandbox mcpx does not have. - Detail. The guide’s integration pattern intercepts calls “over an in-process or stdio channel (so network
permissions can stay fully denied)” (
:260). lootbox and mcpx both reach their broker over the network or a socket (CODE-49, CODE-50), which is why lootbox cannot drop--allow-net(CODE-08). The guide’s other points map to rows here: typed functions fromoutputSchema(:181, CODE-44),isErroras a thrown exception (:301; content-types register, CT-19), resource limits (:294, CODE-11, CODE-14), output truncation (:295, CODE-16), per-call authorization (:290; plugin-API register, PLG-36: opencode checks every child call, mcpx sees one opaque call), and credentials held by the host (:293; auth register, AUTH-25). Cloudflare argues the same point against shell-based tools by name: the agent “needs a shell … a much broader attack surface than a sandboxed isolate” (2026 blog, “Comparing approaches to context reduction”). - Sources.
docs/docs/2026-07-28/develop/clients/client-best-practices.mdx:169-170“requires clients to implement a sandbox environment.”;docs/docs/2026-07-28/develop/clients/client-best-practices.mdx:292“The sandbox should have no direct network access.”; https://modelcontextprotocol.io/docs/2026-07-28/develop/clients/client-best-practices;ASSESSMENT.md:168“I dropped the sandbox, as you asked.”
CODE-11 Default whole-script wall-clock timeout
- What. Defaults: lootbox 10 s, mcpx 120 s, opencode none, Cloudflare 60 s by its API reference and 30 s by its changelog and examples.
- Where. All five differ. opencode’s
timeoutMshas no default and neither host sets one. - mcpx @ 05c78b2.
exec.timeoutis120s(internal/settings/exec.go:16). On timeout the process group gets SIGTERM, then SIGKILL after a grace period, and the exit code is 124 (internal/runner/runner.go:424-438). - Value to mcpx. + low: 120 s suits a browser-driving script; lootbox’s 10 s killed them.
- Effort. S.
- Risk. An opencode
executecan run until the user cancels; the plugin’smcpx_execignores that cancel (plugin-API register, PLG-38). - Detail. lootbox’s CLI client clears its own timeout on any message, including the server’s
welcomeframe, soclient_timeoutnever fires and a stuck daemon hangslootbox execindefinitely (.lootbox/src/lib/lootbox-cli/exec.ts:43-44; welcome sent at.lootbox/src/lib/rpc/managers/connection_manager.ts:163); upstream587a5a1has the sameclearTimeoutinonmessage. opencode v1’s host passes nolimits(v1:packages/opencode/src/tool/code-mode.ts:239-260) and v2’s callsCodeMode.make({ tools, ...hooks })(v2:packages/core/src/codemode/tool.ts:222); v1’s README calls that deliberate for a host “that can interrupt the execution fiber (as OpenCode does on user cancel)” (v1:packages/codemode/README.md:268). In v2 a result returned before cleanup times out stays successful with aTimeoutExceededwarning (v2:packages/codemode/README.md:184-187). Cloudflare’s conflict is unresolved; the API reference is the newest page (2026-07-22). The guide asks for “timeouts and memory limits” (docs/docs/2026-07-28/develop/clients/client-best-practices.mdx:294). - Sources.
.lootbox/src/lib/constants.ts:17export const DEFAULT_TIMEOUT_MS = 10_000;;v2:packages/codemode/README.md:182“Execution limits have no default values.”; https://developers.cloudflare.com/agents/tools/codemode/api-reference/#dynamicworkerexecutor (“timeout|number| No |60000”); https://developers.cloudflare.com/changelog/post/2026-02-20-codemode-sdk-rewrite/ (“default 30s”)
CODE-12 Per-tool-call timeout inside a script
- What. Each tool call a script makes has its own deadline, separate from the script’s: lootbox 30 s, mcpx 120 s, opencode v1 the SDK’s 60 s (reset by progress), opencode v2 12 hours.
- Where. All four local products differ; Cloudflare documents only the executor timeout.
- mcpx @ 05c78b2.
pool.callTimeoutis"120s"(internal/defaults/defaults.json:6), the same length asexec.timeout, so one slow call can use the whole script budget. - Value to mcpx. + low: informational; the split exists.
- Effort. S.
- Risk. A call that legitimately runs past 120 s dies (see CODE-13 for the fix other clients use).
- Detail. lootbox wraps
client.callTool(...)in aPromise.raceagainstrpcTimeout(default 30 s,.lootbox/src/lib/constants.ts:20), which abandons the promise without cancelling it (CODE-18). The generated client’s per-call timeout isconfig.timeoutat startup butclient_timeoutafter a tool file changes (.lootbox/src/lib/rpc/websocket_server.ts:91versus:151). opencode v1 passes the per-servertimeoutorexperimental.mcp_timeout, else nothing, leaving the SDK’s 60,000 ms, withresetTimeoutOnProgress: trueand nomaxTotalTimeout(v1:packages/opencode/src/mcp/index.ts:672); code-mode child calls passtimeout: input.entry.tool.timeout(v1:packages/opencode/src/tool/code-mode.ts:154-158). v1’s config text says “Defaults to 5000” while the code uses 30,000 for connect and list (v1:packages/core/src/v1/config/mcp.ts:21,v1:packages/opencode/src/mcp/index.ts:38). opencode v2 splitstimeout.startup/catalog/execution, defaulting to 30 s, 30 s and 12 h (v2:packages/core/src/mcp/client.ts:34,:241). - Sources.
internal/defaults/defaults.json:6;.lootbox/src/lib/rpc/execute_mcp.ts:60-64;v2:packages/core/src/mcp/client.ts:34“const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours”
CODE-13 Progress token requested so long calls outlive the timeout
- What. opencode passes a no-op
onprogressto everycallTool, which makes the SDK send_meta.progressToken(equal to the JSON-RPC id). v1 also setsresetTimeoutOnProgress, so a server that keeps reporting progress keeps the call alive. mcpx never sends a progress token, so upstream servers cannot report progress to it and its 120 s call timeout cannot be extended. - Where. opencode v1 (token and reset) and v2 (token, no reset). lootbox: no token, and its
Promise.raceis not reset. Cloudflare: not documented. - mcpx @ 05c78b2.
progressTokenappears only in theProgressstruct it parses (internal/mcpclient/client.go:438). - Value to mcpx. + med: long browser or crawl tools that report progress would not die at 120 s, and progress could
flow into
/v1/execstream frames (CODE-26) and plugin metadata (plugin-API register, PLG-37). Today the stream’s progress plumbing can never carry upstream progress for a tool call. - Effort. S — add
_meta.progressTokentotools/call, reset the deadline on each progress notification, up to a hard maximum. - Risk. A server that sends progress forever never times out, so a hard wall is needed; v1 sets no
maxTotalTimeout. - Detail. v2’s comment says “Requesting progress keeps long calls alive under the SDK’s timeout; execution is the
hard wall”, but v2 never sets
resetTimeoutOnProgress(rg finds none inv2:packages/core/src), and the SDK resets only when it is set; the 12-hourexecutionvalue is the SDK timeout itself. The 1.x SDK readsresetTimeoutOnProgress ?? false(@modelcontextprotocol/sdk@1.22.0/dist/esm/shared/protocol.js:306-308). v1’s comment: “The MCP SDK only sends a progress token when this hook is present, enabling timeout resets”. - Sources.
v1:packages/opencode/src/tool/code-mode.ts:154-158;v1:packages/opencode/src/mcp/catalog.ts:61resetTimeoutOnProgress: true,;v2:packages/core/src/mcp/client.ts:280-281;internal/mcpclient/client.go:438;internal/defaults/defaults.json:6
CODE-14 CPU-time and subrequest caps per invocation
- What. A Cloudflare Dynamic Worker accepts
limits: { cpuMs, subRequests }, in its code or atgetEntrypoint(), and the lower value wins. Hitting either throws at once. - Where. Cloudflare. No local product caps CPU. opencode’s interpreter yields between steps, so its timeout also
stops busy loops (
v1:packages/codemode/README.md:286). - mcpx @ 05c78b2. Wall clock only (CODE-11).
- Value to mcpx. − scripts run on the user’s own machine with full authority; a CPU cap adds little beyond the timeout.
- Effort. M —
setrlimitper runtime. - Risk. None; not a candidate.
- Detail. Without custom limits the Workers plan limits apply. The guide asks for “memory limits” as well as
timeouts (
docs/docs/2026-07-28/develop/clients/client-best-practices.mdx:294); no local product sets one. - Sources.
v1:packages/codemode/README.md:286; https://developers.cloudflare.com/dynamic-workers/usage/limits/#set-custom-limits (limits: { cpuMs: 10, subRequests: 5 })
CODE-15 Tool-call budget per execution
- What. opencode’s library has a
maxToolCallsknob; going over it gives aToolCallLimitExceededdiagnostic. In v2,searchcalls count too. opencode’s own host never sets it. - Where. opencode v1 and v2 (library only). lootbox, mcpx and Cloudflare have no per-run budget.
- mcpx @ 05c78b2. None; a looping script is stopped by the 120 s timeout or by pool contention.
- Value to mcpx. + low: bounds fan-out cost from
mcpx_exec; the timeout already bounds it roughly. - Effort. S — count calls per
MCPX_RUNin/v1/call. - Risk. Low. From the other side: an opencode code-mode script calling mcpx over MCP is itself unbounded, so mcpx should expect long many-call bursts.
- Detail. v1 README: “none - unlimited” (
v1:packages/codemode/README.md:265); v2 README: “Search counts towardmaxToolCalls” (v2:packages/codemode/README.md:171-172). - Sources.
v1:packages/codemode/README.md:265;v2:packages/codemode/README.md:179;v2:packages/core/src/codemode/tool.ts:222
CODE-16 Cap on the size of returned output
- What. opencode’s
maxOutputBytestruncates the value and logs with an in-band marker and setstruncated: true. Cloudflare shipstruncateResult/truncateResponsewith a 6,000-token default, andopenApiMcpServerclips text at about 6,000 tokens. mcpx returns all of stdout. - Where. opencode library (the host leaves it unset and relies on its generic tool-output truncation). Cloudflare utilities. lootbox and mcpx return stdout whole.
- mcpx @ 05c78b2. stdout accumulates in an unbounded
strings.Builderand is returned whole (internal/execsvc/execsvc.go:413-417,:510). Only stderr is tail-bounded (defaults.ExecStderrLimit,internal/execsvc/execsvc.go:436), and only inline artifacts are capped (artifacts.inlineMaxBytes,docs/exec.md:323). - Value to mcpx. + high: one
console.log(bigObject)inmcpx_exec, over MCP or through the plugin (which prints the whole envelope; plugin-API register, PLG-40), floods the caller’s context, which is exactly what code mode exists to prevent. - Effort. S — an
exec.maxOutputBytessetting that truncates with a marker and setstruncated. - As built (#1937, 2026-10-31). Landed as specified: setting
exec.maxOutputBytes(KindBytes, default64KiB, defaults.jsonexec.stdoutLimitfollowing thestderrLimitpattern), wired intoexecsvc.Limits.StdoutLimitby both the daemon (/v1/exec) and the CLI local path. The cap is applied once, at the end ofexecsvc.Service.Runwhere theResultis handed back, so every returning surface sees the same cappedStdout: the head is kept (UTF-8-boundary safe), the returned string never exceeds the cap, it ends with a marker naming kept/total/dropped bytes, andResult.StdoutTruncated(stdoutTruncated) is set. Internal consumers (the JSON answer parse, hooks) still see the full text, and the full output is unchanged wherever a full record already exists. 0 disables the cap. Tests:internal/execsvc/stdout_limit_test.go(over-cap capped+marker, under-cap and exact-cap byte-identical, 0 disables, UTF-8 boundary). - Risk. Without it, one chatty script can cost more tokens than the direct call it replaced.
- Detail. v1’s README tells hosts without their own truncation to set it, “or oversized results silently flood model
context” (
v1:packages/codemode/README.md:268). v2 gives warnings a separate budget equal tomaxOutputBytes(v2:packages/codemode/README.md:184). Cloudflare’s durable runtime also limits stored values to 1,000,000 characters (how-it-works, “Durable value and result limits”). The guide: “Validate and truncate sandbox console output before feeding it back to the model” (docs/docs/2026-07-28/develop/clients/client-best-practices.mdx:295). - Sources.
v1:packages/codemode/README.md:284“Exceeding a configuredmaxOutputBytesnever fails the execution.”;internal/execsvc/execsvc.go:413-417; https://developers.cloudflare.com/agents/tools/codemode/api-reference/#code-and-output-utilities (“The default budget is6000estimated tokens”); https://developers.cloudflare.com/agents/tools/codemode/api-reference/#openapimcpserver (“Text responses are limited to approximately 6,000 tokens”)
CODE-17 Concurrent tool calls within one program
- What. opencode v1 runs at most 8 eagerly started tool calls at once, through a semaphore. v2 removed the cap and
tells the model to use
Promise.all. - Where. opencode v1 (8), v2 (unrestricted). Cloudflare advises sequential calls whenever a run might pause (CODE-37). lootbox and mcpx have no per-script cap.
- mcpx @ 05c78b2. None per script. The pool caps instances per server at 4 (
internal/defaults/defaults.json:3);Promise.allover 50 calls to a shared server multiplexes on one process. - Value to mcpx. + low: a per-run cap would protect upstream servers from a single fan-out.
- Effort. S.
- Risk. Low. The other direction matters more: an opencode v2 script sends concurrent
tools/callto mcpx on one connection, and mcpx’s stdio server handles requests in line (transports register), so they serialise. - Detail. v1: “At most 8 tool calls run concurrently” and “Neither is part of the public contract”
(
v1:packages/codemode/README.md:249,:288); the constant feedsSemaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)(v1:packages/codemode/src/interpreter/runtime.ts:628). - Sources.
v1:packages/codemode/src/stdlib/promise.ts:6export const TOOL_CALL_CONCURRENCY = 8;v2:packages/codemode/README.md:187“Tool-call concurrency is unrestricted.”;v2:packages/core/src/codemode/tool.ts:67
CODE-18 Upstream call cancelled when the script times out or dies
- What. When a script times out or dies, mcpx cancels its in-flight upstream request with
notifications/cancelled. lootbox’s per-call timeout abandons the promise, and a killed script’s calls keep running in the daemon. - Where. mcpx (request context, then
notifications/cancelled). opencode (signaloncallTool, or fiber interruption; v2 on modern HTTP aborts the request instead). lootbox: neither. Cloudflare: unverified. - mcpx @ 05c78b2.
/v1/callpassesr.Context()toreg.Call(internal/daemon/server.go:696); when that context ends, the client sendsnotifications/cancelled(internal/mcpclient/client.go:709-716). - Value to mcpx. + low: already works.
- Effort. S.
- Risk. n/a.
- Detail. mcpx’s
reasonis always"timeout", even when the cause is a disconnect or a kill (internal/mcpclient/client.go:714). lootbox passes nosignal(.lootbox/src/lib/rpc/execute_mcp.ts:61); the SDK’s own 60 s default (@modelcontextprotocol/sdk@1.22.0/dist/esm/shared/protocol.js:5) eventually sendsnotifications/cancelleditself, 30 s after lootbox’srpcTimeoutgave up. - Sources.
.lootbox/src/lib/rpc/execute_mcp.ts:199-208;internal/daemon/server.go:696;internal/mcpclient/client.go:709-716
CODE-19 Final top-level expression becomes the result
- What. In opencode v2 a program without
returnreturns its last top-level expression, REPL-style. Cloudflare’screateCodeTool“auto-returns the last expression”. v1 returnsnull. lootbox has no return value at all, and mcpx uses stdout. - Where. opencode v2 and Cloudflare. v1:
null. lootbox: “Do not return from scripts always log”. mcpx: stdout,emit(), or a default export’s return value. - mcpx @ 05c78b2. The
mcpx execprelude only binds names (internal/execsvc/execsvc.go:607-625); output is stdout,emit()or a default export (AGENTS.md:53,AGENTS.md:69-71). - Value to mcpx. + med: models naturally end with
await ns.tool({...})and expect a result; REPL semantics save a turn when they forgetconsole.log. - Effort. M — last-expression detection and rewrite for
execsnippets only, not named scripts. - Risk. A snippet ending in an expression with side effects prints where it did not before.
- Detail. v1: “A program that returns
undefined, including by reaching the end withoutreturn, producesnull” (v1:packages/codemode/README.md:65); v2 also mapsundefinedtonull. Cloudflare’s 2025 blog said results come back “by invokingconsole.log()”; the current SDK returns the function’s value asresultwithlogsbeside it (CodeOutput = { result: unknown; logs?: string[] }, API reference), so the blog is historical. The guide says the model sees “typically the output ofconsole.logstatements or a final return value” (docs/docs/2026-07-28/develop/clients/client-best-practices.mdx:284). - Sources.
v2:packages/codemode/README.md:20“Without an explicitreturn, the final top-level expression becomes the result.”;.lootbox/LLM_QUICK_START.md:93; https://developers.cloudflare.com/dynamic-workers/examples/codemode/ (“createCodeTool”: “auto-returns the last expression”)
CODE-20 What happens to un-awaited calls when the program ends
- What. When the program finishes, opencode v1 awaits un-awaited tool calls and reports a failure among them as an unhandled rejection; v2 interrupts them and returns unhandled rejections as warnings.
- Where. opencode v1 awaits; v2 interrupts. In lootbox and mcpx a pending
fetchkeeps the runtime’s event loop alive, so the calls complete (runtime semantics, not code). Cloudflare: unverified. - mcpx @ 05c78b2. No explicit handling; pending calls finish unless the timeout kills the process group
(
internal/runner/runner.go:424-438). - Value to mcpx. + low: informational; a model moving between harnesses meets different semantics.
- Effort. S.
- Risk. Under v2, a forgotten
awaiton a write cancels it silently. - Detail. v2’s tool description warns the model: “Await every call whose completion matters; pending calls are
interrupted when execution ends” (
v2:packages/core/src/codemode/tool.ts:67). The flip between versions is one of two (with CODE-17) where instructions written for one version mislead on the other. - Sources.
v1:packages/codemode/README.md:249“When a program completes, still-running un-awaited calls are awaited”;v2:packages/codemode/README.md:18-19“anything still running is interrupted.”
CODE-21 Working directory the script runs in
- What. The lootbox CLI sends the script’s text over the WebSocket; the daemon writes it to a temp file and
spawns
denowith nocwd, so relative paths resolve against the daemon’s directory (launchd sets~/.config/nix), not the caller’s. - Where. lootbox always runs daemon-side. mcpx runs locally with the caller’s cwd by default;
/v1/execand--remotepass the caller’scwd. opencode and Cloudflare have no working directory. - mcpx @ 05c78b2. The runner inherits the caller’s directory (
internal/runner/runner.go:354-358);/v1/exectakescwd/env/stdinas the caller’s context (docs/exec.md:102-105). - Value to mcpx. + low: already solved (but the plugin’s
mcpx_execnever sendscwd; plugin-API register, PLG-39). - Effort. S — done.
- Risk. A lootbox script that writes
./out.jsonwrites into~/.config/nix. - Detail. lootbox sends stdin as a JS constant,
const $STDIN = <json>, prepended to the source (.lootbox/src/lib/lootbox-cli/exec.ts:155-168). With no--allow-readby default (CODE-08), the cwd mostly matters for writes, which also need a permission. - Sources.
.lootbox/src/lib/lootbox-cli/exec.ts:40ws.send(JSON.stringify({ script, id }));;.lootbox/src/lib/execute_llm_script.ts:20;nix:configuration.nix:742WorkingDirectory = "/Users/drewry.pope/.config/nix";
CODE-22 Caller chooses local or remote execution
- What.
exec.whereisauto,localorremote.autoruns locally when the daemon is local. A remote run sends the script text, not its path.--localagainst a remote daemon is refused. - Where. mcpx only. lootbox is always daemon-side (CODE-21); opencode always in-process; Cloudflare always on the platform.
- mcpx @ 05c78b2.
docs/exec.md:54-73; the CLI resolves where to run atinternal/cli/commands.go:520-530. - Value to mcpx. + med: a remote daemon is usable without every call crossing the network.
- Effort. S — done.
- Risk.
--exportand relative imports do not survive a remote run; both are refused (docs/exec.md:69-73). - Detail. Remoteness is declared by the caller, never inferred from the transport (
docs/exec.md:189-192). - Sources.
docs/exec.md:54“exec.whereisauto,localorremote”
CODE-23 How arguments reach a script
- What. A lootbox script receives input only on stdin, through an injected
stdin()helper with.json(),.text(),.lines()and.raw(). mcpx passesDeno.args, calls a default export with argv, calls a named export with--export, and fills recipe@paramplaceholders. A Cloudflare snippet takes one input value. - Where. lootbox stdin; mcpx argv, exports, placeholders; Cloudflare
codemode.run(name, input?); opencode none. - mcpx @ 05c78b2.
AGENTS.md:25,AGENTS.md:69-71; launcher atinternal/runner/runner.go:530-579; recipes atinternal/recipes/recipes.go:12-17. - Value to mcpx. + low: already covered.
- Effort. S.
- Risk. n/a.
- Detail. lootbox injects
stdinonly when stdin is not a TTY (.lootbox/src/lib/lootbox-cli/exec.ts:156). Combined with the type check (CODE-06), a script that callsstdin()run from a terminal would fail with “Cannot find name ‘stdin’” (inferred, not run). - Sources.
.lootbox/src/lib/lootbox-cli/exec.ts:158-167;.lootbox/README.md:219-226;AGENTS.md:25
CODE-24 Where named scripts are looked up
- What. lootbox tries the path as given, then
<scripts_dir>/<file>in one directory. mcpx walks up from the working directory looking for.mcpx/scripts/, then checks~/.config/mcpx/scripts/; the nearest wins. - Where. lootbox (one directory, shared across repos in practice through
lootbox-linksymlinks); mcpx (search path); Cloudflare (named snippets in durable storage, CODE-39); opencode (none). - mcpx @ 05c78b2.
AGENTS.md:21-22;builtinScriptDirsatinternal/cli/scripts.go:69-77. - Value to mcpx. + low: per-repo scripts already work.
- Effort. S.
- Risk. n/a.
- Detail. lootbox lists scripts by parsing the first JSDoc block for a description and
@examplelines (.lootbox/src/lib/lootbox-cli/scripts.ts:14-51). - Sources.
.lootbox/src/lib/lootbox-cli/exec.ts:103-120;nix:pkgs/lootbox-link/lootbox-link.sh:5;AGENTS.md:21“A named script lives at”
CODE-25 Named injection points in the script launcher
- What. mcpx wraps each script in a generated launcher with named injection points: before, prefix, onSuccess,
onError, suffix. The launcher can be replaced by a template or removed (
none). - Where. mcpx only. lootbox prepends an
importline (.lootbox/src/lib/execute_llm_script.ts:18); opencode and Cloudflare wrap the code in an async function. - mcpx @ 05c78b2.
internal/runner/runner.go:190-205(phases); launchernoneat:497-502. - Value to mcpx. + low: an operator can add tracing or cleanup around every script without editing scripts.
- Effort. S — done.
- Risk. None.
- Detail. The launcher imports the module dynamically so that injected lines really run first
(
internal/runner/runner.go:523-525).docs/exec.md:39-43explains why these knobs stay CLI-only rather than joining the/v1/execwire type. - Sources.
internal/runner/runner.go:162-165;internal/runner/runner.go:190-205
CODE-26 emit() streams values out while the script runs
- What.
emit(value)writes each value as it is produced./v1/execcan stream ordered frames: start, log/emit/stdout, artifact metadata, result or error, end, then artifact bodies. The format is SSE, or NDJSON on request. - Where. mcpx only. lootbox buffers everything with
.output()until the process exits. opencode streams only tool-call status to its UI (plugin-API register, PLG-37), never program output. Cloudflare returns{result, logs}at the end. - mcpx @ 05c78b2.
AGENTS.md:62-67;docs/exec.md:137-148;emitResultatinternal/codegen/emit.go:847. - Value to mcpx. + med: long browser scripts can report progress, and a consumer can hang up before paying for artifact bodies.
- Effort. S — done.
- Risk. None.
- Detail. A disconnecting stream consumer cancels the run context and the process group is killed
(
docs/exec.md:156-160). The plugin does not consume the stream (PLG-37). - Sources.
.lootbox/src/lib/execute_llm_script.ts:38-43;docs/exec.md:129“stream — frames as they happen.”
CODE-27 Structured log records on a side channel
- What.
log.info("msg {x}", {x})writes a record to stderr behind a sentinel (\x1emcpx\x1e). The runner separates records from plain stderr and collects them intologs[]with level and attributes; call sites are captured for warn and above. - Where. mcpx only. lootbox gives raw stderr (and drops it on success, CODE-28). opencode and Cloudflare give
logs: string[]of console output. - mcpx @ 05c78b2. Sentinel at
internal/codegen/emit.go:556; parsing atinternal/runner/runner.go:374-398. - Value to mcpx. + low: a machine-readable account of why a script failed, queryable later through
mcpx_log. - Effort. S — done.
- Risk. None.
- Detail.
captureConsolemirrorsconsole.*into records and is on by default (internal/defaults/defaults.json:35). - Sources.
internal/codegen/emit.go:556const LOG_SENTINEL = "\u001emcpx\u001e";;AGENTS.md:56-57
CODE-28 lootbox drops stderr on success and stdout on failure
- What. lootbox’s executor returns
{output, warnings: stderr}on success and{error: stderr, output: stdout}on failure, but the message router forwards onlyoutputon success and onlyerroron failure.console.errorfrom a successful run, and partial stdout from a failed one, never reach the caller. - Where. lootbox. mcpx keeps both. opencode appends logs to the output on both paths. Cloudflare returns
logsin both itscompletedanderrorvariants. - mcpx @ 05c78b2. stderr is kept so a non-zero exit is explained (
internal/execsvc/execsvc.go:430-438,:518-525). - Value to mcpx. + low: already correct.
- Effort. S — done.
- Risk. n/a.
- Detail. lootbox’s history DB stores both fields (
.lootbox/src/lib/execute_llm_script.ts:59-67,:77-84), so the data exists; only the WebSocket reply drops it. - Sources.
.lootbox/src/lib/rpc/managers/message_router.ts:111-115;.lootbox/src/lib/execute_llm_script.ts:86-90
CODE-29 One JSON document per run
- What. mcpx returns
{runId, result, emits[], logs[], stdout, artifacts[], exitCode, durationMs, error?}from--json run,/v1/execandmcpx_exec. lootbox’s CLI prints the stdout text or the error text and exits 0 or 1. - Where. mcpx; opencode (
CodeMode.Resultinternally, flattened to text for the model); Cloudflare (ProxyToolOutputwith statuscompleted,pausedorerror). lootbox has nothing comparable. - mcpx @ 05c78b2.
internal/execsvc/execsvc.go:201-213;AGENTS.md:73-74. - Value to mcpx. + low: already there.
- Effort. S.
- Risk. n/a.
- Detail. When stdout parses as JSON it is also offered parsed as
result(internal/execsvc/execsvc.go:527-535), a rule inherited frommcpx --json run(docs/exec.md:134-135). opencode’sSuccesscarriesvalue,logs,truncatedandtoolCalls(v1:packages/codemode/README.md:128-135). - Sources.
internal/execsvc/execsvc.go:201-213;.lootbox/src/lib/lootbox-cli/exec.ts:82-93; https://developers.cloudflare.com/agents/tools/codemode/api-reference/#runtime-tool-input-and-output (| { status: "paused"; executionId: string; pending: PendingAction[]; })
CODE-30 Failures reported as typed kinds
- What. opencode returns failures as data with a
kind: ParseError, UnsupportedSyntax, UnknownTool, InvalidToolInput, InvalidToolOutput, InvalidDataValue, ToolCallLimitExceeded, TimeoutExceeded, ToolFailure, ExecutionFailure, and Truncated in v2. Cloudflare’s runtime usesstatus: "error"and never throws through the tool call. - Where. opencode v1/v2 and Cloudflare. mcpx reports
exitCode,timedOutand anerrorstring; lootbox an error string and exit 1. - mcpx @ 05c78b2.
Result.Error string,TimedOut bool,ExitCode int(internal/execsvc/execsvc.go:201-213); nothing separates “script threw”, “tool failed”, “argument invalid”, “did not start” and “timed out”. - Value to mcpx. + med: an agent could retry on a tool failure and fix its code on a parse error. The information exists today and is lost in a string.
- Effort. M — a kind enum, propagated from the runner’s failure paths plus a
ToolErrormarker. - Risk. Low.
- Detail. v2 adds non-fatal
warningsbeside success (v2:packages/codemode/README.md:127). Cloudflare: “Custom executors should report failures inExecuteResult.errorinstead of throwing” (API reference, “Executor”). The guide asks that an uncaught error be surfaced “as the script’s result so the model can self-correct” (docs/docs/2026-07-28/develop/clients/client-best-practices.mdx:302-303). - Sources.
v2:packages/codemode/README.md:145-159;v1:packages/codemode/README.md:292-305; https://developers.cloudflare.com/agents/tools/codemode/api-reference/#runtime-tool-input-and-output (“Sandbox and replay errors use theerroroutput variant.”)
CODE-31 Budgeted catalogue: every namespace, signatures to about 2,000 tokens
- What. Every namespace is listed with its tool count; full signatures are then added one namespace at a time, cheapest first, until an estimated budget of 2,000 tokens (4 characters per token) runs out. The catalogue says when it is partial.
- Where. mcpx (
mcpx catalog,mcpx_catalog), opencode v1 and v2, all defaulting to 2,000. lootbox lists namespace names and counts only. Cloudflare either puts every type in the tool description (createCodeTool,codeMcpServer) or lists only connector names (durable runtime). - mcpx @ 05c78b2.
Catalog(internal/codegen/catalog.go:53-68); budget ininternal/defaults/defaults.json:30-32;charsPerToken = 4(internal/codegen/catalog.go:30). - Value to mcpx. + low: parity. The difference is delivery: opencode puts the catalogue in front of the model
automatically (v1 in the
executetool description, v2 in session instructions); mcpx needs a call, except through the plugin. - Effort. S.
- Risk. None.
- Detail. The unit is tokens, not characters, in all three.
OPENCODE-V2.md:45says v2 inlines “2,000 characters”; the source estimates tokens asMath.round(text.length / CHARACTERS_PER_TOKEN)withCHARACTERS_PER_TOKEN = 4(v2:packages/core/src/codemode/catalog.ts:49-50,:187-188), about 8,000 characters. v2 charges namespace header lines against the budget (v2:packages/core/src/codemode/catalog.ts:96-113); v1 counts only full entries (v1:packages/codemode/README.md:184); mcpx charges headers too (internal/codegen/catalog.go:64-66). lootbox’s/rpc-namespacesstill tells the model to call aget_namespace_types()function that does not exist (.lootbox/src/lib/rpc/managers/type_generator_manager.ts:258-266). Cloudflare’s durable runtime spends a turn deliberately: the model callscodemode.search()/describe()in one execution and acts in the next (how-it-works, “What the model sees”). lootbox’s discovery re-lists every server on every request and waits for every server at startup (process-model register, PM-23 and PM-22). - Sources.
v2:packages/core/src/codemode/catalog.ts:50const INLINE_BUDGET = 2_000;v1:packages/codemode/src/tool-runtime.ts:86const defaultCatalogBudget = 2_000;internal/codegen/catalog.go:53-68;.lootbox/src/lib/rpc/managers/type_generator_manager.ts:188-200;OPENCODE-V2.md:45“2,000 characters inline”
CODE-32 Catalogue changes sent as a delta when shorter
- What. When tools change mid-session, v2 re-renders its instructions as added/changed/removed entries (or namespace count changes when the catalogue is partial), and falls back to the full catalogue when that is shorter.
- Where. opencode v2. v1’s
describeCatalogre-renders everything (v1:packages/opencode/src/tool/registry.ts:283-289). mcpx has the same machinery. lootbox and Cloudflare have none. - mcpx @ 05c78b2.
Fingerprint,Diff,ShorterOf(internal/codegen/catalog.go:244-357). - Value to mcpx. + low: parity; it needs a long-lived consumer, and the plugin is that consumer.
- Effort. S.
- Risk. None.
- Detail. v2 always sends a full replacement when the catalogue flips between partial and complete, or when any
namespace description changes (
v2:packages/core/src/codemode/instructions.ts:39-50). - Sources.
v2:packages/core/src/codemode/instructions.ts:34-37;internal/codegen/catalog.go:244-357
CODE-33 Pinned tools always shown in the catalogue
- What. A v2 tool registered with
options.pinned === trueis always in the inlined catalogue, and its cost comes off the budget first. - Where. opencode v2. mcpx has
--bias wordsto favour matching tools. The others have neither. - mcpx @ 05c78b2. Bias scoring only (
internal/codegen/catalog.go:223). - Value to mcpx. + low: an operator could pin, say,
chrome_devtools.take_screenshot. - Effort. S — a per-server
pinnedlist in config. - Risk. None.
- Detail. Pinned entries are removed from the round-robin selection order
(
v2:packages/core/src/codemode/catalog.ts:79-91). - Sources.
v2:packages/core/src/codemode/catalog.ts:79-84;v2:packages/core/src/codemode/tool.ts:162-166
CODE-34 Tool search callable from inside the program
- What. A script can search the catalogue without ending the run. mcpx: synchronous
search(query, limit = 20)anddescribe(name). opencode v1:await tools.$codemode.search({query, namespace, limit, offset}), through a reserved namespace. opencode v2: a synchronoussearch(...)built-in. Cloudflare:await codemode.search(query)(top 50) andcodemode.describe(target). - Where. mcpx, opencode v1/v2, Cloudflare. lootbox has no search, not even a CLI command.
- mcpx @ 05c78b2.
searchatinternal/codegen/emit.go:1121,describeat:1167; alsomcpx searchand themcpx_searchMCP tool (internal/mcpserver/server.go:307). - Value to mcpx. + low: parity.
- Effort. S.
- Risk. None.
- Detail. v1 is asynchronous and namespaced, not a synchronous built-in like v2’s: the reserved namespace is
"$codemode"and the default limit 10 (v1:packages/codemode/src/tool-runtime.ts:85-87). v1 scores exact path 20, path substring 8, description 4, searchable text 2, with naive singularisation (v1:packages/codemode/README.md:206), and advertises search only when the catalogue is partial but always registers it (v1:packages/codemode/README.md:195). v2 search also matches enclosing namespace descriptions and counts towardmaxToolCalls(v2:packages/codemode/README.md:170-172). Cloudflare’s search also returns snippets (CODE-36). - Sources.
internal/codegen/emit.go:1121export function search(query: string, limit = 20): ToolMatch[] {;v1:packages/codemode/README.md:198;v2:packages/codemode/README.md:170; https://developers.cloudflare.com/agents/tools/codemode/api-reference/#sandbox-codemode-api (“Results are ranked and limited to50.”)
CODE-35 In-program search pages its results
- What. opencode’s search takes
offsetand returnsremainingandnext: {offset} | null. An empty query with anamespacelists that namespace; a query naming an exact path is a lookup. - Where. opencode v1/v2. mcpx’s
searchhas no offset. Cloudflare returnstotalandtruncatedbut no cursor. - mcpx @ 05c78b2.
export function search(query: string, limit = 20)(internal/codegen/emit.go:1121). - Value to mcpx. + low: a model can browse a large namespace (chrome’s 27 tools) without guessing words.
- Effort. S.
- Risk. None.
- Detail. v2’s
searchcounts againstmaxToolCalls(CODE-15). v1 says to “spread it into the original request to preserve its query, namespace, and limit” (v1:packages/codemode/README.md:206). - Sources.
v1:packages/codemode/README.md:206“nextis{ offset }when another page exists andnullon the final page”;internal/codegen/emit.go:1121
CODE-36 In-program search also finds saved snippets
- What. Cloudflare’s
codemode.search()ranks connector methods and saved snippets together, and a snippet runs withcodemode.run(name, input). mcpx’s in-scriptsearch()covers tools only; saved scripts (“recipes”) are matched from the CLI or the daemon, not from inside a script. - Where. Cloudflare. mcpx has recipe matching outside scripts (
internal/recipes/match.go). lootbox lists scripts withlootbox scripts. opencode has no saved scripts. - mcpx @ 05c78b2.
search()readstoolMetaonly (internal/codegen/emit.go:138-146,:1121). - Value to mcpx. + low: an agent mid-script could find “screenshot-and-diff” and reuse it.
- Effort. M — emit recipe metadata into the client and add
run(name, args). - Risk. None.
- Detail. Cloudflare snippets come from host-reviewed executions (CODE-39), so what search returns was vetted; mcpx recipes are whatever is on disk.
- Sources.
internal/recipes/recipes.go:1“Package recipes turns saved scripts into parameterised, matchable units.”; https://developers.cloudflare.com/agents/tools/codemode/how-it-works/#platform-sdk (“codemode.search()searches connector methods and saved snippets.”)
CODE-37 Approval pauses a run by aborting it and replaying it
- What. A Cloudflare connector method marked
requiresApprovalrecords a pending action and aborts the pass. After approval the whole program re-runs with the same execution id; calls already applied return their recorded results, and a replay that diverges fails.codemode.step(name, fn)runs a closure once and records its result so replays return the recorded value. - Where. Cloudflare durable runtime only. opencode asks a permission question per call, synchronously, for every
child call (plugin-API register, PLG-36). mcpx can ask through elicitation (
elicit.confirmDestructive,internal/settings/consumer.go:47). lootbox: none. - mcpx @ 05c78b2. No durable pause; a script waiting on an elicitation stays blocked in its own process.
- Value to mcpx. − mcpx scripts have full local authority and side effects (filesystem, subprocesses) that cannot be replayed deterministically; the design assumes determinism mcpx cannot give.
- Effort. XL.
- Risk. None; not a candidate.
- Detail. Cloudflare warns: “Calls in
Promise.all()can arrive in different orders across passes and cause replay divergence” (how-it-works, “Deterministic replay”). Connector calls do not needstep(); the runtime records them already. The guide allows “categorical approval” per run but says “the broker must still evaluate each call against that grant” (docs/docs/2026-07-28/develop/clients/client-best-practices.mdx:290). - Sources.
internal/settings/consumer.go:47; https://developers.cloudflare.com/agents/tools/codemode/how-it-works/#approvals-through-abort-and-replay (“the runtime records the action as pending and aborts the current pass.”); https://developers.cloudflare.com/agents/tools/codemode/api-reference/#sandbox-codemode-api (“step(name, fn)| Runs a closure once and records its result.”)
CODE-38 Rollback through a per-tool revert
- What.
runtime.rollback()walks the applied connector calls in reverse and calls each tool’srevert(args, result)where one is defined. - Where. Cloudflare only.
- mcpx @ 05c78b2. None.
- Value to mcpx. − MCP tools have no
revert; mcpx would have to invent one per server. - Effort. XL.
- Risk. None; not a candidate.
- Detail. “Rollback is compensation, not database transaction isolation.” (how-it-works, “Rollback”).
- Sources.
internal/settings/consumer.go:47(mcpx’s only confirmation mechanism); https://developers.cloudflare.com/agents/tools/codemode/api-reference/#connector-tool-types (revert?: (args: unknown, result: unknown, ctx?: ToolExecuteContext) => Promise<void> | void;)
CODE-39 Only the host may promote a run to a saved snippet
- What. In Cloudflare the model cannot save its own code: the application reviews an execution and calls
runtime.saveSnippet(name, {executionId, description}). - Where. Cloudflare. lootbox and mcpx let the agent write files into the scripts directory. opencode has no saved scripts.
- mcpx @ 05c78b2. Agents write
.mcpx/scripts/<name>.tsdirectly (AGENTS.md:21-22). - Value to mcpx. − mcpx’s model is “the agent is the user”; a review gate would get in the way of the main workflow.
- Effort. M.
- Risk. An agent can plant a script that a later agent runs through recipe matching (CODE-36).
- Detail. “The API accepts any execution status, so verify that the execution completed successfully before saving it.” (how-it-works, “Snippets”).
- Sources.
AGENTS.md:21-22; https://developers.cloudflare.com/agents/tools/codemode/how-it-works/#snippets (“The model does not promote its own code.”)
CODE-40 Tool names that are JavaScript reserved words get a suffix
- What. Cloudflare’s
sanitizeToolNamereplaces separators, strips invalid characters, prefixes a leading digit (3d-renderbecomes_3d_render) and suffixes reserved words (deletebecomesdelete_). mcpx does the first three but not reserved words. - Where. Cloudflare (
McpConnector,generateTypesFromJsonSchema). mcpx: invalid characters and leading digit. lootbox: invalid characters only. opencode keeps the original name with bracket notation (CODE-43). - mcpx @ 05c78b2.
ToolFuncNamekeepsdelete,neworclassas is (internal/codegen/ts.go:397-418).toolDeclemitsfunction delete(args…)inside a declaration block (internal/codegen/emit.go:97), which is not valid TypeScript;toolImplemits method shorthand (internal/codegen/emit.go:217), which is. - Value to mcpx. + low:
mcpx typesprints invalid TypeScript for a tool nameddeleteorimport, andAGENTS.md:99-100calls that output authoritative. The runtime client and--typecheck(which checks againsttypeof __mcpx.<ns>,internal/codegen/emit.go:1432) are unaffected. - Effort. S — a reserved-word table in
ToolFuncName. - Risk. An agent that pastes
mcpx typesoutput into a.d.tsgets a parse error. Low. - Detail.
AGENTS.md:108-109documentsfancy-namebecomingfancy_nameand says nothing about reserved words. ASSESSMENT lists aToolFuncName("9lives")bug already fixed (ASSESSMENT.md:258-259). - Sources.
internal/codegen/ts.go:413-416; https://developers.cloudflare.com/agents/tools/codemode/mcp/ (step 2: “deletebecomesdelete_”); https://developers.cloudflare.com/agents/tools/codemode/api-reference/#code-and-output-utilities (“suffixes JavaScript reserved words”)
CODE-41 Namespace names checked against reserved words and prelude names
- What. Cloudflare validates namespaces: each must be a valid identifier, unique, and must not shadow an executor
global. mcpx’s
SanitizeNamespacefixes invalid characters and a leading digit but checks neither reserved words (default,import,new,delete) nor the prelude’s own names (tools,log,emit,call,artifact,readResource,ToolError). - Where. Cloudflare validates. mcpx does not. lootbox is safe because MCP namespaces always carry an
mcp_prefix. opencode uses bracket notation. - mcpx @ 05c78b2.
SanitizeNamespace(internal/config/config.go:407-416). The client emitsexport const <ns> = {per namespace (internal/codegen/emit.go:122); theexecprelude importstools, { call, readResource, artifact, log, emit, ToolError, installGlobals, captureConsole }and thenconst { <every ns> } = tools;(internal/execsvc/execsvc.go:610-617). - Value to mcpx. + med: a server configured as
logordefaultwould, going by the code, make the client or the prelude fail to parse, breaking everymcpx execand script, not just calls to that server. Not run. - Effort. S — reject or suffix reserved and prelude names in
SanitizeNamespaceand say so inmcpx ls, or have the prelude skip colliding names. - Risk. One unlucky server name takes out all scripting, with a parse error that does not name the server.
- Detail. An explicit
namespacein config bypasses the sanitiser:pick(ex.Namespace, SanitizeNamespace(name))(internal/config/config.go:480); whether that value is validated elsewhere is unchecked. - Sources.
internal/config/config.go:407-416;internal/execsvc/execsvc.go:610-617; https://developers.cloudflare.com/agents/tools/codemode/api-reference/#dynamicworkerexecutor (“Names must be valid JavaScript identifiers, unique, and must not shadow executor globals.”)
CODE-42 Two tool names that sanitise to the same identifier
- What. Cloudflare’s
McpConnectorthrows when two source names produce the same identifier, andtoolName()can be overridden to tell them apart. mcpx emits duplicate object keys, so the last one wins at run time and a type check fails. lootbox’s lookup usesfind, so the first wins, and colliding server names silently overwrite each other. - Where. Cloudflare detects it; mcpx and lootbox do not; opencode avoids it by not renaming (CODE-43).
- mcpx @ 05c78b2. No collision check in
ModuleortoolImpl(internal/codegen/emit.go:108-146,:204-219); rg finds no “collid” or “duplicate” in codegen. - Value to mcpx. + med:
a-banda_bon one server currently call whichever sorts last, silently. - Effort. S — detect while building the namespace and suffix
_2, or warn. - Risk. The wrong tool is called with no error.
- Detail. lootbox sanitises server names at load (
.lootbox/src/lib/lootbox-cli/server.ts:10-11,:82-83), sofff-nixandfff_nixin one config collide; its tool lookup isschemas.tools.find((t) => t.name === toolName)(.lootbox/src/lib/rpc/execute_mcp.ts:46). The spec’s SHOULD on aggregators disambiguating names is the tools register’s TOOL-02. - Sources.
internal/codegen/emit.go:108-146;.lootbox/src/lib/rpc/execute_mcp.ts:46; https://developers.cloudflare.com/agents/tools/codemode/mcp/ (step 2: “If two source names produce the same identifier, the connector throws an error.”)
CODE-43 opencode keeps real tool names and uses bracket notation
- What. opencode keeps the original tool name in its path and renders non-identifier segments with brackets:
tools.context7["resolve-library-id"](…). Only characters outside[a-zA-Z0-9_-]become_, so hyphens survive. - Where. opencode v1/v2. mcpx, lootbox and Cloudflare rename to identifiers.
- mcpx @ 05c78b2. Renames (
fancy-nametofancy_name,AGENTS.md:108-109). - Value to mcpx. + low: the name the model reads in upstream docs is the name it calls, and nothing can collide. −
bracket calls are less natural, and bare-identifier binding (
chrome_devtools.x) needs identifier namespaces anyway. - Effort. M — keep the rename but also accept
tools.ns["orig-name"]aliases. - Risk. Low.
- Detail. v2 tells the model “Do not infer or normalize tool names; preserve bracket notation”
(
v2:packages/core/src/codemode/tool.ts:65) and rejects any tool part over 64 characters (names must match^[A-Za-z0-9_-]{1,64}$,v2:packages/core/src/tool.ts:297). v1 derives paths fromserver_toolkeys by the longest matching server prefix (v1:packages/opencode/src/tool/code-mode.ts:40). - Sources.
v2:packages/codemode/README.md:77-78;v2:packages/core/src/tool/runtime.ts:274;AGENTS.md:108-109
CODE-44 Return types generated from a tool’s outputSchema
- What. opencode feeds each MCP tool’s
outputSchemainto its signature, so structured results are typed. mcpx types every result asany; lootbox as the raw envelope. - Where. opencode v1 (
output: entry.tool.def.outputSchema), v2 (output: tool.outputSchema ?? {}), Cloudflare (JsonSchemaToolDescriptor.outputSchema). lootbox:Promise<McpToolResult>. mcpx:ToolResult = any.outputSchemafirst appears in 2025-06-18. - mcpx @ 05c78b2.
mcpclient.ToolparsesOutputSchema(internal/mcpclient/client.go:122), butcodegen.Toolhas no such field (internal/codegen/ts.go:19-23) and every function returnsPromise<ToolResult>withexport type ToolResult = any;(internal/codegen/emit.go:458). - Value to mcpx. + med: mcpx already unwraps
structuredContent(content-types register, CT-15), so typing it is nearly free and makes--typecheckmeaningful for results as well as arguments. The guide’s step 1 is exactly this (docs/docs/2026-07-28/develop/clients/client-best-practices.mdx:181). - Effort. S — thread
OutputSchemaintocodegen.Tooland renderPromise<T & {raw}>when present. - Risk. A server’s
structuredContentmay not match its schema; types are advisory. The spec says clients SHOULD validate structured results against it. - Detail. With an outputSchema present, v2 also stops JSON-parsing text (content-types register, CT-16), so the
declared type and the value agree. Cloudflare’s illustrative types show
Output = unknownor[key:string]: any. The guide’s advice when no schema exists: “Use a generic type and move on” (:212), which is what mcpx does today for every tool. TheoutputSchemafield itself, across revisions, is the tools register’s TOOL-13. - Sources.
v1:packages/opencode/src/tool/code-mode.ts:127output: entry.tool.def.outputSchema as SandboxTool.JsonSchema | undefined,;v2:packages/core/src/tool/mcp.ts:48;schema/2025-06-18/schema.ts:951outputSchema?: {;2025-06-18/server/tools.mdx:315“Clients SHOULD validate structured results against this schema.”;internal/codegen/emit.go:458
CODE-45 lootbox’s generated types carry no descriptions
- What. lootbox turns MCP tools into interfaces and method signatures with no JSDoc: tool descriptions are stored but never printed, and property descriptions are dropped. mcpx and opencode render JSDoc for tools and properties; Cloudflare renders doc comments.
- Where. lootbox lacks them; mcpx, opencode, Cloudflare have them.
- mcpx @ 05c78b2. Tool JSDoc at
internal/codegen/emit.go:81-90; per-propertyjsdoc(p, inner)atinternal/codegen/ts.go:229-231; serverInstructionsand operatorPreludeabove the signatures (internal/codegen/ts.go:31-37). - Value to mcpx. + low: already done.
- Effort. S.
- Risk. n/a.
- Detail. Verified live:
curl 127.0.0.1:9420/types/mcp_codedbreturned bare interfaces such ascodedb_callpath(args: Codedb_McpCodedb_Codedb_callpathArgs): Promise<Codedb_McpToolResult>;with no comments.generateArgsInterfacekeeps only name, type and optional (.lootbox/src/lib/external-mcps/parse_mcp_schemas.ts:168-178). opencode v1 adds@default/@formattags (v1:packages/codemode/README.md:214). - Sources.
.lootbox/src/lib/type_system/client_generator.ts:404-479;.lootbox/src/lib/external-mcps/parse_mcp_schemas.ts:195-242;internal/codegen/emit.go:81-90
CODE-46 lootbox generates one function per MCP resource
- What. lootbox lists each server’s resources and generates
tools.mcp_x.resource_<name>(args)per resource, with arguments from URI-template variables, callingreadResource. - Where. lootbox. mcpx has one generic
readResourceexport. opencode keeps resources out of code mode. Cloudflare’s code-mode docs do not cover resources. - mcpx @ 05c78b2.
readResource(uri)(internal/execsvc/execsvc.go:610-611). - Value to mcpx. − one function per resource bloats the types for servers with many resources; one reader is simpler.
- Effort. S — nothing to build.
- Risk. n/a.
- Detail. The template branch never runs: lootbox reads
uriTemplateoffresources/listentries (.lootbox/src/lib/external-mcps/mcp_schema_fetcher.ts:110-116), but templates arrive fromresources/templates/list, which lootbox never calls (rg finds nolistResourceTemplatesin.lootbox/src). Every generated resource function therefore takes an empty argument object. The resources register carries the same row (CODE-46). - Also recorded from the resources register. lootbox only. mcpx has one generic
readResource(uri); opencode keeps resources out of code mode. n/a —readResource(uri)(internal/execsvc/execsvc.go:610-611). lootbox’s template branch never runs: it readsuriTemplateoffresources/listentries, but templates come fromresources/templates/list, which lootbox never calls, so every generated function takes an empty argument object. - Sources.
.lootbox/src/lib/external-mcps/parse_mcp_schemas.ts:130;.lootbox/src/lib/rpc/execute_mcp.ts:160-194;internal/execsvc/execsvc.go:610-611;.lootbox/src/lib/external-mcps/mcp_schema_fetcher.ts:110-116
CODE-47 lootbox exposes local TypeScript files as namespaces
- What. Any
.lootbox/tools/<ns>.tsthat exports 0- or 1-argument functions becomestools.<ns>.<fn>. Types are extracted with ts-morph, and each file runs in a worker process with--allow-all, restarted when the file changes. - Where. lootbox only. mcpx turns non-MCP things into servers through config-declared CLI adapters
(
internal/adapter/adapter.go:1-12) and OpenAPI (mcpx api), and scripts import other scripts (--export). - mcpx @ 05c78b2. No TypeScript-module-as-namespace adapter, by choice: a plain module import “already does this
without a protocol in between” (
ASSESSMENT.md:296-299). - Value to mcpx. − ts-morph on the request path was measured as the main cost and the OOM source
(
ASSESSMENT.md:62-64). ASSESSMENT still calls it “the one real gap” if the user relies on it. - Effort. M — a small adapter that serves a module’s exports as an MCP server.
- Risk. Low.
- Detail. The deployed lootbox serves five such namespaces (
fs,kv,sqlite,memory,graphql), per a live/namespacescall. ASSESSMENT measured extracting them at 46–209 ms each, on every discovery request (ASSESSMENT.md:38). - Sources.
.lootbox/README.md:86-99;.lootbox/src/lib/rpc/worker_manager.ts:298-301;ASSESSMENT.md:299“If you use that feature, it is the one real gap.”
CODE-48 lootbox YAML prompt workflows
- What.
lootbox workflow start|step|status|reset|abortwalks an agent through YAML-defined prompt steps: Handlebars templates that can loop between a minimum and maximum count, with state in.lootbox-workflow.jsonand events logged to SQLite. - Where. lootbox only.
- mcpx @ 05c78b2. None, deliberately: “No workflow engine or script history. Lootbox has both; they are separable
concerns” (
ASSESSMENT.md:294-295). - Value to mcpx. − harness-level prompt sequencing, unrelated to reaching MCP servers.
- Effort. M.
- Risk. None.
- Detail. None beyond the above.
- Sources.
.lootbox/README.md:460-471;.lootbox/src/lib/db.ts:71-80;ASSESSMENT.md:294
CODE-49 lootbox’s script-to-gateway protocol is its own WebSocket JSON
- What. A lootbox client sends
{script, id}or{method: "ns.fn" | "mcp_ns.fn", args, id}overws://…/ws. The server first sends{type: "welcome", functions, config}, then answers{result | error, id}. It is neither JSON-RPC nor MCP; MCP calls are told apart only by themcp_prefix. - Where. lootbox. mcpx serves REST (
/v1/call,/v1/exec) over a unix socket or loopback TCP, and MCP at/mcp. opencode is in-process. Cloudflare uses Workers RPC (CODE-51). - mcpx @ 05c78b2.
POST /v1/call(internal/daemon/server.go:468); the generated client posts{server, tool, args}(internal/codegen/emit.go:524-535). - Value to mcpx. + low: mcpx already has a documented REST surface with an OpenAPI description.
- Effort. S.
- Risk. n/a.
- Detail. lootbox has a second socket,
/worker-ws, for its tool workers. Any local client that sends{type:"identify", workerId}is registered as that worker’s sender with no check, and that namespace’s calls are then routed to it (.lootbox/src/lib/rpc/managers/connection_manager.ts:125-131); inferred, not exploited. - Sources.
.lootbox/src/lib/rpc/managers/message_router.ts:16-32;.lootbox/src/lib/rpc/managers/connection_manager.ts:86-93;.lootbox/src/lib/rpc/websocket_server.ts:273-291
CODE-50 Script client reaches the daemon over a unix socket
- What. mcpx’s generated client uses the daemon’s unix socket when
MCPX_SOCKETis set: throughDeno.createHttpClient({proxy:{transport:"unix"}})on Deno andfetch(u, {unix})on Bun. It falls back to loopback TCP, which is what node always uses. - Where. mcpx. lootbox uses a TCP WebSocket. opencode is in-process. Cloudflare uses Workers RPC.
- mcpx @ 05c78b2.
internal/codegen/emit.go:268-293. - Value to mcpx. + med: the socket’s file mode becomes the access control (
defaults.PrivateMode,internal/daemon/server.go:202), and calls are about four times faster (0.17 ms versus 0.65 ms, per the comment). - Effort. S — done.
- Risk. node scripts fall back to TCP, which is unauthenticated and checks no
Origin(auth register, AUTH-24). - Detail. ASSESSMENT recorded a fix for socket paths over macOS’s 104-byte
sun_pathlimit (ASSESSMENT.md:262-264). - Sources.
internal/codegen/emit.go:258-261;internal/codegen/emit.go:268-293
CODE-51 Cloudflare connects script and host through Workers RPC
- What. Cloudflare’s sandbox gets one
Proxyper namespace, and each call crosses to the host over Workers RPC (ToolDispatcher/ConnectorBinding.callTool). The host intercepts every call for approval, logging and replay. - Where. Cloudflare.
- mcpx @ 05c78b2. HTTP to the daemon (CODE-50;
internal/codegen/emit.go:524-535). - Value to mcpx. − Workers-only.
- Effort. XL.
- Risk. None; not a candidate.
- Detail. “The runtime intercepts each call before the connector executes it.” (how-it-works, “Connectors”).
- Sources.
internal/codegen/emit.go:524-535; https://developers.cloudflare.com/agents/tools/codemode/how-it-works/#connectors (“Connector calls cross the sandbox boundary through Workers remote procedure calls (RPC).”); https://developers.cloudflare.com/agents/tools/codemode/api-reference/#executor (binding: { callTool(method: string, args: unknown): Promise<unknown>; };)
CODE-52 Every run recorded with its script text and output
- What. Every lootbox execution is written to a
script_runstable: id, timestamp, full script text, success flag, full output, error, duration and session id. The write is fire-and-forget, and nothing in lootbox reads the table back. - Where. lootbox. mcpx keeps a durable structured log (
mcpx_log) and an artifact index. Cloudflare keeps a durable execution log capped atmaxExecutions50. opencode keeps only the session transcript. - mcpx @ 05c78b2. Log records and run ids, not the script source (
internal/mcpserver/server.go:339-341). - Value to mcpx. + low: storing the source per run would let an agent “re-run what worked yesterday” and would feed recipe candidates. − stored output is where secrets end up.
- Effort. M — a retention setting and redaction.
- Risk. Output is stored unredacted.
- Detail. Observed by the research worker on 2026-09-30: the live table held 1,713 runs from 2026-04-05 to
2026-09-30, of which 1,100 succeeded (a 36% failure rate), and none had a session id, because the CLI never sends
one (
.lootbox/src/lib/lootbox-cli/exec.ts:40). That is a baseline mcpx’s own success rate can be compared with. The live DB is~/Library/Application Support/lootbox/lootbox.db(.lootbox/src/lib/db.ts:19-23); the.lootbox/lootbox.dbfile in the checkout is a stray. ASSESSMENT left history out on purpose (ASSESSMENT.md:294-295). The process-model register has the same row (CODE-52). - Also recorded from the process model register. Cloudflare keeps a durable execution log capped at
maxExecutions50. mcpx keeps a structured log and an artifact index but not the script source. opencode keeps only the transcript.mcpx_log(internal/mcpserver/server.go:339-341); run ids without source. The live lootbox table holds 1,713 runs (2026-04-05 to 2026-09-30), 36% failed, and none carries a session id because the CLI never sends one.lootbox.dbin the checkout is a stray. - Sources.
.lootbox/src/lib/script_history.ts:33“Don’t await - fire and forget”;.lootbox/src/lib/script_history.ts:29-63;.lootbox/src/lib/db.ts:97; https://developers.cloudflare.com/agents/tools/codemode/how-it-works/#durable-execution-log (“Retention and stale executions”);internal/mcpserver/server.go:339-341
CODE-53 opencode wraps MCP servers in code mode by default
- What. In v2 each MCP server config has
codemode, default true, and every tool whoseoptions.codemode !== falsedisappears from the direct tool list intoexecute. In v1 code mode is gated byOPENCODE_EXPERIMENTAL, which the user’s build sets, so the installed v1 does the same for MCP tools. mcpx’s own/mcpis therefore nested inside opencode’s code mode unless the user opts out. - Where. opencode v2: on by default, per server; a built-in plugin opts out only
executorservers. opencode v1:experimentalCodeModeisenabledByExperimental("OPENCODE_EXPERIMENTAL_CODE_MODE"), the specific flag when set, elseOPENCODE_EXPERIMENTAL; the nix wrapper and home profile setOPENCODE_EXPERIMENTAL=1. - mcpx @ 05c78b2. Not listed by the exclusion plugin; nothing in mcpx’s docs tells users to opt out (
codemode: falsein v2,OPENCODE_EXPERIMENTAL_CODE_MODE=0in v1). The model would write opencode JS that callstools.mcpx.mcpx_exec({source: "<TS>"}): code nested in code. - Value to mcpx. + high: one docs sentence (or a v2 plugin that sets it through
mcp.transform) removes two catalogues, two error surfaces and double cost per call. - Effort. S.
- Risk. Not done: models get confused and every call costs twice. Plans that assume v1 shows MCP tools directly are wrong for this machine.
- Detail. v2 wraps all tools, not only MCP ones; built-ins opt out individually
(
v2:packages/core/src/tool/plugin/edit.ts:123,v2:packages/core/src/tool/plugin/grep.ts:77), and the whole of code mode is switched off only by a deny rule onexecute. v1 wraps MCP tools only (v1:packages/opencode/src/tool/code-mode.ts:210), registers none of them directly once the flag is on, and still adds three resource tools when a server declaresresources. The exclusion plugin matches localexecutor mcpand remotehttps://executor.sh/<x>/mcp(v2:packages/core/src/plugin/mcp-codemode-exclusion.ts:7,:16). For remote servers in code mode, v2 also appends?codemode=falseto the URL, asking servers that bundle their own code mode for raw tools, and retries without it on 400 or 404; mcpx neither sends nor honours it (plugin-API register, PLG-35). The plugin-API register holds the same default from the plugin angle (CODE-53, and PLG-27 for plugin tools).OPENCODE-V2.mdis wrong at the pin in two places here. The doc calls the exclusion plugin “mcp-codemode-defaults.ts” and says it turns code mode off “for servers that are themselves code mode” (OPENCODE-V2.md:53-54); the source names itmcp-codemode-exclusion.tsand matches onlyexecutor. The doc says “If you stay on opencode v1 … then none of v2’s Code Mode is available to you today” (OPENCODE-V2.md:222-225); the source enables it from the environment the user’s build sets, and the doc’s correction note says only that v1 ships it “behind” the flag (OPENCODE-V2.md:4-5). - Also recorded from the plugin apis register. opencode v2 (per server, default on); opencode v1 (global, off
unless the flag is set; this user’s nix build sets
OPENCODE_EXPERIMENTAL=1, so v1 here already runs code mode). Not excluded. mcpx’s/mcpis wrapped: the model writes opencode code-mode JS that callstools.mcpx.mcpx_exec({source: "<TS>"}), code nested in code.OPENCODE-V2.md:53calls the filemcp-codemode-defaults.tsand says it turns code mode off “for servers that are themselves code mode”; the file ismcp-codemode-exclusion.tsand matches only executor. v2 also adds “Use tools from this server throughexecute” to a code-mode server’s instructions (capabilities area). - Sources.
v2:packages/schema/src/mcp.ts:34-36“Expose this server’s tools through Code Mode. Defaults to true.”;v2:packages/core/src/tool.ts:228-229;v2:packages/core/src/plugin/mcp-codemode-exclusion.ts:16;v1:packages/opencode/src/effect/runtime-flags.ts:11-14;v1:packages/opencode/src/effect/runtime-flags.ts:48;v1:packages/opencode/src/session/tools.ts:388if (flags.experimentalCodeMode) return tools;nix:flake.nix:297“–set-default OPENCODE_EXPERIMENTAL 1”;nix:homeUser.nix:115;OPENCODE-V2.md:53“that turns opencode’s code mode”;v2:packages/core/src/plugin/mcp-codemode-exclusion.ts:7
CODE-54 Code execution reachable from a shell, HTTP or CI
- What. opencode’s code mode runs only when a model calls
executeinside a session; no CLI command or HTTP route invokes it. mcpx runs scripts from the shell,/v1/execand MCP; lootbox fromlootbox execand its WebSocket. - Where. mcpx; lootbox; Cloudflare (from the Worker’s own code). opencode v1 and v2: model turn only.
- mcpx @ 05c78b2.
mcpx exec/mcpx run,POST /v1/exec(internal/daemon/routes_exec.go:36), andmcpx_execover MCP (internal/mcpserver/server.go:327). - Value to mcpx. + high: scripting from shell, CI, cron or another agent has no opencode equivalent; this is one of
the two things
OPENCODE-V2.mdsays to keep mcpx for (OPENCODE-V2.md:218-220). - Effort. S — done.
- Risk. None.
- Detail. The only mention of code-mode execution in v2’s protocol code is a simulation schema, and
POST /api/generateis text generation (v2:packages/protocol/src/groups/generate.ts:8); rg overv2:packages/server/srcandv2:packages/cli/srcfinds only an ACP tool-kind mapping (v2:packages/cli/src/acp/tool.ts:15). The doc’s claim that nothing outside a model turn can invoke it (OPENCODE-V2.md:62-67) holds at the pin. Serving code execution as an MCP server (mcpx/mcp, CloudflarecodeMcpServerandopenApiMcpServer; lootbox never) is the plugin-API register’s PLG-41. - Sources.
v2:packages/protocol/src/simulation.ts:500;v2:packages/protocol/src/groups/generate.ts:8;v1:packages/opencode/src/tool/registry.ts:118-119;internal/daemon/routes_exec.go:36
Completion
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: completion/complete per revision (capability, references, context, limits), what mcpx answers and forwards, and what opencode sends
completion/complete suggests values for a prompt argument or a resource-template variable as a user types. The
method is in every revision, while the completions capability (2025-03-26; CAP-13 in the capabilities register),
context.arguments (2025-06-18) and a schema-enforced 100-value limit (2026) came later. What matters most at
05c78b2: mcpx’s MCP server ignores the reference and answers with its own tool names although the daemon can already
forward the request to the upstream that owns the prompt, and neither opencode version ever sends the request.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| CMP-01 | method exists in 2024-11-05 with no gating capability | 2024-11-05 has mcpx missing | 24-11 ✓ (ungated) · 25-03 ✓ (gated) · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | partial — served in every era; a 2024-11-05 upstream is never asked (#213) | + low | S | low |
| CMP-02 | calling without the capability → -32601 | 2025-03-26 has | 25-03 ✓ onward (prose) | ✓ — client does not call undeclared upstreams | + low | S | low |
| CMP-03 | ref/prompt and ref/resource; ResourceReference renamed, wire unchanged | 2024-11-05 has 2025-06-18 has | all five (TS rename 25-06; 26-07 prose: uri may be a template) | ✓ — wire unchanged; /v1/complete accepts both | + low | S | low |
| CMP-04 | PromptReference.title | 2025-06-18 has | 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✓ — tolerated; dropped when forwarded | + low | S | low |
| CMP-05 | context.arguments: previously resolved argument values | 2025-06-18 has mcpx missing | 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✗ — ignored by the MCP server, not forwarded by /v1 (#213) | + med | S | med |
| CMP-06 | at most 100 values; schema-enforced from 2026 | 2024-11-05 has 2026-07-28 has | 24-11..25-11 prose · 26-07 @maxItems 100 | ✓ — own replies capped at 100 by default | + low | S | low |
| CMP-07 | total and hasMore (no cursor) | 2024-11-05 has | all five ✓ | ✓ — computed correctly for what mcpx returns | + low | S | low |
| CMP-08 | completion is neither MRTR-capable nor cacheable | 2026-07-28 has | 26-07 ✓ | ✓ — n/a in practice | + low | S | low |
| CMP-09 | mcpx ignores ref and completes from its own tool names | 2024-11-05 has mcpx missing | mcpx only | ✗ — prompt argument got tool names (wire W24) (#213) | + med | S | med |
| CMP-10 | MCP surface never forwards upstream, though the daemon can | mcpx missing | mcpx only | ✗ — Pool.Complete unused by /mcp (#213) | + med | S | low |
| CMP-11 | neither opencode version sends completion/complete | mcpx has, others don't | opencode v1 — · v2 — | n/a — mcpx’s completion serves other hosts | + low | S | low |
CMP-01 completion/complete without a capability in 2024-11-05
- What. The method, its
ref,argument {name, value}and resultcompletion {values, total?, hasMore?}are all in 2024-11-05; no capability gates them. 2025-03-26 addscompletions, which servers supporting the method MUST declare. - Where. Method in all five; capability from 2025-03-26.
- mcpx @ 05c78b2. As a server it answers in every era (
internal/mcpserver/server.go:558-563) and declarescompletionsin every era it serves; it does not serve 2024-11-05 (internal/mcpserver/server.go:778). As a client it callscompletion/completeonly when the upstream declaredcompletions(internal/mcpclient/request.go:50), so a 2024-11-05 upstream — which cannot declare it — is never asked, even if it implements the method. - Value to mcpx. + low: accept liberally by trying the method on a 2024-11-05 upstream and treating
-32601as “unsupported”. - Effort. S.
- Risk. A 2024-11-05 server that implements completion looks like one that does not.
- Detail. The gate’s comment explains why it exists: a well-behaved server answers method-not-found and “the rest” answer unpredictably. For 2024-11-05 that reasoning inverts, because declaring was impossible.
- Sources.
schema/2024-11-05/schema.ts:954“export interface CompleteRequest extends Request {”;2025-03-26/changelog.mdx:26“Addedcompletionscapability to explicitly indicate support for argument”;internal/mcpclient/request.go:50“if !c.Supports(”;internal/mcpserver/server.go:558-563
CMP-02 capability-less call → -32601
- What. From 2025-03-26 the prose error list includes “Method not found:
-32601(Capability not supported)”. - Where. 2025-03-26 onward.
- mcpx @ 05c78b2. As a server mcpx always declares and always answers, so it never needs this code. As a client it
refuses to call an upstream that did not declare
completionsand reportsok=falseso the caller can fall back (internal/mcpclient/request.go:50). - Value to mcpx. + low; nothing to change.
- Effort. None.
- Risk. None.
- Detail. mcpx deliberately answers rather than returning
-32601, because “a client offering completion and receiving method-not-found simply shows nothing, and the user concludes the feature is broken” (internal/mcpserver/server.go:559-562). The cost of that choice is CMP-09. - Sources.
2025-03-26/server/utilities/completion.mdx:130“- Method not found:-32601(Capability not supported)”;internal/mcpclient/request.go:50;internal/mcpserver/server.go:559-562
CMP-03 reference types
- What.
refis{type: "ref/prompt", name}or{type: "ref/resource", uri}. The TypeScript name of the resource variant changes fromResourceReferencetoResourceTemplateReferencein 2025-06-18 —typeanduriunchanged. 2026 prose says theurimay be a URI template. - Where. All five; the rename is in no changelog.
- mcpx @ 05c78b2. The MCP server ignores
refentirely (CMP-09)./v1/completeaccepts both types and rejects any other with 400 (internal/daemon/ops.go:339-343). - Value to mcpx. + none on the wire.
- Effort. None.
- Risk. None.
- Detail. The rename signals that
ref/resourcenames a template, which is whaturiheld in practice. - Sources.
schema/2025-03-26/schema.ts:1138“export interface ResourceReference {”;schema/2025-06-18/schema.ts:1369“export interface ResourceTemplateReference {”;2026-07-28/server/utilities/completion.mdx:140“References a resource URI or URI template”;internal/daemon/ops.go:339-343
CMP-04 PromptReference.title
- What.
PromptReference extends BaseMetadata, so a reference may carrytitlebesidename. - Where. 2025-06-18 onward.
- mcpx @ 05c78b2. Tolerated.
/v1/completere-marshalsreffromtype,nameandurionly (internal/daemon/ops.go:314-318,internal/daemon/ops.go:355), so atitleis dropped before an upstream sees it. - Value to mcpx. + none.
- Effort. None.
- Risk. None.
- Detail. A display title on a request reference carries no meaning for the server; ignoring it is right.
- Sources.
schema/2025-06-18/schema.ts:1384“export interface PromptReference extends BaseMetadata {”;internal/daemon/ops.go:355
CMP-05 context.arguments
- What. An optional
context: { arguments?: {[name]: string} }on the request carries the values already chosen for other arguments, so a multi-argument prompt or template can complete dependent values (the branch after the repo). - Where. 2025-06-18 onward. Values are strings only.
- mcpx @ 05c78b2. The MCP server parses only
argument(internal/mcpserver/server.go:1556-1562)./v1/completebuilds the upstream request fromrefandargumentalone (internal/daemon/ops.go:355); its owncontextfield is mcpx’s call context (session, cwd), a different thing that happens to share the name (internal/daemon/ops.go:323). - Value to mcpx. + correct dependent completion once requests are forwarded (CMP-10).
- Effort. S — accept
context.argumentsand pass it through. - Risk. If not done: upstreams suggest values that ignore earlier choices. The name clash on
/v1invites a caller to putargumentsin the wrongcontext. - Detail. None beyond the above.
- Sources.
schema/2025-06-18/schema.ts:1333“context?: {”;2025-06-18/server/utilities/completion.mdx:79“clients should include previous completions in thecontext.argumentsobject”;internal/daemon/ops.go:355“params, _ := json.Marshal(map[string]any{”;internal/daemon/ops.go:323“Context config.CallContext `”
CMP-06 the 100-value limit
- What. A reply may carry at most 100 values. Through 2025-11-25 that is prose and a doc comment; 2026 adds a
machine-checkable
@maxItems 100. - Where. Limit in all five; schema constraint in 2026.
- mcpx @ 05c78b2. Its own replies are cut to
completion.maxValues(default 100;internal/mcpserver/server.go:1583-1586). The setting’s help says “higher is not” legal, but nothing clamps it (internal/settings/wire.go:179-183)./v1/completepasses an upstream’scompletionthrough without applying the cap (internal/daemon/ops.go:362-374). - Value to mcpx. + truncate upstream replies before they reach a 2026 host, once CMP-10 forwards them.
- Effort. S.
- Risk. A value above 100, from the setting or a sloppy upstream, fails 2026 schema validation.
- Detail. The MCP server and
/v1read the same setting; a comment records that the MCP side used to hard-code 100 (internal/mcpserver/server.go:1579-1582). - Sources.
2024-11-05/server/utilities/completion.mdx:78“- Maximum 100 items per response”;schema/2026-07-28/schema.ts:2649“@maxItems 100”;internal/settings/wire.go:179-183;internal/mcpserver/server.go:1583-1586;internal/daemon/ops.go:362-374
CMP-07 total and hasMore
- What.
totalmay exceed the number of values sent;hasMoresays more exist even when the total is unknown. There is no cursor: the client narrows by typing more. - Where. All five, unchanged.
- mcpx @ 05c78b2. Both the MCP server and the
/v1cache fallback settotalto the full match count andhasMoreto whether values were cut (internal/mcpserver/server.go:1587-1598,internal/daemon/ops.go:417-421). - Value to mcpx. + low.
- Effort. None.
- Risk. None.
- Detail. Correct for what is computed; what is computed is the problem (CMP-09).
- Sources.
schema/2024-11-05/schema.ts:986“total?: number;”;schema/2024-11-05/schema.ts:990“hasMore?: boolean;”;internal/mcpserver/server.go:1587-1598
CMP-08 neither MRTR-capable nor cacheable
- What. In 2026
CompleteRequestParamsextends plainRequestParams(notInputResponseRequestParams) andCompleteResultextendsResult(notCacheableResult). - Where. 2026.
- mcpx @ 05c78b2. Nothing to do: mcpx never answers completion with
input_requiredand adds no cache hints. - Value to mcpx. + no work.
- Effort. None.
- Risk. None.
- Detail. An upstream that would need user input to complete has no protocol path; it can only return nothing.
- Sources.
schema/2026-07-28/schema.ts:2593“export interface CompleteRequestParams extends RequestParams {”;schema/2026-07-28/schema.ts:2644“export interface CompleteResult extends Result {”
CMP-09 mcpx ignores ref and completes tool names
- What. Whatever the reference — a prompt argument, a resource-template variable — mcpx’s MCP server answers with its own tool and namespace names that contain the typed text.
- Where. mcpx only.
docs/protocol.md:86documents it: “from mcpx’s own names”. - mcpx @ 05c78b2.
completeparses onlyargumentand matches againsts.Tools()(internal/mcpserver/server.go:1555-1599). Wire W24: completing promptfake_summarise, argumentstyle, valueb, returned["mcpx_ask_abandon","mcpx_ask_begin","mcpx_globals"]. - Value to mcpx. + real suggestions; − today’s output is noise for every real use.
- Effort. S — forward to the owning upstream when
refnames one (CMP-10). - Risk. Hosts show nonsense suggestions, and users conclude completion is broken — the outcome the design meant to avoid.
- Detail. The
completionsdeclaration is honest in that mcpx answers; it is not honest about what the answers are. - Sources.
internal/mcpserver/server.go:1556-1562;internal/mcpserver/server.go:1555-1599;docs/protocol.md:86
CMP-10 the MCP surface never forwards upstream
- What.
/v1/completeforwardscompletion/completeto the server that owns the prompt or template throughPool.Completewhen that server declaredcompletions, and falls back to cached names otherwise, saying which in asourcefield. The MCPcompletion/completebranch never uses it. - Where. mcpx internal parity (CLI = /v1 = MCP).
- mcpx @ 05c78b2.
internal/pool/request.go:60-70;internal/daemon/ops.go:357; the MCP branch (internal/mcpserver/server.go:558-563) answers locally. - Value to mcpx. + real argument values from upstream prompts and templates, through the protocol hosts already speak.
- Effort. S — split the namespaced prompt name back into
<ns>and<name>, asGetPromptalready does (internal/cli/serve.go:593-594), and stripmcpx://<ns>/from template URIs. - Risk. None.
- Detail. The
/v1cache fallback has its own bug: forref/resourceit matchest.URI(internal/daemon/ops.go:405-409), but cached templates carry their template inURITemplateand leaveURIempty (internal/pool/pool.go:562-571), so a non-empty prefix matches nothing and an empty one returns empty strings. Forref/promptthe fallback suggests prompt names, not argument values. - Sources.
internal/pool/request.go:60“func (p *Pool) Complete(ctx context.Context, sessionKey string, params json.RawMessage) (json.RawMessage, bool, error) {”;internal/daemon/ops.go:357;internal/daemon/ops.go:405-409;internal/pool/pool.go:562-571
CMP-11 opencode never sends completion/complete
- What. Neither opencode client calls
completion/complete(norresources/subscribeorlogging/setLevel). - Where. opencode v1 and v2 lack it; their MCP client interfaces have no completion method.
- mcpx @ 05c78b2. n/a — mcpx’s completion (CMP-09, CMP-10) serves other hosts.
- Value to mcpx. + low: fixing CMP-09 improves mcpx for other hosts, not opencode.
- Effort. None.
- Risk. None.
- Detail. v2 on a modern connection can still open
subscriptions/listenthrough its SDK for list changes. - Sources.
v1:packages/opencode/src/mcp/index.ts:164;v2:packages/core/src/mcp/client.ts:91
Content types
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: Every MCP content type and result-content field per revision, and what mcpx, opencode, lootbox and Cloudflare do with each.
A tool result, a prompt message and a sampling message carry typed content blocks: text, image, audio, embedded
resource, resource_link, and, inside sampling only, tool_use and tool_result; each may carry annotations and
_meta, and a tool result may add structuredContent. mcpx has a per-revision downgrade() that rewrites these for
older clients, but in production only two of its five branches can fire; what decides what a host actually sees is that
mcpx_call renders every upstream result to one text block (dropping images, audio, resources, _meta and isError),
so the only non-text block mcpx ever sends is the resource_link for an mcpx_exec artifact.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| CT-01 | text content block | 2024-11-05 has opencode v1 has opencode v2 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ (+_meta) · 25-11 ✓ · 26-07 ✓ | ✓ — the only block mcpx_call emits | + high | S | low |
| CT-02 | image content block | 2024-11-05 has opencode v1 has opencode v2 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ · opencode v1 attachment · v2 media | partial — mcpx_call drops it; scripts keep it | + med | M | med |
| CT-03 | audio content block | 2025-03-26 has opencode v2 has | 24-11 — · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ · opencode v1 dropped · v2 media | partial — dropped by mcpx_call; downgrade branch dead | + low | S | low |
| CT-04 | Embedded resource block (tool results and prompts only) | 2024-11-05 has opencode v1 has opencode v2 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ · never in sampling | partial — dropped by mcpx_call; downgrade target | + med | S | low |
| CT-05 | resource_link block | 2025-06-18 has opencode v2 has | 25-03 — · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ · opencode v1 direct dropped · v2 uri text | ✓ — emitted for mcpx_exec artifacts only | + high | S | med |
| CT-06 | resource_link rewritten as embedded resource below 2025-06-18 | 2025-06-18 has mcpx missing | mcpx only (downgrade for 2025-03-26 clients) | partial — broken mime: link type over text label (#206) | + low | S | med |
| CT-07 | ContentBlock union and where each type is allowed | 2025-06-18 has specs conflict | 24-11 inline unions · 25-06 ContentBlock · prompts prose lags to 26-07 | ✓ — gated per revision in downgrade() | + low | S | low |
| CT-08 | tool_use / tool_result blocks, sampling only | 2025-11-25 has 2026-07-28 deprecates | 25-11 ✓ · 26-07 dep | n/a — not examined | − niche | M | low |
| CT-09 | annotations on content (audience, priority, lastModified) | 2024-11-05 has 2025-06-18 has | 24-11 Annotated · 25-03 Annotations · 25-06 +lastModified · 26-07 ✓ | partial — passed by downgrade(), dropped by mcpx_call | + med | S | low |
| CT-10 | _meta on content blocks | 2025-06-18 has | 25-03 — · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | partial — dropped by mcpx_call, link rewrite, MRTR render | + low | S | low |
| CT-11 | ToolResultContent.structuredContent becomes any JSON | 2026-07-28 has | 25-11 object · 26-07 any JSON (sampling) | n/a — not examined | + low | S | low |
| CT-12 | What downgrade() touches, and which branches can fire | mcpx has, others don't | mcpx only | partial — only link and resultType branches live | + med | S | med |
| CT-13 | mcpx_call renders every result to one text block | 2025-06-18 has mcpx missing | mcpx /mcp and stdio mcpx serve | partial — images, audio, resources, _meta dropped (#206) | + med | M | high |
| CT-14 | Unknown content block type fails the whole call | opencode v1 has opencode v2 has | opencode v1 verified · v2 same union | ✓ — never forwards an unknown block | + med | S | high |
| CT-15 | Script clients unwrap tool results | has better replacement opencode v1 has opencode v2 has | mcpx · opencode v1/v2 · Cloudflare unwrap · lootbox raw envelope | ✓ — structuredContent, then text, then blocks | + high | S | low |
| CT-16 | JSON arriving as text parsed into a value | opencode v2 has | mcpx always by shape · opencode v2 without outputSchema · v1 never | ✓ — parses {…}/[…] text | + low | S | low |
| CT-17 | Full envelope kept on .raw | mcpx has, others don't | mcpx only | ✓ — non-enumerable .raw on every value | + med | S | low |
| CT-18 | Media produced by a tool inside a script | opencode v1 has opencode v2 has | opencode attaches to model · mcpx artifact link · lootbox base64 · CF mixed | partial — plugin never declares artifacts | + med | S | med |
| CT-19 | isError: true becomes an exception in scripts | has better replacement opencode v1 has opencode v2 has | mcpx · opencode v1/v2 · Cloudflare throw · lootbox returns it | ✓ — ToolError with .raw | + high | S | low |
CT-01 text content block
- What.
{type: "text", text, annotations?, _meta?}. - Where. All five revisions; allowed in tool results, prompt messages and sampling messages.
_metafrom 2025-06-18. opencode v1 and v2 pass it to the model as text. - mcpx @ 05c78b2. The one block
mcpx_callever emits (CT-13); also whatdowngrade()rendersstructuredContentand audio into for older clients (internal/mcpserver/revisions.go:146-156,:224-226). - Value to mcpx. + high: the baseline every host reads.
- Effort. S (nothing to do).
- Risk. None.
- Detail. Everything mcpx cannot express for a client ends up here, so text is also where information is lost silently: a text block has no mime type and no URI.
- Sources.
schema/2024-11-05/schema.ts:846“type: "text";”;schema/2026-07-28/schema.ts:2317“type: "text";”;v2:packages/core/src/mcp/client.ts:324
CT-02 image content block
- What.
{type: "image", data (base64), mimeType, annotations?, _meta?}. - Where. All five revisions; tool results, prompts and sampling. opencode v1 turns it into a data-URL file
attachment; v2 into a
mediapart. Both deliver it to a vision model. - mcpx @ 05c78b2.
mcpx_calldrops it (renderResultkeeps onlytext,internal/cli/commands.go:385-415); if a result has no text block at all, the entire raw result JSON, base64 included, becomes the text (internal/cli/commands.go:414). Scripts keep images reachable through the value and.raw(CT-15, CT-17). The MRTR result render also drops images (see the MRTR area). - Value to mcpx. + med: a screenshot tool behind
mcpx_calleither loses the picture or floods the context with base64. − text keeps context small, which is mcpx’s goal;mcpx_execartifacts (CT-05) are the designed answer. - Effort. M — pass image blocks through, or turn them into artifacts the way
mcpx_execdoes. - Risk. Med: megabytes of base64 in a model’s context when the upstream returns only an image.
- Detail. The raw-JSON fallback is the worst case: an image-only result produces no text, so
renderResultreturnsstring(raw). - Sources.
schema/2024-11-05/schema.ts:857“type: "image";”;internal/cli/commands.go:414“return string(raw)”;v1:packages/opencode/src/session/tools.ts:430;v2:packages/core/src/mcp/client.ts:325-326
CT-03 audio content block
- What.
{type: "audio", data (base64), mimeType}. - Where. 2025-03-26 onward; tool results, prompts and sampling. opencode v2 treats it as
media; opencode v1’s direct path drops it silently (its converter handles only text, image and resource), while v1 code mode attaches it as a file. - mcpx @ 05c78b2.
downgrade()has an audio→text branch (“(audio mime, base64, elided)”), butFeatAudio’s floor equalsOldest(2025-03-26), and every version mcpx agrees to is at or above it, so the branch cannot fire; a 2024-11-05 client is refused atinitializebefore it could need it. mcpx emits no audio anyway, andmcpx_calldrops upstream audio. - Value to mcpx. − dead code today. + becomes live if mcpx ever serves 2024-11-05.
- Effort. S.
- Risk. Serving 2024-11-05 without extending the floors table would send 2025-03-26 shapes (audio, tool
annotations, thecompletionscapability) to 2024-11-05 clients. - Detail. The comment at
internal/mcpserver/revisions.go:45-48says the branch is kept deliberately (“always true today” is a fact about the floor).describeBlobappends “base64, elided” only whendatawas non-empty. - Sources.
schema/2025-03-26/schema.ts:991“type: "audio";”;2025-03-26/changelog.mdx:25“Added support for audio data, joining the existing text and image content types”;internal/mcpserver/revisions.go:87“FeatAudio: "2025-03-26",”;internal/mcpserver/revisions.go:14;v1:packages/opencode/src/session/tools.ts:429
CT-04 Embedded resource block (tool results and prompts only)
- What.
{type: "resource", resource: TextResourceContents | BlobResourceContents, annotations?, _meta?}: the resource’s contents inline. - Where. All five revisions in tool results and prompt messages. Never a top-level sampling block in any revision
(from 2025-11-25 only nested inside
tool_result.content). opencode v1 turns text into text and a blob into an attachment only for PDF, GIF, JPEG, PNG and WebP up to 10 MiB, else a “[Binary MCP resource omitted …]” line; v2 turns text into text and a blob intomedia, or into its URI when the blob has nomimeType. - mcpx @ 05c78b2. The target of the
resource_linkdowngrade (CT-06).mcpx_calldrops upstream embedded resources. - Value to mcpx. + med: the only way before 2025-06-18 to hand a client something with a URI.
- Effort. S.
- Risk. Low.
- Detail. In 2024-11-05 the sampling message union is
TextContent | ImageContentonly, so a resource can never be sampled directly. - Sources.
schema/2024-11-05/schema.ts:617“type: "resource";”;schema/2024-11-05/schema.ts:812“content: TextContent | ImageContent;”;v1:packages/opencode/src/session/tools.ts:32“const MAX_MCP_RESOURCE_BLOB_BYTES = 10 * 1024 * 1024”;v1:packages/opencode/src/session/tools.ts:436;v2:packages/core/src/mcp/client.ts:328
CT-05 resource_link block
- What.
ResourceLink extends Resourceplustype: "resource_link": a URI reference with name, mime type and description instead of inline contents. A link returned by a tool is not guaranteed to appear inresources/list. - Where. 2025-06-18 onward in tool results and prompts (and inside sampling
tool_result.contentfrom 2025-11-25). opencode v2 renders it as its bareuriin a text part (the name is lost). opencode v1 drops it silently on the direct path and renders"<name>: <uri>"in code mode. - mcpx @ 05c78b2. The only non-text block mcpx emits:
mcpx_execartifacts becomeresource_linkblocks (urimcpx://artifacts/{id},name,mimeType,description“N bytes, produced by this script”,internal/cli/serve.go:174-187). They travel through the backend’s string result behind a U+001E marker and are re-encoded intools/call(internal/mcpserver/toolresult.go:21,:34-56;internal/mcpserver/server.go:676-681). - Value to mcpx. + high: keeps binaries out of context, the reason
mcpx_execexists. - Effort. S (done).
- Risk. Med: opencode v1 without code mode never shows the model an
mcpx_execartifact; the text part of the result should mention the URI for such hosts. - Detail.
descriptionon a link is valid becauseResourceLinkextendsResource. The prompts prose documents links only from 2026-07-28 although the 2025-06-18 schema allows them (CT-07). - Sources.
schema/2025-06-18/schema.ts:761“type: "resource_link";”;schema/2026-07-28/schema.ts:1712“Note: resource links returned by tools are not guaranteed to appear in the results of”;internal/cli/serve.go:180“"type": "resource_link",”;v2:packages/core/src/mcp/client.ts:327“if (part.type === "resource_link") return [{ type: "text", text: part.uri }]”;v1:packages/opencode/src/tool/code-mode.ts:104
CT-06 resource_link rewritten as embedded resource below 2025-06-18
- What. For a 2025-03-26 client,
linkAsResourceturns a link into{type: "resource", resource: {uri, mimeType: <link's mimeType>, text: "name — uri"}}, in tool results and inside a prompt message’s single block. - Where. mcpx only.
- mcpx @ 05c78b2.
internal/mcpserver/revisions.go:220-223(dispatch),:248-253(prompt messages),:260-273(the rewrite). For a PNG artifact the client receivesmimeType: "image/png"over a text body that is a label. - Value to mcpx. + low:
text/plainfor the label, with the original type in the text or_meta, would be honest. - Effort. S.
- Risk. Med: a 2025-03-26 host that trusts
mimeTypetries to render a label as an image. - Detail.
text/plainis used only when the link has nomimeType(internal/mcpserver/revisions.go:264-266), so the label is typed honestly only for untyped links; every typed artifact is mislabelled. The rewrite keepsurimachine-readable (its purpose) but drops the link’sdescription,title,size,annotationsand_meta. It is one of the twodowngrade()branches that can fire in production (CT-12). - Sources.
internal/mcpserver/revisions.go:271-272“"uri": uri, "mimeType": mime, "text": text}}”;schema/2025-03-26/schema.ts:654
CT-07 ContentBlock union and where each type is allowed
- What. 2025-06-18 names the union
TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResourceand uses it forCallToolResult.content[]andPromptMessage.content; earlier revisions spell inline unions. - Where. Allowed blocks per location (T text, I image, A audio, R embedded resource, L link, U
tool_use, Xtool_result):CallToolResult.content[]— 24-11 T I R; 25-03 T I A R; 25-06 onward T I A R L.PromptMessage.content(one block, never an array) — same progression.SamplingMessage.content— 24-11 T I; 25-03/25-06 T I A; 25-11 T I A U X, single or array; 26-07 same, deprecated.ToolResultContent.content[](nested in sampling) — 25-11 onward T I A R L.ReadResourceResult.contents[]isText|BlobResourceContents, never content blocks. - mcpx @ 05c78b2.
downgrade()gatesresource_linkandstructuredContentby revision (internal/mcpserver/revisions.go:146); audio is gated but never fires (CT-03). - Value to mcpx. + low: the table is what
downgrade()must encode. - Effort. S.
- Risk. Low.
- Detail. Prose and schema disagree for prompts: the 2025-06-18 schema allows
resource_linkin prompt messages, but the 2025-06-18 and 2025-11-25 prompts pages document only text, image, audio and embedded resource; a “Resource Links” section first appears in 2026-07-28, and the 2025-06-18 changelog advertises links only “in tool call results” (prompts area). - Sources.
schema/2025-06-18/schema.ts:1133“export type ContentBlock =”;schema/2024-11-05/schema.ts:656“content: (TextContent | ImageContent | EmbeddedResource)[];”;schema/2025-03-26/schema.ts:699“content: (TextContent | ImageContent | AudioContent | EmbeddedResource)[];”;schema/2025-06-18/schema.ts:750“content: ContentBlock;”;schema/2025-11-25/schema.ts:1683“content: SamplingMessageContentBlock | SamplingMessageContentBlock[];”;schema/2025-11-25/schema.ts:1884“content: ContentBlock[];”;2026-07-28/server/prompts.mdx:274“#### Resource Links”
CT-08 tool_use / tool_result blocks, sampling only
- What.
ToolUseContent {id, name, input, _meta?}andToolResultContent {toolUseId, content: ContentBlock[], structuredContent?, isError?, _meta?}, allowed only inSamplingMessageContentBlock. - Where. 2025-11-25; 2026-07-28 keeps them but deprecates sampling.
- mcpx @ 05c78b2. Not examined (the sampling area covers what mcpx relays).
- Value to mcpx. − matters only for a sampling relay.
- Effort. M.
- Risk. Low.
- Detail. Neither carries
annotations; clients SHOULD preserve their_meta(it is used for caching). - Sources.
schema/2025-11-25/schema.ts:1693“export type SamplingMessageContentBlock =”;schema/2025-11-25/schema.ts:1835“type: "tool_use";”;schema/2025-11-25/schema.ts:1869“type: "tool_result";”
CT-09 annotations on content (audience, priority, lastModified)
- What.
annotations: {audience?, priority? (0..1), lastModified? (ISO 8601)}on text, image, audio, embedded resource and link blocks. - Where. 2024-11-05 through a mixin (
extends Annotated); 2025-03-26 renames it to an explicitannotations?: Annotationsfield (same wire shape, not in the changelog); 2025-06-18 addslastModified(changelog-silent). Never ontool_use/tool_result. - mcpx @ 05c78b2.
downgrade()never touches annotations, so they pass through for any revision;mcpx_calldrops them with everything else that is not text (CT-13); the link rewrite drops the link’s annotations (CT-06). - Value to mcpx. + med:
audience: ["user"]is the spec’s own way to keep a block out of model context, which is mcpx’s goal. - Effort. S.
- Risk. Low.
- Detail.
lastModifiedwould be sent unchanged to a 2025-03-26 client, which does not define it; harmless to a client that ignores unknown fields, and one more fielddowngrade()does not police. - Sources.
schema/2024-11-05/schema.ts:818“export interface Annotated {”;schema/2025-03-26/schema.ts:924“export interface Annotations {”;schema/2025-06-18/schema.ts:1127“lastModified?: string;”
CT-10 _meta on content blocks
- What.
TextContent,ImageContent,AudioContent,EmbeddedResourceandResourceLink(throughResource) gain_meta. - Where. 2025-06-18 onward.
- mcpx @ 05c78b2. Dropped by
mcpx_call(CT-13), by the link rewrite (CT-06) and by the MRTR result render (MRTR area).downgrade()leaves it in place for 2025-03-26 clients, which do not define it. - Value to mcpx. + low: extensions (MCP Apps UI pointers, for example) ride on
_meta. - Effort. S.
- Risk. Low today.
- Detail. Result-level
_metais recorded in the_metaarea; this row is the per-block field. - Sources.
schema/2025-06-18/schema.ts:1157“_meta?: { [key: string]: unknown };”
CT-11 ToolResultContent.structuredContent becomes any JSON
- What. The sampling
tool_resultblock’sstructuredContentis an object in 2025-11-25 andunknownin 2026-07-28, mirroring TOOL-27. - Where. 2025-11-25 object; 2026-07-28 any JSON. The 2026 changelog mentions only the
CallToolResultfield. - mcpx @ 05c78b2. Not examined.
- Value to mcpx. + low.
- Effort. S.
- Risk. Low.
- Detail. A sampling relay forwarding a 2026 host’s tool results to a 2025-11-25 server would hit the same non-object gap as TOOL-27.
- Sources.
schema/2025-11-25/schema.ts:1891“structuredContent?: { [key: string]: unknown };”;schema/2026-07-28/schema.ts:2469“structuredContent?: unknown;”
CT-12 What downgrade() touches, and which branches can fire
- What.
downgrade(result, version)runs on every successful result. At 2026-07-28 it returns the result untouched. Otherwise, on a JSON copy: below 2025-06-18 it movesstructuredContentinto a text block (TOOL-27) and rewritesresource_link(CT-06); below 2025-03-26 it rewritesaudio(CT-03); below 2026-07-28 it deletesresultType(LV-36). The same content rewrite applies to each prompt message’s single block. - Where. mcpx only; opencode and lootbox never rewrite for a peer.
- mcpx @ 05c78b2.
internal/mcpserver/revisions.go:132-170,206-232,236-258,260-289. mcpx emits nostructuredContentand no audio, so only the link andresultTypebranches can fire. - Value to mcpx. + med: the single edge is sound design; it is also where every other send-conservatively fix belongs.
- Effort. S.
- Risk. Med: it never looks at
text,image,resource,annotations,_meta, icons, titles, or any list result (tools/list,prompts/list, …). When mcpx emits richer results, fields a client’s revision lacks will leak; titles inprompts/listto 2025-03-26 already do (prompts area). - Detail. The fast path’s condition is “the version defines structuredContent, resource_link, audio and resultType”,
that is 2026-07-28 only; 2025-06-18 and 2025-11-25 results are still copied, although at most
resultTypechanges. - Sources.
internal/mcpserver/revisions.go:133-138;internal/mcpserver/server.go:439“resp.Result = downgrade(resp.Result, peer.Version)”
CT-13 mcpx_call renders every result to one text block
- What. The core proxy tool returns one text block.
renderResultdecodes onlycontent[].type/text,structuredContentandisError: ifstructuredContentis present it wins, pretty-printed; else text blocks are joined with newlines; else the whole raw result JSON is the text. Images, audio, embedded resources, links, annotations and upstream_metaare dropped. - Where. mcpx’s MCP surface (
/mcpand stdiomcpx serve)./v1/calland scripts get the raw result. - mcpx @ 05c78b2.
internal/cli/commands.go:385-415; called frommcpBackend.Call(internal/cli/serve.go:103-117). Wire W21: an upstreamstructuredContent {"n": 42}reached the host as the text"{\n \"n\": 42\n}". - Value to mcpx. + med: images to multimodal hosts (as blocks, or as artifacts as
mcpx_execdoes) and_metafor extensions. − text-only is cheap, which is mcpx’s goal. - Effort. M.
- Risk. High: an image-only upstream result becomes megabytes of base64 text (CT-02); a failure becomes a success (TOOL-26).
- Detail. The function’s comment says it unwraps “the same way the script client does, so CLI and script output
agree”; the script client throws on
isErrorand keeps images on.raw, so the two do not agree.downgradeMessages/downgradeContenttherefore only ever see one text block frommcpx_call. - Also recorded from the tools register.
renderResult(internal/cli/commands.go:385-415), falling back tostring(raw)(internal/cli/commands.go:414). Wire W21: upstreamstructuredContent {n: 42}arrived as the text"{\n \"n\": 42\n}"with nostructuredContent.renderResult’s comment says it unwraps “the same way the script client does”, but the script client throws onisErrorand keeps the untouched envelope on.raw(code-mode register);mcpx_calldoes neither. A JSONnullstructuredContentis rendered as the textnull. - Sources.
internal/cli/commands.go:383-384“// renderResult unwraps a CallToolResult the same way the script client does,”;internal/cli/commands.go:414“return string(raw)”;internal/cli/serve.go:116“return renderResult(res.Result), nil”; wire W21;internal/cli/commands.go:383“renderResult unwraps a CallToolResult the same way the script client does,”;internal/cli/commands.go:385-415
CT-14 Unknown content block type fails the whole call
- What. Both opencode SDKs define
ContentBlockas a closed union (text | image | audio | resource_link | resource). In v1 the result is parsed againstCallToolResultSchema, so any othertyperejects the call as a zod error, not an MCP error. - Where. opencode v1 (verified); v2’s SDK has the same union (its validation path was not traced).
- mcpx @ 05c78b2.
downgrade()gatesresource_linkandstructuredContentby revision (internal/mcpserver/revisions.go:146), andmcpx_callemits text only, so no unknown block reaches opencode today. - Value to mcpx. + med: mcpx must never pass a new or extension block type through verbatim to a host that did not negotiate it.
- Effort. S.
- Risk. High if broken: one unknown block from an upstream loses the whole result, text included.
- Detail. This is the practical reason send-conservatively applies to content: a strict client does not skip what
it cannot parse.
downgrade()’s own comment makes the same point (“a content block it cannot parse is not a degraded result, it is an unreadable one”,internal/mcpserver/revisions.go:130-131). - Sources.
v1:packages/opencode/src/mcp/catalog.ts:59“CallToolResultSchema,”; https://unpkg.com/@modelcontextprotocol/sdk@1.29.0/dist/esm/types.js (L1131–L1137, L1296); https://unpkg.com/@modelcontextprotocol/core@2.0.0/dist/auth-CUe6YdwF.mjs (L678–L684)
CT-15 Script clients unwrap tool results
- What. What a script gets back from a tool call. mcpx: throws on
isError; elsestructuredContentif present; else all-text results joined (and JSON-parsed if they look like JSON, CT-16); else the block array. opencode v1 code mode:structuredContent(unlessnull), else joined text (links as"<name>: <uri>"), else a placeholder such as[2 images attached to the result]. opencode v2:structuredContent, else text (parsed when there is nooutputSchema). CloudflarecodeMcpServer: compatibilitytoolResult, MCP errors,structuredContent, all-text, then the mixed result. lootbox: the rawCallToolResult; the script digs outcontent[0].textitself. - Where. mcpx, opencode v1/v2 and Cloudflare unwrap; lootbox does not.
- mcpx @ 05c78b2.
unwrapin the generated client (internal/codegen/emit.go:493-519). - Value to mcpx. + high: this is the good path for agents, and it is done.
- Effort. S (done).
- Risk. Low.
- Detail. mcpx does not handle the pre-2024-11-05 compatibility
toolResultfield that Cloudflare still honours (notoolResultininternal).mcpx_call(CT-13) does not share this logic although its comment says it does. - Sources.
internal/codegen/emit.go:501“if (raw && raw.structuredContent !== undefined) {”;.lootbox/src/lib/rpc/execute_mcp.ts:68-72;.lootbox/src/lib/external-mcps/parse_mcp_schemas.ts:283-293;v1:packages/opencode/src/tool/code-mode.ts:109-115;v2:packages/core/src/tool/mcp.ts:96-106; https://developers.cloudflare.com/agents/tools/codemode/api-reference/#codemcpserver “Returned MCP values are unwrapped in this order: compatibilitytoolResult, MCP errors,structuredContent, all-text content, then the original mixed-content result.”
CT-16 JSON arriving as text parsed into a value
- What. mcpx parses all-text results that look like a JSON object or array, even when the tool declares an
outputSchema. opencode v2 parses only when the tool has nooutputSchemaand the text starts with{or[. opencode v1 never parses. - Where. mcpx (always, by shape); opencode v2 (only without a schema); opencode v1 (never); Cloudflare (undocumented); lootbox (n/a, raw envelope).
- mcpx @ 05c78b2.
internal/codegen/emit.go:505-516. - Value to mcpx. + low: v2’s extra condition avoids returning a parsed object where
structuredContentwas expected; mcpx could adopt it once it generates return types fromoutputSchema. - Effort. S.
- Risk. Low: prose that happens to be
[…]becomes an array;.rawkeeps the original (CT-17). - Detail. mcpx checks the first and last characters (
{…}or[…]); v2 checks only the first and falls back to text on a parse error. - Sources.
internal/codegen/emit.go:505-516;v2:packages/core/src/tool/mcp.ts:99-104“Agents assume JSON returned as text is already an object, so parse it when the server declares no schema.”;v1:packages/opencode/src/tool/code-mode.ts:109-110
CT-17 Full envelope kept on .raw
- What. mcpx attaches the untouched
CallToolResultto every unwrapped value as a non-enumerable.rawproperty; strings are boxed so they can carry it. Image bytes, annotations and_metastay reachable there. - Where. mcpx only. opencode projects the envelope away and moves media to attachments; Cloudflare returns the mixed result only when content is not all text; lootbox returns nothing but the envelope.
- mcpx @ 05c78b2.
attachRaw(internal/codegen/emit.go:477-491). - Value to mcpx. + med: ergonomic values without losing the protocol detail.
- Effort. S (done).
- Risk. Low:
typeofa boxed string is"object", which surprises a model that teststypeof r === "string"; a frozen object cannot take the property and the error is swallowed (internal/codegen/emit.go:481). - Detail.
JSON.stringifyof a boxed string gives the string, so logging is unaffected. - Sources.
internal/codegen/emit.go:486-488
CT-18 Media produced by a tool inside a script
- What. When a call inside an opencode script returns image, audio or blob content, opencode attaches it to the
executeresult as data-URL files, so the model sees the image. mcpx leaves the bytes to the script and, when the caller declares theartifactscapability, replaces media at the output boundary with aresource_linkto a stored artifact;artifact()stores one explicitly. - Where. opencode v1/v2 (attach); mcpx (store and link); lootbox (base64 in the envelope, seen only if printed); Cloudflare (mixed result object, no attachment channel documented).
- mcpx @ 05c78b2.
docs/exec.md:259-275;interceptMedia(internal/execsvc/execsvc.go:543-545);artifact()(internal/codegen/emit.go:1275). - Value to mcpx. − opencode’s approach is the context cost mcpx avoids (“one screenshot costs more than the rest of
the task”). + where a vision model must see the image, opencode’s channel is the only one that renders it; mcpx needs
a
resources/read. - Effort. S.
- Risk. Med: the plugin’s
mcpx_execnever declaresartifacts, so its images are not intercepted and must be printed as base64 to be seen (inferred fromplugin/opencode/mcpx/daemon.ts:452-455). - Detail. Artifacts are served by a random 128-bit id, not the content hash, because a hash is guessable
(
docs/exec.md:200-207). - Sources.
v1:packages/opencode/src/tool/code-mode.ts:89-100;v2:packages/core/src/codemode/tool.ts:139-147;docs/exec.md:259-275
CT-19 isError: true becomes an exception in scripts
- What. mcpx, opencode and Cloudflare throw when a tool result carries
isError: true. lootbox returns it as a successful value, so the script must check.isErroritself and usually does not. - Where. mcpx (
ToolErrorwith server, tool and.raw); opencode v1 (throws, surfaced as aToolFailurediagnostic); opencode v2 (ToolFailure); Cloudflare (“MCP error results become thrown connector errors”); lootbox passes it through. - mcpx @ 05c78b2.
internal/codegen/emit.go:494-499. - Value to mcpx. + high: done, and the reason scripts do not share TOOL-26’s bug.
- Effort. S (done).
- Risk. Low for mcpx; a lootbox script can report success after every call failed.
- Detail. Every thrower builds the message from the text blocks joined with newlines, with a default when there are
none: mcpx “
<server>.<tool> returned isError”, opencode “MCP tool returned an error”. - Sources.
internal/codegen/emit.go:494“if (raw && raw.isError) {”;.lootbox/src/lib/rpc/execute_mcp.ts:60-72;v2:packages/core/src/tool/mcp.ts:78-85; https://developers.cloudflare.com/agents/tools/codemode/api-reference/#mcpconnector “MCP error results become thrown connector errors.”
Elicitation
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-10-30T19:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: elicitation/create per revision (form, url, 2026 via MRTR), its schema and capability rules, and how mcpx, opencode and lootbox carry it
Elicitation is how a server asks the user something through the client: a flat form (2025-06-18), a URL to open
(2025-11-25), and from 2026-07-28 a question embedded in an input_required result rather than a request of its own. It
is the one client feature 2026 does not deprecate, and for mcpx it is central: every upstream question lands in mcpx’s
broker and is either relayed to the host that made the call or answered by another audience. What matters most is that
the relay works for legacy hosts and for 2026 hosts that keep an Mcp-Session-Id, but a correct stateless 2026 host gets
-32603 from any eliciting upstream (ELI-17), and that mcpx never tells upstreams it can take URL mode although its
broker does. The 2026 mechanism itself (InputRequiredResult, requestState, retries, and mcpx as an MRTR client of a
modern upstream) is registered in mrtr.md.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| ELI-01 | elicitation/create and the elicitation client capability | 2025-06-18 has opencode v2 has | 24-11 — · 25-03 — · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (MRTR); opencode v2 ✓, v1 ✗, lootbox ✗ | ✓ — both directions through the broker | + high | S | low |
| ELI-02 | requestedSchema limited to flat primitive properties | 2025-06-18 has | 25-06 ✓ · 25-11 ✓ (+ arrays for multi-select) · 26-07 ✓ | ✓ — forwarded verbatim; never validated | + low | S | low |
| ELI-03 | Three-way action accept / decline / cancel | 2025-06-18 has | 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✓ — cancel when nobody was asked | + low | S | low |
| ELI-04 | Enum schemas: enumNames → titled/untitled single and multi-select | 2025-11-25 has | 25-06 enum+enumNames · 25-11 rich · 26-07 rich | ✓ — titled single-select downgraded to enum+enumNames for pre-2025-11-25 hosts in paramsFor, non-boolean default stripped; multi-select refused whole for old hosts (no equivalent; stays with the broker) (#2078) | + low | M | low |
| ELI-05 | default on every primitive schema | 2025-11-25 has opencode v2 has | 25-06 boolean only · 25-11 all · 26-07 all; opencode v2 applies defaults | ✓ — forwarded unchanged | + low | S | low |
| ELI-06 | ElicitResult.content values may be string[] | 2025-11-25 has | 25-06 scalars · 25-11 + string[] · 26-07 + string[] | ✓ — answers relayed as raw JSON | + low | S | low |
| ELI-07 | requestedSchema.$schema allowed | 2025-11-25 has | 25-11 ✓ · 26-07 ✓ | ✓ — forwarded verbatim | − moot | S | low |
| ELI-08 | mode field; omitted means form | 2025-11-25 has | 25-11 ✓ · 26-07 ✓ | ✓ — stripped for pre-2025-11-25 hosts | + low | S | low |
| ELI-09 | URL-mode elicitation | 2025-11-25 has opencode v2 has | 25-11 ✓ · 26-07 ✓; opencode v2 renders a link field | ✓ — relayed only to url-declared hosts; as a client mcpx declares url only with a handler installed (mcpclient/modern.go), the pool installs it, and -32042-named URLs open through the broker (#2079) | + med | S | med |
| ELI-10 | elicitationId added, then removed | 2025-11-25 has 2026-07-28 removes mcpx missing | 25-11 only | partial — stripped for old hosts, leaked to 2026 hosts (#201) | + low | S | low |
| ELI-11 | notifications/elicitation/complete added, then removed | 2025-11-25 has 2026-07-28 removes opencode v2 has | 25-11 only; opencode v2 handles it | partial — consumed from upstreams, never sent to hosts | + low | S | low |
| ELI-12 | -32042 URLElicitationRequiredError added, then removed | 2025-11-25 has 2026-07-28 removes has better replacement | 25-11 only; 26-07 reserves the code | ✓ — handled from upstreams as a client (broker URL flow, one retry, #2079) and emitted as a server to 2025-11-25 url-declared hosts when a pending URL question is undeliverable, listing exactly those elicitations; never emitted at 2026 (#2080) | + med | M | med |
| ELI-13 | Sensitive data: never → only via URL mode | 2025-11-25 has | 25-06 never · 25-11 URL only · 26-07 URL only | n/a — relays; broker answers persist to SQLite | + low | S | med |
| ELI-14 | 2026 ElicitRequest embedded in InputRequiredResult, not a request | 2026-07-28 has | 26-07 only | ✓ — produced and answered | + med | S | low |
| ELI-15 | Legacy delivery over Streamable HTTP: request on the POST’s SSE | 2025-06-18 has 2026-07-28 removes | 25-03..25-11 allowed · 26-07 forbidden | ✓ — works with a session (wire L1–L3) | + high | S | low |
| ELI-16 | Legacy delivery over stdio: request on the pipe | 2025-06-18 has | 25-06 ✓ · 25-11 ✓ | ✓ — interruptible calls run concurrently; not wire-driven | + high | S | low |
| ELI-17 | Stateless 2026 host plus eliciting upstream | 2026-07-28 has mcpx missing | 26-07 only | ✗ — -32603 after 8 busy rounds (wire M1) (#201) | + high | M | high |
| ELI-18 | Naming the originating server in a relayed question | mcpx missing | spec: client MUST show who asks; mcpx: legacy path only | partial — legacy prefixed “(via mcpx)”; 2026 path raw (#201) | + med | S | med |
| ELI-19 | Question from a shared upstream instance | impl deferred | mcpx only | partial — goes to the broker, never inline | + low | L | low |
| ELI-20 | Questions raised inside mcpx_exec | impl deferred opencode v2 has mcpx missing | mcpx only | partial — always the broker, never the host (#77) | + low | L | low |
| ELI-21 | Answering an elicitation from inside a script | opencode v2 has mcpx missing | opencode v2 UI form; mcpx designed, not built | ✗ — no onElicit; broker and policy only (#214) | + med | M | low |
| ELI-22 | Client with no elicitation answerer | opencode v1 has lootbox has | opencode v1 and lootbox: -32601; mcpx: cancel | ✓ — declares, answers cancel when nobody is asked | + low | S | low |
| ELI-23 | Elicitation attributed to a session or a whole location | opencode v2 has | opencode v2: Location-global forms | n/a — mcpx is the server here | + low | S | low |
ELI-01 elicitation/create and the elicitation client capability
- What. A server asks the user, through the client, for structured data:
messageplusrequestedSchema. A client that can answer declareselicitation(an opaque object in 2025-06-18). - Where. 2025-06-18 onward; absent in 2024-11-05 and 2025-03-26. In 2026-07-28 it survives as an embedded input request (ELI-14). opencode v2 declares it and answers; opencode v1 comments it out; lootbox declares no capabilities.
- mcpx @ 05c78b2. Both directions, through the broker. Downstream: sent only to hosts at ≥2025-06-18 that declared it
(
internal/mcpserver/conn.go:192). Upstream: always declareselicitation: {}(internal/mcpclient/modern.go:51) and answerscancelwhen nothing is installed to ask (internal/mcpclient/modern.go:129-131), which keeps the always-on declaration honest. - Value to mcpx. + high: this is the feature that lets an upstream stop and ask the user, and mcpx exists partly to route those questions.
- Effort. S — done.
- Risk. None as is.
- Detail. 2025-06-18
ElicitRequestparams are one inline object; 2025-11-25 splits them intoElicitRequestFormParams | ElicitRequestURLParams. Upstream questions become broker rows (elc-…ids), answered by a host inline (ELI-15, ELI-16, the 2026 path in mrtr.md), by the CLI, TUI or plugin, or by policy. - Sources.
schema/2025-06-18/schema.ts:1459“elicitation/create”;schema/2025-06-18/schema.ts:250“elicitation?: object;”;2025-06-18/changelog.mdx:24“Add support for”;internal/mcpclient/modern.go:51;internal/mcpserver/conn.go:192;v2:packages/core/src/mcp/client.ts:143;v1:packages/opencode/src/mcp/index.ts:44“// elicitation: {},”;.lootbox/src/lib/external-mcps/mcp_client_manager.ts:150-153.
ELI-02 requestedSchema limited to flat primitive properties
- What.
type: "object"withpropertiesof string, number, boolean or enum schemas and an optionalrequiredlist. No nesting and no arrays of objects, so any client can render it as a form. - Where. 2025-06-18 onward. 2025-11-25 adds array-typed multi-select enums (ELI-04).
- mcpx @ 05c78b2. Forwards the schema verbatim in both eras: the modern path copies the upstream’s params
(
internal/mcpserver/ask.go:259); the legacy path rewrites onlymessage,modeandelicitationId(internal/mcpserver/conn.go:382-407). mcpx does not check that an upstream kept to the subset. - Value to mcpx. + low: the restriction is what makes a CLI or TUI prompt feasible.
- Effort. S.
- Risk. low: an upstream sending a nested schema reaches the host unchanged, and the host decides.
- Detail.
StringSchema.formatis one ofemail,uri,date,date-time. opencode v2 maps each property to a typed form field and turns arrays into multi-select, replacing machine titles such as “string with format email” with the description or key. - Sources.
schema/2025-06-18/schema.ts:1485“export type PrimitiveSchemaDefinition =”;2025-06-18/client/elicitation.mdx:279“Note that complex nested structures, arrays of objects”;internal/mcpserver/ask.go:259;v2:packages/core/src/mcp/index.ts:224.
ELI-03 Three-way action accept / decline / cancel
- What.
accept(withcontentin form mode),decline(an explicit no),cancel(dismissed without answering). - Where. 2025-06-18 onward, unchanged.
- mcpx @ 05c78b2. Keeps the distinction: with no answerer installed the client replies
cancel, “Cancel, not decline. Nobody was asked, so nobody said no.” (internal/mcpclient/modern.go:129-131). - Value to mcpx. + low: correct already.
- Effort. S.
- Risk. None.
- Detail. In URL mode
acceptmeans the user consented to open the URL, not that the out-of-band flow finished (ELI-14). Sampling has no such three-way answer: mcpx turns a declined sampling question into an error (see sampling.md). - Sources.
schema/2025-06-18/schema.ts:1550“action: "accept" | "decline" | "cancel";”;internal/mcpclient/modern.go:130“Cancel, not decline.”.
ELI-04 Enum schemas: enumNames → titled/untitled single and multi-select
- What. 2025-06-18 has
EnumSchema{enum, enumNames?}. 2025-11-25 (SEP-1330) replaces it withUntitledSingleSelect{enum},TitledSingleSelect{oneOf[{const,title}]},UntitledMultiSelect{type:"array", items.enum},TitledMultiSelect{items.anyOf}, plus aLegacyTitledEnumSchema(the oldenumNames). - Where. Simple enums in 2025-06-18; rich enums in 2025-11-25 and 2026-07-28.
- mcpx (current, #2078).
paramsFor(internal/mcpserver/conn.go) downgrades a titled single-select property toenum+enumNamesfor a pre-2025-11-25 host and stripsdefaultfrom every non-boolean property (the 2025-06-18 schema allows it only on booleans, ELI-05). A multi-select question has no 2025-06-18 equivalent and is refused whole for an old host (Question.Sendable): it stays with the broker rather than being flattened into a different answer type. - Value to mcpx. + low: downgrade titled single-select to
enum+enumNamesfor 2025-06-18 hosts; multi-select has no 2025-06-18 equivalent and would have to be refused or flattened. - Effort. M — a schema rewrite per property, plus mapping multi-select answers back.
- Risk. low: a strict 2025-06-18 host rejects the whole request, the same risk the conn.go comment names for
mode. - Detail. Multi-select answers are
string[](ELI-06).LegacyTitledEnumSchema“will be removed in a future version” but is not in the 2026 deprecated-features registry. - Sources.
schema/2025-06-18/schema.ts:1535“enumNames?: string[]; // Display names for enum values”;schema/2025-11-25/schema.ts:2463“export type EnumSchema =”;2025-11-25/changelog.mdx:16“Update”;schema/2026-07-28/schema.ts:3096;internal/mcpserver/conn.go:398-404.
ELI-05 default on every primitive schema
- What. 2025-06-18 allows
defaultonly onBooleanSchema. 2025-11-25 (SEP-1034) adds it to string, number and enum schemas; clients that support defaults SHOULD prefill. - Where. 2025-11-25 and 2026-07-28. opencode v2 declares
form: { applyDefaults: true }, so its SDK fills schema defaults into accepted content. - mcpx @ 05c78b2. Forwarded unchanged to hosts of every revision (as ELI-04). Whether mcpx’s own CLI prompt prefills was not examined.
- Value to mcpx. + low: prefill in
mcpx elicitprompts. - Effort. S.
- Risk. low: an extra field to a strict 2025-06-18 host.
- Detail.
applyDefaultsis an SDK option carried inside theformcapability object, which the spec types as an open object. - Sources.
schema/2025-06-18/schema.ts:1524“default?: boolean;”;schema/2025-11-25/schema.ts:2247“default?: string;”;2025-11-25/client/elicitation.mdx:233“All primitive types support optional default values”;v2:packages/core/src/mcp/client.ts:143.
ELI-06 ElicitResult.content values may be string[]
- What. Accepted content values are
string | number | booleanin 2025-06-18, and may also bestring[]from 2025-11-25, for multi-select enums. - Where. 2025-11-25 and 2026-07-28.
- mcpx @ 05c78b2. On the server side answers stay raw JSON: a modern host’s
inputResponsesare read asmap[string]json.RawMessage(internal/mcpserver/ask.go:79-89) and a legacy host’s reply is taken as raw (internal/mcpserver/ask.go:214). How the broker and CLI decode array answers was not examined. - Value to mcpx. + low: needed only once multi-select is rendered.
- Effort. S.
- Risk. low: a decoder typed to the 2025-06-18 shape rejects arrays.
- Detail. Downgrading to a 2025-06-18 host would also have to map an array answer back (ELI-04).
- Sources.
schema/2025-06-18/schema.ts:1556“content?: { [key: string]: string | number | boolean };”;schema/2025-11-25/schema.ts:2485“content?: { [key: string]: string | number | boolean | string[] };”;internal/mcpserver/ask.go:82.
ELI-07 requestedSchema.$schema allowed
- What. An optional
$schemaon the form schema (JSON Schema 2020-12 is the default dialect). - Where. 2025-11-25 and 2026-07-28.
- mcpx @ 05c78b2. Forwarded verbatim with the rest of the schema (ELI-02).
- Value to mcpx. − moot.
- Effort. S.
- Risk. None.
- Detail. Not mentioned in the 2025-11-25 changelog.
- Sources.
schema/2025-11-25/schema.ts:2171“$schema?: string;”.
ELI-08 mode field; omitted means form
- What.
mode?: "form"ormode: "url". Servers MAY omit it for form mode, and clients MUST treat an absentmodeas form. - Where. 2025-11-25 and 2026-07-28.
- mcpx @ 05c78b2. Stripped for hosts below 2025-11-25 (
internal/mcpserver/conn.go:398-402); a URL question never reaches such a host becauseSendablerefuses it. - Value to mcpx. + low: done.
- Effort. S.
- Risk. None.
- Detail. The same strip removes
elicitationId(ELI-10). - Sources.
schema/2025-11-25/schema.ts:2159“mode?: "form";”;2025-11-25/client/elicitation.mdx:97“For backwards compatibility, servers”;internal/mcpserver/conn.go:398-402.
ELI-09 URL-mode elicitation
- What.
mode: "url",message,url: the user completes something out of band (credentials, OAuth, payment) and nothing but the URL passes through the client. - Where. 2025-11-25 and 2026-07-28. opencode v2 renders an external-link field and settles the form on the answer (or, in legacy, on the completion notification).
- mcpx (current, #2079). Relays URL questions only to hosts that declared
url(CAP-08). As a client it now declaresurlonly with a handler installed (internal/mcpclient/modern.gocapabilities; the pool installs the handler), and an upstream-32042is opened through the broker’s existing elicitation/create path (internal/daemon/hooks.go,urlElicitationsViaBroker) before the call is retried once. The earlier state – accepting URL questions it never declared – is gone. - Value to mcpx. + med: declaring
urlwould let OAuth-style servers use it legitimately rather than by accident. - Effort. S for the declaration; the per-host gating is CAP-08.
- Risk. med: two upstreams choosing the same
elicitationIdcollide in the broker, because the row id is theirs. - Detail. Clients MUST NOT prefetch the URL, MUST show it in full and MUST get consent before opening it.
docs/protocol.mdalso lists as a known gap that mcpx never starts a URL elicitation of its own (for example for upstream OAuth). The 2026 URL params are onlymode,message,url. - Sources.
schema/2025-11-25/schema.ts:2189“mode: "url";”;2025-11-25/changelog.mdx:17“Added support for”;internal/daemon/hooks.go:138“ID: p.ElicitationID,”;internal/mcpclient/modern.go:51;docs/protocol.md:376“url-mode elicitation raised by mcpx itself.”;schema/2026-07-28/schema.ts:2821-2838;v2:packages/core/src/mcp/index.ts:226.
ELI-10 elicitationId added, then removed
- What. A required opaque id on 2025-11-25 URL requests, used to correlate the completion notification and the
-32042error. 2026 deletes it; a server encodes correlation inrequestStateinstead. - Where. 2025-11-25 only.
- mcpx @ 05c78b2. Stripped for hosts below 2025-11-25 (
internal/mcpserver/conn.go:398-404). The 2026 path copies the upstream’s params verbatim (internal/mcpserver/ask.go:259), so a 2025-11-25 upstream’selicitationIdreaches a 2026 host whose schema has no such field. As a client, mcpx uses it as the broker row id (ELI-09). - Value to mcpx. + low: extend the strip to 2026 hosts.
- Effort. S.
- Risk. low: an undefined field sent to 2026 hosts (a send-conservatively conflict).
- Detail. opencode v2’s code notes the same fact: “Legacy only: 2026-07-28 has no elicitationId and no completion notification”.
- Sources.
schema/2025-11-25/schema.ts:2200“elicitationId: string;”;2026-07-28/changelog.mdx:53“Remove the”;internal/mcpserver/ask.go:259;internal/mcpserver/conn.go:398-404;v2:packages/core/src/mcp/index.ts:226“Legacy only: 2026-07-28 has no elicitationId”.
ELI-11 notifications/elicitation/complete added, then removed
- What. An optional server-to-client notice that an out-of-band URL interaction finished. Removed in 2026 because under MRTR the client learns the outcome by retrying.
- Where. 2025-11-25 only. opencode v2 handles it (legacy URL mode); v1 does not.
- mcpx @ 05c78b2. As a client it consumes it from upstreams (
internal/mcpclient/client.go:501). As a server it has an emitter (internal/events/events.go:262-268) that nothing reaches: the listen filter has no elicitation kind (internal/cli/serve.go:727-739), so no host ever gets one andFeatElicitationCompletein the feature table is never exercised. - Value to mcpx. + low: a 2025-11-25 host that opened a URL through mcpx would learn completion early.
- Effort. S — but it would have to be gated to 2025-11-25 hosts and to the one host that got the URL request.
- Risk. low: MAY-level; hosts learn the outcome from the call’s result anyway.
- Detail. The notification MUST go only to the client that received the URL request and MUST carry its
elicitationId. The emitter maps anyElicitCompletedevent, with no revision gate at that point; the feature table gives it a 2026 ceiling (internal/mcpserver/revisions.go:103). - Sources.
schema/2025-11-25/schema.ts:2494“notifications/elicitation/complete”;2025-11-25/client/elicitation.mdx:398“Servers MAY send a”;internal/mcpclient/client.go:501;internal/events/events.go:262-268;v2:packages/core/src/mcp/client.ts:160.
ELI-12 -32042 URLElicitationRequiredError added, then removed
- What. 2025-11-25: a tool may fail with
-32042listing URL elicitations (data.elicitations) that must complete before a retry. 2026 removes the type;-32042is reserved “2025-11-25 only” and MUST NOT be emitted. AnInputRequiredResultcarrying a URLelicitation/createreplaces it. - Where. 2025-11-25 only.
- mcpx (current, #2079 + #2080). As a client,
-32042is intercepted inCallToolat 2025-11-25 only (internal/mcpclient/client.go): the named elicitations open through the broker and the call retries exactly once. As a server,viaAsk(internal/mcpserver/url_required.go) now produces-32042for a 2025-11-25 host that declaredelicitation.urlwhen a URL question of its call is pending and undeliverable (no channel at entry, oraskClientreturnsErrNoPush), listing exactly the pending URL questions viaparamsFor; the broker keeps the questions. An upstream-32042reaching the server side unhandled is relayed only to url-declared 2025-11-25 hosts, else the call fails generically. Never emitted at 2026 (the conformance gapelicitation-server-must-return-32042is retired by #2080). - Value to mcpx. + med: as a client of 2025-11-25 upstreams, turn
-32042into URL questions for the host (legacyelicitation/create, orinput_requiredfor a 2026 host) and retry the call. - Effort. M.
- Risk. med: tools that rely on
-32042fail through mcpx. - Detail. The constant is
@internaland sits in the implementation-defined range. The removal is changelog-silent: the 2026 changelog mentionselicitationIdand the notification but not this error; onlybasic/index.mdxand a schema comment record it. The 2025-11-25 changelog never named the addition either. - Also recorded from the errors register. Absent from
internal/mcpserverandinternal/mcpclient. As a client of 2025-11-25 upstreams, a tool that answers-32042fails through mcpx instead of becoming a URL elicitation and a retry; as a server, an upstream-32042would need translating for a 2026 host. The 2025-11-25 constant is marked@internaland commented as belonging to the[-32000, -32099]implementation-defined range; neither its addition nor its removal is in a changelog. Under the 2026 policy it lies in the specification’s sub-range, reserved as a historical code. - Sources.
schema/2025-11-25/schema.ts:181“export const URL_ELICITATION_REQUIRED = -32042;”;2025-11-25/client/elicitation.mdx:426“the server MAY return a”;2026-07-28/basic/index.mdx:144“URL elicitation required (2025-11-25 only).”.
ELI-13 Sensitive data: never → only via URL mode
- What. 2025-06-18: servers MUST NOT request sensitive information by elicitation. 2025-11-25 narrows it to form mode and requires URL mode for sensitive interactions. 2026 keeps the 2025-11-25 rule.
- Where. As stated.
- mcpx @ 05c78b2. Relays whatever the upstream asks. Broker rows persist in SQLite (
state/logs/elicit.db,internal/daemon/hooks.go:235-241), so a secret collected by a misbehaving upstream in form mode is stored. - Value to mcpx. + low: mcpx’s own prompts must never collect secrets by form.
- Effort. S.
- Risk. med: secrets in the broker database and the event bus if an upstream breaks the rule.
- Detail. The rule binds the asking server, not the relay; a relay can only limit what it keeps.
- Sources.
2025-06-18/client/elicitation.mdx:32“Servers MUST NOT use elicitation to request sensitive information.”;2025-11-25/client/elicitation.mdx:30“Servers MUST NOT use form mode elicitation to request sensitive information such as”;internal/daemon/hooks.go:235-241.
ELI-14 2026 ElicitRequest embedded in InputRequiredResult, not a request
- What. In 2026
ElicitRequestis a bare{method, params}(nojsonrpc, noid) insideinputRequests; its params have no_metaortask;ElicitResultno longer extendsResult. A URLacceptmeans consent; the outcome is learned on the retry. - Where. 2026-07-28. Elicitation is not deprecated there, unlike Roots, Sampling and Logging (SEP-2577 names only those three).
- mcpx @ 05c78b2. Produces it for 2026 hosts (
internal/mcpserver/ask.go:256-266) and answers it from modern upstreams with the sameanswer()used for legacy requests (internal/mcpclient/modern.go:103-135). - Value to mcpx. + med: done.
- Effort. S.
- Risk. None beyond the mechanism’s own gaps (mrtr.md).
- Detail. A 2025-11-25
ElicitRequestFormParamsextendedTaskAugmentedRequestParams, so an elicitation could run as a client-hosted task; that path is gone (see tasks.md). - Sources.
schema/2026-07-28/schema.ts:2856“export interface ElicitRequest {”;schema/2026-07-28/schema.ts:3134“export interface ElicitResult {”;2026-07-28/client/elicitation.mdx:376“The response with”;2026-07-28/changelog.mdx:73;internal/mcpclient/modern.go:103-135.
ELI-15 Legacy delivery over Streamable HTTP: request on the POST’s SSE
- What. A legacy server may send its own requests on the SSE stream of a POST before the response. 2026 forbids independent requests on any stream.
- Where. 2025-03-26..2025-11-25 transport rule; elicitation itself from 2025-06-18; forbidden in 2026-07-28.
- mcpx @ 05c78b2. When an upstream elicits during
mcpx_call,prompts/getorresources/read, a ≥2025-06-18 host that declared elicitation and holds a session getselicitation/create(negative id,-1) as an SSE frame on its POST; it answers with a separate POST (202), and the result follows on the same stream (internal/mcpserver/ask.go:212-229;internal/mcpserver/conn.go:228-274). Wire L1–L3 shows exactly this, with the message prefixed “ask (via mcpx) asks:”. - Value to mcpx. + high: inline answers from the host that made the call.
- Effort. S — done.
- Risk. low: it needs an
Mcp-Session-Id, because the answer POST finds the waiting request by session (internal/mcpserver/server.go:1142-1151). - Detail. Only the three methods above are interruptible (
internal/mcpserver/ask.go:318-325). A host’s call is held up toproto.askTimeout(10 m). When a relayed request times out, mcpx sends the hostnotifications/cancelledfor it (see progress-cancellation.md). The transports register owns the SSE rules themselves. - Sources.
2025-03-26/basic/transports.mdx:111“The server MAY send JSON-RPC requests and notifications before sending a”;2026-07-28/basic/transports/streamable-http.mdx:117“The server MUST NOT send independent JSON-RPC requests on this stream.”;internal/mcpserver/conn.go:395;internal/mcpserver/server.go:1142-1151; wire L1–L3.
ELI-16 Legacy delivery over stdio: request on the pipe
- What. On stdio the server writes
elicitation/createto stdout and reads the client’s reply from stdin while the original call is still open. - Where. 2025-06-18 and 2025-11-25 (2026 forbids server-written requests on stdio).
- mcpx @ 05c78b2. An interruptible request runs on its own goroutine so the answer can arrive on the same read loop;
replies are recognised by shape (an id and no method) and routed to the waiting request
(
internal/mcpserver/server.go:1032-1039,:1055-1062). - Value to mcpx. + high: works for every spawning host that declares elicitation.
- Effort. S — done.
- Risk. low: verified by reading code only; the wire study drove HTTP elicitation, not a stdio host answering.
- Detail. Everything else on stdio is handled in line, so a long non-interruptible call still blocks the pipe (a transports-register item).
- Sources.
internal/mcpserver/server.go:1036“if id, result, rerr, ok := replyOf([]byte(line)); ok {”;internal/mcpserver/server.go:1055-1062.
ELI-17 Stateless 2026 host plus eliciting upstream
- What. A 2026 host that declares elicitation and calls, over HTTP without an
Mcp-Session-Id(2026 removed it), an upstream tool that elicits. The host should receive aninput_requiredresult; from mcpx it gets-32603. - Where. 2026-07-28, server side.
- mcpx @ 05c78b2.
mintrefuses an empty connection binding (internal/mcpserver/state.go:64), and an HTTP request without a session id gets an anonymous connection with an empty id (internal/mcpserver/server.go:1209-1218). InviaAskthe round counter is incremented before the mint and the failed mintcontinues without waiting, so rounds 1–8 spin at once and the ninth exceedsproto.askRounds; the call is abandoned (internal/mcpserver/ask.go:195-209). Wire M1:-32603 "tools/call was still asking for input after 8 rounds"after about a second. With a session id taken fromserver/discoverthe same call works (wire M2–M3). - Value to mcpx. + high: this is the whole 2026 server-side elicitation story for correct clients.
- Effort. M — bind
requestStateto something the request carries (or to nothing but the HMAC, plus a method and params digest; see mrtr.md), and make a failed mint fall back to the broker as intended. - Risk. high: every stateless 2026 host that declares elicitation gets an internal error from any eliciting upstream. A 2026 host that declares nothing takes the broker path and works.
- Detail. Both the code comment and the doc say the intended fallback is the broker (“the question goes back to the
broker”; “its questions stay with the broker”); the round counter defeats it. The binding choice itself is registered in
mrtr.md; that mcpx mints a session id on
server/discoverat all is a lifecycle-register item. - Sources.
internal/mcpserver/state.go:64;internal/mcpserver/ask.go:195-209;internal/mcpserver/ask.go:204“No verifiable state means no safe resume”;docs/protocol.md:225-228“is issued no token at all, and its questions stay with the broker”;internal/mcpserver/server.go:1209-1218;2026-07-28/changelog.mdx:12“Remove protocol-level sessions”; wire M1, M2–M3.
ELI-18 Naming the originating server in a relayed question
- What. Clients MUST make clear which server is asking. Behind a proxy the client sees only the proxy, so the proxy has to put the originator into the question.
- Where. Client rule from the elicitation pages; mcpx applies it on the legacy path only.
- mcpx @ 05c78b2. Legacy:
paramsForprefixes"<server> (via mcpx) asks: "unless the message already names the server (internal/mcpserver/conn.go:390-396; wire L2). 2026:inputRequiredcopies the upstream’s params verbatim, with no prefix (internal/mcpserver/ask.go:259; wire M2 shows the bare “Which repository should this go in?”). - Value to mcpx. + med: a 2026 host cannot tell which upstream is asking, which matters most for confirmations and credentials.
- Effort. S — run the same rewrite on the modern path (and drop
elicitationIdthere, ELI-10). - Risk. med: users answering questions without knowing who asked.
- Detail. The
inputRequestskeys are broker ids (elc-…), which is fine; only the params differ between the paths. - Sources.
2025-11-25/client/elicitation.mdx:42“Provide UI that makes it clear which server is requesting information”;internal/mcpserver/conn.go:395;internal/mcpserver/ask.go:259“requests[q.ID] = map[string]any{”; wire L2, M2.
ELI-19 Question from a shared upstream instance
- What. A question arrives on an upstream connection, not on a call. When two calls share one pooled instance
(
sharing: shared), mcpx cannot tell which call asked. - Where. mcpx design; the spec is silent.
- mcpx @ 05c78b2.
forKeyattributes a question only when exactly one call is running on that (server, scope key) (internal/daemon/routes_proto.go:151-160); otherwise the question goes to the broker’s default audience. The doc says so and why (docs/protocol.md:241-244). - Value to mcpx. + low: safety over convenience; one host’s credential prompt never reaches another host.
- Effort. L — needs request-level correlation from the upstream.
- Risk. low: hosts using shared servers never get inline questions.
- Detail. 2025-11-25’s
io.modelcontextprotocol/related-task_metawould correlate exactly for task-augmented upstream calls, but mcpx never task-augments upstream calls (see tasks.md) and 2026 drops the key. - Sources.
docs/protocol.md:241-244“mcpx makes none, and the question goes to the broker”;internal/daemon/routes_proto.go:156.
ELI-20 Questions raised inside mcpx_exec
- What. Upstream calls made by a script never interrupt the MCP host that ran the script; their questions go to the broker.
- Where. mcpx design.
- mcpx @ 05c78b2.
daemonAsker.Begintreats onlymcpx_callas interruptible (internal/cli/serve_ask.go:44-58); the gap is written down (docs/protocol.md:379-382): the correlation identifies a call, and a script is many calls. - Value to mcpx. + low: scripts that elicit could run under the host’s UI.
- Effort. L.
- Risk. low: nothing new breaks.
- Detail. The script-side answer to the same problem is ELI-21.
- Also recorded from the code mode register. opencode v2 (UI form, attributed to a
"global"sentinel session because the server “cannot attribute them to a persisted session row”). Cloudflare (agent-level handlers). mcpx (broker; no script hook). opencode v1 (capability commented out; answers-32601) and lootbox (capabilities: {}; elicitation register, ELI-22) refuse. Broker routesGET /v1/elicit,GET /v1/elicit/{id},POST /v1/elicit/{id}/{action}(internal/daemon/server.go:422-424).daemonAsker.Begintreats onlymcpx_callas interruptible (internal/cli/serve_ask.go:44-58). The script-sideonElicit(q => …)is designed in a proposal (docs/elicitation.md:7statusproposed;docs/elicitation.md:308-330) and not built (rg finds noonElicitininternal/codegen). mcpx defers the inline case on purpose: “A script makes many upstream calls and the question belongs to one of them; the correlation in §3.4 identifies a call, and a script is not one” (docs/protocol.md:379-382). Protocol details live in the elicitation register (ELI-20, ELI-21). - Sources.
docs/protocol.md:379“A modern client cannot answer a question raised by”;internal/cli/serve_ask.go:57“p.Name != "mcpx_call"”.;docs/elicitation.md:321onElicit(async (q) => {;v2:packages/core/src/mcp/client.ts:143;v2:packages/core/src/mcp/index.ts:80-82; https://developers.cloudflare.com/agents/model-context-protocol/apis/client-api/#elicitation (“On the stateless path, elicitation returnsinput_requiredand completes through multi-round-trip requests (MRTR).”)
ELI-21 Answering an elicitation from inside a script
- What. When a tool call made by a script asks a question, the script itself could answer it (it often knows the repository or the choice). opencode v2 shows a form in its UI instead; Cloudflare’s agent runs registered handlers.
- Where. opencode v2 (UI form, form and URL modes). mcpx: daemon broker only. opencode v1 and lootbox declare no elicitation capability.
- mcpx @ 05c78b2. The broker has
GET /v1/elicit,GET /v1/elicit/{id}andPOST /v1/elicit/{id}/{action}(internal/daemon/server.go:422-424), but the generated script client has noonElicit; rg finds none ininternal/codegen.docs/elicitation.md:308-330describesonElicitas if a script could register it today. - Value to mcpx. + med: a script that expects a question could answer from its own data instead of failing or waiting for a human.
- Effort. M — a long-poll or SSE from the generated client to
/v1/elicit?run=<id>, plus handler registration. - Risk. low: without it scripts depend on policy answers or a human at the CLI or TUI.
- Detail. The doc is ahead of the code, so the gap is a doc/code disagreement as well as a missing feature.
- Sources.
docs/elicitation.md:318-324“onElicit(async (q) => {”;internal/daemon/server.go:422-424;v2:packages/core/src/mcp/client.ts:143.
ELI-22 Client with no elicitation answerer
- What. What a client replies when it receives
elicitation/createand has nothing to answer with. - Where. opencode v1 registers only
roots/list, so its SDK answers every other server request with-32601. lootbox declarescapabilities: {}and registers no handler, so its SDK does the same. mcpx declares elicitation and repliescancel. - mcpx @ 05c78b2. As a server it never sends
elicitation/createto a host that did not declare it (internal/mcpserver/conn.go:192), so opencode v1 never sees one. As a client it answerscancelrather than an error (internal/mcpclient/modern.go:129-131). - Value to mcpx. + low: confirms that capability gating is enough.
- Effort. S.
- Risk. low.
docs/opencode-plugin.md:184says v1 “will getcancel”; on the wire v1 would answer-32601, not anElicitResult(moot, because mcpx never sends it). - Detail. The 1.x SDK also refuses to register an elicitation handler without the capability.
- Sources.
v1:packages/opencode/src/mcp/index.ts:77;v1:packages/opencode/src/mcp/index.ts:44“// elicitation: {},”;.lootbox/src/lib/external-mcps/mcp_client_manager.ts:150-153;internal/mcpclient/modern.go:129-131;docs/opencode-plugin.md:184.
ELI-23 Elicitation attributed to a session or a whole location
- What. opencode v2 turns
elicitation/createinto a form owned by the sentinel session id"global", because “the server cannot attribute them to a persisted session row”. Every session in that Location sees it. - Where. opencode v2 only (v1 has no elicitation).
- mcpx @ 05c78b2. mcpx is the server here: its questions reach v2 users as Location-global forms. On the legacy path the “(via mcpx) asks:” prefix at least names the upstream (ELI-18).
- Value to mcpx. + low: v2 users can answer mcpx questions at all.
- Effort. S.
- Risk. low: with several sessions open, it is unclear whose question it is.
- Detail. v2 maps form properties to typed fields and URL mode to an external-link field. mcpx’s own analogue of the attribution problem is ELI-19.
- Sources.
v2:packages/core/src/mcp/index.ts:80“MCP elicitations are Location-scoped, not Session-scoped”;v2:packages/core/src/mcp/index.ts:82;v2:packages/core/src/mcp/client.ts:160.
Errors
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: JSON-RPC and MCP error codes across revisions, their HTTP statuses, tool errors versus protocol errors, and what mcpx sends.
MCP reports failures two ways: JSON-RPC error responses (protocol errors) and, for tools, successful results with
isError: true (execution errors). The code set barely moved until 2026-07-28, which added an allocation policy, three
codes (-32020..-32022) that must travel with HTTP 400, moved resource-not-found to -32602, and gave unknown
methods HTTP 404. For mcpx, a dual-era proxy, the two things that matter most are choosing codes by the peer’s revision
(it sends the 2026-only -32022 to legacy clients and -32602 for a missing resource to everyone) and the HTTP status
(every JSON-RPC error goes out as 200, which defeats the status-based era detection 2026 clients use). mcpx answering
unknown notifications with an error is in the notifications register.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| ERR-01 | -32700 Parse error | 2024-11-05 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (typed) | ✓ — sent without id; HTTP 400 | + low | S | low |
| ERR-02 | -32600 Invalid Request | 2024-11-05 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (typed) | partial — stdio checks jsonrpc; HTTP never does | + low | S | low |
| ERR-03 | -32601 Method not found, and what it now covers | 2024-11-05 has 2026-07-28 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ + 404 | partial — used for unknown methods; always HTTP 200 | + med | S | low |
| ERR-04 | -32602 Invalid params, meanings widening | 2024-11-05 has 2026-07-28 has mcpx missing | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (wider) | partial — also used for internal failures (#202) | + med | S | med |
| ERR-05 | -32603 Internal error | 2024-11-05 has 2026-07-28 has mcpx missing | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | partial — MRTR path uses it; resources and prompts use -32602 (#202) | + med | S | low |
| ERR-06 | -32002 Resource not found → -32602 | 2024-11-05 has 2026-07-28 removes mcpx missing 2026-07-28 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 MUST NOT emit | partial — -32602 to every era; legacy expects -32002 (#202) | + low | S | low |
| ERR-07 | -32020 HeaderMismatch | 2026-07-28 has mcpx missing | 26-07 ✓ (HTTP 400) | ✗ — plain-JSON 400 for one case; others unchecked (#199) | + high | S | high |
| ERR-08 | -32021 MissingRequiredClientCapability | 2026-07-28 has | 26-07 ✓ (HTTP 400) | ✗ — never used; the broker answers instead | + low | S | low |
| ERR-09 | -32022 UnsupportedProtocolVersion | 2026-07-28 has mcpx missing | 26-07 ✓ (HTTP 400) | partial — right shape; requested empty; HTTP 200 (#199) | + med | S | med |
| ERR-10 | Code for refusing a legacy initialize: -32602 vs -32022 | 2024-11-05 has specs conflict mcpx missing | 24-11..25-11 example -32602 · 26-07 -32022 | ✗ — 2026-only -32022 sent to legacy peers (#202) | + med | S | med |
| ERR-11 | Draft codes -32001 / -32003 / -32004 | 2026-07-28 has | pre-release 2026 drafts only | n/a — never used by mcpx | + low | S | low |
| ERR-12 | Error-code allocation policy (2026) | 2026-07-28 has specs conflict | 26-07 ✓ | ✓ — standard codes plus -32022 only | + low | S | low |
| ERR-13 | HTTP status for modern JSON-RPC errors (400 / 404) | 2026-07-28 has mcpx missing | 26-07 ✓ | ✗ — every error goes out as 200 (#199) | + med | S | med |
| ERR-14 | Check order for a malformed 2026 HTTP request | 2026-07-28 has specs conflict | 26-07 (unspecified) | partial — header-vs-_meta first; missing fields never rejected | + low | S | low |
| ERR-15 | Error response id optional | 2025-11-25 has | 24-11 — · 25-03 — · 25-06 — · 25-11 ✓ · 26-07 ✓ | partial — sends id-less parse errors; client drops id-less errors | + low | S | low |
| ERR-16 | Local errors versus peer errors through a proxy | 2026-07-28 has mcpx missing | 26-07 guidance | partial — upstream code and data survive only as text (#202) | + med | M | med |
| ERR-17 | Tool input validation: protocol error → isError | 2025-11-25 has | 24-11..25-06 protocol error · 25-11 ✓ · 26-07 ✓ isError | ✓ — tool failures are isError results | + med | S | low |
| ERR-18 | 2026 schema still shows -32602 for bad tool arguments | 2026-07-28 has specs conflict | 26-07 prose vs schema | ✓ — mcpx follows the prose | + low | S | low |
ERR-01 -32700 Parse error
- What. Invalid JSON received. Standard JSON-RPC; 2026 adds a typed
ParseErrorinterface. - Where. Every revision.
- mcpx @ 05c78b2. stdio answers
-32700with no id (internal/mcpserver/server.go:1040-1044); HTTP answers 400 with-32700(internal/mcpserver/server.go:1153-1157). Batch arrays land here (wire W16, S3;transports.mdTR-09), and so does an HTTP body over 64 MiB, which is truncated before parsing (internal/mcpserver/server.go:1132). - Value to mcpx. + low.
- Effort. S — done.
- Risk. None.
- Detail. Legacy HTTP allows an error status whose body MAY be an id-less JSON-RPC error, which is what mcpx sends. The mcpx client drops id-less errors it receives (ERR-15).
- Sources.
schema/2024-11-05/schema.ts:80“export const PARSE_ERROR = -32700;”;schema/2026-07-28/schema.ts:328;internal/mcpserver/server.go:1042-1043; wire S3.
ERR-02 -32600 Invalid Request
- What. The message is not a valid request object; the 2026 doc names missing required fields such as
jsonrpcormethod, or wrong types for them. 2025-11-25 also uses it for misuse of task-required execution. - Where. Every revision.
- mcpx @ 05c78b2. stdio answers
-32600whenjsonrpcis present and not"2.0", but accepts a frame with nojsonrpcat all (internal/mcpserver/server.go:1046-1049). The HTTP path has no such check; line 1046 is the only one. - Value to mcpx. + low.
- Effort. S.
- Risk. Low; accepting a missing
jsonrpcis accept-liberally. - Detail. None.
- Sources.
schema/2024-11-05/schema.ts:81;schema/2026-07-28/schema.ts:339;2025-11-25/basic/utilities/tasks.mdx:775;internal/mcpserver/server.go:1046“if req.JSONRPC != "" && req.JSONRPC != "2.0" {”.
ERR-03 -32601 Method not found, and what it now covers
- What. Unknown method, in every revision. It accumulates meanings: from 2025-03-26 the completion page uses it for
completion/completewithout the capability; 2025-11-25 for ataskSupport: "required"tool called without a task; the 2026 schema for any method “gated behind a server capability the server did not advertise” (a missing client capability is-32021instead); and 2026 HTTP pairs it with status 404. The tasks extension also requires it for the removedtasks/result. - Where. Every revision; 404 pairing in 2026-07-28.
- mcpx @ 05c78b2. Unrecognised methods fall through to
fail(-32601, "no method …")(internal/mcpserver/server.go:766), sent with HTTP 200 (wire W12; ERR-13). The same fallthrough answers unknown notifications, which is covered in the notifications register. - Value to mcpx. + med: the 404 lets 2026 clients tell “unknown method” from “old server”.
- Effort. S.
- Risk. Low.
- Detail. None.
- Sources.
schema/2026-07-28/schema.ts:346“In MCP, a server returns this error when a client invokes a method the server does not implement”;2025-03-26/server/utilities/completion.mdx:130;2026-07-28/basic/transports/streamable-http.mdx:271;internal/mcpserver/server.go:766; wire W12.
ERR-04 -32602 Invalid params, meanings widening
- What. Invalid or malformed parameters. The documented uses grow: unknown tool or prompt, missing prompt
arguments, invalid or expired cursor, invalid log level, the legacy
initializeversion example (ERR-10), an elicitation mode the client did not declare, malformed sampling tool results; 2026 adds a request missing required_metafields (HTTP 400) and a missing resource (ERR-06). - Where. Every revision; widest in 2026-07-28.
- mcpx @ 05c78b2.
tools/callparams that do not decode get-32602(internal/mcpserver/server.go:662-664). Everyresources/readandprompts/getfailure also gets it, internal ones included (ERR-05), and a missing resource gets it in every era (ERR-06). A request missing required_metais not rejected at all (lifecycle-versioning.mdLV-24). - Value to mcpx. + med: meaning “fix your parameters”, it should not be used for server faults.
- Effort. S.
- Risk. Clients retry with “fixed” parameters against a broken server.
- Detail. In 2026 an invalid per-request
logLevelSHOULD reject the whole request with-32602; see the logging register. The elicitation and sampling cases are errors a client returns, and in 2026 a client answers those requests only inside MRTR, whereInputResponsehas no error variant, so the documented-32602has no channel (MRTR register). - Also recorded from the resources register. JSON-RPC semantics generally. A daemon or upstream failure (process
crash, timeout, unknown namespace) in
resources/readorprompts/getcomes back as-32602(internal/mcpserver/server.go:714-716,internal/mcpserver/server.go:755-758). The MRTR path uses-32603for the same failures (internal/mcpserver/ask.go:147-149), so the two paths disagree. Distinguishing “no such resource” (-32602) from “upstream down” (-32603) needs the backend to return a typed error. Overlaps the errors register. - Sources.
schema/2026-07-28/schema.ts:389;schema/2026-07-28/schema.ts:366;schema/2026-07-28/schema.ts:370-371;schema/2026-07-28/schema.ts:541-542;2026-07-28/basic/index.mdx:381;2024-11-05/server/utilities/logging.mdx:110;internal/mcpserver/server.go:715.;2026-07-28/server/resources.mdx:405“Servers SHOULD return-32603for internal errors.”;internal/mcpserver/server.go:755-758;internal/mcpserver/ask.go:147-149
ERR-05 -32603 Internal error
- What. The receiver hit an unexpected condition. 2026’s resources page says servers SHOULD use
-32603for internal errors. - Where. Every revision.
- mcpx @ 05c78b2. A daemon or upstream failure during
resources/readorprompts/get(process crash, timeout, unknown namespace) is reported as-32602(internal/mcpserver/server.go:714-716,:755-758). The MRTR path reports the same failures as-32603(internal/mcpserver/ask.go:147-149,:170-172), so the two paths disagree. The-32603a stateless 2026 client gets when MRTR gives up is in the elicitation register. - Value to mcpx. + med: clients stop retrying corrected parameters against a broken server.
- Effort. S.
- Risk. Low.
- Detail. None.
- Sources.
schema/2024-11-05/schema.ts:84;schema/2026-07-28/schema.ts:403;2026-07-28/server/resources.mdx:405“Servers SHOULD return-32603for internal errors.”;internal/mcpserver/server.go:714-716;internal/mcpserver/ask.go:147-149.
ERR-06 -32002 Resource not found → -32602
- What. 2024-11-05..2025-11-25 answer a missing resource with
-32002. 2026 servers MUST use-32602and MUST NOT emit-32002; clients SHOULD still accept-32002; a missing resource MUST NOT be answered with emptycontents. - Where.
-32002through 2025-11-25;-32602in 2026-07-28. - mcpx @ 05c78b2. Any backend error becomes
-32602for every era (internal/mcpserver/server.go:713-716). Wire W8:{"code":-32602,"message":"mcp error -32602: no such resource: nope"}; the upstream’s own code survives only as text in the message and itsdatais lost.-32002appears nowhere ininternal/mcpserverorinternal/mcpclient. - Value to mcpx. + low: legacy hosts that special-case
-32002show “not found” properly; as a proxy mcpx should translate in both directions by peer revision. - Effort. S — choose the code by
peer.Version. - Risk. Low.
- Detail.
-32002sits in the legacy implementation-defined band; it is the one code there that 2026 lets receivers interpret, and it is reserved, never reused. - Also recorded from the resources register.
-32002in 2024-11-05 through 2025-11-25 (SHOULD); Any backend error from a read becomes-32602in every era (internal/mcpserver/server.go:713-716); wire W8:{"code":-32602,"message":"mcp error -32602: no such resource: nope"}. Correct for 2026; a legacy host that special-cases-32002does not recognise it. The upstream’s own code survives only as text inside mcpx’s message; itsdatais lost.-32002sat in JSON-RPC’s implementation-defined band; 2026 declares-32000..-32019legacy/implementation-defined with-32002the only one receivers may interpret. Overlaps the errors register. - Sources.
2025-11-25/server/resources.mdx:388“- Resource not found:-32002”;2026-07-28/server/resources.mdx:404;2026-07-28/server/resources.mdx:407;2026-07-28/basic/index.mdx:140;internal/mcpserver/server.go:715; wire W8.;2024-11-05/server/resources.mdx:337“- Resource not found:-32002”;internal/mcpserver/server.go:713-716
ERR-07 -32020 HeaderMismatch
- What. A server that processes the body MUST reject a request whose required standard header
(
MCP-Protocol-Version,Mcp-Method,Mcp-Name) is missing, disagrees with the body, or contains invalid characters, with HTTP 400 and a JSON-RPC-32020error; values using the Base64 sentinel are decoded before comparing. Intermediaries must return an HTTP error but need not use JSON-RPC. - Where. 2026-07-28 (numbered
-32001in the pre-release draft). - mcpx @ 05c78b2. Only a mismatch between
MCP-Protocol-Versionand_metais caught, and it is answered 400 with{"error": "MCP-Protocol-Version … does not match …"}, not JSON-RPC (internal/mcpserver/server.go:1159-1168; wire W10).Mcp-MethodandMcp-Nameare never checked (wire W11), a 2026 request with no version header is not rejected, and-32020appears nowhere ininternal/. As a client, a-32020from an upstream is not recognised as modern (lifecycle-versioning.mdLV-28). - Value to mcpx. + high: a dual-era client decides “modern server” from a recognised JSON-RPC error in a 400 body;
mcpx’s plain body makes it fall back to
initialize, which mcpx answers, so the exchange works but in the wrong era. - Effort. S.
- Risk. Dual-era clients misclassify mcpx; a policy proxy trusting the headers can be fooled.
- Detail. Apply it only to modern-era requests; legacy requests carry no
Mcp-Method. The schema’s own note on_metaprotocolVersionsays a mismatch returns “400 Bad Request” without naming a code, while the transport page requires-32020. A client seeing-32020for anx-mcp-headertool SHOULD re-list tools and retry (transports.mdTR-39). - Sources.
schema/2026-07-28/schema.ts:434“export const HEADER_MISMATCH = -32020;”;2026-07-28/basic/transports/streamable-http.mdx:597“When rejecting a request due to header validation failure, servers MUST”;2026-07-28/basic/transports/streamable-http.mdx:624;schema/2026-07-28/schema.ts:71-72;internal/mcpserver/server.go:1163; wire W10.
ERR-08 -32021 MissingRequiredClientCapability
- What. If processing needs a capability absent from this request’s
clientCapabilities, the server MUST return-32021withdata.requiredCapabilities, a fullClientCapabilitiesobject; HTTP 400. - Where. 2026-07-28 (numbered
-32003in the pre-release draft). - mcpx @ 05c78b2. Never used, and no constant exists. When a 2026 client did not declare elicitation,
canAskis false and the question goes to mcpx’s broker, where another audience can answer it (internal/mcpserver/ask.go:66-76;internal/mcpserver/server.go:649-657). - Value to mcpx. + low: a host that could re-send with the capability never learns it was needed; the broker is a deliberate alternative.
- Effort. S.
- Risk. Low; not using it is conformant while mcpx can finish without the capability.
- Detail. MRTR forbids input requests for undeclared capabilities, so when input is truly required this is the only compliant answer (MRTR register). The tasks extension uses it for clients that did not declare the extension (tasks register).
- Also recorded from the elicitation register. 2026-07-28 only (earlier drafts numbered it
-32003). Never emitted, and no constant exists. A 2026 host that did not declare elicitation gets the direct path:canAskis false (internal/mcpserver/ask.go:66-76) and the question waits in the broker for another audience. The findings disagree on the label: the base-protocol axis calls-32021the only compliant answer when input is truly required; the mcpx axis calls the broker fallback a design choice. Both hold: mcpx never requires the capability, because a broker audience can answer.data.requiredCapabilitiesis a fullClientCapabilitiesobject. The rule that mcpx never puts an undeclared kind of question intoinputRequestsis in mrtr.md. - Sources.
schema/2026-07-28/schema.ts:442“export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32021;”;schema/2026-07-28/schema.ts:523“requiredCapabilities: ClientCapabilities;”;2026-07-28/basic/index.mdx:387;internal/mcpserver/ask.go:67.;schema/2026-07-28/examples/MissingRequiredClientCapabilityError/missing-elicitation-capability.json:6
ERR-09 -32022 UnsupportedProtocolVersion
- What. If the requested version is unknown, or known but deliberately unsupported (an experimental draft, say),
the server MUST return
-32022withdata.supported: string[]anddata.requested: string; the client SHOULD retry with a mutually supported version. HTTP 400. - Where. 2026-07-28 (numbered
-32004in the pre-release draft). - mcpx @ 05c78b2. The pre-dispatch check (
internal/mcpserver/server.go:469-471) callsunsupportedVersion, which readsparams.protocolVersion, theinitializefield, instead of_meta(internal/mcpserver/server.go:245-248), sorequestedis always""on modern requests. It goes out with 200 (ERR-13). Wire W13:HTTP/1.1 200 OK … "data":{"requested":"","supported":[…]}. Thesupportedlist is right and includes 2026-07-28. - Value to mcpx. + med: a correct error lets a modern client pick a version and retry, and the 400 is how dual-era clients recognise a modern server.
- Effort. S — read the
_metakey; map the status. - Risk. Clients that inspect
requestedor key on the 400 misbehave. - Detail. mcpx’s own client recognises modern servers by this code alone, via a substring test
(
lifecycle-versioning.mdLV-28). - Sources.
2026-07-28/basic/versioning.mdx:50“support), it MUST respond with an”;schema/2026-07-28/schema.ts:483“export interface UnsupportedProtocolVersionError extends Omit<”;2026-07-28/basic/transports/streamable-http.mdx:265;internal/mcpserver/server.go:248; wire W13.
ERR-10 Code for refusing a legacy initialize: -32602 vs -32022
- What. Every legacy lifecycle page shows an “initialization error” example:
-32602 "Unsupported protocol version"withdata.supportedanddata.requested, the same data shape 2026 standardises under-32022. mcpx answers legacyinitializerefusals with-32022, a code no legacy revision defines. - Where. Example in 2024-11-05..2025-11-25;
-32022in 2026-07-28 only. - mcpx @ 05c78b2.
codeUnsupportedVersion = -32022(internal/mcpserver/server.go:230) is used for everyinitializerefusal (internal/mcpserver/server.go:506-509); wire W1, W3, W4, S1. - Value to mcpx. + med. A dual-era client may read
-32022as “modern server”, which happens to reach mcpx’s discover path; a legacy client sees an unknown code. For any well-formed version the legacy answer is a counter-offer, not an error (lifecycle-versioning.mdLV-05..LV-07); an error remains right only for garbage, where the legacy example uses-32602. - Effort. S.
- Risk. Legacy clients that switch on
-32602treat the refusal as unknown; mcpx’s own client aborts on it (lifecycle-versioning.mdLV-29). - Detail. The legacy example requests
"1.0.0", not a date. 2026’s versioning page tells a modern-only server to name its versions in any error toinitializewithout assigning a code. Sending a 2026 code to a legacy peer is a send-conservatively conflict. - Sources.
2024-11-05/basic/lifecycle.mdx:210“"code": -32602,”;2025-11-25/basic/lifecycle.mdx:278;2026-07-28/basic/versioning.mdx:59“"code": -32022,”;schema/2026-07-28/schema.ts:450;internal/mcpserver/server.go:230; wire W1.
ERR-11 Draft codes -32001 / -32003 / -32004
- What. Before 2026-07-28 was released its draft numbered HeaderMismatch
-32001, MissingRequiredClientCapability-32003and UnsupportedProtocolVersion-32004; they were renumbered to-32020..-32022. Pre-release SDKs may still send the old numbers, which now fall in the band where receivers must assume nothing. - Where. Pre-release 2026 drafts only.
- mcpx @ 05c78b2. Never used. A pre-release server sending
-32004would not be recognised as modern by mcpx’s-32022substring test (internal/mcpclient/client.go:330), which is also what the policy requires. - Value to mcpx. + low.
- Effort. S.
- Risk. Low.
- Detail. None.
- Sources.
2026-07-28/changelog.mdx:65“HeaderMismatch-32001→-32020,MissingRequiredClientCapability”;internal/mcpclient/client.go:330.
ERR-12 Error-code allocation policy (2026)
- What. JSON-RPC’s
-32000..-32099is split.-32000..-32019is legacy: no new allocations, new implementations SHOULD NOT use it, and receivers MUST NOT assume a meaning, except for-32002.-32020..-32099belongs to the specification: implementations MUST NOT emit undefined codes there and MUST use defined ones only with their meaning; codes are allocated sequentially from-32020, and earlier codes (-32002,-32042) stay reserved. New application errors SHOULD be allocated outside-32768..-32000. - Where. 2026-07-28.
- mcpx @ 05c78b2. Uses only the JSON-RPC standard codes and
-32022(internal/mcpserver/server.go:222-231). - Value to mcpx. + low: nothing to change today; it constrains any code mcpx adds.
- Effort. S.
- Risk. None.
- Detail. The schema’s comment describes
-32000..-32019as implementation-defined, used by existing SDKs “for their own purposes”, without the prose’s SHOULD NOT for new implementations. - Sources.
2026-07-28/basic/index.mdx:117“--32000to-32019— legacy. Codes in this sub-range were allocated by”;2026-07-28/basic/index.mdx:122;2026-07-28/basic/index.mdx:153-154;schema/2026-07-28/schema.ts:413-416;internal/mcpserver/server.go:222-231.
ERR-13 HTTP status for modern JSON-RPC errors (400 / 404)
- What. For 2026 requests over HTTP:
-32602for missing_meta,-32020,-32021and-32022MUST go out with 400; an unimplemented method MUST go out as 404 with-32601, whose body distinguishes it from an old HTTP+SSE server’s bare 404. Legacy Streamable HTTP ties no JSON-RPC code to a status: its 400 is for a bad version header or a missing required session, and its 404 for an expired session. - Where. 2026-07-28.
- mcpx @ 05c78b2. Every non-streaming response, errors included, is written with 200
(
internal/mcpserver/server.go:1192). Wire W12 (-32601, 200), W13 (-32022, 200), W8 (-32602, 200). - Value to mcpx. + med: dual-era clients classify servers by these statuses (
lifecycle-versioning.mdLV-30,transports.mdTR-11). - Effort. S — map by code when the request is modern; keep 200 for legacy requests.
- Risk. A dual-era client probing mcpx with a 2026 request gets a 200 error and may misclassify it.
- Detail. None.
- Sources.
2026-07-28/basic/transports/streamable-http.mdx:265“not to support), it MUST respond with400 Bad Requestand an”;2026-07-28/basic/transports/streamable-http.mdx:271“If the server does not implement the requested RPC method, it MUST respond”;2026-07-28/basic/index.mdx:381;internal/mcpserver/server.go:1192“writeJSON(w, http.StatusOK, resp)”; wire W12.
ERR-14 Check order for a malformed 2026 HTTP request
- What. A missing
MCP-Protocol-Versionheader is a HeaderMismatch (-32020); a missing_metaprotocolVersionis-32602; an unsupported version is-32022. All three are 400, and the spec does not order the checks, for example when the header is present with an unsupported version and_metais absent. Only the JSON-RPC code differs, and dual-era clients classify by that code. - Where. 2026-07-28.
- mcpx @ 05c78b2. Order: parse (
-32700), then header against_metaonly when both are present (plain 400,internal/mcpserver/server.go:1161), then session, then the version check before dispatch (-32022,internal/mcpserver/server.go:469); missing header or_metafields are never rejected. - Value to mcpx. + low: pick one order and document it.
- Effort. S.
- Risk. Clients may classify the same malformed request differently across servers.
- Detail. None.
- Sources.
2026-07-28/basic/transports/streamable-http.mdx:624“- A required standard header (MCP-Protocol-Version,Mcp-Method,”;2026-07-28/basic/index.mdx:380-381;internal/mcpserver/server.go:1161;internal/mcpserver/server.go:469.
ERR-15 Error response id optional
- What. 2024-11-05..2025-06-18 schemas require
idonJSONRPCError; 2025-11-25 makes it optional for errors where the id could not be read, and splitsJSONRPCResultResponsefromJSONRPCErrorResponse. Earlier HTTP prose already allowed id-less error bodies on 4xx. - Where. 2025-11-25, 2026-07-28.
- mcpx @ 05c78b2. The server sends parse errors without an id (
internal/mcpserver/server.go:1042-1043,:1155-1156). The client skips any frame without an id as a notification (internal/mcpclient/client.go:369-370), so an id-less error from an upstream, such as its parse error for one of mcpx’s frames, is dropped and the pending call waits out its timeout. - Value to mcpx. + low.
- Effort. S — fail the in-flight call, or log, on an id-less error.
- Risk. A strict 2025-06-18 client may reject id-less errors; that is unavoidable for parse errors.
- Detail. None.
- Sources.
schema/2024-11-05/schema.ts:91“id: RequestId;”;schema/2025-11-25/schema.ts:161“id?: RequestId;”;2025-11-25/basic/index.mdx:89;internal/mcpclient/client.go:369-370.
ERR-16 Local errors versus peer errors through a proxy
- What. 2026: errors purely local to an implementation, such as an SDK timeout, have no codes, and implementations that surface them in JSON-RPC shape should make sure they cannot be mistaken for errors from the peer. A proxy has three sources to keep apart: the upstream’s error, its own failures, and local timeouts.
- Where. 2026-07-28 guidance.
- mcpx @ 05c78b2. An upstream error arrives as Go error text (
mcp error -32602: no such resource: nope) and is re-wrapped in a fresh-32602(internal/mcpserver/server.go:713-716; wire W8), so the upstream’sdatais lost and a daemon or transport failure looks like an upstream invalid-params error (ERR-05). Whether other upstream codes, such as-32002or-32042, are ever forwarded unchanged was not verified. - Value to mcpx. + med: an agent can tell “the upstream said no” from “mcpx broke”.
- Effort. M — carry upstream code and data through the daemon, and mark local failures.
- Risk. Agents retry the wrong thing.
- Detail. None.
- Sources.
2026-07-28/basic/index.mdx:146“Errors that are purely local to an implementation (for example, a request”;2026-07-28/basic/index.mdx:149;internal/mcpserver/server.go:715; wire W8.
ERR-17 Tool input validation: protocol error → isError
- What. 2024-11-05..2025-06-18 list unknown tools and invalid arguments as protocol errors and execution failures as
isError: trueresults. 2025-11-25 (SEP-1303) moves input-validation failures into tool execution errors so the model can correct itself; protocol errors are left for unknown tools, malformed requests and server errors, and clients SHOULD pass execution errors to the model. - Where. 2025-11-25 and 2026-07-28 prose.
- mcpx @ 05c78b2. A failing tool is an
isError: trueresult (internal/mcpserver/server.go:666-674); params that do not decode get-32602(internal/mcpserver/server.go:662-664). In the proxy directionmcpx_callloses an upstream’sisError(tools register). - Value to mcpx. + med: matches mcpx’s design; as a proxy, pass upstream errors through unchanged.
- Effort. S — done on the server side.
- Risk. Low.
- Detail. None.
- Sources.
2024-11-05/server/tools.mdx:231“1. Protocol Errors: Standard JSON-RPC errors for issues like:”;2024-11-05/server/tools.mdx:233“- Invalid arguments”;2025-11-25/changelog.mdx:28;2025-11-25/server/tools.mdx:471;internal/mcpserver/server.go:666-674.
ERR-18 2026 schema still shows -32602 for bad tool arguments
- What. 2026 prose makes input-validation failures (a date in the wrong format, a value out of range)
isErrorresults. The 2026 schema’sInvalidParamsErrordoc lists “Tools: Unknown tool name or invalid tool arguments” and ships an exampleinvalid-tool-arguments.json:-32602 "Invalid arguments for tool calculate: Missing required property 'expression'". - Where. 2026-07-28, prose against schema.
- mcpx @ 05c78b2. Follows the prose: tool failures are
isError(internal/mcpserver/server.go:666-674). - Value to mcpx. + low: clients, mcpx included, must accept both forms from upstreams.
- Effort. S.
- Risk. Low.
- Detail. The reading that reconciles them draws the line between failing the
CallToolRequestschema (a malformed request,-32602) and failing the tool’sinputSchema(isError). The prose never states that line, and “missing required property” is aninputSchemafailure, so the example contradicts the prose either way. - Sources.
2026-07-28/server/tools.mdx:762“- Input validation errors (e.g., date in wrong format, value out of range)”;schema/2026-07-28/schema.ts:366“- Tools: Unknown tool name or invalid tool arguments”;schema/2026-07-28/examples/InvalidParamsError/invalid-tool-arguments.json:3.
Lifecycle and versioning
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: How peers agree on a revision: the legacy initialize handshake, 2026 per-request versioning, server/discover, dual-era rules.
Revisions 2024-11-05 through 2025-11-25 open every connection with an initialize handshake that fixes the version
and capabilities for the connection’s life; 2026-07-28 removes the handshake, puts version, capabilities and identity
in every request’s _meta, and adds server/discover. mcpx has to speak both eras in both directions, and two things
matter most: its modern path agrees with itself but not with the schema (protocolVersions where the field is
supportedVersions), so it cannot complete discovery with a conformant 2026 peer in either direction; and it refuses
legacy clients that the legacy rules say it must answer with a counter-offer.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| LV-01 | initialize / notifications/initialized handshake, removed in 2026 | 2024-11-05 has 2026-07-28 removes has better replacement opencode v1 has opencode v2 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — · opencode v1 ✓ · opencode v2 ✓ (default) | ✓ — served and spoken; legacy tried first as a client | + high | S | low |
| LV-02 | InitializeRequest.params: protocolVersion, capabilities, clientInfo | 2024-11-05 has 2025-11-25 has 2026-07-28 removes | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ (+_meta) · 26-07 — | ✓ — all three sent; missing protocolVersion accepted | + low | S | low |
| LV-03 | 2024-11-05 is not served; the floor is 2025-03-26 | impl deferred 2024-11-05 has | 24-11 ✓ hosts exist | ✗ — deliberate floor at 2025-03-26 | + med | M | med |
| LV-04 | Server echoes a supported requested version | 2024-11-05 has 2026-07-28 removes | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — | ✓ — 2025-03-26 and 2025-06-18 agreed as asked | + high | S | low |
| LV-05 | Counter-offer for an older legacy version (2024-11-05) | mcpx missing 2024-11-05 has specs conflict | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — | ✗ — refused with -32022 instead of a counter-offer (#202) | + high | S | high |
| LV-06 | Counter-offer for an unknown newer version | mcpx missing 2024-11-05 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — | ✗ — 9999-01-01 refused; future dates count as modern (#202) | + med | S | med |
| LV-07 | initialize asking for 2026-07-28 | 2026-07-28 has mcpx missing | 26-07 ✓ (dual-era rule) | ✗ — refused -32022, legacy list only; rule says counter-offer (#202) | + low | S | low |
| LV-08 | Client checks the version the server answered | specs conflict mcpx missing opencode v1 has opencode v2 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — · opencode v1 ✓ · opencode v2 ✓ | ✗ — stores any version the server returns (#203) | + med | S | med |
| LV-09 | Version each client offers in initialize | 2025-11-25 has opencode v1 has opencode v2 has lootbox has | mcpx 2025-11-25 · opencode v1/v2 2025-11-25 · lootbox 2025-06-18 | partial — always 2025-11-25; documented override does not exist | + low | S | low |
| LV-10 | Pre-initialization restrictions (only ping, logging) | 2024-11-05 has 2026-07-28 removes | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — | acc. — any method served before initialize, as 2025-03-26 | + low | S | low |
| LV-11 | “Respect negotiated version and capabilities”: SHOULD → MUST | 2025-06-18 has | 24-11 SHOULD · 25-03 SHOULD · 25-06 MUST · 25-11 MUST · 26-07 per request | partial — results downgraded; some declarations leak | + high | M | med |
| LV-12 | initialize MUST NOT be batched | 2025-03-26 has 2025-06-18 removes | 25-03 ✓ only | n/a — mcpx accepts no batches at all | + low | S | low |
| LV-13 | notifications/initialized arriving at a 2026 server | 2026-07-28 removes | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — | ✓ — swallowed silently, bare initialized too | + low | S | low |
| LV-14 | instructions: InitializeResult → DiscoverResult | 2024-11-05 has 2026-07-28 has specs conflict opencode v1 has opencode v2 has | 24-11 ✓(schema) · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (discover) · opencode v1/v2 system prompt | ✓ — sent on both; client stores both | + high | S | low |
| LV-15 | Implementation.title | 2025-06-18 has | 24-11 — · 25-03 — · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✗ — not sent (optional) | + low | S | low |
| LV-16 | Implementation.description | 2025-11-25 has | 25-11 ✓ · 26-07 ✓ | ✗ — not sent (optional) | + low | S | low |
| LV-17 | Implementation.icons | 2025-11-25 has | 25-11 ✓ · 26-07 ✓ | ✗ — not sent, not read | + low | S | low |
| LV-18 | Implementation.websiteUrl | 2025-11-25 has | 25-11 ✓ · 26-07 ✓ | ✗ — not sent (optional) | + low | S | low |
| LV-19 | Per-request _meta io.modelcontextprotocol/protocolVersion | 2026-07-28 has opencode v2 has | 26-07 ✓ · opencode v2 (modern era) | ✓ — read and checked per request; client sends it | + high | S | low |
| LV-20 | Per-request _meta io.modelcontextprotocol/clientCapabilities | 2026-07-28 has opencode v2 has | 26-07 ✓ · opencode v2 (modern era) | partial — read per request; a missing key means “none” | + high | S | med |
| LV-21 | Per-request _meta io.modelcontextprotocol/clientInfo | 2026-07-28 has opencode v2 has | 26-07 ✓ (SHOULD) · opencode v2 (modern era) | ✓ — client sends it; server does not read it | + low | S | low |
| LV-22 | io.modelcontextprotocol/serverInfo in every result’s _meta | 2026-07-28 has mcpx missing | 26-07 ✓ (SHOULD) | ✗ — never emitted; client never reads it (#199) | + med | S | low |
| LV-23 | DiscoverResult has no top-level serverInfo | 2026-07-28 has mcpx missing | 26-07 (field absent) | ✗ — sends undefined field; client reads it there (#199) | + low | S | low |
| LV-24 | A 2026 request missing required _meta is malformed | 2026-07-28 has mcpx missing | 26-07 ✓ | partial — no version ⇒ served as 2025-03-26; no caps ⇒ none (#199) | + med | S | med |
| LV-25 | server/discover MUST be implemented | 2026-07-28 has opencode v2 has | 26-07 ✓ · opencode v2 (protocol auto/pinned) | partial — answered in any era; field names wrong | + high | S | high |
| LV-26 | DiscoverResult.supportedVersions | 2026-07-28 has mcpx missing | 26-07 ✓ | ✗ — protocolVersions on both server and client (#199) | + high | S | high |
| LV-27 | stdio probe order: server/discover before initialize | 2026-07-28 has impl deferred opencode v2 has | 26-07 SHOULD · opencode v2 auto | partial — legacy first by default; modern first is opt-in | + med | S | med |
| LV-28 | Which probe errors mean “modern server” | 2026-07-28 has mcpx missing | 26-07 ✓ | ✗ — only a -32022 substring counts (#200) | + med | S | med |
| LV-29 | Modern-only server refusing initialize; mcpx client aborts | 2026-07-28 has mcpx missing | 26-07 SHOULD name versions | ✗ — client aborts on -32022; never tries discover (#200) | + high | S | high |
| LV-30 | HTTP era detection from the 400 body | 2026-07-28 has | 26-07 MAY/SHOULD | partial — status ignored; body matched by substring | + med | S | med |
| LV-31 | Era is a property of the server; cache it | 2026-07-28 has | 26-07 SHOULD cache, MAY persist | partial — per connection; not persisted across restarts | + low | S | low |
| LV-32 | Per-server era setting | opencode v2 has | mcpx four values · opencode v2 three values | partial — typos silently fall back to legacy | + low | S | low |
| LV-33 | Dual-era server: era chosen per opening message | 2026-07-28 has | 26-07 MAY | ✓ — per-request _meta first, else connection handshake | + high | S | low |
| LV-34 | ping received from a modern client | 2026-07-28 removes has better replacement | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — | acc. — answered {resultType:"complete"} | + low | S | low |
| LV-35 | ping sent by a client | 2024-11-05 has 2026-07-28 removes lootbox has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — · lootbox (fork) | partial — Ping() era-blind but unused; answers server pings | + low | S | low |
| LV-36 | resultType required on every 2026 result | 2026-07-28 has | 26-07 ✓ | ✓ — stamped for modern peers, stripped for legacy | + high | S | low |
| LV-37 | Request-id uniqueness: per session → among in-flight | 2026-07-28 has | 24-11..25-11 session · 26-07 in flight | ✓ — server-initiated ids are negative | + low | S | low |
| LV-38 | stdio server exits promptly on stdin EOF | 2026-07-28 has | 26-07 SHOULD | ✓ — mcpx serve exits at EOF | + med | S | low |
| LV-39 | Client stdio shutdown: close stdin, wait, SIGTERM, SIGKILL | 2024-11-05 has mcpx missing opencode v2 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ · opencode v2 ✓ | partial — SIGTERM immediately after closing stdin (#203) | + low | S | low |
| LV-40 | Restart a stdio server that exited unexpectedly | 2026-07-28 has lootbox has | 26-07 SHOULD · lootbox (fork) · opencode v1/v2 — | ✓ — lazy respawn on next call, backoff after failures | + med | S | low |
LV-01 initialize / notifications/initialized handshake, removed in 2026
- What. In the four legacy revisions every connection opens with an
initializerequest, itsInitializeResult, and anotifications/initializednotification, and that exchange MUST be the first interaction. 2026-07-28 deletes both message types; per-request_meta(LV-19..LV-21) and the optionalserver/discover(LV-25) replace them. - Where. 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25. Not in 2026-07-28:
ClientRequestlistsDiscoverRequestand noInitializeRequest, andClientNotificationisCancelledNotificationalone. opencode v1 always initializes; opencode v2 does unless its per-serverprotocoloption says otherwise (LV-32). - mcpx @ 05c78b2. The server answers
initialize(internal/mcpserver/server.go:505-527) and swallowsnotifications/initialized(internal/mcpserver/server.go:540-541). The client initializes first by default (internal/mcpclient/client.go:231-232) and sendsnotifications/initializedafter the result (internal/mcpclient/client.go:277). - Value to mcpx. + high: every published legacy client and server uses it, and 2026 explicitly allows a server to keep answering it (LV-33).
- Effort. S — done in both directions.
- Risk. Removing it would cut off the whole legacy ecosystem; keeping it costs nothing.
- Detail. mcpx also accepts the non-standard bare method name
initialized, an accept-liberally extra. What a modern-only server does with a straynotifications/initializedis LV-13. - Sources.
2024-11-05/basic/lifecycle.mdx:40“The initialization phase MUST be the first interaction”;2026-07-28/changelog.mdx:14“remove theinitialize/notifications/initializedhandshake”;schema/2026-07-28/schema.ts:3153“export type ClientRequest =”;schema/2026-07-28/schema.ts:3166“export type ClientNotification = CancelledNotification;”;v1:patches/@modelcontextprotocol%2Fsdk@1.29.0.patch:36;v2:packages/core/src/mcp/client.ts:312;internal/mcpserver/server.go:540.
LV-02 InitializeRequest.params: protocolVersion, capabilities, clientInfo
- What.
protocolVersion: string,capabilities: ClientCapabilitiesandclientInfo: Implementationare required and unchanged in every legacy revision. 2025-11-25 moves them intoInitializeRequestParams extends RequestParams, which formally allows_metaoninitialize. - Where. 2024-11-05..2025-11-25. 2026-07-28 moves each into a per-request
_metakey (LV-19, LV-20, LV-21). - mcpx @ 05c78b2. The client sends all three (
internal/mcpclient/client.go:262-267). The server readsprotocolVersionandcapabilities(internal/mcpserver/server.go:506-517) and ignoresclientInfo. Aninitializewith noprotocolVersion, or with params that do not parse, is accepted and answered with 2025-11-25 (internal/mcpserver/server.go:848). - Value to mcpx. + low: nothing to add.
- Effort. S — done.
- Risk. None.
- Detail. The schema describes
protocolVersionas the latest version the client supports and adds “The client MAY decide to support older versions as well.” The 2026 mapping isprotocolVersion→io.modelcontextprotocol/protocolVersion(required),capabilities→io.modelcontextprotocol/clientCapabilities(required),clientInfo→io.modelcontextprotocol/clientInfo(optional, SHOULD). Accepting a missingprotocolVersionis accept-liberally: the field is required in every legacy schema. - Sources.
schema/2024-11-05/schema.ts:151“protocolVersion: string;”;schema/2025-11-25/schema.ts:255“export interface InitializeRequestParams extends RequestParams {”;2026-07-28/basic/index.mdx:375;internal/mcpserver/server.go:848.
LV-03 2024-11-05 is not served; the floor is 2025-03-26
- What. mcpx serves 2025-03-26, 2025-06-18, 2025-11-25 and 2026-07-28. A 2024-11-05 host cannot connect, and a request that declares nothing is assumed to be 2025-03-26.
- Where. 2024-11-05 hosts exist (older desktop builds, early SDKs); the spec repo still carries its schema and its HTTP+SSE transport.
- mcpx @ 05c78b2.
Supportedlists four revisions (internal/mcpserver/server.go:778) andOldestis 2025-03-26 (internal/mcpserver/revisions.go:14).docs/protocol.md:51-59records the floor as a deliberate choice. As a client mcpx still accepts a 2024-11-05 answer from an upstream (LV-08). - Value to mcpx. + med: reach to the oldest hosts, and “every revision” is the stated goal. − another
downgrade()column to maintain. - Effort. M — the version, a downgrade column (no audio, no tool annotations, no
completionscapability), and the HTTP+SSE transport if those hosts are to be reached remotely (transports.mdTR-10). - Risk. Not done: the “speak every revision” goal stays unmet for 2024-11-05.
- Detail.
docs/protocol.md:51-53says the 2025-03-26 schema “was not among” those read; it is available now and confirms the doc’s assumptions (audio, annotations andcompletionspresent; nostructuredContent,resource_link, elicitation or tasks). Serving 2024-11-05 is a separate decision from counter-offering to a 2024-11-05 client (LV-05), which needs no new column. - Sources.
internal/mcpserver/revisions.go:14“const Oldest = "2025-03-26"”;docs/protocol.md:56“as the floor”;internal/mcpserver/server.go:778.
LV-04 Server echoes a supported requested version
- What. In every legacy revision, if the server supports the version the client asked for, it MUST answer with that same version.
- Where. 2024-11-05..2025-11-25; no negotiation exists in 2026-07-28 (LV-19).
- mcpx @ 05c78b2.
negotiatereturns the requested version when it is a supported legacy one (internal/mcpserver/server.go:855) and the reply echoes it (internal/mcpserver/server.go:523), with capabilities shaped for that revision. Wire W2 agreed 2025-03-26; the stdio run S1 agreed 2025-06-18. - Value to mcpx. + high: this is the common path for every legacy host.
- Effort. S — done.
- Risk. None.
- Detail. The agreed version is stored on the connection and governs everything sent to that peer afterwards
(
internal/mcpserver/conn.go:101-105). - Sources.
2024-11-05/basic/lifecycle.mdx:126“If the server supports the requested protocol version, it MUST respond with the same”;internal/mcpserver/server.go:855; wire W2.
LV-05 Counter-offer for an older legacy version (2024-11-05)
- What. Every legacy revision says that when the server does not support the requested version it MUST still
answer
initializesuccessfully, with another version it supports (SHOULD be its latest); the client then decides whether to disconnect. Negotiation is a counter-offer, not an error. - Where. 2024-11-05..2025-11-25, identical text.
- mcpx @ 05c78b2.
negotiate()returns""for any version not inSupported(internal/mcpserver/server.go:844-859) and theinitializebranch answersunsupportedVersion(..., legacyOnly=true)(internal/mcpserver/server.go:506-509). Wire W1 (HTTP) and S1 (stdio):-32022withdata.requested: "2024-11-05"andsupported= the three legacy versions. The same failed handshake also mints anMcp-Session-Id(transports.mdTR-27).docs/protocol.md:76claimsinitializeaccepts “any legacy version”. - Value to mcpx. + high: a 2024-11-05 host would be offered 2025-11-25 and could proceed if it tolerates it; today it gets a code it has never heard of.
- Effort. S — return
Latestinstead of""for a well-formed version mcpx does not implement. - Risk. Not done: every 2024-11-05-only host fails the handshake. Done: such a host may disconnect (spec-intended), or proceed and meet 2025-era shapes it did not ask for, which is its decision to make.
- Detail. The same lifecycle pages show an “initialization error” example with
-32602anddata: {supported, requested}(errors.mdERR-10), which is why the texts conflict. The example’srequestedvalue is"1.0.0", not a date, which is one way to reconcile them: counter-offer for a well-formed version, error for garbage. - Sources.
2024-11-05/basic/lifecycle.mdx:126;2025-03-26/basic/lifecycle.mdx:132“Otherwise, the server MUST respond with another protocol version it”;2025-11-25/basic/lifecycle.mdx:170;internal/mcpserver/server.go:858return "";docs/protocol.md:76; wire W1, S1.
LV-06 Counter-offer for an unknown newer version
- What. A legacy handshake from a client newer than mcpx (for example
9999-01-01) gets the same counter-offer rule as LV-05: answer with mcpx’s own latest legacy version. - Where. Required by every legacy revision.
- mcpx @ 05c78b2. Same refusal path as LV-05. Wire W4: HTTP 200,
-32022,supported= the legacy list, and a newMcp-Session-Idheader. - Value to mcpx. + med: forward compatibility with the next client that still starts with
initialize. - Effort. S — the same change as LV-05, plus the
Modern()check below. - Risk. Not done: a future dual-era client that probes with
initializefirst falls over at the handshake. - Detail.
Modern()is a string comparison,version >= "2026-07-28"(internal/mcpserver/server.go:793), so every future date counts as “modern” and would still be refused overinitializeafter LV-05 is fixed, unless that check changes too. - Sources.
2025-11-25/basic/lifecycle.mdx:170;internal/mcpserver/server.go:793“func Modern(version string) bool { return version >= "2026-07-28" }”; wire W4.
LV-07 initialize asking for 2026-07-28
- What. 2026 has no
initialize. A dual-era server treats aninitializerequest as selecting legacy semantics, so aninitializecarryingprotocolVersion: "2026-07-28"falls under the legacy counter-offer rule and should be answered with 2025-11-25. - Where. 2026-07-28 versioning page (dual-era rules).
- mcpx @ 05c78b2. The
initializebranch passeslegacyOnly=true(internal/mcpserver/server.go:237-242,:508). Wire W3:-32022,supported: ["2025-11-25","2025-06-18","2025-03-26"].docs/protocol.md:76records the refusal as intended. - Value to mcpx. + low: either answer is consistent with “
initializemeans legacy”. A counter-offer lets a confused client proceed; the refusal’s data omits 2026-07-28, so it gives no hint to tryserver/discover. - Effort. S — the same change as LV-05.
- Risk. Low.
- Detail. The refusal code is itself 2026-only (
errors.mdERR-10). A dual-era client that receives it concludes “modern server” and retries withserver/discover, which mcpx answers, so the exchange still ends in the modern era after one wasted round trip. - Sources.
2026-07-28/basic/versioning.mdx:178“- Aninitializerequest selects legacy semantics, scoped to the stdio”;internal/mcpserver/server.go:508;docs/protocol.md:76; wire W3.
LV-08 Client checks the version the server answered
- What. Legacy prose says the client SHOULD disconnect if it does not support the version in the server’s
InitializeResult; the schema doc comment onInitializeResult.protocolVersionsays MUST. - Where. Both texts in all four legacy revisions. opencode v1 and v2 accept only a known list (2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07) and fail the connection on anything else.
- mcpx @ 05c78b2. The client stores
ir.ProtocolVersionasNegotiatedwith no check (internal/mcpclient/client.go:275)./v1/protocolshowed the test upstream atnegotiated: 2025-06-18(wire, “mcpx as client”). A 2024-11-05 answer would be accepted although mcpx implements neither that revision’s shapes nor its HTTP+SSE transport. - Value to mcpx. + med: prevents silently misparsing a server that picked a revision mcpx cannot speak, while accepting known lower versions stays accept-liberally.
- Effort. S — check against
Supportedand disconnect otherwise. - Risk. Not done: silent misparse against an old or unknown revision.
- Detail. The schema is the source of truth for a revision, so treat it as MUST. Nothing below the handshake adapts
to the accepted version either: the HTTP version header stays 2025-11-25 (
transports.mdTR-34). - Sources.
2024-11-05/basic/lifecycle.mdx:130“If the client does not support the version in the server’s response, it SHOULD”;schema/2024-11-05/schema.ts:162“If the client cannot support this version, it MUST disconnect.”;schema/2025-11-25/schema.ts:281;internal/mcpclient/client.go:275; https://unpkg.com/@modelcontextprotocol/sdk@1.29.0/dist/esm/types.js L4; https://unpkg.com/@modelcontextprotocol/core@2.0.0/dist/auth-CUe6YdwF.mjs L4–L12.
LV-09 Version each client offers in initialize
- What. Which revision a client proposes when it opens a legacy connection.
- Where. mcpx: 2025-11-25. opencode v1 (
@modelcontextprotocol/sdk1.29.0 plus a 647-line patch) and opencode v2 (@modelcontextprotocol/client2.0.0, unlessprotocolis set): 2025-11-25. lootbox (@modelcontextprotocol/sdk1.22.0): 2025-06-18 as its latest, with client capabilities{}. - mcpx @ 05c78b2.
ProtocolVersion = "2025-11-25"(internal/mcpclient/client.go:22), sent atinternal/mcpclient/client.go:263; the modern list is["2026-07-28"](internal/mcpclient/client.go:26).docs/protocol.md:39-40says 2025-03-26 and 2025-06-18 are spoken upstream “on request (protocol: force-legacy)”, butforce-legacyonly removes the fallback; it never chooses a version. - Value to mcpx. + low: offering the newest legacy revision is right. A per-server pin would matter only for servers that mishandle counter-offers.
- Effort. S — correct the doc, or add the pin it describes.
- Risk. A documented knob that does not exist is a declared-but-not-delivered bug.
- Detail. lootbox’s
capabilities: {}means an upstreamelicitation/createorroots/listgets the SDK’s default method-not-found. opencode v1’s patch moves the handshake into_initializeso it can be re-run on session expiry (transports.mdTR-29). - Sources.
internal/mcpclient/client.go:22“const ProtocolVersion = "2025-11-25"”;docs/protocol.md:39“on request (protocol: force-legacy)”;v1:packages/opencode/package.json:83;v2:packages/core/package.json:113;v2:packages/core/src/mcp/client.ts:312;.lootbox/deno.json:44;.lootbox/src/lib/external-mcps/mcp_client_manager.ts:150-153;@modelcontextprotocol/sdk@1.22.0/dist/esm/types.js:2“export const LATEST_PROTOCOL_VERSION = ‘2025-06-18’;” (the copy lootbox resolves from the local Deno npm cache).
LV-10 Pre-initialization restrictions (only ping, logging)
- What. Legacy: the client SHOULD NOT send requests other than
pingbefore theinitializeresponse, and the server SHOULD NOT send requests other thanpingand logging beforenotifications/initialized. - Where. 2024-11-05..2025-11-25; meaningless in 2026-07-28.
- mcpx @ 05c78b2. The server processes any method on a connection that never initialized and treats it as
Oldest, 2025-03-26 (internal/mcpserver/conn.go:140-146). - Value to mcpx. + low: SHOULD NOT, so serving is permitted.
- Effort. S — nothing to do.
- Risk. A modern client that forgot
_metais silently served legacy shapes (LV-24). - Detail. 2026’s stdio page notes that legacy servers which do not validate ordering answer an era-ambiguous
method under legacy semantics, which is why the modern probe treats any non-modern answer as “legacy” (LV-27,
LV-28). The dual-era case of a request with neither
_metanor a prior handshake is covered in the process-model register. - Sources.
2024-11-05/basic/lifecycle.mdx:113“The client SHOULD NOT send requests other than”;2024-11-05/basic/lifecycle.mdx:116;2026-07-28/basic/transports/stdio.mdx:144“some legacy servers do not validate that a”;internal/mcpserver/conn.go:144“v = Oldest”.
LV-11 “Respect negotiated version and capabilities”: SHOULD → MUST
- What. 2024-11-05 and 2025-03-26 say both parties SHOULD respect the negotiated version and use only negotiated capabilities; 2025-06-18 makes it MUST. 2026-07-28 restates it per request: a server MUST NOT rely on capabilities the client has not declared on that request.
- Where. MUST from 2025-06-18.
- mcpx @ 05c78b2.
Defines()anddowngrade()gate result shapes per revision (internal/mcpserver/revisions.go:115-123,:132-170). Declarations are not covered by that machinery; the capability leaks to 2025-11-25 and 2026 peers are in the capabilities register. - Value to mcpx. + high: this is mcpx’s “send conservatively” rule.
- Effort. M — the remaining leaks are spread across capabilities, lists and requests.
- Risk. Strict peers reject or misread fields they never negotiated.
- Detail. The 2025-06-18 changelog records the change as item 9.
- Sources.
2024-11-05/basic/lifecycle.mdx:162“Both parties SHOULD:”;2025-06-18/basic/lifecycle.mdx:175“Both parties MUST:”;2025-06-18/changelog.mdx:31;internal/mcpserver/revisions.go:132-170.
LV-12 initialize MUST NOT be batched
- What. With JSON-RPC batching introduced, 2025-03-26 forbids putting
initializein a batch; the sentence goes away with batching in 2025-06-18. - Where. 2025-03-26 only.
- mcpx @ 05c78b2. mcpx does not accept batches at all (
transports.mdTR-09). - Value to mcpx. + low: only relevant if 2025-03-26 batching is implemented.
- Effort. S.
- Risk. None.
- Detail. The stated reason was compatibility with 2024-11-05 peers that cannot read batches.
- Sources.
2025-03-26/basic/lifecycle.mdx:72“The initialize request MUST NOT be part of a JSON-RPC”.
LV-13 notifications/initialized arriving at a 2026 server
- What. 2026 defines no such notification, so a modern-only receiver sees an unknown notification. Notifications never get a response; on HTTP the server returns 202 if it accepts the POST or a 4xx status.
- Where. Required after
initializein 2024-11-05..2025-11-25; absent in 2026-07-28. - mcpx @ 05c78b2. Accepted silently, as are bare
initializednotifications (internal/mcpserver/server.go:540-541). The mcpx client sends it only after a legacyinitializesucceeded, so it never sends it to a modern-only server. - Value to mcpx. + low: correct for a dual-era server.
- Effort. S — done.
- Risk. None.
- Detail. 2026 HTTP says header requirements for notification POSTs are “not defined by this revision”, so a
modern-only HTTP server might 400 it for a missing
Mcp-Method; a legacy client would already have failed atinitialize. - Sources.
schema/2026-07-28/schema.ts:3166;2026-07-28/basic/index.mdx:160“The receiver MUST NOT send a response.”;2026-07-28/basic/transports/streamable-http.mdx:102;internal/mcpserver/server.go:540.
LV-14 instructions: InitializeResult → DiscoverResult
- What. Optional natural-language guidance from the server about how to use it. Legacy servers put it in
InitializeResult.instructions; 2026 servers inDiscoverResult.instructions. - Where. Present in every legacy schema including 2024-11-05, although the lifecycle prose first shows it in
2025-03-26. 2026-07-28 moves it to
DiscoverResult. opencode v1 and v2 render server instructions inside<mcp_instructions>in the system prompt; v2 also reads them fromserver/discoverand adds a code-mode hint. - mcpx @ 05c78b2. Sent in both handshakes (
internal/mcpserver/server.go:526,:537); the client stores either (internal/mcpclient/client.go:274,:322). - Value to mcpx. + high: mcpx’s whole pitch (use
mcpx_exec, not a hundred tools) reaches the model this way, and opencode puts it in front of the model on every turn. - Effort. S — done.
- Risk. Long instructions cost tokens on every opencode turn. A 2026 client that never calls
server/discover(it is optional for clients) never sees them. - Detail. 2026 tightens the wording: instructions “should not duplicate information already in tool descriptions”. opencode v1 drops servers whose tools are all permission-disabled; v2 delivers instruction changes as diffs. The schema-vs-prose gap for 2024-11-05 matters only to someone implementing from prose.
- Sources.
schema/2024-11-05/schema.ts:172“instructions?: string;”;2025-03-26/basic/lifecycle.mdx:103;schema/2026-07-28/schema.ts:696;internal/mcpserver/server.go:526;internal/mcpclient/client.go:322;v1:packages/opencode/src/session/system.ts:129;v2:packages/core/src/mcp/instructions.ts:23;v2:packages/core/src/mcp/client.ts:249.
LV-15 Implementation.title
- What.
Implementation(the type ofclientInfoandserverInfo) gains an optional displaytitlebeside the programmaticname, throughBaseMetadata. - Where.
{name, version}only in 2024-11-05 and 2025-03-26;{name, title?, version}from 2025-06-18. - mcpx @ 05c78b2. Sends
{name, version}as server and client (internal/mcpserver/server.go:525,internal/mcpclient/modern.go:84). - Value to mcpx. + low: a UI nicety.
- Effort. S.
- Risk. None; optional.
- Detail. Send conservatively: no
titleto a 2024-11-05 or 2025-03-26 peer (harmless in practice, since the object is open). - Sources.
schema/2025-06-18/schema.ts:331“export interface Implementation extends BaseMetadata {”;schema/2025-03-26/schema.ts:277“export interface Implementation {”;2025-06-18/basic/lifecycle.mdx:69.
LV-16 Implementation.description
- What. Optional free-text
descriptiononImplementation. - Where. 2025-11-25 and 2026-07-28.
- mcpx @ 05c78b2. Not sent (
internal/mcpserver/server.go:525); the client does not surface an upstream’s. - Value to mcpx. + low: an upstream’s description would improve mcpx’s catalog output.
- Effort. S.
- Risk. None.
- Detail. Of the three
Implementationfields added in 2025-11-25, this is the only one the changelog names. - Sources.
2025-11-25/changelog.mdx:25“Add optionaldescriptionfield toImplementation”;schema/2025-11-25/schema.ts:550.
LV-17 Implementation.icons
- What.
Implementationextends theIconsmixin, so a client or server can publish icons for itself. - Where. 2025-11-25 and 2026-07-28.
- mcpx @ 05c78b2. Not sent; not read from upstreams.
- Value to mcpx. + low.
- Effort. S to send; more to render safely.
- Risk. Rendering upstream icons without the icon rules below.
- Detail. Not named in the changelog; it arrives through the mixin. Consumers MUST reject icon URIs with unsafe
schemes (
javascript:,file:,ftp:,ws:, local app schemes), should fetch without credentials, and should check that icons come from the server’s origin. - Sources.
schema/2025-11-25/schema.ts:550“export interface Implementation extends BaseMetadata, Icons {”;2025-11-25/basic/index.mdx:247;2025-11-25/basic/index.mdx:251;2025-11-25/basic/index.mdx:252.
LV-18 Implementation.websiteUrl
- What. Optional
websiteUrl(@format uri) onImplementation. - Where. 2025-11-25 and 2026-07-28.
- mcpx @ 05c78b2. Not sent (
internal/mcpserver/server.go:525). - Value to mcpx. + low.
- Effort. S.
- Risk. None.
- Detail. The 2025-11-25 changelog never mentions it.
- Sources.
schema/2025-11-25/schema.ts:567“websiteUrl?: string;”.
LV-19 Per-request _meta io.modelcontextprotocol/protocolVersion
- What. Every 2026 request MUST carry its protocol version in
params._meta["io.modelcontextprotocol/protocolVersion"]. There is no negotiation step: the server accepts or rejects each request on its own, and the version of the request is the version of the response. - Where. 2026-07-28. opencode v2’s SDK stamps it on every request of a modern connection.
- mcpx @ 05c78b2. The server reads it (
requestVersion,internal/mcpserver/server.go:813-822) and refuses an unsupported one before dispatch (internal/mcpserver/server.go:469-471; the code iserrors.mdERR-09). The client adds it to every modern request (internal/mcpclient/modern.go:82). - Value to mcpx. + high: the core of the modern era.
- Effort. S — done.
- Risk. None.
- Detail. The key is non-optional in the schema. On HTTP it MUST equal the
MCP-Protocol-Versionheader: the schema doc says “400 Bad Request”, the transport page says 400 with-32020(errors.mdERR-07). Absent means malformed (LV-24). - Sources.
2026-07-28/basic/versioning.mdx:12“There is no negotiation handshake. Every request carries its protocol”;schema/2026-07-28/schema.ts:76;schema/2026-07-28/schema.ts:71-72;internal/mcpserver/server.go:469;internal/mcpclient/modern.go:82; https://unpkg.com/@modelcontextprotocol/core@2.0.0/dist/auth-CUe6YdwF.mjs L24;v2:packages/core/src/mcp/client.ts:248.
LV-20 Per-request _meta io.modelcontextprotocol/clientCapabilities
- What. Client capabilities are declared on every 2026 request;
{}means “no optional capabilities”, and the server MUST NOT carry them over from earlier requests. - Where. 2026-07-28 (legacy declares once, in
initialize). opencode v2’s SDK sends it in the modern era. - mcpx @ 05c78b2. The server builds a per-request
Peerfrom_meta(internal/mcpserver/conn.go:133-136,:150-166); if the key is missing the capabilities are nil and the request is served rather than rejected (LV-24). The client always sends its own (internal/mcpclient/modern.go:83). - Value to mcpx. + high: one connection can carry many agents with different capabilities.
- Effort. S.
- Risk. A proxy that caches capabilities per connection breaks the MUST NOT.
- Detail. The mcpx client declares
roots: {listChanged: false}in the modern era (internal/mcpclient/modern.go:52) although 2026rootsis{}and deprecated; the shape of what mcpx declares is in the capabilities register. Because capabilities are per request, mcpx could declare upstream only what the originating host declared (for example url-mode elicitation); today its declaration is fixed per client. - Sources.
schema/2026-07-28/schema.ts:96“Servers MUST NOT infer capabilities from prior requests.”;2026-07-28/basic/index.mdx:377;internal/mcpserver/conn.go:133;internal/mcpclient/modern.go:83.
LV-21 Per-request _meta io.modelcontextprotocol/clientInfo
- What. Client identity moves to optional per-request
_meta; clients SHOULD send it on every request unless configured not to. It is self-reported and display-only (meta.mdMETA-08). - Where. 2026-07-28. opencode v2’s SDK sends it in the modern era.
- mcpx @ 05c78b2. The client sends it on every modern request (
internal/mcpclient/modern.go:84). The server defines the key (internal/mcpserver/conn.go:174) but never reads it. - Value to mcpx. + low: attributing upstream calls per agent in logs is allowed; changing behaviour on it is not.
- Effort. S.
- Risk. Using it for authorization or isolation would break the SHOULD NOT.
- Detail. SEP-2575 had it required; PR #3002 made it optional after the SEP reached Final.
- Sources.
2026-07-28/basic/index.mdx:384“Clients SHOULD includeio.modelcontextprotocol/clientInfoon every request”;seps/2575-stateless-mcp.md:791;internal/mcpclient/modern.go:84;internal/mcpserver/conn.go:174.
LV-22 io.modelcontextprotocol/serverInfo in every result’s _meta
- What. A 2026 server SHOULD identify itself on every result under
result._meta["io.modelcontextprotocol/serverInfo"],DiscoverResultincluded. - Where. 2026-07-28.
- mcpx @ 05c78b2. Never emitted:
HandleOnadds onlyresultType(internal/mcpserver/server.go:432-436; wire W6, W7, W8). The client reads identity only from a top-level field (LV-23). - Value to mcpx. + med: a modern client that wants to show which server answered finds nothing, and with LV-23 it has no identity from mcpx at all.
- Effort. S — stamp it where
resultTypeis stamped, for modern peers only. - Risk. Low (SHOULD).
- Detail. Legacy results must not get it. PR #3002 removed
DiscoverResult.serverInfo“to avoid duplicate representations”. The 2026 reserved-key table omits this key although the schema defines it (meta.mdMETA-06). - Sources.
2026-07-28/basic/index.mdx:396“Servers SHOULD include the followingio.modelcontextprotocol/*field in”;schema/2026-07-28/schema.ts:157;seps/2575-stateless-mcp.md:795;2026-07-28/changelog.mdx:14;internal/mcpserver/server.go:432-436; wire W6.
LV-23 DiscoverResult has no top-level serverInfo
- What. 2026’s
DiscoverResultdefinessupportedVersions,capabilities,instructionsand the cache fields. Server identity belongs in result_meta(LV-22), not in aserverInfofield. - Where. 2026-07-28.
- mcpx @ 05c78b2. The server sends a top-level
serverInfo(internal/mcpserver/server.go:535; wire W5, W9) and the client reads it from there (internal/mcpclient/client.go:285,:320). - Value to mcpx. + low on its own; it is the other half of LV-22.
- Effort. S.
- Risk. A validator that treats the result as closed rejects it; an open one ignores it and finds no identity.
- Detail. Sending a field the peer’s revision does not define is a send-conservatively conflict.
- Sources.
schema/2026-07-28/schema.ts:678-697;internal/mcpserver/server.go:535;internal/mcpclient/client.go:285; wire W5.
LV-24 A 2026 request missing required _meta is malformed
- What. A 2026 request without
protocolVersionorclientCapabilitiesin_metais malformed; the server MUST reject it with-32602, and on HTTP with status 400. - Where. 2026-07-28.
- mcpx @ 05c78b2. Not rejected. No version: treated as legacy at 2025-03-26
(
internal/mcpserver/conn.go:134-146). Version present but no capabilities: capabilities nil, request served (internal/mcpserver/conn.go:150-166). Wire W9: a bareserver/discoverwith no_metaand no headers was answered like W5 and issued a session. - Value to mcpx. + med: conformance for modern peers. − rejecting every request without
_metawould break lenient legacy clients that skipinitialize, so reject only whenprotocolVersionis present andclientCapabilitiesis not. - Effort. S.
- Risk. As is, a modern client with a bug silently gets legacy-shaped results.
- Detail. 2026 defines behaviour for “a request carrying modern
_meta” and for “aninitializerequest”; a dual-era server receiving neither is not covered (see the process-model register). On HTTP, a server supporting pre-2025-06-18 clients MAY treat a missing version header as 2025-03-26, which matches mcpx’s default. The same malformed request can also draw-32020for the missing header; the order is unspecified (errors.mdERR-14). - Sources.
2026-07-28/basic/index.mdx:380“A request missing any required field is malformed; the server MUST reject it with”;2026-07-28/basic/index.mdx:381;internal/mcpserver/conn.go:144; wire W9.
LV-25 server/discover MUST be implemented
- What. Every 2026 server MUST answer
server/discover, which returns supported versions, capabilities, optional instructions and identity. Clients MAY call it; it is the recommended stdio probe (LV-27). The request carries nothing but_meta, yetparamsitself is required. - Where. 2026-07-28. opencode v2 calls it when a server’s
protocolisautoor pinned to2026-07-28. - mcpx @ 05c78b2. Answered in any era (
internal/mcpserver/server.go:529-538; wire W5, W9), with capabilities in the 2026 shape. The client probes it (internal/mcpclient/client.go:296). - Value to mcpx. + high: without a conformant answer no modern client can learn what mcpx speaks.
- Effort. S — the field fixes are LV-23 and LV-26.
- Risk. High while the field names are wrong: modern interop is broken in both directions.
- Detail.
DiscoverResult extends CacheableResult, sottlMsandcacheScopeare required; mcpx sends neither, and the 2026 changelog’s cacheable-result item forgets to listserver/discover(both covered in the caching register). The capability object mcpx puts in the answer, including a coretasksobject 2026 does not define, is covered in the capabilities register. Answering it on a legacy connection is accept-liberally. - Sources.
2026-07-28/server/discover.mdx:8“capabilities, and identity before sending any other requests. Servers MUST”;schema/2026-07-28/schema.ts:665-668;schema/2026-07-28/schema.ts:678“export interface DiscoverResult extends CacheableResult {”;2026-07-28/changelog.mdx:36;internal/mcpserver/server.go:529-538;v2:packages/core/src/mcp/client.ts:313.
LV-26 DiscoverResult.supportedVersions
- What. The version list in
DiscoverResultissupportedVersions: string[]; the client picks one for its subsequent requests. - Where. 2026-07-28.
- mcpx @ 05c78b2. Both halves use the wrong name. The server answers
"protocolVersions": Supported(internal/mcpserver/server.go:534); the client parsesprotocolVersions(internal/mcpclient/client.go:284), so against a conformant server its list is empty and it fails with “no shared protocol version; the server offers []” (internal/mcpclient/client.go:314-318). Tests pin the wrong name on both sides (internal/mcpserver/server_test.go:393,internal/mcpclient/era_test.go:87,internal/mcpclient/modern_test.go:88). Wire W5, W9. - Value to mcpx. + high: this is the whole “mcpx as a modern server” and “mcpx as a modern client” path.
- Effort. S, plus the three tests.
- Risk. High: mcpx↔mcpx works only because both halves are wrong the same way; every conformant modern peer fails.
- Detail. The client’s selection logic is right (newest mutually supported version, not the first listed); only
the key is wrong. The stdio probe text says to “Select a mutually supported version from
supportedVersions”. The nameprotocolVersionsmay come from an early SEP draft. Over HTTP a modern upstream fails even earlier, on the missingMcp-Methodheader (transports.mdTR-37). - Sources.
schema/2026-07-28/schema.ts:683“supportedVersions: string[];”;internal/mcpserver/server.go:534“"protocolVersions": Supported,”;internal/mcpclient/client.go:284;internal/mcpclient/client.go:314-318; wire W5.
LV-27 stdio probe order: server/discover before initialize
- What. A dual-era stdio client SHOULD send
server/discover, with its preferred modern version, before anything else: aDiscoverResultmeans modern; a recognised modern error means modern (retry with a version fromsupported, do not fall back); any other error or a timeout means legacy (fall back toinitialize). Modern-only clients are also recommended to probe. - Where. 2026-07-28. opencode v2 with
protocol: "auto"probesserver/discoverand falls back to legacy; its default is plain legacy. - mcpx @ 05c78b2. The default is the reverse,
initializefirst (internal/mcpclient/client.go:184,:231-232); modern-first is opt-in per server (LV-32). The comment atinternal/mcpclient/client.go:193-197defers the flip: “Legacy first is correct today and will stop being correct, which is why it is configurable”.docs/protocol.md:267gives the same reason. - Value to mcpx. + med: the spec’s SHOULD. − legacy first saves a round trip on today’s mostly legacy servers.
- Effort. S to flip the default, once LV-26 and LV-29 are fixed.
- Risk. A modern-only stdio server that treats a bad
initializeas fatal and exits makes legacy-first fail where probe-first works; with LV-29 unfixed, legacy-first is exactly what makes spec-following modern servers unreachable. - Detail. Legacy servers answer an unknown pre-
initializerequest with-32601,-32602or nothing, hence “or times out”. The HTTP equivalent is LV-30. Cache the result (LV-31). - Sources.
2026-07-28/basic/transports/stdio.mdx:124“legacy version that requires aninitializehandshake SHOULD probe with”;internal/mcpclient/client.go:193-197;internal/mcpclient/client.go:232;docs/protocol.md:267;v2:packages/schema/src/mcp.ts:20;v2:packages/core/src/mcp/client.ts:313.
LV-28 Which probe errors mean “modern server”
- What. A recognised modern error (for example
-32022) identifies a modern server; everything else is treated as legacy, and the legacy fallback MUST NOT be keyed to one specific error code. - Where. 2026-07-28.
- mcpx @ 05c78b2.
isVersionErrorisstrings.Contains(err.Error(), "-32022")(internal/mcpclient/client.go:327-331). A modern server’s-32020or-32021therefore triggers a legacy fallback, which a modern-only server also rejects. Whenserver/discoveritself draws-32022, the client aborts rather than retrying with a version fromdata.supported; that is moot today because mcpx knows one modern version (internal/mcpclient/client.go:26). - Value to mcpx. + med: correct era decisions against modern servers that reject for reasons other than the version.
- Effort. S — parse the error code and treat
-32020..-32022as modern. - Risk. Wrong-era fallback and a misleading reported error.
- Detail. A substring test also matches
-32022quoted anywhere in an error message. Pre-release SDKs that still send-32004are not recognised, which is the reading the 2026 allocation policy requires anyway (errors.mdERR-11). - Sources.
2026-07-28/basic/transports/stdio.mdx:139“The fallback MUST NOT be keyed to one specific error code: legacy servers”;internal/mcpclient/client.go:330“strings.Contains(err.Error(), "-32022")”;internal/mcpclient/client.go:26.
LV-29 Modern-only server refusing initialize; mcpx client aborts
- What. A modern-only server rejects
initializewith an implementation-defined JSON-RPC error and SHOULD name the versions it supports, since a legacy client has no way to fall forward. A dual-era client that tried legacy first should fall forward toserver/discover. - Where. 2026-07-28 versioning page and its compatibility matrix.
- mcpx @ 05c78b2.
newClientchecksisVersionErrorafter the first attempt regardless of which era it was (internal/mcpclient/client.go:244-249); the comment there assumes the first attempt was modern. So a modern server that answersinitializewith-32022, as mcpx’s own server does, makes the client give up. The era tests use a fake that answersinitializewith-32601, so the case is untested (internal/mcpclient/era_test.go:66-69). - Value to mcpx. + high: reachability of every modern-only server that follows the SHOULD.
- Effort. S — short-circuit only when the first attempt was modern.
- Risk. Not done: mcpx cannot connect to spec-following modern-only servers with its default settings.
- Detail. Over HTTP the mcpx client’s
initializecarriesMCP-Protocol-Versionbut noMcp-Method, so a modern-only HTTP server rejects it with 400 before method dispatch. If its header check runs first the body is-32020, which does not contain-32022, so the client does fall forward, and then fails again on the missingMcp-Method(transports.mdTR-37); if its version check runs first the body is-32022and the client aborts as above. The spec does not order the two checks (errors.mdERR-14). - Sources.
2026-07-28/basic/versioning.mdx:154“A server that supports only modern versions SHOULD name”;2026-07-28/basic/versioning.mdx:170;internal/mcpclient/client.go:246“if isVersionError(err) {”;internal/mcpclient/era_test.go:66-69.
LV-30 HTTP era detection from the 400 body
- What. On Streamable HTTP a dual-era client MAY send a modern request first. On a 400 it SHOULD inspect the body:
a recognised modern JSON-RPC error means modern (fix and retry); an empty or unrecognised body means fall back to
initialize, and possibly further to HTTP+SSE (transports.mdTR-11). - Where. 2026-07-28.
- mcpx @ 05c78b2. The client uses the stdio logic (legacy first) and never consults the status for era
(
internal/mcpclient/client.go:235-258). A non-2xx answer becomes an error carrying the status and up to 4 KiB of body (internal/mcpclient/http.go:136-140), whichisVersionErrorthen greps. - Value to mcpx. + med: as a server, mcpx must return 400 with a JSON-RPC body for modern errors for this to work
in its favour; it returns 200 (
errors.mdERR-13) and a plain JSON body for header mismatches (errors.mdERR-07). - Effort. S.
- Risk. Dual-era clients misclassify mcpx, and mcpx misclassifies servers that reject for header reasons.
- Detail. Modern servers use 400 for
-32602(missing_meta),-32020,-32021and-32022, and 404 with-32601for unknown methods. - Sources.
2026-07-28/basic/transports/streamable-http.mdx:654“era the server implements by attempting a modern request first. On”;2026-07-28/basic/transports/streamable-http.mdx:660;internal/mcpclient/http.go:136-140.
LV-31 Era is a property of the server; cache it
- What. Era belongs to the server, not the request. Clients SHOULD cache it for the life of a stdio process or per HTTP origin, MAY persist it across restarts, and re-probe if the assumption fails.
- Where. 2026-07-28.
- mcpx @ 05c78b2.
Client.Erais set once per connection (internal/mcpclient/client.go:237,:257); each new client, one per pool instance, probes again, and nothing persists across daemon restarts./v1/protocolreports each server’s era, negotiated version and preference (internal/daemon/routes_proto.go:515). - Value to mcpx. + low: mcpx spawns many instances of one server, and each repeats the probe.
- Effort. S.
- Risk. None.
- Detail. For HTTP the key is the origin, not the URL path.
- Sources.
2026-07-28/basic/versioning.mdx:148“The era determination is a property of the server, not of an individual”;internal/mcpclient/client.go:237;internal/daemon/routes_proto.go:515.
LV-32 Per-server era setting
- What. A per-upstream setting that chooses or forces the era.
- Where. mcpx:
protocol: legacy | modern | force-legacy | force-modern. opencode v2:protocol: "legacy" | "auto" | "2026-07-28", default legacy; a pinned version the server does not support fails with a message naming the versions it does, and a pin must be a modern revision. opencode v1 has no such option. - mcpx @ 05c78b2. Parsed at
internal/config/config.go:142-146and mapped atinternal/pool/pool.go:636-646, whosedefault:branch turns any unknown value, typos included, intolegacy; config loading validates only command and URL (internal/config/config.go:701-708). - Value to mcpx. + low: lets a user pin an era; typos should fail loudly.
- Effort. S — validate at load.
- Risk. Low: a misspelt
protocolsilently probes legacy first. - Detail.
docs/opencode-plugin.md:187says opencode v2 “negotiating2026-07-28per server” will exercise mcpx’s modern path; that happens only when the user setsprotocolfor mcpx, because v2 defaults to legacy. - Sources.
internal/pool/pool.go:645“return mcpclient.PreferLegacy”;internal/config/config.go:142-146;v2:packages/schema/src/mcp.ts:20“Schema.Literals(["legacy", "auto", "2026-07-28"])”;v2:packages/schema/src/mcp.ts:23;docs/opencode-plugin.md:187.
LV-33 Dual-era server: era chosen per opening message
- What. A request carrying modern
_metais served statelessly under 2026; aninitializeselects legacy semantics scoped to that stdio process or HTTP session; a dual-era server MAY serve both eras concurrently on one endpoint or process. - Where. 2026-07-28.
- mcpx @ 05c78b2. The peer is resolved per request, from
_metafirst and the connection’s handshake otherwise (internal/mcpserver/conn.go:133-147); legacy capabilities are stored per connection (internal/mcpserver/conn.go:101-105). - Value to mcpx. + high: this is the “speak every revision” goal.
- Effort. S — done.
- Risk. None.
- Detail. The rule is per request, so on stdio a request with modern
_metathat follows aninitializeon the same process is still served statelessly; mcpx does this because_metais checked first. The gap for a request with neither is covered in the process-model register. - Sources.
2026-07-28/basic/versioning.mdx:176“- A request carrying modern per-request_metais served statelessly”;2026-07-28/basic/versioning.mdx:182;2026-07-28/basic/versioning.mdx:130;internal/mcpserver/conn.go:133.
LV-34 ping received from a modern client
- What. Legacy
ping(either side; the receiver MUST answer promptly with{}) is removed in 2026 in both directions: servers cannot send requests, any request proves liveness client to server, and transport keep-alives do the rest. - Where. 2024-11-05..2025-11-25. Absent from the 2026 schema altogether: removed, not deprecated.
- mcpx @ 05c78b2. The server answers
pingin any era (internal/mcpserver/server.go:543-544); a modern one gets{resultType: "complete"}(wire W7). - Value to mcpx. + low: harmless accept-liberally.
- Effort. S — done.
- Risk. None;
docs/protocol.md:78is the statement of intent. - Detail. A modern-only server answers
pingwith-32601(404 on HTTP). 2026 recommends SSE comment lines as keep-alive on long streams (transports.mdTR-24). - Sources.
2024-11-05/basic/utilities/ping.mdx:28“1. The receiver MUST respond promptly with an empty response:”;2026-07-28/changelog.mdx:20“Removeping,logging/setLevel, andnotifications/roots/list_changed.”;seps/2575-stateless-mcp.md:569;internal/mcpserver/server.go:543; wire W7.
LV-35 ping sent by a client
- What. A client may ping a legacy server as a liveness check; against a 2026 server the method does not exist.
- Where. 2024-11-05..2025-11-25. lootbox’s health monitor pings every server periodically and reconnects with backoff (fork only; the deployed upstream build has no monitor).
- mcpx @ 05c78b2.
Client.Pingsendspingregardless of era (internal/mcpclient/client.go:726-728), but nothing in the pool or daemon calls it. The client does answer a server’spingin any era (internal/mcpclient/modern.go:110-115), which it once answered with method-not-found. - Value to mcpx. + low: a probe could catch a wedged stateful server. − pinging an idle, lazily started server
keeps it alive and defeats
idleTimeout; pinging a modern upstream gets-32601, a false “dead”. - Effort. S — gate
Ping()on the legacy era if it is ever used. - Risk. Low while unused.
- Detail. For a modern upstream the equivalent check is
server/discoveror nothing. - Sources.
internal/mcpclient/client.go:726-728;internal/mcpclient/modern.go:110-115;.lootbox/README.md:385-388“Lootbox automatically monitors MCP server health with periodicping()”.
LV-36 resultType required on every 2026 result
- What. Every 2026 result MUST carry
resultType:"complete","input_required", or an extension value advertised through capabilities. An unrecognised value MUST be treated as invalid; an absent one (legacy servers) MUST be treated as"complete". - Where. 2026-07-28.
- mcpx @ 05c78b2. Stamped
"complete"on modern results that lack one (internal/mcpserver/server.go:432-436,:444-456), deleted below 2026 (internal/mcpserver/revisions.go:163-168); wire W6, W7. The client reads it to spotinput_required(internal/mcpclient/client.go:663-665). - Value to mcpx. + high: done.
- Effort. S — done.
- Risk. None for ordinary results.
- Detail. The TypeScript type is open (
"complete" | "input_required" | string) while the prose makes unknown values invalid. Error responses carry noresultType, correctly. The task-creation result stamped"complete"where the tasks extension says"task"is in the tasks register. - Also recorded from the content types register. Clients treat an absent value as
"complete"and an unrecognised one as invalid. The server stampscompletefor modern peers (internal/mcpserver/server.go:432-436) anddowngrade()deletes the field for anyone below 2026-07-28 (internal/mcpserver/revisions.go:163-168). One of the two branches that fire in production (CT-12). The deletion happens on a copy (internal/mcpserver/revisions.go:139-145), so a stored task result handed out twice is not mutated. - Sources.
2026-07-28/basic/index.mdx:73“TheresultMUST include aresultTypefield”;2026-07-28/basic/index.mdx:84;schema/2026-07-28/schema.ts:234“resultType: ResultType;”;schema/2026-07-28/schema.ts:216;internal/mcpserver/server.go:452-454.;2026-07-28/basic/index.mdx:85“clients MUST treat an absentresultTypeas\"complete\".”;internal/mcpserver/server.go:439“resp.Result = downgrade(resp.Result, peer.Version)”
LV-37 Request-id uniqueness: per session → among in-flight
- What. Legacy: a request id MUST NOT have been used before by the requestor within the same session. 2026: an id MUST NOT match any other request the sender has issued and not yet had answered.
- Where. Session-scoped in 2024-11-05..2025-11-25; in-flight-scoped in 2026-07-28.
- mcpx @ 05c78b2. Server-initiated ids are negative so they cannot collide with a client’s
(
internal/mcpserver/conn.go:239-244). - Value to mcpx. + low: a pooled proxy may reuse ids after completion under 2026, never under legacy.
- Effort. S.
- Risk. None.
- Detail.
subscriptionIdis the listen request’s id, so reusing an id while a listen stream is open is forbidden anyway (it is still in flight). MRTR retries MUST use a new id. Ids MUST NOT benullin every revision. - Sources.
2024-11-05/basic/messages.mdx:28“- The request ID MUST NOT have been previously used by the requestor within the same”;2026-07-28/basic/index.mdx:48;seps/2567-sessionless-mcp.md:168;internal/mcpserver/conn.go:240.
LV-38 stdio server exits promptly on stdin EOF
- What. 2026 makes stdin EOF the primary portable shutdown signal: a server SHOULD exit promptly when its standard input is closed.
- Where. 2026-07-28 stdio page (legacy revisions describe only the client’s side, LV-39).
- mcpx @ 05c78b2.
mcpx serveexited at stdin EOF in every recorded stdio run; the read loop ends when the scanner does (internal/mcpserver/server.go:1000-1073). - Value to mcpx. + med: hosts that only close the pipe get a clean exit.
- Effort. S — done.
- Risk. None.
- Detail. 2026 also gives Windows equivalents for the signals.
- Sources.
2026-07-28/basic/transports/stdio.mdx:102“Servers SHOULD exit promptly when their standard input is closed or reads”;internal/mcpserver/server.go:1000-1073; wire S1–S5.
LV-39 Client stdio shutdown: close stdin, wait, SIGTERM, SIGKILL
- What. The client SHOULD close the server’s stdin, wait for it to exit, send SIGTERM if it does not exit in reasonable time, then SIGKILL.
- Where. Legacy lifecycle pages; the 2026 stdio page keeps the sequence. opencode v2 follows it (close stdin,
wait 2 s, SIGTERM, SIGKILL after 2 s, whole process group). opencode v1 instead SIGTERMs descendants found with
pgrep -Pand closes the client, and only on instance disposal. - mcpx @ 05c78b2. Closes stdin and immediately SIGTERMs the process group, then SIGKILLs after
plumbing.stdioDrainGrace(3 s) (internal/mcpclient/stdio.go:197-209;internal/defaults/defaults.json:40). - Value to mcpx. + low: servers that flush state on EOF get their chance.
- Effort. S — wait for exit (bounded by the grace setting) before SIGTERM.
- Risk. A server that saves on stdin EOF may be killed mid-write.
- Detail. Using the process group means grandchildren die too, which matters for servers that fork a browser
(
internal/mcpclient/stdio.go:69). - Sources.
2024-11-05/basic/lifecycle.mdx:178“1. First, closing the input stream to the child process (the server)”;2025-11-25/basic/lifecycle.mdx:233-236;internal/mcpclient/stdio.go:202;v2:packages/core/src/mcp/stdio.ts:10;v2:packages/core/src/mcp/stdio.ts:57;v1:packages/opencode/src/mcp/index.ts:541;v1:packages/opencode/src/mcp/index.ts:546.
LV-40 Restart a stdio server that exited unexpectedly
- What. 2026: if the server process exits unexpectedly the client SHOULD restart it; in-flight requests are lost
and listen streams are re-established. Statelessness makes the restart free; a legacy server needs a fresh
initialize. - Where. 2026-07-28. lootbox’s fork reconnects with exponential backoff behind a circuit breaker. opencode v1 and v2 never respawn: the server is marked failed (“Connection closed”), its tools disappear, and recovery is a manual reconnect (v2 also reconnects when that server’s integration credential changes).
- mcpx @ 05c78b2. Dead instances are reaped (
internal/pool/pool.go:344-356) and the next call starts a fresh one, which probes and initializes again; a failed start sets a cooldown that grows byplumbing.restartBackoffStep(2 s) up toplumbing.restartBackoffMax(30 s) (internal/pool/pool.go:205-208;internal/defaults/defaults.json:49-50). - Value to mcpx. + med: as a client this works. As a server it matters the other way round: if
mcpx servedies, opencode loses mcpx until the user reconnects, so the stdio side should outlive daemon restarts. - Effort. S.
- Risk. A daemon upgrade that kills
mcpx servesilently removes mcpx from every open opencode session. - Detail. The cap is triggered by an inline
30*time.Secondrather than by the setting (internal/pool/pool.go:206): the computed backoff is replaced byrestartBackoffMaxonly once it exceeds 30 s, so arestartBackoffMaxbelow 30 s is not a cap. - Sources.
2026-07-28/basic/transports/stdio.mdx:111“If the server process exits unexpectedly, the client SHOULD restart it.”;internal/pool/pool.go:344-356;internal/pool/pool.go:205-208;v1:packages/opencode/src/mcp/index.ts:448;v2:packages/core/src/mcp/index.ts:355;.lootbox/README.md:385-388.
Logging
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: logging/setLevel, notifications/message, log levels and the 2026 per-request logLevel, across revisions and products
Logging is the server-to-client notifications/message stream and the knobs that control it. 2026-07-28 changes it more than
its size suggests: it removes logging/setLevel, moves the level into each request’s _meta, flips the default from “the
server decides” to “send nothing”, and deprecates the whole feature in the same revision. For mcpx the two things that
matter are that its client still sends the removed setLevel to modern upstreams, and that its own code comment and
protocol doc describe the 2026 rule backwards. Request-scoped delivery of notifications/message (never on a listen
stream) is one rule shared with progress and is registered once, in notifications.md.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| LOG-01 | logging/setLevel request removed | 2024-11-05 has 2026-07-28 removes has better replacement | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — | acc. — answers {} in every era, does nothing | + low | S | low |
| LOG-02 | Per-request _meta io.modelcontextprotocol/logLevel | 2026-07-28 has 2026-07-28 deprecates mcpx missing specs conflict | 26-07 ✓ (dep.) | ✗ — never sent upstream, never read from hosts (#200) | + low | S | low |
| LOG-03 | No logLevel means no log messages at all | 2026-07-28 has specs conflict | 24-11..25-11: server decides · 26-07: MUST NOT emit | ✓ — emits none; code comment states the opposite | + low | S | med |
| LOG-04 | Invalid log level rejects the whole request | 2026-07-28 has | 24-11..25-11: setLevel -32602 · 26-07: any request -32602 | partial — setLevel never validates; _meta level ignored | + low | S | low |
| LOG-05 | RFC 5424 log levels unchanged | 2024-11-05 has 2026-07-28 deprecates | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 dep. | n/a — emits none; client only ever asks info | − moot | S | low |
| LOG-06 | Logging feature deprecated (SEP-2577) | 2026-07-28 deprecates | 26-07 dep.; removable from 2027-07-28 | ✓ — nothing declared or emitted | − deprecated | S | low |
| LOG-07 | mcpx client sends setLevel info to every logging upstream | 2026-07-28 removes mcpx missing | mcpx only; opencode v1/v2 never send setLevel | ✗ — removed method sent to modern upstreams; level hard-coded (#200) | + med | S | low |
| LOG-08 | Relaying upstream log messages to an MCP host | mcpx missing 2026-07-28 deprecates | spec: any server may emit; mcpx: event bus only | ✓ — relayed to a host that set a level, during its calls (#212; docs/protocol.md §4.4) | + low | M | low |
| LOG-09 | Client handling of notifications/message | opencode v1 has has no replacement | opencode v1 ✓ · opencode v2 ✗ | n/a — mcpx sends none to hosts | − moot | S | low |
LOG-01 logging/setLevel request removed
- What. The session-wide “send me logs at this level and above” request is gone in 2026-07-28; the level now travels on each request (LOG-02).
- Where. 2024-11-05 through 2025-11-25; removed in 2026-07-28. opencode v1 and v2 never send it
(
v1:packages/opencode/src/mcp/index.ts:164;v2:packages/core/src/mcp/client.ts:91). - mcpx @ 05c78b2. Accepted from every era and answered
{}(plusresultTypefor a 2026 caller), with no effect and nothing forwarded upstream (internal/mcpserver/server.go:565-572). Wire W25: a 2026logging/setLevelgets{"resultType":"complete"}. - Value to mcpx. + low: accept-liberally is the right call; the handler’s comment says refusing “would make a well-behaved client that asked anyway treat the whole connection as degraded”.
- Effort. S — done.
- Risk. None as is.
- Detail. Params are
{level: LoggingLevel}. The legacy rule was “all logs at this level and higher”. mcpx answers without parsing the params, so an invalid level is not rejected (LOG-04). TheFeatLoggingSetLevelcomment that justifies the removal is wrong about the replacement (LOG-03). - Sources.
schema/2025-11-25/schema.ts:1520“logging/setLevel”;2026-07-28/changelog.mdx:20“Remove”;schema/2024-11-05/schema.ts:714“The server should send all logs at this level and higher”;internal/mcpserver/server.go:572“return reply(map[string]any{})”; wire W25.
LOG-02 Per-request _meta io.modelcontextprotocol/logLevel
- What. An optional
LoggingLevelin a request’s_metaopts that one request intonotifications/messageat or above the level. It is the replacement forsetLevel, and it is itself deprecated along with the rest of Logging. - Where. 2026-07-28 only; listed in the reserved
_metakey table. - mcpx @ 05c78b2. Never sent: no
logLevelanywhere ininternal/mcpclient, so a 2026 upstream can never send mcpx a log message. As a server mcpx ignores it on host requests (it emits nothing anyway). - Value to mcpx. + low: if upstream logs are wanted in the event bus from modern servers, the level has to ride on every forwarded request. Deprecated, so not worth more than that.
- Effort. S — one
_metakey inwithMeta, fed from the per-serverlogLevelsetting that already exists (LOG-07). - Risk. Without it, upstream diagnostics from 2026 servers are lost.
- Detail. There is no way to set a minimum once; a proxy that wants logs must add the key to each forwarded request.
A proxy relaying a host’s own level should forward exactly what the host sent, because adding a level the host did not
ask for makes the upstream emit what the host did not request (LOG-03). The key is simultaneously the replacement for
setLeveland@deprecatedin the same schema. - Also recorded from the meta register. The server emits no
notifications/messageat all, so it is compliant by omission (internal/mcpserver/server.go:565-572), but the comment atinternal/mcpserver/revisions.go:72-73sayssetLevelwas removed “in favour of servers emitting at a level of their own choosing”, the opposite of the spec. The client never sets the key upstream, so a 2026 upstream never sends mcpx logs; what it sends instead is in the logging register. Being the replacement and deprecated at once is why this row carriesspecs conflict. Logging behaviour is in the logging register. - Sources.
schema/2026-07-28/schema.ts:110“"io.modelcontextprotocol/logLevel"?: LoggingLevel;”;schema/2026-07-28/schema.ts:106“@deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577).”;2026-07-28/basic/index.mdx:356“Minimum log level the server should emit for a request”; https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging.;2026-07-28/server/utilities/logging.mdx:63;2026-07-28/server/utilities/logging.mdx:100;internal/mcpserver/revisions.go:73“// favour of servers emitting at a level of their own choosing.”.
LOG-03 No logLevel means no log messages at all
- What. Legacy: if the client never sent
setLevel, the server MAY decide which messages to send. 2026: if a request carries nologLevel, the server MUST NOT sendnotifications/messagefor it. The default flips from “server decides” to “none”. - Where. Legacy default in 2024-11-05..2025-11-25; the inverse in 2026-07-28.
- mcpx @ 05c78b2. Compliant by omission: it emits no
notifications/message(internal/mcpserver/server.go:566-568). But the feature table comment says 2026 removedsetLevel“in favour of servers emitting at a level of their own choosing” (internal/mcpserver/revisions.go:72-73), which is the legacy default presented as the 2026 one. - Value to mcpx. + low: fix the comment before someone builds on it.
- Effort. S — two comment lines.
- Risk. med: the wrong model leads straight to forwarding unsolicited upstream logs to 2026 hosts, which violates a MUST.
- Detail. This is a behaviour inversion, not just a method removal. A dual-era proxy must drop legacy upstream log
messages for a 2026 host unless that host’s request carried
logLevel. The schema says it twice: the_metadoc (“If absent, the server MUST NOT send any”) and the logging page. - Sources.
schema/2025-11-25/schema.ts:1545“the server MAY decide which messages to send automatically”;2026-07-28/server/utilities/logging.mdx:63“The server MUST NOT”;schema/2026-07-28/schema.ts:102“If absent, the server MUST NOT send any”;internal/mcpserver/revisions.go:73“favour of servers emitting at a level of their own choosing.”.
LOG-04 Invalid log level rejects the whole request
- What. Legacy: an unknown level in
logging/setLevelgets-32602. 2026: an unknownlogLevelin a request’s_metaSHOULD fail that whole request with-32602, whatever the request was. - Where.
-32602onsetLevelin 2024-11-05..2025-11-25; on any request in 2026-07-28. - mcpx @ 05c78b2.
logging/setLevelreturns{}without reading its params (internal/mcpserver/server.go:565-572), so a bad level is accepted. A 2026_metalevel is not read at all, so it cannot fail a request. - Value to mcpx. + low: as a proxy, the useful rule is the reverse one: never forward a level the upstream will reject, because that fails an unrelated tool call.
- Effort. S.
- Risk. A typo in a logging hint failing a tool call, if mcpx ever forwards levels unchecked.
- Detail. SHOULD, so a server may also ignore the bad value. Validation belongs where mcpx adds or forwards the key (LOG-02), against the eight RFC 5424 names (LOG-05).
- Sources.
2024-11-05/server/utilities/logging.mdx:110“Invalid log level”;2026-07-28/server/utilities/logging.mdx:100“value carried in a request’s”;2026-07-28/server/utilities/logging.mdx:101“the server SHOULD reject that”.
LOG-05 RFC 5424 log levels unchanged
- What.
debug,info,notice,warning,error,critical,alert,emergency, identical in all five revisions. TheLoggingLeveltype is marked deprecated in 2026. - Where. All five.
- mcpx @ 05c78b2. Emits none. As a client it only ever asks for
info(internal/daemon/hooks.go:29, LOG-07). - Value to mcpx. − moot.
- Effort. S.
- Risk. None.
- Detail. The
notifications/messageparams arelevel, optionallogger, anddata(any JSON), unchanged in every revision. - Sources.
schema/2024-11-05/schema.ts:747“export type LoggingLevel =”;schema/2026-07-28/schema.ts:2075“export type LoggingLevel =”;schema/2024-11-05/schema.ts:733“logger?: string;”;schema/2026-07-28/schema.ts:2043“data: unknown;”.
LOG-06 Logging feature deprecated (SEP-2577)
- What. The whole feature (capability, notification, level type,
_metakey) is deprecated in 2026-07-28. New implementations SHOULD NOT adopt it; existing ones SHOULD move tostderr(stdio) or OpenTelemetry. - Where. 2026-07-28; eligible for removal no earlier than twelve months after the revision.
- mcpx @ 05c78b2. Declares and emits nothing (CAP-12), so there is nothing to retire.
- Value to mcpx. − deprecated: nothing to build.
- Effort. S.
- Risk. None.
- Detail. Deprecated is not removed: a 2026 server may still emit when asked (LOG-02). Roots and Sampling are deprecated by the same SEP; Elicitation is not.
- Sources.
2026-07-28/server/utilities/logging.mdx:8“The Logging feature is deprecated as of protocol version”;2026-07-28/server/utilities/logging.mdx:13“New implementations”;2026-07-28/deprecated.mdx:28“Logging”.
LOG-07 mcpx client sends setLevel info to every logging upstream
- What. After connecting, the pool sends
logging/setLevel {"level":"info"}to any upstream that declareslogging, including a modern one, for which the method no longer exists. It should use the per-request_metakey for modern upstreams. - Where. mcpx only. opencode v1 and v2 never send
setLevel(v1:packages/opencode/src/mcp/index.ts:164;v2:packages/core/src/mcp/client.ts:91). - mcpx @ 05c78b2.
LogLevel: "info"is an inline constant (internal/daemon/hooks.go:29); the pool sends it (internal/pool/pool.go:410-417); the client checks onlySupports("logging"), not the era (internal/mcpclient/client.go:568-575). A per-serverlogLevelis resolved in config and read by nothing (internal/config/config.go:483). - Value to mcpx. + med: correct 2026 behaviour, and a configurable level instead of a hard-coded one.
- Effort. S — era switch plus wiring the existing setting.
- Risk. low: a modern upstream answers
-32601, which is swallowed (_ =); the cost is that logs from modern upstreams never flow. - Detail. This is a send-conservatively conflict (a method sent to a peer whose revision does not define it). The
hard-coded
"info"breaks the project’s “nothing hardcoded” rule. Legacy upstreams that do not declareloggingare correctly left alone. - Sources.
internal/daemon/hooks.go:29;internal/mcpclient/client.go:568-575;internal/config/config.go:483;schema/2026-07-28/schema.ts:110“"io.modelcontextprotocol/logLevel"?: LoggingLevel;”.
LOG-08 Relaying upstream log messages to an MCP host
- What. Upstream
notifications/messageis published on mcpx’s event bus as aServerLogevent (visible on/v1/eventsand in the TUI). The MCP notifier forwards only list_changed and resources/updated, so no MCP host ever sees an upstream log. - Where. Every revision defines
notifications/message(deprecated in 2026). mcpx: event bus only. - mcpx @ 05c78b2.
internal/daemon/hooks.go:31-33publishes the event; the listen filter kinds are only the four list and resource kinds (internal/cli/serve.go:726-742);MCPNotificationhas noServerLogcase (internal/events/events.go:252-271). - Value to mcpx. + low: hosts that show server logs could; for an agent host, logs in context are mostly noise, and the event bus is the better home.
- Effort. M — request-scoped forwarding on the call’s own stream, only when the host asked (legacy
setLevel, 2026_metalogLevel). - Risk. low. Blanket forwarding would itself break LOG-03’s MUST and the request-scoping rule in notifications.md.
- Detail. A relayed message would have to be attributed to the request that caused it, which on a shared upstream instance mcpx cannot always do (the same limit as elicitation on shared instances, see elicitation.md). The 2026 page also says log messages MUST NOT contain credentials or secrets; a relay inherits that obligation.
- Sources.
internal/daemon/hooks.go:32“r.publish(events.Event{Kind: events.ServerLog, Server: server, Data: mustJSON(m)})”;internal/cli/serve.go:726-742;internal/events/events.go:252-271;2026-07-28/server/utilities/logging.mdx:123“Log messages MUST NOT contain:”.
LOG-09 Client handling of notifications/message
- What. opencode v1 maps server log messages onto its own logger (“MCP server log”, by level). opencode v2 registers no logging handler, so server logs are dropped.
- Where. opencode v1 only; v2 removed it with no replacement. Neither version sends
setLevel. - mcpx @ 05c78b2. Sends no
notifications/messageto hosts and no longer declareslogging(internal/mcpserver/server.go:565), so neither opencode version loses anything from mcpx. - Value to mcpx. − moot.
- Effort. S.
- Risk. None.
- Detail. v1 maps
logger,levelanddatainto structured fields. Because neither version sendssetLevel, a legacy server talking to v1 sends whatever it decides (LOG-03’s legacy default). - Sources.
v1:packages/opencode/src/mcp/index.ts:457;v1:packages/opencode/src/mcp/index.ts:474;v2:packages/core/src/mcp/client.ts:140.
_meta
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: The _meta extension point across revisions: naming and reservation rules, reserved keys, and which keys mcpx reads, writes, forwards or drops.
_meta is the reserved metadata object on requests, notifications and results. It has existed since 2024-11-05,
acquired naming rules in 2025-06-18, had its reserved-prefix rule inverted in 2025-11-25, and in 2026-07-28 became the
carrier for protocol state itself: version, capabilities, identity, log level and subscription ids. For mcpx, which
terminates one MCP conversation and starts another, the question is which keys belong to a single hop and which must
travel end to end. It replaces the per-hop keys correctly, but drops everything else in both directions, including
trace context and opencode v2’s per-session isolation hint. The per-request version, capability, client and server
identity keys are rows in lifecycle-versioning.md (LV-19..LV-22).
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| META-01 | _meta on requests, notifications and results, without rules | 2024-11-05 has | 24-11 ✓(schema) · 25-03 ✓(schema) · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✓ — client keeps caller keys when adding its own | + low | S | low |
| META-02 | Key-name format and forward-DNS prefix reservation | 2025-06-18 has | 25-06 ✓ | n/a — mcpx defines no _meta keys | + low | S | low |
| META-03 | Reservation flips to reverse-DNS “second label” | 2025-11-25 has specs conflict | 25-11 ✓ · 26-07 ✓ | n/a — no mcpx keys yet | + low | S | low |
| META-04 | Typed _meta objects per message kind | 2026-07-28 has | 26-07 ✓ | partial — request keys read; notification and result keys never set | + med | S | low |
| META-05 | RequestParams._meta required, so params is required | 2026-07-28 has | 26-07 ✓ | ✓ — client creates params; server treats absence as legacy | + low | S | low |
| META-06 | 2026 reserved-key table | 2026-07-28 has specs conflict | 26-07 ✓ | partial — three request keys handled; the rest ignored | + med | S | med |
| META-07 | traceparent / tracestate / baggage | 2026-07-28 has mcpx missing | 26-07 ✓ (prose only) | ✓ — passed upstream from a host’s tools/call (#212; docs/protocol.md §4.4) | + med | S | low |
| META-08 | clientInfo / serverInfo are self-reported, display-only | 2026-07-28 has | 26-07 ✓ (legacy silent) | ✓ — neither drives behaviour | + low | S | low |
| META-09 | ai.opencode/sessionID on every opencode v2 tools/call | opencode v2 has mcpx missing has better replacement | opencode v2 ✓ · opencode v1 — | ✗ — ignored; every host shares one scope (#211) | + high | M | high |
| META-10 | Per-hop versus end-to-end keys through a proxy | 2026-07-28 has | 26-07 (implied) | partial — per-hop keys replaced; nothing end-to-end survives | + med | M | med |
| META-11 | Upstream result _meta reaching the host | 2024-11-05 has mcpx missing | 24-11 ✓(schema) · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | partial — lost via mcpx_call; kept on /v1 and in scripts (#206) | + med | M | low |
META-01 _meta on requests, notifications and results, without rules
- What. 2024-11-05 already reserves
params._metaon requests (typed with the single keyprogressToken),params._metaon notifications andresult._metaon results (both open maps). No key-format or reservation rules exist until 2025-06-18. - Where. Schema-only in 2024-11-05 and 2025-03-26; prose rules from 2025-06-18.
- mcpx @ 05c78b2. When the client adds its own 2026 keys it keeps any the caller already set
(
internal/mcpclient/modern.go:73-81). - Value to mcpx. + low.
- Effort. S — done.
- Risk. None.
- Detail. Progress opt-in has always lived in
_meta, never as a top-level parameter (PC-04). - Sources.
schema/2024-11-05/schema.ts:21“_meta?: {”;schema/2024-11-05/schema.ts:25“progressToken?: ProgressToken;”;schema/2024-11-05/schema.ts:37;schema/2024-11-05/schema.ts:46“_meta?: { [key: string]: unknown };”;internal/mcpclient/modern.go:73-81.
META-02 Key-name format and forward-DNS prefix reservation
- What. 2025-06-18 defines a key as an optional prefix (dot-separated labels, then
/) plus a name, and reserves any prefix of the form “zero or more labels, thenmodelcontextprotocolormcp, then any label”, for examplemodelcontextprotocol.io/,mcp.dev/,api.modelcontextprotocol.org/,tools.mcp.com/. Implementations MUST NOT make assumptions about values at reserved keys. The same revision adds_metato more types (PR #710). - Where. 2025-06-18; replaced one revision later (META-03).
- mcpx @ 05c78b2. Defines no
_metakeys of its own (internal/mcpclient/modern.go:12-16lists only the threeio.modelcontextprotocol/request keys). - Value to mcpx. + low.
- Effort. S.
- Risk. None.
- Detail. Names, unless empty, begin and end with an alphanumeric and may contain hyphens, underscores and dots.
The
_metafields added toTool,Resource,ResourceTemplate,Prompt,Rootand content blocks are covered in the tools, resources, prompts, roots and content-types registers. - Sources.
2025-06-18/basic/index.mdx:140“- Any prefix beginning with zero or more valid labels, followed bymodelcontextprotocolormcp, followed by any valid label,”;2025-06-18/basic/index.mdx:142;2025-06-18/changelog.mdx:35“1. Add_metafield to additional interface types”.
META-03 Reservation flips to reverse-DNS “second label”
- What. 2025-11-25 says implementations SHOULD use reverse-DNS prefixes and reserves a prefix exactly when its
second label is
modelcontextprotocolormcp(io.modelcontextprotocol/,dev.mcp/,org.modelcontextprotocol.api/,com.mcp.tools/);com.example.mcp/is not reserved. Sotools.mcp.com/was reserved under 2025-06-18 and is not under 2025-11-25, whilecom.mcp.tools/now is. - Where. 2025-11-25 and 2026-07-28 (same text). The 2025-11-25 changelog does not mention the change.
- mcpx @ 05c78b2. No mcpx keys yet (
internal/mcpclient/modern.go:12-16). - Value to mcpx. + low: any key mcpx adds should use a reverse-DNS prefix whose second label is not
mcpormodelcontextprotocol;dev.mcpx/qualifies under both rules. - Effort. S.
- Risk. None.
- Detail. opencode’s
ai.opencode/is reverse DNS for opencode.ai and unreserved under both rules. - Sources.
2025-11-25/basic/index.mdx:207“- Implementations SHOULD use reverse DNS notation”;2025-11-25/basic/index.mdx:208“- Any prefix where the second label ismodelcontextprotocolormcpis reserved for MCP use.”;2026-07-28/basic/index.mdx:337;2026-07-28/basic/index.mdx:339.
META-04 Typed _meta objects per message kind
- What. The 2026 schema types
_metaper kind:MetaObject(the naming rules),RequestMetaObject(progressToken, requiredprotocolVersion,clientInfo, requiredclientCapabilities,logLevel),NotificationMetaObject(subscriptionId),ResultMetaObject(serverInfo), andSubscriptionsListenResultMetaObject, wheresubscriptionIdis required. It fixes which key is legal where: version and capabilities only on requests,subscriptionIdon notifications and the listen result,serverInfoon results. The changelog does not mention these types. - Where. 2026-07-28.
- mcpx @ 05c78b2. Reads the two required request keys (
internal/mcpserver/conn.go:171-175); sets no notification key (SUB-05) and no result key (lifecycle-versioning.mdLV-22). - Value to mcpx. + med: the typed objects are the checklist for what a 2026 peer expects on each message.
- Effort. S.
- Risk. Low.
- Detail. 2026’s embedded input requests are not
JSONRPCRequests:ListRootsRequest.params._metais a plainMetaObjectwithout version or capabilities, andCreateMessageRequesthas no_meta; see the roots and sampling registers. - Sources.
schema/2026-07-28/schema.ts:54“export type MetaObject = Record<string, unknown>;”;schema/2026-07-28/schema.ts:63;schema/2026-07-28/schema.ts:120;schema/2026-07-28/schema.ts:143;schema/2026-07-28/schema.ts:1326;internal/mcpserver/conn.go:171-175.
META-05 RequestParams._meta required, so params is required
- What. 2026 declares
RequestParams { _meta: RequestMetaObject }with_metanon-optional, and concrete requests typeparamsas required (PaginatedRequest.params,DiscoverRequest.params). In legacy revisionsparamswas optional for list requests andping. - Where. 2026-07-28.
- mcpx @ 05c78b2. The client’s
withMetacreatesparamswhen there is none (internal/mcpclient/modern.go:66-91). The server cannot see a version in a request withoutparams, so it serves it as legacy (lifecycle-versioning.mdLV-24). - Value to mcpx. + low.
- Effort. S — done.
- Risk. None.
- Detail. The base
Request.paramsstays optional in the schema, so a request withoutparamsis valid as aJSONRPCRequestyet malformed by the prose. - Sources.
schema/2026-07-28/schema.ts:180“_meta: RequestMetaObject;”;schema/2026-07-28/schema.ts:1064“params: PaginatedRequestParams;”;schema/2025-11-25/schema.ts:637“params?: PaginatedRequestParams;”;schema/2026-07-28/schema.ts:188;internal/mcpclient/modern.go:66-91.
META-06 2026 reserved-key table
- What. 2026 lists the reserved keys:
progressToken;io.modelcontextprotocol/protocolVersion,/clientInfo,/clientCapabilities,/logLevel,/subscriptionId; andtraceparent,tracestate,baggage. Official extensions define more underio.modelcontextprotocol/, third-party extensions use their own vendor prefix, and implementations MUST NOT make assumptions about values at these keys. - Where. 2026-07-28.
- mcpx @ 05c78b2. Knows
protocolVersion,clientCapabilitiesandclientInfo(internal/mcpserver/conn.go:171-175;internal/mcpclient/modern.go:12-16); nothing handleslogLevel,subscriptionId,serverInfoor the trace keys. - Value to mcpx. + med: each unhandled key is a row below or in
lifecycle-versioning.md. - Effort. S per key.
- Risk. As in the per-key rows.
- Detail. The table omits
io.modelcontextprotocol/serverInfo, which the schema defines onResultMetaObjectand the same page describes in a separate result-fields table. SEP-2575 listsrootsamong the per-request fields, but the released revision defines no roots key; roots reach a server only through MRTRroots/listinput requests. Per-key rows:lifecycle-versioning.mdLV-19..LV-22 and PC-04..TASK-15 here. - Sources.
2026-07-28/basic/index.mdx:348“The following_metakeys are reserved by this specification:”;seps/2575-stateless-mcp.md:760;2026-07-28/basic/index.mdx:328;2026-07-28/basic/index.mdx:358;2026-07-28/basic/index.mdx:402;schema/2026-07-28/schema.ts:157;internal/mcpserver/conn.go:171-175.
META-07 traceparent / tracestate / baggage
- What. Unprefixed keys reserved for W3C Trace Context and W3C Baggage (SEP-414); when present, values MUST follow those formats. The spec says the exception exists for compatibility with existing implementations and the OpenTelemetry semantic conventions for MCP.
- Where. 2026-07-28 (SDKs used them before).
- mcpx @ 05c78b2. No
traceparentininternal/; the host’s_metais dropped (internal/mcpserver/server.go:658-665) and never reaches the upstream request. - Value to mcpx. + med: a proxy is where a trace continues from agent to mcpx to upstream.
- Effort. S to pass through; M to create spans.
- Risk. Traces break at mcpx.
- Detail. The keys are prose-only: the 2026 schema has no
traceparent(itsMetaObjectisRecord<string, unknown>). The prose calls them “an exception to the prefix requirement above”, but the key-format rules above make the prefix optional and the same table lists an unprefixedprogressToken; the requirement is only implied by “third-party extensions use their own vendor prefix”. - Sources.
2026-07-28/basic/index.mdx:421“As an exception to the prefix requirement above, the keystraceparent,tracestate, and”;2026-07-28/basic/index.mdx:358;2026-07-28/basic/index.mdx:330;2026-07-28/basic/index.mdx:362;internal/mcpserver/server.go:658-665.
META-08 clientInfo / serverInfo are self-reported, display-only
- What. Both are unverified. Implementations SHOULD NOT change behaviour or make security decisions based on them; display, logging and debugging are their purpose.
- Where. 2026-07-28 says so explicitly; legacy revisions are silent.
- mcpx @ 05c78b2. The server never reads
clientInfo(the key is only declared,internal/mcpserver/conn.go:174), and pool scope does not key on it. - Value to mcpx. + low: logging it for attribution is fine; special-casing agents by name is not.
- Effort. S.
- Risk. None.
- Detail. Aggregating clients should not rely on
serverInfo.namebeing unique; see the tools register on namespacing. - Sources.
2026-07-28/basic/index.mdx:407“intended for display, logging, and debugging. Implementations SHOULD NOT”;internal/mcpserver/conn.go:174.
META-09 ai.opencode/sessionID on every opencode v2 tools/call
- What. opencode v2’s MCP client attaches the calling opencode session id as the vendor key
_meta["ai.opencode/sessionID"]on every tool call, direct and code-mode child calls alike. It is advisory: a server that chooses to can partition its state by it. opencode v1 sends nothing butnameandarguments. - Where. opencode v2 only, in exactly one place.
- mcpx @ 05c78b2. Ignored.
tools/callreads{name, arguments}(internal/mcpserver/server.go:658-665), noai.opencodestring exists ininternal/, andBackend.Call(ctx, namespace, tool, args)has no caller identity (internal/mcpserver/server.go:45). Pool scope for/mcpcomes from a process-wide session key that every HTTP host shares (process-model register, finding M-3). - Value to mcpx. + high: using it as the scope key gives opencode v2 real per-session isolation of stateful
servers through
/mcp, with no plugin and no shell environment;OPENCODE-V2.md:92-94notes that nothing else provides this. - Effort. M — reading the key is small; threading a caller identity from
tools/callinto pool leasing is the work. - Risk. Not done: two opencode sessions on one daemon share one browser.
- Detail. Other clients will not send it. v2 tells the server which session is calling and leaves partitioning to the server.
- Also recorded from the code mode register. opencode v2 only; used in exactly one place. Script to daemon:
x-mcpx-sessionheaders (internal/codegen/emit.go:528-533). As a server: noai.opencodestring ininternal/, andBackend.Call(ctx, namespace, tool, args)has no caller identity (internal/mcpserver/server.go:45). As a client: rg finds no_metaininternal/pool. Child calls carry it because they run the same MCP executor withsessionID: context.sessionID(v2:packages/core/src/tool/mcp.ts:69). The key is prefixed as_metarequires. The mcpx side has three gaps held in other registers: the plugin’smcpx_execsends no session and nocwd, so every call gets a freshexec-<runID>session and the daemon’s directory (plugin-API register, PLG-39); session-scoped instances started through/v1/execare never released at run end (process-model register, PM-12); and every HTTP MCP host shares one pool session key (PM-10). What mcpx gets right is that a session lease is pinned for the whole script run, so navigate, snapshot and click reach the same browser (PM-11). - Sources.
v2:packages/core/src/mcp/client.ts:278;v2:packages/core/src/tool/mcp.ts:69“sessionID: context.sessionID,”;v1:packages/opencode/src/mcp/catalog.ts:56;OPENCODE-V2.md:89;internal/mcpserver/server.go:45.
META-10 Per-hop versus end-to-end keys through a proxy
- What. A proxy that terminates one MCP conversation and starts another must not forward per-hop keys
(
protocolVersion,clientCapabilities,clientInfo) verbatim, because forwarding a host’s capabilities upstream promises capabilities the proxy must then honour. End-to-end keys (trace context, vendor hints, a progress correlation) should cross. The spec implies this through “MUST NOT make assumptions about values at these keys”; it does not spell it out for proxies. - Where. 2026-07-28 (implied).
- mcpx @ 05c78b2. The host’s
_metais deleted before dispatch (internal/mcpserver/ask.go:95-108) and the upstream request gets mcpx’s own keys (internal/mcpclient/modern.go:60-91), so per-hop keys are right. Nothing end-to-end survives (PC-04, META-07, META-09).withMetakeeps any key the caller already set (internal/mcpclient/modern.go:73-81), so passing a host’s_metathrough unfiltered would let the host’sclientCapabilitiesoverride mcpx’s. - Value to mcpx. + med: one allowlist decides what crosses.
- Effort. M.
- Risk. Forwarding everything breaks send-conservatively; forwarding nothing breaks tracing and isolation.
- Detail. None.
- Sources.
2026-07-28/basic/index.mdx:328“implementations MUST NOT make assumptions about values at these keys.”;internal/mcpserver/ask.go:100;internal/mcpclient/modern.go:73-81.
META-11 Upstream result _meta reaching the host
- What. Results carry
_meta(an open map since 2024-11-05, formalised in 2025-06-18); extensions such as MCP Apps put UI resource pointers there. - Where. Every revision.
- mcpx @ 05c78b2.
mcpx_callrenders the upstream result as text, decoding onlycontent,structuredContentandisError(internal/cli/commands.go:385-415), so result_metais lost./v1/callpasses the raw result (internal/daemon/server.go:703), and the script client exposes it as.raw(internal/codegen/emit.go:476-491). - Value to mcpx. + med: extensions that ride in
_metawork through mcpx. - Effort. M.
- Risk. Low today.
- Detail. None.
- Sources.
schema/2024-11-05/schema.ts:46;internal/cli/commands.go:386-393;internal/daemon/server.go:703;internal/codegen/emit.go:476-491.
Multi round-trip requests (the 2026 input_required mechanism)
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: InputRequiredResult, inputRequests, requestState and retries in 2026-07-28, and how mcpx and opencode v2 implement them
In 2026-07-28 a server can no longer send the client a request: when it needs the user’s answer, a model completion or
the client’s roots, it answers the client’s own tools/call, prompts/get or resources/read early with an
InputRequiredResult, and the client sends the same request again with the answers and an opaque requestState. This
page covers that mechanism for elicitation, sampling and roots alike; the embedded request shapes are in
elicitation.md, sampling.md and roots.md, and the resultType field every 2026
result carries is registered with lifecycle. For mcpx the point is that its requestState is a signed token bound to the
Mcp-Session-Id that 2026 removed, so the mechanism works only for 2026 hosts that behave like legacy ones (MRTR-08), and
that results routed through it come back flattened to text (MRTR-19).
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| MRTR-01 | InputRequiredResult with resultType: "input_required" | 2026-07-28 has | 26-07 only | ✓ — produced for 2026 hosts and consumed from upstreams | + high | S | low |
| MRTR-02 | inputRequests: server-assigned keys, bare {method, params} values | 2026-07-28 has | 26-07 only | ✓ — keys are broker question ids | + med | S | low |
| MRTR-03 | Only tools/call, prompts/get, resources/read may answer input_required | 2026-07-28 has specs conflict | 26-07 only | ✓ — exactly these three are interruptible | + low | S | low |
| MRTR-04 | Server-initiated requests removed (ServerRequest union gone) | 2026-07-28 removes has better replacement | 24-11..25-11 ✓ · 26-07 — | ✓ — wire requests for legacy, MRTR for 2026 | + high | S | low |
| MRTR-05 | No input request of a kind the client did not declare | 2026-07-28 has | 26-07 only | ✓ — only sendable questions included | + med | S | low |
| MRTR-06 | requestState opaque; client echoes it exactly, never invents one | 2026-07-28 has | 26-07 only | ✓ — echoed verbatim; stale fields removed per retry | + med | S | low |
| MRTR-07 | Server MUST integrity-protect requestState | 2026-07-28 has | 26-07 only | ✓ — base64url(payload).HMAC-SHA256, per-process key | + high | S | low |
| MRTR-08 | requestState SHOULD bind the authenticated principal | 2026-07-28 has mcpx missing | 26-07 only | ✗ — bound to Mcp-Session-Id, which 2026 removed (#201) | + high | M | high |
| MRTR-09 | requestState SHOULD carry a short expiry | 2026-07-28 has | 26-07 only | ✓ — e expiry, proto.stateTTL 30 m | + low | S | low |
| MRTR-10 | requestState SHOULD identify method and parameters digest | 2026-07-28 has mcpx missing | 26-07 only | ✗ — call id only; retry params ignored (#201) | + med | S | med |
| MRTR-11 | Single use needs server-side enforcement | 2026-07-28 has | 26-07 only | n/a — not enforced; replay re-polls the same call | − moot | S | low |
| MRTR-12 | Retry carries inputResponses keyed like inputRequests | 2026-07-28 has | 26-07 only | ✓ — built per key; relayed per key | + med | S | low |
| MRTR-13 | Retry uses a new JSON-RPC id; state only for that retry | 2026-07-28 has | 26-07 only | ✓ — each round takes a fresh id | + low | S | low |
| MRTR-14 | requestState-only result: client MAY retry at once | 2026-07-28 has | 26-07 only | partial — retries immediately, no backoff | + low | S | med |
| MRTR-15 | Rounds are unbounded on the server side | 2026-07-28 has | 26-07 only | partial — mcpx as server abandons after 8 rounds | + low | S | low |
| MRTR-16 | Client-side cap on rounds | 2026-07-28 has opencode v2 has | spec: none; mcpx 8; opencode v2 10 | ✓ — elicit.inputRounds 8, then an error | + low | S | low |
| MRTR-17 | No per-input error channel | 2026-07-28 has specs conflict has no replacement | 26-07 only | partial — one failed input aborts the whole call | + low | M | low |
| MRTR-18 | Client fulfils input_required and retries | 2026-07-28 has opencode v2 has | mcpx ✓; opencode v2 SDK ✓; opencode v1, lootbox ✗ | partial — implemented; unreachable until discovery is fixed | + high | S | med |
| MRTR-19 | Results returned through MRTR keep their full shape | mcpx missing | spec: result is the method’s own; mcpx: text | ✗ — flattened to one text block (#201) | + med | M | med |
| MRTR-20 | Where the interrupted call’s state lives | 2026-07-28 has | spec: in requestState; mcpx: daemon task | partial — call runs as a daemon task; token names it | + med | L | med |
MRTR-01 InputRequiredResult with resultType: "input_required"
- What. An interim result carrying optional
inputRequestsand optionalrequestState; at least one MUST be present. It terminates the original request; the retry is a new, independent request. - Where. 2026-07-28 only.
- mcpx @ 05c78b2. Produced for 2026 hosts by
inputRequired(internal/mcpserver/ask.go:256-266) and detected on upstream results by the client (internal/mcpclient/client.go:663-676). - Value to mcpx. + high: the only way a 2026 host can be asked anything.
- Effort. S — done.
- Risk. None beyond the rows below.
- Detail. The server builds
{"resultType":"input_required","inputRequests":{…},"requestState":"…"}; wire M2 shows one. Everything a server needs to resume should be in the retry itself (MRTR-20 on how far mcpx is from that). - Sources.
schema/2026-07-28/schema.ts:584“export interface InputRequiredResult extends Result {”;schema/2026-07-28/schema.ts:575“At least one of”;internal/mcpserver/ask.go:256-266;internal/mcpclient/client.go:663-676; wire M2; https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr.
MRTR-02 inputRequests: server-assigned keys, bare {method, params} values
- What. A map from server-chosen keys, unique within the request, to
ElicitRequest,CreateMessageRequestorListRootsRequestobjects. There is nojsonrpcand noid; the key does the id’s job. - Where. 2026-07-28.
- mcpx @ 05c78b2. Keys are the broker’s question ids (
elc-…) and values are the upstream’s method and raw params (internal/mcpserver/ask.go:258-259). - Value to mcpx. + med: done.
- Effort. S.
- Risk. None.
- Detail. Because the params are copied raw, the originator prefix and the
elicitationIdstrip that the legacy path applies are missing here (see elicitation.md, ELI-10 and ELI-18). Keys need only be unique per request; the tasks extension requires uniqueness over a task’s lifetime (tasks.md). - Sources.
2026-07-28/basic/patterns/mrtr.mdx:228“keys are server assigned identifiers”;schema/2026-07-28/schema.ts:538“CreateMessageRequest | ListRootsRequest | ElicitRequest;”;internal/mcpserver/ask.go:259.
MRTR-03 Only tools/call, prompts/get, resources/read may answer input_required
- What. The prose lists three methods and says servers MUST NOT send
InputRequiredResulton any other request. A schema comment onInputResponseRequestParamssays the params “may be included in any client-initiated request”. - Where. 2026-07-28.
- mcpx @ 05c78b2. Exactly these three can block on the client (
internal/mcpserver/ask.go:318-325). - Value to mcpx. + low: aligned.
- Effort. S.
- Risk. None.
- Detail. The schema’s actual
extendswireInputResponseRequestParamsinto those three param types only, agreeing with the prose; the comment is the outlier. Completion is not on the list, socompletion/completecan never ask. - Also recorded from the resources register.
resources/listandresources/templates/listcannot. The server routesresources/readthrough the ask path (internal/mcpserver/server.go:701-705), and renders the final answer as one text item (internal/mcpserver/ask.go:276-287). AnInputRequiredResultanswer is not a cacheable result (pagination-caching register). The mechanism and its gaps are in the MRTR register. - Also recorded from the prompts register. The server routes
prompts/getthrough the ask path (internal/mcpserver/server.go:742-746); the finished call is rendered as one user text message (internal/mcpserver/ask.go:271-275).GetPromptResultis not a cacheable result, unlikeReadResourceResult(pagination-caching register). The mechanism is in the MRTR register. - Sources.
2026-07-28/basic/patterns/mrtr.mdx:192“Servers MUST NOT send”;schema/2026-07-28/schema.ts:598“These parameters may be included in any client-initiated request.”;internal/mcpserver/ask.go:318-325.;schema/2026-07-28/schema.ts:1205“export interface ReadResourceRequestParams”;schema/2026-07-28/schema.ts:1245“result: ReadResourceResult | InputRequiredResult;”;internal/mcpserver/server.go:701-705;internal/mcpserver/ask.go:276-287;schema/2026-07-28/schema.ts:1602“export interface GetPromptRequestParams extends InputResponseRequestParams {”;2026-07-28/server/prompts.mdx:165“Servers MAY also respond toprompts/getwith an”;internal/mcpserver/server.go:742-746;internal/mcpserver/ask.go:271-275
MRTR-04 Server-initiated requests removed (ServerRequest union gone)
- What. 2026 servers cannot send
ping,sampling/createMessage,roots/listorelicitation/createas JSON-RPC requests; MRTR is the only way.ClientResultshrinks toEmptyResult. - Where.
ServerRequestin 2024-11-05..2025-11-25; none in 2026-07-28. - mcpx @ 05c78b2. Asks a legacy host on the wire and a 2026 host through
input_required; the call does not know which happened (internal/mcpserver/ask.go:117-121). - Value to mcpx. + high: done per era.
- Effort. S.
- Risk. None.
- Detail. The changelog calls this a breaking change but never says the
ServerRequestunion and the non-emptyClientResultmembers disappeared.ClientResult = EmptyResultis vestigial, since a 2026 client answers nothing. - Sources.
schema/2025-11-25/schema.ts:2545“export type ServerRequest =”;schema/2026-07-28/schema.ts:3169“export type ClientResult = EmptyResult;”;2026-07-28/basic/patterns/mrtr.mdx:12“The previous pattern of server-initiated requests is no longer”;internal/mcpserver/ask.go:117-121.
MRTR-05 No input request of a kind the client did not declare
- What. A server MUST NOT put an
elicitation/create(or sampling, or roots) request intoinputRequestsfor a client that did not declare that capability on this request. - Where. 2026-07-28; capabilities are per request there.
- mcpx @ 05c78b2.
sendableTokeeps only questions the host can take (internal/mcpserver/ask.go:241-249, viaQuestion.Sendableatinternal/mcpserver/conn.go:367). A question the host cannot take (a URL flow to a form-only host, say) stays in the broker for another audience, and mcpx waits (internal/mcpserver/ask.go:177-193). - Value to mcpx. + med: correct, and the broker makes the fallback useful.
- Effort. S.
- Risk. low.
- Detail. When the request truly cannot finish without the capability, the spec’s answer is
-32021, which mcpx never sends because a broker audience can still answer; see elicitation.md, ERR-08. - Sources.
2026-07-28/basic/patterns/mrtr.mdx:246“that the client has not declared support for in its capabilities”;internal/mcpserver/ask.go:241-249;internal/mcpserver/ask.go:179“Either nothing was asked yet”.
MRTR-06 requestState opaque; client echoes it exactly, never invents one
- What. Clients MUST NOT inspect, parse or modify
requestState, MUST send back the exact value, and MUST NOT include one if none was given. - Where. 2026-07-28.
- mcpx @ 05c78b2. As a client, echoed as received (
internal/mcpclient/modern.go:180-187), with stalerequestStateandinputResponsesremoved before each retry (internal/mcpclient/modern.go:171-172). - Value to mcpx. + med: done.
- Effort. S.
- Risk. None.
- Detail. The spec lets servers encode anything (“base64-encoded JSON, encrypted JWT, serialized binary”), so a client cannot assume a format; mcpx’s own token happens to be readable base64url JSON (MRTR-07).
- Sources.
2026-07-28/basic/patterns/mrtr.mdx:254“the client MUST echo back the exact value of that field”;2026-07-28/basic/patterns/mrtr.mdx:255“Clients MUST NOT inspect, parse, modify”;internal/mcpclient/modern.go:181“Opaque: passed back exactly as received”.
MRTR-07 Server MUST integrity-protect requestState
- What. Servers MUST treat
requestStateas attacker-controlled; when it influences authorization, resource access or business logic they MUST protect its integrity (HMAC or AEAD) and reject anything that fails verification. - Where. 2026-07-28.
- mcpx @ 05c78b2. The token is
base64url(json{c, b, e}) + "." + base64url(HMAC-SHA256)with a random 32-byte key per process that is never persisted (internal/mcpserver/state.go:47-51,:56-74); verification is constant-time (internal/mcpserver/state.go:82-112). The state comment says the spec “says nothing about what a server should put in it” (internal/mcpserver/state.go:20-21), which is stale against mrtr.mdx. - Value to mcpx. + high: a bare call id would let anyone resume, and read, another host’s call.
- Effort. S — done; fix the comment.
- Risk. low.
- Detail. Observed on the wire (M2, decode of the base64url part):
{"c":"tsk-685a4a42635a4122", "b":"sess-e4af48671de84810a1c018d965371751","e":1790768865}, that is a daemon task id, the session id, and a Unix expiry. A daemon restart invalidates every token, as intended, because the calls they name do not survive either. - Sources.
2026-07-28/basic/patterns/mrtr.mdx:232“servers MUST treatrequestStateas an attacker-controlled input.”;internal/mcpserver/state.go:73“return body + "." + s.sign(body), nil”;internal/mcpserver/state.go:21“nothing about what a server should put in it”; wire M2.
MRTR-08 requestState SHOULD bind the authenticated principal
- What. To prevent replay, the integrity-protected payload SHOULD include the authenticated principal, and state presented by a different principal SHOULD be rejected.
- Where. 2026-07-28.
- mcpx @ 05c78b2. Binds to the connection instead: the binding is the connection id (
internal/mcpserver/conn.go:67), and minting refuses an empty one (internal/mcpserver/state.go:64-66). Over HTTP a connection has an id only when the client echoes anMcp-Session-Idminted oninitializeorserver/discover; any other request gets an anonymous connection with an empty id (internal/mcpserver/server.go:1209-1218). 2026 removed that header. Wire M4: replaying a valid state without the session header gets-32602 "this requestState belongs to another session". - Value to mcpx. + high: this is why a correct stateless 2026 host cannot be asked anything (the resulting
-32603is elicitation.md ELI-17). - Effort. M — mcpx has no authenticated principal on
/mcp(an auth-register item), so the honest binding today is nothing beyond the HMAC plus the method and params digest (MRTR-10); bind to a principal once one exists. - Risk. high if not done: every stateless 2026 host is locked out of MRTR. If done carelessly (no binding, no digest), a leaked token resumes someone else’s call.
- Detail. The spec binds to the principal, not the connection, and 2026 statelessness says servers SHOULD NOT depend on connection reuse. mcpx’s doc chose the session deliberately (“A token bound to nothing is a token anyone may present”), which was reasonable before the HMAC-plus-digest option was considered.
- Sources.
2026-07-28/basic/patterns/mrtr.mdx:235“the authenticated principal, rejecting state presented by a different principal.”;internal/mcpserver/state.go:103“if p.Binding != binding {”;internal/mcpserver/conn.go:67;internal/mcpserver/server.go:1209-1218;2026-07-28/changelog.mdx:12“Remove protocol-level sessions”; wire M4.
MRTR-09 requestState SHOULD carry a short expiry
- What. The payload SHOULD include a short TTL, and expired state SHOULD be rejected.
- Where. 2026-07-28.
- mcpx @ 05c78b2. The
efield is the Unix expiry,proto.stateTTL(30 m) after minting, and verification refuses it afterwards with “this requestState expired; the call it named is gone” (internal/mcpserver/state.go:109). - Value to mcpx. + low: done.
- Effort. S.
- Risk. None.
- Detail. The setting lives in
internal/defaults/defaults.jsonunderproto, so it is configurable rather than inline. - Sources.
2026-07-28/basic/patterns/mrtr.mdx:236“a short expiry (TTL), rejecting state presented after it lapses;”;internal/mcpserver/state.go:109“if time.Now().Unix() > p.Expires {”;internal/defaults/defaults.json:73“"stateTTL": "30m",”.
MRTR-10 requestState SHOULD identify method and parameters digest
- What. The payload SHOULD name the originating request (method and a digest of its salient parameters), and state presented on a different request SHOULD be rejected.
- Where. 2026-07-28.
- mcpx @ 05c78b2. The payload has no method and no digest (
internal/mcpserver/state.go:47-51); a retry is resumed by call id alone and its own params are ignored (internal/mcpserver/ask.go:131-141). - Value to mcpx. + med: closes cross-request replay, and is what would let mcpx drop the session binding (MRTR-08).
- Effort. S — hash method plus name plus arguments into the payload and compare on resume.
- Risk. med: within one session, a state replayed on a different
tools/callreturns the first call’s result. - Detail.
forAskalready strips_meta,inputResponsesandrequestStatefrom params (internal/mcpserver/ask.go:95-108), so the digest input is readily available. - Sources.
2026-07-28/basic/patterns/mrtr.mdx:237“an identifier for the originating request, e.g. the method name and a digest of its salient parameters”;internal/mcpserver/state.go:47-51;internal/mcpserver/ask.go:131-141.
MRTR-11 Single use needs server-side enforcement
- What. Principal, TTL and request binding bound the replay window but do not make a state single-use; a server for which a state must be consumed at most once MUST enforce that itself.
- Where. 2026-07-28.
- mcpx @ 05c78b2. Not enforced:
verifychecks signature, binding and expiry only (internal/mcpserver/state.go:82-112). Replaying a state within its TTL re-polls the same daemon call. - Value to mcpx. − moot: an mcpx call is not a one-time redemption, so the MUST does not bind it.
- Effort. S.
- Risk. low.
- Detail. Replaying with fresh answers would feed them to an already-answered question; what the broker does then was not examined.
- Sources.
2026-07-28/basic/patterns/mrtr.mdx:240“do not by themselves guarantee single-use.”;2026-07-28/basic/patterns/mrtr.mdx:242“enforce that invariant server-side.”;internal/mcpserver/state.go:82-112.
MRTR-12 Retry carries inputResponses keyed like inputRequests
- What.
InputResponseRequestParams{inputResponses?, requestState?}: each key matches a request key, each value is anElicitResult,CreateMessageResultorListRootsResult. Servers SHOULD ignore unexpected keys. - Where. 2026-07-28.
- mcpx @ 05c78b2. As a client it answers every key and builds the map (
internal/mcpclient/modern.go:163-179). As a server it reads the map as raw JSON and hands it to the broker (internal/mcpserver/ask.go:131-141). - Value to mcpx. + med: done.
- Effort. S.
- Risk. None known; how the broker treats an unknown key was not examined.
- Detail. If required answers are missing, the server SHOULD ask again rather than fail (MRTR-15).
- Sources.
schema/2026-07-28/schema.ts:600“export interface InputResponseRequestParams extends RequestParams {”;schema/2026-07-28/schema.ts:542“CreateMessageResult | ListRootsResult | ElicitResult;”;2026-07-28/basic/patterns/mrtr.mdx:264“the server SHOULD ignore any information it does not recognize or need.”;internal/mcpclient/modern.go:163-179.
MRTR-13 Retry uses a new JSON-RPC id; state only for that retry
- What. The initial request and the retry are independent, so the id MUST differ;
inputRequestsandrequestStateMUST NOT be used for any other request the client sends in parallel. - Where. 2026-07-28.
- mcpx @ 05c78b2. Every round goes through
roundTrip, which takes a fresh id (internal/mcpclient/client.go:681-682). - Value to mcpx. + low: done.
- Effort. S.
- Risk. None.
- Detail. Unspecified: whether a retry may reuse the original request’s
progressToken. Neither mrtr.mdx nor the progress page says (see progress-cancellation.md). - Sources.
2026-07-28/basic/patterns/mrtr.mdx:256“The JSON-RPCidMUST be different”;2026-07-28/basic/patterns/mrtr.mdx:257“They MUST NOT be used for any other request”;internal/mcpclient/client.go:681-682.
MRTR-14 requestState-only result: client MAY retry at once
- What. An
InputRequiredResultwith noinputRequestsasks the client only to come back; it MAY retry immediately. The schema’s example calls this load shedding; the prose never does. - Where. 2026-07-28.
- mcpx @ 05c78b2. Handled: empty responses, state echoed (
internal/mcpclient/modern.go:173-187). The retry is immediate, with no backoff (internal/mcpclient/client.go:654-677). - Value to mcpx. + low: a server-directed continuation that mcpx could also use itself.
- Effort. S — add a backoff.
- Risk. med: a hot loop against a server that is shedding load, until the round cap (MRTR-16).
- Detail. “MAY retry immediately” permits waiting; a load-shedding server presumably wants the client to.
- Sources.
schema/2026-07-28/schema.ts:579“@example InputRequiredResult with request state only (load shedding)”;2026-07-28/basic/patterns/mrtr.mdx:253“the client MAY retry the original request immediately.”;internal/mcpclient/client.go:654-677.
MRTR-15 Rounds are unbounded on the server side
- What. A server MAY answer
input_requiredrepeatedly, SHOULD ask again when required information is missing rather than fail, and MUST NOT assume the client will fulfil or retry. No maximum is specified. - Where. 2026-07-28.
- mcpx @ 05c78b2. As a server mcpx counts rounds per request and abandons the upstream call past
proto.askRounds(8) with-32603(internal/mcpserver/ask.go:195-199). The cap is also what turns a failed mint into an immediate error (ELI-17 in elicitation.md). - Value to mcpx. + low: a guard the spec omits, and configurable.
- Effort. S.
- Risk. low: an upstream wizard asking more than eight questions fails through mcpx.
- Detail. mcpx also bounds the whole hold by
proto.askTimeout(10 m). Abandoning after a cap is allowed; the spec only asks servers not to error for missing answers. - Sources.
2026-07-28/basic/patterns/mrtr.mdx:247“Servers MAY choose to return anInputRequiredResulton multiple attempts”;2026-07-28/basic/patterns/mrtr.mdx:267“rather than returning an error.”;internal/mcpserver/ask.go:195-199;internal/defaults/defaults.json:72“"askRounds": 8,”.
MRTR-16 Client-side cap on rounds
- What. Nothing caps rounds, so a client needs its own limit.
- Where. mcpx:
elicit.inputRounds(8). opencode v2: its SDK auto-fulfils up to 10 rounds by default. The spec sets none. - mcpx @ 05c78b2. Errors after
defaults.InputRoundsrounds (internal/mcpclient/client.go:671-673;internal/defaults/defaults.go:356). - Value to mcpx. + low: sensible.
- Effort. S — done.
- Risk. low: a legitimate multi-step server longer than the cap fails.
- Detail. mcpx has two separate caps of the same value: one as a client of upstreams (this row), one as a server to hosts (MRTR-15).
- Sources.
internal/mcpclient/client.go:671-673;internal/defaults/defaults.json:66“"inputRounds": 8”;v2:packages/core/src/mcp/client.ts:157; https://unpkg.com/@modelcontextprotocol/client@2.0.0/dist/src-D_zzAWoS.mjs (L5049,DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10).
MRTR-17 No per-input error channel
- What.
InputResponseis a union of results only. A client that cannot or will not fulfil one input request (malformed sampling, an unsupported mode) cannot say so for that key: it can omit it, answerdecline/cancel(elicitation only), or give up. Yet the 2026 schema still documents client-side-32602cases for sampling and elicitation that MRTR has no way to carry. - Where. 2026-07-28. Legacy clients returned JSON-RPC errors to the server’s request.
- mcpx @ 05c78b2. As a client, any failed answer aborts the whole call with a local error, naming the key
(
internal/mcpclient/modern.go:164-168), rather than answering the others. - Value to mcpx. + low: answering what it can and omitting the rest would let the server re-ask or fail cleanly.
- Effort. M.
- Risk. low.
- Detail. Omitting a key is the only in-band signal, and the server SHOULD then ask again (MRTR-15), which can loop until a cap.
- Sources.
schema/2026-07-28/schema.ts:541“export type InputResponse =”;schema/2026-07-28/schema.ts:371“Sampling: Missing tool result or tool results mixed with other content”;internal/mcpclient/modern.go:164-168.
MRTR-18 Client fulfils input_required and retries
- What. The client side of MRTR: answer each input request with the matching handler and retry the original request.
- Where. mcpx (client of modern upstreams). opencode v2: the SDK fulfils automatically from registered handlers, which
are only
elicitation/createandroots/list. opencode v1 speaks no 2026. lootbox uses SDK 1.22, whose newest revision is 2025-06-18. - mcpx @ 05c78b2. Modern results with
resultType: "input_required"are answered byanswer(), the same function that serves legacy server-initiated requests, so questions go through the same broker; the call is retried withinputResponsesand the echoed state (internal/mcpclient/client.go:654-677;internal/mcpclient/modern.go:103-135,:156-189). - Value to mcpx. + high: parity across eras for upstream questions.
- Effort. S — done.
- Risk. med: unreachable in practice until mcpx’s modern discovery and headers work against conformant servers (a lifecycle and transports issue), so this path is untested against real 2026 upstreams.
- Detail. opencode v2 fails an input request it has no handler for (sampling) after the SDK refuses, which is the missing error channel of MRTR-17 seen from the client.
- Sources.
internal/mcpclient/client.go:654-677;internal/mcpclient/modern.go:103-135;v2:packages/core/src/mcp/client.ts:155;v2:packages/core/src/mcp/client.ts:157;.lootbox/deno.json:44“@modelcontextprotocol/sdk”; https://unpkg.com/@modelcontextprotocol/client@2.0.0/dist/src-D_zzAWoS.mjs (L5044,DEFAULT_INPUT_REQUIRED_AUTO_FULFILL = true).
MRTR-19 Results returned through MRTR keep their full shape
- What. After the rounds, the final result is the method’s own result: a full
CallToolResult,GetPromptResultorReadResourceResult. - Where. 2026-07-28 (and the same holds for legacy interruptible calls).
- mcpx @ 05c78b2.
askResultrenders a finished interruptible call as one text block:tools/callgets one text content (plusisError),prompts/getone user text message,resources/readone text entry (internal/mcpserver/ask.go:269-296). Images,structuredContent,resource_link, multi-message prompts and blobs are lost on this path, though the direct path keeps more. - Value to mcpx. + med: a call should not return less because it happened to ask a question.
- Effort. M — carry the raw upstream result through the daemon task instead of
Outcome.Text. - Risk. med: MRTR-routed calls silently degrade compared with the same call made without a question.
- Detail. The same renderer serves legacy hosts whose calls were interrupted on the wire, so the loss is not 2026-only.
- Sources.
internal/mcpserver/ask.go:269-296;internal/mcpserver/ask.go:269“func askResult(req request, out Outcome) map[string]any {”.
MRTR-20 Where the interrupted call’s state lives
- What. MRTR lets a server ask “without maintaining any server-side state”: everything needed to resume can travel in
requestState. mcpx instead keeps the upstream call running and puts only its handle in the token. - Where. 2026-07-28 describes the stateless option; it is not a MUST.
- mcpx @ 05c78b2. An interruptible call is a daemon task tracked in an
askTable(internal/daemon/routes_proto.go:53-65); the token’scis its task id (wire M2). If the host disconnects, the call “keeps running as a task” (internal/mcpserver/ask.go:157-162), and its result is kept forproto.askTTL(15 m). - Value to mcpx. + med: a live upstream call cannot be serialised into a token, so this is the server-minted-handle pattern, and it is right for a proxy. What it costs is that a resume must reach the same daemon.
- Effort. L to change, and not worth changing.
- Risk. med: a disconnect does not cancel the upstream call on this path (unlike ordinary calls; see
progress-cancellation.md), and these tasks appear in
mcpx_tasks_listbut not in MCPtasks/list, because the two task stores differ (tasks.md). - Detail. Resuming therefore needs both a valid token and the task still alive: after a daemon restart the key and the call are both gone, which the state comment calls intended.
- Sources.
2026-07-28/basic/patterns/mrtr.mdx:199“without maintaining any server-side state”;internal/mcpserver/ask.go:159“keeps running as a task, and its questions keep their”;internal/daemon/routes_proto.go:53-65;internal/defaults/defaults.json:75“"askTTL": "15m",”;internal/mcpserver/state.go:27-28“The key is per process and never persisted.”; wire M2.
Subscriptions and notifications
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: subscriptions/listen and the listen-stream rules of 2026, list_changed delivery per revision, request-scoped notifications, and notification hygiene in mcpx and opencode
Until 2025-11-25 a server pushes change notifications (…/list_changed, resources/updated after
resources/subscribe) whenever it has a channel to the client. 2026-07-28 replaces that with one long-lived request,
subscriptions/listen, whose filter says what the client wants; every message on it is tagged with the listen request’s
id, and progress and log messages stay on the stream of the request they belong to. mcpx implements the listen request on
stdio but gets the acknowledgement and the tagging wrong, answers it over HTTP with an empty 202, and, as a client, never
subscribes to anything upstream. Other notifications are registered with their feature: notifications/cancelled and
notifications/progress in progress-cancellation.md, notifications/message in
logging.md, notifications/roots/list_changed in roots.md,
notifications/elicitation/complete in elicitation.md, notifications/tasks/status and
notifications/tasks in tasks.md; notifications/initialized goes with lifecycle, and
resources/subscribe, resources/updated and mcpx’s mcpx:// URI rewriting (its R-7 and R-8) go with resources.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| SUB-01 | subscriptions/listen, a long-lived notification request | 2026-07-28 has | 26-07 only | partial — works on stdio; HTTP see SUB-12 | + high | M | med |
| SUB-02 | SubscriptionFilter: three list flags and resourceSubscriptions[] | 2026-07-28 has | 26-07 only (+ taskIds from the tasks extension) | ✓ — four fields mapped to daemon event kinds | + med | S | low |
| SUB-03 | Acknowledgement carries notifications and _meta subscription id | 2026-07-28 has mcpx missing | 26-07 only | ✗ — params.subscriptionId; no notifications field (#208) | + med | S | med |
| SUB-04 | Acknowledgement first; nothing on the subscription before it | 2026-07-28 has mcpx missing | 26-07 only | ✗ — listener starts before the ack is written (#208) | + low | S | low |
| SUB-05 | Every listen-stream message tagged with _meta subscription id | 2026-07-28 has specs conflict mcpx missing | 26-07 only | ✗ — list_changed and updated sent untagged (#208) | + med | S | med |
| SUB-06 | list_changed delivered only on an opted-in listen stream | 2024-11-05 has 2026-07-28 has | 24-11..25-11 unsolicited · 26-07 listen only | ✓ — gated by the filter | + med | S | low |
| SUB-07 | Progress and log messages stay on their request’s stream | 2026-07-28 has | 25-03..25-11 SHOULD relate · 26-07 MUST relate, never on listen | n/a — mcpx sends neither to hosts | + med | M | med |
| SUB-08 | Several concurrent subscriptions per client | 2026-07-28 has mcpx missing | 26-07 only | ✗ — a second listen replaces the first (#208) | + med | M | med |
| SUB-09 | Client ends a subscription: close SSE, or notifications/cancelled on stdio | 2026-07-28 has mcpx missing | 26-07 only | ✗ — stdio cancel is recorded; the stream continues (#208) | + med | S | med |
| SUB-10 | Graceful end: SubscriptionsListenResult with required subscription id | 2026-07-28 has mcpx missing | 26-07 only | ✗ — never sent; stdio exit ends silently (#208) | + low | S | low |
| SUB-11 | Server teardown: notifications/cancelled versus a final result | 2026-07-28 has specs conflict mcpx missing | 26-07 only | ✗ — neither sent; comment says the transport sends one (#208) | + low | S | low |
| SUB-12 | subscriptions/listen over Streamable HTTP | 2026-07-28 has mcpx missing 2025-03-26 has | 26-07 only | ✗ — 202 Accepted, empty body (wire W17) (#208) | + high | M | high |
| SUB-13 | subscriptions/listen from a legacy client | 2026-07-28 has | 26-07 method | acc. — accepted on stdio from any era | + low | S | low |
| SUB-14 | stdio reconnect: client re-sends listen; server keeps no state | 2026-07-28 has | 26-07 only | ✓ — subscription state dies with the stdio process | + low | S | low |
| SUB-15 | Client subscribes upstream for changes | 2026-07-28 has opencode v2 has mcpx missing 2024-11-05 has | spec: listen (26-07) or resources/subscribe; opencode v2 SDK listens | ✗ — never listens, never subscribes upstream (#200) | + med | M | med |
| SUB-16 | Client reaction to list_changed | opencode v1 has opencode v2 has | opencode v1 tools only; v2 tools, prompts, resources | ✓ — invalidates the upstream schema cache | + low | S | low |
| SUB-17 | Client notifications shrink to notifications/cancelled only | 2026-07-28 removes | 24-11..25-11 four or five kinds · 26-07 one | ✓ — sends no client progress or roots notifications | − moot | S | low |
| SUB-18 | A notification never gets a response | mcpx missing | every revision | ✗ — unknown notifications answered with -32601 (wire S5) (#202) | + med | S | med |
SUB-01 subscriptions/listen, a long-lived notification request
- What. A client request that opens a notification stream from the server, with a
notificationsfilter. It replaces the legacy HTTP GET stream andresources/subscribe, and means the same on HTTP and stdio. Its response arrives only when the stream ends gracefully (SUB-10). - Where. 2026-07-28 only.
- mcpx @ 05c78b2. Implemented and accepted from any era (
internal/mcpserver/server.go:622-640); the daemon notifier maps the filter to its event bus (internal/cli/serve.go:726-763). Without a notifier it answers-32601“this mcpx pushes no notifications”. On stdio it works apart from SUB-03, SUB-05 and SUB-08..SUB-11; over HTTP it does not (SUB-12). - Value to mcpx. + high: the only way a 2026 host learns that mcpx’s resources or prompts changed.
- Effort. M — mostly the HTTP half.
- Risk. med: see the rows below.
- Detail. The daemon notifier sends only kinds the filter asked for, as the spec requires (“a server MUST NOT send what the client did not ask for”, in the code’s words).
- Sources.
schema/2026-07-28/schema.ts:1315“subscriptions/listen”;2026-07-28/basic/patterns/subscriptions.mdx:7“opens a long-lived notification stream from the server to the”;internal/mcpserver/server.go:622-640;internal/cli/serve.go:726-763.
SUB-02 SubscriptionFilter: three list flags and resourceSubscriptions[]
- What.
toolsListChanged,promptsListChanged,resourcesListChanged(booleans) andresourceSubscriptions(URIs). All optional; omitting one means not subscribed; the server MUST NOT send types that were not requested. - Where. 2026-07-28 only. The tasks extension adds
taskIds(tasks.md). - mcpx @ 05c78b2. All four map to daemon event kinds (
internal/cli/serve.go:726-763; struct atinternal/mcpserver/server.go:77-80).resourceSubscriptionsURIs are stripped ofmcpx://<ns>/to match upstream events; that loses the namespace, which the resources register covers. - Value to mcpx. + med: done.
- Effort. S.
- Risk. low.
- Detail.
taskIdsis not accepted, consistent with mcpx having no extension tasks. - Sources.
schema/2026-07-28/schema.ts:1270“export interface SubscriptionFilter {”;2026-07-28/basic/patterns/subscriptions.mdx:49“All fields are optional. Omitting a field is equivalent to not subscribing to that”;internal/cli/serve.go:726-763.
SUB-03 Acknowledgement carries notifications and _meta subscription id
- What. For each subscription the server MUST first send
notifications/subscriptions/acknowledgedwithparams.notificationsset to the subset of the filter it will honour, and the listen request’s id in_meta["io.modelcontextprotocol/subscriptionId"]. - Where. 2026-07-28 only. The changelog says only that “the server acknowledges”; the notification is not named there.
- mcpx @ 05c78b2. Sends
params: {"subscriptionId": <id>}: the id as a top-level param instead of the_metakey, and nonotificationsfield (internal/mcpserver/server.go:637-639). Wire S4:{"method":"notifications/subscriptions/acknowledged","params":{"subscriptionId":4}}. - Value to mcpx. + med: clients use the echoed filter to learn which kinds are unsupported, and the id to correlate.
- Effort. S.
- Risk. med: a schema-invalid notification; a strict client may never believe the stream is live.
- Detail. Unsupported kinds are simply omitted from the echoed filter. The 2026
_metakey registration is in the_metaregister. - Sources.
schema/2026-07-28/schema.ts:1399“notifications/subscriptions/acknowledged”;schema/2026-07-28/schema.ts:1378“notifications: SubscriptionFilter;”;2026-07-28/basic/patterns/subscriptions.mdx:61“reflects the subset the server agreed to”;internal/mcpserver/server.go:638“"subscriptionId": json.RawMessage(req.ID),”; wire S4.
SUB-04 Acknowledgement first; nothing on the subscription before it
- What. No notification for a subscription may precede its acknowledgement. On stdio the ordering is per subscription id: messages of other subscriptions MAY come first.
- Where. 2026-07-28 only.
- mcpx @ 05c78b2.
restartListenstarts the listener goroutine (internal/mcpserver/server.go:1427-1444) before the acknowledgement is pushed (internal/mcpserver/server.go:636-639), so a change arriving in that window can be written first. - Value to mcpx. + low: a narrow race.
- Effort. S — push the ack, then start listening.
- Risk. low.
- Detail. Found as a code-order nuance, not observed on the wire.
- Sources.
2026-07-28/basic/patterns/subscriptions.mdx:56“send any notification on the”;2026-07-28/basic/patterns/subscriptions.mdx:57;internal/mcpserver/server.go:636-639.
SUB-05 Every listen-stream message tagged with _meta subscription id
- What. Every notification on a listen stream (and the acknowledgement and the graceful-close result) MUST carry
_meta["io.modelcontextprotocol/subscriptionId"]= the listen request’s id; on stdio clients MUST use it to demultiplex. - Where. 2026-07-28 only.
- mcpx @ 05c78b2. list_changed and resources/updated go out with empty or
{uri}params and no_meta(internal/events/events.go:252-261); the stdio push adds nothing (internal/mcpserver/server.go:1010-1012). - Value to mcpx. + med: required for stdio hosts with more than one subscription.
- Effort. S — tag in the push closure of a listen, not in the event mapper.
- Risk. med: 2026 hosts cannot attribute notifications.
- Detail. The same notification builder serves legacy
resources/subscribelisteners, which must not get the key. The 2026 prose examples ofnotifications/tools/list_changedand…/prompts/list_changedomit the tag themselves, contradicting the MUST. - Also recorded from the meta register. The acknowledgement sends
{"subscriptionId": <id>}as a top-level param with no_meta(internal/mcpserver/server.go:637-639; wire S4).list_changedandresources/updatedare built without_meta(internal/events/events.go:252-271) and pushed as is (internal/mcpserver/conn.go:78-85). The client never opens a listen stream, so the client side is not exercised. The same notification builders serve legacyresources/subscribeclients, for whom the key must not be added. 2026’s own prose example ofnotifications/tools/list_changedomits the key. The acknowledgement’s missingnotificationsfield and the rest of listen are in the notifications register. - Sources.
schema/2026-07-28/schema.ts:133“"io.modelcontextprotocol/subscriptionId"?: RequestId;”;2026-07-28/basic/index.mdx:412“On notifications delivered via a”;2026-07-28/basic/index.mdx:413;2026-07-28/server/tools.mdx:246“"method": "notifications/tools/list_changed"”;2026-07-28/server/prompts.mdx:177;internal/events/events.go:252-261.;schema/2026-07-28/schema.ts:1334;internal/mcpserver/server.go:638“"subscriptionId": json.RawMessage(req.ID),”;internal/events/events.go:255; wire S4.
SUB-06 list_changed delivered only on an opted-in listen stream
- What.
notifications/{tools,prompts,resources}/list_changedexist in every revision. Until 2025-11-25 a server that declaredlistChangedmay send them unsolicited; in 2026listChanged: truemeans the server sends them only on a listen stream whose filter asked. - Where. All five; the delivery model changes in 2026-07-28.
- mcpx @ 05c78b2. For 2026 (listen) delivery is gated by the filter (
internal/cli/serve.go:728-736). The legacy half is CAP-20. - Value to mcpx. + med: done for 2026 on stdio.
- Effort. S.
- Risk. low: a 2026 host that never opens a listen never hears of changes, by design.
- Detail. The capability flags are unchanged in shape; only what they promise changed. The capabilities register owns the flags themselves.
- Sources.
schema/2024-11-05/schema.ts:681“notifications/tools/list_changed”;schema/2025-11-25/schema.ts:1159“This may be issued by servers without any previous subscription from the client.”;schema/2026-07-28/schema.ts:1888“This is only delivered on a”;2026-07-28/server/tools.mdx:238“When the list of available tools changes, servers that declared the”;internal/cli/serve.go:728-736.
SUB-07 Progress and log messages stay on their request’s stream
- What. 2026:
notifications/progressandnotifications/messageflow only on the response stream of the request they relate to (they MUST relate to it), before its response, and never on a listen stream, which carries only opted-in change notifications. Legacy servers could send notifications on a POST’s stream that SHOULD relate to the request, or on the standalone GET stream. - Where. SHOULD relate in 2025-03-26..2025-11-25; MUST relate, and listen-free, in 2026-07-28.
- mcpx @ 05c78b2. Sends neither progress nor log messages to hosts (progress-cancellation.md, logging.md).
- Value to mcpx. + med: the rule to follow when mcpx starts relaying upstream progress: route it to the host request that caused it, never to a shared bus or a listen stream.
- Effort. M — per-call correlation; on stdio all streams share one channel, so correlation is by
progressTokenor request context. - Risk. med: cross-host leakage of progress or logs in a pooled proxy if done wrong.
- Detail. The same rule is stated three times in 2026: the changelog, the transport page, and the logging page. The tasks extension further forbids both on a task’s listen stream (tasks.md).
- Sources.
2026-07-28/changelog.mdx:18“Request-scoped notifications such as”;2026-07-28/basic/transports/streamable-http.mdx:115“before the final response. These notifications MUST relate to the”;2025-03-26/basic/transports.mdx:112“These messages SHOULD relate to the originating client”;2026-07-28/server/utilities/logging.mdx:69“the server MUST NOT deliver it on a”.
SUB-08 Several concurrent subscriptions per client
- What. A client MAY hold several subscriptions at once (say, tools changes and resource updates), each identified by its own listen request id.
- Where. 2026-07-28 only.
- mcpx @ 05c78b2. One stream per connection: a new listen stops the previous listener
(
internal/mcpserver/server.go:1427-1444). The comment calls replacement “the simplest faithful implementation”, but the spec explicitly allows concurrency. - Value to mcpx. + med: match the spec.
- Effort. M — per-subscription listeners, which also needs SUB-05’s tagging to be useful.
- Risk. med: a host that opens two subscriptions silently loses the first.
- Detail. The previous listen request never gets its graceful-close result either (SUB-10).
- Sources.
2026-07-28/basic/patterns/subscriptions.mdx:109“A client MAY have multiple active subscriptions concurrently”;internal/mcpserver/server.go:1425-1426“the simplest faithful implementation”;internal/mcpserver/server.go:1427-1444.
SUB-09 Client ends a subscription: close SSE, or notifications/cancelled on stdio
- What. On HTTP the client closes the listen’s SSE stream; on stdio it sends
notifications/cancellednaming the listen request id. - Where. 2026-07-28 only.
- mcpx @ 05c78b2. On stdio the cancel is only recorded (
internal/mcpserver/server.go:1521-1538); the listener keeps running and the host keeps receiving. Over HTTP there is no listen stream to close (SUB-12). - Value to mcpx. + med: a host that unsubscribes should stop receiving.
- Effort. S — when the cancelled id is the active listen, stop it.
- Risk. med: an unsubscribed host keeps getting notifications.
- Detail. The general inbound-cancel gap is in progress-cancellation.md.
- Sources.
2026-07-28/basic/patterns/subscriptions.mdx:120-121;internal/mcpserver/server.go:1533“c.cancelled[fmt.Sprint(p.RequestID)] = p.Reason”.
SUB-10 Graceful end: SubscriptionsListenResult with required subscription id
- What. When the server ends a subscription itself (shutdown, say), it SHOULD answer the original listen request with a
completeresult whose_metacarries the subscription id (required on this result type). A transport that closes without it is an unexpected disconnect, which the client MAY treat as a reason to reconnect. - Where. 2026-07-28 only.
- mcpx @ 05c78b2. Never sends it: the listen request gets no reply while open (
internal/mcpserver/server.go:622-640), andServeStdiostops the listener on exit without a message (internal/mcpserver/server.go:1014). - Value to mcpx. + low: lets clients tell shutdown from failure.
- Effort. S.
- Risk. low: clients may reconnect-loop on a clean shutdown.
- Detail.
SubscriptionsListenResultMetaObjectmakes the key required here, unlike on ordinary results. - Sources.
schema/2026-07-28/schema.ts:1334“"io.modelcontextprotocol/subscriptionId": RequestId;”;2026-07-28/basic/patterns/subscriptions.mdx:132“with a completion result before closing the stream.”;2026-07-28/basic/patterns/subscriptions.mdx:158“treat as a trigger to reconnect.”;internal/mcpserver/server.go:1014.
SUB-11 Server teardown: notifications/cancelled versus a final result
- What. Three 2026 texts disagree on how a server ends a subscription. The cancellation page: the server MUST send
notifications/cancellednaming the listen id when it tears the subscription down, and for nothing else. The subscriptions page: it SHOULD send a successful listen result, then close. The schema: the server sends that cancel “On stdio” only. - Where. 2026-07-28 only. Legacy servers could cancel any request they had issued.
- mcpx @ 05c78b2. Sends neither. The listen comment says “the transport sends the terminating notifications/cancelled
when the client closes it” (
internal/mcpserver/server.go:624-626); no code does, and the comment has the direction backwards (a client closing is a client cancel). - Value to mcpx. + low: on a graceful stdio teardown, send both the cancel and the result, which satisfies all three texts.
- Effort. S.
- Risk. low: clients waiting for one signal see the other, or nothing.
- Detail. On HTTP it is unclear whether a
notifications/cancelledshould appear on a listen SSE stream that otherwise carries only opted-in kinds. Server-sent cancellations MUST reference asubscriptions/listenid. - Sources.
2026-07-28/basic/patterns/cancellation.mdx:11“A server MUST send”;2026-07-28/basic/patterns/subscriptions.mdx:122“it SHOULD send a”;schema/2026-07-28/schema.ts:637“On stdio, the server also sends this notification, solely to terminate a”;2026-07-28/basic/patterns/cancellation.mdx:71“Server-sent cancellation notifications MUST reference a”;internal/mcpserver/server.go:624-626.
SUB-12 subscriptions/listen over Streamable HTTP
- What. A JSON-RPC request over HTTP MUST get either
application/jsonortext/event-stream. For a listen that means an SSE stream carrying the acknowledgement and then the notifications. - Where. 2026-07-28 only.
- mcpx @ 05c78b2. An HTTP connection has no push function, so
restartListenreturns at once (internal/mcpserver/server.go:1433-1437), the acknowledgement goes nowhere,handlereturns nil (internal/mcpserver/server.go:640), andServeHTTPwrites202 Acceptedwith no body (internal/mcpserver/server.go:1188-1190). Wire W17.docs/protocol.md:84presents listen as working in any era. - Value to mcpx. + high: remote 2026 hosts can never hear of changes otherwise.
- Effort. M to stream (hold the POST open and push SSE; mcpx already streams SSE for legacy elicitation); S to refuse with an error.
- Risk. high: a 2026 client waits forever for an acknowledgement, or treats 202 as a transport error.
- Detail. The capabilities are truthfully
listChanged: falseover HTTP (wire W5), yet the method is “accepted”. - Also recorded from the transports register. Any request whose handler returns no response gets 202
(
internal/mcpserver/server.go:1188-1190). That happens tosubscriptions/listenover HTTP: the HTTP connection has no push function, the listener returns early (internal/mcpserver/server.go:1433-1437), the acknowledgement goes nowhere and the handler returns nil (internal/mcpserver/server.go:640). Wire W17:202 Accepted, empty body. Listen semantics are in the notifications register; this row is the transport rule. - Sources.
2026-07-28/basic/transports/streamable-http.mdx:88-90“If the body is a JSON-RPC request, the server MUST return either”;internal/mcpserver/server.go:1188-1190;docs/protocol.md:84; wire W17.;2026-07-28/basic/transports/streamable-http.mdx:88“6. If the body is a JSON-RPC request, the server MUST return either”
SUB-13 subscriptions/listen from a legacy client
- What. A 2025-06-18 stdio client can open a 2026-style listen and get notifications.
- Where. The method is 2026-07-28’s.
- mcpx @ 05c78b2. Accepted on purpose (
internal/mcpserver/server.go:622;docs/protocol.md:84); wire S4 is a legacy client doing exactly this. It is not declared to legacy clients. - Value to mcpx. + low: accept-liberally, harmless.
- Effort. S — done.
- Risk. None.
- Detail. The acknowledgement a legacy client gets has the same wrong shape (SUB-03).
- Sources.
internal/mcpserver/server.go:622;docs/protocol.md:84; wire S4.
SUB-14 stdio reconnect: client re-sends listen; server keeps no state
- What. On stdio, after the connection is re-established the client MUST send
subscriptions/listenagain; the server holds no subscription state across reconnections. - Where. 2026-07-28 only.
- mcpx @ 05c78b2. As a server, subscription state lives on the stdio connection and dies with the process
(
internal/mcpserver/server.go:1014). As a client it never listens, so it has nothing to re-send (SUB-15). - Value to mcpx. + low: nothing to change server-side.
- Effort. S.
- Risk. low.
- Detail. None.
- Sources.
2026-07-28/basic/patterns/subscriptions.mdx:161“client MUST re-send”;2026-07-28/basic/patterns/subscriptions.mdx:162“the server holds no subscription state across reconnections.”.
SUB-15 Client subscribes upstream for changes
- What. To learn about upstream changes a client opens
subscriptions/listento a 2026 server (the only way to get list_changed there) or callsresources/subscribeon a legacy one; unsolicited legacy list_changed needs no request. - Where. 2026 listen; legacy
resources/subscribe. opencode v2’s SDK opens the listen itself on modern connections. - mcpx @ 05c78b2. The pool never opens a listen to a modern upstream and never calls
resources/subscribeon a legacy one;Client.SubscribeResourceexists and nothing calls it (internal/mcpclient/client.go:536-543). Its comment defers modern servers to “the listening stream”, which the client never opens (internal/mcpclient/client.go:532-535). Cache invalidation therefore depends on unsolicited legacy notifications (internal/daemon/hooks.go:37-51). - Value to mcpx. + med: fresh catalogues from modern and remote upstreams.
- Effort. M.
- Risk. med: stale tool lists from modern upstreams until
mcpx refresh. - Detail. A modern listen would also have to be re-opened after an upstream stdio restart (SUB-14’s MUST applies to mcpx as a client).
- Also recorded from the resources register. Every revision (by one mechanism or the other).
Client.SubscribeResourceexists (internal/mcpclient/client.go:536-543) and nothing calls it; the pool never openssubscriptions/listento a modern upstream. A host’s subscription starts a listener on the daemon’s event bus, which seesresources/updatedonly if an upstream sends it unasked.docs/protocol.md:83says subscribe “forwards from the same bus”.SubscribeResourceis a deliberate no-op for modern upstreams, deferring to “the listening stream”, which does not exist (notifications register). - Sources.
internal/mcpclient/client.go:532-535“that is handled by”;internal/mcpclient/client.go:536-543;internal/daemon/hooks.go:37-51;v2:packages/core/src/mcp/client.ts:132“the SDK opens the subscriptions/listen stream behind these itself.”.;internal/mcpclient/client.go:536“func (c *Client) SubscribeResource(ctx context.Context, uri string) error {”;docs/protocol.md:83
SUB-16 Client reaction to list_changed
- What. What a client does when told a list changed.
- Where. opencode v1 handles
tools/list_changedonly, and only when the server declarestools: it re-lists tools and publishesToolsChanged. opencode v2 re-lists tools (reconcile debounced by 100 ms), refreshes prompts (republished as slash commands) and publishesResourcesChanged. - mcpx @ 05c78b2. As a client, list_changed invalidates the upstream schema cache (
internal/daemon/hooks.go:37-51;internal/mcpclient/client.go:495-500). As a server, its own tool list is fixed once built, so a host refreshing mcpx’s tools gains nothing; prompts and resources are where changes would show (CAP-20). - Value to mcpx. + low: v2 hosts would show mcpx prompt changes live if mcpx sent them.
- Effort. S.
- Risk. low.
- Detail. v1 fetches prompts fresh on every command listing, so ignoring
prompts/list_changedcosts it little. - Sources.
v1:packages/opencode/src/mcp/index.ts:462;v1:packages/opencode/src/mcp/index.ts:461;v2:packages/core/src/tool/mcp.ts:140;v2:packages/core/src/mcp/index.ts:374;v2:packages/core/src/mcp/index.ts:375;internal/daemon/hooks.go:37-51.
SUB-17 Client notifications shrink to notifications/cancelled only
- What. 2026 clients can no longer send
notifications/initialized,notifications/progress,notifications/roots/list_changedornotifications/tasks/status;ClientNotificationisCancelledNotificationalone. - Where. Legacy client notifications: cancelled, progress, initialized, roots/list_changed (+ tasks/status in 2025-11-25).
- mcpx @ 05c78b2. As a client it sends no progress and declares roots without
listChanged, so onlyinitialized(legacy) andcancelledgo upstream. - Value to mcpx. − moot.
- Effort. S.
- Risk. None.
- Detail. Client-to-server progress vanishes because server-initiated requests vanish; the changelog never says so.
Over 2026 HTTP not even
cancelledis defined (see progress-cancellation.md). - Sources.
schema/2025-11-25/schema.ts:2525“export type ClientNotification =”;schema/2026-07-28/schema.ts:3166“export type ClientNotification = CancelledNotification;”.
SUB-18 A notification never gets a response
- What. In every revision the receiver of a notification MUST NOT reply.
- Where. Every revision (JSON-RPC and MCP both).
- mcpx @ 05c78b2. Any notification other than
notifications/initialized,initializedandnotifications/cancelledfalls through tofail(-32601, "no method …")(internal/mcpserver/server.go:766), and the stdio loop writes it (internal/mcpserver/server.go:1063-1066); over HTTP it goes out as 200 with an error body instead of 202. Wire S5:notifications/roots/list_changedandnotifications/progresseach got an id-less-32601. - Value to mcpx. + med: protocol hygiene; legacy hosts that declare
roots.listChangedtrigger it on every change. - Effort. S — return nothing when the message has no id; an unknown request must still get
-32601. - Risk. med: hosts log or fail on unexpected error frames.
- Detail. A send-conservatively conflict. Wire W26 shows the known case working:
notifications/cancelledover HTTP gets 202. - Sources.
2025-03-26/basic/index.mdx:78“The receiver MUST NOT send a response.”;2026-07-28/basic/index.mdx:160“The receiver MUST NOT send a response.”;internal/mcpserver/server.go:766“return fail(codeMethodNotFound, "no method "+req.Method)”;internal/mcpserver/server.go:1063-1066; wire S5, W26.
Pagination and caching
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-10-29T02:40:00-05:00
increment: 1
status: standard
tags: area:compare
description: cursor rules per revision, 2026 cacheable results (ttlMs, cacheScope), list stability, and how mcpx and opencode page and cache
Every list method pages with an opaque cursor in every revision; 2026 adds caching hints (ttlMs, cacheScope) that
six results MUST carry, and tightens what a cursor and a list may be. PG-08..PG-13 were trued up on 2026-10-29
(#1604, Phase 3 of design #1601): mcpx’s server now stamps both cache fields on complete cacheable results for
modern peers, and its client reads, aggregates and honours them – the pool catalogue cache expires at the
earliest declared ttlMs for modern upstreams, and cacheScope: "private" catalogues never reach the disk
cache. What matters most in the rest of this register at 05c78b2 is that its client stops paging on an empty
nextCursor, which 2026 says is a valid cursor. Deterministic tools/list order is TOOL-23 in the tools register.
Status column: mcpx @ 05c78b2, except PG-08..PG-13, which read mcpx @ c58507a (post-#1601, trued up by #1604). Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| PG-01 | opaque cursors; “don’t persist across sessions” dropped in 2026 | 2024-11-05 has 2026-07-28 has | 24-11..25-11 ✓ (session-bound) · 26-07 ✓ (no sessions) | ✓ — stateless encoded offsets | + low | S | low |
| PG-02 | "" is a valid cursor and MUST NOT end the list | 2026-07-28 has mcpx missing | 26-07 ✓ only · opencode v1 ✓ (only undefined ends) | ✗ — client stops paging on "" (#200) | + med | S | med |
| PG-03 | invalid cursor SHOULD be -32602 | 2024-11-05 has mcpx missing | all five ✓ (SHOULD) | ✗ — undecodable cursor silently restarts at zero (#202) | + low | S | low |
| PG-04 | servers SHOULD provide stable cursors | 2024-11-05 has | all five ✓ (SHOULD); 26-07 no cross-page guarantee | partial — offsets shift if the list changes | + low | S | low |
| PG-05 | page size is the server’s choice | 2024-11-05 has | all five ✓ | ✓ — mcp.pageSize, default 100 | + low | S | low |
| PG-06 | client page-following caps | opencode v1 has opencode v2 has | opencode v1 1000 pages · v2 64 pages · spec silent | partial — 100 pages, silent truncation, no loop check | + low | S | low |
| PG-07 | mcpx client swallows list errors and skips template pages | 2024-11-05 has mcpx missing | mcpx client | ✗ — prompts/list and templates errors hidden (#203) | + low | S | med |
| PG-08 | six results MUST carry cache hints; changelog names five | 2026-07-28 has specs conflict | 26-07 ✓ only | ✓ — server stamps both fields on complete cacheable results for modern peers (#199); client reads and aggregates them on list results (#1601 Phase 1, PR #1608) | + med | S | med |
| PG-09 | ttlMs: integer ms ≥ 0; absent means 0 | 2026-07-28 has | 26-07 ✓ only | ✓ — emitted by the server (Cache.List / Cache.Read); honoured by the client: a modern pool cache expires at fetch + min declared ttlMs, absent/0/negative is immediately stale (#1602) | + med | S | med |
| PG-10 | cacheScope: "public" or "private" | 2026-07-28 has | 26-07 ✓ only | ✓ — emitted by the server; honoured by the client at the disk boundary: private catalogues never persist, public entries carry deadline + scope (#1603, PR #1705) | + med | S | high |
| PG-11 | per-page caching: own TTL clock, same scope on every page | 2026-07-28 has | 26-07 ✓ only | ✓ by construction — pages are aggregated into one catalogue cache with the minimum TTL across pages and private if any page is private; an expired catalogue is re-fetched whole, never per page (#1601) | + low | S | low |
| PG-12 | MRTR retries and input_required results never cached | 2026-07-28 has | 26-07 ✓ only | ✓ — mcpx caches no MRTR result in either direction; the server stamps hints only on resultType: "complete" results, so input_required carries none | + low | S | low |
| PG-13 | mcpx’s upstream schema cache ignores ttlMs | 2026-07-28 has | 26-07 (client MAY cache per ttlMs) | ✓ (modern era) — the pool cache expires at the declared deadline and a failed refetch of an expired entry serves stale for that call only; legacy upstreams keep cache-until-list_changed by design (#1601); disk persistence is scope-aware (#1603) | + med | S | med |
| PG-14 | lists MUST NOT vary per connection; MAY vary by authorization | 2026-07-28 has | 26-07 ✓ only (tools, prompts, resources) | ✓ — lists are per daemon | + med | S | med |
PG-01 opaque cursors and persistence
- What. Cursors are opaque strings in every revision. 2024-11-05 through 2025-11-25 also tell clients not to persist cursors across sessions; 2026 drops that line (there are no sessions) and replaces it with PG-02’s rule.
- Where. All five; the persistence rule is gone in 2026.
- mcpx @ 05c78b2.
nextCursorisbase64url("o:<offset>")(internal/mcpserver/server.go:1477-1505), encoded so that clients are not tempted to parse it (internal/mcpserver/server.go:1472-1476). It depends on no server state, so it stays valid across connections — what 2026 now permits. - Value to mcpx. + already stateless.
- Effort. S.
- Risk. Low.
- Detail. Being stateless, the cursor is also unsigned and unversioned (PG-04).
- Sources.
2024-11-05/server/utilities/pagination.mdx:17“- The cursor is an opaque string token, representing a position in the result set”;2025-11-25/server/utilities/pagination.mdx:93“- Don’t persist cursors across sessions”;internal/mcpserver/server.go:1477-1505
PG-02 the empty-string cursor
- What. 2026: clients MUST NOT decide anything from a cursor’s value except whether a non-null one was given; an
empty string is a valid cursor and MUST NOT be treated as the end of results. Only an absent or null
nextCursorends the list. - Where. 2026 only. opencode v1 already stops only on
undefined. - mcpx @ 05c78b2. As a server it never emits
""(it addsnextCursoronly when non-empty,internal/mcpserver/server.go:551-555). As a client it stops whenNextCursor == ""(internal/mcpclient/client.go:744,internal/mcpclient/client.go:766,internal/mcpclient/client.go:820), and a Gostringcannot tell""from absent, so a 2026 upstream that uses""as a real cursor is read as one page. - Value to mcpx. + complete catalogues from every conformant 2026 server.
- Effort. S — decode
nextCursoras*string. - Risk. If not fixed: silent truncation of an upstream’s tools, resources or prompts.
- Detail. mcpx’s server treats an incoming
cursor: ""as “start” (internal/mcpserver/server.go:1483) — harmless, since it never issues one. - Sources.
2026-07-28/server/utilities/pagination.mdx:106“non-null value was provided (e.g. an empty string is a valid cursor and”;internal/mcpclient/client.go:744“if res.NextCursor == “;v1:packages/opencode/src/mcp/catalog.ts:29
PG-03 invalid cursors
- What. An invalid cursor SHOULD produce
-32602(Invalid params). The 2026 schema’sInvalidParamsErrordoc names “invalid or expired cursor values” and ships an example. - Where. All five (SHOULD).
- mcpx @ 05c78b2. An undecodable cursor is ignored and the list restarts at offset 0
(
internal/mcpserver/server.go:1483-1486). - Value to mcpx. + clients notice a stale cursor instead of looping or duplicating.
- Effort. S.
- Risk. Low. In 2026 an error is also the signal for a client to discard its cached pages (PG-11); a silent restart never sends it.
- Detail. None beyond the above.
- Sources.
2024-11-05/server/utilities/pagination.mdx:94“Invalid cursors SHOULD result in an error with code -32602 (Invalid params).”;2026-07-28/server/utilities/pagination.mdx:111;schema/2026-07-28/schema.ts:368;internal/mcpserver/server.go:1483-1486
PG-04 stable cursors
- What. Servers SHOULD provide stable cursors. 2026’s caching page adds that there is no cross-page consistency guarantee: if data changes between pages, clients may see duplicates or gaps, and those wanting a snapshot SHOULD re-fetch from the start.
- Where. All five (SHOULD); the relaxation is 2026.
- mcpx @ 05c78b2. Cursors are plain offsets, not tied to a list version, so a list that changes between pages
(an upstream reload, an adapter added) shifts silently (
internal/mcpserver/server.go:1477-1505). - Value to mcpx. + low:
tools/listfits one page today (61 < 100). - Effort. S — version or sign the cursor.
- Risk. Low, and within what 2026 explicitly tolerates.
- Detail. mcpx’s own tool list never changes after it is built (TOOL-24, CAP-20), so only resource and prompt lists can shift.
- Sources.
2024-11-05/server/utilities/pagination.mdx:80“- Provide stable cursors”;2026-07-28/server/utilities/caching.mdx:158“- There is no cross-page consistency guarantee. If the underlying data changes between”
PG-05 page size
- What. The server decides the page size; clients MUST NOT assume a fixed one.
- Where. All five.
- mcpx @ 05c78b2.
mcp.pageSize(internal/cli/serve.go:309), default"100"written inline in the settings registry (internal/settings/registry.go:417), with a second inlinereturn 100fallback (internal/mcpserver/server.go:1469). All four list methods use it. - Value to mcpx. + low.
- Effort. S — move both defaults into
defaults.json. - Risk. Low. The page size is configurable; only its two defaults are inline constants, against mcpx’s “nothing hardcoded” rule.
- Detail. With opencode v2’s 64-page cap (PG-06), 100 per page allows 6,400 entries before v2 stops reading.
- Sources.
2024-11-05/server/utilities/pagination.mdx:18“- Page size is determined by the server, and clients MUST NOT assume a fixed page”;internal/cli/serve.go:309“srv.PageSize = a.Settings().Int(”;internal/settings/registry.go:417;internal/mcpserver/server.go:1469“return 100”
PG-06 client page-following caps
- What. No revision caps how many pages a client follows; clients choose a cap to survive a server that never stops.
- Where. opencode v1 pages itself up to 1,000 pages and errors on a repeated cursor; opencode v2 relies on its SDK, which aggregates up to 64 pages.
- mcpx @ 05c78b2.
ListTools,ListResourcesandListPromptsfollow at most 100 pages (internal/mcpclient/client.go:734,internal/mcpclient/client.go:756,internal/mcpclient/client.go:807), then return what they have without an error, and do not detect a repeated cursor. - Value to mcpx. + low.
- Effort. S — report truncation, detect loops, move the literal into settings.
- Risk. A server that repeats a cursor costs 100 round trips; a very large one is truncated without notice.
- Detail. The cap is an inline
100in three loops. - Sources.
internal/mcpclient/client.go:734“for i := 0; i < 100; i++ {”;v1:packages/opencode/src/mcp/catalog.ts:12“const MAX_LIST_PAGES = 1_000”;v1:packages/opencode/src/mcp/catalog.ts:30; https://unpkg.com/@modelcontextprotocol/client@2.0.0/dist/index.mjs L2904
PG-07 mcpx client swallows list errors
- What. How a list reader treats an error mid-listing.
- Where. mcpx client.
- mcpx @ 05c78b2.
ListPromptsreturns whatever it has on any error, as if the server had no prompts (internal/mcpclient/client.go:813-818).ListResourcesrequestsresources/templates/listonce, without a cursor, and ignores its error (internal/mcpclient/client.go:772);ListResourceTemplatesdoes not page either (internal/mcpclient/client.go:556-562). - Value to mcpx. + low.
- Effort. S — distinguish
-32601from other errors; follow template cursors. - Risk. A transient error hides a server’s prompts until the next refresh, because the empty list is cached (PG-13); templates past the first page never appear.
- Detail. The pool calls these only when the server declared the capability, so “no prompts” is not the likely meaning of an error there.
- Sources.
internal/mcpclient/client.go:813-818“A server without prompts answers method-not-found”;internal/mcpclient/client.go:772;internal/mcpclient/client.go:556-562
PG-08 which results must carry cache hints
- What. In 2026, complete results of
server/discover,tools/list,prompts/list,resources/list,resources/templates/listandresources/readMUST carryttlMsandcacheScope.prompts/get,completion/complete,tools/calland anyinput_requiredresult do not. A cached response is keyed by the method plus the parameters that affect it (uri,cursor). - Where. 2026 only. The changelog lists five methods and omits
server/discover; the caching page and the schema (DiscoverResult extends CacheableResult) include it. - mcpx @ c58507a. Both halves landed. As a server, mcpx stamps
ttlMsandcacheScopeon every complete cacheable result for modern peers (envelope ininternal/mcpserver/server.go, scope table andCachevalues from theprotoMessageslist/read max-age settings; legacy peers have both fields stripped,internal/mcpserver/revisions.go). As a client, the list result types carryttlMs/cacheScope(internal/mcpclient/client.go) and theList*WithHintsvariants aggregate them across pages intoCacheHints(minimum declared TTL; private if any page is private) – #1601 Phase 1, PR #1608. - Value to mcpx. + schema-required; hosts could cache mcpx’s small, fixed tool surface.
- Effort. S — stamp both fields for modern peers alongside
resultType. (Done.) - Risk. Strict 2026 clients reject the results as schema-invalid.
- Detail.
resources/readis cacheable whileprompts/getis not — an asymmetry the schema makes explicit. - Sources.
2026-07-28/server/utilities/caching.mdx:13“Servers MUST include caching hints on results with”;2026-07-28/changelog.mdx:36“RequirettlMsandcacheScopefields on results returned by”;schema/2026-07-28/schema.ts:678“export interface DiscoverResult extends CacheableResult {”;schema/2026-07-28/schema.ts:1779“export interface ListToolsResult extends PaginatedResult, CacheableResult {”;schema/2026-07-28/schema.ts:1229“export interface ReadResourceResult extends CacheableResult {”;schema/2026-07-28/schema.ts:1634“export interface GetPromptResult extends Result {”;2026-07-28/server/utilities/caching.mdx:31
PG-09 ttlMs
- What. An integer number of milliseconds the result may be considered fresh, like
Cache-Control: max-age. Servers MUST send a value>= 0.0means immediately stale; absent means clients assume0; negative is treated as0. TTL is not a polling interval; clients that poll MUST add jitter and backoff. Alist_changednotification invalidates a fresh cached list. - Where. 2026 only.
- mcpx @ c58507a. Emitted by the server (
Cache.Listcovers discover and the list methods,Cache.Readcoversresources/read; a negative configured value is clamped to 0). Honoured by the client for modern upstreams:Pool.refreshSchemassets the catalogue deadline to fetch time + the minimum effective TTL across the lists actually fetched, where a list that declared nottlMs– or 0, or a negative – makes the catalogue stale immediately;Pool.Schemasre-lists once the deadline passes. Legacy upstreams keep a zero deadline: their cache never expires on a clock (#1601 Phase 1, PR #1608). - Value to mcpx. + mcpx’s own tool list is fixed for the daemon’s life, so a long TTL is honest for
tools/list;ttlMs: 0is always legal where freshness is unknown. - Effort. S.
- Risk. As PG-08.
- Detail. A server MAY send
ttlMswithoutlistChanged, relying on TTL alone. - Sources.
schema/2026-07-28/schema.ts:1094“ttlMs: number;”;2026-07-28/server/utilities/caching.mdx:56“- IfttlMsis absent, clients SHOULD assume a default of0(immediately stale)”;2026-07-28/server/utilities/caching.mdx:60“Servers MUST provide attlMsvalue that is>= 0.”;2026-07-28/server/utilities/caching.mdx:81
PG-10 cacheScope
- What.
"public": no user-specific data; any client, gateway or proxy MAY share it across users."private": reuse only within the same authorization context; caches MUST NOT be shared across contexts. Servers MUST NOT rely oncacheScopefor access control. - Where. 2026 only.
- mcpx @ c58507a. Emitted by the server per method (scope table in
internal/mcpserver/server.go). Honoured by the client at the disk boundary (#1601 Phase 2, PR #1705):Registry.SaveCachenever persists a private catalogue, public entries are written with their absolute deadline and scope, andloadCachenever seeds a private entry – a public entry whose deadline already passed seeds expired, so the first read re-lists. In memory, one private page marks the whole aggregated list private. - Value to mcpx. +
"private"is the honest answer for a per-user daemon’s resources;"public"fits mcpx’s own tool list, which is identical for every caller. As a gateway in front of several hosts mcpx must honour"private"from upstreams by keying any cache on the upstream credential. - Effort. S to emit; M to honour upstream scopes in a shared cache.
- Risk. High if gotten wrong:
"public"on a per-user result lets a shared cache leak one user’s resources to another. - Detail. The spec’s own guidance:
"public"for lists identical for all users,"private"for user-dependentresources/readresults and per-user filtered lists. - Sources.
schema/2026-07-28/schema.ts:1109“cacheScope: “;2026-07-28/server/utilities/caching.mdx:101;2026-07-28/server/utilities/caching.mdx:178“- MUST apply appropriate per-primitive access controls, and MUST NOT rely on”
PG-11 caching paginated lists
- What. Each page is cached independently: its own
ttlMsand freshness clock (servers MAY vary TTL per page), all pages of one list MUST share onecacheScope, an expired page is re-fetched by its cursor, and a cursor that becomes invalid means discarding every cached page and starting over. - Where. 2026 only.
- mcpx @ c58507a. Satisfied by construction, in the conservative direction: mcpx’s client aggregates a
list’s pages into a single catalogue cache, so there are no per-page clocks to keep – the aggregate takes
the minimum TTL declared on any page and is private if any page is private, and an expired catalogue is
re-fetched whole (all pages) rather than page by page. This is why the conformance gap
caching-page-refetchwas retired as not applicable by construction (#1604). mcpx’s silent restart on a bad cursor (PG-03) remains a server-side concern for its own caching clients. - Value to mcpx. + low.
- Effort. S.
- Risk. Low.
- Detail. None beyond the above.
- Sources.
2026-07-28/server/utilities/caching.mdx:153“- Each page response carries its ownttlMsvalue. The freshness clock for each page”;2026-07-28/server/utilities/caching.mdx:162;2026-07-28/server/utilities/caching.mdx:166“Servers MUST apply the samecacheScopeto all response pages for a given list”
PG-12 MRTR retries are never cached
- What.
input_requiredresults carry no cache hints, and results of retried requests — those carryinginputResponsesorrequestState— MUST NOT be cached, because they depend on inputs outside the cache key. - Where. 2026 only.
- mcpx @ c58507a. ✓ — mcpx caches no MRTR result in either direction, and the server stamps cache hints
only when the result’s
resultTypeis"complete", so aninput_requiredresult carries none. - Value to mcpx. + a guardrail for any future cache.
- Effort. S.
- Risk. None today.
- Detail. A
resources/readthat went through an MRTR round is cacheable by method but not by this rule. - Sources.
2026-07-28/server/utilities/caching.mdx:23-25;2026-07-28/server/utilities/caching.mdx:36“is, requests carryinginputResponsesorrequestState—MUST NOT be cached,”
PG-13 mcpx’s upstream schema cache ignores ttlMs
- What. A 2026 client MAY treat an upstream list as fresh for
ttlMsand SHOULD re-fetch once stale. - Where. 2026.
- mcpx @ c58507a. Fixed for the modern era by #1601 (Phases 1-2, PRs #1608 and #1705): the client’s list
result types carry the hint fields, the pool cache expires at the earliest declared deadline, and the disk
cache is scope-aware (PG-10). Legacy upstreams are unchanged by design – the TTL rules exist only in
2026-07-28, and legacy’s invalidation channel is its notifications. A
list_changednotification empties the cache in both eras (internal/daemon/hooks.goOnListChanged ->Pool.Invalidate), now covered bydaemon.TestListChangedInvalidatesPoolCache(#1604), which retired the conformance gapcaching-notification-invalidates. Two sibling gaps were retired with reasons in the same pass:caching-may-refetch-early-serve-stale– declined by decision on #1601 (refresh is synchronous on read; stale is served only when a refetch of an expired entry fails, #1603), andcaching-key-method-params– not applicable by construction (the pool cache is one catalogue per configured server, fetched by fixed list methods; it is never keyed by method+params andresources/readresults are not cached by the pool). - Value to mcpx. + fresher catalogues from modern upstreams, which today send mcpx no
list_changedat all because it never opens a listen stream (notifications register). - Effort. S.
- Risk. Stale catalogues until
mcpx refresh. - Detail.
cacheScope: "private"would matter only if mcpx shared one cache across authorization contexts; the cache is per configured server, and each server carries one credential. - Sources.
internal/pool/pool.go:446-455;internal/mcpclient/client.go:131-134;2026-07-28/changelog.mdx:36
PG-14 lists must not vary per connection
- What.
tools/list,prompts/listandresources/listMAY change over time but MUST NOT vary per connection or as a side effect of other requests; they MAY vary by the authorization presented on the request. - Where. 2026 only.
- mcpx @ 05c78b2. mcpx’s tool list is built once per daemon (
internal/cli/serve_ask.go:242) and resource and prompt listings come from per-server schema caches (internal/pool/pool.go:121-128), so no list depends on which connection asks. - Value to mcpx. + keeps mcpx cacheable; rules out “load a namespace’s tools into this session after the agent asks for it”, a pattern mcpx should not adopt over MCP.
- Effort. S (policy).
- Risk. Per-connection lazy tool expansion would break 2026 caching.
- Detail. This is the list half of the 2026 statelessness change; the ordering half is TOOL-23 in the tools register.
- Sources.
2026-07-28/server/tools.mdx:65“but MUST NOT vary”;2026-07-28/server/tools.mdx:67“MAY vary by the authorization presented on the request”;2026-07-28/server/prompts.mdx:60;2026-07-28/server/resources.mdx:77;internal/cli/serve_ask.go:242
Plugin APIs (opencode v1 and v2) and harness integration
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: Every opencode v1 vs v2 plugin-API difference, the state of mcpx's opencode plugin under each, and how code execution meets the harness.
opencode v2 replaces the v1 plugin API wholesale: a new package name, a {id, setup} module instead of a function that
returns hooks, a Context of domains instead of an SDK client, and a hook set in which shell.env no longer carries a
session id. mcpx’s plugin is v1-only and uses four hooks (shell.env PLG-13, experimental.chat.system.transform
PLG-16, the tool map PLG-26, tool.execute.after PLG-24); none of them works unchanged under v2, and one has never
worked under v1. The more robust v2 route for session identity is not a plugin at all: v2 sends
_meta["ai.opencode/sessionID"] on every MCP call (the _meta and process-model areas), and v2’s code mode will wrap
mcpx itself unless mcpx is configured codemode: false.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| PLG-01 | Plugin package @opencode-ai/plugin renamed @opencode/plugin | opencode v1 has opencode v2 has mcpx missing | opencode v1 @opencode-ai/plugin · v2 @opencode/plugin | ✗ — both files import the v1 name (#47) | + high | S | high |
| PLG-02 | Module shape: hooks-returning function vs {id, setup|effect} | opencode v1 has opencode v2 has mcpx missing | opencode v1 function or {id?, server} · v2 {id, setup|effect} | ✗ — v1 shape; v2 rejects the file (#47) | + high | M | high |
| PLG-03 | v1 legacy loader calls every export of a plugin file | opencode v1 has has better replacement | opencode v1 ✓ · v2 default export only | ✓ — helpers kept in a subdirectory | + low | S | med |
| PLG-04 | Plugin discovery: flat file glob vs files or directories | opencode v1 has opencode v2 has | opencode v1 {plugin,plugins}/*.{ts,js} · v2 + directories | partial — mcpx/ subdirectory becomes a package in v2 | + low | S | med |
| PLG-05 | Config key plugin vs plugins; where options arrive | opencode v1 has opencode v2 has | opencode v1 plugin · v2 plugins + legacy plugin | partial — config carries over, module does not | + low | S | low |
| PLG-06 | v1 also runs the v2 plugin host | opencode v1 has | opencode v1 ✓ | n/a — mcpx ships the v1 shape | + low | S | low |
| PLG-07 | Plugin runtime: Bun vs build-dependent | opencode v1 has opencode v2 has | opencode v1 Bun · v2 Bun build, Node build exists | partial — assumes Bun fetch({unix}) | + low | S | low |
| PLG-08 | Plugin lifecycle: dispose vs setup cleanup and hot reload | opencode v1 has opencode v2 has | opencode v1 dispose · v2 cleanup + file-watch reload | n/a — no dispose used | + low | S | low |
| PLG-09 | Plugin instance per directory vs per Location | opencode v1 has opencode v2 has | opencode v1 per directory · v2 per Location | partial — “session” cache is per place | + med | S | med |
| PLG-10 | client SDK and serverUrl in plugin input | opencode v1 has opencode v2 has | opencode v1 ✓ · v2 absent (domains instead) | ✗ — uses client.session.get, client.tui.showToast | + med | M | high |
| PLG-11 | directory/worktree/project vs ctx.location | opencode v1 has opencode v2 has | opencode v1 fields · v2 ctx.location | partial — destructures v1 fields | + low | S | low |
| PLG-12 | Bun $ shell handed to plugins | opencode v1 has has no replacement | opencode v1 ✓ · v2 — | ✓ — uses execFile and the socket | + low | S | low |
| PLG-13 | shell.env hook vs shell.hook("create.before") without session | opencode v1 has opencode v2 has mcpx missing | opencode v1 sessionID, callID · v2 neither | ✗ — MCPX_SESSION_ID lost under v2 (#47) | + high | M | high |
| PLG-14 | v1 shell.env has no session id on PTY terminals | opencode v1 has | opencode v1 bash tool and ! shell ✓ · PTY — | partial — terminal-run mcpx gets no session | + low | S | low |
| PLG-15 | v2 per-session environment route replaces the whole env | opencode v2 has | opencode v2 only; TUI-owned; in-memory | n/a — docs propose it; it would break shells | − harmful | S | high |
| PLG-16 | experimental.chat.system.transform output is {system: string[]} | opencode v1 has mcpx has | opencode v1 ✓ · v2 no hook of that name | ✓ fixed — pushes the text onto output.system (#1938; regression test plugin/opencode/mcpx/instructions.test.ts) | + med | S | med |
| PLG-17 | v2 session.hook("context") edits system, messages, tools | opencode v2 has | opencode v1 three hooks · v2 one; invented tools dropped | n/a — not used | + med | S | med |
| PLG-18 | chat.message vs session.hook("prompt") | opencode v1 has opencode v2 has | opencode v1 chat.message · v2 prompt | n/a — not used | + low | S | low |
| PLG-19 | chat.params/chat.headers vs context.options/model.request.headers | opencode v1 has opencode v2 has | opencode v1 two hooks · v2 context, model.request (+ v2-only) | n/a — not used | + low | S | low |
| PLG-20 | experimental.session.compacting vs session.hook("compaction") | opencode v1 has opencode v2 has | opencode v1 hook · v2 compaction | n/a — not used | + low | S | low |
| PLG-21 | v1 hooks with no v2 hook of the same purpose | opencode v1 has | opencode v1 only | n/a — mcpx uses none | + low | S | low |
| PLG-22 | event hook vs ctx.event.subscribe() stream | opencode v1 has opencode v2 has | opencode v1 pushed · v2 subscribed | n/a — not used | + low | S | low |
| PLG-23 | tool.execute.before: v2 adds session and mutable input | opencode v1 has opencode v2 has | opencode v1 {args} · v2 {sessionID, agent, messageID, id, input} | n/a — not used | + med | M | med |
| PLG-24 | tool.execute.after: success-only vs status union | opencode v1 has opencode v2 has has better replacement | opencode v1 success only · v2 completed or error | partial — reads title, undefined for MCP | + low | S | low |
| PLG-25 | permission.ask declared but dead vs live evaluate hook | opencode v1 has opencode v2 has has better replacement | opencode v1 never triggered · v2 live | n/a — not used | + low | S | low |
| PLG-26 | Defining a tool: zod map vs ctx.tool.transform editor | opencode v1 has opencode v2 has | opencode v1 map, un-namespaced · v2 editor, optional namespace | partial — v1 map only | + high | M | med |
| PLG-27 | v2 plugin tools hide behind code mode by default | opencode v2 has mcpx missing | opencode v2 codemode !== false · v1 plugin tools direct | ✗ — no v2 port; it would need codemode: false (#47) | + high | S | high |
| PLG-28 | A plugin tool can ask the user | opencode v1 has has no replacement | opencode v1 ToolContext.ask · v2 — | partial — mcpx_daemon_select relies on it | − lost | M | med |
| PLG-29 | Toast from a server plugin | opencode v1 has opencode v2 has | opencode v1 client.tui.showToast · v2 TUI plugin only | partial — v1 toast only | + med | M | med |
| PLG-30 | TUI plugin shape and dialogs | opencode v1 has opencode v2 has mcpx missing | opencode v1 {id?, tui} JSX · v2 {id, setup} promises | ✗ — v1 TUI plugin only (#47) | + med | M | low |
| PLG-31 | ctx.rpc between server and TUI plugins | opencode v2 has | opencode v1 — · v2 ✓ | n/a — v1 uses a file as the channel | + med | M | low |
| PLG-32 | Plugin storage | opencode v1 has opencode v2 has | opencode v1 TUI kv only · v2 ctx.storage | n/a — own remember file | + low | S | low |
| PLG-33 | Calling a model from a plugin | opencode v1 has opencode v2 has | opencode v1 full turn · v2 ctx.generate.text | n/a — not examined | + med | M | low |
| PLG-34 | Plugins add or change MCP servers (Location-scoped) | opencode v2 has | opencode v1 config hook · v2 ctx.mcp.transform/reload/list | n/a — not used | + med | S | med |
| PLG-35 | ?codemode=false appended to remote MCP URLs | opencode v2 has mcpx honours, does not send | opencode v2 client | ◐ — honoured on /mcp (raw tools, namespaced); mcpx as a client still does not send it (#1940) | + med | M | med |
| PLG-36 | Hooks and permission checks per child call in code mode | opencode v1 has opencode v2 has mcpx missing | opencode v1/v2 per child · mcpx one opaque call | ✗ — script calls bypass opencode permissions (#218) | + high | L | high |
| PLG-37 | Live status of each child call during a script | opencode v1 has opencode v2 has mcpx missing | opencode v1 metadata · v2 progress | ✗ — plugin makes one blocking POST (#218) | + med | S | low |
| PLG-38 | Cancelling in the harness stops the script | opencode v1 has opencode v2 has mcpx missing | opencode v1 abort signal · v2 fiber interrupt | ✗ — plugin ignores the abort signal (#218) | + med | S | med |
| PLG-39 | Harness session and working directory reach the script | opencode v2 has mcpx missing | opencode v2 _meta on child calls · mcpx bash path only | ✗ — plugin mcpx_exec sends neither (#189) | + high | S | high |
| PLG-40 | Plugin mcpx_exec reads a field the daemon never returns | mcpx missing | mcpx plugin only | ✗ broken — whole envelope shown to model (#189) | + med | S | med |
| PLG-41 | Code execution offered as an MCP server | mcpx has, others don't | mcpx /mcp · Cloudflare codeMcpServer · opencode — · lootbox — | ✓ — mcpx_exec and friends over /mcp | + med | S | low |
PLG-01 Plugin package @opencode-ai/plugin renamed @opencode/plugin
- What. The plugin SDK changed npm scope. v1 exports
.,./tool,./tui,./v2/effect,./v2/promise; v2 exports.,./effect,./host,./tui. v2 has no@opencode-ai/pluginanywhere in core, cli, server or plugin sources. - Where. opencode v1 and v2, incompatible.
- mcpx @ 05c78b2. Both plugin files import the v1 name (
plugin/opencode/mcpx-session.ts:1,plugin/opencode/mcpx-tui.tsx:42). - Value to mcpx. + high: the v2 port is a new file, not an edit.
- Effort. S for the import; the API behind it differs everywhere (PLG-02 onward).
- Risk. High: under v2 the v1 plugin cannot even resolve its import.
- Detail. v1 auto-installs
@opencode-ai/plugin@<version>into every config directory so imports resolve; no equivalent auto-install of@opencode/pluginwas found in v2, whose loader takes npm packages vianpm.addand local files via a watching source loader. How a local v2 plugin file resolves the import in the Bun-compiled binary is unverified (v2’s Node entry publishes the API on a global symbol). - Sources.
v1:packages/plugin/package.json:3“"name": "@opencode-ai/plugin"”;v2:packages/plugin/package.json:3“"name": "@opencode/plugin"”;plugin/opencode/mcpx-session.ts:1“import { tool, type Plugin } from "@opencode-ai/plugin"”;v1:packages/opencode/src/config/config.ts:456;v2:packages/core/src/plugin/module.ts:91;v2:packages/cli/src/node/plugin-runtime.promise.ts:14
PLG-02 Module shape: hooks-returning function vs {id, setup|effect}
- What. v1 calls
Plugin = (input, options) => Promise<Hooks>and uses the hooks object it returns. v2 requires a default export{id, setup(ctx)}(Promise) or{id, effect(ctx)}(Effect) that registers hooks imperatively;setupreturns an optional cleanup. - Where. v1: plain function export or
PluginModule {id?, server, tui?: never}. v2: only the{id, setup|effect}union; anything else is aPluginModule.LoadError. - mcpx @ 05c78b2.
export default (async ({ directory, worktree, project, client }, options) => {…}) satisfies Plugin(plugin/opencode/mcpx-session.ts:169), v1 only. - Value to mcpx. + high: a v2 build is required before mcpx’s plugin works on v2. − two artifacts to maintain.
- Effort. M — every hook maps to a different v2 domain call.
- Risk. High: v2 rejects the current file with “Plugin must export a default definition with an id and an effect or setup function.”
- Detail. v2 validates with Effect Schema
Struct({id: String, effect|setup: function});idis mandatory and duplicate ids fail (v2:packages/core/src/plugin/supervisor.ts:108). - Sources.
v1:packages/plugin/src/index.ts:74“export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise”; v2:packages/core/src/plugin/module.ts:60;v2:packages/core/src/plugin/module.ts:111;v2:packages/plugin/src/promise/plugin.ts:58;v2:packages/plugin/src/promise/plugin.ts:60
PLG-03 v1 legacy loader calls every export of a plugin file
- What. When a v1 module’s default is not a
{id|server|tui}object, every export is treated as a plugin function; one non-function export throws and the whole module is skipped (logged). - Where. v1 only; v2 reads only
default. - mcpx @ 05c78b2. Works around it by keeping helpers in a subdirectory and exporting only the default
(
plugin/opencode/mcpx-session.ts:2). - Value to mcpx. + low: the workaround is still needed for v1.
- Effort. S.
- Risk. Med: a helper added beside
mcpx-session.tssilently disables the plugin in v1. - Detail. The throw happens in
getLegacyPluginsbefore any export is invoked, so there is no partial registration. - Sources.
v1:packages/opencode/src/plugin/index.ts:107“if (!plugin) throw new TypeError("Plugin export is not a function")”;v1:packages/opencode/src/plugin/index.ts:123;v2:packages/core/src/plugin/module.ts:115
PLG-04 Plugin discovery: flat file glob vs files or directories
- What. v1 scans
{plugin,plugins}/*.{ts,js}in each config directory, non-recursively. v2 listsplugin/andplugins/entries and accepts.ts/.jsfiles and directories (package-style, resolved throughserver, index,tuiorrpcentrypoints). - Where. opencode v1 and v2.
- mcpx @ 05c78b2. Relies on the non-recursive glob to hide its
mcpx/helper directory (plugin/opencode/README.md:34). - Value to mcpx. + low: v2 lets one directory ship server, TUI and rpc together.
- Effort. S.
- Risk. Med: copying the v1 layout into a v2 plugin directory makes v2 try to load
mcpx/as a plugin package and fail (“Plugin entrypoint not found”). - Detail. v2
Host.resolvelooks forserver, then""(index),tui,rpc. - Sources.
v1:packages/opencode/src/config/plugin.ts:21“Glob.scan("{plugin,plugins}/*.{ts,js}"”;v2:packages/core/src/plugin/source-directory.ts:25“if (entry.type === "directory") return Option.some(entry.target)”;v2:packages/plugin/src/host.ts:43
PLG-05 Config key plugin vs plugins; where options arrive
- What. v1 config is
plugin: Array<string | [string, options]>; v2’s native key isplugins: [{package, options}], and it still accepts legacypluginentries, normalising both intoplugins. Options reach a v1 plugin as its second argument and a v2 plugin asctx.options. - Where. Both accept
plugin; only v2 hasplugins. - mcpx @ 05c78b2. The README documents
"plugin": [["./…/mcpx-session.ts", {…}]](plugin/opencode/README.md:53). - Value to mcpx. + low: configuration is the one thing that carries across.
- Effort. S.
- Risk. Low: a user who keeps the v1 entry on v2 gets a module load error (PLG-02), not a config error.
- Detail. None beyond the above.
- Sources.
v1:packages/plugin/src/index.ts:71“plugin?: Array<string | [string, PluginOptions]>”;v2:packages/core/src/config/normalize.ts:185;v2:packages/core/src/config/normalize.ts:190;v2:packages/plugin/src/promise/plugin.ts:29
PLG-06 v1 also runs the v2 plugin host
- What. v1.18.31 contains
packages/coreand wires its location services, includingPluginV2.nodeand aconfig-pluginloader that globs the same{plugin,plugins}/*.{ts,js}and accepts{id, effect|setup}. - Where. v1 only (v2 is the host).
- mcpx @ 05c78b2. Not affected; mcpx ships the v1 shape.
- Value to mcpx. + low: a v2-shaped plugin can be tried under v1. − a v2 plugin in a shared directory is also offered to the v1 loader, which rejects it (“must default export an object with server()”), logged and non-fatal.
- Effort. S.
- Risk. Low: log noise.
- Detail. Whether v1 sessions consume v2 hooks such as
session.hook("context")is unverified: the host is wired, but v1’s prompt path uses v1 triggers (v1:packages/opencode/src/session/llm/request.ts:69). - Sources.
v1:packages/core/src/location-services.ts:52“PluginV2.node,”;v1:packages/core/src/config/plugin/external.ts:60;v1:packages/core/src/plugin/internal.ts:119;v1:packages/opencode/src/plugin/shared.ts:297
PLG-07 Plugin runtime: Bun vs build-dependent
- What. v1 plugins run under Bun (the server hands in
Bun.$). v2’s shipped build is Bun (its source loader usesBun.Transpiler), but v2 also has a Node build target. In both, plugins are imported unsandboxed into the server process (v1 the server Worker, v2 the daemon). - Where. opencode v1 and v2.
- mcpx @ 05c78b2. The plugin reaches the daemon socket with Bun’s
fetch(url, {unix}); its own comment notes that Node’s fetch ignores the option (plugin/opencode/mcpx/daemon.ts:348-351). - Value to mcpx. + low: holds for the nix build installed here.
- Effort. S.
- Risk. Low: a Node build of v2 would break unix-socket fetch.
- Detail.
docs/opencode-plugin.md:201saysfetch(url, {unix})“works in both, since plugins run under Bun”; the source says that depends on the build (v2:packages/cli/package.json:21). - Sources.
v1:packages/opencode/src/plugin/index.ts:167;v2:packages/plugin/src/source.bun.ts:27;v2:packages/cli/package.json:21“"build:node": "bun run script/build-node.ts",”;v1:packages/opencode/src/plugin/index.ts:118;v2:packages/core/src/plugin/module.ts:102;docs/opencode-plugin.md:201
PLG-08 Plugin lifecycle: dispose vs setup cleanup and hot reload
- What. v1 plugins may return a
disposehook, run on the instance finalizer. v2setupmay return a cleanup, and local plugin sources (and their local imports) are file-watched and reloaded on change. - Where. opencode v1 and v2.
- mcpx @ 05c78b2. No dispose used.
- Value to mcpx. + low: v2 reload makes iteration fast.
- Effort. S.
- Risk. Low: module-level state (mcpx’s
sessionscache, the chosen daemon) resets on every v2 reload. - Detail. v2’s Bun source loader scans imports with
Bun.Transpilerto decide what to watch. - Sources.
v1:packages/plugin/src/index.ts:223“dispose?: () => Promise”; v2:packages/plugin/src/promise/plugin.ts:60;v2:packages/core/src/plugin/module.ts:22;v2:packages/plugin/src/source.bun.ts:27
PLG-09 Plugin instance per directory vs per Location
- What. v1 instantiates each plugin once per directory instance; v2 once per Location (directory plus workspace id). Neither is per session.
- Where. opencode v1 and v2.
- mcpx @ 05c78b2. Caches per-session data inside one plugin instance (
plugin/opencode/mcpx-session.ts:375). - Value to mcpx. + med: in-memory “session” state for the daemon choice is really “this directory in this process”.
- Effort. S.
- Risk. Med: in v2 one plugin instance serves every TUI window attached to the daemon, so that memory is shared across windows (process-model area).
- Detail. v1’s
eventhook only receives events whoselocation.directoryequals the instance directory. - Sources.
v1:packages/opencode/src/plugin/index.ts:134;v1:packages/opencode/src/plugin/index.ts:256;v2:packages/core/src/plugin.ts:296“Node.makeLocationNode({”
PLG-10 client SDK and serverUrl in plugin input
- What. v1 hands a plugin an SDK
clientbound to the server (through an in-process fetch shim when no port is open) and aserverUrlgetter. v2’sContexthas neither; it has typed domains instead (app,location,session,mcp,tool,shell,permission,generate,storage,rpc,eventand more). - Where. opencode v1 and v2.
- mcpx @ 05c78b2. Uses
client.session.getto describe sessions (plugin/opencode/mcpx-session.ts:393) andclient.tui.showToastfor its boot and ambiguity toast (plugin/opencode/mcpx-session.ts:298). - Value to mcpx. + med: v2 domains are typed and direct (
ctx.session.get). − everyclientcall must be re-sourced, and the toast has no server-side equivalent (PLG-29). - Effort. M.
- Risk. High: the v1 code paths do not exist on v2.
- Detail. v2 also removes the TUI routes from its HTTP protocol, so there is no URL a plugin could call instead.
- Sources.
v1:packages/plugin/src/index.ts:56;v1:packages/opencode/src/plugin/index.ts:146;v1:packages/opencode/src/plugin/index.ts:163;v2:packages/plugin/src/promise/plugin.ts:26;v2:packages/plugin/src/promise/plugin.ts:46
PLG-11 directory/worktree/project vs ctx.location
- What. v1 passes
project,directoryandworktreeas input fields; v2 carries directory, workspace and project inctx.location. - Where. opencode v1 and v2.
- mcpx @ 05c78b2. Destructures
directory, worktree, project, client(plugin/opencode/mcpx-session.ts:169). - Value to mcpx. + low.
- Effort. S.
- Risk. Low.
- Detail. v1 also passes
experimental_workspace; v2 folds workspace into the Location key. - Sources.
v1:packages/plugin/src/index.ts:58;v2:packages/plugin/src/promise/plugin.ts:28;plugin/opencode/mcpx-session.ts:169
PLG-12 Bun $ shell handed to plugins
- What. v1 passes
Bun.$asinput.$; v2’sContexthas no command executor (itsshelldomain is only thecreate.beforehook). - Where. opencode v1 only.
- mcpx @ 05c78b2. Already independent of
$: it spawns withnode:child_processexecFile(plugin/opencode/mcpx/daemon.ts:41) and talks to the daemon over the unix socket (plugin/opencode/mcpx/daemon.ts:352). - Value to mcpx. + low: nothing to port.
- Effort. S.
- Risk. Low.
- Detail.
docs/opencode-plugin.md:103says mcpx’s plugin “uses$for exactly two things”; the source at 05c78b2 usesexecFileand the socket, and does not destructure$.import("node:child_process")still works in v2 because plugins are imported unsandboxed. - Sources.
v1:packages/plugin/src/index.ts:65“$: BunShell”;v2:packages/plugin/src/promise/shell.ts:12“readonly "create.before": ShellCreateBefore”;plugin/opencode/mcpx/daemon.ts:41;docs/opencode-plugin.md:103
PLG-13 shell.env hook vs shell.hook("create.before") without session
- What. v1
shell.env(input: {cwd, sessionID?, callID?}, output: {env})runs per command; v2’screate.beforeevent is{command, cwd, timeout, shell, env}with a mutable env and no session or call id. - Where. v1 bash tool and
!shell passsessionID/callID. v2 never exposes them to this hook, although its shell tool carriesmetadata.sessionIDinternally. - mcpx @ 05c78b2. Sets
MCPX_SESSION_IDfrominput.sessionID(plugin/opencode/mcpx-session.ts:664,:670); this is howmcpxrun from an agent’s shell gets a session-scoped browser. - Value to mcpx. + high: session attribution is the whole reason mcpx uses the hook.
- Effort. M. The only v2 hook that sees both
sessionIDand the command istool.hook("execute.before")(PLG-23); the robust v2 answer is server-side: read_meta["ai.opencode/sessionID"]when opencode calls mcpx over MCP (_metaarea). - Risk. High: a naive port injects no session id and session leasing silently degrades to one shared instance.
- Detail. v2’s
create.beforeenv starts from the session environment if one was set (PLG-15), elseprocess.env, plusTERMandOPENCODE_TERMINAL=1. - Sources.
v1:packages/plugin/src/index.ts:270;v1:packages/opencode/src/tool/shell.ts:418;v2:packages/plugin/src/promise/shell.ts:3;v2:packages/core/src/shell.ts:268“yield* hooks.trigger("shell", "create.before", invocation)”;v2:packages/core/src/tool/plugin/shell.ts:205“metadata: { sessionID: context.sessionID },”;v2:packages/plugin/src/promise/tool.ts:40
PLG-14 v1 shell.env has no session id on PTY terminals
- What. v1 fires
shell.envfrom three places; the PTY paths (the TUI terminal, HTTPpty.create) pass only{cwd}. - Where. opencode v1.
- mcpx @ 05c78b2. The hook comment says it “receives exactly
{ cwd, sessionID?, callID? }” (plugin/opencode/mcpx-session.ts:44), which is right, butmcpxrun inside an opencode terminal gets noMCPX_SESSION_ID. - Value to mcpx. + low: explains “unknown” sessions in mcpx logs from terminals.
- Effort. S (document).
- Risk. Low.
- Detail.
docs/opencode-plugin.md:411-412saysshell.envcarriessessionIDandcallID; the source passes onlycwdon PTY paths. - Sources.
v1:packages/opencode/src/plugin/pty-environment.ts:18;v1:packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts:71;v1:packages/opencode/src/session/prompt.ts:554;docs/opencode-plugin.md:411
PLG-15 v2 per-session environment route replaces the whole env
- What.
PUT /api/experimental/session/:sessionID/environment {variables}stores a per-session map that replacesprocess.envfor that session’s shell commands. The v2 TUI itself calls it with the terminal’s environment whenever a session view is active. - Where. opencode v2 only; stored in an in-memory global map (lost on daemon restart); ignored when the Location has a workspace id.
- mcpx @ 05c78b2. Not used.
docs/opencode-plugin.mdproposes it as the v2 replacement forshell.env. - Value to mcpx. − as a plugin channel it is unusable: a plugin that PUTs
{MCPX_SESSION_ID}wipesPATHandHOMEfor that session and is overwritten by the TUI’s next update; and a v2 plugin has no HTTP client to call it with. - Effort. S (to drop the plan).
- Risk. High if adopted: shell commands break.
- Detail.
docs/opencode-plugin.md:86and:412call it “the API v2 added for this”; the route’s own description is “Replace the process environment used by local shell commands for this session.” - Sources.
v2:packages/protocol/src/groups/session.ts:795;v2:packages/core/src/shell.ts:263“…(sessionEnvironment ?? process.env),”;v2:packages/core/src/shell.ts:254;v2:packages/core/src/session/environment.ts:18;v2:packages/tui/src/app.tsx:511;docs/opencode-plugin.md:86
PLG-16 experimental.chat.system.transform output is {system: string[]}
- What. v1 triggers the hook with
output = { system }, an array of strings. mcpx’s handler readoutput.parts, found nothing, and returned, so the plugin’sinstructionsoption never injected anything. Fixed by #1938: the handler now pushes the text ontooutput.system, with a regression test against v1’s real call shape. - Where. opencode v1 (both trigger sites pass
{system}); v2 has no hook of this name. - mcpx @ 05c78b2.
const parts = (output as any)?.parts; if (Array.isArray(parts)) parts.push({ type: "text", text })(plugin/opencode/mcpx-session.ts:745-746, hook at:737). - Value to mcpx. + med: a one-line fix,
output.system.push(text). - Effort. S.
- Risk. Was Med: users who set
instructions: truebelieved the model was told about mcpx; it was not. Resolved by #1938. - Detail. v1 request preparation re-joins extra system entries into one message when a plugin pushes more than one
(
v1:packages/opencode/src/session/llm/request.ts:74). The v2 analogue issession.hook("context"), whosesystemisArray<SystemPart>({type: "text", text}): the shape mcpx pushes, on a different key (PLG-17). - Sources.
v1:packages/plugin/src/index.ts:291;v1:packages/opencode/src/session/llm/request.ts:69;v1:packages/opencode/src/agent/agent.ts:381;plugin/opencode/mcpx-session.ts:745“const parts = (output as any)?.parts”;v2:packages/plugin/src/promise/session.ts:28“system: Array”
PLG-17 v2 session.hook("context") edits system, messages, tools
- What. The v2
contextevent carriessystem,messages,optionsandtools(description and input schema). After the hook, tool entries that match neither an original definition nor an original name are dropped. v1 spreads this overexperimental.chat.system.transform,experimental.chat.messages.transformandtool.definition. - Where. opencode v2 (one hook); v1 (three hooks, and
tool.definitionapplies only to registry tools, never to MCP tools). - mcpx @ 05c78b2. Not used.
- Value to mcpx. + med: the right place for a short “mcpx exists” line in v2. − it cannot add a catalogue pseudo-tool.
- Effort. S.
- Risk. Med:
docs/opencode-plugin.md:348suggests putting a budgeted catalogue intoevent.tools; the source drops invented entries. - Detail. Tools can be renamed (moved to a new key) and re-described; execution maps back by object identity.
- Sources.
v2:packages/plugin/src/promise/session.ts:35;v2:packages/core/src/session/model-request.ts:220“// Match by identity first, then by key. Entries matching neither were invented by a”;v2:packages/core/src/session/model-request.ts:361;v1:packages/plugin/src/index.ts:282;v1:packages/opencode/src/tool/registry.ts:318;docs/opencode-plugin.md:348
PLG-18 chat.message vs session.hook("prompt")
- What. v1’s
chat.messagehook becomes v2’ssession.hook("prompt"). - Where. opencode v1 and v2.
- mcpx @ 05c78b2. Not used.
- Value to mcpx. + low.
- Effort. S.
- Risk. Low.
- Detail. Listed in the plugin-API matrix only; the payload shapes were not compared.
- Sources.
v1:packages/plugin/src/index.ts:234;v2:packages/plugin/src/promise/session.ts:114
PLG-19 chat.params/chat.headers vs context.options/model.request.headers
- What. v1’s per-request LLM parameter and header hooks map to v2
context(mutableoptions: typed generation keys plus provider options) andmodel.request(mutableheaders,baseURL). v2 addshttp.request,http.response,retry,title,generate,compactionandexperimental.ws.handshake. - Where. opencode v1 and v2, different shapes.
- mcpx @ 05c78b2. Not used.
- Value to mcpx. + low.
- Effort. S.
- Risk. Low.
- Detail. v2’s
SessionRequestKinddistinguishesprimary | compaction | title | generate. - Sources.
v1:packages/plugin/src/index.ts:247;v1:packages/plugin/src/index.ts:257;v2:packages/plugin/src/promise/session.ts:115;v2:packages/plugin/src/promise/session.ts:119;v2:packages/plugin/src/promise/session.ts:117;v2:packages/core/src/session/model-request.ts:257
PLG-20 experimental.session.compacting vs session.hook("compaction")
- What. v1’s compaction hook becomes v2’s
session.hook("compaction"). - Where. opencode v1 and v2.
- mcpx @ 05c78b2. Not used.
- Value to mcpx. + low.
- Effort. S.
- Risk. Low.
- Detail. v1’s companion
experimental.compaction.autocontinuehas no v2 hook (PLG-21). - Sources.
v1:packages/plugin/src/index.ts:305;v2:packages/plugin/src/promise/session.ts:116
PLG-21 v1 hooks with no v2 hook of the same purpose
- What.
experimental.text.complete,experimental.provider.small_model,experimental.compaction.autocontinue,command.execute.beforeandconfighave no v2 hook;auth/providerare replaced byintegration,providerandmodeltransforms. The v2 hook set issession.{prompt, context, compaction, generate, title, model.request, http.request, http.response, experimental.ws.handshake, retry},tool.{execute.before, execute.after},shell.create.before,permission.evaluate,aisdk.{sdk, language}, plus domain transforms. - Where. opencode v1 only for the listed names;
aisdkhooks are v2-only. - mcpx @ 05c78b2. Uses none of them.
- Value to mcpx. + low: confirms mcpx’s four hooks are the only ones to port.
- Effort. S.
- Risk. Low.
- Detail. v1’s
confighook runs once after load and is the only way a v1 plugin can add MCP servers (PLG-34). - Sources.
v1:packages/plugin/src/index.ts:327;v1:packages/plugin/src/index.ts:297;v1:packages/plugin/src/index.ts:316;v1:packages/plugin/src/index.ts:262;v1:packages/opencode/src/plugin/index.ts:247;v2:packages/plugin/src/README.md:68;v2:packages/plugin/src/promise/session.ts:113;v2:packages/plugin/src/promise/aisdk.ts:5
PLG-22 event hook vs ctx.event.subscribe() stream
- What. v1 pushes every bus event for the instance directory to
hook.event({event}); v2 exposesevent.subscribe(), a stream of server and rpc events. - Where. opencode v1 and v2.
- mcpx @ 05c78b2. Not used.
- Value to mcpx. + low: a v2 plugin could watch
mcp.*status events to know when mcpx connects. - Effort. S.
- Risk. Low.
- Detail. v1 fires
void hook.event(...)without awaiting it. - Sources.
v1:packages/opencode/src/plugin/index.ts:259;v2:packages/core/src/plugin/host.ts:256
PLG-23 tool.execute.before: v2 adds session and mutable input
- What. v1
tool.execute.before(input {tool, sessionID, callID}, output {args}); v2execute.before {tool, sessionID, agent, messageID, id, input}, where the possibly replacedevent.inputis what executes, for direct and code-mode calls alike. - Where. opencode v1 and v2.
- mcpx @ 05c78b2. Not used.
- Value to mcpx. + med: the only v2 hook that sees both the session id and a shell command before it runs; a plugin
could prefix the
shelltool’s command or recordsessionID → callIDfor correlation. - Effort. M.
- Risk. Med: rewriting commands is visible in the transcript; correlating with
create.beforeraces under concurrency. - Detail. v2’s shell tool is named
shell(v2:packages/core/src/tool/plugin/shell.ts:22). - Sources.
v1:packages/plugin/src/index.ts:266;v2:packages/plugin/src/promise/tool.ts:38;v2:packages/core/src/tool.ts:102;v2:packages/core/src/tool.ts:257“const event = yield* beforeExecute(input.call.name, input.call.input, context)”
PLG-24 tool.execute.after: success-only vs status union
- What. v1 fires
tool.execute.after(input {tool, sessionID, callID, args}, output)only after a successful execute; for MCP toolsoutputis the rawCallToolResult, not{title, output, metadata}. v2 firesexecute.afterfor both outcomes withstatus: "completed"(result) or"error"(error). - Where. opencode v1 and v2.
- mcpx @ 05c78b2. Records timing into mcpx’s log (
plugin/opencode/mcpx-session.ts:773) and reads(output as any)?.title(:780), which is always undefined for MCP tools in v1; failures are never recorded. - Value to mcpx. + low: v2 lets mcpx record failures.
- Effort. S.
- Risk. Low.
- Detail. v1 errors skip the hook entirely (it is triggered after
item.executereturns). In v2 an MCPisErrorbecomes aToolFailurethat does reach this hook withstatus: "error". Both versions fire it per child call in code mode (PLG-36). - Sources.
v1:packages/plugin/src/index.ts:274;v1:packages/opencode/src/session/tools.ts:421;v2:packages/plugin/src/promise/tool.ts:46;v2:packages/core/src/tool.ts:131;plugin/opencode/mcpx-session.ts:780“title: (output as any)?.title,”
PLG-25 permission.ask declared but dead vs live evaluate hook
- What. v1 declares a
"permission.ask"hook that no code triggers; v2 triggerspermission/evaluatewith a mutableeffectandmessage. - Where. v1 declared only; v2 live.
- mcpx @ 05c78b2. Not used.
- Value to mcpx. + low: a v2 plugin could auto-allow mcpx tools.
- Effort. S.
- Risk. Low: implementing the v1 hook is a silent no-op.
- Detail. The only
permission.ask(call in v1 is the permission service itself, not a plugin trigger. - Sources.
v1:packages/plugin/src/index.ts:261;v1:packages/opencode/src/session/processor.ts:372;v2:packages/core/src/permission.ts:174“const event = yield* hooks.trigger("permission", "evaluate", {”
PLG-26 Defining a tool: zod map vs ctx.tool.transform editor
- What. v1 plugin tools are a
{[name]: ToolDefinition}map with zod raw-shapeargs(tool.schema = z) returning a string or{title, output, metadata, attachments}. v2 tools are added throughctx.tool.transform(e => e.add({name, options: {namespace, codemode, permission, pinned}, description, input, output, execute})), where input and output are Effect Schema, Standard Schema (zod 4 qualifies) or JSON Schema, andexecutereturns{output, content, metadata}. - Where. opencode v1 map; v2 editor.
- mcpx @ 05c78b2. v1 map (
plugin/opencode/mcpx-session.ts:762) with zod args (:499):mcpx_daemon_*,mcpx_discover,mcpx_exec,mcpx_observe. - Value to mcpx. + high: v2 gives typed structured output. − a rewrite.
- Effort. M.
- Risk. Med: v2 rejects names outside
^[A-Za-z0-9_-]{1,64}$. - Detail. v1 plugin tools are not namespaced (id = map key), while v1 file tools in
{tool,tools}/*.{js,ts}get<file>_<export>. v2 namespaces only whenoptions.namespaceis set (idns_name, dots become_).docs/opencode-plugin.md:78calls v2 tools “namespaced and typed”; the namespace is optional, and withoutcodemode: falsethe tool disappears into code mode (PLG-27). - Sources.
v1:packages/plugin/src/tool.ts:45;v1:packages/plugin/src/tool.ts:52“tool.schema = z”;v1:packages/opencode/src/tool/registry.ts:202;v2:packages/plugin/src/promise/tool.ts:66;v2:packages/core/src/tool/runtime.ts:276;v2:packages/core/src/tool.ts:297;docs/opencode-plugin.md:78
PLG-27 v2 plugin tools hide behind code mode by default
- What. In v2 every registered tool whose
options.codemode !== falseis removed from the model’s direct tool list and exposed only inside theexecutetool; built-ins opt out one by one withcodemode: false. - Where. opencode v2 only. v1’s code mode wraps MCP tools only, never plugin tools.
- mcpx @ 05c78b2. n/a (v1 plugin).
- Value to mcpx. + high: a v2 port must set
options: { codemode: false }onmcpx_daemon_*andmcpx_exec, or they vanish behindexecute. - Effort. S.
- Risk. High: tools the model should call directly become code-mode-only functions.
- Detail.
executeis a reserved name for a direct tool. Code mode itself can be switched off only by a wholly-denying permission rule onexecute. - Sources.
v2:packages/core/src/tool.ts:228“const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))”;v2:packages/core/src/tool.ts:229;v2:packages/core/src/tool.ts:299;v2:packages/core/src/tool/plugin/shell.ts:191“options: { codemode: false },”
PLG-28 A plugin tool can ask the user
- What. v1
ToolContext.ask({permission, patterns, always, metadata})raises a permission prompt. v2’s tool context is{sessionID, agent, messageID, id, progress}, with noask, and the permission domain offers list, get, reply, rules and the hook, but no ask. - Where. opencode v1 only.
- mcpx @ 05c78b2.
mcpx_daemon_selectconfirms throughctx.ask(plugin/opencode/mcpx-session.ts:529). - Value to mcpx. − the only v1 server-side interactive channel is gone.
- Effort. M — a v2 TUI plugin plus
ctx.rpc(PLG-30, PLG-31), or drop the confirmation. - Risk. Med.
- Detail.
docs/opencode-plugin.md:272lists “a tool with a permission prompt (server plugin, v1 and v2)”; v2’s tool context has noask. v2 plugin tools are gated only by wholly-disabling permission rules onoptions.permission ?? name. - Sources.
v1:packages/plugin/src/tool.ts:19“ask(input: AskInput): Promise”; v1:packages/opencode/src/tool/registry.ts:150;v2:packages/schema/src/tool.ts:14;v2:packages/core/src/tool.ts:225;docs/opencode-plugin.md:272
PLG-29 Toast from a server plugin
- What. v1 plugins call
client.tui.showToast. A v2 server plugin has noclient, and the v2 HTTP protocol has no toast route; toasts exist only in the TUI plugin API (ui.toast.show). - Where. v1 server plugin: yes. v2 server plugin: no. v2 TUI plugin: yes.
- mcpx @ 05c78b2. Boot and ambiguity toast via
client.tui.showToast(plugin/opencode/mcpx-session.ts:298). - Value to mcpx. + med: on v2 ambiguity must go on tool results (already done through
annotate) or through a TUI plugin. - Effort. M — a v2 TUI plugin plus
ctx.rpc. - Risk. Med.
- Detail.
docs/opencode-plugin.md:111and:361say the server plugin’sclient.tui.*is “identical in v1 and v2”; v2’s server context has noclient. v2 defines an ephemeraltui.toast.showevent schema, but the plugineventdomain is subscribe-only. - Sources.
v1:packages/sdk/js/src/gen/sdk.gen.ts:1118“public showToast”;v2:packages/core/src/plugin/host.ts:255;v2:packages/plugin/src/tui/context.ts:279“show(options: ToastOptions): void”;v2:packages/schema/src/tui-event.ts:42;docs/opencode-plugin.md:111
PLG-30 TUI plugin shape and dialogs
- What. v1 TUI plugins default-export
{id?, tui(api, options, meta)}and renderapi.ui.DialogSelectthroughapi.ui.dialog.replace. v2 TUI plugins default-export{id, setup(ctx)}with promise dialogs (ui.dialog.select<V>(),prompt(),confirm(),alert()),ui.toast.show()andkeymap.layer(() => …). - Where. opencode v1 and v2, incompatible.
- mcpx @ 05c78b2. v1 only (
plugin/opencode/mcpx-tui.tsx:123,:189,:206). - Value to mcpx. + med: v2’s promise dialogs are simpler than JSX dialogs, and a v2 TUI plugin is where toasts and confirmations must now live.
- Effort. M.
- Risk. Low today.
- Detail. v1 forbids
serverandtuiin one module; v2 packages declareserver,tuiandrpcentrypoints side by side (v2:packages/plugin/src/host.ts:43). - Sources.
v1:packages/plugin/src/tui.ts:630;v1:packages/plugin/src/tui.ts:604;v2:packages/plugin/src/tui/plugin.ts:7;v2:packages/plugin/src/tui/context.ts:379;v2:packages/plugin/src/tui/context.ts:440
PLG-31 ctx.rpc between server and TUI plugins
- What. v2 server plugins can
rpc.register(definition, handlers)and emit events; a plugin package can ship anrpcentrypoint. It is the only channel a v2 server plugin has to UI. - Where. opencode v2.
- mcpx @ 05c78b2. Uses the filesystem as the channel between the server and TUI plugins
(
plugin/opencode/mcpx-tui.tsx:17). - Value to mcpx. + med: replaces the remember-file hand-off.
- Effort. M.
- Risk. Low.
- Detail.
RpcDomain.registerreturns{events.emit}for push. - Sources.
v2:packages/plugin/src/promise/rpc.ts:27;v2:packages/core/src/plugin/host.ts:119
PLG-32 Plugin storage
- What. v1 server plugins have no persistence API (v1 TUI plugins have
kv); v2 server plugins havestorage.get/set/remove/scan, namespaced per plugin id, and v2 TUI plugins havestorage.store/memory. - Where. opencode v1 (TUI only) and v2.
- mcpx @ 05c78b2. Remembers the daemon choice in its own file (
plugin/opencode/mcpx-session.ts:455). - Value to mcpx. + low: the remember file can go on v2; sharing between TUI and server still needs
rpcor a file. - Effort. S.
- Risk. Low.
- Detail. The namespace is
plugin:<hex of id>:. - Sources.
v1:packages/plugin/src/tui.ts:611“kv: TuiKV”;v2:packages/plugin/src/promise/storage.ts:4;v2:packages/core/src/plugin/host.ts:450;v2:packages/core/src/plugin/host.ts:578
PLG-33 Calling a model from a plugin
- What. v1 plugins can only drive an agent turn (
client.session.prompt/promptAsync/command). v2 plugins havectx.generate.text({prompt, model?}) → {text}andctx.session.generate, also exposed asPOST /api/generate. - Where. opencode v1 (full turn) and v2 (one-shot).
- mcpx @ 05c78b2. Not examined (the sampling area covers what mcpx forwards).
- Value to mcpx. + med: a v2 plugin could answer an MCP
sampling/createMessagethat mcpx forwards. - Effort. M.
- Risk. Low.
- Detail.
generate.texttakes a plain prompt string: no messages, system prompt or tool use, so a sampling request withmessages[],systemPromptortoolsmust be flattened. - Sources.
v1:packages/sdk/js/src/gen/sdk.gen.ts:615;v2:packages/core/src/plugin/host.ts:271;v2:packages/protocol/src/groups/generate.ts:8;v2:packages/core/src/generate.ts:10
PLG-34 Plugins add or change MCP servers (Location-scoped)
- What. v2
ctx.mcp.transform(editor => editor.set/update/remove)andctx.mcp.reload()edit the Location’s MCP server set;ctx.mcp.list()reads status. A v1 plugin can only mutate config through theconfighook, once. - Where. opencode v2. Both versions also expose runtime MCP registration over HTTP (v1
POST /mcp; v2PUT /api/experimental/mcp/:server, which needs the server password). - mcpx @ 05c78b2. Not used.
- Value to mcpx. + med: a v2 plugin could register mcpx itself with
codemode: falseandprotocol: "auto". v2’s own built-inopencode.mcp.codemode.exclusionplugin uses exactly this editor (CODE-53). - Effort. S.
- Risk. Med: the scope is the Location, shared by all sessions and windows.
- Detail.
docs/opencode-plugin.md:346says “registering MCP servers per session (mcp.transform)”; the MCP service is a Location node, and reconcile replaces changed servers. - Sources.
v2:packages/plugin/src/promise/mcp.ts:15;v2:packages/core/src/plugin/host.ts:384;v2:packages/core/src/mcp/index.ts:736“return makeLocationNode({”;v1:packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts:55;v2:packages/protocol/src/groups/mcp.ts:24;docs/opencode-plugin.md:346
PLG-35 ?codemode=false appended to remote MCP URLs
- What. For a remote server whose config does not set
codemode: falseand whose URL has nocodemodeparameter, v2 addscodemode=falseto the query, because “Servers that bundle their own Code Mode (Cloudflare and others) expose raw tools when asked”. On HTTP 400 or 404 it retries once at the configured URL. - Where. opencode v2 (client side). No Cloudflare document describing the parameter was found.
- mcpx @ 05c78b2. Neither sends nor honours it:
/mcp(proto.mcpPath,internal/defaults/defaults.json:77) always exposes mcpx’s meta-tools (internal/mcpserver/server.go:1-13). Whether/mcptolerates unknown query parameters was not examined. - Update (2026-10-31, #1940 / task 3240). Honoured now:
ServeHTTPreadscodemode=falsefrom the request URL (internal/mcpserver/codemode.go); that request’s tools/list is every upstream tool flat under<namespace>_<tool>(the registry’s Native spelling), the gateway tools are absent from it, and tools/call routes a raw name to its upstream under the upstream’s own name viaBackend.Call. The backend half ismcpBackend.RawTools(internal/cli/serve.go), listing each visible namespace through the sameUpstreamToolspass-through uses; a namespace that cannot be listed fails the whole list rather than serving a partial catalogue. Absence, and any value other than exactlyfalse, change nothing. Still open: mcpx as a client does not send the parameter to Cloudflare-style servers. - Value to mcpx. + med: honouring it by exposing every upstream tool flat (namespaced) would let v2’s code mode orchestrate mcpx’s pooled servers directly, keeping mcpx’s per-caller isolation under v2’s catalogue. As a client, mcpx could send it to Cloudflare-style servers so its own codegen sees raw tools.
- Effort. M — a flat-tools mode plus a query switch.
- Risk. Med: a flat mode lists hundreds of tools, the context cost mcpx exists to avoid; it should activate only on request. A strict endpoint costs v2 a failed first connect on every start.
- Detail. The configured URL, without the parameter, stays the OAuth identity; opencode’s SDK patch keeps the
parameter out of metadata discovery. With
codemode: falseset (CODE-53) the parameter is never added. - Also recorded from the transports register. The endpoint is routed by path (
internal/daemon/server.go:488), so the query string is ignored and mcpx’s meta-tools are served regardless; mcpx as a client never sends the parameter. The configured URL, without the parameter, stays the OAuth identity; v2’s SDK patch stops the parameter leaking into metadata discovery. - Sources.
v2:packages/core/src/mcp/client.ts:214“// Servers that bundle their own Code Mode (Cloudflare and others) expose raw tools when asked”;v2:packages/core/src/mcp/client.ts:217-218“if (addedCodemode) url.searchParams.set("codemode", "false")”;v2:packages/core/src/mcp/client.ts:228-233;internal/defaults/defaults.json:77;v2:packages/core/src/mcp/client.ts:217;v2:packages/core/src/mcp/client.ts:218“if (addedCodemode) url.searchParams.set("codemode", "false")”;v2:packages/core/src/mcp/client.ts:233.
PLG-36 Hooks and permission checks per child call in code mode
- What. Each MCP call made inside opencode’s
executegoes through opencode’s permission system and fires the plugin hooks. v1 firesctx.ask({permission: key})andtool.execute.before/afterwithcallID = "<parent>/<n>"; v2 routes child calls throughbeforeExecuteandexecuteTool, sopermission.assertandexecute.before/afterfire per child. User rules such as “deny chrome_devtools_*” therefore apply inside code. - Where. opencode v1 and v2. Calls inside
mcpx_execormcpx execgo script → daemon → server, and opencode sees onemcpx_execorbashcall. Cloudflare connectors can setrequiresApproval; lootbox has nothing. - mcpx @ 05c78b2. The plugin only forwards opencode’s own tool outcomes to mcpx’s log
(
plugin/opencode/mcpx-session.ts:773-789); nothing flows from daemon calls to opencode’s permission system. - Value to mcpx. + high: closes a policy bypass: a tool denied in opencode is still reachable through
mcpx exec. - Effort. L — the daemon would ask the harness per call through an elicitation-style round trip to the plugin
(
docs/elicitation.md§5.5). - Risk. High: today mcpx is a way around opencode permissions.
- Detail. v1 builds the permission key from the MCP tool key (
server_tool), so rules written for direct MCP tools apply unchanged inside code mode. mcpx’s timing hook would see v1 child calls (PLG-24). - Sources.
v1:packages/opencode/src/tool/code-mode.ts:141-145;v1:packages/opencode/src/tool/code-mode.ts:147;v1:packages/opencode/src/tool/code-mode.ts:227;v2:packages/core/src/tool/mcp.ts:51-63;v2:packages/core/src/tool.ts:235;plugin/opencode/mcpx-session.ts:773-789
PLG-37 Live status of each child call during a script
- What. While
executeruns, opencode publishestoolCalls: [{tool, status: running|completed|error, input}]as tool metadata (v1ctx.metadata) or progress (v2context.progress), and the TUI shows it. - Where. opencode v1 and v2. mcpx’s daemon has an event stream and
/v1/execcan stream frames, but the plugin does not use them; lootbox has nothing. - mcpx @ 05c78b2. The plugin’s
mcpx_execmakes one blocking POST and publishes nothing; itsexecute(args)does not even take the tool context (plugin/opencode/mcpx-session.ts:615-625;plugin/opencode/mcpx/daemon.ts:451-455). - Value to mcpx. + med: a user watching a two-minute browser script sees nothing until it ends.
- Effort. S — read
/v1/execwithoutput: "stream"and map log and emit frames to metadata. - Risk. Low.
- Detail. None beyond the above.
- Sources.
v1:packages/opencode/src/tool/code-mode.ts:216-217;v2:packages/core/src/codemode/tool.ts:85-88;plugin/opencode/mcpx-session.ts:615-625
PLG-38 Cancelling in the harness stops the script
- What. When the user cancels, opencode aborts the running program: v1 races the interpreter against
ctx.abortand passessignal: ctx.abortto eachcallTool; v2 interrupts the Effect fiber. The mcpx plugin’smcpx_execignores the abort signal, so the daemon-side script runs until it finishes or times out. - Where. opencode v1 and v2. mcpx CLI: a killed CLI process kills its local script’s process group. lootbox: the
CLI’s WebSocket closes but the daemon’s
denokeeps running. Cloudflare: unverified. - mcpx @ 05c78b2.
postRawtakes only an optional timeout, andexec()passes neither timeout nor signal (plugin/opencode/mcpx/daemon.ts:380-386,:452-455). The daemon would cancel on client disconnect (internal/daemon/routes_exec.go:252), but the plugin never disconnects. - Value to mcpx. + med: Esc on a runaway browser script should stop it.
- Effort. S — pass the tool context’s abort signal to
fetch. - Risk. Med: runaway scripts keep holding pooled browsers.
- Detail. The progress-cancellation area records how an HTTP disconnect propagates to the upstream call.
- Sources.
v1:packages/opencode/src/tool/code-mode.ts:262-274;v1:packages/opencode/src/tool/code-mode.ts:155“signal: input.ctx.abort,”;plugin/opencode/mcpx/daemon.ts:380-386;internal/daemon/routes_exec.go:252
PLG-39 Harness session and working directory reach the script
- What. opencode v2 passes the session id to every child MCP call as
_meta["ai.opencode/sessionID"]. mcpx’s plugin setsMCPX_SESSION_IDforbash, somcpx execfrom bash is session-scoped; but the plugin’s ownmcpx_execposts/v1/execwith nosessionand nocwd, so each call gets a freshexec-<runID>session and the daemon’s working directory. - Where. opencode v2; mcpx bash path yes, plugin tool path no.
- mcpx @ 05c78b2.
exec()sends only{source, options: {output: "structured"}}(plugin/opencode/mcpx/daemon.ts:452-455); withoptions.sessionempty the service falls back to"exec-"+runID(internal/execsvc/execsvc.go:382). - Value to mcpx. + high: with the session, a browser persists across
mcpx_execcalls in one opencode session, whichAGENTS.md:87-89promises; withcwd,repoandcwdscopes resolve to the right repository. - Effort. S — pass
ctx.sessionIDandctx.directoryasoptions.sessionandoptions.cwd. - Risk. High: repo-scoped servers resolve against the daemon’s directory, and session-scoped browsers are recreated on every call and linger (process-model area).
- Detail.
call()in the same class already accepts a session (plugin/opencode/mcpx/daemon.ts:462-467). - Sources.
plugin/opencode/mcpx/daemon.ts:452-455;internal/execsvc/execsvc.go:382“session := firstNonEmpty(opts.Session, ropts.Env["MCPX_SESSION"], "exec-"+runID)”;plugin/opencode/mcpx-session.ts:664-670;v2:packages/core/src/tool/mcp.ts:69“sessionID: context.sessionID,”
PLG-40 Plugin mcpx_exec reads a field the daemon never returns
- What. The plugin returns
out.output ?? pretty(out.result ?? out).execsvc.Resulthas nooutputfield (its fields areresult,stdout,logs,emits,artifactsand the rest), so when stdout is not JSON the model sees the entire envelope instead of the script’s stdout. - Where. mcpx plugin only. opencode’s own
executereturns the value, then warnings, then logs. - mcpx @ 05c78b2.
plugin/opencode/mcpx-session.ts:619-623;Resultfields atinternal/execsvc/execsvc.go:201-213. The client type even declaresoutput?: string(plugin/opencode/mcpx/daemon.ts:451). - Value to mcpx. + med: the model should see stdout, then error, then compact logs, not
runId,emits: [],artifacts: []anddurationMson every call. - Effort. S — use
out.stdout(orout.error). - Risk. Med: every
mcpx_execwastes tokens, against the point of the feature. - Detail. A declared behaviour that is not delivered (build-brief rule 8), verified by reading only. The fallback used
when the daemon route is absent (
viaCLI(["exec", …])) returns what the CLI prints, so the two paths show the model different things for the same script. - Sources.
plugin/opencode/mcpx-session.ts:621-623“: (out.output ?? pretty(out.result ?? out))”;internal/execsvc/execsvc.go:201-212;v2:packages/core/src/codemode/tool.ts:293-305
PLG-41 Code execution offered as an MCP server
- What. mcpx serves
mcpx_catalog,mcpx_search,mcpx_types,mcpx_exec,mcpx_calland more over/mcp, and also runs scripts from the CLI and/v1. Cloudflare publishes code mode as an MCP server two ways:codeMcpServer()(onecodetool with the upstream typed in its description) andopenApiMcpServer()(searchplusexecuteover an OpenAPI document kept in the sandbox). opencode’s code mode is reachable only from a model turn: no CLI, HTTP or MCP route. lootbox has never been an MCP server. - Where. mcpx and Cloudflare. The label counts the products this register tracks with labels (opencode, lootbox); Cloudflare has it too.
- mcpx @ 05c78b2.
internal/mcpserver/server.go:1-13,:327-337. - Value to mcpx. + med: a fixed-footprint surface any MCP host can use; Cloudflare’s “search the spec from code” idea
is what
mcpx_execwith in-scriptsearch()/describe()already allows. - Effort. S (done).
- Risk. Low.
- Detail. Cloudflare’s argument for server-side code mode names the CLI approach mcpx takes and rejects it for the
web (“the agent needs a shell, which not every environment provides”); mcpx’s counter-argument (local-first,
stateful servers, full authority) is not yet written down in its docs. In opencode v2 the only code-mode mention in
protocol code is a simulation schema;
POST /api/generateis text generation, not code execution. - Sources.
internal/mcpserver/server.go:327;v2:packages/protocol/src/simulation.ts:500;v2:packages/protocol/src/groups/generate.ts:8; https://developers.cloudflare.com/agents/tools/codemode/api-reference/#codemcpserver “Wraps an existing MCP server with onecodetool.”; https://developers.cloudflare.com/agents/model-context-protocol/codemode/#search-and-execute “searchruns generated code against an OpenAPI document.”
Process model and statelessness
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: What each MCP revision expects of server state and connection lifetime, and how mcpx, opencode, lootbox and Cloudflare arrange processes.
Until 2025-11-25 an MCP connection is a stateful session; 2026-07-28 makes the protocol stateless by default, so a server may not use earlier requests, the connection, or the process as context, and anything that spans requests travels as an explicit identifier. For mcpx the question is sharper than for a plain server, because it is a daemon that pools upstream processes for many callers: what identifies a caller decides which browser a call reaches, and today every HTTP host of the daemon is one caller. The full list of state mcpx keeps is in ../stateless.md; this file records the items that are differences.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| PM-01 | One stateful session per client–server connection | 2026-07-28 removes | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — | ✓ — legacy caps and version held per Conn | + low | S | low |
| PM-02 | Server must not use earlier requests on a connection as context | 2026-07-28 has | 25-11 — · 26-07 MUST | ✓ — modern path reads _meta first | + med | S | low |
| PM-03 | Cross-request state named by an explicit identifier each request | 2026-07-28 has | 25-11 — · 26-07 MUST (handles are a pattern) | partial — tasks are handles; pool key is not | + high | S | med |
| PM-04 | A connection or process is not a conversation | 2026-07-28 has | 25-11 — · 26-07 SHOULD/SHOULD NOT | partial — pool scope keyed on a process id | + high | S | high |
| PM-05 | Server state carried through the client in requestState | 2026-07-28 has has better replacement | 25-11 — · 26-07 ✓ | partial — token names daemon-side call; bound to session | + med | M | med |
| PM-06 | A broken stream loses the request; client re-issues | 2026-07-28 has mcpx has | 25-03..25-11 resumable · 26-07 lost | ✓ — modern broken-stream requests re-issued once with a new id; tools/call gated on readOnlyHint/idempotentHint (#1638) | + low | S | med |
| PM-07 | State that 2026 still permits | 2026-07-28 has | 26-07 listen, tasks, requestState, handles, legacy sessions | ✓ — tasks and legacy sessions allowed | + low | S | low |
| PM-08 | Request with neither modern _meta nor a prior initialize | specs conflict | 26-07 silent for dual-era stdio; HTTP MAY assume 2025-03-26 | acc. — served as 2025-03-26 | + low | S | low |
| PM-09 | What “supports 2026-07-28” minimally means | 2026-07-28 has | 24-11..25-11 base + lifecycle · 26-07 base + versioning + patterns | partial — discover and listen shapes wrong | + high | M | high |
| PM-10 | Every HTTP MCP host shares one pool session key | opencode v2 has mcpx missing | mcpx /mcp | ✗ — key is the daemon’s pid (#211) | + high | S | high |
| PM-11 | Stateful server isolation per caller | mcpx has, others don't opencode v1 has opencode v2 has | mcpx per session · opencode v1 per directory · v2 per Location · lootbox shared · CF per execution id | ✓ — session-scoped process leases | + high | S | med |
| PM-12 | /v1/exec session-scoped instances outlive the run | mcpx missing | mcpx only | ✗ — never marked ephemeral; waits for idle reap (#219) | + med | S | med |
| PM-13 | MCP surface built once per daemon, never rebuilt | mcpx missing | mcpx only | ✗ — sync.Once; new adapters invisible (#219) | + med | S | med |
| PM-14 | Daemon’s MCP server calls the daemon over its own socket | mcpx has, others don't | mcpx only | ✓ — one path for stdio and HTTP | − latency | M | low |
| PM-15 | /v1/protocol reports nativeElicit from the default | mcpx missing | mcpx only | ✗ — compiled default, not the setting (#219) | + low | S | low |
| PM-16 | opencode process topology: one process vs shared daemon | opencode v1 has opencode v2 has | opencode v1 TUI + Worker per window · v2 background daemon | n/a — mcpx is already a daemon | + med | S | med |
| PM-17 | MCP connection scope: per directory vs per Location | opencode v1 has opencode v2 has | opencode v1 per directory per process · v2 per Location per daemon | n/a — mcpx must tell sessions apart | + med | S | med |
| PM-18 | When MCP servers start: awaited all vs forked each | opencode v1 has opencode v2 has | opencode v1 all at first use · v2 async per server | n/a — mcpx connect time matters on v1 | + low | S | low |
| PM-19 | MCP server lifetime: instance vs daemon, never idle | opencode v1 has opencode v2 has | opencode v1 until dispose/exit · v2 no idle TTL | n/a — mcpx serve may live for days | + low | S | med |
| PM-20 | No automatic restart when an MCP server dies | opencode v1 has opencode v2 has | opencode v1 failed · v2 failed | n/a — a dead mcpx serve stays dead | + med | S | med |
| PM-21 | opencode database filename depends on release channel | opencode v1 has opencode v2 has mcpx missing | opencode.db: v1 latest/beta/prod · v2 also dev/next | ✗ — mcpx stats opencode reads opencode.db only (#219) | + low | S | med |
| PM-22 | lootbox connects every server at startup and pings | lootbox has | lootbox fork (monitor) · deployed upstream (no monitor) | n/a — lazy pools, min 0 | + low | S | low |
| PM-23 | lootbox re-fetches every schema on every discovery | lootbox has has better replacement | lootbox | ✓ — persisted cache invalidated by list_changed | − none | S | low |
| PM-24 | lootbox multi-instance strategies and port rewriting | lootbox has has better replacement | lootbox fork only | n/a — isolates callers, not daemons | − misplaced | S | low |
| PM-25 | Per-run script and client setup | lootbox has has better replacement | lootbox HTTP import + --reload · mcpx content-addressed on disk | ✓ — compile cache stays warm | − none | S | low |
PM-01 One stateful session per client–server connection
- What. 2024-11-05 through 2025-11-25 describe MCP as a stateful session protocol: each client holds one session per
server, opened by
initialize, and capabilities and version hold for the whole session. - Where. 2024-11-05 to 2025-11-25. 2026-07-28 removes it (PM-02); a dual-era server may still run legacy sessions
for clients that open with
initialize. - mcpx @ 05c78b2. Legacy capabilities and version are stored per
Conn(internal/mcpserver/conn.go:101-112); on HTTP a legacyConnlives behindMcp-Session-Id, on stdio it is the process. - Value to mcpx. + low: kept for legacy clients; nothing to change.
- Effort. S.
- Risk. Low.
- Detail. The session lifetime and reaping rules (30-minute idle, reaped only when another session is issued) are recorded in the lifecycle and transports areas and in ../stateless.md.
- Sources.
2024-11-05/architecture/index.mdx:8“isolating concerns. Built on JSON-RPC, MCP provides a stateful session protocol focused”;2024-11-05/architecture/index.mdx:61“- Establishes one stateful session per server”;internal/mcpserver/conn.go:101-112
PM-02 Server must not use earlier requests on a connection as context
- What. Servers MUST NOT rely on prior requests over the same connection to establish context (capabilities,
protocol version, client identity); every request supplies it in
_meta. - Where. 2026-07-28.
- mcpx @ 05c78b2. The modern path reads
_metabefore any connection state (internal/mcpserver/conn.go:133-136). The legacy HTTP question-and-answer path relies on sessions by design, for legacy peers only. - Value to mcpx. + med: a stateless front lets many agents share one daemon without cross-talk.
- Effort. S (done for modern peers).
- Risk. Low.
- Detail. The same page adds that no state “should be inferred from previous requests, even those on the same
connection or stream”; capabilities that arrive per request mean mcpx may not remember an earlier request’s
elicitationdeclaration either. - Sources.
2026-07-28/basic/index.mdx:191“- Servers MUST NOT rely on prior requests over the same connection to”;internal/mcpserver/conn.go:133-136
PM-03 Cross-request state named by an explicit identifier each request
- What. State that spans requests (long-running tasks, application handles) MUST be referenced by an explicit
identifier the client passes on each request. With sessions gone, a server that needs cross-call state mints a
handle (
create_basket()→basket_id) and takes it as an ordinary tool argument; there is no protocol type for handles. - Where. 2026-07-28 (SEP-2567).
- mcpx @ 05c78b2. Task ids (
tsk-<16 hex>) are such handles. The pool scope key is not: for/mcphosts it comes from the serving process (PM-10), not from anything in the request. - Value to mcpx. + high: the 2026 way to give a host its own browser is a handle or a per-request key, which is exactly what mcpx’s pool needs as input.
- Effort. S.
- Risk. Med: without it the pool key stays process-derived.
- Detail. SEP guidance for handles: opaque, at least 128 random bits when unauthenticated, validate
(handle, principal)when authenticated, clear expiry errors. mcpx’s task store has no principal binding (tasks area). - Sources.
2026-07-28/basic/index.mdx:200“- State that needs to span multiple requests (e.g., long-running tasks,”;2026-07-28/changelog.mdx:12“Servers that need cross-call state use explicit, server-minted handles passed as ordinary tool arguments”;seps/2567-sessionless-mcp.md:98“That third point is not a protocol change. There is nohandles/*method”
PM-04 A connection or process is not a conversation
- What. Servers SHOULD handle requests from many tasks, threads or conversations; SHOULD NOT require a client to reuse the same connection or process for related operations; clients SHOULD NOT make a conversation the lifetime of a stdio process. The note spells it out: a server must not treat connection or process identity as a proxy for conversation or session continuity.
- Where. 2026-07-28.
- mcpx @ 05c78b2. As a server:
mcpx servekeys pool scope on its own pid (mcp-<pid>), which is exactly process identity standing in for a conversation; over HTTP it is worse (PM-10). As a client: thesessionscope gives each session key its own upstream process (PM-11), tying a stdio child’s lifetime to a conversation. - Value to mcpx. + high: the per-request identity the spec wants is what would fix PM-10.
- Effort. S.
- Risk. High: sharing a stateful upstream across callers is the failure mcpx exists to prevent.
- Detail. The client-side SHOULD NOT is aimed at modern servers that keep state in handles. A legacy stateful server (a browser MCP) has nowhere else to keep state, so per-session processes remain the only isolation for it; the tension is real but only for servers that could have used handles.
- Sources.
2026-07-28/basic/index.mdx:194“- Servers SHOULD be prepared to handle requests associated with multiple”;2026-07-28/basic/index.mdx:196“- Servers SHOULD NOT require that a client reuse the same connection or process to”;2026-07-28/basic/index.mdx:198“- Clients SHOULD NOT use an individual task, thread, or conversation as the”;2026-07-28/basic/index.mdx:207“transport, and a server must not treat connection or process identity as a”;internal/cli/serve.go:296“return fmt.Sprintf("mcp-%d", os.Getpid())”
PM-05 Server state carried through the client in requestState
- What. In 2026 a server that needs input returns
input_requiredwith an opaquerequestState; the client echoes it on the retry. The server MUST treat it as attacker-controlled and integrity-protect it if it matters, and SHOULD bind principal, TTL and request digest. The state lives in the client, not in a session. - Where. 2026-07-28. The mechanics (which methods, retry ids,
inputResponses) are in the MRTR area. - mcpx @ 05c78b2. The token is HMAC-signed with a per-process key and time-bound (
proto.stateTTL30m), but it names a call id whose live upstream call and question table stay in the daemon (askTable, result keptproto.askTTL15m), and it is bound to theMcp-Session-Idthat 2026 removed. So a stateless 2026 client cannot complete it (elicitation area), and the daemon holds state behind the token. - Value to mcpx. + med: binding to a principal (or nothing, since the HMAC already authenticates) plus a method/params digest would make it work for session-less clients.
- Effort. M.
- Risk. Med: a live upstream call cannot be serialised into a token, so mcpx will always hold some state here; it
is the server-minted-handle pattern carried in
requestState, which 2026 permits (PM-07). - Detail. The signing key is per
mcpserver.Server, so a daemon restart invalidates every outstanding token. - Sources.
2026-07-28/basic/patterns/mrtr.mdx:232“1. If a client request contains arequestStatefield, servers MUST treatrequestStateas an attacker-controlled input.”;internal/mcpserver/state.go:29-51;internal/mcpserver/server.go:161-163;internal/daemon/routes_proto.go:53-65
PM-06 A broken stream loses the request; client re-issues
- What. No redelivery: if a response stream breaks, the in-flight request is lost and the client MUST re-issue it with a new id; on stdio a crash means restart and retry.
- Where. 2026-07-28. 2025-03-26 through 2025-11-25 had SSE resumability instead (transports area).
- mcpx (as built, #1638 / PR #1645). Examined, and implemented for upstream calls: on a modern-era connection a
request whose response stream breaks unanswered is re-issued exactly once with a new id, in
Client.roundTrip(internal/mcpclient/client.go). Stream-lost identity is a data marker, never the message: the synthesized error carries codecodeStreamLost(-32000) plus data{mcpxStreamLost: true}, andisStreamLostrequires both — the receive loop’s own -32000 “connection closed” and any server-sent -32000 carry no marker and are never re-issued (transport death is a different mechanism, and a server’s own error is an answer). Modern era only: legacy connections keep the transport’sresume()mechanism. A call whose caller context is cancelled is never re-issued, and a second marked failure surfaces to the caller. Covered byTestModernReissueBrokenRequest(internal/mcpclient/reissue_test.go:107); the conformance gapstreamable-http-client-reissue-broken-requestwas retired by PR #1645. - Value to mcpx. + low: retry-on-crash is spec-aligned for modern upstreams.
- Effort. S.
- Risk. Med: re-issuing a non-idempotent tool executes it twice and nothing makes the server deduplicate; the
annotation gate below is the containment, and its residual is that annotations are the server’s own claim — a
tool that lies about
idempotentHintcan still run twice. - Detail. The MUST applies to the client’s intent to get a result, and this entry’s earlier text left the proxy
decision to mcpx. Settled by #1638, recorded as mcpx’s proxy decision: every method re-issues automatically
except
tools/call, which re-issues only when the tool’s list-time annotations declarereadOnlyHintoridempotentHint(hints retained atListToolsbyrecordToolHints, consulted viatoolCallReissueAllowed). An ungatedtools/callsurfaces the stream-lost error to the host instead of risking a double execution. - Sources.
2026-07-28/changelog.mdx:28“A broken response stream loses the in-flight request; clients MUST re-issue it as a new request with a new request ID”;2026-07-28/basic/transports/stdio.mdx:112“Because the protocol is stateless, any in-flight requests are simply lost and”;internal/mcpclient/client.go(roundTrip,isStreamLost,recordToolHints);internal/mcpclient/reissue_test.go:107;internal/conformance/coverage_transport.go:20
PM-07 State that 2026 still permits
- What. Stateless “by default”, not entirely: open
subscriptions/listenstreams (request-scoped); durable tasks in the tasks extension, polled by id; client-heldrequestState; application handles in tool arguments; per-request auth context; process-lifetime state on stdio (SEP: SHOULD NOT rely on it); legacy sessions of a dual-era server. - Where. 2026-07-28.
- mcpx @ 05c78b2. Tasks (legacy shape) and legacy HTTP sessions both exist (
internal/mcpserver/server.go:475-502,1208-1230); both are permitted. What is not permitted is minting a session for a modern peer, whichserver/discoverdoes (lifecycle and transports areas). - Value to mcpx. + low: tells mcpx which of its stores need no change.
- Effort. S.
- Risk. Low.
- Detail. The inventory in ../stateless.md classifies each mcpx store against this list.
- Sources.
2026-07-28/basic/index.mdx:211“Long-lived requests like”;docs/extensions/tasks/overview.mdx:56“initial status, TTL, and suggested polling interval. The task is durably”;seps/2567-sessionless-mcp.md:237“such servers SHOULD NOT rely on process-lifetime state and SHOULD migrate to explicit handles”;seps/2575-stateless-mcp.md:726“Not entirely (hence ‘by default’).”
PM-08 Request with neither modern _meta nor a prior initialize
- What. 2026-07-28 defines a modern request (with
_meta) and a legacy one (afterinitialize), and says modern-only servers reject a missing_metawith-32602. It is silent on a dual-era server receiving a bare request with no handshake; on HTTP a dual-era server MAY treat a missingMCP-Protocol-Versionas 2025-03-26. - Where. 2026-07-28.
- mcpx @ 05c78b2. Serves it as
Oldest= 2025-03-26 (internal/mcpserver/conn.go:140-145), consistent with the HTTP MAY, on stdio as well. - Value to mcpx. + low: accept liberally; keep.
- Effort. S.
- Risk. Low: a modern client that forgot
_metasilently gets legacy shapes. The stdio probe advice exists because lenient legacy servers do exactly this. - Detail. mcpx also ignores the legacy HTTP version header on such a request (lifecycle area).
- Sources.
2026-07-28/basic/transports/streamable-http.mdx:278“2025-06-18(which did not define theMCP-Protocol-Versionheader) MAY”;2026-07-28/basic/versioning.mdx:176“- A request carrying modern per-request_metais served statelessly”;internal/mcpserver/conn.go:140-145
PM-09 What “supports 2026-07-28” minimally means
- What. Legacy revisions: all implementations MUST support the base protocol and lifecycle management. 2026-07-28: base protocol, versioning and the message patterns (request/response, MRTR, subscribe/notify) are MUST; everything else is optional.
- Where. 2024-11-05..2025-11-25 vs 2026-07-28.
- mcpx @ 05c78b2. Advertises 2026-07-28, but
server/discoverandsubscriptions/listenshapes are wrong (lifecycle and subscriptions areas). - Value to mcpx. + high: the minimum is discover, per-request
_meta, MRTR and listen, and nothing more. - Effort. M.
- Risk. High: advertising 2026-07-28 while its mandatory patterns are malformed is “declared but not delivered”.
- Detail. Tools, resources, prompts and the rest are optional in 2026, so a correct minimal 2026 surface is small.
- Sources.
2024-11-05/basic/index.mdx:30“All implementations MUST support the base protocol and lifecycle management”;2026-07-28/basic/index.mdx:17“All implementations MUST support the base protocol, versioning,”
PM-10 Every HTTP MCP host shares one pool session key
- What. Calls from
/mcprun withCallContext{SessionID: mcpSession()}, which is$MCPX_SESSION_IDormcp-<pid>of the process running the MCP server. Over HTTP that process is the daemon, so every remote host lands on the same session-, pid- or cwd-scoped instance. - Where. mcpx. opencode v2 sends
_meta["ai.opencode/sessionID"]on everytools/call, which mcpx drops (_metaarea). - mcpx @ 05c78b2.
internal/cli/serve.go:287-297; used byCall,Exec,ReadResource,GetPromptand the asker (internal/cli/serve.go:111-112,139,575-576,597-598;internal/cli/serve_ask.go:34-37). - Value to mcpx. + high: per-host isolation over HTTP, from
ai.opencode/sessionID, a server-minted handle (PM-03), or client identity. - Effort. S.
- Risk. High: two opencode sessions (or two opencode windows, PM-16) on one daemon drive one browser.
- Detail.
mcpx serveover stdio gets per-host isolation for free, since each host spawns its own process; the comment “one per process is the honest answer” is true there and false for the daemon.OPENCODE-V2.md:92-94says nobody provides per-session isolation for opencode v2; reading the_metakey would. - Sources.
internal/cli/serve.go:296“return fmt.Sprintf("mcp-%d", os.Getpid())”;internal/cli/serve_ask.go:31-33“one per process is the honest answer”;v2:packages/core/src/mcp/client.ts:278
PM-11 Stateful server isolation per caller
- What. Who shares one instance of a stateful upstream (a browser). mcpx gives each session key its own process and pins the lease for a whole script run. opencode v1 has one connection per server per directory per opencode process, shared by every session in that window; v2 one per server per Location, shared by every session in every window attached to the daemon. lootbox holds one client per server for everyone. Cloudflare’s connections belong to one Agent, and a connector can key resources by the execution id.
- Where. As listed; opencode v2’s only mitigation is the advisory
_metasession id. - mcpx @ 05c78b2. Scopes global, repo, worktree, cwd, session, parent-session, pid and call
(
internal/config/config.go:50-71), each key its own process; sharing shared or exclusive (the package comment still names the stateful modes “pooled” and “session”,internal/pool/pool.go:3-16; key derivationinternal/config/scope.go:46-64). Asessionscope with no session id degrades tocall. Instances live untilIdleTimeoutwith no holders, with aMinfloor and aMaxcap evicted LRU (internal/pool/pool.go:238-244,697-723). - Value to mcpx. + high: concurrent Chrome sessions are the problem mcpx exists to solve; v2 makes the problem worse, which strengthens the case.
- Effort. S (done; the key is the open question, PM-10).
- Risk. Med: see PM-12 for instances that are not released.
- Detail. The pool is invisible to MCP hosts and compatible with 2026 statelessness; only its key matters, and the
key could come from request
_metaor a handle (PM-03). Cloudflare’s equivalent is resource-level, not process-level:ctx.executionIdpassed to connector methods, plusonPassEnd/disposeExecutionhooks. - Sources.
internal/pool/pool.go:14“"session" mode is the important one: a lease is pinned to a session key for”;v1:packages/opencode/src/cli/cmd/tui.ts:210;v2:packages/core/src/mcp/index.ts:152;.lootbox/src/lib/external-mcps/mcp_client_manager.ts:49; https://developers.cloudflare.com/agents/tools/codemode/how-it-works/#resource-lifetimes “Connector methods receive the stable execution ID, which can key durable resource metadata across passes.”
PM-12 /v1/exec session-scoped instances outlive the run
- What. After every run
execsvc.RunWithcallsRelease(session), which reachesRegistry.ReleaseCaller; that stops only keys whereCallerOwnedis true (Ephemeral, or acall:prefix).scriptEnvnever setsMCPX_EPHEMERAL, so for/v1/execruns, including the plugin’smcpx_execwith its generatedexec-<runID>session, session-scoped instances such as Chrome are not released; they wait for idle reaping. - Where. mcpx only. The CLI path sets
MCPX_EPHEMERALand is unaffected. - mcpx @ 05c78b2.
internal/execsvc/execsvc.go:481-485;internal/config/scope.go:39-41;internal/execsvc/execsvc.go:573-593; the generated client forwardsx-mcpx-ephemeralonly from that variable (internal/codegen/emit.go:533); CLI sets it atinternal/cli/commands.go:694. - Value to mcpx. + med: pooled browsers return promptly.
- Effort. S.
- Risk. Med: combined with the plugin sending no session (plugin-APIs area), a Chrome script run through the plugin gets a fresh browser every call, and each one lingers until idle timeout.
- Detail. Inferred from code; not run.
- Sources.
internal/execsvc/execsvc.go:481-485;internal/config/scope.go:39-41;internal/codegen/emit.go:533;internal/cli/commands.go:694
PM-13 MCP surface built once per daemon, never rebuilt
- What.
lazyMCPbuilds the MCP server on the first/mcprequest (adapters, OpenAPI documents, op tools) under async.Onceand never rebuilds it. A config reload that adds an adapter or an API does not changetools/list, andtools.listChangedcould never fire for mcpx’s own list. - Where. mcpx only.
- mcpx @ 05c78b2.
internal/cli/serve_ask.go:234-244;internal/cli/serve.go:332-347. - Value to mcpx. + med: adapters added at runtime appear without a restart.
- Effort. S.
- Risk. Med: users restart the daemon to see new adapter tools.
- Detail. The build uses the first request’s context, so a cancelled first request would cache a context error for the daemon’s life (inferred from code, not triggered).
- Sources.
internal/cli/serve_ask.go:242“l.once.Do(func() { l.srv, l.err = l.app.MCPServer(ctx) })”
PM-14 Daemon’s MCP server calls the daemon over its own socket
- What. Every
/mcptool call goes back to the same daemon over its own/v1socket (b.app.ensure(ctx), thenc.Call), adding a hop and a second HTTP request per call. The same code serves stdiomcpx serve, where it is the right design. - Where. mcpx only.
- mcpx @ 05c78b2.
internal/cli/serve.go:30-117; daemon mount atinternal/cli/root.go:190-195; rationale atdocs/protocol.md:341-344. - Value to mcpx. − latency and a failure mode (a daemon that cannot reach its own socket). + one code path, and it is what makes HTTP disconnects cancel upstream calls through context propagation.
- Effort. M to short-circuit.
- Risk. Low.
- Detail. Short-circuiting would have to keep the cancellation chain intact.
- Sources.
internal/cli/serve.go:104“c, err := b.app.ensure(ctx)”;docs/protocol.md:341-344
PM-15 /v1/protocol reports nativeElicit from the default
- What. The status endpoint says
"nativeElicit": truefromdefaults.ProtoNativeeven whenproto.native=falseis configured, which does disable native asking. - Where. mcpx only.
- mcpx @ 05c78b2.
internal/daemon/routes_proto.go:530; the setting is honoured atinternal/cli/serve.go:320-326. - Value to mcpx. + low: the endpoint’s stated reason to exist is that it “cannot disagree” with the code.
- Effort. S.
- Risk. Low: a declared-versus-delivered report.
- Detail. The rest of
asServercomes from the same tables the code consults (internal/daemon/routes_proto.go:495-497). - Sources.
internal/daemon/routes_proto.go:530“"nativeElicit": defaults.ProtoNative,”;internal/cli/serve.go:320“if a.Settings().Bool("proto.native") {”
PM-16 opencode process topology: one process vs shared daemon
- What. opencode v1 is one process per window: the TUI on the main thread and the server in a
Worker, reached by an in-process fetch shim (http://opencode.internal); a TCP port opens only with--port,--hostnameor mDNS. opencode v2’s default command resolves or starts a shared background service (“Starting background server…”) on 127.0.0.1:0xc0de (49374) for the latest/dev/beta/next channels, and every TUI connects to it over HTTP;--standaloneopts out. - Where. v1 and v2. v2 also deletes
packages/opencode: the binary ispackages/cli, running@opencode/server(/api/*, typed by@opencode/protocol) over@opencode/coreEffect services built per Location. - mcpx @ 05c78b2. Not affected directly; the plugin infers “headless” from
isMainThread(plugin/opencode/mcpx-session.ts:161), which is a v1 assumption. - Value to mcpx. + med: v2 is architecturally close to mcpx (daemon plus thin clients) and offers a typed HTTP API mcpx could call.
- Effort. S.
- Risk. Med: under v2 one server, one plugin instance per Location and one MCP connection per server per Location serve every window (PM-11).
- Detail. v2’s
Modeis"default" | "service" | "stdio", and the service may be registered with the OS.opencode serve/runin v1 put the server on the main thread. - Sources.
v1:packages/opencode/src/cli/cmd/tui.ts:210“const worker = new Worker(file, {”;v1:packages/opencode/src/cli/cmd/tui.ts:246;v2:packages/cli/src/commands/handlers/default.ts:31;v2:packages/cli/src/services/service-config.ts:35“return 0xc0de”;v2:packages/cli/src/services/server-connection.ts:41;v2:packages/cli/package.json:7
PM-17 MCP connection scope: per directory vs per Location
- What. v1 keeps MCP clients in
InstanceState, a cache keyed by directory; v2 keeps aServerEntry {client?, tools?, prompts?}map in a Location-scoped service (directory plus workspace id). All sessions of that directory or Location share each server’s single connection. - Where. opencode v1 and v2.
- mcpx @ 05c78b2. n/a; opencode will never give mcpx more than one connection per server entry per place, so mcpx must tell sessions apart itself (PM-10).
- Value to mcpx. + med.
- Effort. S.
- Risk. Med.
- Detail. v2 canonicalises the Location key before use. v2 spawns stdio servers in the Location’s
Environment, which may be a remote workspace (v2:packages/core/src/mcp/client.ts:198); v1 spawns them on the host. - Sources.
v1:packages/opencode/src/effect/instance-state.ts:49;v1:packages/opencode/src/mcp/index.ts:492;v2:packages/core/src/mcp/index.ts:67;v2:packages/core/src/mcp/index.ts:152;v2:packages/core/src/location-service-map.ts:21
PM-18 When MCP servers start: awaited all vs forked each
- What. v1 connects every configured server concurrently when MCP state is first touched, so the first MCP access
in a directory waits for the slowest server (up to its timeout). v2 forks each
startServer, publishesToolsChangedas each lands, and onlycallTool/prompt/readResourcewait on that server’s startup latch. - Where. opencode v1 and v2.
- mcpx @ 05c78b2. n/a.
- Value to mcpx. + low:
mcpx servestart time delays every v1 session’s first request. - Effort. S.
- Risk. Low.
- Detail. v1’s MCP state is not part of instance bootstrap; it materialises lazily on the first
InstanceState.get. - Sources.
v1:packages/opencode/src/mcp/index.ts:505;v1:packages/opencode/src/mcp/index.ts:528;v2:packages/core/src/mcp/index.ts:499;v2:packages/core/src/mcp/index.ts:507
PM-19 MCP server lifetime: instance vs daemon, never idle
- What. v1 closes clients (and kills descendants) when the directory instance is disposed or the process exits. v2
retains Location service graphs with
idleTimeToLive: Duration.infinity, so MCP servers live as long as the daemon. - Where. opencode v1 and v2.
- mcpx @ 05c78b2. n/a; an
mcpx servechild of the v2 daemon can live for days. - Value to mcpx. + low: mcpx must not assume a short-lived parent.
- Effort. S.
- Risk. Med: resource leaks in
mcpx servesurface under v2 first. - Detail. v2 invalidates a Location only on boot failure or explicit invalidation. Teardown differs too: v1 SIGTERMs descendants; v2 signals the process group, SIGKILL after 2 s (lifecycle area).
- Sources.
v1:packages/opencode/src/mcp/index.ts:531;v2:packages/core/src/location-services.ts:52“{ idleTimeToLive: Duration.infinity },”
PM-20 No automatic restart when an MCP server dies
- What. On transport close both versions mark the server
failed: "Connection closed", drop its tools and publishToolsChanged; neither respawns it. Recovery is a manualconnect. - Where. opencode v1 and v2. v2 does reconnect when a credential for that server’s integration changes.
- mcpx @ 05c78b2. n/a; if
mcpx serveexits, opencode loses mcpx until the user reconnects. - Value to mcpx. + med: the stdio side should survive daemon restarts and upgrades.
- Effort. S.
- Risk. Med: a daemon upgrade that kills
mcpx servesilently removes mcpx from every open opencode session. - Detail. 2026-07-28 expects a client to restart a crashed stdio server and retry (PM-06); neither opencode version does.
- Sources.
v1:packages/opencode/src/mcp/index.ts:448;v2:packages/core/src/mcp/index.ts:358;v2:packages/core/src/mcp/index.ts:543
PM-21 opencode database filename depends on release channel
- What. v1 uses
opencode.dbonly for the latest, beta and prod channels, elseopencode-<channel>.db; v2 usesopencode.dbfor latest, dev, beta, next and prod. Both honourOPENCODE_DBandOPENCODE_DISABLE_CHANNEL_DB. - Where. opencode v1 and v2; they disagree on whether
nextgets its own file. - mcpx @ 05c78b2.
mcpx stats opencodelooks only foropencode.db(internal/opencode/opencode.go:37,:46); this machine has bothopencode.dbandopencode-next.db. - Value to mcpx. + low: enumerate
opencode*.dbor read the channel. - Effort. S.
- Risk. Med: stats silently read the wrong opencode’s database.
- Detail. A v1
nextbuild writesopencode-next.db; a v2nextbuild writesopencode.db. - Sources.
v1:packages/core/src/database/database.ts:49;v2:packages/cli/src/database-path.ts:7;internal/opencode/opencode.go:37
PM-22 lootbox connects every server at startup and pings
- What. lootbox connects to every configured server before its HTTP listener opens, keeps one client per server for the daemon’s lifetime, and (in the fork) runs a health monitor that pings each server, reconnects with exponential backoff and gives up after a set number of failures.
- Where. lootbox. The deployed daemon on :9420 is not the fork: it is upstream
jx-codes/lootbox@587a5a1plus four patches (~/.config/nix/flake.nix, lines 386–398), and has neither the monitor nor a deep/health(the live/healthreturned{"status":"ok"}). The fork-only set also includes the multi-instance strategies (PM-24) and the configurable timeout and permissions. - mcpx @ 05c78b2. Lazy pools (
pool.min0,max4, idle 5 m,internal/defaults/defaults.json:2-10);Client.Pingexists (internal/mcpclient/client.go:726-727). - Value to mcpx. + low: a ping could spot a wedged stateful server before a script blocks on it.
- Effort. S.
- Risk. Low: pinging an idle lazy server keeps it alive and defeats
idleTimeout. - Detail. Startup waits for every server (
await clientManager.initializeClients(...)beforeDeno.serve);ASSESSMENT.md:100measured 43 s before the port opened. opencode v2 starts asynchronously (PM-18); Cloudflare restores an Agent’s connections after hibernation. - Sources.
.lootbox/README.md:385-388“Lootbox automatically monitors MCP server health with periodicping()probes.”;.lootbox/src/lib/rpc/managers/mcp_integration_manager.ts:221-222;.lootbox/src/lib/rpc/websocket_server.ts:190
PM-23 lootbox re-fetches every schema on every discovery
- What.
GET /namespaces,/rpc-namespacesand/types/:nsall callgetSchemas(), which runstools/listandresources/listagainst every connected server in turn; the cache accessorgetAllSchemas()is stubbed to return[]. - Where. lootbox (fork and deployed; the path is identical in both).
- mcpx @ 05c78b2. A persisted schema cache (
internal/daemon/registry.go:87-100) invalidated bylist_changed(internal/mcpclient/client.go:495-500). - Value to mcpx. − already solved.
- Effort. S.
- Risk. Low.
- Detail. lootbox calls
listTools()with no cursor, so only the first page is ever read. - Sources.
.lootbox/src/lib/rpc/managers/mcp_integration_manager.ts:380-396;.lootbox/src/lib/external-mcps/mcp_schema_fetcher.ts:137-140“// return Array.from(this.cache.values()); return [];”;.lootbox/src/lib/external-mcps/mcp_schema_fetcher.ts:69
PM-24 lootbox multi-instance strategies and port rewriting
- What. When two lootbox daemons configure the same server, the
warn/fail/auto-port/per-sessionstrategies coordinate them through a file registry;auto-portfinds a port in the server’s arguments (for example--remote-debugging-port) and rewrites it to a free one. - Where. lootbox fork only.
- mcpx @ 05c78b2. n/a: mcpx isolates callers within one daemon (PM-11).
- Value to mcpx. −
ASSESSMENT.mdputs it plainly: “the thing that needs isolating is the caller, not the process”; there is only ever one lootbox instance, a launchd singleton. - Effort. S.
- Risk. Low.
- Detail.
per-sessiondoes whatwarndoes without the warning: it registers a session and spawns one shared client, although the README says “Spawn independent server per session”. - Sources.
.lootbox/src/lib/rpc/managers/mcp_integration_manager.ts:200-216;ASSESSMENT.md:95-96“The abstraction is at the wrong level: the thing that needs isolating is the caller, not the process.”;.lootbox/README.md:417“|per-session| Spawn independent server per session |”
PM-25 Per-run script and client setup
- What. lootbox writes a temp file whose first line imports the client over HTTP from the daemon itself, then runs
deno run --reload=http://localhost:<port>/client.ts, re-fetching and re-compiling the client every time. mcpx writes the client to a directory named after its content hash only when it changes, and names inline scripts after their own hash, so the runtime’s compile cache stays warm. - Where. lootbox; mcpx. opencode runs in-process; Cloudflare uses a new isolate (CODE-04).
- mcpx @ 05c78b2.
internal/runner/runner.go:4-9(the rationale, naming lootbox),:245-251,:276-291,:455-475. - Value to mcpx. − already solved;
ASSESSMENT.mdmeasuredlootbox exec 'console.log(1)'at 0.74–10.3 s. - Effort. S.
- Risk. Low.
- Detail. lootbox’s client URL already carries
?v=<version>, so--reloadis redundant. A lootbox script runs in the daemon’s working directory, not the caller’s (code-mode area). - Sources.
.lootbox/src/lib/execute_llm_script.ts:18;.lootbox/src/lib/execute_llm_script.ts:33;.lootbox/src/lib/execute_llm_script.ts:13;internal/runner/runner.go:4-9
Progress and cancellation
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: notifications/progress and progress tokens, timeouts, notifications/cancelled and 2026 transport-specific cancellation, in the spec, mcpx, opencode and lootbox
Progress and cancellation let a long request report on itself and be stopped: progress gained a message in 2025-03-26 and
became server-only in 2026-07-28, and 2026 made cancellation transport-specific, so that closing an HTTP response stream
is the cancel and a client’s notifications/cancelled is defined only on stdio. mcpx never asks upstreams for progress
and never relays it to hosts, so long calls look hung and opencode v1’s 60-second timer cannot be kept alive (PC-04,
PC-05); an inbound notifications/cancelled is recorded and acted on by nothing, while an HTTP disconnect does cancel the
upstream call, for legacy hosts too (PC-11, PC-12). The request-scoping rule shared with log messages and a server ending a
listen stream with notifications/cancelled are in notifications.md; progress on tasks is in
tasks.md.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| PC-01 | notifications/progress fields; message added | 2025-03-26 has | 24-11 token, progress, total · 25-03+ + message | partial — parsed from upstreams onto the event bus only | + low | S | low |
| PC-02 | progressToken rules: string or integer, unique among active requests | 2024-11-05 has | all five; chosen by “sender” (24-11) → “client” (26-07) | n/a — never issues or forwards a token | + low | S | low |
| PC-03 | Progress direction: either side → server only | 2026-07-28 removes | 24-11..25-11 both ways · 26-07 server → client | partial — host progress answered with a -32601 error | + low | S | low |
| PC-04 | Client requests progress with _meta.progressToken | opencode v1 has opencode v2 has mcpx missing 2024-11-05 has | opencode v1/v2: token = request id; mcpx ✗; lootbox ✗ | ✓ — a call whose host sent a token carries one of mcpx’s own (#212) | + med | S | med |
| PC-05 | Proxy relays progress between host and upstream | mcpx missing | every revision (server → client) | ✓ — relayed under the host’s token (#212; docs/protocol.md §4.4) | + high | M | med |
| PC-06 | Timeouts, reset on progress, hard maximum | 2025-03-26 has opencode v1 has | 24-11 "appropriate" · 25-03+ SHOULD time out, MAY reset, SHOULD cap | partial — fixed 120 s call timeout; no progress reset | + med | S | med |
| PC-07 | notifications/cancelled requestId: required → optional → required | 2025-11-25 has 2026-07-28 has | 24-11..25-06 required · 25-11 optional · 26-07 required | ✓ — always sends it; tolerates its absence | + low | S | low |
| PC-08 | Cancellation reason | mcpx missing | optional string in every revision | ✗ — client always says "timeout" (#203) | + low | S | low |
| PC-09 | initialize MUST NOT be cancelled | 2024-11-05 has 2026-07-28 removes mcpx missing | 24-11..25-11 ✓ · 26-07 — (no initialize) | ✗ — a timed-out initialize gets a cancel (#203) | + low | S | low |
| PC-10 | Who may cancel: either side → client only (+ listen) | 2026-07-28 has | 24-11..25-11 either side · 26-07 client; server only for listen | ✓ — cancels only its own relayed legacy requests | + low | S | low |
| PC-11 | Receiver stops work on notifications/cancelled | impl deferred mcpx missing | every revision (stdio only in 26-07) | partial — recorded; no upstream call is interrupted (#77) | + med | M | med |
| PC-12 | HTTP: closing the stream is cancellation (2026), not (legacy) | 2026-07-28 has specs conflict opencode v2 has | 25-03..25-11 SHOULD NOT · 26-07 MUST | partial — disconnect cancels for every era; interruptible calls survive | + med | M | med |
| PC-13 | Client notifications/cancelled: stdio only in 2026 | 2026-07-28 has specs conflict mcpx missing | 24-11..25-11 any transport · 26-07 stdio MUST, HTTP undefined | partial — aborts the POST and also sends the notification (#203) | + low | S | low |
| PC-14 | A caller’s abort or timeout cancels the upstream call | opencode v1 has opencode v2 has | opencode ✓; mcpx ✓; lootbox ✗ | ✓ — context end sends notifications/cancelled upstream | + med | S | low |
PC-01 notifications/progress fields; message added
- What.
progressToken,progress(MUST increase with each notification) and optionaltotal, in every revision; 2025-03-26 adds an optional human-readablemessagethat SHOULD be relevant.progressandtotalMAY be floating point. - Where. Base fields in all five;
messagefrom 2025-03-26. - mcpx @ 05c78b2. As a client it parses all four fields,
messageincluded (internal/mcpclient/client.go:436-442), and publishes them as aProgressevent on the bus (internal/daemon/hooks.go:34-36). As a server it emits none (PC-05). - Value to mcpx. + low: the parsing is done; what is missing is the relay.
- Effort. S.
- Risk. low.
- Detail. The 2026 changelog also makes progress request-scoped (notifications.md, SUB-07). A
2026 embedded sampling request has no
_meta, so no token and no progress for it (sampling.md). - Sources.
schema/2024-11-05/schema.ts:271“notifications/progress”;schema/2025-03-26/schema.ts:316“message?: string;”;2025-03-26/changelog.mdx:24“Added”;2024-11-05/basic/utilities/progress.mdx:52“values MAY be floating point.”;internal/mcpclient/client.go:436-442;internal/daemon/hooks.go:34-36.
PC-02 progressToken rules: string or integer, unique among active requests
- What. A requester opts in by putting
progressTokenin the request’s_meta. Tokens MUST be a string or integer and MUST be unique across all active requests; notifications MUST reference only tokens from an active request, and MUST stop after completion. The receiver MAY send none at all. - Where. All five. 2024-11-05 says the token is “chosen by the sender”; 2026 says “chosen by the client”, since only clients request progress there (PC-03).
- mcpx @ 05c78b2. Issues no tokens upstream and forwards none from hosts (PC-04, PC-05).
- Value to mcpx. + low: the rules a relay must follow when it starts relaying.
- Effort. S.
- Risk. low.
- Detail. A host’s token is unique only among that host’s active requests. A relay that multiplexes many hosts onto one upstream connection therefore has to mint its own upstream tokens and map them back, rather than pass hosts’ tokens through. Unspecified in 2026: whether an MRTR retry may reuse the original request’s token (mrtr.md). opencode uses the JSON-RPC request id as the token (PC-04).
- Sources.
2024-11-05/basic/utilities/progress.mdx:15“Progress tokens MUST be a string or integer value”;2024-11-05/basic/utilities/progress.mdx:16-17“across all active requests.”;2026-07-28/basic/patterns/progress.mdx:17“Progress tokens can be chosen by the client using any means”;2024-11-05/basic/utilities/progress.mdx:56“Progress notifications MUST only reference tokens that:”;2024-11-05/basic/utilities/progress.mdx:88“Progress notifications MUST stop after completion”.
PC-03 Progress direction: either side → server only
- What. Legacy: either party may request and send progress, so a client can report progress on a server-initiated request (a long elicitation, say). 2026: only the server sends progress, only for tokens from an active client request, only on that request’s stream.
- Where. Both ways in 2024-11-05..2025-11-25; server to client in 2026-07-28.
- mcpx @ 05c78b2. mcpx never asks a host for progress on the questions it relays, and a host that sends
notifications/progressanyway gets an id-less-32601error back (wire S5; the hygiene bug is notifications.md, SUB-18). - Value to mcpx. + low: nothing to build; stop replying to the notification.
- Effort. S.
- Risk. low.
- Detail. Client-to-server progress disappears in 2026 because server-initiated requests do; legacy
ClientNotificationincludesProgressNotificationand 2026’s does not. - Sources.
2024-11-05/basic/utilities/progress.mdx:7“Either side can send progress notifications to”;2026-07-28/basic/patterns/progress.mdx:8“The server MAY send progress notifications”; wire S5.
PC-04 Client requests progress with _meta.progressToken
- What. Whether a client asks its server for progress at all. Without a token, a conforming server sends none.
- Where. opencode v1 and v2 pass a no-op
onprogressto everycallTool, which makes the SDK attach_meta.progressTokenequal to the numeric request id; the values are otherwise ignored (not shown to the user or the model). v1 code-mode child calls do the same. lootbox sends no token. mcpx sends none. - mcpx @ 05c78b2.
CallToolsends{name, arguments}only (internal/mcpclient/client.go:849);progressTokenappears only in the struct it parses (internal/mcpclient/client.go:438). - Value to mcpx. + med: upstream tools that report progress (browsers, crawls) could extend mcpx’s call deadline
(PC-06) and feed
/v1/execstreams and plugin metadata. - Effort. S — attach a token per upstream call; the notification path already exists.
- Risk. med: without it, a long upstream call is killed at the fixed call timeout even while it is making progress.
- Detail. opencode’s v1 comment says why: “The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.”
- Also recorded from the meta register. Every revision. opencode v1 and v2 pass a no-op progress handler, which
makes their SDKs set
_meta.progressTokento the numeric request id on everytools/call; the values are not shown to anyone. v1 resets its 60 s call timeout on progress; v2 does not.tools/callreads onlynameandarguments(internal/mcpserver/server.go:658-665), and the ask path deletes_metabefore dispatch (internal/mcpserver/ask.go:100), so the host’s token reaches nothing. The client never sets a token upstream. Forwarding progress itself is in the progress register. - Sources.
v1:packages/opencode/src/mcp/catalog.ts:64;v1:packages/opencode/src/tool/code-mode.ts:154-158;v2:packages/core/src/mcp/client.ts:281; https://unpkg.com/@modelcontextprotocol/sdk@1.29.0/dist/esm/shared/protocol.js (L649,progressToken: messageId);internal/mcpclient/client.go:849;internal/mcpclient/client.go:438.;schema/2024-11-05/schema.ts:25;2026-07-28/basic/index.mdx:352;internal/mcpserver/ask.go:100; https://unpkg.com/@modelcontextprotocol/sdk@1.29.0/dist/esm/shared/protocol.js L649 “progressToken: messageId”.
PC-05 Proxy relays progress between host and upstream
- What. A proxy in the middle of a call can pass the host’s progress request upstream and the upstream’s progress back to the host, on the host request’s own stream.
- Where. Progress exists in every revision; mcpx relays none of it.
- mcpx @ 05c78b2. The host’s
_meta(and with itprogressToken) is stripped before the call (forAsk,internal/mcpserver/ask.go:95-108); upstream calls carry no token (PC-04); progress that does arrive goes to the event bus only (internal/daemon/hooks.go:34-36);internal/mcpserverhas nonotifications/progresssender. - Value to mcpx. + high: progress for long
mcpx_callandmcpx_execruns in hosts, and, for opencode v1, the only way to keep a call alive past the SDK’s 60-second default, because v1 resets its timer on progress (PC-06). - Effort. M — token mapping per call (PC-02) and request-scoped delivery on HTTP (an SSE response) and stdio.
- Risk. med: long calls look hung, and v1 kills them at 60 s.
- Detail. In 2026 progress MUST go only on the originating request’s stream, never on a listen stream (notifications.md, SUB-07). mcpx already opens an SSE response for legacy elicitation, which is the same mechanism.
- Sources.
internal/mcpserver/ask.go:100“delete(m, "_meta")”;internal/daemon/hooks.go:34-36;2026-07-28/basic/patterns/progress.mdx:14;v1:packages/opencode/src/mcp/catalog.ts:61.
PC-06 Timeouts, reset on progress, hard maximum
- What. 2024-11-05 says only to implement appropriate timeouts. From 2025-03-26: implementations SHOULD time out every request and SHOULD cancel on timeout, SDKs SHOULD allow per-request timeouts, implementations MAY reset the clock on progress but SHOULD always enforce a maximum. 2026 moves the text from the lifecycle page to the cancellation page and phrases “cancel” per transport.
- Where. 2025-03-26 onward. opencode v1: connect and list 30 s;
tools/calluses the per-servertimeoutorexperimental.mcp_timeout, else the SDK’s 60 s, withresetTimeoutOnProgress: trueand nomaxTotalTimeout. opencode v2: startup 30 s, catalog 30 s, execution 12 h, with no reset on progress. lootbox: aPromise.raceper call that progress cannot reset. - mcpx @ 05c78b2. A fixed
pool.callTimeoutof 120 s (internal/defaults/defaults.json:6), configurable but not extended by progress, since mcpx requests none (PC-04). On timeout the client cancels upstream (PC-14). - Value to mcpx. + med: reset-on-progress up to a hard wall is the spec’s recommended shape and what long tools need.
- Effort. S once PC-04 exists.
- Risk. med: a server that sends progress forever never times out under a reset-only policy, which is why the SHOULD asks for a maximum. opencode v1 has no maximum.
- Detail. opencode v2’s comment says “Requesting progress keeps long calls alive under the SDK’s timeout; execution is
the hard wall”, but it never sets
resetTimeoutOnProgress, so progress has no effect and the 12 h value is the timeout. (The code-mode findings say v2 resets on progress too; the v2 source says otherwise.) opencode v1’s config text says the default is 5 s while its code uses 30 s, anddocs/opencode-plugin.md:178says “one value, 30 s”, which is only the connect and list timeout. - Sources.
2025-03-26/basic/lifecycle.mdx:199“Implementations SHOULD establish timeouts for all sent requests”;2025-03-26/basic/lifecycle.mdx:208“Implementations MAY choose to reset the timeout clock when receiving a”;2026-07-28/basic/patterns/cancellation.mdx:63“always enforce a maximum timeout, regardless of progress notifications”;v1:packages/opencode/src/mcp/catalog.ts:61“resetTimeoutOnProgress: true,”;v1:packages/opencode/src/mcp/index.ts:38“const DEFAULT_TIMEOUT = 30_000”;v2:packages/core/src/mcp/client.ts:34“const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000”;v2:packages/core/src/mcp/client.ts:280“Requesting progress keeps long calls alive under the SDK’s timeout”;v1:packages/core/src/v1/config/mcp.ts:21;.lootbox/src/lib/rpc/execute_mcp.ts:199-208;internal/defaults/defaults.json:6“"callTimeout": "120s",”.
PC-07 notifications/cancelled requestId: required → optional → required
- What.
requestIdis required through 2025-06-18. 2025-11-25 makes it optional so the notification’s shape can coexist with tasks (it MUST be present for non-task requests and MUST NOT be used for tasks, which usetasks/cancel). 2026 makes it required again, tasks having left the core. - Where. Required 2024-11-05..2025-06-18 and 2026-07-28; optional in 2025-11-25.
- mcpx @ 05c78b2. Always sends it, as client and as server (
internal/mcpclient/client.go:714;internal/mcpserver/conn.go:266). Inbound, a cancel withoutrequestIdis accepted and recorded under the key<nil>(internal/mcpserver/server.go:1521-1538), which does no harm because nothing reads the record (PC-11). - Value to mcpx. + low: accept a missing
requestIdfrom 2025-11-25 peers without error, which mcpx does. - Effort. S.
- Risk. low: a strict parser would reject 2025-11-25 cancels without the id.
- Detail. Neither changelog mentions either change.
- Sources.
schema/2024-11-05/schema.ts:132“requestId: RequestId;”;schema/2025-11-25/schema.ts:223“requestId?: RequestId;”;schema/2026-07-28/schema.ts:626“requestId: RequestId;”;internal/mcpserver/server.go:1533“c.cancelled[fmt.Sprint(p.RequestID)] = p.Reason”.
PC-08 Cancellation reason
- What. An optional free-text
reasononnotifications/cancelled; both parties SHOULD log it. - Where. Every revision.
- mcpx @ 05c78b2. The client says
"timeout"whatever ended the context: a host’s cancellation, a disconnect, or a real timeout (internal/mcpclient/client.go:714). A legacy question mcpx relayed and then gave up on is cancelled with"timed out"(internal/mcpserver/conn.go:266), which is accurate. - Value to mcpx. + low: honest upstream logs.
- Effort. S — take the reason from the context’s cause.
- Risk. low: misleading upstream logs.
- Detail. None beyond the above.
- Sources.
schema/2024-11-05/schema.ts:137“reason?: string;”;2026-07-28/basic/patterns/cancellation.mdx:108“Both parties SHOULD log cancellation reasons for debugging”;internal/mcpclient/client.go:714“"reason": "timeout"”.
PC-09 initialize MUST NOT be cancelled
- What. Every legacy revision forbids a client to cancel its
initializerequest, in prose and schema. - Where. 2024-11-05..2025-11-25; 2026 has no
initialize. - mcpx @ 05c78b2. The client’s generic path sends
notifications/cancelledfor any request whose context ends (internal/mcpclient/client.go:708-716),initializeincluded. - Value to mcpx. + low: skip the cancel for
initialize. - Effort. S.
- Risk. low: a strict server may treat it as a protocol error.
- Detail. Found by reading the code path; not driven on the wire.
- Sources.
2024-11-05/basic/utilities/cancellation.mdx:34“request MUST NOT be cancelled by clients”;schema/2025-11-25/schema.ts:238“A client MUST NOT attempt to cancel its”;internal/mcpclient/client.go:708-716.
PC-10 Who may cancel: either side → client only (+ listen)
- What. Legacy: either party may cancel a request it issued, in the same direction. 2026: the client cancels its own
requests; a server may send
notifications/cancelledonly to end asubscriptions/listenstream (see notifications.md, SUB-11), because it has no requests of its own to cancel. - Where. Either side in 2024-11-05..2025-11-25; client (plus listen teardown) in 2026-07-28.
- mcpx @ 05c78b2. As a legacy server it cancels its own relayed questions when it stops waiting for them
(
internal/mcpserver/conn.go:258-266). For 2026 hosts it sends no requests, so it has nothing to cancel. - Value to mcpx. + low: correct.
- Effort. S.
- Risk. low.
- Detail. The 2026 page still opens with “A client SHOULD send a cancellation notification”, generically, although on HTTP the client cancels by closing (PC-13).
- Sources.
2024-11-05/basic/utilities/cancellation.mdx:7“Either side can send a cancellation notification to”;2026-07-28/basic/patterns/cancellation.mdx:8“A client SHOULD send a cancellation notification”;internal/mcpserver/conn.go:265-266.
PC-11 Receiver stops work on notifications/cancelled
- What. A receiver SHOULD stop processing the cancelled request, free its resources and not send a response; it MAY ignore the cancel if the request is unknown, finished, or cannot be cancelled.
- Where. Every revision; in 2026 the notification exists only on stdio (PC-13).
- mcpx @ 05c78b2. The cancel is recorded in a per-connection map (
internal/mcpserver/server.go:642-647,:1521-1538) andOnCancelis a field nothing sets (internal/mcpserver/server.go:134-135), so no upstream call is interrupted. The doc says so and defers it (docs/protocol.md:88,:372-374). - Value to mcpx. + med: stdio hosts could stop long scripts and calls.
- Effort. M — keep a
context.CancelFuncper request id on each connection; on stdio this first needs requests handled off the read loop (a transports-register item). - Risk. med: work runs on after the host gave up; the recorded map on the default stdio connection grows for the process lifetime.
- Detail. Recording is conformant under the MAY (the request “cannot be cancelled” today), but useless. Over HTTP the disconnect path already cancels (PC-12), which makes stdio the gap.
- Sources.
2025-06-18/basic/utilities/cancellation.mdx:36“Receivers of cancellation notifications SHOULD:”;2026-07-28/basic/patterns/cancellation.mdx:73“Servers receiving cancellation notifications SHOULD:”;docs/protocol.md:88“recorded; mcpx cannot yet interrupt an upstream call mid-flight”;docs/protocol.md:372“Inbound cancellation does not reach an upstream call.”;internal/mcpserver/server.go:134-135.
PC-12 HTTP: closing the stream is cancellation (2026), not (legacy)
- What. Legacy Streamable HTTP: a disconnect SHOULD NOT be read as cancellation; to cancel, the client SHOULD send
notifications/cancelled. 2026: closing the request’s SSE response stream MUST be treated as cancellation of that request. - Where. 2025-03-26..2025-11-25 versus 2026-07-28. opencode v2 on a modern HTTP connection aborts the HTTP request.
- mcpx @ 05c78b2. When an HTTP host closes its POST,
r.Context()ends (internal/mcpserver/server.go:1176-1177), which cancels the backend’s/v1/callto the daemon (internal/cli/client.go:197), the daemon handler’sreg.Call(internal/daemon/server.go:696), and the pool call, whose client then cancels upstream (internal/mcpclient/client.go:709-716). This matches 2026, is not era-gated, and is not described in the docs. For an interruptible call the upstream work runs as a daemon task and is deliberately not cancelled (internal/mcpserver/ask.go:157-162; mrtr.md, MRTR-20). - Value to mcpx. + med: already works; document and test it, and gate it by era.
- Effort. M — era gating plus a test that drives a real disconnect.
- Risk. med: treating a legacy host’s dropped stream as a cancel kills work a resuming legacy client expected to keep; not treating a 2026 host’s as one wastes upstream work.
- Detail. Inferred from the context chain; no disconnect was driven on the wire, and buffering in the unix-socket HTTP client was not traced. On 2026 HTTP a plain JSON (non-SSE) response has no stream to close, so dropping the connection is the only signal.
- Sources.
2025-03-26/basic/transports.mdx:122“Disconnection SHOULD NOT be interpreted as the client cancelling its request.”;2026-07-28/basic/transports/streamable-http.mdx:235“Closing the SSE response stream MUST be treated by the server as”;2026-07-28/basic/patterns/cancellation.mdx:39“Closing the SSE response stream is the cancellation signal.”;internal/mcpserver/server.go:1176-1177;internal/daemon/server.go:696;internal/mcpclient/client.go:709-716;v2:packages/core/src/mcp/client.ts:273.
PC-13 Client notifications/cancelled: stdio only in 2026
- What. 2026 stdio: the client MUST send
notifications/cancelled(there is no per-request stream to close). 2026 HTTP defines no client-to-server notifications at all; closing the stream is the cancel and no notification is expected. Yet the cancellation page opens with a generic “A client SHOULD send a cancellation notification”. - Where. Any transport in 2024-11-05..2025-11-25; stdio only in 2026-07-28.
- mcpx @ 05c78b2. On a 2026 HTTP upstream the client does both: the request’s own POST is aborted by the same context
(
internal/mcpclient/http.go:115), which is the correct signal, and it also sendsnotifications/cancelled(internal/mcpclient/client.go:713-715), which 2026 HTTP does not define. - Value to mcpx. + low: drop the notification for 2026 HTTP upstreams.
- Effort. S.
- Risk. low: a strict 2026 server may reject the notification POST.
- Detail. Read the SHOULD as applying to stdio. Header requirements for notification POSTs are “not defined by this revision”, so a dual-era server should not reject a header-less notification; mcpx’s own server answers one with 202 (wire W26).
- Sources.
2026-07-28/basic/patterns/cancellation.mdx:42“There is no per-request stream to close. The client MUST send a”;2026-07-28/basic/transports/streamable-http.mdx:95“This revision of the core protocol defines no client-to-server”;2026-07-28/basic/transports/streamable-http.mdx:98-104;internal/mcpclient/client.go:713-715;internal/mcpclient/http.go:115; wire W26.
PC-14 A caller’s abort or timeout cancels the upstream call
- What. When the thing that started a tool call gives up (the user presses Esc, a script times out or dies), the in-flight upstream request should be cancelled rather than left running.
- Where. opencode v1 and v2 pass the call’s
AbortSignaltocallTool, and the SDK sendsnotifications/cancelled {requestId, reason}on abort or timeout (v2 on modern HTTP aborts the request instead). mcpx cancels through the request context. lootbox’s per-call timeout is aPromise.racethat abandons the promise without cancelling it, and no signal is passed, so a killed script’s calls keep running in the daemon until the SDK’s own 60 s timeout cancels them. - mcpx @ 05c78b2.
/v1/callpassesr.Context()toreg.Call(internal/daemon/server.go:696); when it ends, the client sendsnotifications/cancelledupstream (internal/mcpclient/client.go:709-716). A script that times out has its process group killed (internal/runner/runner.go:424-438), which drops its socket requests and so cancels their upstream calls the same way. - Value to mcpx. + med: already done; it keeps pool slots and upstream work from leaking.
- Effort. S — done.
- Risk. low. The opencode plugin’s
mcpx_execpasses no abort signal to the daemon, so a user’s cancel in opencode does not reach an mcpx script (a code-mode register item). - Detail. The
reasonis always"timeout"(PC-08), and inbound cancellation of mcpx itself is PC-11. - Sources.
v1:packages/opencode/src/mcp/catalog.ts:62“signal: options.abortSignal,”;v2:packages/core/src/mcp/client.ts:273; https://unpkg.com/@modelcontextprotocol/sdk@1.29.0/dist/esm/shared/protocol.js (L677);internal/daemon/server.go:696;internal/mcpclient/client.go:709-716;.lootbox/src/lib/rpc/execute_mcp.ts:199-208;.lootbox/src/lib/rpc/execute_mcp.ts:61.
Prompts
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: Prompt, PromptArgument and PromptMessage per revision, content allowed in prompt messages, and how mcpx and opencode pass prompts on
Prompts are server-authored message templates a user picks — opencode turns each into a slash command — and the shape
has grown only by optional fields (title, icons, _meta) and by what a message may carry (audio in 2025-03-26,
resource_link in 2025-06-18). What matters most at 05c78b2 is that mcpx collapses every prompts/get into one user
text message, and sends the 2025-06-18 title to 2025-03-26 hosts because lists are never revision-filtered.
Content-block types are in the content-types register, prompts.listChanged in the capabilities register, cache hints in
the pagination-caching register, and prompts/get failures reported as -32602 are ERR-04 in the resources register.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| PRM-01 | Prompt.title | 2025-06-18 has mcpx missing | 24-11 — · 25-03 — · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✗ — kept, but also sent to 2025-03-26 hosts (#207) | + low | S | low |
| PRM-02 | PromptArgument.title: in the schema, not in 2025-06-18 prose | 2025-06-18 has specs conflict mcpx missing | 25-06 ✓ (schema) · 25-11 ✓ · 26-07 ✓ | ✗ — dropped on parse (#207) | + low | S | low |
| PRM-03 | Prompt.icons (not on arguments) | 2025-11-25 has mcpx missing | 25-11 ✓ · 26-07 ✓ | ✗ — dropped on parse (#207) | + low | S | low |
| PRM-04 | _meta on Prompt (not on arguments) | 2025-06-18 has mcpx missing | 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✗ — dropped on parse (#207) | + med | S | med |
| PRM-05 | audio allowed in PromptMessage.content | 2025-03-26 has | 24-11 T I R · 25-03 +audio · later same | ✗ — dropped by flattening (PRM-07) | + low | S | low |
| PRM-06 | resource_link allowed in prompt messages; prose lags until 2026 | 2025-06-18 has specs conflict | 25-06 ✓ (schema) · 25-11 ✓ (schema) · 26-07 ✓ (schema + prose) | ✗ — dropped by flattening (PRM-07) | + low | S | low |
| PRM-07 | mcpx flattens prompts/get into one user text message | 2024-11-05 has mcpx missing | every revision allows many messages, roles, content types | ✗ — roles, order and non-text content lost (#206) | + med | S | med |
| PRM-08 | opencode turns MCP prompts into slash commands (text only) | opencode v1 has opencode v2 has | opencode v1 ✓ ($1..$n) · v2 ✓ (server:prompt) | ✓ — mcpx prompts appear as commands | + low | S | low |
PRM-01 Prompt.title
- What. A human display name for a prompt, separate from
name, viaBaseMetadata. - Where. 2025-06-18 onward; documented in 2025-06-18 prose.
- mcpx @ 05c78b2. The client keeps an upstream
title(internal/mcpclient/client.go:786), and mcpx’sPromptRefemits it (internal/mcpserver/server.go:94) to every host.downgrade()touches only content, messages,structuredContentandresultType(internal/mcpserver/revisions.go:132-170), so a 2025-03-26 host receives a field its revision does not define — a send-conservatively conflict. - Value to mcpx. + low; the fix is the list filtering the tools register (TOOL-25) also needs.
- Effort. S.
- Risk. Low: unknown fields are usually ignored. The same gap applies to any future
iconsor_meta. - Detail. The 2025-03-26
Promptisname,description,argumentsonly. - Sources.
schema/2025-06-18/schema.ts:701“export interface Prompt extends BaseMetadata {”;2025-06-18/server/prompts.mdx:176“-title: Optional human-readable name of the prompt for display purposes.”;schema/2025-03-26/schema.ts:599-612;internal/mcpserver/server.go:94“Title string;internal/mcpserver/revisions.go:132-170`
PRM-02 PromptArgument.title
- What. Each prompt argument may carry a display
title. - Where. In the 2025-06-18 schema onward (
PromptArgument extends BaseMetadata); the 2025-06-18 prose documentsPrompt.titlebut not the argument’s. - mcpx @ 05c78b2. Neither the client’s
PromptArgument(internal/mcpclient/client.go:792-796) nor mcpx’sPromptArg(internal/mcpserver/server.go:100-104) has atitle, so it is dropped. - Value to mcpx. + low: argument labels in a host’s prompt form.
- Effort. S.
- Risk. Low.
- Detail.
PromptReferencein completion requests gainstitlethe same way (completion register). - Sources.
schema/2025-06-18/schema.ts:722“export interface PromptArgument extends BaseMetadata {”;internal/mcpclient/client.go:792-796;internal/mcpserver/server.go:100-104
PRM-03 Prompt.icons
- What.
Prompt extends BaseMetadata, Icons.PromptArgumentgets no icons. - Where. 2025-11-25 and 2026.
- mcpx @ 05c78b2. The client
Promptstruct hasname,title,description,argumentsonly (internal/mcpclient/client.go:784-789); icons are dropped. - Value to mcpx. + display.
- Effort. S.
- Risk. Low.
- Detail. Same
Iconshape as tools and resources. - Sources.
schema/2025-11-25/schema.ts:984“export interface Prompt extends BaseMetadata, Icons {”;2025-11-25/server/prompts.mdx:185“-icons: Optional array of icons for display in user interfaces”;internal/mcpclient/client.go:784-789
PRM-04 _meta on Prompt
- What. Arbitrary metadata on a prompt definition.
PromptArgumenthas none. - Where. 2025-06-18 onward.
- mcpx @ 05c78b2. Dropped on parse (
internal/mcpclient/client.go:784-789). - Value to mcpx. + extension data must survive the proxy.
- Effort. S.
- Risk. Extension metadata silently lost.
- Detail. See the tools register for
_metaonTool. - Sources.
schema/2025-06-18/schema.ts:714“_meta?: { [key: string]: unknown };”;internal/mcpclient/client.go:784-789
PRM-05 audio in prompt messages
- What. 2024-11-05 prompt messages carry text, image or an embedded resource; 2025-03-26 adds audio.
PromptMessage.contentis always one block, never an array. - Where. 2025-03-26 onward.
- mcpx @ 05c78b2.
downgradeMessagesrewrites each message’s single block per revision (internal/mcpserver/revisions.go:236-258), and audio’s floor is 2025-03-26, which is mcpx’s own floor, so no rewrite is ever needed. In practice audio never reaches a host at all:prompts/getkeeps only text (PRM-07). - Value to mcpx. + low.
- Effort. S (with PRM-07).
- Risk. Low.
- Detail. mcpx does not serve 2024-11-05, the one revision where audio in a prompt would be invalid.
- Sources.
schema/2024-11-05/schema.ts:607“content: TextContent | ImageContent | EmbeddedResource;”;schema/2025-03-26/schema.ts:645“content: TextContent | ImageContent | AudioContent | EmbeddedResource;”;internal/mcpserver/revisions.go:236-258
PRM-06 resource_link in prompt messages
- What. From 2025-06-18
PromptMessage.contentis aContentBlock, so a prompt may carry aresource_link(a URI to fetch) instead of embedding contents. - Where. In the schema from 2025-06-18. The 2025-06-18 and 2025-11-25 prompts prose documents only text, image, audio and embedded resources, and the 2025-06-18 changelog advertises resource links only “in tool call results”; a “Resource Links” section first appears in 2026 prose.
- mcpx @ 05c78b2.
downgradeMessageswould turn aresource_linkinto an embedded resource for pre-2025-06-18 hosts (internal/mcpserver/revisions.go:248-253), but PRM-07 means no link ever reaches it. - Value to mcpx. + low.
- Effort. S (with PRM-07).
- Risk. Low.
- Detail. A host implemented from the 2025-06-18 prose alone may not expect links in prompts.
- Sources.
schema/2025-06-18/schema.ts:750“content: ContentBlock;”;2026-07-28/server/prompts.mdx:274“#### Resource Links”;2025-06-18/changelog.mdx:27“Add support for resource links in”;internal/mcpserver/revisions.go:248-253
PRM-07 mcpx flattens prompts/get
- What. A
GetPromptResultis an optionaldescriptionplusmessages[], each with arole(userorassistant) and one content block — enough for few-shot prompts that alternate roles and carry images or resources. - Where. Every revision.
- mcpx @ 05c78b2. The upstream result is rendered to one string by
renderPrompt(internal/cli/serve.go:640-669): the description is folded into the text, non-user roles become[role]prefixes (internal/cli/serve.go:662-664), non-text content is dropped, and mcpx answers with a single{role:"user", content:{type:"text"}}message (internal/mcpserver/server.go:759-764). - Value to mcpx. + few-shot prompts keep working through mcpx; images and resources survive.
- Effort. S — pass
messagesthrough and letdowngradeMessagesdo its job. - Risk. If not fixed: prompt semantics change silently — an assistant turn becomes user text.
- Detail.
downgradeMessagesexists (internal/mcpserver/revisions.go:236-258) but only ever sees one text block. opencode also keeps only the text of prompt messages (PRM-08), so for opencode hosts the loss is mostly the roles. - Also recorded from the content types register. Multi-message, multi-role prompts exist in every revision.
opencode v1 and v2 use only the text of prompt messages anyway (slash commands).
renderPrompt(internal/cli/serve.go:640-669), used byprompts/get(internal/mcpserver/server.go:759-764) and the ask path (internal/mcpserver/ask.go:271-275). An embedded text resource in a prompt message is dropped too:renderPromptreads onlycontent.text, and a resource’s text sits atcontent.resource.text. - Sources.
internal/cli/serve.go:662-664“fmt.Fprintf(&b, “;internal/cli/serve.go:640-669;internal/mcpserver/server.go:759-764;internal/mcpserver/revisions.go:236-258;internal/mcpserver/server.go:760-763;schema/2024-11-05/schema.ts:607“content: TextContent | ImageContent | EmbeddedResource;”
PRM-08 opencode prompts as slash commands
- What. opencode lists prompts from servers that declare
promptsand exposes each as a slash command, using only thetextcontent of the resulting messages. - Where. opencode v1: command key
sanitize(server):sanitize(prompt), positional arguments mapped to$1..$n,getPromptfetched lazily when the template is read. opencode v2:server:prompt, parsed arguments, refreshed onprompts/list_changed. - mcpx @ 05c78b2. mcpx re-exposes upstream prompts as
<ns>_<name>(wire W19), so under an opencode server entry calledmcpxa prompt appears asmcpx:<ns>_<name>. - Value to mcpx. + mcpx-proxied prompts are usable in opencode with no extra work; − image and resource content in a prompt never reaches opencode either way.
- Effort. S.
- Risk. Low.
- Detail. v1 fetches prompts fresh on each command listing (no cache), so it ignores
prompts/list_changedwithout going stale for long. - Sources.
v1:packages/opencode/src/command/index.ts:105;v1:packages/opencode/src/command/index.ts:124;v1:packages/opencode/src/mcp/catalog.ts:108;v2:packages/core/src/plugin/command.ts:55;v2:packages/core/src/plugin/command.ts:105
Resources
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: Resource and ResourceTemplate fields, resources/read contents, subscriptions and resource errors per revision, and how mcpx, opencode and lootbox expose resources
Resources are read-only data a server publishes by URI, listed directly or through URI templates; mcpx re-exposes every
upstream’s resources under mcpx://<ns>/… and publishes script artifacts as a template of its own. What mcpx sends back
at 05c78b2 is lossy — templates carry uri instead of the required uriTemplate, binary and multi-part contents
collapse into one text item, optional fields are dropped on parse — and resources/subscribe is accepted and declared on
stdio but never reaches an upstream. List-change and subscribe capabilities are in the capabilities register, cache hints
in the pagination-caching register, resource_link and embedded resources in the content-types register; ERR-06 and
ERR-04 overlap the errors register.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| RES-01 | Resource.title / ResourceTemplate.title | 2025-06-18 has mcpx missing | 24-11 — · 25-03 — · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✗ — dropped on parse (#207) | + low | S | low |
| RES-02 | Resource.size: in the 2024-11-05 schema, in prose from 2025-03-26 | 2024-11-05 has specs conflict mcpx missing | all five ✓ (schema) · prose from 25-03 · templates never | ✗ — dropped on parse (#207) | + med | S | low |
| RES-03 | resource annotations (audience, priority) | 2024-11-05 has mcpx missing | all five ✓ (mixin until 25-03, same wire) | ✗ — dropped on parse (#207) | + med | S | low |
| RES-04 | Annotations.lastModified | 2025-06-18 has mcpx missing | 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✗ — dropped with annotations (#207) | + low | S | low |
| RES-05 | Resource.icons / ResourceTemplate.icons | 2025-11-25 has mcpx missing | 25-11 ✓ · 26-07 ✓ | ✗ — dropped on parse (#207) | + low | S | low |
| RES-06 | _meta on Resource, ResourceTemplate, read contents | 2025-06-18 has mcpx missing | 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✗ — dropped on parse and on read (#207) | + med | S | med |
| RES-07 | ResourceTemplate.uriTemplate (required) | 2024-11-05 has mcpx missing | all five ✓ | ✗ — broken: mcpx sends uri (wire W18) (#207) | + high | S | high |
| RES-08 | upstream URIs re-exposed as mcpx://<ns>/<uri> | mcpx has, others don't | mcpx only | partial — disambiguates; leading / trimmed so URIs can collide | + med | S | low |
| RES-09 | text vs base64 blob contents, unchanged since 2024-11-05 | 2024-11-05 has mcpx missing | all five ✓ (+ _meta 25-06) | ✗ — always text; binary as base64 text or a placeholder (#206) | + med | S | med |
| RES-10 | contents[] may hold several items | 2024-11-05 has mcpx missing | all five ✓ | ✗ — merged into one text item under the requested URI (#206) | + low | S | low |
| RES-11 | a missing resource MUST NOT read as empty contents | 2026-07-28 has | 26-07 ✓ only | ✓ — failures are errors, never contents: [] | + low | S | low |
| RES-12 | resources/subscribe removed in 2026 | 2024-11-05 has 2026-07-28 removes has better replacement | 24-11..25-11 ✓ · 26-07 rem (listen filter) · opencode never sends | acc. — accepted from every era | + med | S | low |
| RES-13 | resources/unsubscribe removed in 2026 | 2024-11-05 has 2026-07-28 removes has better replacement | 24-11..25-11 ✓ · 26-07 rem | acc. — accepted from every era | + low | S | low |
| RES-14 | notifications/resources/updated listen-only and tagged in 2026 | 2024-11-05 has 2026-07-28 has mcpx missing | 24-11..25-11 after subscribe · 26-07 listen stream + subscriptionId | partial — sent untagged (#208) | + med | S | med |
| RES-15 | resources/updated carries the upstream URI, not mcpx:// | 2024-11-05 has mcpx missing | all five (the uri param) | ✗ — the host cannot correlate the update (#208) | + med | S | med |
| RES-16 | how resources reach the model in each host | opencode v1 has opencode v2 has | opencode v1 3 model tools · v2 TUI @ list only · mcpx tool + script API | ✓ — mcpx_resource_read and readResource() | + med | S | med |
RES-01 Resource.title / ResourceTemplate.title
- What. A human display name, separate from the programmatic
name(which stays required), viaBaseMetadata. - Where. 2025-06-18 onward.
- mcpx @ 05c78b2. The client’s
Resourcestruct has onlyuri,uriTemplate,name,description,mimeType(internal/mcpclient/client.go:137-143), sotitleis gone before mcpx re-exposes anything; mcpx’s ownResourceRefhas notitleeither (internal/mcpserver/server.go:84-89). - Value to mcpx. + display in hosts and in
mcpx_resources. - Effort. S — but re-exposing it needs revision filtering, since lists are not downgraded (tools register).
- Risk. Display data lost.
- Detail. Same loss for
Prompt-side titles is in the prompts register. - Sources.
schema/2025-06-18/schema.ts:526“export interface Resource extends BaseMetadata {”;2025-06-18/server/resources.mdx:279“-title: Optional human-readable name of the resource for display purposes.”;internal/mcpclient/client.go:137-143;internal/mcpserver/server.go:84-89
RES-02 Resource.size
- What. Optional raw size in bytes (before base64), so a client can estimate context use before reading.
- Where. In the schema of all five revisions; the 2024-11-05 prose field list omits it, 2025-03-26 prose adds it.
ResourceTemplatenever hassize. - mcpx @ 05c78b2. Dropped on parse (
internal/mcpclient/client.go:137-143) and absent fromResourceRef(internal/mcpserver/server.go:84-89). - Value to mcpx. + a size before a read is exactly what an agent needs to decide whether to pull a resource into context — mcpx’s own goal.
- Effort. S.
- Risk. Low.
- Detail. mcpx’s artifacts already know their size (it goes into the
resource_linkdescription), so mcpx could emitsizefor its own resources too. - Sources.
schema/2024-11-05/schema.ts:449“size?: number;”;2024-11-05/server/resources.mdx:276“-mimeType: Optional MIME type”;2025-03-26/server/resources.mdx:277“-size: Optional size in bytes”;internal/mcpclient/client.go:137-143
RES-03 resource annotations
- What.
annotations: {audience?, priority?}on resources and templates. 2024-11-05 gets it through theAnnotatedmixin; 2025-03-26 names the typeAnnotationsand adds an explicit field — same JSON. - Where. All five. The rename is not in the 2025-03-26 changelog, and the prose “Annotations” section first appears in 2025-06-18.
- mcpx @ 05c78b2. Dropped on parse (
internal/mcpclient/client.go:137-143). - Value to mcpx. +
audience: ["user"]is the upstream saying “keep this out of the model’s context”;priority(0..1) orders what to read first. - Effort. S.
- Risk. Low.
- Detail. The same
Annotationstype sits on content blocks (content-types register). - Sources.
schema/2024-11-05/schema.ts:417“export interface Resource extends Annotated {”;schema/2025-03-26/schema.ts:475“annotations?: Annotations;”;internal/mcpclient/client.go:137-143
RES-04 Annotations.lastModified
- What. Optional ISO 8601 last-modified timestamp in
Annotations, on resources, templates and content blocks. - Where. 2025-06-18 onward. The 2025-06-18 changelog does not list it.
- mcpx @ 05c78b2. Dropped along with
annotations(RES-03). - Value to mcpx. + a freshness hint for cached reads.
- Effort. S.
- Risk. Low.
- Detail. None beyond RES-03.
- Sources.
schema/2025-06-18/schema.ts:1127“lastModified?: string;”;2025-06-18/server/resources.mdx:314“lastModified: An ISO 8601 formatted timestamp”
RES-05 Resource.icons / ResourceTemplate.icons
- What.
ResourceandResourceTemplateextendIcons. - Where. 2025-11-25 and 2026.
- mcpx @ 05c78b2. Dropped on parse (
internal/mcpclient/client.go:137-143). - Value to mcpx. + display.
- Effort. S.
- Risk. Low.
- Detail. Same
Iconshape as tools (tools register). - Sources.
schema/2025-11-25/schema.ts:802“export interface Resource extends BaseMetadata, Icons {”;schema/2025-11-25/schema.ts:845“export interface ResourceTemplate extends BaseMetadata, Icons {”
RES-06 _meta on resources and read contents
- What. Arbitrary metadata on each
Resource,ResourceTemplateand each item ofresources/readcontents. - Where. 2025-06-18 onward.
- mcpx @ 05c78b2. Dropped on parse for listings (
internal/mcpclient/client.go:137-143) and when reads are flattened to text (internal/cli/serve.go:608-637). - Value to mcpx. + extensions (MCP Apps UI resources among them) carry their data here.
- Effort. S.
- Risk. Extension metadata silently lost.
- Detail. See the tools register for
_metaonTool. - Sources.
schema/2025-06-18/schema.ts:561“_meta?: { [key: string]: unknown };”;schema/2025-06-18/schema.ts:620;internal/cli/serve.go:608-637
RES-07 ResourceTemplate.uriTemplate
- What. A template carries its RFC 6570 URI template in the required field
uriTemplate; there is nourion a template. - Where. All five.
- mcpx @ 05c78b2. Broken.
resources/templates/listreturns[]ResourceRef, whose only URI field isjson:"uri"(internal/mcpserver/server.go:85,internal/mcpserver/server.go:574-593,internal/cli/serve.go:684-698). Wire W18:{"uri":"mcpx://artifacts/{id}","name":"mcpx artifact",…}. To a schema-validating host these are malformed; a lax one finds nouriTemplateto expand. - Value to mcpx. + mcpx’s artifact template and every upstream template become usable by hosts.
- Effort. S — a separate template type with
uriTemplate. - Risk. If not fixed: every host ignores or rejects mcpx’s templates.
- Detail. The client side reads
uriTemplatecorrectly (internal/mcpclient/client.go:139). lootbox readsuriTemplateoffresources/listentries, where it never appears (CODE-46). - Sources.
schema/2025-11-25/schema.ts:851“uriTemplate: string;”;internal/mcpserver/server.go:85“URI string;internal/cli/serve.go:684-698;internal/mcpclient/client.go:139`
RES-08 mcpx://<ns>/<uri> namespacing
- What. mcpx re-exposes each upstream resource as
mcpx://<namespace>/<original-uri>, appends “ (ns)“ to its description, and keeps onlyname,descriptionandmimeType. - Where. mcpx only; it is how a proxy keeps two servers’ identical URIs apart (tools register, TOOL-02, for the same rule on names).
- mcpx @ 05c78b2.
internal/cli/serve.go:486-495. A leading/is trimmed from the upstream URI (internal/cli/serve.go:490), so an upstream URI/absandabsmap to the samemcpx://ns/abs. - Value to mcpx. + disambiguation is necessary; − the optional fields dropped with it (RES-01..RES-06).
- Effort. S.
- Risk. Low; the collision needs an upstream that publishes bare paths.
- Detail. The daemon’s
/v1/resourcesand themcpx_resourcestool use the same listing. Reads strip the prefix back off before calling the upstream. - Sources.
internal/cli/serve.go:490“URI: "mcpx://" + r.Namespace + "/" + strings.TrimPrefix(r.URI, "/"),”;internal/cli/serve.go:486-495
RES-09 text versus blob contents
- What.
resources/readreturnscontents: (TextResourceContents | BlobResourceContents)[]:uri, optionalmimeType, and eithertextor base64blob. Only_metawas added (2025-06-18). - Where. All five, otherwise unchanged.
- mcpx @ 05c78b2. Every read answers one
TextResourceContents(internal/mcpserver/server.go:720-722). An mcpx artifact with a binary type becomes base64 intextunder e.g.image/png(internal/cli/exec.go:548-552). An upstreamblobbecomes the placeholder(N bytes of mime, base64)(internal/cli/serve.go:608-637), where N is the length of the base64 string, not the byte count. - Value to mcpx. + hosts that can render images get them; − blobs cost context, but
resources/readis the explicit “I want the bytes” call. - Effort. S — emit
blobfor binary. - Risk. A host that trusts
mimeTypetries to decode text as an image; an upstream binary resource is unreadable through mcpx’s MCP surface. - Detail. The first item’s
mimeTypebecomes the whole result’s (internal/cli/serve.go:621-624); an empty type becomestext/plain. - Also recorded from the content types register.
BlobResourceContentsin every revision. Result shape atinternal/mcpserver/server.go:720-722; artifacts atinternal/cli/exec.go:548-552; upstream flattening inrenderResource(internal/cli/serve.go:608-637). Several upstreamcontents[]items, possibly with different URIs and types, are joined with newlines into one item under the requested URI, and the first item’smimeTypebecomes the whole result’s (internal/cli/serve.go:621-624,:636); per-part URIs (a directory listing, say) are lost. - Sources.
schema/2024-11-05/schema.ts:359“contents: (TextResourceContents | BlobResourceContents)[];”;schema/2026-07-28/schema.ts:1230;internal/cli/exec.go:552“return base64.StdEncoding.EncodeToString(body), mime, nil”;internal/mcpserver/server.go:720-722;schema/2024-11-05/schema.ts:506;schema/2025-03-26/schema.ts:537-548;internal/cli/serve.go:636“return strings.Join(parts, "\n"), mime, nil”
RES-10 several contents items merged into one
- What. A read may return several items, with different URIs and types (a directory listing, a multi-part document).
- Where. All five.
- mcpx @ 05c78b2. The upstream
contents[]is joined with newlines into one text item under the requested URI (internal/cli/serve.go:619-636); the ask path uses the same renderer (internal/cli/serve_ask.go:196-201). - Value to mcpx. + low.
- Effort. S — pass the array through with rewritten URIs.
- Risk. Per-part URIs and types are lost.
- Detail. Blob parts become placeholders inside the joined text (RES-09).
- Sources.
internal/cli/serve.go:636“return strings.Join(parts, “;internal/cli/serve_ask.go:196-201
RES-11 no empty contents for a missing resource
- What. 2026 states outright that “not found” must be an error, never
contents: []; an empty array stays legal for a resource that exists but is empty. - Where. 2026; not stated in earlier revisions.
- mcpx @ 05c78b2. Read failures are errors (
internal/mcpserver/server.go:713-716), and a success always carries exactly one item (internal/mcpserver/server.go:720-722). - Value to mcpx. + low: already conforms.
- Effort. None.
- Risk. Low. The one-item shape has its own ambiguity: an upstream that returns
contents: []for an existing empty resource reaches the host as one item with emptytext. - Detail. None beyond the above.
- Sources.
2026-07-28/server/resources.mdx:410“Servers MUST NOT return an emptycontentsarray for a non-existent resource.”;internal/mcpserver/server.go:720-722
RES-12 resources/subscribe
- What. A legacy per-URI subscribe RPC. 2026 removes it; a client lists the URIs in
resourceSubscriptionsonsubscriptions/listeninstead. - Where. 2024-11-05 through 2025-11-25. Neither opencode version ever sends it.
- mcpx @ 05c78b2. Accepted from any era (
internal/mcpserver/server.go:595-620) — accept liberally. The per-URI set is kept on the connection and mapped ontorestartListenwith only the URI list (internal/mcpserver/server.go:619), which replaces any list-changed filter the connection had open. - Value to mcpx. + accepting from every era is right.
- Effort. S.
- Risk. A host mixing
subscriptions/listenfor list changes andresources/subscribeon stdio loses its list-changed filter on the first subscribe. As a client, calling it against a pure-2026 upstream would get-32601(mcpx’s client makes it a no-op for modern upstreams). - Detail. Over HTTP the call answers
{}and nothing can ever be pushed (capabilities register, CAP-17). - Sources.
schema/2025-11-25/schema.ts:751“method: "resources/subscribe";”;2026-07-28/changelog.mdx:18“Replace the HTTP GET endpoint andresources/subscribe/resources/unsubscribewithsubscriptions/listen”;internal/mcpserver/server.go:619“s.restartListen(c, ListenFilter{ResourceSubscriptions: uris})”
RES-13 resources/unsubscribe
- What. The legacy counterpart of RES-12. 2026 has no per-URI unsubscribe: the client cancels the listen stream and opens a new one with a different filter.
- Where. 2024-11-05 through 2025-11-25.
- mcpx @ 05c78b2. Accepted from any era (
internal/mcpserver/server.go:595); on stdio it answered{}on the wire (stdio run 2). - Value to mcpx. + fine as is.
- Effort. Done.
- Risk. None.
- Detail. Unsubscribing the last URI restarts the listener with an empty filter.
- Sources.
schema/2025-11-25/schema.ts:769“method: "resources/unsubscribe";”;2026-07-28/basic/patterns/subscriptions.mdx:9“the client cancels it. It replaces the formerresources/subscribeRPC and the HTTP GET”;internal/mcpserver/server.go:595
RES-14 notifications/resources/updated in 2026
- What. The notification and its
uriparam are unchanged, but in 2026 it is sent only on asubscriptions/listenstream for URIs inresourceSubscriptions, and every listen-stream notification MUST carry_meta["io.modelcontextprotocol/subscriptionId"]. - Where. All five; delivery changes in 2026.
- mcpx @ 05c78b2. Emitted with params
{uri}and no_meta(internal/events/events.go:260-261), to legacy and modern listeners alike. - Value to mcpx. + 2026 stdio hosts can attribute updates to a subscription.
- Effort. S — tag on listen streams only; legacy subscribers must not get the key.
- Risk. 2026 hosts multiplexing several subscriptions cannot correlate untagged notifications.
- Detail. In every revision the
uri“might be a sub-resource of the one that the client actually subscribed to”. The tagging rule applies to all listen-stream notifications (notifications register). - Sources.
schema/2026-07-28/schema.ts:1421“This is only sent for resources the client opted in to via theresourceSubscriptionsfield”;schema/2024-11-05/schema.ts:403“method: "notifications/resources/updated";”;internal/events/events.go:261
RES-15 resources/updated carries the upstream URI
- What. A host subscribes to the URI it was given; the update has to name that URI.
- Where. Every revision.
- mcpx @ 05c78b2. The listener strips
mcpx://<ns>/from the host’s URIs to match upstream events (internal/cli/serve.go:747-758) but the outgoing notification carries the upstream URI (internal/events/events.go:261), which the host never saw. The namespace is discarded in the strip, so the same URI from two servers matches both. - Value to mcpx. + updates become usable.
- Effort. S — re-prefix on the way out and keep the namespace in the filter.
- Risk. If not fixed: every update is uncorrelatable for the host.
- Detail. Only matters once SUB-15 makes updates arrive at all.
- Sources.
internal/events/events.go:261“return "notifications/resources/updated", map[string]any{"uri": e.URI}, true”;internal/cli/serve.go:747-758
RES-16 how resources reach the model
- What. Every revision calls resources “application-driven”: the host decides how they reach the model. Hosts differ sharply.
- Where. opencode v1 adds three model-facing tools —
list_mcp_resources,list_mcp_resource_templates,read_mcp_resource— to every request once any connected server declaresresources, even in code mode; reads go through areadpermission onmcp:<server>:<uri>. opencode v2 lists resources and templates only for the TUI@autocomplete andGET /api/mcp/resource;Mcp.readResourceexists with no caller. lootbox generates functions (CODE-46). - mcpx @ 05c78b2. Resources are reachable through the
mcpx_resources,mcpx_resource_templatesandmcpx_resource_readop tools (wire W6) and from scripts through onereadResource(uri)export (internal/execsvc/execsvc.go:610-611). - Value to mcpx. + opencode v1 pays three schemas per request just because mcpx declares
resources(capabilities register, CAP-16); v2 never reads them, so mcpx’s own tools are the only route for a v2 model. - Effort. S.
- Risk. Context cost in v1.
- Detail. v1’s three tools duplicate mcpx’s own resource tools when mcpx is the server.
- Sources.
2026-07-28/server/resources.mdx:24“Resources in MCP are designed to be application-driven, with host applications”;v1:packages/opencode/src/session/tools.ts:27;v1:packages/opencode/src/session/tools.ts:136;v1:packages/opencode/src/session/tools.ts:388;v2:packages/tui/src/component/prompt/autocomplete.tsx:413;v2:packages/core/src/mcp/index.ts:713;internal/execsvc/execsvc.go:610-611
Roots
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-11-01T16:22:00-06:00
increment: 1
status: standard
tags: area:compare
description: roots/list, the roots capability and its list_changed notification across revisions, and what mcpx, opencode and lootbox answer
Roots let a server ask the client which filesystem locations it should work in. The request is in every revision; 2026-07-28
drops the change notification and the listChanged flag, carries roots/list only as an embedded input request, and
deprecates the feature in favour of tool parameters or configuration. mcpx declares roots and answers from configured
roots, and relays a roots request to the originating host when that host declared roots; otherwise it uses its configured
fallback (ROOT-01/04). opencode declares roots in both versions and answers with one root, its directory.
Status column: ROOT-01/03/04 were refreshed at 2a93f3f64; other rows retain the mcpx @ 05c78b2 snapshot. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| ROOT-01 | roots/list and what a client answers | 2024-11-05 has 2026-07-28 deprecates opencode v1 has opencode v2 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 dep. (MRTR); opencode one root; lootbox ✗ | ✓ — declares roots and answers configured roots, or the calling host’s roots when relayed (modern.go:342-359; hooks.go:373-409) | + med | S | low |
| ROOT-02 | notifications/roots/list_changed removed | 2024-11-05 has 2026-07-28 removes | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — | ✗ — answers the notification with a -32601 error | + low | S | low |
| ROOT-03 | Roots deprecated; ListRootsRequest embedded in InputRequiredResult | 2026-07-28 deprecates has better replacement | 26-07 only | ✓ — embedded roots/list answered like legacy | − deprecated | S | low |
| ROOT-04 | Server asks its host for roots | 2026-07-28 deprecates | spec: any server may; mcpx relays when the request has a calling client | ✓ — forwards roots/list to the declared host’s roots handler (hooks.go:373-409; relay_caps_test.go:78-134) | + med | M — done | low |
| ROOT-05 | Per-request roots _meta key | specs conflict | SEP-2575 lists it; 2026-07-28 defines no such key | n/a — neither sent nor read | − moot | S | low |
ROOT-01 roots/list and what a client answers
- What. A server asks the client for
Root{uri, name?}entries;urimust be afile://URI. A client that supports it declares therootscapability._metaonRootarrives in 2025-06-18. - Where. All five revisions; in 2026-07-28 only as an MRTR input request, and deprecated. opencode v1 and v2 both
declare
rootsand answer one root, the opencode directory (v1 the instance directory, v2 the Location directory). lootbox declares no capabilities, so its SDK would answer-32601. - mcpx @ 2a93f3f64. It declares roots (
internal/mcpclient/modern.go:102-108) and answers from configured roots (internal/mcpclient/modern.go:342-359). When a server asks during a call whose originating client declared roots, the daemon relays the request to that client; otherwise mcpx falls back to its configured roots (internal/daemon/hooks.go:373-409). - Value to mcpx. + med: filesystem servers can use configured roots or the calling client’s workspace.
- Effort. S — configured-root fallback and call-scoped host relay are implemented.
- Risk. low — an empty configured-root list is legal; relayed roots stay scoped to the host call that requested them.
- Detail. The natural roots vary per scope key (cwd, repo, worktree), so a pooled instance shared across keys has no single right answer; per-key instances do. opencode’s root is the opencode directory, not the git worktree. 2026 prose spells out that roots are “informational guidance rather than an access-control mechanism”.
- Sources.
schema/2024-11-05/schema.ts:1029“roots/list”;schema/2025-06-18/schema.ts:1439“_meta?: { [key: string]: unknown };”;schema/2026-07-28/schema.ts:2760“This must start with”;internal/mcpclient/modern.go:102-108,342-359;internal/daemon/hooks.go:373-409;v1:packages/opencode/src/mcp/index.ts:78“Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),”;v2:packages/core/src/mcp/client.ts:155;v2:packages/core/src/mcp/client.ts:145“Some legacy servers refuse to run without one”;.lootbox/src/lib/external-mcps/mcp_client_manager.ts:150-153;2026-07-28/client/roots.mdx:22“are informational guidance rather than an access-control mechanism.”.
ROOT-02 notifications/roots/list_changed removed
- What. The client-to-server “my roots changed” notification. Legacy clients that declared
listChanged: trueMUST send it on change. 2026 removes it; the only client notification left isnotifications/cancelled. - Where. 2024-11-05..2025-11-25.
- mcpx @ 05c78b2. As a client it declares
listChanged: false, so it never has to send one. As a server it has no handler, and the default branch answers with a-32601error (internal/mcpserver/server.go:766). Wire S5:{"jsonrpc":"2.0","error":{"code":-32601,"message":"no method notifications/roots/list_changed"}}, a response to a notification, which MUST NOT get one. - Value to mcpx. + low: stop replying; host-root relay is implemented in ROOT-04.
- Effort. S — ignore notifications (the general fix is in notifications.md).
- Risk. low: strict hosts log a protocol error on every roots change.
- Detail. opencode declares
rootswithoutlistChanged, so it never sends this notification to mcpx. - Sources.
schema/2025-11-25/schema.ts:2146“notifications/roots/list_changed”;2025-11-25/client/roots.mdx:78“When roots change, clients that support”;schema/2026-07-28/schema.ts:3166“export type ClientNotification = CancelledNotification;”;internal/mcpserver/server.go:766; wire S5.
ROOT-03 Roots deprecated; ListRootsRequest embedded in InputRequiredResult
- What. 2026 deprecates Roots (SEP-2577): migrate to tool parameters, resource URIs or server configuration.
ListRootsRequestbecomes a bare{method, params?: {_meta?}}insideinputRequests, andListRootsResultno longer extendsResult. - Where. 2026-07-28.
- mcpx @ 2a93f3f64. The client answers an embedded
roots/listwith the sameanswer()as a legacy request (internal/mcpclient/modern.go:342-359), so a 2026 upstream gets configured roots or the originating host’s roots when the request is relayed (ROOT-01/04). - Value to mcpx. − deprecated.
- Effort. S — done.
- Risk. low.
- Detail. The embedded request’s
_metais a plainMetaObject, notRequestMetaObject, so no protocol version or capabilities are required inside it. opencode v2’s code says roots are “Legacy era only: … modern servers cannot request them”, yet the same client registers aroots/listhandler and its SDK auto-fulfils MRTR input requests from registered handlers (mrtr.md, MRTR-18); which of the two holds on a modern v2 connection was not verified. - Sources.
2026-07-28/client/roots.mdx:8“The Roots feature is deprecated as of protocol version”;schema/2026-07-28/schema.ts:2718“export interface ListRootsRequest {”;schema/2026-07-28/schema.ts:2742“export interface ListRootsResult {”;internal/mcpclient/modern.go:116-120;v2:packages/core/src/mcp/client.ts:144;v2:packages/core/src/mcp/client.ts:155.
ROOT-04 Server asks its host for roots
- What. Any server may ask its client for roots (a
roots/listrequest in legacy, aListRootsRequestinput request in 2026). A proxy can use that to learn the host’s workspace and pass it on to upstreams. - Where. Every revision (deprecated in 2026).
- mcpx @ 2a93f3f64.
rootsViaBrokerrelaysroots/listto the host client whose call was interrupted; without a calling client, the client answers from its configured roots (internal/daemon/hooks.go:373-409;internal/mcpclient/modern.go:342-359). - Value to mcpx. + med: upstreams can learn the workspace of the client whose call they are serving.
- Effort. M — done.
- Risk. low: a host with no declared roots is not sent a roots request.
- Detail. The relay is request-scoped; mcpx does not broadcast one client’s roots to other pooled clients.
- Sources.
internal/daemon/hooks.go:373-409;internal/mcpclient/relay_caps_test.go:78-134;internal/mcpserver/relay_test.go:149-152;internal/mcpclient/modern.go:342-359;v1:packages/opencode/src/mcp/index.ts:78.
ROOT-05 Per-request roots _meta key
- What. SEP-2575 (stateless MCP) lists
rootsamong the fields carried per request in_meta, next toprotocolVersion,clientInfo,logLevelandclientCapabilities. The 2026-07-28 specification defines noio.modelcontextprotocol/rootskey; roots arrive only as MRTRroots/listinput requests. - Where. SEP text only.
- mcpx @ 05c78b2. Neither sends nor reads such a key.
- Value to mcpx. − moot: follow the specification, not the SEP.
- Effort. S.
- Risk. low: a server built from the SEP text might look for a key no client sends.
- Detail. The reserved
_metakey table in 2026 hasprotocolVersion,clientInfo,clientCapabilities,logLevelandsubscriptionId, noroots. - Sources.
seps/2575-stateless-mcp.md:760“(protocolVersion,clientInfo,roots,logLevel,clientCapabilities)”;2026-07-28/basic/index.mdx:356.
Sampling
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-11-01T16:20:00-06:00
increment: 1
status: standard
tags: area:compare
description: sampling/createMessage fields and capabilities per revision, tool use in sampling, its 2026 deprecation, and how mcpx relays it
Sampling lets a server ask the client’s model for a completion: nearly unchanged from 2024-11-05 to 2025-06-18, given tool
use and sub-capabilities in 2025-11-25, and deprecated in 2026-07-28, where it survives only as an embedded input request
(mrtr.md); sampling as a client-hosted task (2025-11-25 only) is in tasks.md. mcpx has no model of
its own, so sampling is a relay: upstream requests become broker rows for an agent to answer. The client declares
sampling only when a request handler exists (SMP-09); host-bound relays check sampling.tools and
sampling.context (SMP-11).
Status column: SMP-03/09/11 are current (audited at 2a93f3f64); other rows retain the mcpx @ 05c78b2 snapshot. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| SMP-01 | sampling/createMessage request, result and baseline fields | 2024-11-05 has 2026-07-28 deprecates mcpx has, others don't | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 dep.; opencode v1/v2 ✗, lootbox ✗ | ✓ — relayed both directions through the broker | + low | S | low |
| SMP-02 | includeContext other than none soft-deprecated, then deprecated | 2025-11-25 deprecates 2026-07-28 deprecates | values in all five; gated by sampling.context from 25-11 | ✓ — never declares sampling.context upstream | − deprecated | S | low |
| SMP-03 | tools and toolChoice in sampling requests | 2025-11-25 has 2026-07-28 deprecates | 25-11 ✓ · 26-07 dep. | ✓ — not declared upstream; undeclared requests are refused, and host relay requires sampling.tools (modern.go:142-179; conn.go:572-581) | + low | M | low |
| SMP-04 | stopReason: "toolUse" | 2025-11-25 has | 25-11 ✓ · 26-07 ✓; open string in all | n/a — never inspected | − moot | S | low |
| SMP-05 | SamplingMessage.content may be an array | 2025-11-25 has | 24-11..25-06 single · 25-11 single or array · 26-07 same | n/a — relayed raw; broker decoding not examined | + low | S | low |
| SMP-06 | Tool-result messages hold only tool_result blocks, paired by id | 2025-11-25 has | 25-11 ✓ · 26-07 ✓ (dep.) | n/a — mcpx runs no tool loop | − moot | S | low |
| SMP-07 | Sampling feature deprecated (SEP-2577) | 2026-07-28 deprecates | 26-07 dep.; removable from 2027-07-28 | ✓ — relay exists; nothing new to build | − deprecated | S | low |
| SMP-08 | 2026 CreateMessageRequest embedded; no _meta; result not a Result | 2026-07-28 has | 26-07 only | ✓ — decoded per inputRequests key | + low | S | low |
| SMP-09 | Client declares sampling only when something can answer | 2026-07-28 deprecates | spec: declare what you support; mcpx: handler-gated | ✓ — declared iff a request handler is configured (modern.go:120-123; daemon/hooks.go:82) | + med | S | med |
| SMP-10 | Upstream sampling answered by an agent through the broker | mcpx has, others don't 2026-07-28 deprecates | mcpx only | partial — decline becomes an error; role/model filled in | + low | S | med |
| SMP-11 | Relaying sampling to a host checks declared sub-capabilities | 2025-11-25 has | 25-11 ✓ · 26-07 ✓ | ✓ — tools require sampling.tools; context is dropped without sampling.context (conn.go:561-581,696-703) | + low | S | low |
SMP-01 sampling/createMessage request, result and baseline fields
- What. A server asks the client’s model for a completion:
messages,modelPreferences(hints[],costPriority,speedPriority,intelligencePriority),systemPrompt,includeContext,temperature,maxTokens(required),stopSequences,metadata. The result is aSamplingMessageplusmodelandstopReason. - Where. All five revisions; deprecated in 2026-07-28. opencode v1 comments
samplingout and v2 does not declare it; neither registers a handler. lootbox declares no capabilities. - mcpx @ 05c78b2. Relayed both ways: an upstream’s request goes to a downstream handler, which in the daemon is the
broker (
internal/mcpclient/modern.go:121-128); host-bound requests are gated byCanSample(internal/mcpserver/conn.go:211). - Value to mcpx. + low: the relay exists; the feature is deprecated, so it should not grow.
- Effort. S — done.
- Risk. low.
- Detail.
metadatais retyped fromobjecttoJSONObjectin 2026.ModelHint.nameis a substring hint, not an exact model id. opencode v2 plugins could answer a forwarded sampling request withctx.generate.text, but only as a flattened prompt string with no messages, system prompt or tools. - Sources.
schema/2024-11-05/schema.ts:762“sampling/createMessage”;schema/2024-11-05/schema.ts:784“maxTokens: number;”;schema/2026-07-28/schema.ts:2140“metadata?: JSONObject;”;internal/mcpclient/modern.go:121-128;internal/mcpserver/conn.go:211;v1:packages/opencode/src/mcp/index.ts:42“// sampling: {},”;v2:packages/core/src/mcp/client.ts:142;.lootbox/src/lib/external-mcps/mcp_client_manager.ts:150-153.
SMP-02 includeContext other than none soft-deprecated, then deprecated
- What.
includeContext: "none" | "thisServer" | "allServers"exists in every revision. 2025-11-25 soft-deprecates the two non-nonevalues and gates them onsampling.context(servers SHOULD send onlynoneor omit it otherwise). 2026 reclassifies them as Deprecated under SEP-2596, removable no later than Sampling itself. - Where. Values in all five; soft-deprecated in 2025-11-25; Deprecated in 2026-07-28.
- mcpx @ 05c78b2. Never declares
sampling.contextupstream (internal/mcpclient/modern.go:55), so conforming upstreams send onlynone. That is right for a proxy, which has no “context from other servers” to inject. The host direction is SMP-11. - Value to mcpx. − deprecated: nothing to build.
- Effort. S.
- Risk. low: SHOULD-level only, so a server may still send the other values and the client MAY ignore them.
- Detail. Changelog-silent: the 2025-11-25 changelog mentions neither the soft-deprecation nor
sampling.context; the 2026 deprecated registry dates it to 2025-11-25. The 2024-11-05 and 2025-06-18 sampling prose never mentionincludeContextat all; it is schema-only there. - Sources.
schema/2024-11-05/schema.ts:776“includeContext?:”;schema/2025-11-25/schema.ts:1592“Values "thisServer" and "allServers" are soft-deprecated.”;schema/2025-11-25/schema.ts:332“context?: object;”;2025-11-25/client/sampling.mdx:69“With context inclusion support (soft-deprecated):”;2026-07-28/deprecated.mdx:30;internal/mcpclient/modern.go:120-123.
SMP-03 tools and toolChoice in sampling requests
- What. A server may pass
tools: Tool[](scoped to that request; they need not be registered tools) andtoolChoice{mode: "auto" | "required" | "none"}. Clients MUST declaresampling.toolsto receive them, and MUST return an error if they arrive anyway; servers MUST NOT send them to clients lacking it. - Where. 2025-11-25; deprecated with Sampling in 2026-07-28.
- mcpx @ 2a93f3f64. Not declared upstream (
internal/mcpclient/modern.go:120-123), so conforming upstreams do not send tools to mcpx. If a non-conforming upstream does, mcpx refuses tools/toolChoice with-32602(internal/mcpclient/modern.go:142-179). Toward hosts, a tool-enabled request is offered only to a host that declaredsampling.tools(internal/mcpserver/conn.go:572-581). - Value to mcpx. + low: only matters for an agentic-sampling relay, of a deprecated feature.
- Effort. M — tool-loop semantics (SMP-05, SMP-06).
- Risk. low while undeclared.
- Detail.
toolChoicedefaults to{mode: "auto"}. The prose gate (server MUST NOT send) and the schema gate (client MUST error) apply to opposite ends; a proxy sits at both. - Sources.
schema/2025-11-25/schema.ts:1615“tools?: Tool[];”;schema/2025-11-25/schema.ts:1636“mode?: "auto" | "required" | "none";”;schema/2025-11-25/schema.ts:336“tools?: object;”;2025-11-25/client/sampling.mdx:40“Clients MUST declare support for tool use via the”;2025-11-25/changelog.mdx:18“Add tool calling support to sampling via”;internal/mcpclient/modern.go:142-179;internal/mcpserver/conn.go:572-581.
SMP-04 stopReason: "toolUse"
- What. A new standard stop reason for “the model wants to call tools”. Earlier standard values are
endTurn,stopSequence,maxTokens. - Where. 2025-11-25 and 2026-07-28. The field is an open string in every revision.
- mcpx @ 05c78b2. Not inspected; the broker answer path does not fill
stopReason(it is optional; see SMP-10). - Value to mcpx. − moot.
- Effort. S.
- Risk. None: open string.
- Detail. Only meaningful together with SMP-03.
- Sources.
schema/2024-11-05/schema.ts:804“stopReason?:”;schema/2025-11-25/schema.ts:1673“"toolUse" | string;”.
SMP-05 SamplingMessage.content may be an array
- What. A single content block through 2025-06-18;
SamplingMessageContentBlock | SamplingMessageContentBlock[]from 2025-11-25. BecauseCreateMessageResult extends SamplingMessage, results may be arrays too. - Where. 2025-11-25 and 2026-07-28.
- mcpx @ 05c78b2. Upstream requests are relayed as raw params on the 2026 path (
internal/mcpserver/ask.go:256-266); how the broker and the answering agent handle array content was not examined. - Value to mcpx. + low: a relay must not assume one block.
- Effort. S.
- Risk. low: decoding an array as an object fails.
- Detail. Not in the 2025-11-25 changelog. Allowed block types grow from text and image (2024-11-05) to text, image,
audio,
tool_useandtool_result(2025-11-25); the content-types register owns the blocks themselves. - Sources.
schema/2025-06-18/schema.ts:1090“content: TextContent | ImageContent | AudioContent;”;schema/2025-11-25/schema.ts:1683“content: SamplingMessageContentBlock | SamplingMessageContentBlock[];”.
SMP-06 Tool-result messages hold only tool_result blocks, paired by id
- What. A user message containing
tool_resultblocks MUST contain only tool results, and every assistant message withtool_useblocks MUST be followed by a user message of only matchingtool_results. - Where. 2025-11-25 and 2026-07-28 (deprecated).
- mcpx @ 05c78b2. mcpx runs no sampling tool loop, so nothing to check.
- Value to mcpx. − moot: needed only if mcpx relayed tool loops.
- Effort. S.
- Risk. low:
-32602from strict clients if violated. - Detail. The 2026 schema still lists “Missing tool result or tool results mixed with other content” as an
InvalidParamsErrorcase for sampling, but under MRTR a client has no way to return an error for one input request (mrtr.md, MRTR-17). - Sources.
2025-11-25/client/sampling.mdx:372“it MUST contain ONLY tool results.”;2025-11-25/client/sampling.mdx:430“MUST be followed by a user message”;schema/2026-07-28/schema.ts:371“Missing tool result or tool results mixed with other content”.
SMP-07 Sampling feature deprecated (SEP-2577)
- What. The capability, request, result and all sampling types are
@deprecatedin 2026-07-28; the migration is to call LLM provider APIs directly. - Where. 2026-07-28; functional through at least 2027-07-28.
- mcpx @ 05c78b2. The relay exists and is declared whenever a handler is installed
(
internal/mcpclient/modern.go:54-56). - Value to mcpx. − deprecated: keep what exists, do not grow it.
- Effort. S.
- Risk. None.
- Detail. Roots and Logging are deprecated by the same SEP; Elicitation is not.
- Sources.
2026-07-28/client/sampling.mdx:8“The Sampling feature is deprecated as of protocol version”;2026-07-28/deprecated.mdx:27“Sampling”.
SMP-08 2026 CreateMessageRequest embedded; no _meta; result not a Result
- What. In 2026
CreateMessageRequestno longer extendsJSONRPCRequest; its params extend nothing (no_meta, so noprogressToken);CreateMessageResult extends SamplingMessageonly (noresultType, no_meta). - Where. 2026-07-28.
- mcpx @ 05c78b2. The client decodes
{method, params}perinputRequestskey (internal/mcpclient/modern.go:140-143). - Value to mcpx. + low: matches.
- Effort. S.
- Risk. None.
- Detail. None of this is in the changelog; it follows from MRTR. A long sampling request therefore has no progress channel in 2026.
- Sources.
schema/2026-07-28/schema.ts:2185“export interface CreateMessageRequest {”;schema/2026-07-28/schema.ts:2210“export interface CreateMessageResult extends SamplingMessage {”;schema/2025-11-25/schema.ts:1656“export interface CreateMessageResult extends Result, SamplingMessage {”;internal/mcpclient/modern.go:140-143.
SMP-09 Client declares sampling only when something can answer
- What. A client should declare only what it supports. mcpx declares
samplingonly when its server-request handler exists. - Where. Spec rule in every revision; mcpx’s
capabilities()gates the field on a configured handler. - mcpx @ 2a93f3f64.
capabilities()addssamplingiffOnServerRequestis non-nil (internal/mcpclient/modern.go:94-123); the daemon installs its broker-backed handler (internal/daemon/hooks.go:82,155-174). The handler turns requests into broker work; an unanswered request may still expire. Thus declaration reflects configured support, not whether an agent is connected at that instant. - Value to mcpx. − done: the declaration follows handler availability.
- Effort. S — done.
- Risk. med: a sampling server waits
elicit.ttl(120 s) per call and gets an error when nobody answers. - Detail. The capability check reports configured support. A broker request can remain open until a handler answers or the request expires; lack of an immediately connected agent does not remove the configured handler.
- Sources.
internal/mcpclient/modern.go:94-123;internal/daemon/hooks.go:82,155-174,280-328.
SMP-10 Upstream sampling answered by an agent through the broker
- What. An upstream’s
sampling/createMessagebecomes a broker row for the agent audience (the opencode plugin can answer it with the session’s model). An accept is returned as aCreateMessageResultwithrole: "assistant"andmodel: "unknown"filled in if missing; any other answer becomes a JSON-RPC error. - Where. mcpx only; neither opencode version nor lootbox handles sampling at all.
- mcpx @ 05c78b2.
internal/daemon/hooks.go:180-232. - Value to mcpx. + low: lets a deprecated feature work for servers that still depend on it.
- Effort. S — done.
- Risk. med: without an answerer, 120 s of waiting per call (SMP-09).
- Detail. Sampling has no
decline/cancelresult, so mapping a declined broker row to an error is the only option in legacy; in 2026 it is also the only option, since MRTR has no per-input error (MRTR-17).stopReasonis not filled; the schema makes it optional. - Sources.
internal/daemon/hooks.go:215-217“the sampling request was”;internal/daemon/hooks.go:180-232.
SMP-11 Relaying sampling to a host checks declared sub-capabilities
- What. A
tools/toolChoicerequest must not reach a host that did not declaresampling.tools; non-noneincludeContextmust not be relayed unless the host declaredsampling.context. The 2025-06-18 schema has nosampling.toolssub-capability; a conforming client from that revision cannot declare it. - Where. The sub-capabilities are 2025-11-25 and later.
- mcpx @ 2a93f3f64. A tool-bearing question is sendable only when the host declared
sampling.tools(internal/mcpserver/conn.go:561-581);paramsFordropsincludeContextunless the host declaredsampling.context(internal/mcpserver/conn.go:652-704). The host-facing gate is declaration-based, not a second explicit version predicate; a conforming pre-2025-11-25 peer cannot declare these sub-keys. In the reverse direction, sampling payload/result validation uses the negotiated revision (internal/daemon/hooks.go:295-303,362-368). - Value to mcpx. − done: no sampling fields beyond the host’s declared sub-capabilities are relayed.
- Effort. S.
- Risk. low: tools questions that are not sendable remain on the broker path; unsupported context is omitted.
- Detail. It needs a non-conforming upstream to send tools to mcpx because its upstream declaration is bare
sampling: {}; mcpx refuses those requests. The host-side sub-capability checks protect the other end of the relay. - Sources.
internal/mcpserver/conn.go:313-319,561-581,652-704;internal/mcpclient/modern.go:142-179;internal/daemon/hooks.go:295-303,362-368;schema/2025-11-25/schema.ts:327-336.
Tasks
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: 2025-11-25 core tasks versus the 2026 io.modelcontextprotocol/tasks extension, difference by difference, and what mcpx implements
A task turns a long request into a handle a client can poll, cancel and collect later. The 2025-11-25 core and 2026 extension have different wire formats. Task observations below reflect phases 1–2 of #1554; other observations retain their original snapshot. Limits, cancellation, audit, ID entropy and upstream input relay remain open questions on #1554.
Status column: updated task rows are current; other rows are mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| TASK-01 | Tasks leave the core and become an extension | 2025-11-25 has 2026-07-28 removes has better replacement specs conflict | 25-11 core · 26-07 extension only | Separate legacy and extension paths (internal/mcpserver/tasks.go:maybeTask; internal/mcpserver/server.go:capabilities). | + high | L | high |
| TASK-02 | Client-hosted tasks (sampling, elicitation) removed; tools/call only | 2025-11-25 has 2026-07-28 removes | 25-11 server + client · 26-07 server only; opencode, lootbox declare none | ✓ — tools/call only; no client tasks declared | − moot | S | low |
| TASK-03 | Opt-in moves from params.task to the server’s discretion | 2026-07-28 removes has better replacement | 25-11 client asks · 26-07 server decides | Legacy opt-in; modern server decides (internal/mcpserver/tasks.go:maybeTask). | + med | M | med |
| TASK-04 | tasks.list declared without requestor binding | 2025-11-25 has mcpx missing | 25-11 only | Lists connection-owned tasks only; unowned tasks remain readable by ID (internal/mcpserver/tasks.go:visible, handleTask; #1554). | + med | S | med |
| TASK-05 | CreateTaskResult: {task} → flat Task with resultType: "task" | 2026-07-28 has | 25-11 nested · 26-07 flat | Legacy {task}; modern flat resultType: "task" (internal/mcpserver/tasks.go:maybeTask, created; internal/mcpclient/tasks.go:finishModernTask). | + high | S | high |
| TASK-06 | Task.ttl → ttlMs | 2026-07-28 has | 25-11 ttl · 26-07 ttlMs | Legacy ttl, modern ttlMs (internal/tasks/tasks.go:Task; internal/mcpserver/tasks.go:modernTask; internal/mcpclient/tasks.go:modernTaskResult.UnmarshalJSON). | + low | S | low |
| TASK-07 | Task.pollInterval → pollIntervalMs | 2026-07-28 has | 25-11 pollInterval · 26-07 pollIntervalMs | Legacy pollInterval, modern pollIntervalMs (internal/tasks/tasks.go:Task; internal/mcpserver/tasks.go:modernTask; internal/mcpclient/tasks.go:finishModernTask). | + low | S | low |
| TASK-08 | tasks/get returns a DetailedTask with inline result or error | 2026-07-28 has specs conflict | 25-11 status only · 26-07 detailed | Modern get inlines terminal result/error or pending input (internal/mcpserver/tasks.go:detailedNow, handleModernTask; internal/mcpclient/tasks.go:finishModernTask). | + med | M | med |
| TASK-09 | Blocking tasks/result removed | 2026-07-28 removes has better replacement | 25-11 only; extension: -32601 | Modern method-not-found; legacy result remains (internal/mcpserver/tasks.go:handleModernTask, handleTask). | + low | S | low |
| TASK-10 | tasks/list removed | 2026-07-28 removes has no replacement | 25-11 only | Modern method-not-found; legacy list scoped to connection (internal/mcpserver/tasks.go:handleModernTask, handleTask). | + low | S | med |
| TASK-11 | tasks/update added for mid-task input | 2026-07-28 has | 26-07 extension only | Modern update answers outstanding asked-task questions, ignoring unknown keys (internal/mcpserver/tasks.go:updateAsked, handleModernTask). | + med | M | med |
| TASK-12 | tasks/cancel result: Task → empty acknowledgement | 2026-07-28 has mcpx missing | 25-11 Task, MUST cancel · 26-07 empty ack, cooperative | Modern empty acknowledgement, legacy snapshot; modern cancellation still forces terminal state (internal/mcpserver/tasks.go:handleTask, handleModernTask; internal/tasks/tasks.go:CancelWithReason; #1554). | + low | S | low |
| TASK-13 | tasks/cancel on a finished task | 2025-11-25 has | 25-11 MUST -32602 · 26-07 empty ack | Legacy terminal cancel rejected; modern acknowledged (internal/mcpserver/tasks.go:handleTask, handleModernTask). | + low | S | low |
| TASK-14 | Status push: notifications/tasks/status → notifications/tasks on listen | 2025-11-25 has 2026-07-28 has | 25-11 unsolicited · 26-07 listen filter taskIds | Modern listen supports task notifications (internal/tasks/tasks.go:Watch; internal/mcpserver/listen_tasks_test.go:TestListenDeliversTaskStatusNotifications). | + low | M | low |
| TASK-15 | io.modelcontextprotocol/related-task _meta | 2025-11-25 has 2026-07-28 removes mcpx missing | 25-11 MUST · 26-07 — | Legacy result and task-related input requests carry related-task metadata (internal/mcpserver/tasks.go:handleTask; internal/conformance/stateful_test.go:TestStatefulServer). | + low | S | low |
| TASK-16 | io.modelcontextprotocol/model-immediate-response hint | 2025-11-25 has 2026-07-28 removes has no replacement | 25-11 only | n/a — never sent | − moot | S | low |
| TASK-17 | failed versus isError semantics inverted | 2026-07-28 has specs conflict | 25-11 isError → failed · 26-07 isError → completed | Legacy store marks isError failed; modern detailed result reports completed (internal/tasks/tasks.go:finish; internal/mcpserver/tasks.go:detailedNow). | + low | S | low |
| TASK-18 | Mid-task input: tasks/result side channel → inputRequests + tasks/update | 2026-07-28 has mcpx missing | 25-11 side channel · 26-07 polled | Server relays asked-task input via get/update; upstream input_required yields a named error instead (internal/mcpserver/tasks.go:driveAsked, updateAsked; internal/mcpclient/tasks.go:TaskInputRequiredError; #1554). | + med | M | med |
| TASK-19 | Progress on tasks: supported → not supported | 2025-11-25 has 2026-07-28 removes | 25-11 token lives with task · 26-07 none | n/a — mcpx sends no progress | − moot | S | low |
| TASK-20 | Durable creation before CreateTaskResult | 2026-07-28 has | 26-07 extension only | Injected SQLite store saves before returning and can reopen; database write errors are ignored (internal/tasks/tasks.go:StartWithDeadline, saveTaskLocked, Open; internal/mcpserver/tasks.go:tasks). | + low | L | low |
| TASK-21 | HTTP routing: Mcp-Name = taskId on tasks/* | 2026-07-28 has | 26-07 extension only | Client sets task routing headers (internal/mcpclient/headers_tasks_test.go:TestStandardHeadersTaskID); server header validation is a separate transport concern. | + low | S | low |
| TASK-22 | Undeclared task support: SHOULD NOT create → -32021 | 2026-07-28 has | 25-11 SHOULD NOT · 26-07 MUST -32021 | Modern undeclared clients get -32021 for extension methods and required tools (internal/mcpserver/tasks.go:missingTasks, handleTask, maybeTask). | + low | S | low |
| TASK-23 | Task IDs unguessable | 2025-11-25 has 2026-07-28 has | 25-11 MUST when unbound · 26-07 MUST | Open: 64 random bits (internal/tasks/tasks.go:NewID); entropy is an open question on #1554; #2869 is unmerged. | + low | S | med |
| TASK-24 | Tasks bound to the requestor; checked on every request | 2025-11-25 has mcpx missing | 25-11 MUST with auth context · 26-07 MUST | Connection-owned legacy tasks isolated; unowned tasks accessible by ID and ownership is in memory (internal/mcpserver/tasks.go:startTask, visible, handleTask; #1554). | + med | S | high |
| TASK-25 | Reserved names tasks/, notifications/tasks/, resultType: "task" | 2026-07-28 has specs conflict | 26-07 extension only | n/a — nothing reserved clashes | − moot | S | low |
| TASK-26 | MRTR rounds resolved before a CreateTaskResult | 2026-07-28 has | 26-07 + extension | Server answers sendable questions inline before returning a task (internal/mcpserver/tasks.go:askTask, maybeTask). | + low | M | low |
| TASK-27 | One task store across MCP and /v1 | mcpx only | Daemon injects its task store into MCP (internal/daemon/ops.go:TaskStore; internal/mcpserver/tasks.go:tasks; internal/cli/serve.go:MCPServer, internal/cli/root.go). | + med | S | med |
TASK-01 Tasks leave the core and become an extension
- What. The 2025-11-25 core “experimental” tasks (
tasks/*methods,Task*types,taskscapabilities,Tool.execution) are absent from the 2026 schema. The redesigned feature isio.modelcontextprotocol/tasks, negotiated throughcapabilities.extensions. SEP-2663 says it is not wire-compatible with 2025-11-25 and must not be enabled under 2025-11-25. - Where. Core in 2025-11-25; extension only with 2026-07-28.
- mcpx (current). Separate legacy and extension paths (
internal/mcpserver/tasks.go:maybeTask;internal/mcpserver/server.go:capabilities). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + high: a real extension implementation would let long upstream calls survive a 2026 host’s disconnect; declaring what is not delivered is the build brief’s “declared but not delivered” bug class.
- Effort. L — new result shape, update and poll flow, notifications; the rows below are the parts.
- Risk. high: 2026 hosts that trust the advertisement send and expect extension shapes mcpx does not produce.
- Detail. Dual support means parallel code paths, not one shape with renames. SEP-2663 calls the 2026 revision
2026-06-30, presumably a pre-release name. The capability-level leaks (extensionsto 2025-11-25 hosts, coretasksto 2026 hosts) are also registered in the capabilities and lifecycle registers. - Sources.
2026-07-28/changelog.mdx:22“Move experimental tasks out of the core protocol and into an official extension”;seps/2663-tasks-extension.md:944“is not wire-compatible with this extension.”;seps/2663-tasks-extension.md:17;docs/extensions/tasks/overview.mdx:7;internal/mcpserver/server.go:1404-1417;internal/mcpserver/tasks.go:88-126;internal/mcpserver/revisions.go:90.
TASK-02 Client-hosted tasks (sampling, elicitation) removed; tools/call only
- What. 2025-11-25 lets a server task-augment
tools/calland lets a client host tasks for server-sentsampling/createMessageandelicitation/create(clienttasks.requests.*,tasks.list,tasks.cancel). The extension supports onlytools/call; aCreateTaskResultfor any other method MUST be treated as invalid. Client-hosted tasks cease to exist, because polling one would be a server-to-client request. - Where. 2025-11-25 only for the client side. opencode v1 comments
tasksout, v2 does not declare it, and lootbox declares no capabilities. - mcpx @ 05c78b2. Server:
tools/callonly (internal/mcpserver/server.go:477-500). Client: notasksdeclared upstream (internal/mcpclient/modern.go:46-58). - Value to mcpx. − moot: already aligned; implementing client-hosted tasks would be work for one revision.
- Effort. S.
- Risk. None.
- Detail. In 2025-11-25
CreateMessageRequestParamsandElicitRequestFormParamsextendTaskAugmentedRequestParams; in 2026 they extend nothing. The extension SHOULD be designed to accommodate more methods later. - Sources.
2025-11-25/basic/utilities/tasks.mdx:71;schema/2025-11-25/schema.ts:346“tasks?: {”;schema/2025-11-25/schema.ts:1578“export interface CreateMessageRequestParams extends TaskAugmentedRequestParams {”;schema/2026-07-28/schema.ts:2104“export interface CreateMessageRequestParams {”;seps/2663-tasks-extension.md:35“Client-hosted tasks are no longer expressible.”;seps/2663-tasks-extension.md:87;v1:packages/opencode/src/mcp/index.ts:48“// tasks: {},”;v2:packages/core/src/mcp/client.ts:142;.lootbox/src/lib/external-mcps/mcp_client_manager.ts:150-153.
TASK-03 Opt-in moves from params.task to the server’s discretion
- What. 2025-11-25 clients add
params.task: {ttl?}to ask for task execution. The extension removestask; servers MUST ignore it, and the server alone decides per request whether to return a task to a client that declared the extension. - Where.
taskparam in 2025-11-25 only. - mcpx (current). Legacy opt-in; modern server decides (
internal/mcpserver/tasks.go:maybeTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + med: server-chosen tasks would let mcpx hand back a handle for a slow upstream call without the host asking, which is the extension’s whole model.
- Effort. M.
- Risk. med: a 2026 host never sends
task, so mcpx’s task path is unreachable for exactly the hosts it advertises the extension to. - Detail. The 2026 changelog’s “without per-request opt-in” still requires the extension in each request’s
clientCapabilities; the overview says “Never return a task to a client that did not declare support”. The two read as contradictory until “opt-in” is taken to mean the removedtaskparam. - Sources.
schema/2025-11-25/schema.ts:44“task?: TaskMetadata;”;seps/2663-tasks-extension.md:85“The server is the sole decider; clients do not signal task preference on the request itself.”;docs/extensions/tasks/overview.mdx:231-233;internal/mcpserver/tasks.go:40-56.
TASK-04 tasks.list declared without requestor binding
- What. A 2025-11-25 receiver that cannot identify requestors SHOULD NOT declare
tasks.list, because listing exposes every task’s metadata to anyone. - Where. 2025-11-25 only.
- mcpx (current). Lists connection-owned tasks only; unowned tasks remain readable by ID (
internal/mcpserver/tasks.go:visible,handleTask; #1554). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + med: removes cross-host metadata exposure.
- Effort. S — stop declaring it, or bind first (TASK-24).
- Risk. med: on a shared daemon any host sees every host’s task ids and can collect their results.
- Detail. Task ids are 64 random bits (TASK-23), so listing is the easy path to someone else’s result.
- Sources.
2025-11-25/basic/utilities/tasks.mdx:876“SHOULD NOT declare the”;internal/mcpserver/server.go:1412;internal/mcpserver/tasks.go:89-95; wire W23.
TASK-05 CreateTaskResult: {task} → flat Task with resultType: "task"
- What. 2025-11-25
CreateTaskResultis{ task: Task }. The extension’s isResult & TaskwithresultType: "task", a value the extension reserves and advertises. - Where. As stated.
- mcpx (current). Legacy
{task}; modern flatresultType: "task"(internal/mcpserver/tasks.go:maybeTask,created;internal/mcpclient/tasks.go:finishModernTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + high: without it no 2026 host can use an mcpx task.
- Effort. S for the shape, given the rest of TASK-01.
- Risk. high: a 2026 host reads
resultType: "complete"and treats{task: …}as an empty tool result. - Detail.
resultTypevalues beyond core are legal only when advertised through capabilities. As a client, mcpx treats anyresultTypeother thaninput_requiredas complete (internal/mcpclient/client.go:665), so a modern upstream’s"task"would be parsed as a tool result too. - Sources.
schema/2025-11-25/schema.ts:1392“task: Task;”;seps/2663-tasks-extension.md:262“type CreateTaskResult = Result & Task;”;seps/2663-tasks-extension.md:128;docs/extensions/tasks/overview.mdx:54-56;internal/mcpserver/server.go:499-500;2026-07-28/basic/index.mdx:83; wire W22.
TASK-06 Task.ttl → ttlMs
- What. Renamed; still milliseconds,
nullfor unlimited. The extension adds that it MAY change over the task’s lifetime. The request-sideTaskMetadata.ttldisappears with thetaskparam. - Where.
ttlin 2025-11-25;ttlMsin the extension. - mcpx (current). Legacy
ttl, modernttlMs(internal/tasks/tasks.go:Task;internal/mcpserver/tasks.go:modernTask;internal/mcpclient/tasks.go:modernTaskResult.UnmarshalJSON). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: a rename.
- Effort. S.
- Risk. low: extension clients see no TTL.
- Detail. The TTL comes from the client’s
task.ttlorplumbing.taskTTL(10 m). - Sources.
schema/2025-11-25/schema.ts:1378“ttl: number | null;”;seps/2663-tasks-extension.md:168“ttlMs: number | null;”;internal/tasks/tasks.go:36.
TASK-07 Task.pollInterval → pollIntervalMs
- What. Renamed. Servers MAY rate-limit clients that poll faster.
- Where.
pollIntervalin 2025-11-25;pollIntervalMsin the extension. - mcpx (current). Legacy
pollInterval, modernpollIntervalMs(internal/tasks/tasks.go:Task;internal/mcpserver/tasks.go:modernTask;internal/mcpclient/tasks.go:finishModernTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low.
- Effort. S.
- Risk. low: extension clients see no interval.
- Detail. None beyond the rename.
- Sources.
schema/2025-11-25/schema.ts:1383“pollInterval?: number;”;seps/2663-tasks-extension.md:175“pollIntervalMs?: number;”;internal/tasks/tasks.go:37.
TASK-08 tasks/get returns a DetailedTask with inline result or error
- What. 2025-11-25
GetTaskResult = Result & Task, a status only. The extension returns status-discriminated variants:input_requiredcarriesinputRequests,completedcarriesresult,failedcarrieserror;resultTypeMUST be"complete"on it. - Where. As stated.
- mcpx (current). Modern get inlines terminal result/error or pending input (
internal/mcpserver/tasks.go:detailedNow,handleModernTask;internal/mcpclient/tasks.go:finishModernTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + med: one polling call gives everything, which suits hosts that reconnect.
- Effort. M.
- Risk. med: see TASK-01.
- Detail. SEP-2663 contradicts itself: it requires
"complete"here but its own execution-error examples show"resultType": "task". - Sources.
schema/2025-11-25/schema.ts:1415“export type GetTaskResult = Result & Task;”;seps/2663-tasks-extension.md:337“type GetTaskResult = Result & DetailedTask;”;seps/2663-tasks-extension.md:340;seps/2663-tasks-extension.md:844“"resultType": "task",”;internal/mcpserver/tasks.go:97-102.
TASK-09 Blocking tasks/result removed
- What. 2025-11-25
tasks/resultblocks until the task is terminal, returns its payload, and is where mid-task input requests travel. The extension removes it; clients calling it MUST get-32601, and results move intotasks/get. - Where. 2025-11-25 only.
- mcpx (current). Modern method-not-found; legacy result remains (
internal/mcpserver/tasks.go:handleModernTask,handleTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: accept-liberally is harmless as long as it is not advertised to 2026 hosts, which it is (CAP-26).
- Effort. S.
- Risk. low.
- Detail. Blocking until the end is exactly what the redesign moved away from, because a blocking request is lost with its connection.
- Sources.
2025-11-25/basic/utilities/tasks.mdx:465“it MUST block the response until the task reaches a terminal status”;seps/2663-tasks-extension.md:949“is removed; clients calling it”;internal/mcpserver/tasks.go:104-117.
TASK-10 tasks/list removed
- What. Listing cannot be scoped without sessions, so the extension removes it with no replacement: clients must keep the task ids they were given.
- Where. 2025-11-25 only.
- mcpx (current). Modern method-not-found; legacy list scoped to connection (
internal/mcpserver/tasks.go:handleModernTask,handleTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: stop serving it to hosts that declared the extension; for legacy hosts the fix is TASK-24.
- Effort. S.
- Risk. med: SEP-2663’s own argument, “a server cannot inadvertently leak the existence of one caller’s tasks to another”, applies directly to mcpx’s shared store.
- Detail. 2025-11-25 required every task visible to
tasks/getfor a requestor to be listable for that requestor. - Sources.
2025-11-25/basic/utilities/tasks.mdx:490“it MUST be retrievable via”;seps/2663-tasks-extension.md:957“a server cannot inadvertently leak the existence of one caller’s tasks to another.”;internal/mcpserver/tasks.go:89-95; wire W23.
TASK-11 tasks/update added for mid-task input
- What.
tasks/update {taskId, inputResponses}answers a task’s outstandinginputRequests. The reply is an empty acknowledgement and eventually consistent; unknown or already-answered keys SHOULD be ignored; partial sets are allowed. - Where. Extension only.
- mcpx (current). Modern update answers outstanding asked-task questions, ignoring unknown keys (
internal/mcpserver/tasks.go:updateAsked,handleModernTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + med: input for long tasks without holding a request open; it maps directly onto the broker and
Ask.Replymachinery MRTR already uses. - Effort. M.
- Risk. med: extension hosts cannot answer mid-task questions.
- Detail. Input-request keys MUST be unique over the task’s whole lifetime, stronger than MRTR’s per-request uniqueness.
- Sources.
seps/2663-tasks-extension.md:358“tasks/update”;seps/2663-tasks-extension.md:352“Each request key in”;docs/extensions/tasks/overview.mdx:36-39;internal/mcpserver/tasks.go:126“return fail(codeMethodNotFound, "no method "+req.Method)”.
TASK-12 tasks/cancel result: Task → empty acknowledgement
- What. 2025-11-25 returns the
Task, MUST move it tocancelledbefore responding, andcancelledis sticky. The extension returns an empty acknowledgement; cancellation is cooperative and the task may still end in another terminal state. - Where. As stated.
- mcpx (current). Modern empty acknowledgement, legacy snapshot; modern cancellation still forces terminal state (
internal/mcpserver/tasks.go:handleTask,handleModernTask;internal/tasks/tasks.go:CancelWithReason; #1554). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: an acknowledgement is simpler for an asynchronous daemon.
- Effort. S.
- Risk. low.
- Detail. In both designs
notifications/cancelledMUST NOT be used to cancel a task. - Sources.
2025-11-25/basic/utilities/tasks.mdx:495“MUST transition the task to”;seps/2663-tasks-extension.md:403“type CancelTaskResult = Result; // empty acknowledgement”;seps/2663-tasks-extension.md:408“Cancellation is cooperative”;internal/mcpserver/tasks.go:119-124.
TASK-13 tasks/cancel on a finished task
- What. 2025-11-25: cancelling a task already
completed,failedorcancelledMUST be rejected with-32602. The extension acknowledges with an empty result. - Where. 2025-11-25 MUST; extension changes it.
- mcpx (current). Legacy terminal cancel rejected; modern acknowledged (
internal/mcpserver/tasks.go:handleTask,handleModernTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low.
- Effort. S.
- Risk. low.
- Detail. Neither revision’s answer is what mcpx sends: 2025-11-25 wants an error, the extension an empty object.
- Sources.
2025-11-25/basic/utilities/tasks.mdx:494“MUST reject cancellation requests for tasks already in a terminal status”;internal/mcpserver/tasks.go:124“return reply(snap)”.
TASK-14 Status push: notifications/tasks/status → notifications/tasks on listen
- What. 2025-11-25 receivers MAY push
notifications/tasks/statusunsolicited. The extension renames itnotifications/tasks, carries the fullDetailedTask, and delivers it only on asubscriptions/listenstream whose filter liststaskIds(a field the extension adds to the core filter). - Where. As stated.
- mcpx (current). Modern listen supports task notifications (
internal/tasks/tasks.go:Watch;internal/mcpserver/listen_tasks_test.go:TestListenDeliversTaskStatusNotifications). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: saves hosts polling.
- Effort. M — needs the listen stream to accept
taskIds(notifications.md). - Risk. low: optional in both designs.
- Detail. The overview page does not mention
taskIds; only SEP-2663 does. - Sources.
schema/2025-11-25/schema.ts:1496“notifications/tasks/status”;seps/2663-tasks-extension.md:422“notifications/tasks”;seps/2663-tasks-extension.md:435“taskIds?: string[];”;docs/protocol.md:297-299“mcpx polls task state rather than tracking it”;internal/mcpclient/client.go:478.
TASK-15 io.modelcontextprotocol/related-task _meta
- What. 2025-11-25 requires every task-related request, notification and response (including the
tasks/resultresponse) to carry_meta["io.modelcontextprotocol/related-task"] = {taskId}. Neither the 2026 core nor SEP-2663 defines it. - Where. 2025-11-25 only.
- mcpx (current). Legacy result and task-related input requests carry related-task metadata (
internal/mcpserver/tasks.go:handleTask;internal/conformance/stateful_test.go:TestStatefulServer). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: lets a 2025-11-25 host tie a mid-task question to its task. The upstream direction would give mcpx exact attribution of questions on shared instances (elicitation.md, ELI-19).
- Effort. S.
- Risk. low.
- Detail. For
tasks/get,tasks/resultandtasks/cancel, receivers MUST prefer thetaskIdparam over this key. With no server-to-client requests in 2026 there is nothing left to associate. - Also recorded from the meta register. No
related-taskanywhere ininternal/;tasks/resultresults and questions raised while a task runs carry none (internal/mcpserver/tasks.go:104-117). The tasks extension replaces it withinputRequestsontasks/get; see the tasks register. - Sources.
2025-11-25/basic/utilities/tasks.mdx:470“All requests, notifications, and responses related to a task”;schema/2025-11-25/schema.ts:1332“export interface RelatedTaskMetadata {”;schema/2025-11-25/schema.ts:1328;internal/mcpserver/tasks.go:104-117.
TASK-16 io.modelcontextprotocol/model-immediate-response hint
- What. A non-binding, “provisional”
_metastring on a 2025-11-25CreateTaskResultthat a host may show the model as an interim tool result. The extension has no such key. - Where. 2025-11-25 only.
- mcpx @ 05c78b2. Never sent (no occurrence in
internal/). - Value to mcpx. − moot: mcpx could synthesise its own interim text if it ever needed one.
- Effort. S.
- Risk. None.
- Detail. None.
- Sources.
2025-11-25/basic/utilities/tasks.mdx:175“servers can provide an optional”.
TASK-17 failed versus isError semantics inverted
- What. 2025-11-25: a tool result with
isError: trueputs the task infailed. Extension:failedMUST NOT be used forisError; that iscompletedwith the result, andfailedis only for JSON-RPC errors, carried inerror. - Where. As stated.
- mcpx (current). Legacy store marks isError failed; modern detailed result reports completed (
internal/tasks/tasks.go:finish;internal/mcpserver/tasks.go:detailedNow). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: the extension’s rule is the cleaner one for a proxy.
- Effort. S.
- Risk. low: clients switching on status misreport tool errors between the two designs.
- Detail. A dual-era server has to pick per revision.
- Sources.
schema/2025-11-25/schema.ts:1310“For tool calls specifically, this includes cases where the tool call result has”;seps/2663-tasks-extension.md:835;internal/mcpserver/tasks.go:62-64.
TASK-18 Mid-task input: tasks/result side channel → inputRequests + tasks/update
- What. 2025-11-25: on
input_requiredthe requestor SHOULD calltasks/resultso the receiver can sendelicitation/createor sampling on that stream. Extension: pending requests appear intasks/getasinputRequestsand are answered withtasks/update. - Where. As stated.
- mcpx (current). Server relays asked-task input via get/update; upstream input_required yields a named error instead (
internal/mcpserver/tasks.go:driveAsked,updateAsked;internal/mcpclient/tasks.go:TaskInputRequiredError; #1554). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + med: fits mcpx’s broker model, where questions are already data.
- Effort. M.
- Risk. med: a task whose upstream asks something sits at
input_requiredwith no way to answer through tasks. - Detail. The statuses themselves (
working,input_required,completed,failed,cancelled) are the same five in both designs. The task-phaseinputRequestskey space is separate from any MRTR phase before it (TASK-26). - Sources.
2025-11-25/basic/utilities/tasks.mdx:449“When the requestor encounters the”;seps/2663-tasks-extension.md:184“will include outstanding requests in the”;internal/mcpserver/tasks.go:69-73.
TASK-19 Progress on tasks: supported → not supported
- What. 2025-11-25: the original
progressTokenstays valid for the task’s life, and notifications stop when it is terminal. Extension:notifications/progressandnotifications/messageare not supported on tasks and MUST NOT go on the listen stream for a task;statusMessageis the only progress channel left. - Where. As stated.
- mcpx @ 05c78b2. Sends no progress at all (progress-cancellation.md).
- Value to mcpx. − moot.
- Effort. S.
- Risk. None.
- Detail.
SetTaskStatustakes a message, sostatusMessageis available to mcpx already. - Sources.
2025-11-25/basic/utilities/tasks.mdx:483“Task-augmented requests support progress notifications”;2025-11-25/basic/utilities/progress.mdx:69;seps/2663-tasks-extension.md:511“are not supported on tasks in general in this specification.”.
TASK-20 Durable creation before CreateTaskResult
- What. New MUST in the extension: a server returns
CreateTaskResultonly oncetasks/getfor that id would succeed. - Where. Extension only.
- mcpx (current). Injected SQLite store saves before returning and can reopen; database write errors are ignored (
internal/tasks/tasks.go:StartWithDeadline,saveTaskLocked,Open;internal/mcpserver/tasks.go:tasks). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: satisfied in-process; restart durability is a separate, larger feature.
- Effort. L for durability across daemon restarts.
- Risk. low: tasks vanish on restart, which servers may do (they may purge), but it surprises hosts that persisted ids.
- Detail. The extension also lets servers fail tasks after their TTL.
- Sources.
seps/2663-tasks-extension.md:302“A server MUST NOT return”;internal/mcpserver/tasks.go:31-38.
TASK-21 HTTP routing: Mcp-Name = taskId on tasks/*
- What. Over Streamable HTTP,
tasks/get,tasks/updateandtasks/cancelMUST setMcp-Nametoparams.taskId, so a load balancer can route to the instance that owns the task. - Where. Extension only.
- mcpx (current). Client sets task routing headers (
internal/mcpclient/headers_tasks_test.go:TestStandardHeadersTaskID); server header validation is a separate transport concern. The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: needed if mcpx becomes a 2026 HTTP client of task-returning upstreams.
- Effort. S.
- Risk. low: misrouted polls against multi-instance upstreams.
- Detail. The headers come from SEP-2243.
- Sources.
seps/2663-tasks-extension.md:515“the client MUST set the”.
TASK-22 Undeclared task support: SHOULD NOT create → -32021
- What. 2025-11-25: if the peer’s
capabilities.tasksis undefined, a requestor SHOULD NOT create tasks. Extension: a server that can serve a request only as a task MUST return-32021withrequiredCapabilities.extensions["io.modelcontextprotocol/tasks"], and MUST do the same fortasks/get,tasks/updateandtasks/cancelfrom clients that did not declare the extension. - Where. As stated.
- mcpx (current). Modern undeclared clients get -32021 for extension methods and required tools (
internal/mcpserver/tasks.go:missingTasks,handleTask,maybeTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: honest failure for 2026 clients.
- Effort. S.
- Risk. low.
- Detail. Invalid or expired
taskId→-32602(MUST fortasks/get, SHOULD for update and cancel); mcpx already answers an unknown id with-32602. Acceptingtasks/*from legacy clients is accept-liberally; the extension’s MUST applies only to 2026 clients. - Sources.
2025-11-25/basic/utilities/tasks.mdx:99“attempt to create tasks during requests.”;seps/2663-tasks-extension.md:89“the server MUST return an error with the code”;seps/2663-tasks-extension.md:799“Servers MUST return this error for non-declaring clients issuing”; wire W22, W23.
TASK-23 Task IDs unguessable
- What. Task ids may act as bearer tokens, so both designs require enough entropy: 2025-11-25 when tasks cannot be bound to an authorization context, the extension always.
- Where. 2025-11-25 and the extension.
- mcpx (current). Open: 64 random bits (
internal/tasks/tasks.go:NewID); entropy is an open question on #1554; #2869 is unmerged. The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: 128 bits would remove the question.
- Effort. S.
- Risk. med: with no binding (TASK-24) the id is the only protection, and
tasks/listhands ids out anyway (TASK-04). - Detail. 2025-11-25 also suggests shorter TTLs when binding is unavailable.
- Sources.
2025-11-25/basic/utilities/tasks.mdx:875“receivers MUST generate cryptographically secure task IDs”;seps/2663-tasks-extension.md:955“Servers MUST generate them with sufficient entropy”;internal/tasks/tasks.go:86-90.
TASK-24 Tasks bound to the requestor; checked on every request
- What. 2025-11-25: when an authorization context exists, receivers MUST bind tasks to it and reject others’ requests;
without one they SHOULD document the limitation. Extension: authentication and authorization MUST be checked on every
task request, and task
inputRequestsget the same trust model as direct ones. - Where. 2025-11-25 and the extension.
- mcpx (current). Connection-owned legacy tasks isolated; unowned tasks accessible by ID and ownership is in memory (
internal/mcpserver/tasks.go:startTask,visible,handleTask; #1554). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + med: isolation between hosts sharing one daemon.
- Effort. S — key tasks by the connection binding now, by principal once
/mcphas one. - Risk. high: result disclosure between hosts.
/mcphas no authentication, so the 2025-11-25 MUST does not strictly apply, but the SHOULD to document it does, and the docs are silent. - Detail. The same binding question as MRTR’s
requestState(mrtr.md, MRTR-08): a session id is what mcpx has, and 2026 removed it. - Sources.
2025-11-25/basic/utilities/tasks.mdx:871-876;seps/2663-tasks-extension.md:956“Servers MUST perform authentication and authorization checks on each task-related request”;internal/mcpserver/tasks.go:34-36; wire W23.
TASK-25 Reserved names tasks/, notifications/tasks/, resultType: "task"
- What. The extension reserves the
tasks/method prefix, thenotifications/tasks/notification prefix, and the"task"result discriminator. - Where. Extension only.
- mcpx @ 05c78b2. Nothing of its own uses these names.
- Value to mcpx. − moot.
- Effort. S.
- Risk. None.
- Detail. The extension’s own notification is
notifications/tasks, with no trailing slash, so it falls outside thenotifications/tasks/prefix it reserves. - Sources.
seps/2663-tasks-extension.md:894“prefix are reserved for this extension.”;seps/2663-tasks-extension.md:422.
TASK-26 MRTR rounds resolved before a CreateTaskResult
- What. A
tools/callmay go through one or moreinput_requiredrounds and then returnresultType: "task"; servers SHOULD resolve all MRTR exchanges synchronously first. The two phases have separate key spaces. - Where. 2026-07-28 with the extension.
- mcpx (current). Server answers sendable questions inline before returning a task (
internal/mcpserver/tasks.go:askTask,maybeTask). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + low: design guidance for when mcpx implements the extension.
- Effort. M.
- Risk. low.
- Detail. SHOULD, not MUST.
- Sources.
seps/2663-tasks-extension.md:304“resolve all MRTR exchanges synchronously before responding with a”.
TASK-27 One task store across MCP and /v1
- What.
internal/taskssays one store is shared so that a task started on one surface is visible from the other. In practice the daemon now injects its store into the MCP server. - Where. mcpx internal parity (config = /v1 = MCP).
- mcpx (current). Daemon injects its task store into MCP (
internal/daemon/ops.go:TaskStore;internal/mcpserver/tasks.go:tasks;internal/cli/serve.go:MCPServer,internal/cli/root.go). The assessment and source references below preserve the original 05c78b2 comparison; implementation-specific claims there are historical, not current. - Value to mcpx. + med: one view of background work.
- Effort. S — the daemon hands its store to the lazily built MCP server.
- Risk. med: a doc/code disagreement, and users look in the wrong place.
- Detail. None beyond the above.
- Sources.
internal/tasks/tasks.go:10-12“Two stores would be two”;internal/mcpserver/tasks.go:35“s.taskStore = tasks.New()”;internal/daemon/ops.go:428-429.
Tools
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: Tool definition fields, JSON Schema rules, tools/list and CallToolResult per revision, and how mcpx and opencode handle each
Tools are the one MCP feature every host uses, and mcpx sits on both sides: it consumes upstream tool definitions to
catalogue, type and call them, and serves its own tool surface to hosts. What matters most at 05c78b2 is that
mcpx_call, the core proxy tool, drops the upstream isError and flattens every result to text (wire W20, W21), and that
mcpx’s client drops icons, execution and _meta from upstream definitions while its own tools/list is 61 tools, not
the promised ten. Content block types are in the content-types register, the invalid-arguments conflict (-32602 or
isError; 2026 prose and schema disagree) in the errors register, ttlMs/cacheScope and per-connection stability in
the pagination-caching register, and Mcp-Param-* header encoding in the transports register.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| TOOL-01 | Tool.name format: 1–128 chars, [A-Za-z0-9_.-], case-sensitive | 2025-11-25 has | 24-11 ✓ (unique id only) · 25-03 same · 25-06 same · 25-11 ✓ (rules) · 26-07 ✓ | ✓ — own names comply | + low | S | low |
| TOOL-02 | aggregators SHOULD prefix a server id; serverInfo.name unreliable | 2026-07-28 has | 26-07 ✓ only | ✓ — namespaces by config key, not serverInfo.name | + med | S | low |
| TOOL-03 | host-side names server_tool; opencode v2 caps at 64, no dot | opencode v1 has opencode v2 has | opencode v1 ✓ · v2 ✓ (≤64) | partial — served as mcpx, names double to mcpx_mcpx_* | + low | S | low |
| TOOL-04 | Tool.title and display precedence title → annotations.title → name | 2025-06-18 has | 24-11 — · 25-03 — · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | partial — upstream title kept; own tools have none | + low | S | low |
| TOOL-05 | ToolAnnotations.title | 2025-03-26 has | 24-11 — · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | partial — op tools only; the ten core tools lack it | + low | S | low |
| TOOL-06 | Tool.description optional; “hint to the model” wording from 2025-03-26 | 2024-11-05 has | all five ✓ | ✓ — always sent | + low | S | low |
| TOOL-07 | inputSchema opens to any 2020-12 keyword; root type:"object" stays | 2024-11-05 has 2026-07-28 has | 24-11..25-11 ✓ (restricted) · 26-07 ✓ (any keyword) | ✓ — upstream schemas passed through raw | + med | M | med |
| TOOL-08 | no-parameter tools: {type:"object", additionalProperties:false} recommended | 2025-11-25 has | 25-11 ✓ · 26-07 ✓ | ✓ — used by every no-arg mcpx tool | + low | S | low |
| TOOL-09 | x-mcp-header on inputSchema properties; HTTP clients drop invalid tools | 2026-07-28 has mcpx missing | 26-07 ✓ only | ✗ — neither validated nor mirrored (#200) | + med | M | med |
| TOOL-10 | JSON Schema 2020-12 is the default dialect | 2025-11-25 has | 25-11 ✓ · 26-07 ✓ · earlier unspecified | n/a — no validator; codegen reads 2020-12 and draft-07 keywords | + low | S | low |
| TOOL-11 | $ref MUST NOT dereference network URIs by default | 2026-07-28 has | 26-07 ✓ only | ✓ — codegen resolves only local #/$defs/, #/definitions/ | + med | S | high |
| TOOL-12 | bound schema depth / subschemas / validation time | 2026-07-28 has | 26-07 ✓ only (SHOULD) | partial — codegen depth 12 and cycle guard; no validator | + low | S | low |
| TOOL-13 | Tool.outputSchema; object-rooted until 2026 | 2025-06-18 has 2026-07-28 has opencode v1 has opencode v2 has | 25-06 ✓ (object) · 25-11 ✓ (object) · 26-07 ✓ (any) · opencode v1 ✓ v2 ✓ (types) | partial — parsed, unused by codegen; own tools declare none | + med | S | med |
| TOOL-14 | outputSchema ⇒ conforming structuredContent MUST; clients SHOULD validate | 2025-06-18 has opencode v1 has | 25-06 ✓ · 25-11 ✓ · 26-07 ✓ · opencode v1 enforces · v2 not traced | n/a — validates nothing; declares no outputSchema | + low | S | med |
| TOOL-15 | Tool.annotations object; untrusted unless the server is trusted | 2025-03-26 has | 24-11 — · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | partial — upstream kept raw; core ten tools carry none | + med | S | med |
| TOOL-16 | readOnlyHint (default false) | 2025-03-26 has | 25-03 ✓ onward | ✓ — op tools set it from Mutating | + med | S | low |
| TOOL-17 | destructiveHint (default true, only when not read-only) | 2025-03-26 has mcpx missing | 25-03 ✓ onward | ✗ — confirm gate reads absent as non-destructive (#207) | + med | S | med |
| TOOL-18 | idempotentHint (default false) | 2025-03-26 has | 25-03 ✓ onward | ✓ — op tools set it; not used for retries | + med | S | med |
| TOOL-19 | openWorldHint (default true) | 2025-03-26 has | 25-03 ✓ onward | partial — true on every op tool, including local ones | + low | S | low |
| TOOL-20 | Tool.icons | 2025-11-25 has mcpx missing | 25-11 ✓ · 26-07 ✓ | ✗ — dropped on parse (#207) | + low | S | low |
| TOOL-21 | Tool.execution.taskSupport; required needs a task call | 2025-11-25 has 2026-07-28 removes mcpx missing | 25-11 ✓ only · opencode v1 SDK throws on required | ✗ — not parsed; required upstream tools uncallable (#209) | + med | M | med |
| TOOL-22 | _meta on Tool | 2025-06-18 has specs conflict mcpx missing | 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (schema; prose omits) | ✗ — dropped on parse (#207) | + med | S | med |
| TOOL-23 | tools/list in deterministic order | 2026-07-28 has | 26-07 ✓ only (SHOULD) | ✓ — fixed slice; upstream cache sorted by name | + med | S | low |
| TOOL-24 | mcpx’s tools/list is 61 tools, not the promised ten | does not match mcpx's goal | mcpx only | partial — ten core plus 51 generated op tools | + high | S | med |
| TOOL-25 | tools/list and other lists are not revision-filtered | 2025-06-18 has 2025-11-25 has | mcpx design | partial — safe today only because own tools carry few fields | + low | S | med |
| TOOL-26 | CallToolResult.isError; mcpx_call drops it | 2024-11-05 has mcpx missing opencode v1 has opencode v2 has | all five ✓ · opencode v1 ✓ v2 ✓ (thrown) | ✗ — upstream failures reported as success (wire W20) (#206) | + high | S | high |
| TOOL-27 | CallToolResult.structuredContent; any JSON value in 2026 | 2025-06-18 has 2026-07-28 has opencode v1 has opencode v2 has | 25-06 ✓ (object) · 25-11 ✓ (object) · 26-07 ✓ (any) | partial — downgraded below 2025-06-18; non-objects not downgraded | + med | S | med |
| TOOL-28 | text duplicate of structuredContent; which one each client reads | 2025-06-18 has opencode v1 has opencode v2 has | 25-06..26-07 SHOULD · opencode v1 text first · v2 structured first | partial — mcpx_call sends text only | + med | S | med |
| TOOL-29 | mcpx_exec returns artifacts as resource_link blocks | 2025-06-18 has mcpx has, others don't | 25-06 ✓ onward · opencode v1 drops them | ✓ — one link per artifact | + med | S | med |
| TOOL-30 | tools/call may answer InputRequiredResult; task param gone | 2026-07-28 has | 26-07 ✓ only | ✓ — both directions (gaps in the MRTR register) | + high | S | med |
| TOOL-31 | per-call permission prompts for MCP tools in the host | opencode v1 has opencode v2 has | opencode v1 ✓ v2 ✓ | n/a — host-side | + low | S | low |
TOOL-01 Tool.name format
- What. 2025-11-25 (SEP-986) adds SHOULD-level naming rules: 1–128 characters, case-sensitive, only ASCII letters,
digits,
_,-and., no spaces or commas, unique within a server. Earlier revisions say only “Unique identifier”. - Where. Rules in 2025-11-25 and 2026; all revisions require a unique name.
- mcpx @ 05c78b2. Every name mcpx serves is
mcpx_plus lower-case words and underscores (wire W6), well inside the rules. Upstream names are not re-exposed as MCP tools; they are reached throughmcpx_call {namespace, tool}and through generated TypeScript, whose identifier rules are a code-mode concern. - Value to mcpx. + low: nothing to change for its own names.
- Effort. S.
- Risk. Low. Accept liberally: these are SHOULDs, so upstream names outside them must still be callable.
- Detail.
.is allowed by the spec but not by opencode v2’s registry (TOOL-03), so a spec-conformant name likeadmin.tools.listis renamed by that host. - Sources.
2024-11-05/server/tools.mdx:183“-name: Unique identifier for the tool”;2025-11-25/server/tools.mdx:219“Tool names SHOULD be between 1 and 128 characters in length (inclusive).”;2025-11-25/server/tools.mdx:221“The following SHOULD be the only allowed characters”;internal/mcpserver/server.go:279
TOOL-02 aggregators SHOULD disambiguate colliding names
- What. 2026 adds a note: name uniqueness is per server; clients or proxies aggregating several servers SHOULD
disambiguate (for example by prefixing a server identifier) and SHOULD NOT rely on
serverInfo.name, which is not guaranteed unique. - Where. 2026 only. It is the only place the specification speaks directly to proxies.
- mcpx @ 05c78b2. Namespaces come from the configuration key, sanitised, or an explicit
namespace(internal/config/config.go:480), never fromserverInfo.name;mcpx_calltakesnamespaceandtoolas separate arguments (wire W6), and prompts are re-exposed as<ns>_<name>(wire W19). - Value to mcpx. + endorses the design mcpx already has.
- Effort. S.
- Risk. Using
serverInfo.namewould collide; mcpx does not. - Detail. Sanitised namespaces can themselves collide (
fff-nixandfff_nix); that and reserved-word identifiers are in the code-mode register. - Sources.
2026-07-28/server/tools.mdx:324“Tool name uniqueness is scoped to a single server. Clients or proxies that”;2026-07-28/server/tools.mdx:330“servers and SHOULD NOT be relied upon for disambiguation.”;internal/config/config.go:480“Namespace: pick(ex.Namespace, SanitizeNamespace(name)),”
TOOL-03 host-side tool names in opencode
- What. opencode exposes each MCP tool as
sanitize(server)_sanitize(tool), replacing anything outside[a-zA-Z0-9_-]with_. v2 also registers the tool under namespaceserver(code-mode pathtools.<server>.<tool>) and rejects a normalised tool name that does not match^[A-Za-z0-9_-]{1,64}$. - Where. opencode v1 and v2. The spec allows 128 characters and
.; opencode v2 allows 64 and no.. - mcpx @ 05c78b2. mcpx’s own tools are already prefixed
mcpx_(internal/mcpserver/server.go:279), so an opencode server entry namedmcpxyieldsmcpx_mcpx_namespaces. The longest mcpx tool name is far below 64 characters. - Value to mcpx. + low: the doubled prefix costs tokens on every opencode v1 request outside code mode.
- Effort. S — document a short server key for opencode, or drop the
mcpx_prefix when served over MCP. - Risk. Low. v2 logs and skips an invalid registration rather than failing the server.
- Detail. Permission keys use the same
server_toolform (TOOL-31), so users can allowmcpx_*wholesale. - Sources.
v1:packages/opencode/src/mcp/catalog.ts:119“export const toolName = (clientName: string, name: string) => sanitize(clientName) + “;v2:packages/core/src/tool/mcp.ts:17;v2:packages/core/src/tool/mcp.ts:45;v2:packages/core/src/tool.ts:297;internal/mcpserver/server.go:279
TOOL-04 Tool.title and display precedence
- What. 2025-06-18 gives
Toola top-leveltitle(viaBaseMetadata) and fixes display precedence:title, thenannotations.title, thenname. - Where. 2025-06-18 onward.
- mcpx @ 05c78b2. The client keeps an upstream
title(internal/mcpclient/client.go:119). mcpx’s ownToolstruct has notitle(internal/mcpserver/server.go:260-268), so hosts fall back toannotations.title(op tools) or the name (the ten core tools). - Value to mcpx. + low: human-facing listings.
- Effort. S — but adding it needs revision filtering (TOOL-25).
- Risk. Sending
titleto a 2025-03-26 host would be a send-conservatively conflict, as P-2 is for prompts. - Detail. The
BaseMetadata.titledoc singles outToolas the one type whereannotations.titleoutranksname. - Sources.
schema/2025-06-18/schema.ts:930;schema/2025-11-25/schema.ts:1289“Display name precedence order is: title, annotations.title, then name.”;internal/mcpclient/client.go:119;internal/mcpserver/server.go:260-268
TOOL-05 ToolAnnotations.title
- What. 2025-03-26’s annotations object carries its own
title, the only display name beforeTool.titleexisted. - Where. 2025-03-26 onward.
- mcpx @ 05c78b2. Every generated op tool carries
annotations.title= the operation summary (internal/api/ops.go:661); the ten core tools (mcpx_call,mcpx_exec, …) carry no annotations at all (wire W6). - Value to mcpx. + low: the tools a host most needs to label are the ten that lack one.
- Effort. S.
- Risk. Low.
- Detail. Sending
annotationsto 2025-03-26 hosts is correct; it is the one tool field mcpx sends that is newer than 2024-11-05, which mcpx does not serve. - Sources.
schema/2025-03-26/schema.ts:741“title?: string;”;internal/api/ops.go:661“"title": o.Summary,”
TOOL-06 Tool.description
- What. Optional human-readable description. 2025-03-26 adds that clients can use it to improve the model’s understanding, “like a hint to the model”.
- Where. All five; the field itself is unchanged.
- mcpx @ 05c78b2. Always sent for mcpx’s own tools (
internal/mcpserver/server.go:261, noomitempty); upstream descriptions are kept and rendered as JSDoc in generated types (code-mode register). - Value to mcpx. + low; the descriptions are what a host’s model pays for per tool (TOOL-24).
- Effort. S.
- Risk. None.
- Detail. None beyond the wording.
- Sources.
schema/2024-11-05/schema.ts:695“description?: string;”;schema/2025-03-26/schema.ts:793“It can be thought of like a”;internal/mcpserver/server.go:261
TOOL-07 inputSchema shape
- What. 2024-11-05 through 2025-11-25 type
inputSchemaas{type:"object", properties?, required?}(plus$schemafrom 2025-11-25). 2026 allows any JSON Schema 2020-12 keyword besidetype(oneOf,$ref,$defs,if/then/else, …); the root must still betype: "object". - Where. Restricted through 2025-11-25; open in 2026.
- mcpx @ 05c78b2. Upstream schemas are kept as raw JSON (
internal/mcpclient/client.go:121); codegen understandsanyOf/oneOf/allOf,prefixItems, local$refand$defs/definitions(internal/codegen/ts.go:52-58). - Value to mcpx. + raw pass-through is already right; − typed codegen must keep up with composition keywords.
- Effort. M — codegen coverage, not the wire.
- Risk. Codegen that assumes flat
propertiesproduces wrong argument types. - Detail. The older TypeScript type was narrower than what servers sent in practice; 2026 aligns the schema with
reality (SEP-2106). opencode v1’s SDK rejects a whole
tools/listif anyinputSchema.typeis not the literal"object", which every revision requires anyway. - Sources.
schema/2024-11-05/schema.ts:699“inputSchema: {”;schema/2026-07-28/schema.ts:1997“inputSchema: { $schema?: string; type: “;2026-07-28/changelog.mdx:49“LooseninputSchemaandoutputSchemato allow any JSON Schema 2020-12 keywords”;internal/mcpclient/client.go:121; https://unpkg.com/@modelcontextprotocol/sdk@1.29.0/dist/esm/types.js L1242
TOOL-08 no-parameter tools
- What.
inputSchemaMUST be a valid JSON Schema object, nevernull; for tools with no parameters the recommended form is{type:"object", additionalProperties:false};{type:"object"}is also valid. - Where. 2025-11-25 and 2026.
- mcpx @ 05c78b2. Every no-argument mcpx tool uses exactly the recommended form
(
internal/mcpserver/server.go:283; wire W6). - Value to mcpx. + already conforms.
- Effort. None.
- Risk. Upstreams sending
inputSchema: nullshould still be tolerated when read (accept liberally). - Detail. Guidance only.
- Sources.
2025-11-25/server/tools.mdx:201“MUST be a valid JSON Schema object (notnull)”;2025-11-25/server/tools.mdx:203;internal/mcpserver/server.go:283
TOOL-09 x-mcp-header parameter annotation
- What. A property in
inputSchemamay carryx-mcp-header: "<Name>"; a Streamable-HTTP client MUST mirror that argument into anMcp-Param-<Name>header so intermediaries can route without parsing the body. Clients on HTTP MUST reject (drop from theirtools/listview) any tool whose annotation breaks the constraints, and SHOULD log a warning. - Where. 2026 only. stdio clients MAY ignore it.
- mcpx @ 05c78b2. Neither
x-mcp-headernorMcp-Paramappears ininternal/: annotated tools are neither validated nor mirrored when mcpx calls an HTTP upstream, and invalid ones are not filtered from mcpx’s catalogue. - Value to mcpx. + mcpx is an HTTP client of remote upstreams, where this is mandatory.
- Effort. M — validation plus header encoding (transports register).
- Risk. If not done: 2026 HTTP servers behind header-routing intermediaries reject or misroute mcpx’s calls, and mcpx exposes tool definitions it was required to drop.
- Detail. Constraints on the value: non-empty; HTTP token syntax; no CR/LF; unique case-insensitively within the
schema; only on
integer,stringorbooleanproperties (notnumber), integers within ±(2^53−1); only on properties statically reachable from the root. Servers SHOULD NOT mark secrets. Anull/absent argument omits the header. - Also recorded from the transports register. Not implemented; such tools would be listed in mcpx’s catalog and
callable through
mcpx_call. - Sources.
2026-07-28/server/tools.mdx:362“Clients using the Streamable HTTP transport MUST reject tool definitions where any”;2026-07-28/server/tools.mdx:346-359;2026-07-28/basic/transports/streamable-http.mdx:364“While the use ofx-mcp-headeris optional for servers, clients MUST”;2026-07-28/basic/transports/streamable-http.mdx:408;2026-07-28/basic/transports/streamable-http.mdx:402“Clients using the Streamable HTTP transport MUST reject tool definitions”
TOOL-10 JSON Schema 2020-12 as the default dialect
- What. Schemas without
$schemaare JSON Schema 2020-12; implementations MUST support 2020-12 and MUST handle unsupported dialects gracefully. - Where. 2025-11-25 and 2026; earlier revisions leave the dialect unspecified.
- mcpx @ 05c78b2. mcpx has no JSON Schema validator (
go.modhas no schema library). Codegen reads both 2020-12 keywords ($defs,prefixItems) and draft-07definitions(internal/codegen/ts.go:52-58). - Value to mcpx. + low: matters only if mcpx starts validating arguments or results.
- Effort. S–M, depending on the validator chosen.
- Risk. A validator defaulting to draft-07 would misread
$defs,prefixItemsand friends. - Detail. Applies to
inputSchema,outputSchemaand elicitationrequestedSchema. - Sources.
2025-11-25/basic/index.mdx:144“Default dialect: When a schema does not include a$schemafield, it defaults to”;2025-11-25/changelog.mdx:33“Establish JSON Schema 2020-12 as the default dialect”;internal/codegen/ts.go:52-58
TOOL-11 $ref network dereference forbidden by default
- What. Implementations MUST NOT automatically dereference
$refs that resolve to a network URI. An opt-in fetch mode MUST be off by default and SHOULD allowlist hosts, reject loopback/link-local/private addresses, apply timeouts and size limits, and log fetched URIs. A schema that fails on an unresolved external$refSHOULD be rejected, not treated as permissive. - Where. 2026 only.
- mcpx @ 05c78b2. Codegen resolves only
#/$defs/and#/definitions/references (internal/codegen/ts.go:263-276) and renders anything else asunknown(internal/codegen/ts.go:119); nothing fetches. - Value to mcpx. + keep it that way: a daemon on loopback that ingests untrusted upstream schemas is the SSRF case the rule exists for.
- Effort. S (none today).
- Risk. High if a future validator is added with default network loading.
- Detail. Rendering an unresolved external ref as
unknownis permissive for types; that is codegen, not validation, so the SHOULD about rejecting such schemas does not bite yet. - Sources.
2026-07-28/basic/index.mdx:301“JSON Schema 2020-12 permits$refto point at an absolute URI. Implementations MUST NOT”;2026-07-28/server/tools.mdx:800;internal/codegen/ts.go:263-276
TOOL-12 bounds on composition keywords
- What. Implementations SHOULD bound schema depth, the number of subschemas, or validation time so a hostile schema cannot stall a validator.
- Where. 2026 only; SHOULD, no numbers given.
- mcpx @ 05c78b2. No validator. Codegen stops at depth 12 and breaks
$refcycles (internal/codegen/ts.go:103-104,internal/codegen/ts.go:111-112). - Value to mcpx. + low: a pooled daemon reading many upstream schemas benefits if it ever validates.
- Effort. S–M.
- Risk. Low today.
- Detail. The depth 12 is an inline constant, against mcpx’s “nothing hardcoded” rule.
- Sources.
2026-07-28/basic/index.mdx:315“expressive schemas but can be expensive to validate. Implementations SHOULD apply”;internal/codegen/ts.go:103“if r.depth > 12 {”
TOOL-13 Tool.outputSchema
- What. Optional JSON Schema for
structuredContent. 2025-06-18 and 2025-11-25 requiretype: "object"at the root; 2026 allows any 2020-12 schema (arrays, scalars). - Where. 2025-06-18 onward; loosened in 2026. opencode v1 and v2 feed it into their code-mode type signatures.
- mcpx @ 05c78b2. The client parses it (
internal/mcpclient/client.go:122), but codegen’sToolhas no such field (internal/codegen/ts.go:19-23) and every generated function returnsToolResult = any(internal/codegen/emit.go:458). mcpx’s own tools declare none. - Value to mcpx. + typed results for
mcpx execscripts; codegen already unwrapsstructuredContent. - Effort. S to type results; M to validate.
- Risk. If mcpx ever re-exposes upstream definitions over MCP, a 2026 upstream’s array-rooted
outputSchemais invalid for 2025-06-18/2025-11-25 hosts — a downgrade casedowngrade()does not cover. - Detail. The code-mode register has the typing comparison (opencode types results from it; lootbox and mcpx do not).
- Sources.
schema/2025-06-18/schema.ts:951“outputSchema?: {”;schema/2025-11-25/schema.ts:1277“Currently restricted to type: “;schema/2026-07-28/schema.ts:2005“outputSchema?: { $schema?: string; [key: string]: unknown };”;internal/mcpclient/client.go:122;internal/codegen/emit.go:458;v1:packages/opencode/src/tool/code-mode.ts:127;v2:packages/core/src/tool/mcp.ts:48
TOOL-14 outputSchema validation obligations
- What. If a tool declares
outputSchema, servers MUST return conformingstructuredContent; clients SHOULD validate it. - Where. 2025-06-18 onward. opencode v1’s SDK enforces it: a call to a tool with
outputSchemathat returns nostructuredContent(and is notisError) throws, and mismatching content throwsInvalidParams; if listing fails on an unresolvable$refin someoutputSchema, v1 re-lists withoutputSchemastripped. v2 was not traced. - mcpx @ 05c78b2. mcpx validates nothing upstream and declares no
outputSchemaon its own tools, so opencode v1’s enforcement never trips on mcpx. - Value to mcpx. + low today; a constraint on the future.
- Effort. S.
- Risk. If mcpx adds
outputSchematomcpx_callormcpx_exec, every success path must emitstructuredContent, or opencode v1 fails the call. - Detail. v2 stops JSON-parsing text results once a tool declares
outputSchema(TOOL-28). - Sources.
2025-06-18/server/tools.mdx:314“Servers MUST provide structured results that conform to this schema.”;2025-06-18/server/tools.mdx:315“Clients SHOULD validate structured results against this schema.”;v1:packages/opencode/src/mcp/catalog.ts:14;v1:packages/opencode/src/mcp/catalog.ts:155; https://unpkg.com/@modelcontextprotocol/sdk@1.29.0/dist/esm/client/index.js L500–L501
TOOL-15 Tool.annotations
- What. Optional behaviour hints on each tool (
title,readOnlyHint,destructiveHint,idempotentHint,openWorldHint). Clients MUST treat them as untrusted unless they come from trusted servers. - Where. 2025-03-26 onward; absent in 2024-11-05.
- mcpx @ 05c78b2. Upstream annotations are kept raw (
internal/mcpclient/client.go:128). mcpx’s generated op tools carry all five fields (internal/api/ops.go:659-667); the ten core tools carry none (wire W6), so by the defaults a host must assumemcpx_callandmcpx_execare destructive, non-idempotent and open-world. - Value to mcpx. + hosts drive confirm-before-run from these;
mcpx_namespacesormcpx_typesmarked read-only could be auto-approved. - Effort. S.
- Risk. Trusting hints from an untrusted upstream; or, for mcpx’s own tools, hosts asking more than they need to.
- Detail. For
mcpx_callandmcpx_execthe honest answer depends on the upstream tool, so leaving them unannotated (defaults) is defensible; the discovery tools are unambiguously read-only. - Sources.
schema/2025-03-26/schema.ts:809“annotations?: ToolAnnotations;”;2025-03-26/server/tools.mdx:189“tool annotations to be untrusted unless they come from trusted servers.”;internal/mcpclient/client.go:128;internal/api/ops.go:659-667
TOOL-16 readOnlyHint
- What.
truemeans the tool does not modify its environment. Defaultfalse: absent is not read-only. - Where. 2025-03-26 onward.
- mcpx @ 05c78b2. Op tools set
readOnlyHint: !o.Mutating(internal/api/ops.go:662); mcpx does not read it on upstream tools. - Value to mcpx. + the primary auto-approve signal for hosts.
- Effort. S.
- Risk. Low.
- Detail.
destructiveHintandidempotentHintare meaningful only whenreadOnlyHintis false. - Sources.
schema/2025-03-26/schema.ts:748“readOnlyHint?: boolean;”;schema/2026-07-28/schema.ts:1921“Default: false”;internal/api/ops.go:662“"readOnlyHint": !o.Mutating,”
TOOL-17 destructiveHint
- What.
truemeans the tool may perform destructive updates;falsemeans additive only. Meaningful only whenreadOnlyHintis false. Defaulttrue. - Where. 2025-03-26 onward.
- mcpx @ 05c78b2. mcpx acts on it: with
elicit.confirmDestructiveon (default off), a call to an upstream tool annotated destructive asks through the broker first (internal/settings/consumer.go:47-49). But the check returns false when annotations or the field are absent (internal/daemon/consumer.go:414-425) — the opposite of the spec default — and ignoresreadOnlyHint. Op tools set it fromo.Destructive(internal/api/ops.go:663). - Value to mcpx. + the confirmation gate is a real safety feature for scripts; it should use the spec default.
- Effort. S — absent
destructiveHintwithreadOnlyHintnot true counts as destructive. - Risk. If not fixed: an unannotated upstream tool that deletes things runs unconfirmed with the setting on. If fixed: more confirmations for unannotated tools, which is what the default means.
- Detail. Reading absent as non-destructive is exactly the misreading the spec default exists to prevent.
- Sources.
schema/2025-03-26/schema.ts:758“destructiveHint?: boolean;”;schema/2025-03-26/schema.ts:756“Default: true”;internal/daemon/consumer.go:414-425;internal/settings/consumer.go:49“ask before a call to a tool annotated destructiveHint”
TOOL-18 idempotentHint
- What.
truemeans repeating a call with the same arguments has no further effect. Meaningful only when not read-only. Defaultfalse. - Where. 2025-03-26 onward.
- mcpx @ 05c78b2. Op tools set
idempotentHint: !o.Mutating || o.Destructive(internal/api/ops.go:664). mcpx does not consult it before retrying anything. - Value to mcpx. + 2026 removed stream resumability, so after a dropped 2026 stream the only safe-retry signal the protocol offers is this hint.
- Effort. S.
- Risk. Retrying a non-idempotent tool after a dropped stream double-executes.
- Detail. 2026: a broken stream means the request is lost and clients MUST re-issue it as a new request.
- Sources.
schema/2025-03-26/schema.ts:768“idempotentHint?: boolean;”;2026-07-28/changelog.mdx:28“clients MUST re-issue it as a new request with a new request ID”;internal/api/ops.go:664
TOOL-19 openWorldHint
- What.
truemeans the tool interacts with an open world of external entities (web search, say);falsea closed domain. Defaulttrue. - Where. 2025-03-26 onward.
- mcpx @ 05c78b2. Every op tool declares
openWorldHint: true(internal/api/ops.go:665), including ones that only read the local daemon (mcpx_health,mcpx_settings_get; wire W6). - Value to mcpx. + low:
falsefor daemon-local operations is the accurate answer. - Effort. S.
- Risk. Low — hosts may be more cautious than needed.
- Detail. Operations that reach a registry or an upstream (
mcpx_registry_search,mcpx_resource_read) are genuinely open-world. - Sources.
schema/2025-03-26/schema.ts:778“openWorldHint?: boolean;”;schema/2025-03-26/schema.ts:776“Default: true”;internal/api/ops.go:665“"openWorldHint": true,”
TOOL-20 Tool.icons
- What.
Tool extends BaseMetadata, Icons: optionalicons: Icon[]withsrc,mimeType,sizes,theme. - Where. 2025-11-25 and 2026.
- mcpx @ 05c78b2. The client
Toolstruct has noiconsfield, so they are dropped on parse (internal/mcpclient/client.go:117-129). - Value to mcpx. + plugin/TUI display.
- Effort. S.
- Risk. Loss of display data only.
- Detail. Clients that render icons MUST support PNG and JPEG and SHOULD support SVG and WebP; SVG can carry script.
- Sources.
schema/2025-11-25/schema.ts:1249“export interface Tool extends BaseMetadata, Icons {”;2025-11-25/changelog.mdx:13“Allow servers to expose icons as additional metadata for tools, resources, resource templates, and prompts”;internal/mcpclient/client.go:117-129
TOOL-21 Tool.execution.taskSupport
- What. Per-tool task negotiation:
"forbidden"(default),"optional","required". Forrequired, clients MUST call the tool as a task or get-32601. The server must also declaretasks.requests.tools.call. - Where. 2025-11-25 only; removed from the 2026 schema (the tasks extension has no per-tool negotiation), which the
2026 changelog does not mention. opencode v1’s SDK throws
InvalidRequestwhen asked to call a tool cached as task-required; neither opencode version declares client tasks. - mcpx @ 05c78b2. Not parsed (
internal/mcpclient/client.go:117-129);CallToolsends only{name, arguments}(internal/mcpclient/client.go:845-858) and notasks/*method exists in the client or pool. A 2025-11-25 upstream tool withtaskSupport: "required"answers-32601and mcpx has no way around it. mcpx’s own tools carry noexecution(internal/mcpserver/server.go:260-268), which makes its declaredtasks.requests.tools.callunreachable (capabilities register). - Value to mcpx. + long-running upstream jobs (CI, batch) survive disconnects; mcpx already has a task store to bridge them.
- Effort. M.
- Risk. If not done: a growing class of servers is uncallable through mcpx. If mcpx ever marks its own tool
required, opencode v1 cannot call it. - Detail.
docs/protocol.mdsays upstream task status notifications are dropped because “mcpx polls task state”; mcpx polls nothing upstream (tasks register). - Also recorded from the tasks register. 2025-11-25. opencode v1’s SDK throws
InvalidRequestbefore sending a call to a tool cached as task-required; neither opencode version can run tasks. The client sendstools/callwith{name, arguments}only (internal/mcpclient/client.go:845-858), neverparams.task, and handles noCreateTaskResult; notasks/*method exists ininternal/mcpclientorinternal/pool. Such a tool answers-32601and mcpx has no way around it. In 2026 the equivalent is declaring the extension per request and accepting server-chosen tasks, which mcpx’s client does not do either.docs/protocol.md:297-299claims mcpx “polls task state”; with no upstream tasks there is nothing to poll (TASK-14). - Sources.
schema/2025-11-25/schema.ts:1241“taskSupport?: “;2025-11-25/basic/utilities/tasks.mdx:117“Ifexecution.taskSupportis";internal/mcpclient/client.go:849; https://unpkg.com/@modelcontextprotocol/sdk@1.29.0/dist/esm/client/index.js L490–L494;v1:packages/opencode/src/mcp/index.ts:48` “// tasks: {},”; https://unpkg.com/@modelcontextprotocol/sdk@1.29.0/dist/esm/client/index.js (L490–L494).
TOOL-22 _meta on Tool
- What. 2025-06-18 adds
_metatoTool(and to resources, templates, prompts and content blocks). Keys whose second label ismodelcontextprotocolormcpare reserved. - Where. 2025-06-18 onward. The 2026 schema has it; the 2026 Tool data-type prose list omits it.
- mcpx @ 05c78b2. The client
Toolstruct has no_meta, so it is dropped on parse (internal/mcpclient/client.go:117-129). - Value to mcpx. + MCP Apps and other extensions hang per-tool data here; a proxy that drops it breaks them.
- Effort. S — keep it raw, like
annotations. - Risk. Extension metadata silently lost.
- Detail. The prose/schema gap in 2026 is an omission, not a removal: the schema is authoritative. Upstream
result
_metaloss is in the_metaregister. - Sources.
schema/2025-06-18/schema.ts:967“_meta?: { [key: string]: unknown };”;2025-06-18/changelog.mdx:35“Add_metafield to additional interface types”;schema/2026-07-28/schema.ts:2014;2026-07-28/server/tools.mdx:286-302;internal/mcpclient/client.go:117-129
TOOL-23 deterministic tools/list order
- What. Servers SHOULD return tools in the same order across requests while the set is unchanged, for client caching and LLM prompt-cache hits.
- Where. 2026 only; SHOULD;
tools/listalone (not prompts or resources). - mcpx @ 05c78b2. The ten core tools come first, then adapter, OpenAPI and op tools in declaration order
(
internal/mcpserver/server.go:376-378;internal/cli/serve.go:332-347). The upstream schema cache is sorted by name (internal/pool/pool.go:501-503). - Value to mcpx. + prompt-cache hits for every host.
- Effort. S (done).
- Risk. Go map iteration would randomise a listing built from a map; none is today.
- Detail. Merges the tools and caching findings on the same rule; the pagination-caching register refers here.
- Sources.
2026-07-28/server/tools.mdx:71“Servers SHOULD return tools in a deterministic order”;2026-07-28/changelog.mdx:34“Servers SHOULD return tools fromtools/listin a deterministic order”;internal/mcpserver/server.go:376-378;internal/pool/pool.go:501-503
TOOL-24 mcpx’s tools/list is 61 tools
- What. Besides the ten core tools, every generated
/v1operation is exposed as its own MCP tool — 61 in total with one upstream configured. The package doc and comments say “Ten tools instead of three hundred”. - Where. mcpx only; the aim is to keep upstream schemas out of the host’s context.
- mcpx @ 05c78b2. Base list
internal/mcpserver/server.go:276-380; op tools fromapi.Ops()(internal/mcpserver/ops.go:30-60), appended ininternal/cli/serve.go:344. Wire W6 returned 61 tools. - Value to mcpx. − about 51 extra schemas in every host’s context, the cost mcpx exists to avoid; + the parity principle (CLI = /v1 = MCP) is why they are there.
- Effort. S — gate op tools behind a setting, or expose one dispatcher tool.
- Risk. Token cost on every request of every host that sends tool schemas to its model directly.
- Detail. A product decision between two stated principles; the “ten” comment is stale either way.
- Sources.
internal/mcpserver/server.go:12-13“Ten tools”;internal/mcpserver/server.go:274-275“These ten reach”;internal/cli/serve.go:344“extras = append(extras, a.v1OpTools()…)”;internal/mcpserver/ops.go:30-60
TOOL-25 lists are not revision-filtered
- What. mcpx builds every result in the newest shape and spells it down in
downgrade(), butdowngrade()rewrites only content blocks, prompt messages,structuredContentandresultType; it never touchestools/listor any other list result. - Where. mcpx design. Tool
title/outputSchema/_metaare 2025-06-18 fields;icons/executionare 2025-11-25. - mcpx @ 05c78b2. mcpx’s tools carry only
name,description,inputSchemaandannotations(wire W6 key set;internal/mcpserver/server.go:260-268), all defined in 2025-03-26, so nothing leaks today.internal/mcpserver/server.go:439appliesdowngradeto every result;internal/mcpserver/revisions.go:132-170shows what it handles. - Value to mcpx. + low now; + necessary before adding
title,outputSchema,iconsorexecution. - Effort. S.
- Risk. The first newer field added to a tool leaks to older hosts; prompts already leak
titleto 2025-03-26 (prompts register). - Detail.
downgrade()copies before editing, so stored task results are not mutated. - Sources.
internal/mcpserver/server.go:439“resp.Result = downgrade(resp.Result, peer.Version)”;internal/mcpserver/revisions.go:132-170;internal/mcpserver/server.go:260-268
TOOL-26 CallToolResult.isError, and mcpx_call dropping it
- What. Tool execution failures are reported in the result with
isError: true, not as protocol errors, so the model can see and react to them. - Where. All five. opencode v1 throws an error built from the text blocks (or “MCP tool returned an error”) and
discards non-text blocks; v2 raises a
ToolFailurethe same way. lootbox hands the envelope to the script as a successful value (code-mode register). - mcpx @ 05c78b2. mcpx’s own failures are correct: a failing tool becomes
isError: true(internal/mcpserver/server.go:666-674). Butmcpx_calllaunders upstream failures:/v1/callreturns 200 with the raw result (internal/daemon/server.go:694-703),mcpBackend.CallreturnsrenderResult(res.Result), nil(internal/cli/serve.go:103-117), andrenderResultparsesIsErrorand ignores it (internal/cli/commands.go:385-415). Wire W20: upstreamboomwithisError: truecame back as{"content":[{"text":"boom: deliberate failure","type":"text"}],"resultType":"complete"}. - Value to mcpx. + correctness of the core proxy tool.
- Effort. S — return an error from
mcpBackend.Callwhen the upstream result hasisError. - Risk. If not fixed: agents act on error text as if it were data.
- Detail. Scripts are unaffected: the generated client throws
ToolErroronisError(internal/codegen/emit.go:493-500). A task created for such a call is markedfailedonly if mcpx’s own result hasisError, which here it never does. The ask path setsisErroronly for transport failures (internal/cli/serve_ask.go:175-181). Whether bad arguments areisErroror-32602is in the errors register. - Also recorded from the content types register.
isErrorexists in every revision. opencode v1 and v2 would turn anisErrorresult into a thrown error carrying the joined text blocks (non-text blocks of an error result are discarded), if mcpx passed it on./v1/callreturns 200 with the raw result (internal/daemon/server.go:694-703);renderResultparsesIsErrorand ignores it (internal/cli/commands.go:392); the ask path setsIsErroronly for transport failures (internal/cli/serve_ask.go:175-181). Wire W20: fakemcp’sboomcame back as{"content":[{"text":"boom: deliberate failure","type":"text"}],"resultType":"complete"}. Scripts are unaffected: the generated client throwsToolError(CT-19). The errors area records the protocol-error-vs-isErrorrules this sits on. - Sources.
schema/2024-11-05/schema.ts:663“isError?: boolean;”;internal/cli/commands.go:392;internal/cli/serve.go:116“return renderResult(res.Result), nil”;internal/mcpserver/server.go:666-674;v1:packages/opencode/src/mcp/catalog.ts:68;v2:packages/core/src/tool/mcp.ts:78; wire W20
TOOL-27 CallToolResult.structuredContent
- What. 2025-06-18 adds
structuredContent: {[key]: unknown}(an object). 2026 retypes itunknown: any JSON value, including arrays, scalars andnull(distinct from absent). - Where. Absent in 2024-11-05 and 2025-03-26; object in 2025-06-18 and 2025-11-25; any JSON in 2026. The same change
applies to sampling’s
ToolResultContent.structuredContent. - mcpx @ 05c78b2. For hosts below 2025-06-18,
downgrade()removes it and appends it as an indented JSON text block (internal/mcpserver/revisions.go:146-156). There is no downgrade of a non-object value for 2025-06-18/2025-11-25 hosts. In practice mcpx never emitsstructuredContent(CT-13), so the branch is dead today. - Value to mcpx. + needed as soon as mcpx relays structured results.
- Effort. S — wrap or textify non-object values for 2025-06-18/2025-11-25 hosts.
- Risk. A 2026 upstream returning
[...]or42relayed verbatim is schema-invalid for 2025-06-18/2025-11-25 hosts. - Detail. opencode v1 treats
structuredContent: nulllike absent (TOOL-28), which erases the 2026 distinction. - Also recorded from the content types register. 2025-06-18 onward. opencode v1’s direct path uses it only when
contentis empty (then asJSON.stringifytext); v1 code mode prefers it over text; v2 makes it the tool’soutputwhenever present. Below 2025-06-18,downgrade()deletes it and appends it as an indented JSON text block (internal/mcpserver/revisions.go:146-156). For 2025-06-18 and 2025-11-25 clients a non-object value from a 2026 upstream is passed unchanged, which their schema forbids. mcpx itself emits nostructuredContentthroughmcpx_call(CT-13), so the branch is dead in production.nullis a legal 2026 value distinct from absent. opencode v1’s SDK also enforces “tool hasoutputSchema⇒ result hasstructuredContent” (tools area). - Sources.
schema/2025-06-18/schema.ts:828“structuredContent?: { [key: string]: unknown };”;schema/2026-07-28/schema.ts:1821“structuredContent?: unknown;”;schema/2025-11-25/schema.ts:1891;schema/2026-07-28/schema.ts:2469;internal/mcpserver/revisions.go:146-156;2026-07-28/server/tools.mdx:500“For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.”;internal/mcpserver/revisions.go:200-203;v1:packages/opencode/src/mcp/catalog.ts:75;v1:packages/opencode/src/tool/code-mode.ts:109;v2:packages/core/src/tool/mcp.ts:97
TOOL-28 structuredContent versus its text duplicate
- What. For backwards compatibility a tool returning
structuredContentSHOULD also return the serialized JSON in a text block. Clients then pick one. - Where. 2025-06-18 onward. opencode v1 returns
contentas-is when non-empty and usesstructuredContentonly whencontentis empty (and notnull); v2 makesstructuredContentthe tool’soutput, else parses text that starts with{/[as JSON when the tool has nooutputSchema, else passes text. - mcpx @ 05c78b2.
mcpx_callsends one text block and neverstructuredContent(CT-13), so v2 code mode gets data only through its JSON-looking-text heuristic. Generated script clients returnstructuredContentwhen present (internal/codegen/emit.go:501). - Value to mcpx. + returning upstream
structuredContentalongside the text would give v2 real values and keep v1 working. - Effort. S.
- Risk. Without the text duplicate, opencode v1 shows the model nothing useful; without
structuredContent, v2 guesses. - Detail. 2026 prose adds that
structuredContentis unrelated to LLM “structured outputs”. - Sources.
2026-07-28/server/tools.mdx:500“For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.”;v1:packages/opencode/src/mcp/catalog.ts:75;v2:packages/core/src/tool/mcp.ts:96;v2:packages/core/src/tool/mcp.ts:100
TOOL-29 mcpx_exec artifacts as resource_link
- What. Files a script produces come back as
resource_linkblocks (urimcpx://artifacts/{id},name,mimeType,description), read later withresources/readif the host wants the bytes. - Where.
resource_linkexists from 2025-06-18. Neither opencode nor lootbox returns artifacts this way; opencode v1 silently dropsresource_linkfrom direct tool results, and v2 keeps only theurias text. - mcpx @ 05c78b2. Built in
internal/cli/serve.go:174-187, carried through the string result with a U+001E marker (internal/mcpserver/toolresult.go:21), appended intools/call(internal/mcpserver/server.go:676-681); for 2025-03-26 hosts it becomes an embeddedresource(content-types register). - Value to mcpx. + keeps binaries out of context.
- Effort. S (done); + S to also name artifact URIs in the text block so opencode v1 users see them.
- Risk. Artifacts appear to vanish in opencode v1 outside code mode.
- Detail.
descriptionon aresource_linkis valid (it extendsResource). - Sources.
internal/cli/serve.go:180“"type": "resource_link",”;internal/mcpserver/toolresult.go:21;internal/mcpserver/server.go:676-681;v1:packages/opencode/src/session/tools.ts:429;v2:packages/core/src/mcp/client.ts:327
TOOL-30 tools/call may answer with InputRequiredResult
- What. In 2026,
CallToolResultResponse.resultisCallToolResult | InputRequiredResult, andCallToolRequestParamsextendsInputResponseRequestParams(inputResponses,requestState) instead ofTaskAugmentedRequestParams, so the 2025-11-25taskfield is gone. - Where. 2026 only.
- mcpx @ 05c78b2. As a client it resolves
input_requiredand retries (internal/mcpclient/client.go:654-677); as a server it emits it fortools/call(internal/mcpserver/server.go:649-657). Its gaps (session dependence, text-only final render, no method binding inrequestState) are in the MRTR register. - Value to mcpx. + implemented; it is how a 2026 upstream asks a question mid-call.
- Effort. Done.
- Risk. See the MRTR register.
- Detail. mcpx still accepts a
taskparam ontools/callfrom any era (tasks register). - Sources.
schema/2026-07-28/schema.ts:1849“result: CallToolResult | InputRequiredResult;”;schema/2026-07-28/schema.ts:1863“export interface CallToolRequestParams extends InputResponseRequestParams {”;internal/mcpclient/client.go:654-677;internal/mcpserver/server.go:649-657
TOOL-31 per-call permission prompts in the host
- What. Hosts gate each MCP tool call on a permission: opencode v1 asks with
permission: "<server>_<tool>"and patterns["*"]; v2 callspermission.assert({action: "<server>_<tool>", resources: ["*"]}). - Where. opencode v1 and v2. v1 code-mode child calls ask per child tool too.
- mcpx @ 05c78b2. n/a — host-side. Every upstream call made inside one
mcpx_execis a single permission from the host’s point of view. - Value to mcpx. + users can allow
mcpx_*wholesale; − one approval then covers everything a script does. - Effort. S (documentation).
- Risk. Low.
- Detail. The permission key is the doubled name from TOOL-03 when the server entry is called
mcpx. - Sources.
v1:packages/opencode/src/session/tools.ts:408;v2:packages/core/src/tool/mcp.ts:51;v1:packages/opencode/src/tool/code-mode.ts:147
Transports
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T08:00:00-05:00
increment: 1
status: standard
tags: area:compare
description: stdio, HTTP+SSE and Streamable HTTP across revisions: framing, streams, sessions, headers, Origin, and mcpx's server and client sides.
MCP runs over stdio (newline-delimited JSON-RPC) and over HTTP: the two-endpoint HTTP+SSE transport of 2024-11-05,
Streamable HTTP from 2025-03-26 with optional Mcp-Session-Id sessions and resumable SSE, and the 2026-07-28 reshape
that is POST-only, sessionless, and mirrors body fields into required headers (Mcp-Method, Mcp-Name,
Mcp-Param-*). For mcpx two things matter most: its /mcp endpoint runs tools as the user on a loopback port and does
not validate Origin, the one MUST in this area with a working browser exploit; and its HTTP client sends none of the
2026 headers, so a conformant 2026 HTTP server rejects every modern request from it.
Status column: mcpx @ 05c78b2. Legend and citation conventions: ../README.md.
| ID | Difference | Labels | Where it exists | mcpx @ 05c78b2 | Value | Effort | Risk |
|---|---|---|---|---|---|---|---|
| TR-01 | stdio framing: one message per line, stdout only MCP | 2024-11-05 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✓ — both sides; client skips non-JSON banner lines | + high | S | low |
| TR-02 | stdio stderr: logging only, not an error signal | 2025-11-25 has opencode v1 has opencode v2 has | 24-11 "logging" · 25-11 any logging, not errors · 26-07 same · opencode v1 piped unread · v2 drained | ✓ — server silent; client drains to a ring buffer | + low | S | low |
| TR-03 | Maximum stdio frame size | opencode v2 has | spec: none · opencode v2 16 MiB · mcpx 64 MiB | ✓ — 64 MiB both sides; larger than opencode v2 accepts | + med | S | med |
| TR-04 | stdio server launch: environment and working directory | opencode v1 has opencode v2 has | opencode v1 ✓ · opencode v2 ✓ · mcpx ✓ | ✓ — config env and cwd over the daemon’s env | + low | S | low |
| TR-05 | stdio requests handled one at a time | mcpx missing | none required; cancellation and ping assume concurrency | partial — only calls that may ask the client run concurrently (#202) | + med | M | med |
| TR-06 | 2026 stdio direction rules | 2026-07-28 has | 26-07 ✓ | ✓ — legacy gets requests, modern gets input_required | + med | S | low |
| TR-07 | Custom byte-stream transports reuse stdio framing | 2026-07-28 has mcpx has, others don't | 26-07 SHOULD | partial — MCP over HTTP on a Unix socket, not NDJSON | + low | M | low |
| TR-08 | JSON-RPC messages MUST be UTF-8 | 2025-03-26 has | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✓ — Go encoding/json emits UTF-8 | + low | S | low |
| TR-09 | JSON-RPC batching | 2025-03-26 has 2025-06-18 removes mcpx missing | 25-03 ✓ only | ✗ — server rejects batches; client splits HTTP, drops stdio (#202) | + low | M | low |
| TR-10 | HTTP+SSE transport (2024-11-05) | 2024-11-05 has 2025-03-26 deprecates 2026-07-28 deprecates mcpx missing opencode v1 has | 24-11 ✓ · 25-03 dep · 25-06 dep · 25-11 dep · 26-07 dep · opencode v1 fallback | ✗ — neither served nor spoken; transport: sse ignored (#220) | + med | M | med |
| TR-11 | Client fallback from Streamable HTTP to HTTP+SSE | specs conflict mcpx missing opencode v1 has | 25-03 any 4xx · 25-11 400/404/405 · 26-07 + body check | ✗ — no fallback (#220) | + med | M | med |
| TR-12 | Streamable HTTP methods: POST+GET → POST only | 2025-03-26 has 2026-07-28 removes | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 POST | ✓ — POST served, GET 405, DELETE 204 | + low | S | low |
| TR-13 | Standalone GET SSE stream: server side | 2025-03-26 has 2026-07-28 removes has better replacement | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 405 | ✓ — 405 with Allow: POST, DELETE | + low | S | low |
| TR-14 | Standalone GET SSE stream: client side | 2025-03-26 has mcpx missing | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — | ✗ — never opened; unsolicited legacy messages lost (#203) | + med | M | med |
| TR-15 | Accept: application/json, text/event-stream on POST | 2025-03-26 has | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✓ — client sends it; server does not require it | + low | S | low |
| TR-16 | Request answered with JSON or SSE, chosen per request | 2025-03-26 has | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✓ — JSON unless mcpx must ask the client something | + med | S | low |
| TR-17 | 202 Accepted for notifications (and legacy responses) | 2025-03-26 has 2026-07-28 has | 25-03..25-11 notifications and responses · 26-07 notifications only | partial — known notifications 202; unknown ones get a 200 error | + low | S | low |
| TR-18 | Client responses POSTed back to the server | 2025-03-26 has 2026-07-28 removes | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 forbidden | ✓ — routed by session to the waiting request | + med | S | low |
| TR-19 | Server requests on a request’s SSE stream | 2025-03-26 has 2026-07-28 removes has better replacement | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 forbidden | ✓ — legacy elicitation there; modern gets input_required | + med | S | low |
| TR-20 | Each message on exactly one stream (no broadcast) | 2025-03-26 has 2026-07-28 removes | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 per-request streams | ✓ — only per-request streams exist | + low | S | low |
| TR-21 | SSE resumability via event ids and Last-Event-ID | 2025-03-26 has 2025-11-25 has 2026-07-28 removes | 25-03 ✓ · 25-06 ✓ · 25-11 ✓+ · 26-07 — | ✗ — no event ids sent; no resume attempted | + low | M | low |
| TR-22 | SSE polling: priming event, retry, reconnect | 2025-11-25 has 2026-07-28 removes mcpx missing | 25-11 ✓ only | ✗ — client ignores retry; never reconnects (#203) | + low | M | med |
| TR-23 | X-Accel-Buffering: no on SSE responses | 2026-07-28 has | 26-07 SHOULD | ✓ — set on every SSE response | + low | S | low |
| TR-24 | SSE comment lines as keep-alive | 2026-07-28 has | 26-07 encouraged | ✓ — client ignores comments; server sends none | + low | S | low |
| TR-25 | Mcp-Session-Id value and assignment rules | 2025-03-26 has 2026-07-28 removes | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 — | ✓ — sess- + 32 random hex; never required | + low | S | low |
| TR-26 | Mcp-Session-Id minted on server/discover | 2026-07-28 removes mcpx missing | 26-07: do not mint or echo | ✗ — minted for every discover, modern or not (#199) | + med | M | med |
| TR-27 | Mcp-Session-Id minted on a failed initialize | 2025-03-26 has mcpx missing | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ | ✗ — issued even when the handshake is refused (#202) | + low | S | low |
| TR-28 | Unknown or expired session id → 404 | 2025-03-26 has mcpx missing | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ignore | ✗ — served statelessly as 2025-03-26 (#202) | + med | S | med |
| TR-29 | Client re-initializes after a session 404 | 2025-03-26 has opencode v1 has opencode v2 has mcpx has | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · opencode v1 ✓ · opencode v2 ✓ | ✓ — client forgets the dead session, re-initializes once and retries once (#1637) | + med | M | low |
| TR-30 | Client sends a session id to modern servers | 2026-07-28 removes mcpx missing | 26-07: no sessions | ✗ — captured from any response, echoed always (#200) | + low | S | low |
| TR-31 | DELETE handled by the server | 2025-03-26 has 2026-07-28 removes | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 405 | ✓ — 204, even for an unknown session | + low | S | low |
| TR-32 | DELETE sent by the client on close | 2025-03-26 has | 25-03 ✓ · 25-06 ✓ · 25-11 ✓ | ✓ — sent when a session id is held | + low | S | low |
| TR-33 | MCP-Protocol-Version header requirement timeline | 2025-06-18 has specs conflict | 25-03 auth only · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ (= _meta) | partial — sent always; server checks only against _meta | + med | S | med |
| TR-34 | Client header value after a legacy handshake | 2025-06-18 has mcpx missing | 25-06 ✓ · 25-11 ✓ | ✗ — always 2025-11-25, not the negotiated version (#203) | + med | S | med |
| TR-35 | Server uses the legacy header as the version | 2025-06-18 has mcpx missing | 25-06 ✓ · 25-11 ✓ | partial — header ignored; no-session requests served as 2025-03-26 (#202) | + med | S | med |
| TR-36 | Invalid legacy header value → 400 | 2025-06-18 has mcpx missing | 25-06 ✓ · 25-11 ✓ | ✗ — 1999-01-01 accepted and served (#202) | + low | S | low |
| TR-37 | Mcp-Method header (2026) | 2026-07-28 has mcpx missing | 26-07 REQUIRED | ✗ — client never sends it; server never checks it (#199) | + high | S | high |
| TR-38 | Mcp-Name header (2026) | 2026-07-28 has mcpx missing | 26-07 REQUIRED on three methods | ✗ — client never sends it; server never checks it (#199) | + high | S | high |
| TR-39 | Mirror x-mcp-header arguments into Mcp-Param-{Name} | 2026-07-28 has mcpx missing | 26-07 clients MUST | ✗ — not implemented (#200) | + med | M | med |
| TR-40 | Intermediaries and mirrored headers | 2026-07-28 has | 26-07 ✓ | n/a — mcpx terminates and re-originates | + low | S | low |
| TR-41 | Origin validation against DNS rebinding | 2024-11-05 has 2025-11-25 has mcpx missing | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ 403 · 26-07 ✓ 403 · lootbox — | ✗ — any Origin accepted on /mcp and /v1 (#204) | + high | S | high |
| TR-42 | Bind to localhost when running locally | 2024-11-05 has | 24-11 ✓ · 25-03 ✓ · 25-06 ✓ · 25-11 ✓ · 26-07 ✓ | ✓ — loopback unless daemon.address says otherwise | + med | S | low |
| TR-43 | /mcp mounted on the daemon’s own listeners | 2025-03-26 has | mcpx design | ✓ — Unix socket and loopback TCP; serve --transport http gone | + med | S | low |
| TR-44 | Client drops server requests with string ids | 2024-11-05 has mcpx missing | RequestId = string | number in every revision | ✗ — ids decoded as int64; frame silently dropped (#203) | + med | S | med |
| TR-45 | Client HTTP timeout caps whole SSE responses | mcpx missing | implementation | ✗ — 10 min cap cuts long streams (#203) | + low | S | med |
TR-01 stdio framing: one message per line, stdout only MCP
- What. One JSON-RPC message per line with no embedded newlines; the server writes only valid MCP messages to stdout and the client writes only valid MCP messages to stdin.
- Where. Every revision. 2026 adds “each message is a single JSON-RPC request, notification, or response” (no batches) and UTF-8.
- mcpx @ 05c78b2. Server: one object per line through a 64 MiB scanner
(
internal/mcpserver/server.go:1021-1025), compact JSON per line under a write lock (internal/mcpserver/server.go:1004-1011);mcpx serveswitches the CLI to machine output so nothing else reaches stdout (internal/cli/serve.go:378-382); stderr was empty in every recorded stdio run. Client: writes each frame plus a newline under a lock (internal/mcpclient/stdio.go:112-121), skips blank lines and any line that does not start with{or[(internal/mcpclient/stdio.go:139-142). - Value to mcpx. + high: this is the path every spawning host uses.
- Effort. S — done.
- Risk. None observed.
- Detail. Skipping banner lines is accept-liberally against the server’s MUST; the comment calls it deliberate tolerance of servers that log on stdout.
- Sources.
2024-11-05/basic/transports.mdx:24“- Messages are delimited by newlines, and MUST NOT contain embedded newlines.”;2026-07-28/basic/transports/stdio.mdx:13;internal/mcpserver/server.go:1025;internal/mcpclient/stdio.go:139; wire S1–S5.
TR-02 stdio stderr: logging only, not an error signal
- What. 2024-11-05..2025-06-18: the server MAY write UTF-8 to stderr “for logging purposes”. From 2025-11-25: any logging (informational, debug, error), the client MAY capture, forward or ignore it, and SHOULD NOT treat it as an error indicator. 2026 names stderr as the migration path for the deprecated Logging feature.
- Where. opencode v1 spawns with
stderr: "pipe"and never reads it; a chatty server could block once the pipe fills (inferred from stream back-pressure, not reproduced). opencode v2 drains it line by line into a debug log “so chatty servers cannot stall on a full pipe”. - mcpx @ 05c78b2. Server:
mcpx servewrites nothing to stderr (wire S1–S5). Client: drains upstream stderr continuously into a 64 KiB ring buffer (internal/mcpclient/stdio.go:92,:101) and quotes its tail only inside error messages (internal/mcpclient/stdio.go:119,:131); it never treats stderr as failure. The pool sets noStderrTo, so upstream stderr is not logged anywhere else (internal/pool/pool.go:369-375). - Value to mcpx. + low: done; forwarding upstream stderr to mcpx’s logs would help diagnosis.
- Effort. S.
- Risk. Treating stderr as fatal breaks chatty servers; mcpx does not.
- Detail. Changelog 2025-11-25 minor item 1 (PR #670).
- Sources.
2024-11-05/basic/transports.mdx:25“The server MAY write UTF-8 strings to its standard error (stderr) for logging”;2025-11-25/basic/transports.mdx:31;internal/mcpclient/stdio.go:92;internal/mcpclient/stdio.go:101;v1:packages/opencode/src/mcp/index.ts:348“stderr: "pipe",”;v2:packages/core/src/mcp/stdio.ts:162.
TR-03 Maximum stdio frame size
- What. No revision sets a maximum message size, so every implementation picks one, and a sender larger than the receiver’s limit kills the connection.
- Where. opencode v2 fails the connection if one line exceeds 16 MiB (“MCP stdio frame exceeded 16 MiB”), with an outgoing queue of 64 frames; no cap was found in opencode v1.
- mcpx @ 05c78b2. Server stdio reads up to 64 MiB per line (
internal/mcpserver/server.go:1025); HTTP bodies are read through a 64 MiBLimitReader(internal/mcpserver/server.go:1132), so a larger body is truncated and fails as a parse error. Client stdio and SSE useplumbing.stdioMaxLine, 64 MiB (internal/mcpclient/stdio.go:49;internal/defaults/defaults.json:41;internal/mcpclient/http.go:166). - Value to mcpx. + med: an
mcpx_execormcpx_callresult over 16 MiB drops opencode v2’s connection to mcpx. - Effort. S — cap or spill results below the smallest known host limit.
- Risk. One oversized result silently removes mcpx from an opencode v2 Location until reconnect.
- Detail. The server-side limits are inline numbers while the client’s is a setting, against mcpx’s “nothing hardcoded” rule.
- Sources.
v2:packages/core/src/mcp/stdio.ts:15“const MAX_FRAME_BYTES = 16 * 1024 * 1024”;v2:packages/core/src/mcp/stdio.ts:117;internal/mcpserver/server.go:1025;internal/mcpserver/server.go:1132;internal/defaults/defaults.json:41.
TR-04 stdio server launch: environment and working directory
- What. How a client spawns a stdio server: command, arguments, working directory and environment. The spec leaves this to the client.
- Where. opencode v1 and v2 run
command[0]with the rest as arguments, cwd from config resolved against the directory (default: the directory), env = host env plus the config’senvironment. v2 spawns through the Location’s environment, so workspace-backed Locations run servers remotely, and with v2’s background daemon “host env” is the daemon’s. - mcpx @ 05c78b2. The pool launches with the config’s command, args, env and cwd and
InheritEnv: true(internal/pool/pool.go:369-375); the child’s environment is the daemon’s plus the config’s (internal/mcpclient/stdio.go:60-66), in its own process group (internal/mcpclient/stdio.go:69). - Value to mcpx. + low.
- Effort. S — done.
- Risk. None.
- Detail.
mcpx servespawned by opencode inherits opencode’s process env, not the per-session shell env (so noMCPX_SESSION_ID); upstreams spawned by the mcpx daemon inherit the daemon’s env, not the caller’s. - Sources.
internal/pool/pool.go:369-375;internal/mcpclient/stdio.go:60-66;v1:packages/opencode/src/mcp/index.ts:346;v2:packages/core/src/mcp/stdio.ts:84“extendEnv: true,”.
TR-05 stdio requests handled one at a time
- What. Over stdio, mcpx handles each request on the read loop, except those that may need to ask the client
something. A 120 s
mcpx_execblocksping,notifications/cancelledand every other request until it returns. - Where. No revision requires concurrency, but cancellation and
pingassume the receiver keeps reading while it works; 2026 stdio makesnotifications/cancelledthe client’s MUST. - mcpx @ 05c78b2.
internal/mcpserver/server.go:1051-1067;mayBlockOnClientcovers onlytools/call,prompts/getandresources/read, and only for a client that can answer (internal/mcpserver/ask.go:318-327). - Value to mcpx. + med: cancellation and liveness on stdio; hosts that ping during long calls stop treating mcpx as hung.
- Effort. M — dispatch every request on its own goroutine; the writer is already locked.
- Risk. A host with a ping watchdog kills mcpx during long scripts.
- Detail. Reply order is the stated reason for in-line handling (
internal/mcpserver/server.go:1051-1054), but JSON-RPC does not require ordered replies. - Sources.
internal/mcpserver/server.go:1063“} else if resp := s.HandleOn(ctx, c, req); resp != nil {”;2026-07-28/basic/patterns/cancellation.mdx:42-43;internal/mcpserver/ask.go:318-327.
TR-06 2026 stdio direction rules
- What. On 2026 stdio the server MUST NOT write JSON-RPC requests to stdout (only responses, request-scoped
notifications and listen-stream notifications), and the client MUST NOT write responses. Server-to-client
interaction goes through
InputRequiredResult. - Where. 2026-07-28.
- mcpx @ 05c78b2. Modern peers get
input_required(internal/mcpserver/ask.go:262); legacy connections still get real requests (internal/mcpserver/conn.go:255-259), which is right per era. - Value to mcpx. + med: done.
- Effort. S — done.
- Risk. None.
- Detail. Because stdio shares one channel, clients MUST demultiplex listen notifications by
io.modelcontextprotocol/subscriptionId(meta.mdSUB-05) and progress byprogressToken. - Sources.
2026-07-28/basic/transports/stdio.mdx:55“The server MUST NOT write JSON-RPC requests tostdout.”;2026-07-28/basic/transports/stdio.mdx:36;internal/mcpserver/ask.go:262.
TR-07 Custom byte-stream transports reuse stdio framing
- What. 2026 says newline-delimited JSON-RPC works unchanged over Unix sockets or TCP, and custom transports on such streams SHOULD reuse it; only launch, stderr and shutdown need equivalents. Custom transports MUST preserve the per-request metadata model.
- Where. 2026-07-28. Earlier revisions only say custom transports must preserve the JSON-RPC format and lifecycle.
- mcpx @ 05c78b2. MCP is served as Streamable HTTP over the daemon’s Unix socket and loopback TCP
(
internal/daemon/server.go:198-219,:483-488), not as raw NDJSON on the socket. - Value to mcpx. + low: an NDJSON-over-Unix-socket endpoint would be a cheap, header-free surface for local agents.
- Effort. M.
- Risk. None.
- Detail. Serving MCP over a Unix socket at all is something mcpx has that the other products compared here do not.
- Sources.
2026-07-28/basic/transports/index.mdx:75“Unix domain sockets or TCP) SHOULD reuse the”;2026-07-28/basic/transports/stdio.mdx:26;internal/daemon/server.go:488.
TR-08 JSON-RPC messages MUST be UTF-8
- What. From 2025-03-26 the transports page says JSON-RPC messages MUST be UTF-8 encoded; 2024-11-05 mentions UTF-8 only for stderr.
- Where. 2025-03-26..2026-07-28.
- mcpx @ 05c78b2. Go’s
encoding/jsonemits UTF-8 (internal/mcpserver/server.go:1004-1011). - Value to mcpx. + low.
- Effort. S — done.
- Risk. None.
- Detail. RFC 8259 already requires UTF-8 for JSON exchanged between systems.
- Sources.
2025-03-26/basic/transports.mdx:5“JSON-RPC messages MUST be UTF-8 encoded.”;2026-07-28/basic/transports/index.mdx:29.
TR-09 JSON-RPC batching
- What. 2025-03-26 adds
JSONRPCBatchRequestandJSONRPCBatchResponse: implementations MAY send batches but MUST support receiving them, on stdio and as HTTP POST bodies. 2025-06-18 removes batching from schema and prose. - Where. 2025-03-26 only.
- mcpx @ 05c78b2. mcpx lists 2025-03-26 in
Supported(internal/mcpserver/server.go:778) but accepts no arrays: stdio unmarshals into a single request and answers-32700with no id (internal/mcpserver/server.go:1040-1044; wire S3); HTTP answers 400 with-32700(internal/mcpserver/server.go:1153-1157; wire W16). The client splits batch arrays on HTTP (internal/mcpclient/http.go:197-210); on stdio it passes them up andrecvLoopdrops them because they do not decode into a struct (internal/mcpclient/client.go:356-368). - Value to mcpx. + low: honest 2025-03-26 support requires it; few clients batch.
- Effort. M — split arrays, answer with an array of responses, omit notifications; or drop 2025-03-26 from
Supported, which is honest but loses clients. - Risk. A 2025-03-26 client that batches gets a parse error from a server that claimed 2025-03-26.
- Detail. 2025-03-26 HTTP: a POST of only notifications or responses gets 202; one containing requests gets JSON or
SSE; SSE responses may themselves be batched;
initializemust not be batched (lifecycle-versioning.mdLV-12). - Sources.
schema/2025-03-26/schema.ts:17“export type JSONRPCBatchRequest = (JSONRPCRequest | JSONRPCNotification)[];”;2025-03-26/basic/index.mdx:97“batches, but MUST support receiving JSON-RPC batches.”;2025-06-18/changelog.mdx:12;internal/mcpserver/server.go:1041-1044; wire W16, S3.
TR-10 HTTP+SSE transport (2024-11-05)
- What. The 2024-11-05 HTTP transport: the client opens an SSE endpoint, the server MUST first send an
endpointevent naming the POST URI, the client POSTs every message there, and server messages arrive as SSEmessageevents. Streamable HTTP replaced it in 2025-03-26. - Where. Native in 2024-11-05; later revisions describe it only as a compatibility fallback, and the 2026 deprecation registry lists it with an earliest removal of three months after SEP-2596 reaches Final, shorter than the twelve-month minimum stated for everything else. opencode v1 falls back to it for remote servers; v2 does not.
- mcpx @ 05c78b2. Not served. The client speaks Streamable HTTP to every
url:server:transportdefaults tohttpand is not consulted for the wire shape, sotransport: "sse"is silently treated as Streamable HTTP (internal/config/config.go:706-708;internal/pool/pool.go:368-381). The SSE reader handles onlydata:lines and would not recognise anendpointevent (internal/mcpclient/http.go:180-192). - Value to mcpx. + med: some hosted servers still speak only HTTP+SSE, and the client fallback is cheap. − serving it is not worth it.
- Effort. M for the client fallback; L to serve it.
- Risk. mcpx cannot reach 2024-11-05-only remote servers, and a config asking for SSE is quietly ignored.
- Detail. The client fallback procedure is TR-11.
- Sources.
2024-11-05/basic/transports.mdx:61“The server MUST provide two endpoints:”;2024-11-05/basic/transports.mdx:67;2026-07-28/deprecated.mdx:31;2026-07-28/changelog.mdx:110;internal/pool/pool.go:368“if p.cfg.Stdio() {”;v1:packages/opencode/src/mcp/index.ts:278.
TR-11 Client fallback from Streamable HTTP to HTTP+SSE
- What. 2025-03-26: POST an
InitializeRequest; if it fails with any 4xx, GET the URL and expect anendpointevent. 2025-11-25: only on 400, 404 or 405. 2026-07-28: POST a modern request; fall back only on 400, 404 or 405 whose body is not a recognised modern JSON-RPC error, otherwise stay modern. - Where. Each of 2025-03-26, 2025-11-25 and 2026-07-28 words it differently. opencode v1 tries Streamable HTTP, then SSE, stopping on authentication errors; opencode v2 has no fallback.
- mcpx @ 05c78b2. No fallback (TR-10;
internal/pool/pool.go:368-381). - Value to mcpx. + med: a dual-era client should implement the 2026 version, the strictest.
- Effort. M.
- Risk. A client following the 2025-03-26 wording classifies a modern server’s 400
-32022as HTTP+SSE. - Detail. 401 is a 4xx, so the literal 2025-03-26 rule makes authentication failures look like an old transport.
As a server, mcpx’s 200-status modern errors and non-JSON-RPC 400 bodies (
errors.mdERR-07, ERR-13) make a 2026 client’s classification of mcpx less reliable. - Sources.
2025-03-26/basic/transports.mdx:271“- If it fails with an HTTP 4xx status code”;2025-11-25/basic/transports.mdx:303;2026-07-28/basic/transports/streamable-http.mdx:729;2026-07-28/basic/transports/streamable-http.mdx:724-735;v1:packages/opencode/src/mcp/index.ts:269.
TR-12 Streamable HTTP methods: POST+GET → POST only
- What. Legacy Streamable HTTP MUST expose one endpoint supporting POST and GET, optionally DELETE. 2026 requires only POST.
- Where. POST and GET in 2025-03-26..2025-11-25; POST in 2026-07-28.
- mcpx @ 05c78b2. POST handled, GET answered 405, DELETE answered 204 after dropping the session
(
internal/mcpserver/server.go:1119-1131). - Value to mcpx. + low: legacy allows “SSE or 405” on GET, and 405 is the 2026 SHOULD.
- Effort. S — done.
- Risk. None.
- Detail. For DELETE, 2026 says a modern-only server SHOULD answer 405; mcpx’s 204 is right for its legacy sessions (TR-31).
- Sources.
2025-03-26/basic/transports.mdx:69“MCP endpoint) that supports both POST and GET methods.”;2026-07-28/basic/transports/streamable-http.mdx:47;internal/mcpserver/server.go:1119-1131.
TR-13 Standalone GET SSE stream: server side
- What. 2025-03-26..2025-11-25: a client MAY GET the endpoint to open an SSE stream for unsolicited server requests
and notifications; the server returns SSE or 405 and MUST NOT send responses there unless resuming. 2026 removes the
GET stream; long-lived notifications use
subscriptions/listenover POST, and GET SHOULD get 405. - Where. 2025-03-26..2025-11-25; 405 in 2026-07-28.
- mcpx @ 05c78b2. 405 with
Allow: POST, DELETEand a text/plain body “mcpx sends no unsolicited messages; POST a request” (internal/mcpserver/server.go:1124-1131; wire W27). - Value to mcpx. + low: correct in both eras.
- Effort. S — done.
- Risk. Legacy HTTP hosts never hear a
list_changedfrom mcpx; the capability is truthfully declared false over HTTP (capabilities register). - Detail. The 405 body is not JSON-RPC, which is fine for a GET.
- Sources.
2025-03-26/basic/transports.mdx:134;2025-11-25/basic/transports.mdx:141“or else return HTTP 405 Method Not Allowed”;2026-07-28/basic/transports/streamable-http.mdx:683;2026-07-28/changelog.mdx:18;internal/mcpserver/server.go:1129; wire W27.
TR-14 Standalone GET SSE stream: client side
- What. A legacy Streamable HTTP server sends
list_changed,resources/updatedand server requests outside any POST on the GET stream; a client that never opens it never receives them. - Where. 2025-03-26..2025-11-25 (the client MAY open it); replaced in 2026 by
subscriptions/listen. - mcpx @ 05c78b2.
HTTPTransportonly POSTs and DELETEs (internal/mcpclient/http.go:97-162,:235-254). - Value to mcpx. + med: cache invalidation and resource updates from remote legacy servers.
- Effort. M.
- Risk. Remote servers’ tool lists go stale until
mcpx refresh. - Detail. stdio upstreams deliver the same messages on the pipe. For 2026 upstreams the equivalent is a listen request, which the client also never opens (notifications register).
- Sources.
2025-11-25/basic/transports.mdx:135-141;internal/mcpclient/http.go:115.
TR-15 Accept: application/json, text/event-stream on POST
- What. The client MUST send an
Acceptheader listing bothapplication/jsonandtext/event-streamon every POST (andtext/event-streamon the legacy GET). - Where. 2025-03-26..2026-07-28.
- mcpx @ 05c78b2. The client sends it on every POST (
internal/mcpclient/http.go:80). The server never readsAcceptand answers requests that omit it, an accept-liberally choice. - Value to mcpx. + low.
- Effort. S — done.
- Risk. None.
- Detail. None.
- Sources.
2025-03-26/basic/transports.mdx:88;2026-07-28/basic/transports/streamable-http.mdx:76“2. The client MUST include anAcceptheader listing both”;internal/mcpclient/http.go:80.
TR-16 Request answered with JSON or SSE, chosen per request
- What. For a POSTed request the server MUST answer with
Content-Type: application/json(one object) ortext/event-stream(an SSE stream); clients MUST handle both. - Where. 2025-03-26..2026-07-28.
- mcpx @ 05c78b2. Plain JSON unless mcpx has to send the client a request mid-call (legacy elicitation or
sampling), in which case the response turns into SSE lazily (
internal/mcpserver/server.go:1299-1324; wire L2). Frames carry onlydata:lines, withCache-Control: no-cache(internal/mcpserver/server.go:1310,:1319). Without anhttp.Flusherthe stream cannot start,ErrNoPushis returned and the question stays with the broker (internal/mcpserver/server.go:1302-1307). The client handles both content types. - Value to mcpx. + med: plain calls pay nothing for streaming.
- Effort. S — done.
- Risk. No progress or log notification ever rides the stream (progress and logging registers).
- Detail. None.
- Sources.
2026-07-28/basic/transports/streamable-http.mdx:88-90;internal/mcpserver/server.go:1311;internal/mcpserver/server.go:1319; wire L2.
TR-17 202 Accepted for notifications (and legacy responses)
- What. Legacy: a POST carrying only responses or notifications gets 202 with no body, or an HTTP error whose body MAY be an id-less JSON-RPC error. 2026: only notifications get 202, clients MUST NOT POST responses, and 2026 defines no client-to-server notification for HTTP at all (cancellation is closing the stream).
- Where. 2025-03-26..2025-11-25; narrowed in 2026-07-28.
- mcpx @ 05c78b2. 202 for a delivered client response (
internal/mcpserver/server.go:1142-1150) and for known notifications (internal/mcpserver/server.go:1188-1190; wire W26). An unrecognised notification is answered with a-32601error and status 200 (notifications register). The client treats 202 and 204 as acknowledged (internal/mcpclient/http.go:132). - Value to mcpx. + low.
- Effort. S.
- Risk. Low.
- Detail. 2026 leaves header requirements for notification POSTs undefined, so a dual-era server should not reject a header-less notification.
- Sources.
2025-03-26/basic/transports.mdx:98“- If the server accepts the input, the server MUST return HTTP status code 202”;2026-07-28/basic/transports/streamable-http.mdx:95“This revision of the core protocol defines no client-to-server”;2026-07-28/basic/transports/streamable-http.mdx:102;internal/mcpserver/server.go:1189; wire W26.
TR-18 Client responses POSTed back to the server
- What. In legacy Streamable HTTP, a client answers a server request (elicitation, sampling, roots) by POSTing the JSON-RPC response. 2026 forbids clients sending responses at all.
- Where. 2025-03-26..2025-11-25.
- mcpx @ 05c78b2. A response POSTed with the session header is routed to the request waiting on that session and
acknowledged 202 (
internal/mcpserver/server.go:1142-1151; wire L3). With no waiter the client gets 400 and a non-JSON-RPC body (internal/mcpserver/server.go:1146).replyOfparses the id as*int64(internal/mcpserver/conn.go:306-320), which is enough because mcpx picks its own negative integer ids. - Value to mcpx. + med: legacy elicitation over HTTP works end to end.
- Effort. S — done.
- Risk. None for mcpx’s own ids.
- Detail. None.
- Sources.
2026-07-28/basic/transports/streamable-http.mdx:81“The client MUST NOT send JSON-RPC responses.”;internal/mcpserver/server.go:1149; wire L3.
TR-19 Server requests on a request’s SSE stream
- What. Legacy servers MAY send JSON-RPC requests and notifications on a POST’s SSE stream before the response, and those notifications SHOULD relate to the originating request. 2026: servers MUST NOT send requests on any stream; notifications MUST relate to the originating request; the response SHOULD end the stream.
- Where. 2025-03-26..2025-11-25; replaced by MRTR in 2026-07-28.
- mcpx @ 05c78b2. Legacy: elicitation goes out on the POST’s own SSE stream and the answer comes back by separate
POST (
docs/protocol.md:183; wire L1–L3); the stream carries the result and then ends (internal/mcpserver/server.go:1179-1186). Modern:input_required. - Value to mcpx. + med: done per era.
- Effort. S — done.
- Risk. None.
- Detail. The legacy wording is SHOULD for “relate”, 2026’s is MUST.
- Sources.
2025-03-26/basic/transports.mdx:111“- The server MAY send JSON-RPC requests and notifications before sending a”;2026-07-28/basic/transports/streamable-http.mdx:117“- The server MUST NOT send independent JSON-RPC requests on this stream.”;docs/protocol.md:183; wire L2.
TR-20 Each message on exactly one stream (no broadcast)
- What. Legacy: a client MAY hold several SSE streams, and the server MUST send each message on exactly one of them. 2026: every request has its own stream; listen notifications go only on the matching listen stream, and request-scoped notifications never go on a listen stream.
- Where. 2025-03-26..2025-11-25; per-subscription filtering in 2026-07-28.
- mcpx @ 05c78b2. mcpx opens no GET streams (
internal/mcpserver/server.go:1124-1131), so its only streams are per-request ones. - Value to mcpx. + low.
- Effort. S — nothing to do.
- Risk. None.
- Detail. 2026’s per-subscription filter replaces “any unsolicited message on any GET stream”.
- Sources.
2025-03-26/basic/transports.mdx:153“streams; that is, it MUST NOT broadcast the same message across multiple streams.”;2026-07-28/basic/transports/streamable-http.mdx:132.
TR-21 SSE resumability via event ids and Last-Event-ID
- What. 2025-03-26: servers MAY attach SSE
ids (unique per session and client); clients SHOULD resume a broken stream with GET andLast-Event-ID; servers MAY replay only that stream’s messages. 2025-11-25: ids SHOULD encode the originating stream, and resumption is always by GET however the stream began. 2026: not supported; a broken stream loses the request and the client MUST re-issue it with a new id; modern-only servers SHOULD ignoreLast-Event-ID. - Where. 2025-03-26..2025-11-25.
- mcpx @ 05c78b2. The server writes no
id:field (internal/mcpserver/server.go:1319); the client reads onlydata:lines and never sendsLast-Event-ID(internal/mcpclient/http.go:180-192). TheLast-Event-IDhandling ininternal/daemon/server.go:807-823is mcpx’s own/v1event feed, not MCP. - Value to mcpx. + low: clients re-issue anyway, and replay needs server-side buffering.
- Effort. M.
- Risk. None; every part is MAY or SHOULD.
- Detail. Whether a dropped stream means the client cancelled (legacy: SHOULD NOT be read as cancellation; 2026: MUST be) is in the progress/cancellation register.
- Sources.
2025-03-26/basic/transports.mdx:162“1. Servers MAY attach anidfield to their SSE events”;2025-11-25/basic/transports.mdx:186;2026-07-28/basic/transports/streamable-http.mdx:157“Resumable SSE streams viaLast-Event-IDare not supported.”;2026-07-28/changelog.mdx:28;internal/mcpserver/server.go:1319.
TR-22 SSE polling: priming event, retry, reconnect
- What. 2025-11-25: when a server opens an SSE stream it SHOULD immediately send an event with an id and empty
data(priming); it MAY then close the connection without ending the stream, SHOULD sendretryfirst, and the client MUST honourretryand reconnect with GET andLast-Event-ID. - Where. 2025-11-25 only (SEP-1699).
- mcpx @ 05c78b2. The client harmlessly skips an empty priming event (
internal/mcpclient/http.go:174) but ignoresretry:andid:and does not reconnect when the server closes the connection (internal/mcpclient/http.go:180-192). - Value to mcpx. + low: only matters for upstreams that opt into polling.
- Effort. M.
- Risk. A 2025-11-25 server in polling mode looks as if it dropped mcpx’s request; the result is lost.
- Detail. None.
- Sources.
2025-11-25/basic/transports.mdx:107“ID and an emptydatafield in order to prime the client to reconnect”;2025-11-25/basic/transports.mdx:115“closing the connection. The client MUST respect theretryfield,”;internal/mcpclient/http.go:174.
TR-23 X-Accel-Buffering: no on SSE responses
- What. When starting an SSE response, servers SHOULD send
X-Accel-Buffering: noso reverse proxies do not buffer the stream. - Where. 2026-07-28.
- mcpx @ 05c78b2. Set on every SSE response (
internal/mcpserver/server.go:1311; wire L2). - Value to mcpx. + low.
- Effort. S — done.
- Risk. None.
- Detail. None.
- Sources.
2026-07-28/basic/transports/streamable-http.mdx:136“When initiating an SSE stream, servers SHOULD include the”;internal/mcpserver/server.go:1311.
TR-24 SSE comment lines as keep-alive
- What. Long-lived streams are encouraged to emit SSE comment lines (starting with
:) as keep-alive; clients MUST NOT treat them as malformed. 2026 recommends this in place ofping. - Where. 2026-07-28.
- mcpx @ 05c78b2. The client skips comment lines (
internal/mcpclient/http.go:183-184). The server sends none, including while a legacy call waits on an elicitation answer. - Value to mcpx. + low.
- Effort. S.
- Risk. A proxy with an idle timeout between host and mcpx could cut a long elicitation wait.
- Detail. None.
- Sources.
2026-07-28/basic/transports/streamable-http.mdx:148“encouraged to periodically emit an SSE comment line (a line beginning with a”;internal/mcpclient/http.go:184.
TR-25 Mcp-Session-Id value and assignment rules
- What. A legacy server MAY assign a session id in the HTTP response carrying
InitializeResult. It SHOULD be globally unique and cryptographically secure, MUST contain only visible ASCII (0x21–0x7E), and the client MUST echo it on every later request; a server that requires sessions SHOULD answer 400 to a request without one. 2025-11-25 spells itMCP-Session-Id(header names are case-insensitive). - Where. 2025-03-26..2025-11-25; removed in 2026-07-28.
- mcpx @ 05c78b2.
sess-plus 32 hex characters fromcrypto/rand(internal/mcpserver/server.go:1278-1284). A request without a session is served statelessly rather than refused (internal/mcpserver/server.go:1214-1218). Idle sessions expire afterproto.sessionIdle(30 min), but reaping runs only when another session is issued (internal/mcpserver/server.go:1226,:1265-1276). - Value to mcpx. + low.
- Effort. S — done.
- Risk. Low.
- Detail. If
crypto/randfails,newSessionIDreturns the fixed idsess-0(internal/mcpserver/server.go:1281), so every session created during the failure would share one id. - Sources.
2025-03-26/basic/transports.mdx:187“initialization time, by including it in anMcp-Session-Idheader on the HTTP”;2025-03-26/basic/transports.mdx:189;2025-03-26/basic/transports.mdx:191;2025-03-26/basic/transports.mdx:197;2025-11-25/basic/transports.mdx:199;internal/mcpserver/server.go:1283.
TR-26 Mcp-Session-Id minted on server/discover
- What. 2026 has no sessions: a modern-only server SHOULD ignore an incoming
Mcp-Session-Idand neither mint nor echo one. - Where. 2026-07-28.
- mcpx @ 05c78b2.
sessionFormints a session forinitializeand forserver/discover(internal/mcpserver/server.go:1214,:1220-1229), so every modern discover over HTTP gets one (wire W5, W9, M2).docs/protocol.md:181-182documents this as intended: it is the only way mcpx can bind arequestStatefor a modern HTTP client (elicitation and MRTR registers). - Value to mcpx. + med: conformance. − removing it without another binding breaks MRTR for the clients that do keep the header.
- Effort. M — bind
requestStateto something the request carries. - Risk. A strict 2026 client ignores the header and so gets no working MRTR anyway; the session map grows by one per discover probe; a gateway that sees the header may start sticky routing.
- Detail. SEP-2567 names the residual problem for gateways that bridge HTTP to stdio by spawning one subprocess per session: they need another correlation key (authenticated principal, cookie). The process-model register covers how mcpx keys pooled instances.
- Sources.
2026-07-28/basic/transports/streamable-http.mdx:685“- AnMcp-Session-Idheader on a request: ignore it, and do not mint or echo”;2026-07-28/changelog.mdx:12;internal/mcpserver/server.go:1214“if req.Method != "initialize" && req.Method != "server/discover" {”;docs/protocol.md:181;seps/2567-sessionless-mcp.md:243; wire W5.
TR-27 Mcp-Session-Id minted on a failed initialize
- What. The legacy session id is assigned “at initialization time” in the response carrying the
InitializeResult; a refused handshake has no result to attach it to. - Where. 2025-03-26..2025-11-25.
- mcpx @ 05c78b2.
sessionForruns before dispatch (internal/mcpserver/server.go:1170-1173), so refused handshakes (2024-11-05, 2026-07-28, 9999-01-01) still get a session header (wire W1, W3, W4). - Value to mcpx. + low: no phantom sessions.
- Effort. S — issue after a successful result.
- Risk. Each failed probe adds a map entry that is reaped only after 30 min and only when another session is issued.
- Detail. Sending a session for a handshake that never happened is a send-conservatively conflict.
- Sources.
2025-11-25/basic/transports.mdx:196-200;internal/mcpserver/server.go:1170“c, issued := s.sessionFor(r, req)”; wire W1.
TR-28 Unknown or expired session id → 404
- What. A legacy server that has terminated a session MUST answer requests carrying that id with 404, which tells
the client to start a new session with
initialize. - Where. 2025-03-26..2025-11-25. 2026 servers ignore the header.
- mcpx @ 05c78b2. An id mcpx does not hold (deleted, reaped or invented) falls through to a fresh stateless
connection (
internal/mcpserver/server.go:1209-1219). Wire L4: a deleted session id got 200 and a 2025-03-26-shaped result. - Value to mcpx. + med: clients recover after a daemon restart instead of silently degrading.
- Effort. S.
- Risk. After a daemon restart every legacy HTTP host drops to 2025-03-26 shapes and loses elicitation, with no error to notice; opencode’s recovery (TR-29) is triggered only by the 404 mcpx never sends.
- Detail. This is also why a stateless legacy client is served as 2025-03-26 (TR-35).
- Sources.
2025-11-25/basic/transports.mdx:211-215;2025-03-26/basic/transports.mdx:201;internal/mcpserver/server.go:1218“return s.newConn("", nil), ""”; wire L4.
TR-29 Client re-initializes after a session 404
- What. A legacy client that gets 404 for a request carrying a session id MUST start a new session with a fresh
initialize. - Where. 2025-03-26..2025-11-25. opencode v1 (through its SDK patch) re-initializes transparently; opencode v2 treats a 404, or a 400 “Bad Request: Server not initialized”, on a session-carrying connection as an expired session, reconnects and retries once, and applies this only to legacy connections.
- mcpx (as built, #1637 / PR #1642). The recovery lives in the client layer, not the pool:
Client.roundTrip(internal/mcpclient/client.go) catches the narrow trigger — a session-carrying legacy-era request (neverinitializeitself) answered HTTP 404 — and recovers exactly once. The dead session is forgotten through the transport seamforgetSession()(internal/mcpclient/http.go:669-676; HTTP transports only, stdio has no session to forget), the client re-initializes with no session id attached, and the request is retried exactly once on the new session. The re-initialize is singleflight:reinitMuplus the recovery generation (reinitGen) mean N concurrent 404s from one server restart produce one initialize, and the other calls retry on the session it installs. After a successful re-initialize the tools, resources and prompts list-changed invalidations fire, because a restarted upstream’s catalogue is not the cached one. Everything outside the trigger surfaces as before: a 404 with no session held, a modern-era 404, and a second 404 after re-initialize are ordinary errors. Covered byTestLegacySession404Recovery(internal/mcpclient/reinit_test.go:96); the conformance gapstreamable-http-client-reinit-on-404was retired by PR #1642. - Value to mcpx. + med: transparent recovery after a remote server restarts.
- Effort. M — landed as client-layer recovery in
roundTrip(singleflight re-initialize), not a pool reconnect. - Risk. Low: the trigger is deliberately narrow (legacy era, HTTP, session-carrying, first 404 only); anything outside it surfaces as an error, as before.
- Detail. This entry is mcpx as a client of other servers; mcpx’s own server side never sending the 404 is TR-28’s subject.
- Sources.
2025-11-25/basic/transports.mdx:213-215“it MUST start a new session”;internal/mcpclient/http.go:139;v1:patches/@modelcontextprotocol%2Fsdk@1.29.0.patch:210;v2:packages/core/src/mcp/client.ts:173;v2:packages/core/src/mcp/index.ts:336;internal/mcpclient/client.go(roundTrip,sessionGone,reinitializeLegacy);internal/mcpclient/reinit_test.go:96;internal/conformance/coverage_transport.go:19.
TR-30 Client sends a session id to modern servers
- What. 2026 has no sessions, so a modern client has no session id to send.
- Where. 2026-07-28.
- mcpx @ 05c78b2. The client captures an
Mcp-Session-Idfrom any response (internal/mcpclient/http.go:126-130) and sends it on every later request, modern ones included (internal/mcpclient/http.go:89-93). - Value to mcpx. + low.
- Effort. S — capture only on a legacy
initialize. - Risk. Low; a modern server should ignore it.
- Detail. A modern server that wrongly mints one (as mcpx’s own server does, TR-26) gets it echoed back.
- Sources.
2026-07-28/basic/transports/streamable-http.mdx:685;internal/mcpclient/http.go:89-93.
TR-31 DELETE handled by the server
- What. Legacy clients SHOULD DELETE the endpoint with their session id to end a session; the server MAY answer 405. 2026 modern-only servers SHOULD answer DELETE with 405.
- Where. 2025-03-26..2025-11-25.
- mcpx @ 05c78b2. Drops the named session and stops its listener; an unknown id still gets 204
(
internal/mcpserver/server.go:1119-1123,:1247-1258; wire W28). After a DELETE the same id is served statelessly (TR-28). - Value to mcpx. + low: right for a dual-era server.
- Effort. S — done.
- Risk. None.
- Detail. None.
- Sources.
2025-03-26/basic/transports.mdx:204“the client application) SHOULD send an HTTP DELETE to the MCP endpoint with the”;2026-07-28/basic/transports/streamable-http.mdx:683;internal/mcpserver/server.go:1120; wire W28.
TR-32 DELETE sent by the client on close
- What. The client side of TR-31.
- Where. 2025-03-26..2025-11-25.
- mcpx @ 05c78b2. On close, if a session id is held, the client sends DELETE with a 5 s timeout
(
internal/mcpclient/http.go:236-247). - Value to mcpx. + low.
- Effort. S — done.
- Risk. None.
- Detail. The 5 s is an inline constant. Because of TR-30, a modern server that minted an id would also be sent a DELETE.
- Sources.
2025-03-26/basic/transports.mdx:204;internal/mcpclient/http.go:236-247.
TR-33 MCP-Protocol-Version header requirement timeline
- What. 2025-03-26: clients SHOULD send it during OAuth server-metadata discovery only. 2025-06-18 and 2025-11-25:
clients MUST send it on every request after
initialize; if it is absent the server SHOULD assume 2025-03-26; an invalid or unsupported value gets 400. 2026: every POST MUST carry it and it MUST equal_metaprotocolVersion, else 400 and-32020; a server supporting pre-2025-06-18 clients MAY treat absence as 2025-03-26, otherwise MUST reject. - Where. 2025-03-26 (auth only); 2025-06-18..2026-07-28.
- mcpx @ 05c78b2. The client sends it on every POST, following the frame’s
_metaversion or else 2025-11-25 (internal/mcpclient/http.go:78-85; TR-34). The server compares it only with_metawhen both are present (internal/mcpserver/server.go:1159-1168;errors.mdERR-07), never uses it as a version (TR-35) and never validates a legacy value (TR-36). - Value to mcpx. + med: needed for modern interop and by intermediaries.
- Effort. S.
- Risk. See TR-34..TR-36.
- Detail. A legacy
initializecarries no header (only “subsequent requests” must), so a 2025-06-18 server cannot demand it oninitialize. 2026 says versions before 2025-06-18 “did not define” the header, which contradicts 2025-03-26’s authorization section (whose example value is2024-11-05). - Sources.
2025-03-26/basic/authorization.mdx:135“MCP clients SHOULD include the headerMCP-Protocol-Version: <protocol-version>during”;2025-06-18/basic/transports.mdx:242;2025-06-18/basic/transports.mdx:256;2026-07-28/basic/transports/streamable-http.mdx:252“Every POST request to the MCP endpoint MUST include an”;2026-07-28/basic/transports/streamable-http.mdx:277;internal/mcpclient/http.go:85.
TR-34 Client header value after a legacy handshake
- What. After a legacy handshake the header SHOULD be the version negotiated in
initialize, and a server MUST reject an unsupported value with 400. - Where. 2025-06-18, 2025-11-25.
- mcpx @ 05c78b2.
setHeadersForuses the 2025-11-25 constant unless the frame carries_meta(internal/mcpclient/http.go:81-85);Negotiatedis never consulted. The header is also sent oninitializeitself, which the spec does not require. - Value to mcpx. + med: remote servers pinned to 2025-06-18 become reachable after the handshake.
- Effort. S.
- Risk. Every request after
initializefails against a server that negotiated 2025-06-18 or 2025-03-26 and validates the header. - Detail. Sending a version the server did not agree to is a send-conservatively conflict.
- Sources.
2025-11-25/basic/transports.mdx:270-271;internal/mcpclient/http.go:81“version := ProtocolVersion”.
TR-35 Server uses the legacy header as the version
- What. From 2025-06-18 the header carries the negotiated version on every legacy HTTP request; the server should assume 2025-03-26 only when it has no other way to identify the version.
- Where. 2025-06-18, 2025-11-25.
- mcpx @ 05c78b2.
peerForlooks at_meta, then the handshake stored on the connection, thenOldest(internal/mcpserver/conn.go:133-147); the header is only compared with_meta(internal/mcpserver/server.go:1161-1168). A legacy client that stays stateless (never echoes the session id) but sendsMCP-Protocol-Version: 2025-11-25is served as 2025-03-26. Wire L4: a reused session id with header 2025-06-18 gotstructuredContentflattened to text. - Value to mcpx. + med: correct shapes for stateless legacy clients.
- Effort. S.
- Risk. Such clients silently lose
structuredContentandresource_link. - Detail. Guessing downward is recoverable, which is
docs/protocol.md’s argument for the floor, but the header is the one signal the spec provides for this case. Once it is used, TR-36 matters. - Sources.
2025-11-25/basic/transports.mdx:274-277;internal/mcpserver/conn.go:139-145; wire L4.
TR-36 Invalid legacy header value → 400
- What. A legacy request with an invalid or unsupported
MCP-Protocol-VersionMUST get 400. - Where. 2025-06-18, 2025-11-25.
- mcpx @ 05c78b2. The header is checked only when
_metacarries a version (internal/mcpserver/server.go:1161). Wire W14:MCP-Protocol-Version: 1999-01-01, no_meta, no session, served with 200. - Value to mcpx. + low.
- Effort. S.
- Risk. Low.
- Detail. None.
- Sources.
2025-11-25/basic/transports.mdx:279-280“MUST respond with400 Bad Request”;internal/mcpserver/server.go:1161; wire W14.
TR-37 Mcp-Method header (2026)
- What. Every 2026 Streamable HTTP request MUST carry
Mcp-Method: <method>mirroring the body; a missing or mismatched value gets 400 and-32020(errors.mdERR-07). The headers are “REQUIRED for compliance”. - Where. 2026-07-28 (SEP-2243).
- mcpx @ 05c78b2. The client never sends it (
internal/mcpclient/http.go:78-94). The server never reads it: wire W11 sentMcp-Method: prompts/listwith atools/listbody and got the tool list with 200. - Value to mcpx. + high: without it a conformant 2026 HTTP server rejects every modern request from mcpx; as a server, a policy proxy in front of mcpx that routes on the header can be fooled.
- Effort. S.
- Risk. High: the modern HTTP upstream path is closed, together with
lifecycle-versioning.mdLV-26. - Detail. Header names are case-insensitive; values are case-sensitive. Validate only modern-era requests, since
legacy ones carry no such header. 2026 leaves header requirements for notification POSTs undefined. A
-32020body does not contain-32022, so the mcpx client then falls back to legacy, which a modern-only server also rejects. - Sources.
2026-07-28/basic/transports/streamable-http.mdx:290“|Mcp-Method|method| All requests |”;2026-07-28/basic/transports/streamable-http.mdx:293“These headers are REQUIRED for compliance.”;internal/mcpclient/http.go:78-94; wire W11.
TR-38 Mcp-Name header (2026)
- What.
Mcp-Namemirrorsparams.namefortools/callandprompts/getandparams.uriforresources/read. Values that are not safely ASCII MUST use the sentinel form=?base64?…?=, and plain values that happen to look like the sentinel MUST be encoded too; servers MUST decode before comparing. - Where. 2026-07-28.
- mcpx @ 05c78b2. Neither sent by the client nor checked by the server (
internal/mcpclient/http.go:78-94). - Value to mcpx. + high: required for modern HTTP interop; also lets mcpx, as an intermediary, route or rate-limit by tool without parsing the body.
- Effort. S, including the sentinel encoding.
- Risk. As TR-37.
- Detail. The tasks extension also requires
Mcp-Nameto carryparams.taskIdon its task methods (tasks register). - Sources.
2026-07-28/basic/transports/streamable-http.mdx:291;2026-07-28/basic/transports/streamable-http.mdx:506“To avoid ambiguity, clients MUST also Base64-encode any plain-ASCII”;seps/2663-tasks-extension.md:515.
TR-39 Mirror x-mcp-header arguments into Mcp-Param-{Name}
- What. A server MAY annotate primitive
inputSchemaproperties withx-mcp-header: <Name>; HTTP clients MUST then send the argument’s value asMcp-Param-{Name}. An absent or null argument means no header; on-32020the client SHOULD re-list tools and retry; stdio clients MAY ignore the annotation. - Where. 2026-07-28.
- mcpx @ 05c78b2. No
x-mcp-headerorMcp-Paramanywhere ininternal/(internal/mcpclient/http.go:78-94sets no such header). - Value to mcpx. + med: calls to annotated tools on 2026 HTTP upstreams fail
-32020without it. - Effort. M — schema walk, value encoding, header emission.
- Risk. Annotated tools are uncallable through mcpx; intermediaries misroute.
- Detail. Values use the same sentinel encoding as
Mcp-Name; servers SHOULD NOT mark secrets for mirroring. - Sources.
2026-07-28/basic/transports/streamable-http.mdx:364“While the use ofx-mcp-headeris optional for servers, clients MUST”;2026-07-28/basic/transports/streamable-http.mdx:374;2026-07-28/basic/transports/streamable-http.mdx:408.
TR-40 Intermediaries and mirrored headers
- What. Intermediaries that do not recognise an
Mcp-Param-{Name}header MUST forward it; intermediaries that route or rate-limit on mirrored headers SHOULD reject requests whoseMCP-Protocol-Versionis absent or older than 2026-07-28, because older versions never required header and body to agree. - Where. 2026-07-28.
- mcpx @ 05c78b2. mcpx terminates and re-originates MCP rather than forwarding HTTP, so this applies only if it
ever makes policy decisions on headers (
internal/daemon/server.go:488). - Value to mcpx. + low: guidance for any future header-based policy.
- Effort. S.
- Risk. Trusting unvalidated legacy headers for policy.
- Detail. None.
- Sources.
2026-07-28/basic/transports/streamable-http.mdx:548“Intermediate servers that do not recognize anMcp-Param-{Name}header”;2026-07-28/basic/transports/streamable-http.mdx:644.
TR-41 Origin validation against DNS rebinding
- What. Servers MUST validate the
Originheader on all incoming connections to prevent DNS rebinding; from 2025-11-25, a present and invalidOrigingets 403 (body MAY be an id-less JSON-RPC error). Requests withoutOrigin(non-browser clients) are unaffected. - Where. Every HTTP transport in every revision. lootbox’s
/wsendpoint on fixed port 9420 accepts WebSocket upgrades with noOrigincheck, so any web page can send it a script (cross-site WebSocket hijacking). - mcpx @ 05c78b2. No
Origincheck anywhere on/mcp, which is mounted on the loopback TCP listener as well as the Unix socket (internal/daemon/server.go:483-488); the only middleware records activity (internal/daemon/server.go:290-293,:344-353). Wire W15:Origin: https://evil.example→ 200. The same gap covers/v1, where/v1/execdecodes JSON whatever theContent-Type(internal/daemon/routes_exec.go:212-213), so a cross-origintext/plain“simple” request runs a script without a preflight. The TCP port defaults to ephemeral (daemon.port0,internal/settings/registry.go:343), which raises the attacker’s cost to a port scan. - Value to mcpx. + high: mcpx runs tools and scripts as the user, and loopback is exactly what DNS rebinding reaches.
- Effort. S — reject a present
Originthat is not on an allowlist (default none, or loopback), requireContent-Type: application/jsonon POSTs, optionally checkHost. - Risk. High: a malicious page plus a found port is remote code execution on the workstation.
- Detail. Unix-socket callers send no
Originand are unaffected. Authentication of/mcpand/v1is in the auth register. - Sources.
2024-11-05/basic/transports.mdx:55“1. Servers MUST validate theOriginheader on all incoming connections to prevent DNS rebinding attacks”;2025-11-25/basic/transports.mdx:79“- If theOriginheader is present and invalid, servers MUST respond with HTTP 403 Forbidden.”;2026-07-28/basic/transports/streamable-http.mdx:58;internal/daemon/server.go:488;internal/daemon/routes_exec.go:212-213;.lootbox/src/lib/rpc/websocket_server.ts:282-290; wire W15.
TR-42 Bind to localhost when running locally
- What. When running locally, servers SHOULD bind only to 127.0.0.1 rather than all interfaces.
- Where. Every HTTP transport in every revision.
- mcpx @ 05c78b2. The TCP listener binds loopback unless
daemon.addresssays otherwise; the comment calls binding wider “somebody’s decision rather than a default” (internal/daemon/server.go:206-213). - Value to mcpx. + med: done.
- Effort. S — done.
- Risk. None, but it does not replace TR-41: DNS rebinding reaches loopback.
- Detail. None.
- Sources.
2024-11-05/basic/transports.mdx:56“2. When running locally, servers SHOULD bind only to localhost (127.0.0.1)”;2025-11-25/basic/transports.mdx:81;2026-07-28/basic/transports/streamable-http.mdx:63;internal/daemon/server.go:206-213.
TR-43 /mcp mounted on the daemon’s own listeners
- What. MCP over HTTP is served by the daemon at
proto.mcpPath(default/mcp) on both its Unix socket and its loopback TCP port;mcpx serve --transport httpnow fails and names the address to use. - Where. mcpx design.
- mcpx @ 05c78b2.
internal/cli/root.go:190-195(proto.serveMCP, a lazily built handler);internal/daemon/server.go:483-489;internal/cli/serve.go:373-377;internal/defaults/defaults.json:77. - Value to mcpx. + med: one listener and one access decision, so
/mcpand/v1cannot drift apart. - Effort. S — done.
- Risk. The listener is unauthenticated and does not check
Origin(TR-41). - Detail. Every
/mcpcall goes back to the same daemon over its own/v1socket, and the MCP surface is built once on first use; both are in the process-model register. - Sources.
internal/daemon/server.go:488“mux.Handle(path, s.MCP)”;internal/cli/root.go:190-195;docs/protocol.md:330.
TR-44 Client drops server requests with string ids
- What. JSON-RPC ids may be strings or numbers in every revision. A server-initiated request such as
elicitation/createorroots/listmay therefore arrive with"id": "abc". - Where.
RequestId = string | numberfrom 2024-11-05 on. - mcpx @ 05c78b2. The client decodes incoming frames with
ID *int64(internal/mcpclient/client.go:351-368), so a string id fails to decode and the frame is silently skipped;handleServerRequesttakes anint64(internal/mcpclient/client.go:401). The server then waits out its own timeout. - Value to mcpx. + med: correctness against servers whose SDKs use string ids.
- Effort. S — keep ids as
json.RawMessage. - Risk. A silent hang, the same class of bug the comment at
internal/mcpclient/client.go:346-350says was fixed once already. - Detail. The server side also parses client replies as
*int64(internal/mcpserver/conn.go:306-320), which is fine only because mcpx chooses its own numeric ids. - Sources.
schema/2024-11-05/schema.ts:53“export type RequestId = string | number;”;schema/2025-11-25/schema.ts:122;internal/mcpclient/client.go:352;internal/mcpclient/client.go:401.
TR-45 Client HTTP timeout caps whole SSE responses
- What. The upstream HTTP client’s timeout also bounds reading an SSE response body, so any stream longer than the timeout is cut.
- Where. Implementation behaviour.
- mcpx @ 05c78b2.
http.Client{Timeout: plumbing.httpRequestTimeout}(10 min) (internal/mcpclient/http.go:54-57,:61). A legacy call that streams for longer than 10 min, such as a slow elicitation, is cut off. - Value to mcpx. + low: long elicitations over remote HTTP survive.
- Effort. S — rely on the context deadline, which
pool.callTimeoutalready sets. - Risk. An answer that arrives after 10 min on a remote server is lost.
- Detail. None.
- Sources.
internal/mcpclient/http.go:61“hc: &http.Client{Timeout: timeout},”.
Decisions
A decision record exists when two or more issues propose different answers to one question and a choice made later would be a breaking change. Each record says what the code already does, what each issue proposed, which option was taken and why, and exactly what every affected issue has to change as a result. A record that lists options without choosing one does not belong here.
They exist because #181 found eighteen collisions between themes, each between issues written by people who could not see each other’s work, and none with an owner.
| # | title | status | date | issues |
|---|---|---|---|---|
| 0001 | One hook vocabulary, and where a hook’s output goes in the exec stream | accepted | 2026-09-30 | #81 #82 #112 #111 · #181 rows 2, 7, 13 |
| 0002 | One autonomy dial, and a ceiling the daemon sets | accepted | 2026-09-30 | #111 #112 #113 #114 #110 · #181 rows 3, 5 |
| 0003 | What mcpx declares, and what it enforces | accepted | 2026-09-30 | #82 #177 · #181 row 10 |
| 0004 | Scope-resolved roots: spawn cwd, ${mcpx.root} substitution, initialize workspace | accepted | 2026-10-26 | dezren39/nix#817 #818 #819 · touches #308 #309 #864 |
| 0005 | Release pipeline, version single-sourcing, and the module path | proposed | 2026-10-29 | #1881 |
| 0006 | TTLs renew by touch-on-read, bounded by a maxAge ceiling | accepted | 2026-10-29 | #1884 · touches #309 #1887 #1893 |
Status
- proposed — in a pull request, open for argument.
- accepted — merged. A record reaches
mainonly by being accepted, so a record onmainthat saysproposedis a bug. - superseded by NNNN — a later record changed the decision. The old one stays, with a line at the top naming its replacement; the history of why a thing was decided is worth more than a tidy directory.
A change that contradicts an accepted record changes the record in the same pull request, or it does not merge.
Numbering
Four digits, in the order they were written. A number is never reused, even for a record that was withdrawn before merging.
Template
# NNNN — the decision, as a statement rather than a question
```
number: NNNN
status: proposed
date: YYYY-MM-DD
issues: #the ones whose text changes because of this · #181 rows, if any
code: the commit the file:line references were checked against
```
> Two to four sentences: what collided, and what this record settles.
---
## Context
What ships today, with file:line, checked against `code:` above. What each
issue proposes, quoted briefly. Anything found while looking that the issues
did not know — a delivery hole, a stale count, a line that has moved — goes
here, because the decision may depend on it.
## Options
### A — …
What it is and what it costs. Real alternatives only; an option nobody would
take is padding.
### B — …
### C — …
## Decision
The option, stated so it can be implemented without asking. Names, values,
defaults, the API if there is one, and the tests that prove it.
## Consequences
Per affected issue: what changes in it. Then what is now forbidden, and what
is deferred and to which issue.
## Mapping
| term | from | becomes |
| -------------------- | ------------------------------ | -------------- |
| every competing term | the issue or file it came from | the chosen one |
0001 — One hook vocabulary, and where a hook’s output goes in the exec stream
number: 0001
status: accepted
date: 2026-09-30
issues: #81 #82 #112 #111 · #181 rows 2, 7 and 13 · touches #5 #6 #7 #80 #172 #177
code: 05c78b2
Four proposals for “run something at a moment mcpx notices”, written by people who could not see each other’s work, and one of them already ships. This record keeps the shipped words, says what a hook may be and what it may do, and fixes where its output goes in the one stream mcpx documents as a contract.
Context
What ships
A file script runs inside a generated launcher, a template with named holes
(internal/launcher/template.go:17-45). Five of the holes are phases the user
fills: before, prefix, onSuccess, onError, suffix
(template.go:35-39). They are declared once, as repeatable KindSource
settings script.before … script.suffix
(internal/settings/registry.go:212-242), so each is a config key, an
MCPX_SCRIPT_* variable, a generated flag and a /v1/settings row. They have
short spellings on run and exec (--before, --prefix, --on-success,
--on-error, --suffix, internal/cli/commands.go:446-455), layer across the
config chain with the null-splice rule (internal/config/config.go:208,
ResolveLines at :264), and are listed in the man page’s LAUNCHER section
(internal/cli/manual.go:427-448).
Their meanings are precise, and the order is tested
(TestLauncherPhasesRunInOrder, internal/e2e/e2e_test.go:1437):
| phase | runs | where it is written |
|---|---|---|
before | first, before mcpx’s globals are installed | internal/runner/runner.go:548 |
prefix | after the globals, before the script’s module is imported | runner.go:554 |
onSuccess | when the entry point returns, with result.value set | runner.go:581 |
onError | when it throws, with result.error set; the error is rethrown — “a hook, not a handler” | runner.go:587, registry.go:233-236 |
suffix | in a finally, on both paths | runner.go:593 |
That is a good vocabulary. onSuccess and onError are separate because they
see different things; suffix is the one that runs either way. It already
answers #112’s own open question — “does onSuccess earn its own phase when
post exists?” — with a yes, and says what post would have to mean.
Three holes in its delivery
Found while writing this, each checked against a binary built from 05c78b2.
All three are the #177 class: accepted, advertised, and not done.
mcpx execaccepts--before,--on-successand--on-error, and drops them. The inline branch splices only prefix and suffix into the snippet (commands.go:786-799);opts.Phasesis set only for a file (commands.go:805-811).mcpx exec --before 'console.error("B")' --on-success 'console.error("S")' 'console.log(1)'prints1, exits 0, and runs neither hook. The same flags onmcpx run file.tsrun both.- A module whose top-level code throws skips
onErrorandsuffix. The launcher’sawait import(...)sits above itstry(runner.go:557against:569), so an error during import is never caught.suffixis documented as “runs last on both paths, like a finally” (registry.go:240). A throwingexecsnippet loses its suffix the same way, since the suffix is spliced after the body. - None of the five reaches
/v1/execor the MCPmcpx_exectool.execsvc.Optionshas no field for them (internal/execsvc/execsvc.go:101),Service.Runnever setsrunner.Options.Phases(execsvc.go:324-343), andmcpx run --remotereturns intoexecOnDaemonbefore the phases are read (commands.go:556-559,internal/cli/exec.go:472-485). The phases are a local-CLI feature that the settings registry advertises on every surface.RunWith’s comment says this was deliberate — “flattening all of that into the wire Options would make the wire type a mirror of one command’s flag list” (execsvc.go:358-370) — and this record reverses it for the phases, below.
The daemon’s internal hooks, and the bus
pool.Hooks (internal/daemon/hooks.go:24) is a second hook set —
OnMessage, OnProgress, OnListChanged, OnResourceUpdated,
OnElicitationComplete, Elicit — and every one of them turns into a bus
event. It is where #81’s observers get their events, not a vocabulary a user
sees.
The bus (internal/events/events.go) declares 16 kinds (events.go:29-52),
not the 17 that #81 and #173 count, and two of them — call.started and
task.updated — are published by nothing: the only reference to either outside
the declaration is events_test.go:67. A hook on them would never fire.
#181 row 13 is right that events.history is 1024 (defaults.json:52, wired at
internal/daemon/server.go:165), and it is not the whole answer. Each
subscriber has its own 256-event channel (events.go:177, an inline constant
the defaults guard does not see), and Publish drops rather than blocks when it
is full (events.go:207-234), counting Subscription.Dropped. A hook runner
that stalls starts losing events after 256, not 1024. It recovers only by
resubscribing from its last delivered seq (SubscribeFrom, events.go:176),
and only while that seq is still inside the 1024 retained.
What the issues propose
| source | points | phases | what runs | what it may do | declared as |
|---|---|---|---|---|---|
| #81, approach C | call, exec, server.start, or any event kind | observe (default), before, after | a command, a JS/TS module, a Python module, a plugin tool | observe: nothing; before: deny or replace; after: rewrite the result | hooks: [{on, phase, run | module | plugin, when, async}] |
| #82, approach C | as #81 | as #81 | the plugin’s own MCP tool | as #81 | registered at runtime over /v1, owned by a plugin session |
| #112, approach C | exec only | pre, main, post, onError, onSuccess | a script or a prompt | advise, transform, script, retry | options.hooks per call, script.hooks in config |
| the notes, quoted in #112 | exec | prompt, prePrompt, postPrompt, onErrorPrompt, onSuccessPrompt | a prompt | — | five options |
Options
A — adopt #81’s table, and re-express script.* as entries in it
One structured hooks list for everything, including inline source; the five
script.* settings and their flags removed.
It renames five shipped settings and five flags, and it pushes the cheapest hook
there is — a line of TypeScript that runs inside the script, costs no process
and can see result and script — through a structure built for
out-of-process actions with timeouts, exit codes and stdin. #81 itself says “do
not do that migration in this issue”. And #81’s after merges success and
failure into one phase, which is exactly the distinction the shipped set got
right.
B — keep all four and document a mapping
Cheapest today. Every later issue picks whichever vocabulary its author read first, and the user learns four. This is the default outcome, and it is why #181 row 2 exists.
C — the shipped phase names become the vocabulary; one structured list for everything that is not inline source
Keep before, prefix, onSuccess, onError, suffix with their meanings,
add observe, keep script.<phase> as the spelling of inline hooks, and add one
setting for every other kind of hook using the same phase names.
It costs: prefix only means something inside a script; #81’s exec point is
renamed; #112’s pre/post/main go; and the registry needs a structured
setting kind it does not have.
Decision
C. A hook is four things: where (on), when (phase), what runs
(the action), and what its output may do (effect).
Phases
Six, and no others. The five shipped ones keep their names and their meaning, extended from the launcher to any point. One is new.
| phase | when | may hold the operation up |
|---|---|---|
before | before the operation starts | yes, bounded by a timeout |
prefix | the script point only, inline source only: after mcpx’s surface is installed, before the body | — (it runs inside the script) |
onSuccess | the operation succeeded, before its result is returned | yes, bounded |
onError | the operation failed, before its error is returned; the error still propagates unless an effect replaces it | yes, bounded |
suffix | after either, like a finally; it cannot change the outcome | yes, bounded |
observe | after the fact, asynchronously, from the event bus | never |
observe is the default, because it is the only phase that cannot slow or
change anything.
Points
With any phase but observe, on names a point: a moment the daemon
passes through, enumerated in one Go table the way api.Ops() enumerates
routes, each entry with the schema of what a hook receives and may return. The
documentation and the validation are generated from the table, and a guard test
fires every point and asserts a hook on it ran (#129’s enumerate → completeness
→ probe), so a point cannot be declared and dead the way call.started is.
| point | fires on | phases it has |
|---|---|---|
script | one script run: mcpx run/exec, local or remote, /v1/exec, a recipe or intent run | all five |
call | one tools/call going upstream, from any caller | before, onSuccess, onError, suffix |
server.start | a pooled instance starting | before, onSuccess, onError |
#81 called the first point exec. It is script here so that script.onError
and {"on": "script", "phase": "onError"} are visibly the same place.
With phase: "observe", on is an events.Filter kind prefix, verbatim — the
same vocabulary as /v1/events?kinds=, so "elicit" matches every elicitation
event and "*" matches all. when carries the filter’s other fields
(server, session, uri), plus tool for the call point.
Actions
Exactly one per hook.
| action | what runs | where |
|---|---|---|
source | inline TypeScript or a file, spliced into the launcher | inside the script; script point only; declared only as script.<phase> |
run | an argv array — never a shell string; write ["sh", "-c", "…"] to ask for a shell | a child process of whichever process owns the point |
module | a script run by mcpx’s own runner, with the generated client | as run |
prompt | text with @placeholders, sent to the provider from #110 | the daemon; skipped with a warning on the run when no model is configured |
plugin | "namespace.tool" — an MCP tool call | whichever server or plugin serves that namespace |
Python is a run (["uv", "run", "hook.py"]) until #105 makes it a runtime
module knows. A shell string is refused rather than guessed at, because
sh -c is an injection site and #81 asked for the choice to be loud.
Input: JSON on stdin for run and module, the arguments object for
plugin, @placeholders for prompt (JSON-encoded, never text-substituted,
as recipes already do), and flat MCPX_HOOK_* variables for the one-line case.
Those names go in #172’s outbound table, not beside the code that sets them.
Output: one JSON object whose keys are the levels of the dial in
0002 — advise, ask or refuse, propose, apply,
run. A key above the hook’s effect is dropped and reported in its hook frame,
never honoured. A non-zero exit is a result, not an error, following the
adapter’s precedent (internal/adapter): the hook failed, hook.failed is
published, and the operation carries on — unless the hook says
"failure": "closed", which is legal only when its effect may refuse (ask or
above), because failing closed is a refusal.
Effect
The dial from 0002, per hook, default advise. It is
clamped three times: by the phase (suffix at most advise, observe always
off), by the caller’s hooks.autonomy, and by the daemon’s autonomy.max.
Declaration: settings, not endpoints
script.before…script.suffixstay exactly as shipped. They are{on: script, phase, source}, and the only way to declare asourcehook. Nothing about them is renamed.- One new daemon-scoped, repeatable setting,
hooks: a list of hook objects for every other action, layered across the config chain with the same null-splice rule asscript.*. It needs a structured setting kind — a JSON value validated against a schema declared on the setting — which the registry does not have. #110’smodel.agentsneeds the same kind; it is one addition, not two. - Per call,
ExecOptionsgains two fields.scriptcarries the resolvedsourcephases —{before, prefix, onSuccess, onError, suffix}, each a list of lines, the settings’ own shape — so a remote run and a local one run the same launcher. That closes hole 3.hooksappendsscript-point hooks for that run and may only lower effects. This is #112’soptions.hooks, with its phase names replaced. - A plugin’s hooks come from its manifest (#80’s
mcpx-plugin.json), are resolved when its plugin session starts and dropped when it ends (#82’s approach C). There is no hook-registration route.GET /v1/settings/hookslists what is in force, and the registry already records where each value came from, which answers #81’s “list them” and #82’s “what is extending this daemon” without a new operation.
The exec frame order (#181 row 7)
One order, replacing the block in docs/exec.md §“The frame order” and the
comment on execsvc.Frame (execsvc.go:138-144):
start{runId}
hook{phase:before} out-of-script before hooks, in declaration order
log | emit | stdout | call | hook{on:call} interleaved, as they happen
artifact{id,name,mime,size,sha256,uri}
hook{phase:onSuccess|onError} in declaration order
result | error exactly one, and final
hook{phase:suffix}
end{exitCode,durationMs}
---- only for delivery:"stream" ----
artifact.chunk{id,seq,data}
artifact.end{id}
With four rules:
resultorerroris sent once, and it is final. EveryonSuccessandonErrorhook finishes before it. #112 put those frames afterresultwhile also saying the final result reflects a transform; both cannot be true, and a consumer that reads the answer and disconnects — the reason the order exists — has to be reading the real answer.- What a hook changed is visible. A hook frame carries
{on, phase, action, effect, durationMs, changed}, plusoriginalwhen anapplyreplaced the value. A caller can always see what mcpx changed. - #111’s
callframe is interleaved, besidelog,emitandstdout, because calls happen while the script runs. It carries server, tool, a hash of the arguments, duration, ok or error, anddestructiveHint. - Frame types are an open set; the order is the contract. A consumer
ignores a type it does not know. That is already what the CLI does —
applyFramehas no default case (internal/cli/exec.go:199-264) — and the plugin uses the structured shape, not frames. New frame types therefore need no capability flag, which answers #111’s open question:callframes are on by default.
Inline source hooks produce no hook frames. What they log or emit is already
part of the run and arrives as log, emit or stdout.
Consequences
#81 adopts this vocabulary. exec becomes script; after becomes
onSuccess and onError, or suffix where it must run either way; async is
observe; string commands are refused. Slice 1, the observer table, is
otherwise unchanged. Its delivery section should use 256 and 1024 as above, and
drop call.started and task.updated from the hookable list until something
publishes them.
#82 does not add a registration route. Runtime hooks are manifest hooks owned by the plugin session, and teardown is the session ending. A runtime-mutable hook table stays in #82 and waits for a concrete need.
#112 keeps its structure and loses its words: pre → before, post →
onSuccess, onSuccess → onSuccess, onError → onError; main is not a
hook at all — a prompt that is the run is /v1/intent (#113). Its effect
names are replaced by 0002’s, its stream placement by the order above, and
script.hooks by hooks entries with "on": "script". The deterministic half
it wants to ship first is module and run hooks at the script point.
#111 gets its call frame placed.
#7 is unchanged: placeholders stay the launcher’s. A prompt hook’s
@placeholders use the same spelling and the same JSON encoding.
#5 decides one question for both: hooks are declarable wherever script.*
is, since a project config can already run code on every mcpx run through
script.before. What a project-level config may declare is #5’s call, made once
for servers, script.* and hooks.
#6: an observer is a live trigger over the same event vocabulary. Replaying hooks over history needs the store.
#172: MCPX_HOOK_* names are declared in the outbound table.
#177 gets the three delivery holes, independent of everything else here. The fixes are in the report that accompanies this record, not in this change.
Forbidden from here: a hook-registration endpoint; a hook action that is a
shell string; a phase outside the six; a source action anywhere but
script.<phase>; any frame after result or error that changes it; a point
that nothing fires.
Deferred: a runtime-mutable hook table → #82. mcpx hook test <name> --event <file> → #81, as a local CLI command. Hooks registering artifacts →
not in the first version; #81 decides. How a run-effect onError retry
streams its second attempt → #111 rung 5, bound by rule 1. script.started and
script.finished bus kinds, so the script point can be observed → #81.
Mapping
| term | from | becomes |
|---|---|---|
script.before | shipped | on: script, phase: before, action source — unchanged |
script.prefix | shipped | on: script, phase: prefix, action source — unchanged |
script.onSuccess | shipped | on: script, phase: onSuccess, action source — unchanged |
script.onError | shipped | on: script, phase: onError, action source — unchanged |
script.suffix | shipped | on: script, phase: suffix, action source — unchanged |
@before … @suffix | launcher placeholders | unchanged; the launcher’s fill for the phases |
pool.Hooks | internal/daemon/hooks.go:24 | not a user vocabulary; the source of bus events |
on: <event kind> | #81 | on: <event kind>, phase: observe |
phase: observe | #81 | observe |
phase: before | #81 | before |
phase: after | #81 | onSuccess and onError, split by outcome; suffix if it must run on both |
point exec | #81 | on: script |
points call, server.start | #81 | unchanged |
run: "cmd …" (a string) | #81 | run: ["cmd", …]; a string is refused |
module: x.ts | #81 | module |
module: x.py | #81 | run: ["uv", "run", "x.py"] until #105 |
plugin + tool | #81 | plugin: "namespace.tool" |
async: true | #81 | phase: observe |
| runtime hook registration | #82 | manifest hooks, owned by the plugin session |
phase: pre | #112 | before |
phase: main | #112 | not a hook: /v1/intent (#113) |
phase: post | #112 | onSuccess |
phase: onSuccess, phase: onError | #112 | unchanged |
script: x.ts | #112 | module (a file), or source through script.<phase> |
prompt | #112 | prompt |
effect values | #112 | 0002’s levels |
options.hooks | #112 | ExecOptions.hooks, script point only |
script.hooks | #112 | hooks entries with on: script |
prePrompt | notes | prompt action, phase: before |
postPrompt, onSuccessPrompt | notes | prompt action, phase: onSuccess |
onErrorPrompt | notes | prompt action, phase: onError |
prompt | notes | /v1/intent, not a hook |
hook frame placed after result | #112 | before result for onSuccess/onError; after it only for suffix |
call frame | #111 | interleaved with log/emit/stdout |
0002 — One autonomy dial, and a ceiling the daemon sets
number: 0002
status: accepted
date: 2026-09-30
issues: #111 #112 #113 #114 #110 · #181 rows 3 and 5 · touches #81 #172 #176
code: 05c78b2
Three issues invented the same dial, and the code already has a fourth spelling of it that any caller can turn up. This record names one dial, says what each stop on it permits, and makes the daemon’s limit a property of the settings registry rather than of whichever feature reaches it first.
Context
The question underneath
Every one of these proposals is answering the same question: when mcpx is about to act on something the caller did not send in the request itself — a script a model wrote, a recipe it picked by score, a repair it worked out, a hook’s output, an operation it routed a sentence to — how far may it go on its own?
That sentence also draws the edge. The script in a /v1/exec body is the
caller’s: it sent the code. A tool call is the caller’s. A recipe run by name is
not — the caller sent a name, and the code is on the daemon’s search path. A
generated script is not. The dial governs the second kind and never the first.
What ships
| knob | where | values | what it actually does |
|---|---|---|---|
prompt.mode | internal/settings/consumer.go:97, call scope | script (default), run | the default for /v1/intent; a body mode overrides it unconditionally (internal/daemon/routes_consumer.go:344-347) |
mcpx prompt --run | internal/cli/consumer.go:402, :423-426 | flag | sends mode: run |
recipe run mode | routes_consumer.go:268-271; mcpx recipes run --script | script, run (default) | render and diagnose only, or run |
prompt.sample | consumer.go:105, daemon scope | never (default), ask | whether mcpx may ask a model at all |
elicit.confirmDestructive | consumer.go:47, daemon scope | bool, off | ask before a destructiveHint call; unanswered refuses |
elicit.disambiguate | consumer.go:23, daemon scope | never, ask | ask which live instance to use |
diagnose.preflight | consumer.go:65, call scope | bool, on | refuse a script whose calls no longer match the schemas |
script.typecheck | internal/settings/registry.go:244 | off, on, strict | refuse a program that does not type-check |
elicit.mode | docs/elicitation.md §4, status: proposed | ask, auto, decline, error | not implemented; who answers an upstream server’s question |
Three things were found reading this, the third by running it:
- There is no ceiling.
prompt.modeis a default, not a limit. Any caller that can reach the socket sends"mode": "run"and the daemon runs a script a model wrote (routes_consumer.go:393). An operator cannot say “this daemon proposes and never runs”, which is the thing #111’s last paragraph asks for. - An unknown mode is silently
script. Both routes testmode != "run"/mode == "run"(routes_consumer.go:315,:393), so #113’splanwould be accepted today and quietly mean something nobody chose. - A caller’s
prompt.modeis accepted and ignored, and so is the runtime API’s. It is declared call-scoped, which promises that “the CLI sends its effective value with the request and the daemon honours it for that request only” (internal/settings/schema.go:87-88). The daemon instead resolves a second, private settings set from its config files and environment once at start (internal/daemon/consumer.go:70-80,settings.Resolve) and reads the policy from that (:128) — never from the request’scallSettings, and never from its own live set.mcpx promptreads only its own--modeand--run(internal/cli/consumer.go:423-426). Against a matching recipe,MCPX_PROMPT_MODE=run mcpx prompt …andmcpx prompt --prompt-mode run …both answer"mode": "script"; only--runruns it. AndPUT /v1/settings/prompt.mode {"value": "run"}answers"applied": true,GETthen reportsrunfromruntime:api, and the next prompt still answers"mode": "script"— the API reports a change that did not happen. Half-read, in #177’s terms: read, soTestEverySettingIsReadSomewherepasses, and honoured only from a config file present when the daemon started. The other six call-scoped settings in the same policy (recipes.minScore,recipes.matchMargin,recipes.limit,prompt.catalogBudget,prompt.maxTokens,prompt.runTimeout) go through the same path (consumer.go:117-135) and share the fault.
The settings registry has scopes (internal/settings/schema.go:63-107), and the
daemon honours only call-scoped values from a caller’s X-Mcpx-Settings header
(internal/daemon/routes_settings.go:50-76). That is the only line between
“the daemon’s policy” and “the caller’s preference” in the code today, and it
has no way to say “a caller may lower this but not raise it”.
What the issues propose
| source | name | values | default |
|---|---|---|---|
| #111 | repair.level | off, explain, confirm, propose, retry, rewrite | explain |
| #112 | hook effect | advise, transform, script, retry | advise |
| #113 | prompt.mode | adds plan | — |
| #114 | generators | “a generated artefact is a file a human reviews”; never a runtime behaviour | — |
| #110 | sampling.answerLocally | a model answering an upstream question itself | off |
| #111, last paragraph | ClampedBy | “the daemon sets the maximum autonomy it will ever exercise, a caller may lower it but never raise it” | — |
#176 asked for #111’s repair.level to be the one dial.
Options
A — one dial per feature, each with its own words
What happens by default. Four vocabularies for one idea, and a ceiling implemented three times (#181 row 5).
B — #111’s repair.level everywhere, as #176 proposed
It is the earliest and the most worked-out. But the name says repair on a
setting that governs prompts and hooks, and level already means log level
(logging.level). Its top two stops, retry and rewrite, differ in what
runs, not in how far mcpx goes — both execute code the caller did not send,
and a ceiling cannot tell them apart. And it has no stop for #112’s transform,
replacing the value the caller receives without running anything, so #112 would
still need words of its own.
C — one ordered vocabulary named for the concept, instantiated per feature, with one daemon ceiling enforced by the registry
A single set of levels called autonomy; each feature that needs the dial gets
a <feature>.autonomy setting; one daemon-scoped autonomy.max bounds them
all through a new ClampedBy field on a setting.
Costs: renames the shipped prompt.mode and its body field; adds a registry
feature.
Decision
C.
The levels
Ordered from least to most. Each permits everything below it.
| level | may | may not |
|---|---|---|
off | nothing — the caller receives exactly what it would have without mcpx’s opinion | attach anything |
advise | attach text or diagnostics to the result or the error | stop, replace, or run anything |
ask | stop the operation: raise a question through the broker, where an unanswered one refuses, as confirmDestructive already does; or refuse with a structured reason | replace or run anything |
propose | return a replacement beside the original, unapplied: a script, a plan, a transformed value, a declaration | apply it |
apply | replace the value the caller receives, with the original kept visible | execute anything the caller did not send |
run | execute code or an operation the caller did not send, with the caller’s own permissions and nothing more | exceed the caller’s permissions, or skip a consent gate |
Two rules hold at every level. No level skips a consent gate:
elicit.confirmDestructive still asks before a destructive call, whoever wrote
the script. No level makes mcpx exceed what the caller could have done —
#112’s “a hook must never be able to do what the script it wraps could not”,
made general.
The settings
| setting | scope | default | replaces |
|---|---|---|---|
autonomy.max | daemon, not hot | run | nothing; new |
prompt.autonomy | call, ClampedBy: autonomy.max | propose | prompt.mode (script → propose, run → run) |
repair.autonomy | call, ClampedBy: autonomy.max | advise | #111’s repair.level |
hooks.autonomy | call, ClampedBy: autonomy.max | run — each hook’s own effect is the real limit | nothing; new |
hook effect | per hook (0001) | advise | #112’s effect |
The mode field on /v1/intent and /v1/recipes/{name}/run becomes
autonomy, and an unknown value is a 400 rather than a silent propose.
An empty field means prompt.autonomy on the intent route and run on the
recipe route, which keeps that route’s own rule — “a route named run runs”
(routes_consumer.go:265-271); both are clamped by autonomy.max.
mcpx prompt --run and mcpx recipes run --script stay as readable spellings,
the way --remote and --local spell exec.where.
autonomy.max defaults to run because that is today’s effective ceiling: no
behaviour changes until an operator lowers it. A recipe run by name is governed
— the caller sent a name, not the code — so autonomy.max: propose makes the
recipe route render instead of run. /v1/exec is not governed, and that is the
point rather than a gap: a caller clamped to propose who takes the proposed
script and sends it to /v1/exec has run it deliberately, with the code in
hand, which is exactly what docs/consumer.md’s trust rule asks for. The
ceiling bounds what mcpx does on its own; it does not claim to stop a caller
running code the caller sends. 0003
holds it to that claim.
Lowered, never refused
A request above the ceiling is lowered to it and reported, not refused. The
answer carries {autonomy, requested, clampedBy}, and the CLI prints one line
to stderr. Lowering is always the safe direction, so delivering the lower
level is delivering a safe subset of what was asked; refusing would cost the
caller a round trip to arrive at the same place. It is not silent — the
response says what was asked, what was used, and which setting and which layer
decided, which is the provenance the registry already keeps for every value
(internal/settings/value.go:47-72).
ClampedBy — an internal/settings capability (#181 row 5)
The API, as it should land:
// internal/settings/schema.go, a new field on Setting:
// ClampedBy names the setting whose value is the most this one may
// resolve to. The ceiling is daemon-scoped and not hot, this setting
// is call-scoped, and both are KindEnum with the same Enum, ordered
// from least to most permissive: that order is what "most" means.
//
// A value above the ceiling is lowered to it, from any layer, and the
// lowering is recorded on the Value. It is never refused: the lower
// value is always a safe answer to the request.
ClampedBy string
// internal/settings/clamp.go, new:
// Clamp is what bounding a requested value by its ceiling produced.
type Clamp struct {
Value string // what will be used
Requested string // what was asked for
By *Value // the ceiling and its origin, when it lowered the request
}
// Clamp bounds requested by the ClampedBy ceiling of path, as resolved in s.
// A handler that takes the dial from a request body calls this; the call
// settings header gets it through WithOverrides.
func (s *Set) Clamp(path, requested string) (Clamp, error)
// Rank is the position of raw in an ordered setting.
func (d Setting) Rank(raw string) (int, bool)
Four hooks into existing code, each small:
New(schema.go) refuses a registry whereClampedBynames a missing setting, a ceiling that is not daemon-scoped or is hot, a clamped setting that is not call-scoped, a differentKindorEnum, or a clamped default above the ceiling’s.Set.raw(value.go:262) returns the clamped value for a setting with a ceiling, so no reader anywhere —consumerPolicyFrom, a handler, a call-scoped view fromWithOverrides— can observe a value above it.ValuegainsRequestedandClampedBy, anddaemon.SettingRecordrenders them, somcpx settings get prompt.autonomysays it was lowered and by what.- The two routes that take the dial in a body call
s.callSettings(r).Clamp("prompt.autonomy", body.Autonomy)and put theClampin the response. An empty body falls back to the request’s call-scoped value, and the policy is read per request froms.callSettings(r)— the daemon’s live set plus the caller’s header — instead of from the private setinitConsumerresolved at start. That closes the half-read above for all seven settings at once, and makes a runtimePUTmean what its"applied": truesays.
Version one supports KindEnum only. A floor — a daemon requiring more
checking than a caller asks for, as diagnose.preflight might want — is the same
mechanism with the order reversed; nothing needs one yet, and
elicit.confirmDestructive gets the same effect today by being daemon-scoped
so a caller cannot touch it at all.
Landed with autonomy.max, repair.autonomy and hooks.autonomy
(internal/settings/clamp.go). Two deviations from the sketch above: a
clamped setting’s Enum is an ordered subset of the ceiling’s rather than
equal to it, so prompt.autonomy keeps its two levels; and a prompt route
whose level is lowered below propose returns neither the script nor a
generation, only the candidates and diagnostics. repair.autonomy governs
the diagnostics a refused call carries (off drops them); hooks.autonomy
governs launcher phases from a configuration file or the environment, never
those given as flags on the command itself. The autonomy.max row in
internal/api/boundaries.go names the refusal tests.
Originally not implemented, for two reasons. The Setting struct lives
in schema.go, which another change is editing now. And a ClampedBy field
that clamps nothing would be a declared capability with no behaviour — the
#177 bug, and the thing 0003 forbids.
It lands with its first consumer: the prompt.mode → prompt.autonomy rename
and autonomy.max, which touch internal/daemon/routes_consumer.go,
internal/daemon/consumer.go, internal/defaults and docs/configuration.md.
The tests it lands with:
Newrefuses each malformedClampedByabove.Clamplowers and never raises; the origin of the ceiling is recorded; a call-scoped override above the ceiling reads as the ceiling; a file layer above the ceiling in the daemon’s own config reads as the ceiling.- End to end, with
autonomy.max: propose:mcpx prompt --runon a prompt that matches a recipe whose body writes a file returns the script withclampedBy: autonomy.max, and the file does not exist. Falsified by removing theClampcall: the file appears. - End to end, with the default ceiling:
MCPX_PROMPT_AUTONOMY=run mcpx prompt …andmcpx prompt --prompt-autonomy run …both run the recipe. Falsified by reading the start-time policy instead of the request’s: both answerpropose, which is today’s behaviour forprompt.mode. PUT /v1/settings/autonomy.maxanswersapplied: false, restartRequired: true(routes_settings.go:216-227), because the ceiling is not hot.
What it does not defend against, stated so nobody reads more into it: a caller
with socket access can write a higher ceiling into a config file with
PUT /v1/settings/autonomy.max {"persist": "user"} and then shut the daemon
down. Every daemon setting has that property until the admin operations are
access-controlled — the advisory op.admin row in
0003.
Consequences
#111: repair.level becomes repair.autonomy, and each rung has a level
(the mapping below). ClampedBy belongs to internal/settings, not to repair;
#111 consumes it. “Whose policy wins when the daemon and the caller disagree” is
answered: the daemon’s ceiling, with the lowering reported.
#112: effect takes these levels. transform is apply; script is run,
or propose to hand the generated script back instead; retry is run. A pre
hook that refuses is ask. #112’s “cached, reviewable, promotable” generated
script is propose by another name.
#113: prompt.mode: plan is prompt.autonomy: propose. Routing to an
operation at run still passes every consent gate, so a destructive operation
still asks.
#114: generators are propose by construction — they print a declaration,
and a human writes it into config. That is not a setting and cannot be raised;
--write <path> is the human’s own act.
#110: prompt.sample and model.provider answer whether a model is
consulted at all, which is a different question from how far its output may go.
A model answering an upstream server’s question on the user’s behalf is apply,
bounded by autonomy.max, whatever #110 names the setting; a question Route
sends to a human (internal/elicit/elicit.go:491) is never answered by a model
at any level.
Shipped code: prompt.mode is renamed, with no alias — there are no users.
Forbidden from here: a feature-specific autonomy vocabulary; a clamped setting that refuses instead of lowering; any level that skips a consent gate; the dial on anything the caller sent in the request (an exec body, a tool call).
Deferred: floors → the first setting that needs one. Clamping kinds other
than KindEnum → the same. Per-feature ceilings (prompt.autonomyMax and so on)
→ only if one global ceiling proves too coarse; ClampedBy already allows it
without an API change.
Mapping
| term | from | level |
|---|---|---|
prompt.mode: script | shipped | propose |
prompt.mode: run, mcpx prompt --run | shipped | run |
an unknown mode, e.g. plan | shipped behaviour | propose, silently — becomes a 400 |
recipe run mode: script, --script | shipped | propose |
recipe run mode: run (the route’s default) | shipped | run |
off | #111 | off |
explain | #111 | advise |
confirm | #111 | ask |
propose | #111 | propose |
retry | #111 | run |
rewrite | #111 | run |
| rung 0, deterministic diagnostic | #111 | advise |
| rung 1, deterministic near-miss | #111 | propose; run when the fix is provably the intended one and the caller allows run |
| rung 2, explain | #111 | advise |
| rung 3, warn / confirm | #111 | ask |
| rung 4, propose as an artifact | #111 | propose |
| rungs 5 and 6, retry and reinterpret | #111 | run |
| refusal with a reason (approach B, item 7) | #111 | ask |
| pass-through | #111 | off |
advise | #112 | advise |
transform | #112 | apply |
script | #112 | run, or propose |
retry | #112 | run |
a pre hook that refuses | #112 | ask |
observe | #81 | off |
a before hook that denies | #81 | ask |
a before hook that returns a replacement result | #81 | apply |
an after hook that rewrites the result | #81 | apply |
prompt.mode: plan | #113 | propose |
mode: run | #113 | run |
| generators | #114 | propose, fixed |
sampling.answerLocally | #110 | apply |
ClampedBy | #111 | Setting.ClampedBy in internal/settings |
| a caller lowering the level | #111 | a call-scoped <feature>.autonomy |
| the daemon’s maximum | #111 | autonomy.max |
dryRun | #111 | not the dial: a flag on the caller’s own script |
elicit.confirmDestructive, elicit.disambiguate | shipped | not the dial: consent and clarification gates, unchanged |
diagnose.preflight, script.typecheck | shipped | not the dial: checks on the caller’s own code |
prompt.sample, model.provider | shipped, #110 | not the dial: whether a model is consulted |
elicit.mode | docs/elicitation.md, proposed | not the dial: who answers an upstream question, by rules the user wrote |
0003 — What mcpx declares, and what it enforces
number: 0003
status: accepted
date: 2026-09-30
issues: #82 #177 · #181 row 10 · touches #80 #81 #111 #129 #172 and every issue whose footer says "Security/auth deferred; see tracker"
code: 05c78b2
Every issue in the backlog defers security to the tracker, and #82 proposes declaring capabilities now and enforcing them later. Nobody owned the line between the two. Looking for it turned up a daemon whose documented access control is not its access control, a sandbox that holds on one runtime of three, and a credentials block that does nothing at all.
Context
What the issues say
- #82, approach C: “Capabilities become declarable. The plugin manifest says what it intends to use … and the daemon can report and later enforce that. Declaring is useful even before enforcing, because it is documentation the daemon can print.” And: “capability enforcement is security work and is out of scope here”.
- #181 row 10: “Nobody owns the declared-vs-enforced capability boundary … The deferral has nowhere to land.”
- #177: a declaration that does not do what it says is a bug, and “it is indistinguishable from success”.
docs/protocol.md:18-32: “Accept liberally, send conservatively … a notification or a server-initiated request that the peer did not negotiate and did not declare is a lie about what was agreed”. Capabilities that depend on a push are “declared only when a notifier is behind them” (:108-110).internal/api/ops.go:59-67:Adminis “carried here so a later OAuth pass has one place to read scopes from, and so the MCP tool can say so in its description”.MutatingandDestructivefeed the MCP annotations.
What exists, and what each declaration actually does
Everything below was checked against a binary built from 05c78b2, in an
isolated state directory, and the daemon stopped afterwards.
1. The daemon’s access control is not what it is documented to be. The
OpenAPI document says: “Every endpoint is unauthenticated. The socket’s file
permissions are the access control” (internal/api/openapi.go:38-39);
daemon.endpoint says the local socket has “filesystem permissions as the
access control” (internal/settings/registry.go:354-355). The socket is
indeed 0600 (internal/daemon/server.go:202). But every daemon also listens
on loopback TCP, unconditionally, on an ephemeral port
(server.go:209-213, daemon.port default 0), and that listener serves the
same unauthenticated API with no check of any kind — no Origin, no content
type, no token. A request shaped exactly like a browser’s no-preflight “simple
request” runs code:
curl -X POST http://127.0.0.1:$PORT/v1/exec \
-H 'Content-Type: text/plain;charset=UTF-8' -H 'Origin: https://evil.example' \
-d '{"source":"console.log(\"ran from a foreign origin\")","options":{"output":"structured"}}'
→ "stdout":"ran from a foreign origin\n"
and /mcp answers initialize for the same foreign Origin. The MCP
Streamable HTTP transport (2025-11-25, §“Security Warning”) says servers
“MUST validate the Origin header on all incoming connections to prevent
DNS rebinding attacks. If the Origin header is present and invalid, servers
MUST respond with HTTP 403 Forbidden.” The OpenAPI text does mention the
TCP listener, but only to say that binding it wider than loopback is a
deliberate act — it treats loopback as private, and loopback is not private:
every other user on a shared host can reach it, and so can a web page, subject
only to whatever the browser does about pages reaching the local network,
which varies by browser and version. The port is ephemeral, not secret; the
range is 16,384 ports on macOS and about 28,000 on Linux, and a caller that
fires the same request at all of them does not need to know which one
answered. Scripts run with --allow-all by default
(internal/runner/runner.go:52-53). The declared boundary is the socket’s
mode bits; the enforced boundary is “can send an HTTP request to localhost”.
2. script.permissions holds on Deno and nowhere else. The setting offers
all, net, read, read-net and strict (registry.go:183-189), and its
help does not mention runtimes. The profiles become Deno flags
(runner.go:41-67), which are appended for Deno and never for Bun or Node
(runner.go:85-101). The code’s own comment says “bun and node run with the
user’s own authority whatever is asked, which is stated here rather than
pretended otherwise” (runner.go:43-45) — stated in a Go comment, to nobody
who sets the value. With a script that reads a file outside its working
directory: --runtime deno --permissions strict fails with NotCapable;
--runtime node and --runtime bun print the file and exit 0. Under
script.runtime: auto, whether strict holds depends on whether Deno happens
to be installed, which on a Nix install it always is (#181 row 11), so nobody
testing through the flake sees it.
Supersession note (2026-10-31, #3246): the paragraph above describes the state when this record was written. It no longer holds as written: the profiles now live in
internal/defaults/defaults.json, enforcement is rendered per runtime ininternal/runner/runtimes.go, node enforcesreadnet/read-netvia--permission --allow-fs-read=*, and a named profile with no entry for the chosen runtime is refused with an error rather than run unenforced. The corrected per-runtime description is the “is it safe?” answer indocs/faq.md. The original text above is kept unchanged as the record of the problem this decision addressed.
3. servers.<name>.auth does nothing. config.Server.Auth
(internal/config/config.go:141) is the only non-test reference to package
mcpauth in the tree; nothing calls Auth.Resolve (mcpauth.go:93) or
Auth.Describe (mcpauth.go:190). A bearer token in auth is parsed and
never sent. docs/story.md:2013-2027 documents it as working, including “an
unset variable is named up front” and “a server requiring [OAuth] says so
before the first request”; Resolved.NeedsOAuth (mcpauth.go:84-86) is read
by nothing. #115 lists it as an existing capability. docs/protocol.md:378
(“OAuth for remote servers. Declared and not performed”) is honest about
OAuth, and says nothing about the rest of the block.
4. One MCP annotation is wrong, and one label reads like a permission.
The annotations are hints by the specification’s own definition — “all
properties in ToolAnnotations are hints … Clients should never make tool
use decisions based on ToolAnnotations received from untrusted servers”
(2026-07-28 schema, ToolAnnotations). But idempotentHint is computed as
!Mutating || Destructive (internal/api/ops.go:664), which calls restart
idempotent; a second restart kills in-flight calls a second time. And an admin
operation’s tool description begins “Privileged:” (ops.go:675-677), an
mcpx-invented label, while nothing checks who calls it. The OpenAPI document
states that plainly; the tool description, which is what a model reads, did
not. The design behind it is sound and written down —
“withholding the tool buys no safety” (docs/parity.md:65-69,
internal/mcpserver/ops.go:25-29) — only the label was ambiguous.
5. Routing is not permission. A question whose single boolean field is
named confirm is routed to a human, because “consent is the one thing an
agent cannot give on somebody’s behalf” (internal/elicit/elicit.go:508-512).
Broker.Respond (elicit.go:292) accepts an answer from anyone, and By is
self-reported. That is correct for what it is — mcpx cannot know who is
answering — and it is a hint, not a guarantee.
6. What is enforced, and says so. The daemon honours only call-scoped
settings from a caller’s X-Mcpx-Settings header (routes_settings.go:64-72).
A non-hot setting written at runtime is not applied and the answer says
restartRequired (routes_settings.go:216-227). The socket and its directory
are 0600/0700 (server.go:202, paths.go:144-160). Capabilities to peers
follow the promise rule in docs/protocol.md §2.2 and §4. ExecOptions’
capabilities: ["artifacts"] is the caller’s declaration and is trusted by
design, because remoteness cannot be read off a transport
(internal/execsvc/execsvc.go:65-72). None of the restrictions in this
paragraph — the header filter, the socket’s mode, the directory’s mode — has a
test that tries to get round it.
The pattern: promises get delivered and guarded, because the protocol and settings work built guards for them. Restrictions are enforced where enforcement was cheap and silently absent where it was not. Hints are a mix of true, false, and phrased like rules.
Options
A — declare nothing until it is enforced
Strip the “Privileged” label, the audience reasons and #82’s manifest capabilities until something enforces them. It throws away real value: the MCP annotations are hints by specification, a model is better for reading them, and #82 is right that a declaration the daemon can print is documentation. It also would not have caught findings 1–3, which are not early declarations but broken ones.
B — declare freely; each feature enforces what it can
The status quo, and it produced every finding above.
C — three kinds of declaration with one rule each, one owner, one table, two guard tests
Distinguish promises, restrictions and hints; give restrictions exactly two states; put every restriction in one table owned by the surfaces that render it; guard both states.
Decision
C.
Three kinds of declaration
| kind | says | rule |
|---|---|---|
| promise | mcpx will do X: a capability to a peer, a setting’s effect, a config field’s effect, a documented behaviour | delivered. There is no advisory promise; an undelivered one is a bug (#177), not a state. |
| restriction | X will be refused: a sandbox profile, a scope filter, a ceiling, a plugin’s capability list, an auth scope, an access control | enforced — mcpx, or something it drives, refuses X, and a test tries X and sees the refusal — or advisory — nothing refuses X, and every surface that shows the restriction says advisory in the same text. No third state. |
| hint | X is probably true, act accordingly: MCP annotations, the admin label, an elicitation’s audience | true, and recognisable as a hint: a field the specification defines as one (MCP’s *Hint), or the word advisory beside it. A false hint is a bug. |
“Declarable now, enforceable later” is advisory, and it may be declared
today — on the condition that the word is on every surface that shows it. It
moves to enforced only in the change that adds its refusal test. Nothing may
be shown as enforced before that test exists.
Ownership
The daemon is the only enforcer. A plugin, a hook, a manifest and a caller only declare.
SURFACES owns the declarations, in one table in internal/api, beside the
ops table: api.Boundaries(). The ops table already names itself “the one
place a later OAuth pass reads scopes from” (ops.go:59-61), and a restriction
on a setting, an operation or a plugin reaches a caller only through surfaces
SURFACES owns — tool descriptions, OpenAPI, /v1/settings, the CLI. Every
restriction any issue wants to declare is a row: #82’s manifest capabilities,
0002’s autonomy.max, 0001’s fail-closed hooks, a future OAuth scope. The
footer “Security/auth deferred; see tracker” on every issue now has somewhere
to land: a deferred restriction is an advisory row naming the issue that will
enforce it.
// internal/api/boundaries.go
type BoundaryState string
const (
Enforced BoundaryState = "enforced"
Advisory BoundaryState = "advisory"
)
// Boundary is one restriction mcpx declares.
type Boundary struct {
Name string // "daemon.access", "script.permissions", "op.admin"
Declares string // the restriction, in one sentence
State BoundaryState
// Refusal names the case in TestEveryEnforcedBoundaryRefuses that tries
// to get round it. Required when enforced; a claim with no probe is the
// thing this table exists to stop.
Refusal string
// Until names the issue that will enforce it. Required when advisory.
Until string
}
func Boundaries() []Boundary
The guard tests
Following #129’s enumerate → completeness → probe, and #177’s second warning that acceptance is not effect:
TestEveryEnforcedBoundaryRefuses, end to end, against the real binary: one case per enforced row, each doing the forbidden thing and asserting the refusal and that the same action without the restriction succeeds (#75’s with-and-without). Its first cases: a daemon-scoped setting inX-Mcpx-Settingsleaves the daemon’s value unchanged; a read outside the working directory underscript.permissions: strict, for every runtime found on the machine; a TCP request without the daemon’s credential, and a request with a foreignOrigin, are refused.TestEveryBoundaryIsEnforcedOrSaysAdvisory, a unit test overapi.Boundaries(): an enforced row must name a case that exists in test 1 (the completeness half, so the table cannot claim what the probe does not try); an advisory row must name an issue, and every surface that renders it must containadvisory.
The two together mean a boundary cannot be declared enforced without a probe, and cannot be declared at all without saying which it is.
The rows it starts with
With the state each has when the table and its tests land together — which they must, since an enforced row without its case fails test 2.
| row | declares | state | refusal case, or until |
|---|---|---|---|
daemon.access | only a process that can open the socket or read the daemon record — or was given its token — can use the API | enforced after the fix below | TCP without the token; foreign Origin |
settings.callScope | a caller’s header changes only call-scoped settings | enforced | daemon-scoped key in the header |
script.permissions | a script cannot exceed its profile | enforced after the fix below | outside read, per runtime |
op.admin | admin operations would need a separate grant | advisory | the OAuth pass — a new issue under #181 |
elicit.audience | a question routed to a human is answered by one | advisory | #82, once answerers have an identity |
plugin.capabilities | a plugin uses only what its manifest lists | advisory | #82, one capability at a time |
autonomy.max | mcpx does not act above the ceiling on its own | enforced, from the day it lands | 0002’s end-to-end case |
hooks.failClosed | a failing fail-closed hook refuses its operation | enforced, from the day it lands | 0001’s hook dispatcher |
What changes now
daemon.accessis the most urgent thing in this record, and it is a security fix rather than a documentation one. Two steps. First, the TCP listener refuses, with 403, any request whoseOriginheader is present and not allowed: cheap, the specification’s MUST, and it closes the browser path, since browsers always sendOriginon a cross-origin POST and the CLI and the plugin never do. Second, it requires a credential that only a reader of the0600daemon record can have: a random token generated at start, written intodaemon-<key>.jsonbeside the endpoint, and sent asAuthorization: Bearerby every client that discovers the endpoint there. A client of a daemon on another machine gets the token the way it gets the endpoint, from its own configuration — a setting besidedaemon.endpoint. The unix socket stays as it is. Then “file permissions are the access control” is true of both listeners. The fix is ininternal/daemon/server.goand the clients that read the record (internal/cli/client.go,plugin/opencode/mcpx/daemon.ts,docs/plugin-discovery.md).script.permissions: a profile other thanallon a runtime with no permission model is refused before anything runs, naming the runtime and the profile. Refusal rather than an advisory label, because a sandbox that silently does not apply is the worst version of this bug. The fix is inrunner.Detect/runner.Permissions, and #105 inherits the rule for Python.servers.<name>.auth: wired, or the field removed. A config block that parses and does nothing is a false promise; until it is fixed,mcpx configandmcpx doctorshould say it is ignored. Thedocs/story.mdsection waits for the final docs pass, which owns that file.idempotentHintcomes from an explicit per-operation field rather than a formula over two unrelated ones;restartis not idempotent.- The admin label says
advisoryin the tool description. Done in this change:internal/api/ops.goandTestAPrivilegedOperationSaysItIsAdvisory.
Consequences
#82: manifest capabilities ship as advisory rows, and every listing of
them says “advisory”. Each becomes enforced separately, with its own refusal
case, once the plugin session identifies callers. The plugin session must carry
an identity the daemon can check, or no capability can ever leave advisory.
#181 row 10 closes: the boundary has an owner, a table and two tests.
#177 gains four instances: the TCP listener, script.permissions on Bun and
Node, the inert auth block, and restart’s idempotentHint. The first needs
to be treated as a security issue; there are no users yet, which is the only
reason it is not an emergency.
#111, #112, #81: ClampedBy and fail-closed hooks are restrictions, so they
land enforced, with their refusal cases, or not at all.
#129: the two tests are guards of its kind; api.Boundaries() is the
registry they walk.
#80, #83: a plugin or tool source that declares anything restrictive adds a row.
Peer capabilities — what mcpx declares in initialize or per request — are
promises, and stay under docs/protocol.md’s rule and the PROTOCOL area’s
guards. “Send conservatively” is the promise rule applied to the wire: do not
send what the peer did not declare, and do not declare what mcpx will not
deliver.
Forbidden from here: a restriction shown without its state; an enforced row without a refusal case; an advisory row without the issue that will enforce it; any surface describing an access control that a test has not tried to get round.
Deferred: authenticating callers — who is calling, not just whether they can reach the socket — to the OAuth pass the ops table already anticipates. Until then every row about who may do something is advisory, and says so.
Mapping
| term | from | kind | state |
|---|---|---|---|
| “the socket’s file permissions are the access control” | openapi.go:38, registry.go:354 | restriction | not true of the TCP listener → daemon.access, enforced after the fix |
| loopback TCP listener | server.go:209-213 | — | the unguarded half of daemon.access |
Origin validation | MCP Streamable HTTP, 2025-11-25 | restriction | missing; a MUST |
| capabilities declared to a client | docs/protocol.md §2.2 | promise | delivered |
sampling declared to servers | docs/protocol.md:271, #39 | promise | delivered only with a handler |
| “send conservatively” | docs/protocol.md:18-32 | promise, on the wire | PROTOCOL’s guards |
readOnlyHint, destructiveHint, openWorldHint | ops.go:659-667 | hint | specification-defined; true |
idempotentHint | ops.go:664 | hint | false for restart → explicit field |
Admin, x-mcpx-admin | ops.go:59-62, openapi.go:58 | hint | the OpenAPI text says so |
| “Privileged:” in a tool description | ops.go:676 | hint | now says advisory |
Mutating, Destructive | ops.go:63-67 | hint | through the annotations |
call-scope filter on X-Mcpx-Settings | routes_settings.go:64-72 | restriction | enforced; gains its refusal case |
Hot | schema.go:183-188, routes_settings.go:216-227 | promise | delivered: not applied, and the answer says so. The field’s comment says the API “refuses it” (schema.go:187); it answers 200 with applied: false |
| socket mode | server.go:202, paths.go:156-160 | restriction | enforced by the OS |
script.permissions | registry.go:183, runner.go:41-101 | restriction | Deno only → refuse elsewhere |
| elicitation audience | elicit.go:74-84, :491-514 | hint | advisory until #82 |
servers.<name>.auth | config.go:141, docs/story.md:2013 | promise | undelivered → wire or remove |
NeedsOAuth, “says so before the first request” | mcpauth.go:59-64, :84-86 | promise | undelivered |
| OAuth “declared and not performed” | docs/protocol.md:378 | a documented gap | honest; a gap written down is not a promise |
ExecOptions.capabilities | execsvc.go:65-72 | the caller’s promise | trusted by design |
manifest capabilities | #82, approach C | restriction | advisory until #82 enforces each |
| “declarable now, enforceable later” | #82 | — | advisory |
ClampedBy, autonomy.max | 0002 | restriction | enforced from the day it lands |
failure: closed | 0001 | restriction | enforced from the day it lands |
| “Security/auth deferred; see tracker” | every issue’s footer | — | an advisory row naming its issue |
0004 — Scope-resolved roots: spawn cwd, ${mcpx.root} substitution, initialize workspace
number: 0004
status: accepted
date: 2026-10-26
issues: dezren39/nix#817 #818 #819 (design source, consolidated here) ·
implementation tracked in this repo, see "Slices" below ·
touches #308 #309 #864
code: pkgs/mcpx at the merge commit of this record's PR
mcpx scopes already decide which process serves a caller, resolving the caller’s cwd to a git root to do it. But nothing tells that process where the caller is: the child spawns in the daemon’s cwd, argv and env are the config’s static strings, and the initialize handshake carries no workspace. Three issues in dezren39/nix (#817, #818, #819) proposed the three halves of one fix from a single design note (2026-10-25). This record consolidates them into one design, decides the questions they left open, and is the design’s home from now on: the source lives in this repo, so the design lives here too. The nix issues stay open until the #309 re-homing pass closes them with pointers; their acceptance criteria carry over verbatim into the implementation issues filed from this record.
Context
What the code does today (verified at the branch point of this record)
internal/config/scope.go—Scope.Key(ctx CallContext)resolves the caller’s cwd vialocateGit(internal/config/gitlocate.go, native discovery with agit rev-parsefallback;gitWherecarriescommonDir,worktree,noWorktree) and returns an opaque key only (repo:<common-dir>,worktree:<top-level>,cwd:<dir>,global,session:…,psession:…,pid:…,call:…). The resolved root is computed and then thrown away.internal/daemon/registry.go—keyFor(p, cc)computes the key from theCallContext;internal/pool/pool.go—Acquire(ctx, key)receives only the key.startLane→dial()buildsStdioOptions{Cwd: p.cfg.Cwd}verbatim (internal/mcpclient/stdio.gosetscmd.Dir = opts.Cwd). Arepo- orworktree-scoped server therefore still runs in the daemon’s cwd.internal/config/config.go— theServerstruct holdsCommand/Args/Env/Cwdwith no placeholders; there is no templating anywhere ininternal/configorinternal/pool.internal/mcpclientsends norootUriand noworkspaceFoldersat initialize (grep: none).internal/pool/eras.go—Identity(c *config.Resolved)hashes the static spawn definition (command, args, env, cwd, transport, URL, headers) for the era cache.- Instance lifecycle is keyed sharing: instances are keyed by
(server, scope key), capped (
pool.max, default 4), LRU-evicted at capacity, stopped afterIdleTimeout(default 5 min) byReapIdle, and stopped eagerly for caller-owned keys (CallContext.CallerOwned). None of that changes here.
What each issue proposed
- nix#817 (step 1) — when a server’s scope is
repo/worktreeand its configcwdis empty, spawn the instance withDir= the resolved scope root. The scope resolution must hand the pool the root, not just the key. Explicit configcwdalways wins. - nix#818 (step 2) — two placeholders, substituted at instance-spawn
time only:
${mcpx.root}(the scope-resolved root) and${mcpx.cwd}(the caller’s cwd), inArgs,EnvandCwd. Stored config stays static; substituted values must feed the era-hash inputs; unknown or malformed placeholders are a loud error, never passed through. - nix#819 (step 3) — when an instance has a scope-resolved root, send
it at initialize as
rootUri+workspaceFolders, so workspace servers (gopls) start a real workspace session.globalinstances send nothing — no fabricated daemon-cwd root.
The casualty that motivates all three
gopls is a workspace server whose workspace is its process cwd and its
initialize root. Behind mcpx today it loads files ad hoc
(“command-line-arguments” package) and go_package_api on a module path
returned empty after 156 s (live fleet, 2026-10-25). Flipping gopls to
"scope": "worktree" before these land only multiplies processes; the
config flip is deliberately the last slice.
Decision
1. One resolution object: ResolvedScope
Scope.Key gains a sibling in internal/config:
type ResolvedScope struct {
Key string // exactly what Key() returns today
Root string // the scope-resolved directory, "" when the scope has none
Degraded string // as Key() reports today
}
func (s Scope) Resolve(ctx CallContext) ResolvedScope
Key() becomes Resolve(ctx).Key so there is one resolution path, not
two. Root semantics per scope:
| scope | Root |
|---|---|
worktree | the caller’s checkout top-level (gitWhere.worktree) |
repo | the main worktree: the parent directory of the common dir, only when the common dir’s base name is .git; otherwise "" (bare repos and submodule-style common dirs whose parent is not a worktree root get no root — never guess) |
cwd | the caller’s cwd, canonicalized exactly as the cwd: key canonicalizes it |
worktree/repo degraded to a cwd: key (outside a repo, unreadable repo) | that same cwd — the instance is effectively per-cwd, so rooting it there is consistent, and the degradation is still logged as today |
global, session, psession, pid, call, and any fallback key | "" — these scopes have no meaningful root, and an empty root is what keeps global instances from fabricating one |
Root is a pure function of the key for repo/worktree/cwd scopes,
which is what makes the plumbing in §2 safe: two callers who share a key
always resolve the same root.
2. Plumbing: the pool learns the root at acquire time
Registry.keyFor already holds the CallContext; it switches to
Resolve and passes the ResolvedScope alongside the key into
Pool.Acquire. The pool stores root on the Instance at creation;
startLane/dial() read it from the instance being started (including
restarts and era-lane redials — the root cannot change for a given key, so
re-resolution at redial is unnecessary and must not happen).
3. Spawn cwd (from #817)
Stdio only. The effective spawn cwd is, in order:
- the config’s
cwd, after §4 substitution, when non-empty — explicit config always wins; - the instance’s resolved root, when non-empty;
- unset (today’s behavior: the child inherits the daemon’s cwd).
HTTP/SSE servers have no process cwd and are unaffected.
4. Substitution (from #818)
At spawn time only, on the effective spawn definition, in Args, every
Env value, and Cwd:
${mcpx.root}→ the instance’s resolved root;${mcpx.cwd}→ the caller’s cwd from theCallContextthat created the instance (canonicalized as in §1).
Rules:
- Substitution never touches the stored or merged config;
mcpxconfig output keeps showing the placeholders. - Placeholder names are validated at config load: any token matching
${mcpx.<name>}with an unknown<name>, and any malformed${mcpx.sequence, is a config error naming the server and the field. Only themcpx.namespace is mcpx’s — every other${…}form passes through verbatim, exactly as today. - If a server’s spawn definition references
${mcpx.root}and the instance’s root is""(e.g. the server isglobal-scoped), the spawn fails loudly naming the server and field. Silently substituting an empty or daemon-cwd root is the failure mode this design exists to remove. - The substituted definition is what
pool.Identityhashes for that instance’s era record: two instances of one server differing only by substituted root must not share an era entry. (TodayIdentityreads the staticResolvedconfig; the slices must move the hash input to the effective per-instance definition and verify this holds, per #818.)
5. Initialize workspace (from #819)
mcpclient.Options gains the instance root. When it is non-empty, the
initialize mcpx sends for that instance includes:
rootUri: the root as afile://URI;workspaceFolders:[{uri: <same>, name: <base name of the root>}];- the
workspace.workspaceFoldersclient capability declared true.
Both fields are sent for every era mcpx speaks (legacy and modern): initialize params are extensible, servers that do not consume workspace fields ignore them, and era-specific withholding would need per-server quirks this design refuses to grow. When the root is empty, initialize is byte-for-byte what it is today. Servers that connect over HTTP get the same treatment — the root describes the caller’s workspace, not the transport.
6. Observability
Every instance spawn logs one line — server, key, root, and the cwd
source (config / scope / daemon) — at the same level the daemon
already logs scope degradations. mcpx status instance JSON gains a
root field. The spawn-cwd decision must be testable without a live
server (a pure function of scope + CallContext + config cwd).
7. codedb and codebase-memory: deliberately global
Decided, closing the open question from the 2026-10-25 design note:
one shared codedb, global scope, is the fleet design and stays.
codedb and codebase-memory are multi-project inside one process —
their indexes are keyed by project path and the project is a per-call
argument — so per-project instances would duplicate indexes and buy
nothing. This decision is about the default, not a limitation: §4 is
what lets an argv-rooted server (codedb’s index root is baked into its
argv in the fleet config today) express a per-scope root if a future
config ever wants one.
The shared codedb’s index root stays a per-machine config concern (the
machine’s git tree: /home/user/git on the laptop, ~/git on the
sandbox), living in the machine-side config, not in the shared
.mcpx.json — the absolute-path leftovers in that file are #864’s to
clean up. No new shared index location is created by this design.
8. Config surface
No new config fields, no new scope values. The entire config-visible
change is the two placeholders and, in the final slice, flipping
gopls-mcp-server to "scope": "worktree" in the fleet config. The
user-level config stays what it is: a symlink to the repo .mcpx.json,
with identical server lists on both machines — unchanged by this design.
9. Failure modes
- Root vanished before spawn (checkout deleted between resolution and start): the spawn fails with the OS error surfaced as the instance start error. No silent fallback to the daemon cwd — a server running in the wrong workspace returns wrong answers, which is worse than a failed start.
- Unreadable or partial git discovery: unchanged from today —
locateGitdegrades the key, the degradation is logged once, and the root follows the degraded key per §1. - Substitution errors: load-time for names (§4), spawn-time for an
empty
${mcpx.root}; both name the server and the field. - Era-cache collisions: covered by §4’s identity rule; a regression here shows up as a server speaking the wrong era after a root change, which the slice-2 tests must pin down.
Non-goals
No new scopes; no daemon, service or deployment changes (how mcpx is installed or started on either machine is untouched); no per-project codedb or codebase-memory; no changes to sharing, leasing, idle or eviction policy.
What every affected issue has to change
- dezren39/nix#817, #818, #819 — design source only from this record’s merge. Their acceptance criteria carry over verbatim into the implementation issues below. They stay open in nix until the #309 re-homing pass closes them with pointers to this record and to the implementation issues (closing them is #309’s acceptance criterion, not this record’s).
- #309 — the re-homing list gains this record as the design home for the per-project trio; the trio’s implementation issues live here, so #309 should not re-file duplicates of them in this repo.
- #308 — untouched: how downstream flakes consume mcpx is orthogonal to what a scope resolves to.
- #864 — untouched, but slice 4 below depends on the fleet config
being in its post-#864 shape (no
/home/userabsolute paths) or must say why it proceeded anyway.
Slices (implementation issues filed from this record)
- ResolvedScope plumbing + spawn cwd — §1, §2, §3, §6. (from #817)
${mcpx.root}/${mcpx.cwd}substitution + era identity on the effective definition — §4. Depends on slice 1’s plumbing. (from #818)- Initialize
rootUri/workspaceFolders— §5. Depends on slice 1. (from #819) - Fleet config flip + live proof —
gopls-mcp-serverto"scope": "worktree"in the fleet config; acceptance is the #819 live test:go_package_apion a module path returns the module’s API promptly through the daemon, from a caller in a linked worktree as well as the main checkout. Depends on slices 1 and 3; slice 2 not required.
Slices 1–3 each carry the acceptance criteria of their source issue,
plus: tests for the spawn-cwd decision (scope root vs explicit cwd vs
daemon fallback), for degraded roots, and for global sending nothing.
0005 — Release pipeline, version single-sourcing, and the module path
number: 0005
status: proposed
date: 2026-10-29
issues: #1881 (this design) · split from triage 2774 · implementation is queue task 3114
code: 7d1b081c
mcpx has no release at all: no tags, no GitHub releases, no release workflow, and a version string hard-coded in four places. The install page’s channels are promises nothing can keep until a release exists. This note decides the shape — channel order, the tag and version scheme for a package that lives in a monorepo, the static-build policy, and the module-path question — so the implementation slices have one answer to build to. The module-path recommendation (§4) is the one item Drew may want to overrule; everything else follows from the code as it is.
1. Current state (verified at 7d1b081c)
- No tags and no releases on developing-today/code for mcpx; the only
mcpx workflow builds and tests (
mcpx.yml) plus vendorHash repair. - Version
0.1.0is hard-coded four times:package.nix(version =),package.nix(-X main.version=0.1.0), the fallbackvar version = "0.1.0"incmd/mcpx/main.go, andARG VERSION=0.1.0inContainerfile. - The module path was the last thing naming a repository that does not
exist: slice 3 below renamed it to
github.com/developing-today/code/pkgs/mcpx, where the code lives, and pointedmeta.homepageat the real tree. (Before the rename,go.moddeclared the legacy path andmeta.homepage404’d; the 328 Go files that imported by it moved with it in the same pass.) - A static build works: the SQLite driver is pure Go,
CGO_ENABLED=0cross-compiles, and a fully static Linux binary was verified in #182’s container work. The shipped artifacts are not static by choice:package.nixtakes the nixpkgs cgo default and the Containerfile setsCGO_ENABLED=1deliberately. - Unix only (
meta.platforms = lib.platforms.unix; untaggedSetpgid/Setsiduse). Release targets are therefore the flake’s three systems: x86_64-linux, aarch64-linux, aarch64-darwin. - The nix package is more than the binary: it generates the man page and
completions and wraps the binary with git/deno/bun/node on PATH. A bare
release binary has no JavaScript runtime;
mcpx run/execfail until the user installs one (mcpx doctorsays so). Every non-nix channel must state this in its install text.
2. Channel order
Build in dependency order; each channel consumes the one before it.
- GitHub release binaries + checksums. The foundation: three static
binaries (
mcpx-<version>-<system>.tar.gz) and a SHA256SUMS file on a GitHub release. Everything else downloads these. curl | shinstaller. Per-user by default (installs to~/.local/bin, states the JavaScript-runtime requirement at the end); a system-wide variant (sudo) documented, not default. The script verifies the checksum before installing — an installer that skips verification teaches users to pipe unverified binaries.- Container image. The existing Containerfile, published to GHCR by the same release workflow, version taken from the release (§3), not from a fifth copy of the string.
- Nix. Already works (
nix profile install …#mcpx); the release does not change it beyond the version source (§3).
Later, and not in the first slice: pwsh (needs Windows binaries, which need the untagged-syscall work), rpm/pkg (repackage the tarballs once they exist), the agent-prompt and just-the-plugin channels (they ride on 1–2). Flatpak is not built: a sandboxed mcpx cannot spawn the arbitrary servers, runtimes and binaries that are its entire function; the sandbox would have to be opened until the packaging is a lie. This records #1881’s recommendation against it as a decision.
3. Tags and the version’s single source
- Tag scheme:
mcpx/vX.Y.Zon developing-today/code. A path-prefixed tag is the monorepo-honest scheme: it cannot collide with another package’s releases in the same repository, and the release workflow triggers on exactly this prefix. (If §4’s recommendation is adopted, Go’s own subdirectory-module versioning wantspkgs/mcpx/vX.Y.Ztags for the module; the release workflow can cut both tags on the same commit — one for humans, one for the Go toolchain — rather than letting the Go scheme dictate the public one.) - Single source: a
pkgs/mcpx/VERSIONfile.package.nixreads it for both itsversionand its-X main.version=ldflag; the Containerfile takesVERSIONas a build argument whose workflow callers pass the file’s contents (the in-fileARGdefault becomes the file’s value at release time, and a comment in the Containerfile says the file is the source); the release workflow refuses to publish when the tag andVERSIONdisagree. cmd/mcpx/main.go’s fallback becomes a sentinel (0.0.0-dev), so a binary built without ldflags announces that fact instead of impersonating a release. The fallback is the one place a literal remains, and it is deliberately not a release number.
4. The module path — options and recommendation
The module path used to name a repository that does not exist, so
go install …@latest could not work and meta.homepage 404’d. Three
ways out were recorded:
- A — Create the repository the path names (dezren39/mcpx or
developing-today/mcpx) and move mcpx into it. Cleanest end state:
normal tags, normal
go install, its own Pages and releases. Cost: mcpx leaves the monorepo it shares tooling, CI and history with; the move itself is a project, and this repository’s other packages that reference mcpx paths must follow. This is Drew’s call, not a worker’s. - B — Rename the module to where the code lives:
github.com/developing-today/code/pkgs/mcpx.go install github.com/developing-today/code/pkgs/mcpx/cmd/mcpx@pkgs/mcpx/vX.Y.Zthen works from the monorepo, tags follow Go’s subdirectory scheme, andmeta.homepagepoints at the real tree. Cost: a mechanical rename across 328 Go files pluspackage.nixreferences — large diff, no design content, scriptable in one pass. If A is ever chosen later, the same mechanical rename runs again in the other direction; B does not close A’s door. - C — Keep the path, publish a release-only mirror. Releases are cut
to a mirror repository that carries the path’s name.
go installstays broken (the proxy resolves the module path, not the mirror’s releases), the homepage stays a lie, and two repositories must be kept in sync forever. Recorded for completeness; not recommended.
Decision: B, done in slice 3. It made every other part of this note true with one mechanical change and no new repository. A remains available whenever Drew wants mcpx to stand alone; nothing in B makes A harder.
5. Static-build policy for releases
Release binaries are CGO_ENABLED=0 static builds (the #182-verified
configuration). The caveat is recorded where the install page will read
it: on Linux, pure-Go os/user parses /etc/passwd instead of asking
NSS, so LDAP/SSSD users are not found, and the pure-Go resolver does not
honour nsswitch.conf. On macOS the flag changes nothing observable.
The nix package keeps its own (cgo) build: it targets managed systems
where NSS is the point, and it already exists.
6. Implementation slices (queue task 3114, in order)
- This design note.
- Version single-sourcing (§3): VERSION file, package.nix, Containerfile
build-arg, the
0.0.0-devfallback sentinel. - Rename slice (§4-B), done: module path, imports, package.nix references, meta.homepage.
- Release workflow: tag-triggered (
mcpx/v*) plusworkflow_dispatchsnapshot mode that builds the three artifacts and checksums without publishing; the snapshot run is the slice’s proof. - Installer script + a CI exercise (install a snapshot artifact into a
temp prefix;
mcpx --versionreports the release version). - Container publication wiring in the same workflow; update the #1878 install-page status column to what actually shipped.
0006 — TTLs renew by touch-on-read, bounded by a maxAge ceiling
number: 0006
status: accepted
date: 2026-10-29
issues: #1884 · touches #309 #1887 #1893
code: pkgs/mcpx at the merge commit of this record's PR
mcpx has at least seven TTLs across five subsystems and, until now, none of the absolute family (elicit, tasks, artifacts, ask, proto state) could be renewed: a question expired from its creation no matter how actively it was being used, while the idle family (daemon leases, MCP sessions, daemon idle-exit) already renewed by use — with no rule for which family a new resource joins. #1884 asked for one convention. This record is that convention.
Context
Verified at origin/main 7d1b081c, before this record’s PR:
internal/elicit/elicit.go— an elicitation’sExpiresAtwas set once at creation (Created.Add(defaults.ElicitTTL), 120s) and never moved. 120s was therefore the ceiling on how long a human could take to answer a question, however close they were to finishing.internal/defaults/defaults.go—elicit.ttlexisted only as a compiled-in default read from defaults.json; it was not a declared registry setting, so no config key,MCPX_*variable, flag, or/v1/settingscould change it.internal/daemon/server.go— the idle family already renews by use:trackActivity/shouldIdleExitkeep a daemon alive while it is being used and let it die when it is not.internal/artifacts/artifacts.go— artifacts expire absolutely at put (Expires: now.Add(ttl)).
Options
A — A /renew endpoint or per-resource renew operation
Every resource grows a renewal verb. Rejected in #1884: it puts the burden on every caller forever, and a caller that forgets is exactly the case renewal exists for.
B — Touch-on-read, unbounded
Any read renews forever. Rejected: a resource somebody keeps glancing at becomes immortal, and expiry stops meaning anything.
C — Touch-on-read with an absolute ceiling (chosen)
Reading a resource renews its idle deadline; no renewal can push the deadline past creation + maxAge. An explicit lifetime given at creation is the one exception: creation is where a lifetime is chosen, reads only renew within the convention.
Decision
- Touch-on-read is the default. An item read of a pending
resource sets
lastTouched = nowand the effective deadline tomin(created + maxAge, lastTouched + ttl). Collection reads do not touch: listing what exists must not keep it alive. - Touch never shortens and never revives. A deadline set explicitly at creation stands; a resource whose deadline has passed is expired, not renewed by a late read.
- One vocabulary. Every expiring representation carries
expiresAt(RFC 3339),lastTouched, andttlRemainingMs(milliseconds untilexpiresAtwhen the representation was produced, never negative). - No
/renewendpoint or per-resource renew operation is added, anywhere, for any family. - The knobs are declared settings.
elicit.ttlandelicit.maxAgeare registry settings (config key,MCPX_ELICIT_TTL/MCPX_ELICIT_MAX_AGE, flags,/v1/settings), defaulting to 120s and 30m. - Internal reads do not touch. Machinery that polls a resource
to enforce its deadline (the broker’s
Await,Respond’s validation) reads without touching; a read that renewed the deadline it enforces would never let it fall.
First family implemented: elicit (this record’s PR). The
remaining absolute families adopt the same convention in follow-ups
named on #1884: tasks, ask, artifacts (absolute by default, opt-in
artifacts.idleTtl). proto state is the documented exception:
its expiry is signed into the token, so it cannot be touched; it is
renewed by re-minting.
Consequences
- #1884: implemented for the elicit family by this record’s PR; remaining families split into linked follow-ups, named on the issue, none silently dropped.
- Forbidden: any
/renewverb; any new expiring resource that does not state which side of this convention it is on. - Deferred: tasks/ask/artifacts adoption (follow-ups on #1884); security/auth questions (the issue’s tracker, #1887/#1893).
Mapping
| term | from | becomes |
|---|---|---|
| renew endpoint | #1884 option A | forbidden — touch-on-read instead |
| absolute expiry | elicit/tasks/artifacts | idle deadline + maxAge ceiling (per family PR) |
elicit.ttl | compiled-in default | declared registry setting |
| stateTTL renewal | proto state | re-minting the token (documented exception) |
The home-page example component
Issue #1876, Approach C, stages 1–2. This directory is the example slot of
the mcpx home page, landed in the docs source because the page itself
(#1874) and the site build/deploy (#1941) have not landed yet — when they
do, the generator includes component.html where the example goes and
runs generate-client.sh as part of the build. Nothing here is a parallel
site.
What a reader gets
- Stage 1 — copy-as-command (
copy-command.js). Every docs example can be copied two ways: Copy as mcpx exec (a quoted-heredoc command that runs the snippet against the reader’s real daemon; see the file header for why the quoting has to be a heredoc) and Copy as script (the source itself, plusscriptCommands()for the save-and-mcpx runtwo-liner). - Stage 2 — run it in the page (
component.html,run-example.js). The example is editable; Run executes it in the tab. The label says “Simulated — runs in your browser”, because it is one:- the script runs through a strip-only TypeScript transform
(
strip-types.js) and is imported as a blob module; - it talks to the real generated client —
client.ts, emitted bymcpx client --ns demoagainst the deterministic fixture server (internal/testsupport/fakemcp), andclient.browser.js, that same file after Node’s strip-only transform. Neither is hand-written; regenerate both with./generate-client.sh; - the client’s one hot route,
POST /v1/call, is answered by a fake daemon (fake-daemon.js) from in-page mocks (mocks.js)./v1/batch,/v1/resourceand/v1/artifactsare not simulated and answer with an honest error.
- the script runs through a strip-only TypeScript transform
(
- No-JS fallback: the static highlighted example, unchanged.
Fidelity gaps (also stated on the page)
- The only namespace is the mock
demofixture; its deterministic tools are mirrored, the rest return an error naming the limitation. No real MCP servers, no launcher placeholders, noDeno.*/node:fs. - Mocks are computed from the same rules as the fixture where the fixture is deterministic (echo, open/state/wipe, structured, boom, fancy-name).
Stage 3 is not here
Running against the reader’s own daemon (a daemon.cors allow-list
setting, a fixed daemon.port, Chrome Local Network Access handling) is
excluded — a separate later follow-on. A hosted public daemon is rejected
in the issue, permanently. This component makes no daemon changes.
Verify
node smoke-test.mjs # shell round-trip for stage 1; end-to-end run for stage 2
./generate-client.sh # regenerate client.ts / client.browser.js from the fixture
#1941 should wire smoke-test.mjs into the docs CI pipeline; until then
it is the recorded smoke check for this component.
docs/spec
created: 2026-09-30T12:00:00-05:00
last-updated: 2026-09-30T12:00:00-05:00
increment: 1
status: standard
tags: area:protocol, area:spec
description: an index of the protocol records: what the specification asks,
what mcpx does on the wire, and how that is measured.
../protocol.md is the design, and where to start. These
are the records under it, each tested by name
(<revision>/<area>/<requirement>).
What the specification asks, independent of mcpx:
| file | one line |
|---|---|
| server-obligations.md | per revision, what a server MUST, SHOULD and MAY do, including what the prose implies and never states |
| client-obligations.md | the same for a client, and what a proxy owes on top |
What mcpx does, as a server and as a client:
| file | one line |
|---|---|
| transport.md | mcpx as a server on the wire: stdio and Streamable HTTP, per revision, headers, statuses, sessions, Origin |
| messages.md | what mcpx sends per revision: the 2026 envelope, per-revision fields, listen, not-found, tasks, requestState |
| identity.md | who an MCP request is from, per connection, and what scope that gives it |
| client.md | mcpx as a client: headers, x-mcp-header, results, lifecycle, listen, elicitation, resumption, HTTP+SSE |
| era-probe.md | how mcpx decides an upstream’s era, and the cache that makes it a one-time cost |
How it is measured:
| file | one line |
|---|---|
| requirements.md | the catalogue: every normative requirement of every revision, one row each, with its quote and anchor |
| revision-conflicts.md | the rules that change between revisions, and where one revision contradicts itself |
| conformance.md | how the matrix is built from the catalogue, the test covers and the gaps, and what building it found |
| conformance-matrix.md | generated: every requirement, per revision and side, with the test that verifies it or the issue that tracks the gap |
| official-suite.md | the official modelcontextprotocol/conformance suite against mcpx, and which failures are defects |
Not here: how mcpx compares with the revisions side by side, with opencode,
lootbox and Cloudflare code mode, and every place two of them disagree, is
../compare/ — start with
matrix-revisions.md and
conflicts.md.
What the specification asks of a client
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T16:00:00-05:00
increment: 2
status: standard
tags: area:protocol, area:spec
description: per revision, what an MCP client MUST, SHOULD and MAY do,
including the obligations the prose implies but never states.
This is the counterpart to server-obligations.md
and follows the same conventions. It is about the specification. mcpx is a
client of every upstream server it runs, and its client behaviour is in
../protocol.md §4. Comparisons with other clients belong
in ../compare/.
“Legacy” means 2024-11-05 through 2025-11-25, and “modern” means 2026-07-28. Several obligations below attach to the host, the application that shows things to a user. The spec addresses these to clients. A proxy is a client with no user of its own, so for each of them it has to decide whether it meets the obligation itself or passes it downstream.
1. Lifecycle
Sources: 2025-11-25 lifecycle, 2026-07-28 basic, 2026-07-28 versioning.
Legacy
initializecomes first. A client MUST sendinitializebefore anything else, offering the latest version it supports.- Check the answer. If the server answers with a version the client cannot speak, the client SHOULD disconnect. The schema’s doc comment says MUST. A client that accepts whatever comes back is running a protocol it never agreed to.
- Then
notifications/initialized. After a successful result the client MUST sendnotifications/initialized. Before the server responds it SHOULD NOT send requests other than pings. - Declare what it can do. The client declares
roots,samplingand, from 2025-06-18,elicitation. What it declares is what the server may ask of it. - Never cancel
initialize.notifications/cancelledforinitializeis forbidden in every legacy revision, including after the request times out. The client closes the connection instead. - stdio shutdown. Close stdin, wait, then SIGTERM, then SIGKILL. In 2026 this rule moved to the stdio transport page.
Modern (2026-07-28)
- Every request carries
_meta. Every request MUST carry_meta["io.modelcontextprotocol/protocolVersion"]andclientCapabilities. The client capabilities apply to that request only. An empty object means none. clientInfo. A client SHOULD sendclientInfo.resultType. An absentresultTypemeans"complete". An unknown value MUST be treated as invalid, never as complete.-32022(unsupported version). The client SHOULD pick a version fromdata.supportedand retry.
Declaring capabilities per request has an effect that shows up in
multiplexing clients. The client can widen or narrow what it offers one call
at a time. For example, it can declare elicitation only on calls made while
a user is present. The server cannot hold it to an earlier declaration.
2. Finding the era
Source: 2026-07-28 versioning, backward compatibility.
The 2026 versioning page describes the probe. The result is “a property of the server”. A client SHOULD cache it for the life of the process (stdio) or the origin (HTTP), and MAY persist it across restarts of the same configuration.
stdio. Send server/discover first (SHOULD). If the answer is
anything other than a recognised modern result or modern error, fall back to
initialize, and that includes a timeout. The fallback MUST NOT be keyed
to one error code, because legacy servers answer an unknown method in
different ways.
HTTP. A dual-era client tries the steps in order:
- POST a modern request with every header. Success means the server is modern.
- A 400, 404 or 405 whose body is a JSON-RPC error with
-32020,-32021,-32022, or-32601together with 404, also means modern. Fix the request or pick a version, and retry. - Anything else: POST
initialize, which gives legacy Streamable HTTP. - If that fails with 400, 404 or 405 (the 2025-11-25 rule), GET the URL and
wait for an
endpointevent, which gives HTTP+SSE (2024-11-05).
A server that returns a bare-text 400 is therefore classified as legacy. That is the server’s fault, but the client can only act on what it received.
A pool of processes of one configuration shares one era answer. The cache
key is the configuration, not the process. A freshly spawned legacy process
still needs its own initialize, because the session is per process. A
modern one needs nothing. If a pooled process answers a different era from its
siblings, the binary changed, and the cached answer has to be dropped and the
probe run again.
3. Transports
Sources: 2024-11-05 transports, 2025-11-25 transports, 2026-07-28 stdio, 2026-07-28 Streamable HTTP, 2025-11-25 authorization.
stdio
- The client launches the server as a subprocess.
- It MUST NOT write anything to stdin that is not an MCP message.
- It SHOULD NOT treat stderr output as an error (2025-11-25 and later).
- In 2026 it SHOULD restart a server that exited and retry.
HTTP+SSE (2024-11-05)
- The client opens the SSE stream first and POSTs to the URL from the
endpointevent. - A backwards-compatible client uses this transport only as the last fallback (§2).
Streamable HTTP, legacy (2025-03-26 .. 2025-11-25)
- Accept header. POST with
Accept: application/json, text/event-stream, and handle both kinds of response. - Session id. Echo
Mcp-Session-Idon every request once it has been assigned. On a 404 for a request that carried a session id, MUST start a new session withinitialize. The client SHOULD DELETE the session when it is done. - Version header (2025-06-18 and later). Send
MCP-Protocol-Version: <negotiated version>on every request afterinitialize. This is the version the server answered, not the one the client prefers. A constant header is wrong whenever negotiation settled lower. - Disconnects. A disconnect is not a cancellation. To cancel, send
notifications/cancelledon a new POST. - Server-initiated messages. The client MAY open a GET stream for them,
and MAY resume with
Last-Event-ID.
Streamable HTTP, modern (2026-07-28)
- Headers. Send
MCP-Protocol-Versionfrom the request’s own_meta, plusMcp-Method,Mcp-Name, andMcp-Param-*for every tool parameter the server annotated withx-mcp-header.- A tool whose
x-mcp-headerannotations are invalid MUST be excluded from the client’s tool list. - A proxy that re-exports such a tool takes on both sides: it sends these headers upstream, and it validates them from its own clients.
- A tool whose
- No sessions or background streams. There is no session, no GET stream, and no resumption. A broken request is re-issued under a new id. That MUST appears only in the 2026 changelog.
- Cancellation. Closing the stream is cancellation.
Authorization (HTTP, 2025-03-26 and later)
A client that authenticates:
- follows the OAuth flow the server’s metadata describes (PKCE is REQUIRED);
- sends
Authorization: Beareron every request, never as a URL query parameter; - uses a token only for the resource it was issued for (the
resourceparameter from 2025-06-18 on).
On stdio the client passes credentials through the environment. A client
that sends static configured headers meets “never in the query” only if none
of its auth modes puts a token in the URL. mcpx’s query auth type does,
and since #240 an auth block is applied, so query sends one when declared.
4. Messages and utilities
Sources: 2025-11-25 utilities (cancellation, progress, ping), 2026-07-28 cancellation, subscriptions, caching, tasks extension.
- Ids.
- Request ids are unique within a session (legacy).
- A client answering server-to-client requests keeps its own id space for them.
- A proxy that re-issues upstream questions downstream must never let the two id spaces meet.
- Timeouts.
- A client SHOULD set a timeout on every request, and SHOULD cancel upstream when one fires.
- It MAY reset the timer on progress, but SHOULD still enforce a maximum.
- A proxy sits between two timeouts. Its upstream timeout has to be shorter than its downstream client’s, or the client gives up first and the upstream work runs on with no observer.
- A
subscriptions/listenrequest is an implied exception, because it normally never gets a response.
- Cancellation. A client MUST NOT cancel
initialize. After it cancels a request it ignores any response that still arrives. - Progress. A
progressTokenMUST be unique among active requests. In 2025-11-25, a token given to a task stays valid until the task is terminal. That can be longer than the connection that carried the original request. - Pagination.
- Cursors are opaque. The client MUST NOT parse or persist them across sessions.
- In 2026 an empty-string
nextCursoris not the end of the list. Only an absentnextCursorends it.
- Ping (legacy). A client MUST answer the server’s pings. 2026 has no
ping, so the health probe for a modern upstream is a cheap real request, andserver/discoveris cacheable and made for it. - Logging.
- Legacy:
logging/setLevelis per session. - 2026: the level is per request, in
_meta. A client that wants logs asks on each call.
- Legacy:
- Caching (2026).
- A client honours
ttlMsandcacheScope. - It MUST NOT share a
privateresult across authorization contexts. - Every page of one list carries the same
cacheScope. - An intermediary cache is bound by the same rules.
- A client honours
- Subscriptions (2026).
list_changedandresources/updatedarrive only on asubscriptions/listenstream whose filter asked for them. A client that never listens never hears about changes.- A client SHOULD check the filter in the acknowledgement against the one it requested.
- A client may open several listens, and tells them apart by
_meta.subscriptionId, which equals the listen request’s id. - Teardown arrives as a result or as a stdio
notifications/cancelled. The spec disagrees with itself on which (conflicts C-21), so a client accepts both.
- Tasks.
- A client that creates a task SHOULD store the task id durably. The handle outlives the request that created it and the connection it was created on.
- The client polls with
tasks/get, at no faster thanpollInterval. - With the 2026 extension, a client receives a task only if it declared the
extension. It has to handle
resultType: "task"on any request where it declared the extension.
5. Features the client provides
Sources: 2025-11-25 roots, sampling, elicitation; 2026-07-28 MRTR.
Roots
- Declaring. Declare
roots, withlistChangedonly if the client will sendnotifications/roots/list_changed. - Roots themselves. Roots MUST be
file://URIs. The client SHOULD validate them against path traversal and ask the user before exposing them. - 2026. Roots are requested through MRTR only. There is no change signal
and no session, so a server caches roots per request, or per
requestState.
Sampling
- Human in the loop. A client SHOULD let a human review and edit the request and the completion, and deny either. A host that auto-approves makes that choice for the user and should say so.
- Model preferences.
modelPreferencesare advisory, and hints are matched in order. - Tools and context (2025-11-25 and later).
- Declaring
sampling.toolspromises to run tool loops. A client must not declare it unless every path that can answer supports them. - A server MUST NOT send
toolsortoolChoiceto a client that did not declaresampling.tools. includeContextother than"none"is soft-deprecated, and needssampling.context.
- Declaring
- 2026.
- Sampling is deprecated, but it is fully functional during the deprecation window.
- The request travels through MRTR.
- The client SHOULD NOT retain sampling messages between requests.
Elicitation (2025-06-18 and later)
- Form mode.
- An empty
elicitation: {}means form mode only. - The requested schema is flat, made of primitives only.
- The client MUST let the user review and change a response before it is sent, and can decline or cancel.
- The client MUST show which server is asking.
- It MUST NOT ask for sensitive information through a form (2025-11-25 and later).
- An empty
- URL mode (2025-11-25 and later) requires
elicitation.url.- A client that did not declare it and receives a URL request MUST
answer
-32602. - A client that did declare it MUST show the full URL with the domain highlighted, MUST NOT open it without explicit consent, and MUST NOT prefetch it.
- URL mode exists precisely so the client never sees the secret.
-32042andnotifications/elicitation/completeexist only in 2025-11-25. The notification goes only to the client that received the elicitation.
- A client that did not declare it and receives a URL request MUST
answer
- Binding the answer to a user. Elicitation state MUST NOT be bound to session ids alone (2025-11-25), so the elicited authorization has to reach the user who started the call.
- 2026.
- Elicitation travels through MRTR only.
elicitationId, the completion notification,-32042, the session-id rule and client rate limiting are all gone.
What a proxy owes, in addition
A proxy answers these requests for upstream servers by asking its own clients. The spec does not address proxies here, but the rules above force the following:
- Check the capability per hop. Before forwarding a question downstream,
the proxy checks that that downstream declared the capability, on the
request being answered (2026), and down to the mode:
elicitation.url,sampling.toolsandsampling.contexteach need their own check. - Scope
includeContext. Through a proxy,"thisServer"means every aggregated server. The proxy forwards"none"unless it can scope the context to the originating upstream. - Translate between eras.
- Legacy upstream to modern downstream: keep the upstream’s wire request
open while the downstream receives
input_required. - Modern upstream to legacy downstream: ask each input request on the wire,
then retry upstream with the upstream’s own
requestState, byte for byte. - Never relay
-32042to a 2026 client. Convert it into a URL-mode input request. - Strip
elicitationIdfor 2026 clients, and synthesise one for 2025-11-25 clients.
- Legacy upstream to modern downstream: keep the upstream’s wire request
open while the downstream receives
- Always answer upstream. If a downstream never retries, a legacy
upstream request stays pending forever. The proxy has to answer it with an
error or
cancel. - Attribute the question and leave it intact. Name the originating
upstream. Leave
url,requestedSchema, hint order andmaxTokensuntouched. - Pooling breaks user binding. An elicited third-party authorization is bound to the proxy’s client identity. With a shared pooled upstream, one user’s URL completion becomes visible to another user’s calls. Per-user scoping is the only remedy.
6. Where mcpx stands
What mcpx does as a client is client.md and
era-probe.md, summarised in ../protocol.md
§4; the per-requirement status, each row with the test that verifies it, is
conformance-matrix.md. Of the gaps the first draft
of this page listed, the discover field names, the unchecked initialize
answer, the -32022 retry, the unknown-resultType fallback, the constant
version header, the missing Mcp-Method/Mcp-Name/Mcp-Param-* headers, the
HTTP+SSE fallback, the cancelled initialize, paging that stopped at an empty
cursor, undeclared url-mode elicitation and elicitationId reaching 2026
clients were fixed by #224, #232 and #239. What the rules above still expose:
- No OAuth (#253). The per-server
authblock is applied (#240);type: oauthis refused before connecting. samplingandelicitation.urlare declared whenever a handler is installed, and the daemon always installs one;rootsis declared and always answered with an empty list (#210).sampling.toolsandsampling.contextare not checked per hop when a question is forwarded (#210, #256).- No
progressTokenis sent upstream, so progress cannot extend a timeout (#212). - A legacy HTTP session answered
404is an error, not a reason to sendinitializeagain (#203). (stdio shutdown now closes stdin, waits, then SIGTERM, then SIGKILL, as the lifecycle page asks — #284.)
mcpx as a client: every revision
What mcpx sends to the servers it fronts, and what it accepts from them,
revision by revision. The server side is transport.md; how the
era is found is era-probe.md. Everything here is tested; test
names are <revision>/<area>/<requirement> in
internal/mcpclient/{conformance_client,http_conformance,http_version}_test.go,
internal/pool/sse_test.go and internal/e2e/client_test.go. Every frame the
scripted servers receive is checked against the schema of the revision in use,
in strict mode (mcpspec.ValidateClientMessageStrict): a key another revision
defines is a failure, because “send conservatively” is exactly the rule a
lenient check cannot see.
mcpx as a client speaks 2024-11-05, 2025-03-26, 2025-06-18 and 2025-11-25
(legacy, initialize) and 2026-07-28 (modern, per-request _meta).
Headers
- Legacy Streamable HTTP.
MCP-Protocol-Versionis the versioninitializereturned, on every request after it. It used to be the constant2025-11-25, so a server that negotiated down to 2025-06-18 was told on every request that the client was speaking a revision it had just declined.initializeitself carries no header (nothing is agreed yet), and a server that negotiated 2025-03-26 or earlier gets none: the header did not exist before 2025-06-18. - Modern Streamable HTTP. The header is the version in the frame’s
_meta;Mcp-Methodis on every POST;Mcp-Nameontools/call,prompts/getandresources/read.Mcp-Session-Idis never sent on a modern frame — there are no sessions in 2026-07-28, and one minted for an earlier legacy exchange means nothing to a modern request. mcpx’s own daemon rejects a modern POST without the mirrored headers, which is whyTestMcpxIsAModernClientOfMcpx(one mcpx’s/mcpas another’s upstream) is the real proof rather than a fake written to agree. - Value encoding. A value outside visible ASCII, space and tab, with
leading or trailing whitespace, or one that already looks like the sentinel
(
=?base64?…?=), is sent as the sentinel around standard Base64. The last case matters: a plain=?base64?literal?=would otherwise be decoded by the server into something the client never sent.
x-mcp-header
A modern server over HTTP may annotate tool parameters with x-mcp-header;
the client MUST mirror them into Mcp-Param-{Name} headers.
- Validation, at
tools/list. An annotation must be a non-empty RFC 9110 token, case-insensitively unique, on aninteger,stringorboolean(nevernumber), reachable from the root throughpropertiesalone. The walk visits the whole schema, so an annotation underitems,anyOf,not,$defsor at the root is found and makes the tool invalid rather than being silently ignored. An invalid tool is excluded from the list mcpx returns and aserver.warninglifecycle event names it and the reason. Only on a modern HTTP connection: stdio clients MAY ignore annotations, and legacy revisions never defined them. - Mirroring, at
tools/call. The value at the annotated path; absent ornullomits the header; an empty string is sent as an empty header (the value was provided). Integers outside ±(2^53−1) and non-integers for an integer header are refused before sending — a header the server will reject is worse than no call. A connection that has not listed tools lists them once before its first call, since the annotations come from the list. -32020on a call means the schema most likely changed since it was listed: mcpx readstools/listagain and retries once (the transport page’s SHOULD).
Results and errors
resultType. Absent iscomplete.completeandinput_requiredare understood; anything else is anInvalidResultError. It used to be treated as complete, so a result meaning something mcpx does not know reached a caller as if it were a finished answer.- Resource not found is
ResourceNotFoundError:-32002in every revision, and-32602from a modern server (2026-07-28 uses-32602and asks clients to accept-32002too). A legacy-32602stays invalid-params: in those revisions it means the request was wrong, not that the resource is missing. - Pagination. A missing
nextCursorends a list in every revision. In 2026-07-28 an empty string is a cursor (“don’t make any determination based on cursor value other than whether a non-null value was provided”), so mcpx asks again with"". The legacy pages never said so, and a legacy server that writes an unset cursor as""means the end, so there""ends the list. A cursor that repeats the one just sent also ends it. - Version retry. A
-32022toserver/discoveris retried with a version fromdata.supported— first one not yet tried, then, once, one already tried that the server nonetheless lists. The official suite rejects the first request withsupportednaming the very version it rejected; retrying it once is what “select a mutually supported version and retry” means there. Each version is sent at mostupstream.versionAttempts(2) times, so a server that keeps doing it cannot loop the probe.
Lifecycle and cancellation
- Negotiated version check. If
initializereturns a version mcpx does not speak, mcpx disconnects withUnsupportedVersionErrorand does not sendnotifications/initialized. Carrying on meant speaking a revision nobody agreed to. initializeis never cancelled. Every legacy cancellation page forbids it; a timed-outinitializeused to sendnotifications/cancelled.- Modern HTTP: closing the response stream is the cancellation; no
notifications/cancelledis POSTed. Modern stdio: the notification MUST be sent, and is. Legacy: sent – including for an HTTP request timed out while its POST still waited for response headers, which was never cancelled:Sendlasts until the headers arrive, so that timeout surfaced as a send error, before the code that sendsnotifications/cancelled. pingandlogging/setLevelare not sent to a modern server — 2026-07-28 removed both.Pingbecomesserver/discover, which every modern server MUST implement and which likewise only answers. A log level becomesio.modelcontextprotocol/logLevelin every later request’s_meta(the daemon asks forinfo); without it a modern server MUST NOT log at all.- A modern response stream that closes without its response is answered at
once with an error (
-32000), where the caller used to wait for its deadline: 2026-07-28 has no resumption, and “a broken response stream loses the in-flight request”. - Capabilities.
rootsis{}on a modern request (2026-07-28 droppedlistChangedwithnotifications/roots/list_changed),{"listChanged": false}ininitialize.
Notifications from a modern server: subscriptions/listen
A modern server delivers list changes and resource updates only on a
subscriptions/listen stream. mcpx opens one per modern connection, asking only
for what the server declared (tools|prompts|resources.listChanged, and
resourceSubscriptions when resources.subscribe is declared and something is
subscribed). SubscribeResource on a modern connection adds the URI and
replaces the stream: the old one is ended the way the transport requires
(closing it over HTTP, notifications/cancelled naming the listen id on stdio)
and a new one opened with the new filter.
Notifications are correlated by _meta["io.modelcontextprotocol/subscriptionId"]
— the stdio MUST. One tagged with the id of a stream mcpx has since replaced is
dropped; untagged ones (progress, log lines, which belong to a request) pass. The
acknowledgement’s filter is compared with what was asked and anything declined
is reported as a server.warning. When the server ends the stream — gracefully
with a response or not — it is reopened after upstream.listenReopenDelay.
The spec disagrees with itself on how a server ends one (subscriptions page: a
successful response; cancellation page: notifications/cancelled naming the
listen id). mcpx as a client accepts both: a response ends the pending request,
and a cancelled notification is harmless.
Resource updates forwarded to mcpx’s own clients now carry the mcpx://
URI the client subscribed with; they carried the upstream URI, which matches no
subscription the client holds. The match is on the upstream URI, because the
event names a server and the listing names a namespace that a config may
override and mcpx serve cannot see.
Server requests and elicitation
- Ids. A server’s request id is kept raw: a string id is as valid as a number, and one was dropped as unparseable.
- Batches. A 2025-03-26 server may send a JSON array; each element is now
dispatched. On stdio the array arrived as one line starting with
[and was dropped whole. - Declarations.
elicitation.formalways (mcpx answerscancelwhen nobody is listening, which is truthful);elicitation.urlonly with a handler installed — the broker stores the URL, shows it (mcpx elicit list), takes the answer and honoursnotifications/elicitation/complete, so it is carried end to end;samplingonly with a handler. A request in a mode that was not declared for the revision in use is-32602, as the elicitation page requires: url mode to a server that negotiated 2025-06-18 is refused even thoughinitializeoffered it, because that revision has no url mode. - Defaults. An accepted form answer’s missing fields are filled from the
requested schema’s
defaults (“clients that support defaults SHOULD pre-populate form fields”); a field the answerer set is never overwritten. - The hang the official suite found. An
elicitation/createsent during atools/callover Streamable HTTP never reached the broker. The TypeScript SDK’sserver.request()inside a tool handler is not related to the POST, so it goes to the standalone GET stream — which mcpx never opened. mcpx now opens it afterinitializeon any Streamable HTTP server that negotiated 2025-03-26 or later; a server without one answers405and that is the end of it.
Resumption (2025-11-25)
A POST’s SSE stream that the server closes before the response is resumed:
wait the retry the server sent (or upstream.sseReconnectDelay), then GET with
Last-Event-ID, up to upstream.sseReconnectAttempts times. A stream with no
event id cannot be resumed and the caller is told at once. The standalone GET
stream reconnects the same way.
HTTP+SSE (2024-11-05)
When a Streamable HTTP endpoint refuses both the modern probe and initialize
with a bare 400, 404 or 405 (LegacyHTTPRefusedError), the pool GETs the
URL expecting an endpoint event, POSTs every message there and reads replies
from the stream. The endpoint must be on the stream’s origin: it is where every
message — and the configured credential headers — goes, and a server able to
name any URL could otherwise have mcpx post credentials anywhere. The result is
cached like any era, with "transport": "http+sse" in the era record, so the
next start goes straight there without the two refused POSTs.
Official suite, client leg (before → after)
| set | before | after |
|---|---|---|
--requirements 2025-11-25 | 5 passed, 64 failed | 20 passed, 56 failed |
--requirements 2026-07-28 | 23 passed, 101 failed, 1 warning | 63 passed, 68 failed, 0 warnings |
Every remaining failure is auth/*: mcpx has no OAuth client. One non-auth
scenario, json-schema-ref-no-deref, is flaky because of the adapter, not
mcpx; official-suite.md has the cause
and the current numbers. The
json-schema-2020-12-preservation pass needed the adapter
(internal/conformance/officialclient) to echo the schema back as mcpx holds
it; it used to call every tool with {}.
Not done
- OAuth. The
authconfig key is applied to every upstream connection (internal/poolresolveAuth, #240): bearer, basic and header become headers, env becomes the child’s environment,queryis added to the URL.type: oauthis refused before connecting. The spec forbids access tokens in a query string;queryexists for services that require it and sends one only when you declare it.
MCP conformance matrix
Generated by internal/conformance (MCPX_UPDATE_MATRIX=1 go test ./internal/conformance -run TestMatrix); do not edit.
Generated from inputs 247a9508b4eda6e7 (sha256 of docs/spec/requirements.md, internal/conformance/overrides/.json, internal/conformance/coverage.go, internal/conformance/authsplit.json). TestMatrix regenerates this file and fails when it differs, so it describes the tree it is committed in.
Every normative requirement of every revision (catalogue: docs/spec/requirements.md), for mcpx as a server and as a client. A requirement is tested when a named test verifies it in that revision, n/a when it does not bind mcpx in that role, and a gap when it binds mcpx and mcpx does not meet it yet, with the issue that tracks it. Run exactly the covering tests with go run ./internal/conformance/cmd/covering.
Summary
| revision | side | level | applies | tested | gap | missing | n/a |
|---|---|---|---|---|---|---|---|
| 2024-11-05 | server | MUST | 32 | 31 | 1 | 0 | 22 |
| 2024-11-05 | server | MUST NOT | 6 | 6 | 0 | 0 | 4 |
| 2024-11-05 | server | SHOULD | 36 | 35 | 1 | 0 | 45 |
| 2024-11-05 | server | SHOULD NOT | 1 | 1 | 0 | 0 | 1 |
| 2024-11-05 | server | MAY | 9 | 9 | 0 | 0 | 15 |
| 2024-11-05 | server | other | 11 | 11 | 0 | 0 | 1 |
| 2024-11-05 | client | MUST | 26 | 25 | 1 | 0 | 28 |
| 2024-11-05 | client | MUST NOT | 9 | 9 | 0 | 0 | 1 |
| 2024-11-05 | client | SHOULD | 31 | 26 | 5 | 0 | 50 |
| 2024-11-05 | client | SHOULD NOT | 1 | 1 | 0 | 0 | 1 |
| 2024-11-05 | client | MAY | 9 | 9 | 0 | 0 | 15 |
| 2024-11-05 | client | other | 7 | 7 | 0 | 0 | 5 |
| 2025-03-26 | server | MUST | 44 | 43 | 1 | 0 | 37 |
| 2025-03-26 | server | MUST NOT | 7 | 7 | 0 | 0 | 8 |
| 2025-03-26 | server | SHOULD | 45 | 44 | 1 | 0 | 58 |
| 2025-03-26 | server | SHOULD NOT | 4 | 3 | 1 | 0 | 1 |
| 2025-03-26 | server | MAY | 16 | 16 | 0 | 0 | 21 |
| 2025-03-26 | server | other | 11 | 11 | 0 | 0 | 1 |
| 2025-03-26 | client | MUST | 35 | 31 | 4 | 0 | 46 |
| 2025-03-26 | client | MUST NOT | 12 | 11 | 1 | 0 | 3 |
| 2025-03-26 | client | SHOULD | 39 | 33 | 6 | 0 | 64 |
| 2025-03-26 | client | SHOULD NOT | 2 | 1 | 1 | 0 | 3 |
| 2025-03-26 | client | MAY | 15 | 15 | 0 | 0 | 22 |
| 2025-03-26 | client | other | 7 | 7 | 0 | 0 | 5 |
| 2025-06-18 | server | MUST | 52 | 51 | 1 | 0 | 49 |
| 2025-06-18 | server | MUST NOT | 10 | 9 | 1 | 0 | 8 |
| 2025-06-18 | server | SHOULD | 48 | 47 | 1 | 0 | 67 |
| 2025-06-18 | server | SHOULD NOT | 4 | 3 | 1 | 0 | 1 |
| 2025-06-18 | server | MAY | 15 | 15 | 0 | 0 | 20 |
| 2025-06-18 | server | other | 15 | 15 | 0 | 0 | 3 |
| 2025-06-18 | client | MUST | 42 | 37 | 5 | 0 | 59 |
| 2025-06-18 | client | MUST NOT | 13 | 11 | 2 | 0 | 5 |
| 2025-06-18 | client | SHOULD | 48 | 40 | 8 | 0 | 67 |
| 2025-06-18 | client | SHOULD NOT | 2 | 1 | 1 | 0 | 3 |
| 2025-06-18 | client | MAY | 13 | 13 | 0 | 0 | 22 |
| 2025-06-18 | client | other | 10 | 8 | 2 | 0 | 8 |
| 2025-11-25 | server | MUST | 102 | 98 | 4 | 0 | 78 |
| 2025-11-25 | server | MUST NOT | 22 | 19 | 3 | 0 | 13 |
| 2025-11-25 | server | SHOULD | 73 | 65 | 8 | 0 | 86 |
| 2025-11-25 | server | SHOULD NOT | 11 | 10 | 1 | 0 | 3 |
| 2025-11-25 | server | MAY | 25 | 25 | 0 | 0 | 30 |
| 2025-11-25 | server | other | 19 | 19 | 0 | 0 | 2 |
| 2025-11-25 | client | MUST | 65 | 57 | 8 | 0 | 115 |
| 2025-11-25 | client | MUST NOT | 16 | 14 | 2 | 0 | 19 |
| 2025-11-25 | client | SHOULD | 58 | 48 | 10 | 0 | 101 |
| 2025-11-25 | client | SHOULD NOT | 5 | 4 | 1 | 0 | 9 |
| 2025-11-25 | client | MAY | 17 | 17 | 0 | 0 | 38 |
| 2025-11-25 | client | other | 13 | 11 | 2 | 0 | 8 |
| 2026-07-28 | server | MUST | 113 | 109 | 4 | 0 | 111 |
| 2026-07-28 | server | MUST NOT | 44 | 42 | 2 | 0 | 20 |
| 2026-07-28 | server | SHOULD | 69 | 65 | 4 | 0 | 91 |
| 2026-07-28 | server | SHOULD NOT | 11 | 10 | 1 | 0 | 9 |
| 2026-07-28 | server | MAY | 29 | 29 | 0 | 0 | 32 |
| 2026-07-28 | server | other | 23 | 22 | 1 | 0 | 4 |
| 2026-07-28 | client | MUST | 73 | 65 | 8 | 0 | 151 |
| 2026-07-28 | client | MUST NOT | 25 | 23 | 2 | 0 | 39 |
| 2026-07-28 | client | SHOULD | 66 | 57 | 9 | 0 | 94 |
| 2026-07-28 | client | SHOULD NOT | 13 | 11 | 2 | 0 | 7 |
| 2026-07-28 | client | MAY | 11 | 11 | 0 | 0 | 50 |
| 2026-07-28 | client | other | 13 | 11 | 2 | 0 | 14 |
2024-11-05
(security; not in area list)
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| security-implementors-guidelines | SHOULD | both | tested: conformance.TestMessagesServer/2024-11-05/security/security-implementors-guidelines | n/a: catalogue marks it n/a |
authorization
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| authorization-custom-auth-may | MAY | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
cancellation
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| cancellation-ref-same-direction-in-progress | MUST | both | tested: conformance.TestUtilitiesServer/2024-11-05/cancellation/cancellation-ref-same-direction-in-progress | tested: conformance.TestUtilitiesClient/2024-11-05/cancellation/cancellation-ref-same-direction-in-progress |
| cancellation-client-must-not-cancel-initialize | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2024-11-05/cancellation/cancellation-client-must-not-cancel-initialize |
| cancellation-receiver-should-stop-free-no-response | SHOULD | both | tested: conformance.TestUtilitiesServer/2024-11-05/cancellation/cancellation-receiver-should-stop-free-no-response | tested: conformance.TestUtilitiesClient/2024-11-05/cancellation/cancellation-receiver-should-stop-free-no-response |
| cancellation-receiver-may-ignore | MAY | both | tested: conformance.TestUtilitiesServer/2024-11-05/cancellation/cancellation-receiver-may-ignore | tested: conformance.TestUtilitiesClient/2024-11-05/cancellation/cancellation-receiver-may-ignore |
| cancellation-sender-ignore-late-response | SHOULD | both | tested: conformance.TestUtilitiesServer/2024-11-05/cancellation/cancellation-sender-ignore-late-response | tested: conformance.TestUtilitiesClient/2024-11-05/cancellation/cancellation-sender-ignore-late-response |
| cancellation-handle-races | MUST | both | tested: conformance.TestUtilitiesServer/2024-11-05/cancellation/cancellation-handle-races | tested: conformance.TestUtilitiesClient/2024-11-05/cancellation/cancellation-handle-races |
| cancellation-log-reasons | SHOULD | both | gap #77: a cancellation reason reaches Server.OnCancel, which nothing outside tests sets; it is never logged | tested: conformance.TestUtilitiesClient/2024-11-05/cancellation/cancellation-log-reasons |
| cancellation-ui-indicate | SHOULD | both | n/a: no UI | n/a: no UI |
| cancellation-ignore-invalid | SHOULD | both | tested: conformance.TestUtilitiesServer/2024-11-05/cancellation/cancellation-ignore-invalid | tested: conformance.TestUtilitiesClient/2024-11-05/cancellation/cancellation-ignore-invalid |
| cancellation-processing-should-cease | SHOULD | both (receiver) | tested: conformance.TestUtilitiesServer/2024-11-05/cancellation/cancellation-processing-should-cease | tested: conformance.TestUtilitiesClient/2024-11-05/cancellation/cancellation-processing-should-cease |
completion
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| completion-max-100 | descr. + schema | server | tested: conformance.TestUtilitiesServer/2024-11-05/completion/completion-max-100 | n/a: addresses the other side |
| completion-server-should | SHOULD | server | tested: daemon.TestHandleCompleteRateLimitHotChangeAndZeroDisables@alldaemon.TestHandleCompleteRateLimitRefusesBeforePool@alldaemon.TestHandleCompleteServerWindowBindsAcrossCallers@alldaemon.TestLocalCompletionCapAfterRanking@alldaemon.TestLocalCompletionTiersPrompts@alldaemon.TestLocalCompletionTiersResources@all | n/a: addresses the other side |
| completion-client-should | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2024-11-05/completion/completion-client-should |
| completion-security | MUST | both | gap #1968: partial: completion inputs are validated and completion/complete is rate limited per caller (completion.perMinute) and per server (completion.serverPerMinute) since #2387, but there is no per-caller access control for sensitive suggestions and no completion-specific information-disclosure control; waiting on principals #1968 / permissive-auth #1972 | n/a: catalogue marks it n/a |
errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| errors-error-has-code-and-message | MUST | both | tested: conformance.TestMessagesServer/2024-11-05/errors/errors-error-has-code-and-message | tested: conformance.TestMessagesClient/2024-11-05/errors/errors-error-has-code-and-message |
| errors-codes-integers | MUST | both | tested: conformance.TestMessagesServer/2024-11-05/errors/errors-codes-integers | tested: conformance.TestMessagesClient/2024-11-05/errors/errors-codes-integers |
| errors-message-concise-single-sentence | SHOULD | both | tested: conformance.TestMessagesServer/2024-11-05/errors/errors-message-concise-single-sentence | n/a: sends no errors except to server requests |
lifecycle
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| lifecycle-implementations-support-base-and-lifecycle | MUST | both | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-implementations-support-base-and-lifecycle | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-implementations-support-base-and-lifecycle |
| lifecycle-initialization-first-interaction | MUST | both | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-initialization-first-interaction | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-initialization-first-interaction |
| lifecycle-client-sends-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-client-sends-initialize |
| lifecycle-server-responds-caps-and-info | MUST | server | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-server-responds-caps-and-info | n/a: addresses the other side |
| lifecycle-client-sends-initialized-after-init | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-client-sends-initialized-after-init |
| lifecycle-client-no-requests-before-init-response | SHOULD NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-client-no-requests-before-init-response |
| lifecycle-server-no-requests-before-initialized | SHOULD NOT | server | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-server-no-requests-before-initialized | n/a: addresses the other side |
| lifecycle-capabilities-establish-session-features | (definitional) | both | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-capabilities-establish-session-features | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-capabilities-establish-session-features |
| lifecycle-respect-negotiated-version-and-capabilities-should | SHOULD | both | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-respect-negotiated-version-and-capabilities-should | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-respect-negotiated-version-and-capabilities-should |
| lifecycle-stdio-client-shutdown-sequence | SHOULD | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-stdio-client-shutdown-sequence |
| lifecycle-stdio-server-may-exit | MAY | server | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-stdio-server-may-exit | n/a: addresses the other side |
| lifecycle-http-shutdown-closing-connection | (definitional) | both | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-http-shutdown-closing-connection | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-http-shutdown-closing-connection |
| lifecycle-prepared-for-error-cases | SHOULD | both | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-prepared-for-error-cases | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-prepared-for-error-cases |
| lifecycle-timeouts-all-requests-2024 | SHOULD | both | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-timeouts-all-requests-2024 | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-timeouts-all-requests-2024 |
| lifecycle-cancel-not-initialize | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-cancel-not-initialize |
| lifecycle-instructions-may-go-in-system-prompt | MAY | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-instructions-may-go-in-system-prompt |
| lifecycle-capability-sets-open | (definitional) | both | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-capability-sets-open | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-capability-sets-open |
| lifecycle-implementation-name-version-required | REQUIRED (schema field) | both | tested: conformance.TestLifecycleServer/2024-11-05/lifecycle/lifecycle-implementation-name-version-required | tested: conformance.TestLifecycleClient/2024-11-05/lifecycle/lifecycle-implementation-name-version-required |
logging
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| logging-declare-capability | MUST | server | tested: conformance.TestUtilitiesServer/2024-11-05/logging/logging-declare-capability | n/a: addresses the other side |
| logging-client-may-setlevel | MAY | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2024-11-05/logging/logging-client-may-setlevel |
| logging-setlevel-at-or-above | should (schema) | server | tested: conformance.TestUtilitiesServer/2024-11-05/logging/logging-setlevel-at-or-above | n/a: addresses the other side |
| logging-unset-server-chooses | MAY | server | tested: conformance.TestUtilitiesServer/2024-11-05/logging/logging-unset-server-chooses | n/a: addresses the other side |
| logging-errors-setlevel | SHOULD | server | tested: conformance.TestUtilitiesServer/2024-11-05/logging/logging-errors-setlevel | n/a: addresses the other side |
| logging-server-hygiene | SHOULD | server | tested: conformance.TestUtilitiesServer/2024-11-05/logging/logging-server-hygiene | n/a: addresses the other side |
| logging-no-secrets-pii | MUST NOT | both (emitter) | tested: conformance.TestUtilitiesServer/2024-11-05/logging/logging-no-secrets-pii | tested: conformance.TestUtilitiesClient/2024-11-05/logging/logging-no-secrets-pii |
| logging-impl-validate-control | SHOULD | both | tested: conformance.TestUtilitiesServer/2024-11-05/logging/logging-impl-validate-control | tested: conformance.TestUtilitiesClient/2024-11-05/logging/logging-impl-validate-control |
messages
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| messages-all-messages-follow-jsonrpc-2 | MUST | both | tested: conformance.TestJSONRPCFramesEveryRevision/2024-11-05/messages/all-messages-follow-jsonrpc-2-server-httpconformance.TestJSONRPCFramesEveryRevision/2024-11-05/messages/all-messages-follow-jsonrpc-2-server-stdioconformance.TestMessagesServer/2024-11-05/messages/messages-all-messages-follow-jsonrpc-2 | tested: conformance.TestJSONRPCFramesEveryRevision/2024-11-05/messages/all-messages-follow-jsonrpc-2-client |
| messages-other-components-may | MAY | both | tested: conformance.TestMessagesServer/2024-11-05/messages/messages-other-components-may | tested: conformance.TestMessagesClient/2024-11-05/messages/messages-other-components-may |
| messages-request-id-string-or-integer | MUST | both | tested: conformance.TestMessagesServer/2024-11-05/messages/messages-request-id-string-or-integer | tested: conformance.TestMessagesClient/2024-11-05/messages/messages-request-id-string-or-integer |
| messages-request-id-not-null | MUST NOT | both | tested: conformance.TestMessagesServer/2024-11-05/messages/messages-request-id-not-null | tested: conformance.TestMessagesClient/2024-11-05/messages/messages-request-id-not-null |
| messages-request-id-unique-per-session | MUST NOT | both | tested: conformance.TestMessagesServer/2024-11-05/messages/messages-request-id-unique-per-session | tested: conformance.TestMessagesClient/2024-11-05/messages/messages-request-id-unique-per-session |
| messages-response-same-id | MUST | both | tested: conformance.TestMessagesServer/2024-11-05/messages/messages-response-same-id | tested: conformance.TestMessagesClient/2024-11-05/messages/messages-response-same-id |
| messages-response-result-xor-error | MUST / MUST NOT | both | tested: conformance.TestMessagesServer/2024-11-05/messages/messages-response-result-xor-error | tested: conformance.TestMessagesClient/2024-11-05/messages/messages-response-result-xor-error |
| messages-notification-no-id | MUST NOT | both | tested: conformance.TestMessagesServer/2024-11-05/messages/messages-notification-no-id | tested: conformance.TestMessagesClient/2024-11-05/messages/messages-notification-no-id |
meta
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
[meta-reserved-for-mcp](https://modelcontextprotocol.io/specification/GH(2025-03-26) Result._meta) | (definitional) | both | tested: conformance.TestMessagesServer/2024-11-05/meta/meta-reserved-for-mcp | tested: conformance.TestMessagesClient/2024-11-05/meta/meta-reserved-for-mcp |
| meta-progress-token-receiver-not-obligated | (definitional) | both | tested: conformance.TestMessagesServer/2024-11-05/meta/meta-progress-token-receiver-not-obligated | tested: conformance.TestMessagesClient/2024-11-05/meta/meta-progress-token-receiver-not-obligated |
pagination
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| pagination-no-fixed-page-size | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2024-11-05/pagination/pagination-no-fixed-page-size |
| pagination-stable-cursors | SHOULD | server | tested: conformance.TestUtilitiesServer/2024-11-05/pagination/pagination-stable-cursors | n/a: addresses the other side |
| pagination-invalid-cursor-graceful | SHOULD | server | tested: conformance.TestUtilitiesServer/2024-11-05/pagination/pagination-invalid-cursor-graceful | n/a: addresses the other side |
| pagination-missing-nextcursor-end | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2024-11-05/pagination/pagination-missing-nextcursor-end |
| pagination-support-both-flows | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2024-11-05/pagination/pagination-support-both-flows |
| pagination-cursor-opaque | MUST | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2024-11-05/pagination/pagination-cursor-opaque |
| pagination-no-persist-across-sessions | MUST | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2024-11-05/pagination/pagination-no-persist-across-sessions |
| pagination-invalid-cursor-32602 | SHOULD | server | tested: conformance.TestUtilitiesServer/2024-11-05/pagination/pagination-invalid-cursor-32602 | n/a: addresses the other side |
ping
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| ping-receiver-responds-promptly-empty | MUST | both | tested: conformance.TestLifecycleServer/2024-11-05/ping/ping-receiver-responds-promptly-empty | tested: conformance.TestLifecycleClient/2024-11-05/ping/ping-receiver-responds-promptly-empty |
| ping-either-party-may-initiate | (definitional) | both | tested: conformance.TestLifecycleServer/2024-11-05/ping/ping-either-party-may-initiate | tested: conformance.TestLifecycleClient/2024-11-05/ping/ping-either-party-may-initiate |
| ping-sender-may-consider-stale | MAY | both | n/a: catalogue marks it n/a | n/a: does not ping |
| ping-periodic-pings | SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| ping-frequency-configurable | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-timeouts-appropriate | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-avoid-excessive | SHOULD | both | tested: conformance.TestLifecycleServer/2024-11-05/ping/ping-avoid-excessive | tested: conformance.TestLifecycleClient/2024-11-05/ping/ping-avoid-excessive |
| ping-timeout-is-connection-failure | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-multiple-failures-may-reset | MAY | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-log-failures | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-schema-receiver-must-respond | must (lowercase) | both | tested: conformance.TestLifecycleServer/2024-11-05/ping/ping-schema-receiver-must-respond | tested: conformance.TestLifecycleClient/2024-11-05/ping/ping-schema-receiver-must-respond |
progress
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| progress-token-string-or-int | MUST | both (requester) | n/a: as a server mcpx never requests progress; a host’s token is relayed back verbatim | tested: conformance.TestUtilitiesClient/2024-11-05/progress/progress-token-string-or-int |
| progress-token-unique-sender | MUST | both | tested: conformance.TestUtilitiesServer/2024-11-05/progress/progress-token-unique-sender | tested: conformance.TestUtilitiesClient/2024-11-05/progress/progress-token-unique-sender |
| progress-receiver-may-send | MAY | both | n/a: optional feature mcpx does not implement | n/a: never receives requests with tokens |
| progress-must-increase | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2024-11-05/progress/progress-must-increase | n/a: catalogue marks it n/a |
| progress-only-active-tokens | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2024-11-05/progress/progress-only-active-tokens | n/a: catalogue marks it n/a |
| progress-receiver-may-skip | MAY | both | tested: conformance.TestUtilitiesServer/2024-11-05/progress/progress-receiver-may-skip | tested: conformance.TestUtilitiesClient/2024-11-05/progress/progress-receiver-may-skip |
| progress-track-tokens | SHOULD | both | n/a: catalogue marks it n/a | tested: conformance.TestUtilitiesClient/2024-11-05/progress/progress-track-tokens |
| progress-rate-limit | SHOULD | both | tested: conformance.TestUtilitiesServer/2024-11-05/progress/progress-rate-limit | tested: conformance.TestUtilitiesClient/2024-11-05/progress/progress-rate-limit |
| progress-stop-after-completion | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2024-11-05/progress/progress-stop-after-completion | n/a: catalogue marks it n/a |
prompts
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2024-11-05/prompts/prompts-declare-capability | n/a: addresses the other side |
| prompts-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2024-11-05/prompts/prompts-list-changed-notify | n/a: addresses the other side |
| prompts-image-base64-mime | MUST | server | tested: conformance.TestFeaturesServer/2024-11-05/prompts/prompts-image-base64-mime | n/a: addresses the other side |
| prompts-embedded-must-include | MUST | server | tested: conformance.TestFeaturesServer/2024-11-05/prompts/prompts-embedded-must-include | n/a: addresses the other side |
| prompts-validate-args | SHOULD | server | tested: conformance.TestFeaturesServer/2024-11-05/prompts/prompts-validate-args | n/a: addresses the other side |
| prompts-respect-capability-negotiation | SHOULD | both | tested: conformance.TestFeaturesServer/2024-11-05/prompts/prompts-respect-capability-negotiation | tested: conformance.TestFeaturesClient/2024-11-05/prompts/prompts-respect-capability-negotiation |
| prompts-validate-io | MUST | both | tested: conformance.TestFeaturesServer/2024-11-05/prompts/prompts-validate-io; mcpclient.TestValidatePromptResult | covered |
prompts / errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-errors-32602-32603 | SHOULD | server | tested: conformance.TestFeaturesServer/2024-11-05/prompts/prompts-errors-32602-32603 | n/a: addresses the other side |
prompts / pagination
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-client-paginate | SHOULD | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2024-11-05/prompts/prompts-client-paginate |
resources
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2024-11-05/resources/resources-declare-capability | n/a: addresses the other side |
| resources-subscribe-cap-meaning-legacy | (definition) | server | tested: e2e.TestResourceSubscriptionsReachUpstream/2025-11-25/resources/stdio-subscribe-delivers-upstream-updates-under-the-mcpx-uri@2024-11-05,2025-03-26,2025-06-18,2025-11-25 | n/a: addresses the other side |
| resources-cap-features-independent | MAY | server | tested: conformance.TestFeaturesServer/2024-11-05/resources/resources-cap-features-independent | n/a: addresses the other side |
| resources-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2024-11-05/resources/resources-list-changed-notify | n/a: addresses the other side |
| resources-updated-may-be-subresource | (note, schema) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2024-11-05/resources/resources-updated-may-be-subresource |
| resources-https-only-if-client-fetchable | SHOULD | server | tested: conformance.TestFeaturesServer/2024-11-05/resources/resources-https-only-if-client-fetchable | n/a: addresses the other side |
| resources-https-else-other-scheme | SHOULD | server | tested: conformance.TestFeaturesServer/2024-11-05/resources/resources-https-else-other-scheme | n/a: addresses the other side |
| resources-file-xdg-mime-may | MAY | server | tested: conformance.TestFeaturesServer/2024-11-05/resources/resources-file-xdg-mime-may | n/a: addresses the other side |
| resources-sec-validate-uris | MUST | server | tested: conformance.TestFeaturesServer/2024-11-05/resources/resources-sec-validate-uris | n/a: addresses the other side |
| resources-sec-access-control | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| resources-sec-binary-encoded | MUST | server | tested: conformance.TestFeaturesServer/2024-11-05/resources/resources-sec-binary-encoded | n/a: addresses the other side |
| resources-sec-permissions | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
resources / errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-not-found-32002 | SHOULD | server | tested: conformance.TestFeaturesServer/2024-11-05/resources/resources-not-found-32002 | n/a: addresses the other side |
| resources-internal-32603 | SHOULD | server | tested: conformance.TestFeaturesServer/2024-11-05/resources/resources-internal-32603 | n/a: addresses the other side |
resources / subscriptions
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-subscribe-rpc | (optional) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2024-11-05/resources/resources-subscribe-rpc |
| resources-unsubscribe-follows-subscribe | should (lowercase, schema) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2024-11-05/resources/resources-unsubscribe-follows-subscribe |
roots
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| roots-client-declare-capability-at-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2024-11-05/roots/roots-client-declare-capability-at-initialize |
| roots-client-listchanged-must-notify | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2024-11-05/roots/roots-client-listchanged-must-notify |
| roots-uri-must-be-file-scheme | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-only-expose-permitted-roots | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-validate-uris-path-traversal | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-implement-access-controls | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-monitor-accessibility | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-server-handle-unavailable-roots | SHOULD | server | n/a: mcpx does not consume roots | n/a: addresses the other side |
| roots-server-respect-boundaries | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-validate-paths-against-roots | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-client-prompt-consent-before-exposing | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-provide-root-management-ui | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-validate-accessibility-before-exposing | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-monitor-root-changes | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-server-check-capability-before-use | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-handle-list-changes | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-cache-root-information | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-client-return-jsonrpc-errors | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2024-11-05/roots/roots-client-return-jsonrpc-errors |
sampling
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| sampling-human-in-loop-can-deny | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2024-11-05/sampling/sampling-human-in-loop-can-deny |
| sampling-app-review-ui | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-app-view-edit-prompts | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-app-review-responses | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-client-declare-capability-at-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2024-11-05/sampling/sampling-client-declare-capability-at-initialize |
| sampling-client-may-sample-fewer-tokens | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-ignore-model-preferences | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-modify-system-prompt | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-ignore-includecontext | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hints-evaluated-in-order | MUST | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hints-over-priorities | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hint-name-substring | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hint-may-map-provider | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-stopreason-custom-values | MAY | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2024-11-05/sampling/sampling-stopreason-custom-values |
| sampling-client-errors-user-rejected | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2024-11-05/sampling/sampling-client-errors-user-rejected |
| sampling-client-user-approval-controls | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-both-validate-message-content | SHOULD | both | tested: conformance.TestSamplingValidation/2024-11-05/sampling/sampling-both-validate-message-content | tested: conformance.TestSamplingValidation/2024-11-05/sampling/sampling-both-validate-message-content |
| sampling-client-respect-hints | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-rate-limit | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2024-11-05/sampling/sampling-client-rate-limit |
| sampling-both-handle-sensitive-data | MUST | both | tested: daemon.TestElicitEventsAreSummaryOnly@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28elicit.TestOpenPrunesTerminalRowsPastTheAgeBound@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28 | tested: daemon.TestElicitEventsAreSummaryOnly@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28elicit.TestOpenPrunesTerminalRowsPastTheAgeBound@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28 |
subscriptions
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| subscriptions-list-changed-should-notify | SHOULD | server | tested: conformance.TestStatefulServer/2024-11-05/subscriptions/subscriptions-list-changed-should-notify | n/a: addresses the other side |
| subscriptions-resources-updated-only-if-subscribed | should (schema) | server | tested: conformance.TestStatefulServer/2024-11-05/subscriptions/subscriptions-resources-updated-only-if-subscribed | n/a: addresses the other side |
tools
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tools-human-in-loop-deny | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-ui-expose-indicate-confirm | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2024-11-05/tools/tools-declare-capability | n/a: addresses the other side |
| tools-list-changed-means-emit | (definition) | server | tested: conformance.TestFeaturesServer/2024-11-05/tools/tools-list-changed-means-emit | n/a: addresses the other side |
| tools-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2024-11-05/tools/tools-list-changed-notify | n/a: addresses the other side |
| tools-result-iserror-for-tool-errors | SHOULD | server | tested: conformance.TestFeaturesServer/2024-11-05/tools/tools-result-iserror-for-tool-errors | n/a: addresses the other side |
| tools-result-lookup-errors-protocol | should (lowercase) | server | tested: conformance.TestFeaturesServer/2024-11-05/tools/tools-result-lookup-errors-protocol | n/a: addresses the other side |
| tools-error-unknown-tool-protocol | (classification) | server | tested: conformance.TestFeaturesServer/2024-11-05/tools/tools-error-unknown-tool-protocol | n/a: addresses the other side |
| tools-error-invalid-args-protocol | (classification) | server | tested: conformance.TestFeaturesServer/2024-11-05/tools/tools-error-invalid-args-protocol | n/a: addresses the other side |
| tools-embedded-resource-may | MAY | server | tested: conformance.TestFeaturesServer/2024-11-05/tools/tools-embedded-resource-may | n/a: addresses the other side |
| tools-server-sec-validate-inputs | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| tools-client-sec | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
tools / json-schema
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tools-inputschema-type-object-root | REQUIRED (schema) | server | tested: conformance.TestFeaturesServer/2024-11-05/tools/tools-inputschema-type-object-root | n/a: addresses the other side |
transport-http-sse
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| http-sse-server-validate-origin | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| http-sse-server-bind-localhost | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| http-sse-server-authenticate | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| http-sse-server-two-endpoints | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| http-sse-server-endpoint-event | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| http-sse-client-post-to-endpoint | MUST | client | n/a: addresses the other side | tested: mcpclient.TestHTTPSSETransport/2024-11-05/transport-http-sse/client-posts-to-endpoint-and-reads-stream |
| http-sse-server-messages-as-sse-events | MUST (implied) | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
transport-stdio
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| transport-client-should-support-stdio | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2024-11-05/transport-stdio/transport-client-should-support-stdio |
| transport-custom-preserve-jsonrpc-and-lifecycle | MUST | both | n/a: mcpx serves MCP only over stdio and Streamable HTTP; its /v1 REST surface is not an MCP transport | n/a: catalogue marks it n/a |
| transport-custom-document-patterns | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| stdio-no-embedded-newlines | MUST NOT | both | tested: conformance.TestTransportServer/2024-11-05/transport-stdio/stdio-no-embedded-newlines | tested: conformance.TestTransportClient/2024-11-05/transport-stdio/stdio-no-embedded-newlines |
| stdio-server-may-log-to-stderr | MAY | server | tested: conformance.TestTransportServer/2024-11-05/transport-stdio/stdio-server-may-log-to-stderr | n/a: addresses the other side |
| stdio-client-may-capture-stderr | MAY | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2024-11-05/transport-stdio/stdio-client-may-capture-stderr |
| stdio-server-stdout-only-mcp | MUST NOT | server | tested: conformance.TestTransportServer/2024-11-05/transport-stdio/stdio-server-stdout-only-mcp | n/a: addresses the other side |
| stdio-client-stdin-only-mcp | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2024-11-05/transport-stdio/stdio-client-stdin-only-mcp |
versioning
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| lifecycle-client-sends-supported-version | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2024-11-05/versioning/lifecycle-client-sends-supported-version |
| lifecycle-client-sends-latest-version | SHOULD | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2024-11-05/versioning/lifecycle-client-sends-latest-version |
| lifecycle-client-may-support-older | MAY | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2024-11-05/versioning/lifecycle-client-may-support-older |
| lifecycle-server-echoes-supported-version | MUST | server | tested: conformance.TestLifecycleServer/2024-11-05/versioning/lifecycle-server-echoes-supported-version | n/a: addresses the other side |
| lifecycle-server-counteroffers-version | MUST | server | tested: conformance.TestLifecycleServer/2024-11-05/versioning/lifecycle-server-counteroffers-version | n/a: addresses the other side |
| lifecycle-server-counteroffers-latest | SHOULD | server | tested: conformance.TestLifecycleServer/2024-11-05/versioning/lifecycle-server-counteroffers-latest | n/a: addresses the other side |
| lifecycle-client-disconnects-on-unsupported-version | SHOULD (prose) / MUST (schema) | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2024-11-05/versioning/lifecycle-client-disconnects-on-unsupported-version |
2025-03-26
(security; not in area list)
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| security-implementors-guidelines | SHOULD | both | tested: conformance.TestMessagesServer/2025-03-26/security/security-implementors-guidelines | n/a: catalogue marks it n/a |
authorization
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| authorization-http-should-conform-stdio-should-not | SHOULD / SHOULD NOT | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authorization-custom-auth-may | MAY | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| streamable-http-auth-discovery-version-header | SHOULD | client | n/a: addresses the other side | n/a: no OAuth |
| authz-optional | OPTIONAL | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-http-should-conform | SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-stdio-use-environment | SHOULD NOT | both | gap not implemented: #1975 | gap not implemented: #1950 |
| authz-alt-transport-best-practice | MUST | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-implement-oauth21 | MUST | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-dcr-should | SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-rfc8414-server-should-client-must | SHOULD / MUST | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-grant-types | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-401-when-unauthorized | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-base-url-discards-path | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-default-endpoints-fallback | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-bearer-header | MUST | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-token-every-request | MUST | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-no-token-in-query | MUST NOT | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-server-validates-token | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-error-status-codes | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-2025-03-26-security-list | MUST / SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-2025-03-26-implementation-reqs | MUST / REQUIRED / SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-third-party-session-binding | MAY / MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
cancellation
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| cancellation-ref-same-direction-in-progress | MUST | both | tested: conformance.TestUtilitiesServer/2025-03-26/cancellation/cancellation-ref-same-direction-in-progress | tested: conformance.TestUtilitiesClient/2025-03-26/cancellation/cancellation-ref-same-direction-in-progress |
| cancellation-client-must-not-cancel-initialize | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-03-26/cancellation/cancellation-client-must-not-cancel-initialize |
| cancellation-receiver-should-stop-free-no-response | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-03-26/cancellation/cancellation-receiver-should-stop-free-no-response | tested: conformance.TestUtilitiesClient/2025-03-26/cancellation/cancellation-receiver-should-stop-free-no-response |
| cancellation-receiver-may-ignore | MAY | both | tested: conformance.TestUtilitiesServer/2025-03-26/cancellation/cancellation-receiver-may-ignore | tested: conformance.TestUtilitiesClient/2025-03-26/cancellation/cancellation-receiver-may-ignore |
| cancellation-sender-ignore-late-response | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-03-26/cancellation/cancellation-sender-ignore-late-response | tested: conformance.TestUtilitiesClient/2025-03-26/cancellation/cancellation-sender-ignore-late-response |
| cancellation-handle-races | MUST | both | tested: conformance.TestUtilitiesServer/2025-03-26/cancellation/cancellation-handle-races | tested: conformance.TestUtilitiesClient/2025-03-26/cancellation/cancellation-handle-races |
| cancellation-log-reasons | SHOULD | both | gap #77: a cancellation reason reaches Server.OnCancel, which nothing outside tests sets; it is never logged | tested: conformance.TestUtilitiesClient/2025-03-26/cancellation/cancellation-log-reasons |
| cancellation-ui-indicate | SHOULD | both | n/a: no UI | n/a: no UI |
| cancellation-ignore-invalid | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-03-26/cancellation/cancellation-ignore-invalid | tested: conformance.TestUtilitiesClient/2025-03-26/cancellation/cancellation-ignore-invalid |
| cancellation-processing-should-cease | SHOULD | both (receiver) | tested: conformance.TestUtilitiesServer/2025-03-26/cancellation/cancellation-processing-should-cease | tested: conformance.TestUtilitiesClient/2025-03-26/cancellation/cancellation-processing-should-cease |
completion
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| completion-declare-capability | MUST | server | tested: conformance.TestUtilitiesServer/2025-03-26/completion/completion-declare-capability | n/a: addresses the other side |
| completion-max-100 | descr. + schema | server | tested: conformance.TestUtilitiesServer/2025-03-26/completion/completion-max-100 | n/a: addresses the other side |
| completion-server-should | SHOULD | server | tested: daemon.TestHandleCompleteRateLimitHotChangeAndZeroDisables@alldaemon.TestHandleCompleteRateLimitRefusesBeforePool@alldaemon.TestHandleCompleteServerWindowBindsAcrossCallers@alldaemon.TestLocalCompletionCapAfterRanking@alldaemon.TestLocalCompletionTiersPrompts@alldaemon.TestLocalCompletionTiersResources@all | n/a: addresses the other side |
| completion-client-should | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-03-26/completion/completion-client-should |
| completion-security | MUST | both | gap #1968: partial: completion inputs are validated and completion/complete is rate limited per caller (completion.perMinute) and per server (completion.serverPerMinute) since #2387, but there is no per-caller access control for sensitive suggestions and no completion-specific information-disclosure control; waiting on principals #1968 / permissive-auth #1972 | n/a: catalogue marks it n/a |
| completion-errors | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-03-26/completion/completion-errors | n/a: addresses the other side |
errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| errors-error-has-code-and-message | MUST | both | tested: conformance.TestMessagesServer/2025-03-26/errors/errors-error-has-code-and-message | tested: conformance.TestMessagesClient/2025-03-26/errors/errors-error-has-code-and-message |
| errors-codes-integers | MUST | both | tested: conformance.TestMessagesServer/2025-03-26/errors/errors-codes-integers | tested: conformance.TestMessagesClient/2025-03-26/errors/errors-codes-integers |
| errors-message-concise-single-sentence | SHOULD | both | tested: conformance.TestMessagesServer/2025-03-26/errors/errors-message-concise-single-sentence | n/a: sends no errors except to server requests |
lifecycle
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| lifecycle-implementations-support-base-and-lifecycle | MUST | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-implementations-support-base-and-lifecycle | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-implementations-support-base-and-lifecycle |
| lifecycle-initialize-not-in-batch | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-initialize-not-in-batch |
| lifecycle-initialization-first-interaction | MUST | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-initialization-first-interaction | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-initialization-first-interaction |
| lifecycle-client-sends-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-client-sends-initialize |
| lifecycle-server-responds-caps-and-info | MUST | server | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-server-responds-caps-and-info | n/a: addresses the other side |
| lifecycle-client-sends-initialized-after-init | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-client-sends-initialized-after-init |
| lifecycle-client-no-requests-before-init-response | SHOULD NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-client-no-requests-before-init-response |
| lifecycle-server-no-requests-before-initialized | SHOULD NOT | server | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-server-no-requests-before-initialized | n/a: addresses the other side |
| lifecycle-capabilities-establish-session-features | (definitional) | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-capabilities-establish-session-features | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-capabilities-establish-session-features |
| lifecycle-respect-negotiated-version-and-capabilities-should | SHOULD | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-respect-negotiated-version-and-capabilities-should | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-respect-negotiated-version-and-capabilities-should |
| lifecycle-stdio-client-shutdown-sequence | SHOULD | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-stdio-client-shutdown-sequence |
| lifecycle-stdio-server-may-exit | MAY | server | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-stdio-server-may-exit | n/a: addresses the other side |
| lifecycle-http-shutdown-closing-connection | (definitional) | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-http-shutdown-closing-connection | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-http-shutdown-closing-connection |
| lifecycle-prepared-for-error-cases | SHOULD | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-prepared-for-error-cases | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-prepared-for-error-cases |
| lifecycle-timeouts-establish | SHOULD | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-timeouts-establish | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-timeouts-establish |
| lifecycle-timeouts-cancel-on-expiry | SHOULD | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-timeouts-cancel-on-expiry | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-timeouts-cancel-on-expiry |
| lifecycle-timeouts-per-request-configurable | SHOULD | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-timeouts-per-request-configurable | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-timeouts-per-request-configurable |
| lifecycle-timeouts-may-reset-on-progress | MAY | both | n/a: catalogue marks it n/a | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-timeouts-may-reset-on-progress |
| lifecycle-timeouts-max-regardless-of-progress | SHOULD | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-timeouts-max-regardless-of-progress | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-timeouts-max-regardless-of-progress |
| lifecycle-cancel-not-initialize | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-cancel-not-initialize |
| lifecycle-instructions-may-go-in-system-prompt | MAY | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-instructions-may-go-in-system-prompt |
| lifecycle-capability-sets-open | (definitional) | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-capability-sets-open | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-capability-sets-open |
| lifecycle-implementation-name-version-required | REQUIRED (schema field) | both | tested: conformance.TestLifecycleServer/2025-03-26/lifecycle/lifecycle-implementation-name-version-required | tested: conformance.TestLifecycleClient/2025-03-26/lifecycle/lifecycle-implementation-name-version-required |
logging
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| logging-declare-capability | MUST | server | tested: conformance.TestUtilitiesServer/2025-03-26/logging/logging-declare-capability | n/a: addresses the other side |
| logging-client-may-setlevel | MAY | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-03-26/logging/logging-client-may-setlevel |
| logging-setlevel-at-or-above | should (schema) | server | tested: conformance.TestUtilitiesServer/2025-03-26/logging/logging-setlevel-at-or-above | n/a: addresses the other side |
| logging-unset-server-chooses | MAY | server | tested: conformance.TestUtilitiesServer/2025-03-26/logging/logging-unset-server-chooses | n/a: addresses the other side |
| logging-errors-setlevel | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-03-26/logging/logging-errors-setlevel | n/a: addresses the other side |
| logging-server-hygiene | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-03-26/logging/logging-server-hygiene | n/a: addresses the other side |
| logging-no-secrets-pii | MUST NOT | both (emitter) | tested: conformance.TestUtilitiesServer/2025-03-26/logging/logging-no-secrets-pii | tested: conformance.TestUtilitiesClient/2025-03-26/logging/logging-no-secrets-pii |
| logging-impl-validate-control | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-03-26/logging/logging-impl-validate-control | tested: conformance.TestUtilitiesClient/2025-03-26/logging/logging-impl-validate-control |
messages
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| messages-all-messages-follow-jsonrpc-2 | MUST | both | tested: conformance.TestJSONRPCFramesEveryRevision/2025-03-26/messages/all-messages-follow-jsonrpc-2-server-httpconformance.TestJSONRPCFramesEveryRevision/2025-03-26/messages/all-messages-follow-jsonrpc-2-server-stdioconformance.TestMessagesServer/2025-03-26/messages/messages-all-messages-follow-jsonrpc-2 | tested: conformance.TestJSONRPCFramesEveryRevision/2025-03-26/messages/all-messages-follow-jsonrpc-2-client |
| messages-other-components-may | MAY | both | tested: conformance.TestMessagesServer/2025-03-26/messages/messages-other-components-may | tested: conformance.TestMessagesClient/2025-03-26/messages/messages-other-components-may |
| messages-request-id-string-or-integer | MUST | both | tested: conformance.TestMessagesServer/2025-03-26/messages/messages-request-id-string-or-integer | tested: conformance.TestMessagesClient/2025-03-26/messages/messages-request-id-string-or-integer |
| messages-request-id-not-null | MUST NOT | both | tested: conformance.TestMessagesServer/2025-03-26/messages/messages-request-id-not-null | tested: conformance.TestMessagesClient/2025-03-26/messages/messages-request-id-not-null |
| messages-request-id-unique-per-session | MUST NOT | both | tested: conformance.TestMessagesServer/2025-03-26/messages/messages-request-id-unique-per-session | tested: conformance.TestMessagesClient/2025-03-26/messages/messages-request-id-unique-per-session |
| messages-response-same-id | MUST | both | tested: conformance.TestMessagesServer/2025-03-26/messages/messages-response-same-id | tested: conformance.TestMessagesClient/2025-03-26/messages/messages-response-same-id |
| messages-response-result-xor-error | MUST / MUST NOT | both | tested: conformance.TestMessagesServer/2025-03-26/messages/messages-response-result-xor-error | tested: conformance.TestMessagesClient/2025-03-26/messages/messages-response-result-xor-error |
| messages-result-any-object | MAY | both | tested: conformance.TestMessagesServer/2025-03-26/messages/messages-result-any-object | tested: conformance.TestMessagesClient/2025-03-26/messages/messages-result-any-object |
| messages-notification-no-response | MUST NOT | both | tested: conformance.TestMessagesServer/2025-03-26/messages/messages-notification-no-response | tested: conformance.TestMessagesClient/2025-03-26/messages/messages-notification-no-response |
| messages-notification-no-id | MUST NOT | both | tested: conformance.TestMessagesServer/2025-03-26/messages/messages-notification-no-id | tested: conformance.TestMessagesClient/2025-03-26/messages/messages-notification-no-id |
| messages-batch-may-send-must-receive | MAY / MUST | both | tested: conformance.TestMessagesServer/2025-03-26/messages/messages-batch-may-send-must-receive | tested: conformance.TestMessagesClient/2025-03-26/messages/messages-batch-may-send-must-receive |
| transport-jsonrpc-utf8 | MUST | both | tested: conformance.TestMessagesServer/2025-03-26/messages/transport-jsonrpc-utf8 | tested: conformance.TestMessagesClient/2025-03-26/messages/transport-jsonrpc-utf8 |
meta
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
[meta-reserved-for-mcp](https://modelcontextprotocol.io/specification/GH(2025-03-26) Result._meta) | (definitional) | both | tested: conformance.TestMessagesServer/2025-03-26/meta/meta-reserved-for-mcp | tested: conformance.TestMessagesClient/2025-03-26/meta/meta-reserved-for-mcp |
| meta-progress-token-receiver-not-obligated | (definitional) | both | tested: conformance.TestMessagesServer/2025-03-26/meta/meta-progress-token-receiver-not-obligated | tested: conformance.TestMessagesClient/2025-03-26/meta/meta-progress-token-receiver-not-obligated |
pagination
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| pagination-no-fixed-page-size | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-03-26/pagination/pagination-no-fixed-page-size |
| pagination-stable-cursors | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-03-26/pagination/pagination-stable-cursors | n/a: addresses the other side |
| pagination-invalid-cursor-graceful | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-03-26/pagination/pagination-invalid-cursor-graceful | n/a: addresses the other side |
| pagination-missing-nextcursor-end | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-03-26/pagination/pagination-missing-nextcursor-end |
| pagination-support-both-flows | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-03-26/pagination/pagination-support-both-flows |
| pagination-cursor-opaque | MUST | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-03-26/pagination/pagination-cursor-opaque |
| pagination-no-persist-across-sessions | MUST | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-03-26/pagination/pagination-no-persist-across-sessions |
| pagination-invalid-cursor-32602 | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-03-26/pagination/pagination-invalid-cursor-32602 | n/a: addresses the other side |
ping
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| ping-receiver-responds-promptly-empty | MUST | both | tested: conformance.TestLifecycleServer/2025-03-26/ping/ping-receiver-responds-promptly-empty | tested: conformance.TestLifecycleClient/2025-03-26/ping/ping-receiver-responds-promptly-empty |
| ping-either-party-may-initiate | (definitional) | both | tested: conformance.TestLifecycleServer/2025-03-26/ping/ping-either-party-may-initiate | tested: conformance.TestLifecycleClient/2025-03-26/ping/ping-either-party-may-initiate |
| ping-sender-may-consider-stale | MAY | both | n/a: catalogue marks it n/a | n/a: does not ping |
| ping-periodic-pings | SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| ping-frequency-configurable | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-timeouts-appropriate | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-avoid-excessive | SHOULD | both | tested: conformance.TestLifecycleServer/2025-03-26/ping/ping-avoid-excessive | tested: conformance.TestLifecycleClient/2025-03-26/ping/ping-avoid-excessive |
| ping-timeout-is-connection-failure | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-multiple-failures-may-reset | MAY | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-log-failures | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-schema-receiver-must-respond | must (lowercase) | both | tested: conformance.TestLifecycleServer/2025-03-26/ping/ping-schema-receiver-must-respond | tested: conformance.TestLifecycleClient/2025-03-26/ping/ping-schema-receiver-must-respond |
progress
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| progress-token-string-or-int | MUST | both (requester) | n/a: as a server mcpx never requests progress; a host’s token is relayed back verbatim | tested: conformance.TestUtilitiesClient/2025-03-26/progress/progress-token-string-or-int |
| progress-token-unique-sender | MUST | both | tested: conformance.TestUtilitiesServer/2025-03-26/progress/progress-token-unique-sender | tested: conformance.TestUtilitiesClient/2025-03-26/progress/progress-token-unique-sender |
| progress-receiver-may-send | MAY | both | n/a: optional feature mcpx does not implement | n/a: never receives requests with tokens |
| progress-must-increase | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2025-03-26/progress/progress-must-increase | n/a: catalogue marks it n/a |
| progress-message-human-readable | SHOULD | both (notifier) | tested: conformance.TestUtilitiesServer/2025-03-26/progress/progress-message-human-readable | n/a: catalogue marks it n/a |
| progress-only-active-tokens | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2025-03-26/progress/progress-only-active-tokens | n/a: catalogue marks it n/a |
| progress-receiver-may-skip | MAY | both | tested: conformance.TestUtilitiesServer/2025-03-26/progress/progress-receiver-may-skip | tested: conformance.TestUtilitiesClient/2025-03-26/progress/progress-receiver-may-skip |
| progress-track-tokens | SHOULD | both | n/a: catalogue marks it n/a | tested: conformance.TestUtilitiesClient/2025-03-26/progress/progress-track-tokens |
| progress-rate-limit | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-03-26/progress/progress-rate-limit | tested: conformance.TestUtilitiesClient/2025-03-26/progress/progress-rate-limit |
| progress-stop-after-completion | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2025-03-26/progress/progress-stop-after-completion | n/a: catalogue marks it n/a |
prompts
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2025-03-26/prompts/prompts-declare-capability | n/a: addresses the other side |
| prompts-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2025-03-26/prompts/prompts-list-changed-notify | n/a: addresses the other side |
| prompts-image-base64-mime | MUST | server | tested: conformance.TestFeaturesServer/2025-03-26/prompts/prompts-image-base64-mime | n/a: addresses the other side |
| prompts-audio-base64-mime | MUST (not bolded) | server | tested: conformance.TestFeaturesServer/2025-03-26/prompts/prompts-audio-base64-mime | n/a: addresses the other side |
| prompts-embedded-must-include | MUST | server | tested: conformance.TestFeaturesServer/2025-03-26/prompts/prompts-embedded-must-include | n/a: addresses the other side |
| prompts-validate-args | SHOULD | server | tested: conformance.TestFeaturesServer/2025-03-26/prompts/prompts-validate-args | n/a: addresses the other side |
| prompts-respect-capability-negotiation | SHOULD | both | tested: conformance.TestFeaturesServer/2025-03-26/prompts/prompts-respect-capability-negotiation | tested: conformance.TestFeaturesClient/2025-03-26/prompts/prompts-respect-capability-negotiation |
| prompts-validate-io | MUST | both | tested: conformance.TestFeaturesServer/2025-03-26/prompts/prompts-validate-io; mcpclient.TestValidatePromptResult | covered |
prompts / errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-errors-32602-32603 | SHOULD | server | tested: conformance.TestFeaturesServer/2025-03-26/prompts/prompts-errors-32602-32603 | n/a: addresses the other side |
prompts / pagination
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-client-paginate | SHOULD | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-03-26/prompts/prompts-client-paginate |
resources
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2025-03-26/resources/resources-declare-capability | n/a: addresses the other side |
| resources-subscribe-cap-meaning-legacy | (definition) | server | tested: e2e.TestResourceSubscriptionsReachUpstream/2025-11-25/resources/http-subscribe-delivers-upstream-updates-on-the-get-stream@2025-03-26,2025-06-18,2025-11-25e2e.TestResourceSubscriptionsReachUpstream/2025-11-25/resources/stdio-subscribe-delivers-upstream-updates-under-the-mcpx-uri@2024-11-05,2025-03-26,2025-06-18,2025-11-25 | n/a: addresses the other side |
| resources-cap-features-independent | MAY | server | tested: conformance.TestFeaturesServer/2025-03-26/resources/resources-cap-features-independent | n/a: addresses the other side |
| resources-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2025-03-26/resources/resources-list-changed-notify | n/a: addresses the other side |
| resources-updated-may-be-subresource | (note, schema) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-03-26/resources/resources-updated-may-be-subresource |
| resources-https-only-if-client-fetchable | SHOULD | server | tested: conformance.TestFeaturesServer/2025-03-26/resources/resources-https-only-if-client-fetchable | n/a: addresses the other side |
| resources-https-else-other-scheme | SHOULD | server | tested: conformance.TestFeaturesServer/2025-03-26/resources/resources-https-else-other-scheme | n/a: addresses the other side |
| resources-file-xdg-mime-may | MAY | server | tested: conformance.TestFeaturesServer/2025-03-26/resources/resources-file-xdg-mime-may | n/a: addresses the other side |
| resources-sec-validate-uris | MUST | server | tested: conformance.TestFeaturesServer/2025-03-26/resources/resources-sec-validate-uris | n/a: addresses the other side |
| resources-sec-access-control | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| resources-sec-binary-encoded | MUST | server | tested: conformance.TestFeaturesServer/2025-03-26/resources/resources-sec-binary-encoded | n/a: addresses the other side |
| resources-sec-permissions | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
resources / errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-not-found-32002 | SHOULD | server | tested: conformance.TestFeaturesServer/2025-03-26/resources/resources-not-found-32002 | n/a: addresses the other side |
| resources-internal-32603 | SHOULD | server | tested: conformance.TestFeaturesServer/2025-03-26/resources/resources-internal-32603 | n/a: addresses the other side |
resources / subscriptions
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-subscribe-rpc | (optional) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-03-26/resources/resources-subscribe-rpc |
| resources-unsubscribe-follows-subscribe | should (lowercase, schema) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-03-26/resources/resources-unsubscribe-follows-subscribe |
roots
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| roots-client-declare-capability-at-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-03-26/roots/roots-client-declare-capability-at-initialize |
| roots-client-listchanged-must-notify | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-03-26/roots/roots-client-listchanged-must-notify |
| roots-uri-must-be-file-scheme | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-only-expose-permitted-roots | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-validate-uris-path-traversal | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-implement-access-controls | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-monitor-accessibility | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-server-handle-unavailable-roots | SHOULD | server | n/a: mcpx does not consume roots | n/a: addresses the other side |
| roots-server-respect-boundaries | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-validate-paths-against-roots | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-client-prompt-consent-before-exposing | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-provide-root-management-ui | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-validate-accessibility-before-exposing | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-monitor-root-changes | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-server-check-capability-before-use | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-handle-list-changes | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-cache-root-information | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-client-return-jsonrpc-errors | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-03-26/roots/roots-client-return-jsonrpc-errors |
sampling
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| sampling-human-in-loop-can-deny | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-03-26/sampling/sampling-human-in-loop-can-deny |
| sampling-app-review-ui | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-app-view-edit-prompts | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-app-review-responses | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-client-declare-capability-at-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-03-26/sampling/sampling-client-declare-capability-at-initialize |
| sampling-client-may-sample-fewer-tokens | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-ignore-model-preferences | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-modify-system-prompt | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-ignore-includecontext | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hints-evaluated-in-order | MUST | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hints-over-priorities | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hint-name-substring | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hint-may-map-provider | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-stopreason-custom-values | MAY | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-03-26/sampling/sampling-stopreason-custom-values |
| sampling-client-errors-user-rejected | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-03-26/sampling/sampling-client-errors-user-rejected |
| sampling-client-user-approval-controls | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-both-validate-message-content | SHOULD | both | tested: conformance.TestSamplingValidation/2025-03-26/sampling/sampling-both-validate-message-content | tested: conformance.TestSamplingValidation/2025-03-26/sampling/sampling-both-validate-message-content |
| sampling-client-respect-hints | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-rate-limit | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-03-26/sampling/sampling-client-rate-limit |
| sampling-both-handle-sensitive-data | MUST | both | tested: daemon.TestElicitEventsAreSummaryOnly@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28elicit.TestOpenPrunesTerminalRowsPastTheAgeBound@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28 | tested: daemon.TestElicitEventsAreSummaryOnly@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28elicit.TestOpenPrunesTerminalRowsPastTheAgeBound@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28 |
subscriptions
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| subscriptions-list-changed-should-notify | SHOULD | server | tested: conformance.TestStatefulServer/2025-03-26/subscriptions/subscriptions-list-changed-should-notify | n/a: addresses the other side |
| subscriptions-resources-updated-only-if-subscribed | should (schema) | server | tested: conformance.TestStatefulServer/2025-03-26/subscriptions/subscriptions-resources-updated-only-if-subscribed | n/a: addresses the other side |
tools
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tools-human-in-loop-deny | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-ui-expose-indicate-confirm | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2025-03-26/tools/tools-declare-capability | n/a: addresses the other side |
| tools-list-changed-means-emit | (definition) | server | tested: conformance.TestFeaturesServer/2025-03-26/tools/tools-list-changed-means-emit | n/a: addresses the other side |
| tools-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2025-03-26/tools/tools-list-changed-notify | n/a: addresses the other side |
| tools-annotations-untrusted | MUST | client | n/a: addresses the other side | tested: daemon.TestAnnotationClearsCallOnlyForLocallyDeclaredServers@2026-07-28 |
| tools-annotations-hints-no-decisions | should (lowercase, schema) | client | n/a: addresses the other side | tested: daemon.TestAnnotationClearsCallOnlyForLocallyDeclaredServers@2026-07-28 |
| tools-result-iserror-for-tool-errors | SHOULD | server | tested: conformance.TestFeaturesServer/2025-03-26/tools/tools-result-iserror-for-tool-errors | n/a: addresses the other side |
| tools-result-lookup-errors-protocol | should (lowercase) | server | tested: conformance.TestFeaturesServer/2025-03-26/tools/tools-result-lookup-errors-protocol | n/a: addresses the other side |
| tools-error-unknown-tool-protocol | (classification) | server | tested: conformance.TestFeaturesServer/2025-03-26/tools/tools-error-unknown-tool-protocol | n/a: addresses the other side |
| tools-error-invalid-args-protocol | (classification) | server | tested: conformance.TestFeaturesServer/2025-03-26/tools/tools-error-invalid-args-protocol | n/a: addresses the other side |
| tools-embedded-resource-may | MAY | server | tested: conformance.TestFeaturesServer/2025-03-26/tools/tools-embedded-resource-may | n/a: addresses the other side |
| tools-server-sec-validate-inputs | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| tools-client-sec | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
tools / json-schema
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tools-inputschema-type-object-root | REQUIRED (schema) | server | tested: conformance.TestFeaturesServer/2025-03-26/tools/tools-inputschema-type-object-root | n/a: addresses the other side |
transport-http-sse
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| http-sse-server-backcompat-host-both | should (lowercase) | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| http-sse-client-backcompat-probe-any-4xx | should (lowercase) | client | n/a: addresses the other side | tested: pool.TestHTTPSSEFallback/2025-11-25/transport-http-sse/client-falls-back-to-get-endpoint-and-caches-it@2025-03-26,2025-06-18 |
transport-stdio
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| transport-client-should-support-stdio | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-03-26/transport-stdio/transport-client-should-support-stdio |
| transport-custom-preserve-jsonrpc-and-lifecycle | MUST | both | n/a: mcpx serves MCP only over stdio and Streamable HTTP; its /v1 REST surface is not an MCP transport | n/a: catalogue marks it n/a |
| transport-custom-document-patterns | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| stdio-no-embedded-newlines | MUST NOT | both | tested: conformance.TestTransportServer/2025-03-26/transport-stdio/stdio-no-embedded-newlines | tested: conformance.TestTransportClient/2025-03-26/transport-stdio/stdio-no-embedded-newlines |
| stdio-message-may-be-batch | MAY (implied) | both | tested: conformance.TestTransportServer/2025-03-26/transport-stdio/stdio-message-may-be-batch | tested: mcpclient.TestReceivedBatchesAreSplit/2025-03-26/messages/client-accepts-batched-server-messages |
| stdio-server-may-log-to-stderr | MAY | server | tested: conformance.TestTransportServer/2025-03-26/transport-stdio/stdio-server-may-log-to-stderr | n/a: addresses the other side |
| stdio-client-may-capture-stderr | MAY | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-03-26/transport-stdio/stdio-client-may-capture-stderr |
| stdio-server-stdout-only-mcp | MUST NOT | server | tested: conformance.TestTransportServer/2025-03-26/transport-stdio/stdio-server-stdout-only-mcp | n/a: addresses the other side |
| stdio-client-stdin-only-mcp | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-03-26/transport-stdio/stdio-client-stdin-only-mcp |
transport-streamable-http
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| streamable-http-server-single-endpoint-post-get | MUST | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-server-single-endpoint-post-get | n/a: addresses the other side |
| streamable-http-server-validate-origin | MUST | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-server-validate-origin | n/a: addresses the other side |
| streamable-http-server-bind-localhost | SHOULD | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-server-bind-localhost | n/a: addresses the other side |
| streamable-http-server-authenticate | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| streamable-http-client-post-every-message | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-03-26/transport-streamable-http/streamable-http-client-post-every-message |
| streamable-http-client-accept-both | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-03-26/transport-streamable-http/streamable-http-client-accept-both |
| streamable-http-post-body-may-batch | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-03-26/transport-streamable-http/streamable-http-post-body-may-batch |
| streamable-http-server-202-for-non-request | MUST | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-server-202-for-non-request | n/a: addresses the other side |
| streamable-http-server-error-status-if-rejected | MUST | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-server-error-status-if-rejected | n/a: addresses the other side |
| streamable-http-server-json-or-sse-for-request | MUST | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-server-json-or-sse-for-request | n/a: addresses the other side |
| streamable-http-client-supports-json-and-sse | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-03-26/transport-streamable-http/streamable-http-client-supports-json-and-sse |
| streamable-http-sse-includes-response | SHOULD | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-sse-includes-response | n/a: addresses the other side |
| streamable-http-post-sse-may-carry-requests | MAY | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-post-sse-may-carry-requests | n/a: addresses the other side |
| streamable-http-sse-not-closed-before-response | SHOULD NOT | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-sse-not-closed-before-response | n/a: addresses the other side |
| streamable-http-server-terminates-sse-after-response | SHOULD | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-server-terminates-sse-after-response | n/a: addresses the other side |
| streamable-http-disconnect-not-cancel | SHOULD NOT | both | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-disconnect-not-cancel | n/a: catalogue marks it n/a |
| streamable-http-client-cancel-by-notification | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-03-26/transport-streamable-http/streamable-http-client-cancel-by-notification |
| streamable-http-server-may-resume | MAY | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| streamable-http-client-may-get | MAY | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| streamable-http-get-accept-sse | MUST | client | n/a: addresses the other side | n/a: no GET |
| streamable-http-get-sse-or-405 | MUST | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-get-sse-or-405 | n/a: addresses the other side |
| streamable-http-get-stream-unrelated | SHOULD | server | n/a: 405 | n/a: addresses the other side |
| streamable-http-get-no-responses | MUST NOT | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| streamable-http-get-close-any-time | MAY | both | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-get-close-any-time | tested: conformance.TestTransportClient/2025-03-26/transport-streamable-http/streamable-http-get-close-any-time |
| streamable-http-multiple-streams | MAY | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-03-26/transport-streamable-http/streamable-http-multiple-streams |
| streamable-http-no-broadcast | MUST / MUST NOT | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-no-broadcast | n/a: addresses the other side |
| streamable-http-event-id-unique | MUST | server | n/a: no ids | n/a: addresses the other side |
| streamable-http-client-resume-with-last-event-id | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestLegacyHTTPStreamResumption/2025-11-25/transport/client-resumes-closed-stream-with-last-event-id-after-retry@2025-03-26,2025-06-18,2025-11-25 |
| streamable-http-server-no-cross-stream-replay | MUST NOT | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| streamable-http-server-may-assign-session | MAY | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-server-may-assign-session | n/a: addresses the other side |
| streamable-http-session-id-secure | SHOULD | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-session-id-secure | n/a: addresses the other side |
| streamable-http-session-id-visible-ascii | MUST | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-session-id-visible-ascii | n/a: addresses the other side |
| streamable-http-client-echoes-session | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-03-26/transport-streamable-http/streamable-http-client-echoes-session |
| streamable-http-server-400-missing-session | SHOULD | server | n/a: mcpx does not require one (stateless fallback | n/a: addresses the other side |
| streamable-http-server-404-terminated-session | MUST | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-server-404-terminated-session | n/a: addresses the other side |
| streamable-http-client-reinit-on-404 | MUST | client | n/a: addresses the other side | tested: mcpclient.TestLegacySession404Recovery/2025-11-25/transport/client-reinitializes-on-session-404-and-retries-once@2025-03-26,2025-06-18,2025-11-25 |
| streamable-http-client-delete-session | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-03-26/transport-streamable-http/streamable-http-client-delete-session |
| streamable-http-server-may-405-delete | MAY | server | tested: conformance.TestTransportServer/2025-03-26/transport-streamable-http/streamable-http-server-may-405-delete | n/a: addresses the other side |
versioning
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| lifecycle-client-sends-supported-version | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-03-26/versioning/lifecycle-client-sends-supported-version |
| lifecycle-client-sends-latest-version | SHOULD | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-03-26/versioning/lifecycle-client-sends-latest-version |
| lifecycle-client-may-support-older | MAY | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-03-26/versioning/lifecycle-client-may-support-older |
| lifecycle-server-echoes-supported-version | MUST | server | tested: conformance.TestLifecycleServer/2025-03-26/versioning/lifecycle-server-echoes-supported-version | n/a: addresses the other side |
| lifecycle-server-counteroffers-version | MUST | server | tested: conformance.TestLifecycleServer/2025-03-26/versioning/lifecycle-server-counteroffers-version | n/a: addresses the other side |
| lifecycle-server-counteroffers-latest | SHOULD | server | tested: conformance.TestLifecycleServer/2025-03-26/versioning/lifecycle-server-counteroffers-latest | n/a: addresses the other side |
| lifecycle-client-disconnects-on-unsupported-version | SHOULD (prose) / MUST (schema) | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-03-26/versioning/lifecycle-client-disconnects-on-unsupported-version |
2025-06-18
(security; not in area list)
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| security-implementors-guidelines | SHOULD | both | tested: conformance.TestMessagesServer/2025-06-18/security/security-implementors-guidelines | n/a: catalogue marks it n/a |
authorization
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| authorization-http-should-conform-stdio-should-not | SHOULD / SHOULD NOT | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authorization-custom-auth-may | MAY | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-optional | OPTIONAL | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-http-should-conform | SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-stdio-use-environment | SHOULD NOT | both | gap not implemented: #1975 | gap not implemented: #1950 |
| authz-alt-transport-best-practice | MUST | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-as-implements-oauth21 | MUST | auth-server | n/a: addresses the other side | n/a: addresses the other side |
| authz-dcr-should | SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-bearer-header | MUST | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-token-every-request | MUST | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-no-token-in-query | MUST NOT | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-server-validates-token | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-server-validates-audience | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-client-sends-only-own-as-tokens | MUST NOT | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-accept-only-own-tokens | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-no-token-passthrough | MUST NOT | server | gap not implemented: #1975 | n/a: addresses the other side |
| authz-error-status-codes | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-server-prm-rfc9728 | MUST | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-prm-lists-authorization-servers | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-as-metadata-rfc8414 | MUST | both | n/a: catalogue marks it n/a | n/a: optional feature mcpx does not implement |
| authz-401-www-authenticate | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-client-parses-www-authenticate | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-client-rfc8414-discovery | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-resource-indicator | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-canonical-uri-guidance | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-secure-token-storage | MUST | both | n/a: catalogue marks it n/a | gap not implemented: #1950 |
| authz-as-short-lived-and-rotate | SHOULD / MUST | auth-server | n/a: addresses the other side | n/a: addresses the other side |
| authz-https-and-redirect-uris | MUST | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-pkce | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-open-redirection | MUST / SHOULD | client, auth-server | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-proxy-consent-per-client | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-follow-security-considerations | MUST | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
cancellation
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| cancellation-ref-same-direction-in-progress | MUST | both | tested: conformance.TestUtilitiesServer/2025-06-18/cancellation/cancellation-ref-same-direction-in-progress | tested: conformance.TestUtilitiesClient/2025-06-18/cancellation/cancellation-ref-same-direction-in-progress |
| cancellation-client-must-not-cancel-initialize | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-06-18/cancellation/cancellation-client-must-not-cancel-initialize |
| cancellation-receiver-should-stop-free-no-response | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-06-18/cancellation/cancellation-receiver-should-stop-free-no-response | tested: conformance.TestUtilitiesClient/2025-06-18/cancellation/cancellation-receiver-should-stop-free-no-response |
| cancellation-receiver-may-ignore | MAY | both | tested: conformance.TestUtilitiesServer/2025-06-18/cancellation/cancellation-receiver-may-ignore | tested: conformance.TestUtilitiesClient/2025-06-18/cancellation/cancellation-receiver-may-ignore |
| cancellation-sender-ignore-late-response | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-06-18/cancellation/cancellation-sender-ignore-late-response | tested: conformance.TestUtilitiesClient/2025-06-18/cancellation/cancellation-sender-ignore-late-response |
| cancellation-handle-races | MUST | both | tested: conformance.TestUtilitiesServer/2025-06-18/cancellation/cancellation-handle-races | tested: conformance.TestUtilitiesClient/2025-06-18/cancellation/cancellation-handle-races |
| cancellation-log-reasons | SHOULD | both | gap #77: a cancellation reason reaches Server.OnCancel, which nothing outside tests sets; it is never logged | tested: conformance.TestUtilitiesClient/2025-06-18/cancellation/cancellation-log-reasons |
| cancellation-ui-indicate | SHOULD | both | n/a: no UI | n/a: no UI |
| cancellation-ignore-invalid | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-06-18/cancellation/cancellation-ignore-invalid | tested: conformance.TestUtilitiesClient/2025-06-18/cancellation/cancellation-ignore-invalid |
| cancellation-processing-should-cease | SHOULD | both (receiver) | tested: conformance.TestUtilitiesServer/2025-06-18/cancellation/cancellation-processing-should-cease | tested: conformance.TestUtilitiesClient/2025-06-18/cancellation/cancellation-processing-should-cease |
completion
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| completion-declare-capability | MUST | server | tested: conformance.TestUtilitiesServer/2025-06-18/completion/completion-declare-capability | n/a: addresses the other side |
| completion-max-100 | descr. + schema | server | tested: conformance.TestUtilitiesServer/2025-06-18/completion/completion-max-100 | n/a: addresses the other side |
| completion-server-should | SHOULD | server | tested: daemon.TestHandleCompleteRateLimitHotChangeAndZeroDisables@alldaemon.TestHandleCompleteRateLimitRefusesBeforePool@alldaemon.TestHandleCompleteServerWindowBindsAcrossCallers@alldaemon.TestLocalCompletionCapAfterRanking@alldaemon.TestLocalCompletionTiersPrompts@alldaemon.TestLocalCompletionTiersResources@all | n/a: addresses the other side |
| completion-client-should | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-06-18/completion/completion-client-should |
| completion-security | MUST | both | gap #1968: partial: completion inputs are validated and completion/complete is rate limited per caller (completion.perMinute) and per server (completion.serverPerMinute) since #2387, but there is no per-caller access control for sensitive suggestions and no completion-specific information-disclosure control; waiting on principals #1968 / permissive-auth #1972 | n/a: catalogue marks it n/a |
| completion-errors | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-06-18/completion/completion-errors | n/a: addresses the other side |
| completion-context-arguments | should (lowercase) | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-06-18/completion/completion-context-arguments |
elicitation
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| elicitation-server-no-sensitive-info | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2025-06-18/elicitation/elicitation-server-no-sensitive-info | n/a: addresses the other side |
| elicitation-app-show-requesting-server | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/elicitation/elicitation-app-show-requesting-server |
| elicitation-app-review-modify-responses | SHOULD | client | n/a: addresses the other side | tested: cli.TestRenderAnswerReviewMasksSensitiveFields@2025-06-18cli.TestRenderQuestionFormShowsSchemaDefaults@2025-06-18 |
| elicitation-app-decline-cancel-options | SHOULD | client | n/a: addresses the other side | tested: elicit.TestAnExpiredQuestionBecomesCancelNotDecline@2025-06-18,2025-11-25,2026-07-28elicit.TestAnsweringAfterTheProcessDiedStillWorks@2025-06-18,2025-11-25,2026-07-28 |
| elicitation-client-declare-capability-at-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/elicitation/elicitation-client-declare-capability-at-initialize |
| elicitation-form-schema-flat-primitives | MUST (schema-structural) | server | tested: conformance.TestClientFeaturesServer/2025-06-18/elicitation/elicitation-form-schema-flat-primitives | n/a: addresses the other side |
| elicitation-client-user-approval-controls | SHOULD | client | n/a: addresses the other side | tested: elicit.TestRoutingSendsCredentialsToAHumanAndTheRestToTheAgent@2025-06-18,2025-11-25,2026-07-28 |
| elicitation-client-allow-decline-any-time | SHOULD | client | n/a: addresses the other side | tested: elicit.TestAnsweringAfterTheProcessDiedStillWorks@2025-06-18,2025-11-25,2026-07-28 |
| elicitation-client-rate-limit | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/elicitation/elicitation-client-rate-limit |
| elicitation-client-clear-what-and-why | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/elicitation/elicitation-client-clear-what-and-why |
| elicitation-both-validate-against-schema | SHOULD | both | tested: mcpclient.TestElicitationAnswersValidatedAgainstRequestedSchema@2025-06-18 | tested: mcpclient.TestElicitationAnswersValidatedAgainstRequestedSchema@2025-06-18 |
errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| errors-error-has-code-and-message | MUST | both | tested: conformance.TestMessagesServer/2025-06-18/errors/errors-error-has-code-and-message | tested: conformance.TestMessagesClient/2025-06-18/errors/errors-error-has-code-and-message |
| errors-codes-integers | MUST | both | tested: conformance.TestMessagesServer/2025-06-18/errors/errors-codes-integers | tested: conformance.TestMessagesClient/2025-06-18/errors/errors-codes-integers |
| errors-message-concise-single-sentence | SHOULD | both | tested: conformance.TestMessagesServer/2025-06-18/errors/errors-message-concise-single-sentence | n/a: sends no errors except to server requests |
lifecycle
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| lifecycle-implementations-support-base-and-lifecycle | MUST | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-implementations-support-base-and-lifecycle | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-implementations-support-base-and-lifecycle |
| lifecycle-initialization-first-interaction | MUST | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-initialization-first-interaction | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-initialization-first-interaction |
| lifecycle-client-sends-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-client-sends-initialize |
| lifecycle-server-responds-caps-and-info | MUST | server | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-server-responds-caps-and-info | n/a: addresses the other side |
| lifecycle-client-sends-initialized-after-init | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-client-sends-initialized-after-init |
| lifecycle-client-no-requests-before-init-response | SHOULD NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-client-no-requests-before-init-response |
| lifecycle-server-no-requests-before-initialized | SHOULD NOT | server | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-server-no-requests-before-initialized | n/a: addresses the other side |
| lifecycle-capabilities-establish-session-features | (definitional) | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-capabilities-establish-session-features | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-capabilities-establish-session-features |
| lifecycle-respect-negotiated-version-and-capabilities-must | MUST | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-respect-negotiated-version-and-capabilities-must | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-respect-negotiated-version-and-capabilities-must |
| lifecycle-stdio-client-shutdown-sequence | SHOULD | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-stdio-client-shutdown-sequence |
| lifecycle-stdio-server-may-exit | MAY | server | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-stdio-server-may-exit | n/a: addresses the other side |
| lifecycle-http-shutdown-closing-connection | (definitional) | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-http-shutdown-closing-connection | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-http-shutdown-closing-connection |
| lifecycle-prepared-for-error-cases | SHOULD | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-prepared-for-error-cases | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-prepared-for-error-cases |
| lifecycle-timeouts-establish | SHOULD | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-timeouts-establish | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-timeouts-establish |
| lifecycle-timeouts-cancel-on-expiry | SHOULD | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-timeouts-cancel-on-expiry | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-timeouts-cancel-on-expiry |
| lifecycle-timeouts-per-request-configurable | SHOULD | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-timeouts-per-request-configurable | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-timeouts-per-request-configurable |
| lifecycle-timeouts-may-reset-on-progress | MAY | both | n/a: catalogue marks it n/a | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-timeouts-may-reset-on-progress |
| lifecycle-timeouts-max-regardless-of-progress | SHOULD | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-timeouts-max-regardless-of-progress | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-timeouts-max-regardless-of-progress |
| lifecycle-cancel-not-initialize | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-cancel-not-initialize |
| lifecycle-instructions-may-go-in-system-prompt | MAY | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-instructions-may-go-in-system-prompt |
| lifecycle-capability-sets-open | (definitional) | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-capability-sets-open | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-capability-sets-open |
| lifecycle-implementation-name-version-required | REQUIRED (schema field) | both | tested: conformance.TestLifecycleServer/2025-06-18/lifecycle/lifecycle-implementation-name-version-required | tested: conformance.TestLifecycleClient/2025-06-18/lifecycle/lifecycle-implementation-name-version-required |
logging
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| logging-declare-capability | MUST | server | tested: conformance.TestUtilitiesServer/2025-06-18/logging/logging-declare-capability | n/a: addresses the other side |
| logging-client-may-setlevel | MAY | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-06-18/logging/logging-client-may-setlevel |
| logging-setlevel-at-or-above | should (schema) | server | tested: conformance.TestUtilitiesServer/2025-06-18/logging/logging-setlevel-at-or-above | n/a: addresses the other side |
| logging-unset-server-chooses | MAY | server | tested: conformance.TestUtilitiesServer/2025-06-18/logging/logging-unset-server-chooses | n/a: addresses the other side |
| logging-errors-setlevel | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-06-18/logging/logging-errors-setlevel | n/a: addresses the other side |
| logging-server-hygiene | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-06-18/logging/logging-server-hygiene | n/a: addresses the other side |
| logging-no-secrets-pii | MUST NOT | both (emitter) | tested: conformance.TestUtilitiesServer/2025-06-18/logging/logging-no-secrets-pii | tested: conformance.TestUtilitiesClient/2025-06-18/logging/logging-no-secrets-pii |
| logging-impl-validate-control | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-06-18/logging/logging-impl-validate-control | tested: conformance.TestUtilitiesClient/2025-06-18/logging/logging-impl-validate-control |
messages
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| messages-all-messages-follow-jsonrpc-2 | MUST | both | tested: conformance.TestJSONRPCFramesEveryRevision/2025-06-18/messages/all-messages-follow-jsonrpc-2-server-httpconformance.TestJSONRPCFramesEveryRevision/2025-06-18/messages/all-messages-follow-jsonrpc-2-server-stdioconformance.TestMessagesServer/2025-06-18/messages/messages-all-messages-follow-jsonrpc-2 | tested: conformance.TestJSONRPCFramesEveryRevision/2025-06-18/messages/all-messages-follow-jsonrpc-2-client |
| messages-other-components-may | MAY | both | tested: conformance.TestMessagesServer/2025-06-18/messages/messages-other-components-may | tested: conformance.TestMessagesClient/2025-06-18/messages/messages-other-components-may |
| messages-request-id-string-or-integer | MUST | both | tested: conformance.TestMessagesServer/2025-06-18/messages/messages-request-id-string-or-integer | tested: conformance.TestMessagesClient/2025-06-18/messages/messages-request-id-string-or-integer |
| messages-request-id-not-null | MUST NOT | both | tested: conformance.TestMessagesServer/2025-06-18/messages/messages-request-id-not-null | tested: conformance.TestMessagesClient/2025-06-18/messages/messages-request-id-not-null |
| messages-request-id-unique-per-session | MUST NOT | both | tested: conformance.TestMessagesServer/2025-06-18/messages/messages-request-id-unique-per-session | tested: conformance.TestMessagesClient/2025-06-18/messages/messages-request-id-unique-per-session |
| messages-response-same-id | MUST | both | tested: conformance.TestMessagesServer/2025-06-18/messages/messages-response-same-id | tested: conformance.TestMessagesClient/2025-06-18/messages/messages-response-same-id |
| messages-response-result-xor-error | MUST / MUST NOT | both | tested: conformance.TestMessagesServer/2025-06-18/messages/messages-response-result-xor-error | tested: conformance.TestMessagesClient/2025-06-18/messages/messages-response-result-xor-error |
| messages-result-any-object | MAY | both | tested: conformance.TestMessagesServer/2025-06-18/messages/messages-result-any-object | tested: conformance.TestMessagesClient/2025-06-18/messages/messages-result-any-object |
| messages-notification-no-response | MUST NOT | both | tested: conformance.TestMessagesServer/2025-06-18/messages/messages-notification-no-response | tested: conformance.TestMessagesClient/2025-06-18/messages/messages-notification-no-response |
| messages-notification-no-id | MUST NOT | both | tested: conformance.TestMessagesServer/2025-06-18/messages/messages-notification-no-id | tested: conformance.TestMessagesClient/2025-06-18/messages/messages-notification-no-id |
| transport-jsonrpc-utf8 | MUST | both | tested: conformance.TestMessagesServer/2025-06-18/messages/transport-jsonrpc-utf8 | tested: conformance.TestMessagesClient/2025-06-18/messages/transport-jsonrpc-utf8 |
meta
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| meta-no-assumptions-on-reserved-keys | MUST NOT | both | tested: conformance.TestMessagesServer/2025-06-18/meta/meta-no-assumptions-on-reserved-keys | tested: conformance.TestMessagesClient/2025-06-18/meta/meta-no-assumptions-on-reserved-keys |
| meta-prefix-label-format | MUST | both | tested: conformance.TestMessagesServer/2025-06-18/meta/meta-prefix-label-format | tested: conformance.TestMessagesClient/2025-06-18/meta/meta-prefix-label-format |
| meta-reserved-prefix-2025-06-18 | (reservation) | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| meta-name-format | MUST / MAY | both | tested: conformance.TestMessagesServer/2025-06-18/meta/meta-name-format | tested: conformance.TestMessagesClient/2025-06-18/meta/meta-name-format |
| meta-schema-may-reserve-names | (reservation) | both | tested: conformance.TestMessagesServer/2025-06-18/meta/meta-schema-may-reserve-names | tested: conformance.TestMessagesClient/2025-06-18/meta/meta-schema-may-reserve-names |
| meta-progress-token-receiver-not-obligated | (definitional) | both | tested: conformance.TestMessagesServer/2025-06-18/meta/meta-progress-token-receiver-not-obligated | tested: conformance.TestMessagesClient/2025-06-18/meta/meta-progress-token-receiver-not-obligated |
pagination
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| pagination-no-fixed-page-size | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-06-18/pagination/pagination-no-fixed-page-size |
| pagination-stable-cursors | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-06-18/pagination/pagination-stable-cursors | n/a: addresses the other side |
| pagination-invalid-cursor-graceful | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-06-18/pagination/pagination-invalid-cursor-graceful | n/a: addresses the other side |
| pagination-missing-nextcursor-end | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-06-18/pagination/pagination-missing-nextcursor-end |
| pagination-support-both-flows | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-06-18/pagination/pagination-support-both-flows |
| pagination-cursor-opaque | MUST | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-06-18/pagination/pagination-cursor-opaque |
| pagination-no-persist-across-sessions | MUST | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-06-18/pagination/pagination-no-persist-across-sessions |
| pagination-invalid-cursor-32602 | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-06-18/pagination/pagination-invalid-cursor-32602 | n/a: addresses the other side |
ping
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| ping-receiver-responds-promptly-empty | MUST | both | tested: conformance.TestLifecycleServer/2025-06-18/ping/ping-receiver-responds-promptly-empty | tested: conformance.TestLifecycleClient/2025-06-18/ping/ping-receiver-responds-promptly-empty |
| ping-either-party-may-initiate | (definitional) | both | tested: conformance.TestLifecycleServer/2025-06-18/ping/ping-either-party-may-initiate | tested: conformance.TestLifecycleClient/2025-06-18/ping/ping-either-party-may-initiate |
| ping-sender-may-consider-stale | MAY | both | n/a: catalogue marks it n/a | n/a: does not ping |
| ping-periodic-pings | SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| ping-frequency-configurable | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-timeouts-appropriate | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-avoid-excessive | SHOULD | both | tested: conformance.TestLifecycleServer/2025-06-18/ping/ping-avoid-excessive | tested: conformance.TestLifecycleClient/2025-06-18/ping/ping-avoid-excessive |
| ping-timeout-is-connection-failure | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-multiple-failures-may-reset | MAY | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-log-failures | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-schema-receiver-must-respond | must (lowercase) | both | tested: conformance.TestLifecycleServer/2025-06-18/ping/ping-schema-receiver-must-respond | tested: conformance.TestLifecycleClient/2025-06-18/ping/ping-schema-receiver-must-respond |
progress
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| progress-token-string-or-int | MUST | both (requester) | n/a: as a server mcpx never requests progress; a host’s token is relayed back verbatim | tested: conformance.TestUtilitiesClient/2025-06-18/progress/progress-token-string-or-int |
| progress-token-unique-sender | MUST | both | tested: conformance.TestUtilitiesServer/2025-06-18/progress/progress-token-unique-sender | tested: conformance.TestUtilitiesClient/2025-06-18/progress/progress-token-unique-sender |
| progress-receiver-may-send | MAY | both | n/a: optional feature mcpx does not implement | n/a: never receives requests with tokens |
| progress-must-increase | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2025-06-18/progress/progress-must-increase | n/a: catalogue marks it n/a |
| progress-message-human-readable | SHOULD | both (notifier) | tested: conformance.TestUtilitiesServer/2025-06-18/progress/progress-message-human-readable | n/a: catalogue marks it n/a |
| progress-only-active-tokens | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2025-06-18/progress/progress-only-active-tokens | n/a: catalogue marks it n/a |
| progress-receiver-may-skip | MAY | both | tested: conformance.TestUtilitiesServer/2025-06-18/progress/progress-receiver-may-skip | tested: conformance.TestUtilitiesClient/2025-06-18/progress/progress-receiver-may-skip |
| progress-track-tokens | SHOULD | both | n/a: catalogue marks it n/a | tested: conformance.TestUtilitiesClient/2025-06-18/progress/progress-track-tokens |
| progress-rate-limit | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-06-18/progress/progress-rate-limit | tested: conformance.TestUtilitiesClient/2025-06-18/progress/progress-rate-limit |
| progress-stop-after-completion | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2025-06-18/progress/progress-stop-after-completion | n/a: catalogue marks it n/a |
prompts
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2025-06-18/prompts/prompts-declare-capability | n/a: addresses the other side |
| prompts-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/prompts/prompts-list-changed-notify | n/a: addresses the other side |
| prompts-image-base64-mime | MUST | server | tested: conformance.TestFeaturesServer/2025-06-18/prompts/prompts-image-base64-mime | n/a: addresses the other side |
| prompts-audio-base64-mime | MUST (not bolded) | server | tested: conformance.TestFeaturesServer/2025-06-18/prompts/prompts-audio-base64-mime | n/a: addresses the other side |
| prompts-embedded-must-include | MUST | server | tested: conformance.TestFeaturesServer/2025-06-18/prompts/prompts-embedded-must-include | n/a: addresses the other side |
| prompts-validate-args | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/prompts/prompts-validate-args | n/a: addresses the other side |
| prompts-respect-capability-negotiation | SHOULD | both | tested: conformance.TestFeaturesServer/2025-06-18/prompts/prompts-respect-capability-negotiation | tested: conformance.TestFeaturesClient/2025-06-18/prompts/prompts-respect-capability-negotiation |
| prompts-validate-io | MUST | both | tested: conformance.TestFeaturesServer/2025-06-18/prompts/prompts-validate-io; mcpclient.TestValidatePromptResult | covered |
prompts / errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-errors-32602-32603 | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/prompts/prompts-errors-32602-32603 | n/a: addresses the other side |
prompts / pagination
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-client-paginate | SHOULD | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-06-18/prompts/prompts-client-paginate |
resources
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2025-06-18/resources/resources-declare-capability | n/a: addresses the other side |
| resources-subscribe-cap-meaning-legacy | (definition) | server | tested: e2e.TestResourceSubscriptionsReachUpstream/2025-11-25/resources/http-subscribe-delivers-upstream-updates-on-the-get-stream@2025-03-26,2025-06-18,2025-11-25e2e.TestResourceSubscriptionsReachUpstream/2025-11-25/resources/stdio-subscribe-delivers-upstream-updates-under-the-mcpx-uri@2024-11-05,2025-03-26,2025-06-18,2025-11-25 | n/a: addresses the other side |
| resources-cap-features-independent | MAY | server | tested: conformance.TestFeaturesServer/2025-06-18/resources/resources-cap-features-independent | n/a: addresses the other side |
| resources-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/resources/resources-list-changed-notify | n/a: addresses the other side |
| resources-updated-may-be-subresource | (note, schema) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-06-18/resources/resources-updated-may-be-subresource |
| resources-https-only-if-client-fetchable | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/resources/resources-https-only-if-client-fetchable | n/a: addresses the other side |
| resources-https-else-other-scheme | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/resources/resources-https-else-other-scheme | n/a: addresses the other side |
| resources-file-xdg-mime-may | MAY | server | tested: conformance.TestFeaturesServer/2025-06-18/resources/resources-file-xdg-mime-may | n/a: addresses the other side |
| resources-custom-scheme-rfc3986 | MUST | server | tested: cli.TestMCPXResourceURIs/2025-06-18/resources/resources-custom-scheme-rfc3986 | n/a: addresses the other side |
| resources-sec-validate-uris | MUST | server | tested: conformance.TestFeaturesServer/2025-06-18/resources/resources-sec-validate-uris | n/a: addresses the other side |
| resources-sec-access-control | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| resources-sec-binary-encoded | MUST | server | tested: conformance.TestFeaturesServer/2025-06-18/resources/resources-sec-binary-encoded | n/a: addresses the other side |
| resources-sec-permissions | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| resources-annotations-defs | (definition) | both | tested: conformance.TestFeaturesServer/2025-06-18/resources/resources-annotations-defs | gap #207: mcpclient.Resource has no annotations field; they are dropped on parse |
resources / errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-not-found-32002 | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/resources/resources-not-found-32002 | n/a: addresses the other side |
| resources-internal-32603 | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/resources/resources-internal-32603 | n/a: addresses the other side |
resources / subscriptions
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-subscribe-rpc | (optional) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-06-18/resources/resources-subscribe-rpc |
| resources-unsubscribe-follows-subscribe | should (lowercase, schema) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-06-18/resources/resources-unsubscribe-follows-subscribe |
roots
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| roots-client-declare-capability-at-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/roots/roots-client-declare-capability-at-initialize |
| roots-client-listchanged-must-notify | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/roots/roots-client-listchanged-must-notify |
| roots-uri-must-be-file-scheme | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-only-expose-permitted-roots | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-validate-uris-path-traversal | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-implement-access-controls | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-monitor-accessibility | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-server-handle-unavailable-roots | SHOULD | server | n/a: mcpx does not consume roots | n/a: addresses the other side |
| roots-server-respect-boundaries | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-validate-paths-against-roots | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-client-prompt-consent-before-exposing | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-provide-root-management-ui | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-validate-accessibility-before-exposing | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-monitor-root-changes | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-server-check-capability-before-use | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-handle-list-changes | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-cache-root-information | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-client-return-jsonrpc-errors | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/roots/roots-client-return-jsonrpc-errors |
sampling
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| sampling-human-in-loop-can-deny | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/sampling/sampling-human-in-loop-can-deny |
| sampling-app-review-ui | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-app-view-edit-prompts | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-app-review-responses | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-client-declare-capability-at-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/sampling/sampling-client-declare-capability-at-initialize |
| sampling-client-may-sample-fewer-tokens | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-ignore-model-preferences | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-modify-system-prompt | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-ignore-includecontext | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hints-evaluated-in-order | MUST | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hints-over-priorities | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hint-name-substring | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hint-may-map-provider | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-stopreason-custom-values | MAY | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/sampling/sampling-stopreason-custom-values |
| sampling-client-errors-user-rejected | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/sampling/sampling-client-errors-user-rejected |
| sampling-client-user-approval-controls | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-both-validate-message-content | SHOULD | both | tested: conformance.TestSamplingValidation/2025-06-18/sampling/sampling-both-validate-message-content | tested: conformance.TestSamplingValidation/2025-06-18/sampling/sampling-both-validate-message-content |
| sampling-client-respect-hints | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-rate-limit | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-06-18/sampling/sampling-client-rate-limit |
| sampling-both-handle-sensitive-data | MUST | both | tested: daemon.TestElicitEventsAreSummaryOnly@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28elicit.TestOpenPrunesTerminalRowsPastTheAgeBound@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28 | tested: daemon.TestElicitEventsAreSummaryOnly@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28elicit.TestOpenPrunesTerminalRowsPastTheAgeBound@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28 |
subscriptions
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| subscriptions-list-changed-should-notify | SHOULD | server | tested: conformance.TestStatefulServer/2025-06-18/subscriptions/subscriptions-list-changed-should-notify | n/a: addresses the other side |
| subscriptions-resources-updated-only-if-subscribed | should (schema) | server | tested: conformance.TestStatefulServer/2025-06-18/subscriptions/subscriptions-resources-updated-only-if-subscribed | n/a: addresses the other side |
tools
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tools-human-in-loop-deny | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-ui-expose-indicate-confirm | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-declare-capability | n/a: addresses the other side |
| tools-list-changed-means-emit | (definition) | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-list-changed-means-emit | n/a: addresses the other side |
| tools-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-list-changed-notify | n/a: addresses the other side |
| tools-annotations-untrusted | MUST | client | n/a: addresses the other side | tested: daemon.TestAnnotationClearsCallOnlyForLocallyDeclaredServers@2026-07-28 |
| tools-annotations-hints-no-decisions | should (lowercase, schema) | client | n/a: addresses the other side | tested: daemon.TestAnnotationClearsCallOnlyForLocallyDeclaredServers@2026-07-28 |
| tools-result-iserror-for-tool-errors | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-result-iserror-for-tool-errors | n/a: addresses the other side |
| tools-result-lookup-errors-protocol | should (lowercase) | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-result-lookup-errors-protocol | n/a: addresses the other side |
| tools-error-unknown-tool-protocol | (classification) | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-error-unknown-tool-protocol | n/a: addresses the other side |
| tools-error-invalid-args-protocol | (classification) | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-error-invalid-args-protocol | n/a: addresses the other side |
| tools-embedded-resource-may | MAY | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-embedded-resource-may | n/a: addresses the other side |
| tools-embedded-implies-resources-cap | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-embedded-implies-resources-cap | n/a: addresses the other side |
| tools-resource-link-may | MAY | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-resource-link-may | n/a: addresses the other side |
| tools-resource-link-may-not-listed | (note) | both | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-resource-link-may-not-listed | tested: conformance.TestFeaturesClient/2025-06-18/tools/tools-resource-link-may-not-listed |
| tools-structured-also-text | SHOULD | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-structured-also-text | n/a: addresses the other side |
| tools-structured-object-only | (type, schema) | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-structured-object-only | n/a: addresses the other side |
| tools-output-schema-conform | MUST | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-output-schema-conform | n/a: addresses the other side |
| tools-output-schema-client-validate | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-output-schema-object-root | (type, schema) | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-output-schema-object-root | n/a: addresses the other side |
| tools-display-name-precedence | (rule) | client | n/a: addresses the other side | gap #207: tool title and annotations.title are carried but nothing that displays a tool uses them |
| tools-server-sec-validate-inputs | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| tools-client-sec | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
tools / json-schema
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tools-inputschema-type-object-root | REQUIRED (schema) | server | tested: conformance.TestFeaturesServer/2025-06-18/tools/tools-inputschema-type-object-root | n/a: addresses the other side |
transport-http-sse
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| http-sse-server-backcompat-host-both | should (lowercase) | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| http-sse-client-backcompat-probe-any-4xx | should (lowercase) | client | n/a: addresses the other side | tested: pool.TestHTTPSSEFallback/2025-11-25/transport-http-sse/client-falls-back-to-get-endpoint-and-caches-it@2025-03-26,2025-06-18 |
transport-stdio
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| transport-client-should-support-stdio | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-stdio/transport-client-should-support-stdio |
| transport-custom-preserve-jsonrpc-and-lifecycle | MUST | both | n/a: mcpx serves MCP only over stdio and Streamable HTTP; its /v1 REST surface is not an MCP transport | n/a: catalogue marks it n/a |
| transport-custom-document-patterns | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| stdio-no-embedded-newlines | MUST NOT | both | tested: conformance.TestTransportServer/2025-06-18/transport-stdio/stdio-no-embedded-newlines | tested: conformance.TestTransportClient/2025-06-18/transport-stdio/stdio-no-embedded-newlines |
| stdio-message-is-single | MUST (implied) | both | tested: conformance.TestTransportServer/2025-06-18/transport-stdio/stdio-message-is-single | tested: conformance.TestTransportClient/2025-06-18/transport-stdio/stdio-message-is-single |
| stdio-server-may-log-to-stderr | MAY | server | tested: conformance.TestTransportServer/2025-06-18/transport-stdio/stdio-server-may-log-to-stderr | n/a: addresses the other side |
| stdio-client-may-capture-stderr | MAY | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-stdio/stdio-client-may-capture-stderr |
| stdio-server-stdout-only-mcp | MUST NOT | server | tested: conformance.TestTransportServer/2025-06-18/transport-stdio/stdio-server-stdout-only-mcp | n/a: addresses the other side |
| stdio-client-stdin-only-mcp | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-stdio/stdio-client-stdin-only-mcp |
transport-streamable-http
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| streamable-http-server-single-endpoint-post-get | MUST | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-single-endpoint-post-get | n/a: addresses the other side |
| streamable-http-server-validate-origin | MUST | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-validate-origin | n/a: addresses the other side |
| streamable-http-server-bind-localhost | SHOULD | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-bind-localhost | n/a: addresses the other side |
| streamable-http-server-authenticate | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| streamable-http-client-post-every-message | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-streamable-http/streamable-http-client-post-every-message |
| streamable-http-client-accept-both | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-streamable-http/streamable-http-client-accept-both |
| streamable-http-post-body-single | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-streamable-http/streamable-http-post-body-single |
| streamable-http-server-202-for-non-request | MUST | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-202-for-non-request | n/a: addresses the other side |
| streamable-http-server-error-status-if-rejected | MUST | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-error-status-if-rejected | n/a: addresses the other side |
| streamable-http-server-json-or-sse-for-request | MUST | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-json-or-sse-for-request | n/a: addresses the other side |
| streamable-http-client-supports-json-and-sse | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-streamable-http/streamable-http-client-supports-json-and-sse |
| streamable-http-sse-includes-response | SHOULD | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-sse-includes-response | n/a: addresses the other side |
| streamable-http-post-sse-may-carry-requests | MAY | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-post-sse-may-carry-requests | n/a: addresses the other side |
| streamable-http-sse-not-closed-before-response | SHOULD NOT | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-sse-not-closed-before-response | n/a: addresses the other side |
| streamable-http-server-terminates-sse-after-response | SHOULD | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-terminates-sse-after-response | n/a: addresses the other side |
| streamable-http-disconnect-not-cancel | SHOULD NOT | both | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-disconnect-not-cancel | n/a: catalogue marks it n/a |
| streamable-http-client-cancel-by-notification | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-streamable-http/streamable-http-client-cancel-by-notification |
| streamable-http-server-may-resume | MAY | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| streamable-http-client-may-get | MAY | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| streamable-http-get-accept-sse | MUST | client | n/a: addresses the other side | n/a: no GET |
| streamable-http-get-sse-or-405 | MUST | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-get-sse-or-405 | n/a: addresses the other side |
| streamable-http-get-stream-unrelated | SHOULD | server | n/a: 405 | n/a: addresses the other side |
| streamable-http-get-no-responses | MUST NOT | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| streamable-http-get-close-any-time | MAY | both | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-get-close-any-time | tested: conformance.TestTransportClient/2025-06-18/transport-streamable-http/streamable-http-get-close-any-time |
| streamable-http-multiple-streams | MAY | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-streamable-http/streamable-http-multiple-streams |
| streamable-http-no-broadcast | MUST / MUST NOT | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-no-broadcast | n/a: addresses the other side |
| streamable-http-event-id-unique | MUST | server | n/a: no ids | n/a: addresses the other side |
| streamable-http-client-resume-with-last-event-id | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestLegacyHTTPStreamResumption/2025-11-25/transport/client-resumes-closed-stream-with-last-event-id-after-retry@2025-03-26,2025-06-18,2025-11-25 |
| streamable-http-server-no-cross-stream-replay | MUST NOT | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| streamable-http-server-may-assign-session | MAY | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-may-assign-session | n/a: addresses the other side |
| streamable-http-session-id-secure | SHOULD | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-session-id-secure | n/a: addresses the other side |
| streamable-http-session-id-visible-ascii | MUST | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-session-id-visible-ascii | n/a: addresses the other side |
| streamable-http-client-echoes-session | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-streamable-http/streamable-http-client-echoes-session |
| streamable-http-server-400-missing-session | SHOULD | server | n/a: mcpx does not require one (stateless fallback | n/a: addresses the other side |
| streamable-http-server-404-terminated-session | MUST | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-404-terminated-session | n/a: addresses the other side |
| streamable-http-client-reinit-on-404 | MUST | client | n/a: addresses the other side | tested: mcpclient.TestLegacySession404Recovery/2025-11-25/transport/client-reinitializes-on-session-404-and-retries-once@2025-03-26,2025-06-18,2025-11-25 |
| streamable-http-client-delete-session | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-streamable-http/streamable-http-client-delete-session |
| streamable-http-server-may-405-delete | MAY | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-may-405-delete | n/a: addresses the other side |
| streamable-http-protocol-version-header-is-negotiated | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-06-18/transport-streamable-http/streamable-http-protocol-version-header-is-negotiated |
| streamable-http-server-assumes-2025-03-26 | SHOULD | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-assumes-2025-03-26 | n/a: addresses the other side |
| streamable-http-server-400-unsupported-version-header | MUST | server | tested: conformance.TestTransportServer/2025-06-18/transport-streamable-http/streamable-http-server-400-unsupported-version-header | n/a: addresses the other side |
versioning
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| lifecycle-client-sends-supported-version | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-06-18/versioning/lifecycle-client-sends-supported-version |
| lifecycle-client-sends-latest-version | SHOULD | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-06-18/versioning/lifecycle-client-sends-latest-version |
| lifecycle-client-may-support-older | MAY | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-06-18/versioning/lifecycle-client-may-support-older |
| lifecycle-server-echoes-supported-version | MUST | server | tested: conformance.TestLifecycleServer/2025-06-18/versioning/lifecycle-server-echoes-supported-version | n/a: addresses the other side |
| lifecycle-server-counteroffers-version | MUST | server | tested: conformance.TestLifecycleServer/2025-06-18/versioning/lifecycle-server-counteroffers-version | n/a: addresses the other side |
| lifecycle-server-counteroffers-latest | SHOULD | server | tested: conformance.TestLifecycleServer/2025-06-18/versioning/lifecycle-server-counteroffers-latest | n/a: addresses the other side |
| lifecycle-client-disconnects-on-unsupported-version | SHOULD (prose) / MUST (schema) | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-06-18/versioning/lifecycle-client-disconnects-on-unsupported-version |
| lifecycle-http-protocol-version-header | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-06-18/versioning/lifecycle-http-protocol-version-header |
2025-11-25
(security; not in area list)
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| security-implementors-guidelines | SHOULD | both | tested: conformance.TestMessagesServer/2025-11-25/security/security-implementors-guidelines | n/a: catalogue marks it n/a |
authorization
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| authorization-http-should-conform-stdio-should-not | SHOULD / SHOULD NOT | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authorization-custom-auth-may | MAY | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-optional | OPTIONAL | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-http-should-conform | SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-stdio-use-environment | SHOULD NOT | both | gap not implemented: #1975 | gap not implemented: #1950 |
| authz-alt-transport-best-practice | MUST | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-as-implements-oauth21 | MUST | auth-server | n/a: addresses the other side | n/a: addresses the other side |
| authz-dcr-may-deprecated | MAY | client, auth-server | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-bearer-header | MUST | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-token-every-request | MUST | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-no-token-in-query | MUST NOT | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-server-validates-token | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-server-validates-audience | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-client-sends-only-own-as-tokens | MUST NOT | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-accept-only-own-tokens | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-no-token-passthrough | MUST NOT | server | gap not implemented: #1975 | n/a: addresses the other side |
| authz-error-status-codes | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-server-prm-rfc9728 | MUST | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-prm-lists-authorization-servers | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-as-metadata-rfc8414-or-oidc | MUST | both | n/a: catalogue marks it n/a | n/a: optional feature mcpx does not implement |
| authz-prm-discovery-header-or-well-known | MUST | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-client-parses-www-authenticate | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-client-as-discovery-order | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-resource-indicator | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-canonical-uri-guidance | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-secure-token-storage | MUST | both | n/a: catalogue marks it n/a | gap not implemented: #1950 |
| authz-as-short-lived-and-rotate | SHOULD / MUST | auth-server | n/a: addresses the other side | n/a: addresses the other side |
| authz-https-and-redirect-uris | MUST | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-pkce-verify-s256 | MUST | client, auth-server | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-open-redirection | MUST / SHOULD | client, auth-server | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-proxy-consent-per-client | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-registration-priority | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-cimd | SHOULD / MUST | client, auth-server | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-preregistration | SHOULD | client | n/a: addresses the other side | n/a: no OAuth client |
| authz-scope-in-challenge | SHOULD / MUST / MUST NOT | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-scope-selection-order | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-insufficient-scope-403 | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-step-up | SHOULD / MAY | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-cimd-as-security | MUST / SHOULD | auth-server | n/a: addresses the other side | n/a: addresses the other side |
| authz-follow-security-considerations | MUST | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
cancellation
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| cancellation-ref-same-direction-in-progress | MUST | both | tested: conformance.TestUtilitiesServer/2025-11-25/cancellation/cancellation-ref-same-direction-in-progress | tested: conformance.TestUtilitiesClient/2025-11-25/cancellation/cancellation-ref-same-direction-in-progress |
| cancellation-client-must-not-cancel-initialize | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-11-25/cancellation/cancellation-client-must-not-cancel-initialize |
| cancellation-task-use-tasks-cancel | MUST | both | tested: conformance.TestUtilitiesServer/2025-11-25/cancellation/cancellation-task-use-tasks-cancel | tested: conformance.TestUtilitiesClient/2025-11-25/cancellation/cancellation-task-use-tasks-cancel |
| cancellation-requestid-required-non-task-forbidden-task | MUST / MUST NOT | both | tested: conformance.TestUtilitiesServer/2025-11-25/cancellation/cancellation-requestid-required-non-task-forbidden-task | tested: conformance.TestUtilitiesClient/2025-11-25/cancellation/cancellation-requestid-required-non-task-forbidden-task |
| cancellation-receiver-should-stop-free-no-response | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-11-25/cancellation/cancellation-receiver-should-stop-free-no-response | tested: conformance.TestUtilitiesClient/2025-11-25/cancellation/cancellation-receiver-should-stop-free-no-response |
| cancellation-receiver-may-ignore | MAY | both | tested: conformance.TestUtilitiesServer/2025-11-25/cancellation/cancellation-receiver-may-ignore | tested: conformance.TestUtilitiesClient/2025-11-25/cancellation/cancellation-receiver-may-ignore |
| cancellation-sender-ignore-late-response | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-11-25/cancellation/cancellation-sender-ignore-late-response | tested: conformance.TestUtilitiesClient/2025-11-25/cancellation/cancellation-sender-ignore-late-response |
| cancellation-handle-races | MUST | both | tested: conformance.TestUtilitiesServer/2025-11-25/cancellation/cancellation-handle-races | tested: conformance.TestUtilitiesClient/2025-11-25/cancellation/cancellation-handle-races |
| cancellation-log-reasons | SHOULD | both | gap #77: a cancellation reason reaches Server.OnCancel, which nothing outside tests sets; it is never logged | tested: conformance.TestUtilitiesClient/2025-11-25/cancellation/cancellation-log-reasons |
| cancellation-ui-indicate | SHOULD | both | n/a: no UI | n/a: no UI |
| cancellation-ignore-invalid | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-11-25/cancellation/cancellation-ignore-invalid | tested: conformance.TestUtilitiesClient/2025-11-25/cancellation/cancellation-ignore-invalid |
| cancellation-processing-should-cease | SHOULD | both (receiver) | tested: conformance.TestUtilitiesServer/2025-11-25/cancellation/cancellation-processing-should-cease | tested: conformance.TestUtilitiesClient/2025-11-25/cancellation/cancellation-processing-should-cease |
completion
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| completion-declare-capability | MUST | server | tested: conformance.TestUtilitiesServer/2025-11-25/completion/completion-declare-capability | n/a: addresses the other side |
| completion-max-100 | descr. + schema | server | tested: conformance.TestUtilitiesServer/2025-11-25/completion/completion-max-100 | n/a: addresses the other side |
| completion-server-should | SHOULD | server | tested: daemon.TestHandleCompleteRateLimitHotChangeAndZeroDisables@alldaemon.TestHandleCompleteRateLimitRefusesBeforePool@alldaemon.TestHandleCompleteServerWindowBindsAcrossCallers@alldaemon.TestLocalCompletionCapAfterRanking@alldaemon.TestLocalCompletionTiersPrompts@alldaemon.TestLocalCompletionTiersResources@all | n/a: addresses the other side |
| completion-client-should | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-11-25/completion/completion-client-should |
| completion-security | MUST | both | gap #1968: partial: completion inputs are validated and completion/complete is rate limited per caller (completion.perMinute) and per server (completion.serverPerMinute) since #2387, but there is no per-caller access control for sensitive suggestions and no completion-specific information-disclosure control; waiting on principals #1968 / permissive-auth #1972 | n/a: catalogue marks it n/a |
| completion-errors | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-11-25/completion/completion-errors | n/a: addresses the other side |
| completion-context-arguments | should (lowercase) | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-11-25/completion/completion-context-arguments |
elicitation
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| elicitation-server-no-sensitive-info-in-form-mode | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-server-no-sensitive-info-in-form-mode | n/a: addresses the other side |
| elicitation-server-sensitive-via-url-mode | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-server-sensitive-via-url-mode | n/a: addresses the other side |
| elicitation-client-show-requesting-server | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-show-requesting-server |
| elicitation-client-form-review-modify-responses | MUST | client | n/a: addresses the other side | tested: cli.TestRenderAnswerReviewMasksSensitiveFields@2025-11-25,2026-07-28cli.TestRenderQuestionFormShowsSchemaDefaults@2025-11-25,2026-07-28 |
| elicitation-client-decline-cancel-options | MUST | client | n/a: addresses the other side | tested: elicit.TestAnExpiredQuestionBecomesCancelNotDecline@2025-06-18,2025-11-25,2026-07-28elicit.TestAnsweringAfterTheProcessDiedStillWorks@2025-06-18,2025-11-25,2026-07-28 |
| elicitation-client-url-show-domain-and-consent | MUST | client | n/a: addresses the other side | tested: cli.TestRenderQuestionURLShowsRegistrableDomain@2025-11-25,2026-07-28cli.TestRenderQuestionURLWarnsOnPunycodeHost@2025-11-25,2026-07-28 |
| elicitation-client-declare-capability-at-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-declare-capability-at-initialize |
| elicitation-empty-capability-means-form-only | (definition) | both | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-empty-capability-means-form-only | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-empty-capability-means-form-only |
| elicitation-client-support-at-least-one-mode | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-support-at-least-one-mode |
| elicitation-server-no-undeclared-modes | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-server-no-undeclared-modes | n/a: addresses the other side |
| elicitation-request-must-include-mode-and-message | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-request-must-include-mode-and-message | n/a: addresses the other side |
| elicitation-server-may-omit-mode-for-form | MAY | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-server-may-omit-mode-for-form | n/a: addresses the other side |
| elicitation-client-missing-mode-is-form | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-missing-mode-is-form |
| elicitation-form-request-shape | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-form-request-shape | n/a: addresses the other side |
| elicitation-form-schema-flat-primitives | MUST (schema-structural) | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-form-schema-flat-primitives | n/a: addresses the other side |
| elicitation-client-prepopulate-defaults | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestElicitationDeclarations/2025-11-25/elicitation/client-applies-schema-defaults |
| elicitation-url-request-shape-with-id | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-url-request-shape-with-id | n/a: addresses the other side |
| elicitation-url-must-be-valid | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-url-must-be-valid | n/a: addresses the other side |
| elicitation-client-elicitationid-opaque | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-elicitationid-opaque |
| elicitation-server-may-send-complete-notification | MAY | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-server-may-send-complete-notification | n/a: addresses the other side |
| elicitation-complete-only-to-initiating-client | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-complete-only-to-initiating-client | n/a: addresses the other side |
| elicitation-complete-must-carry-id | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-complete-must-carry-id | n/a: addresses the other side |
| elicitation-client-ignore-unknown-complete | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-ignore-unknown-complete |
| elicitation-client-may-auto-retry-on-complete | MAY | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-may-auto-retry-on-complete |
| elicitation-client-manual-retry-cancel-controls | SHOULD | client | n/a: addresses the other side | tested: e2e.TestElicitationsCanBeListedAndAnsweredFromTheCommandLine@2025-11-25,2026-07-28 |
| elicitation-server-may-return-url-required-error | MAY | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-server-may-return-url-required-error | n/a: addresses the other side |
| elicitation-url-required-error-only-when-required | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-url-required-error-only-when-required | n/a: addresses the other side |
| elicitation-url-required-error-lists-elicitations | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-url-required-error-lists-elicitations | n/a: addresses the other side |
| elicitation-url-required-error-url-mode-with-id | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-url-required-error-url-mode-with-id | n/a: addresses the other side |
| elicitation-server-must-return-32042 | MUST | server | gap #214: a url question for a client that cannot take it stays with the broker; -32042 is never returned | n/a: addresses the other side |
| elicitation-client-32602-on-undeclared-mode | MUST | client | n/a: addresses the other side | tested: mcpclient.TestElicitationDeclarations/2025-11-25/elicitation/undeclared-mode-is-32602 |
| elicitation-server-state-bound-to-user | MUST | server | gap #257: mcpx has no authenticated user: state is bound to the request, the session id is whatever the caller sends | n/a: addresses the other side |
| elicitation-state-not-session-id-alone | MUST NOT | server | gap #257: state is bound to the request, not a user; there is no user to bind to | n/a: addresses the other side |
| elicitation-state-storage-protected | MUST | server | tested: elicit.TestOpenAtCreatesOwnerOnlyStore@2025-11-25,2026-07-28elicit.TestOpenAtMigratesLegacyStore@2025-11-25,2026-07-28 | n/a: addresses the other side |
| elicitation-remote-user-id-from-authorization | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| elicitation-server-must-not-self-authorize-via-url | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-server-must-not-self-authorize-via-url | n/a: addresses the other side |
| elicitation-third-party-creds-not-via-client | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-third-party-creds-not-via-client | n/a: addresses the other side |
| elicitation-no-token-passthrough | MUST NOT | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| elicitation-user-authorizes-server-directly | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-user-authorizes-server-directly | n/a: addresses the other side |
| elicitation-server-not-transmit-url-creds-to-client | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-server-not-transmit-url-creds-to-client | n/a: addresses the other side |
| elicitation-server-bind-to-client-and-user | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-server-bind-to-client-and-user | n/a: addresses the other side |
| elicitation-client-user-approval-controls | SHOULD | client | n/a: addresses the other side | tested: elicit.TestRoutingSendsCredentialsToAHumanAndTheRestToTheAgent@2025-06-18,2025-11-25,2026-07-28 |
| elicitation-client-allow-decline-any-time | SHOULD | client | n/a: addresses the other side | tested: elicit.TestAnsweringAfterTheProcessDiedStillWorks@2025-06-18,2025-11-25,2026-07-28 |
| elicitation-client-rate-limit | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-rate-limit |
| elicitation-client-clear-what-and-why | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-clear-what-and-why |
| elicitation-client-validate-responses | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestElicitationAnswersValidatedAgainstRequestedSchema/2025-11-25/elicitation/wrong-typed-answer-is-32602-not-relayed |
| elicitation-server-validate-received-data | SHOULD | server | tested: mcpclient.TestElicitationAnswersValidatedAgainstRequestedSchema/2025-11-25/elicitation/wrong-typed-answer-is-32602-not-relayed | n/a: addresses the other side |
| elicitation-url-no-sensitive-user-info | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-url-no-sensitive-user-info | n/a: addresses the other side |
| elicitation-url-not-pre-authenticated | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-url-not-pre-authenticated | n/a: addresses the other side |
| elicitation-no-clickable-urls-in-form | SHOULD NOT | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-no-clickable-urls-in-form | n/a: addresses the other side |
| elicitation-url-https-outside-dev | SHOULD | server | tested: conformance.TestClientFeaturesServer/2025-11-25/elicitation/elicitation-url-https-outside-dev | n/a: addresses the other side |
| elicitation-client-handle-urls-carefully | MUST | client | n/a: addresses the other side | tested: cli.TestRenderQuestionURLShowsRegistrableDomain@2025-11-25,2026-07-28cli.TestRenderQuestionURLWarnsOnPunycodeHost@2025-11-25,2026-07-28 |
| elicitation-client-no-url-prefetch | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-no-url-prefetch |
| elicitation-client-no-open-without-consent | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-no-open-without-consent |
| elicitation-client-show-full-url | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-show-full-url |
| elicitation-client-open-url-securely | MUST | client | n/a: addresses the other side | tested: elicit.TestRoutingSendsCredentialsToAHumanAndTheRestToTheAgent@2025-06-18,2025-11-25,2026-07-28 |
| elicitation-client-highlight-domain | SHOULD | client | n/a: addresses the other side | tested: cli.TestRenderQuestionURLShowsRegistrableDomain@2025-11-25,2026-07-28 |
| elicitation-client-warn-punycode | SHOULD | client | n/a: addresses the other side | tested: cli.TestRenderQuestionURLWarnsOnPunycodeHost@2025-11-25,2026-07-28 |
| elicitation-client-no-clickable-except-url-field | SHOULD NOT | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/elicitation/elicitation-client-no-clickable-except-url-field |
| elicitation-server-no-trust-client-user-id | MUST NOT | server | gap #257: mcpx has no authenticated user: state is bound to the request, the session id is whatever the caller sends | n/a: addresses the other side |
| elicitation-server-follow-best-practices | SHOULD | server | gap #255: requestState signing is sound and question storage is owner-only (0700/0600, #1676); there is no user identity | n/a: addresses the other side |
| elicitation-url-verify-opening-user | MUST | server | n/a: mcpx issues no URL; the upstream that does sees mcpx’s identity, not the end user’s | n/a: addresses the other side |
| elicitation-url-same-user-start-and-complete | MUST | server | n/a: see Implied: shared pooled upstream | n/a: addresses the other side |
| elicitation-url-identity-resilient-to-url-tampering | MUST | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| elicitation-task-related-id-shared | MUST | server | tested: conformance.TestStatefulServer/2025-11-25/elicitation/elicitation-task-related-id-shared | n/a: addresses the other side |
errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| messages-error-response-same-id-unless-unreadable | MUST | both | tested: conformance.TestMessagesServer/2025-11-25/errors/messages-error-response-same-id-unless-unreadable | tested: conformance.TestMessagesClient/2025-11-25/errors/messages-error-response-same-id-unless-unreadable |
| errors-error-has-code-and-message | MUST | both | tested: conformance.TestMessagesServer/2025-11-25/errors/errors-error-has-code-and-message | tested: conformance.TestMessagesClient/2025-11-25/errors/errors-error-has-code-and-message |
| errors-codes-integers | MUST | both | tested: conformance.TestMessagesServer/2025-11-25/errors/errors-codes-integers | tested: conformance.TestMessagesClient/2025-11-25/errors/errors-codes-integers |
| errors-message-concise-single-sentence | SHOULD | both | tested: conformance.TestMessagesServer/2025-11-25/errors/errors-message-concise-single-sentence | n/a: sends no errors except to server requests |
json-schema
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| json-schema-default-2020-12 | (definitional) | both | tested: conformance.TestMessagesServer/2025-11-25/json-schema/json-schema-default-2020-12 | n/a: catalogue marks it n/a |
| json-schema-may-declare-dialect | MAY | both | tested: conformance.TestMessagesServer/2025-11-25/json-schema/json-schema-may-declare-dialect | tested: conformance.TestMessagesClient/2025-11-25/json-schema/json-schema-may-declare-dialect |
| json-schema-support-2020-12 | MUST | both | tested: mcpclient.TestCallToolRejectsInvalidArgumentsBeforeTheTransport@all | n/a: passes through |
| json-schema-validate-per-dialect-and-error-on-unsupported | MUST | both | tested: mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@allschemaval.TestDeclaredDialectsCompile@allschemaval.TestUnsupportedDialect@all | tested: mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@allschemaval.TestDeclaredDialectsCompile@allschemaval.TestUnsupportedDialect@all |
| json-schema-document-dialects | SHOULD | both | tested: schemaval.TestDeclaredDialectsCompile@allschemaval.TestUnsupportedDialect@all | tested: schemaval.TestDeclaredDialectsCompile@allschemaval.TestUnsupportedDialect@all |
| json-schema-recommend-2020-12 | RECOMMENDED | both | tested: conformance.TestMessagesServer/2025-11-25/json-schema/json-schema-recommend-2020-12 | n/a: catalogue marks it n/a |
| json-schema-schemas-valid | MUST | both | tested: conformance.TestMessagesServer/2025-11-25/json-schema/json-schema-schemas-valid | n/a: catalogue marks it n/a |
| tools-schema-default-2020-12 | (definition) | both | tested: conformance.TestMessagesServer/2025-11-25/json-schema/tools-schema-default-2020-12 | tested: conformance.TestMessagesClient/2025-11-25/json-schema/tools-schema-default-2020-12 |
lifecycle
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| lifecycle-implementations-support-base-and-lifecycle | MUST | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-implementations-support-base-and-lifecycle | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-implementations-support-base-and-lifecycle |
| lifecycle-initialization-first-interaction | MUST | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-initialization-first-interaction | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-initialization-first-interaction |
| lifecycle-client-sends-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-client-sends-initialize |
| lifecycle-server-responds-caps-and-info | MUST | server | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-server-responds-caps-and-info | n/a: addresses the other side |
| lifecycle-client-sends-initialized-after-init | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-client-sends-initialized-after-init |
| lifecycle-client-no-requests-before-init-response | SHOULD NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-client-no-requests-before-init-response |
| lifecycle-server-no-requests-before-initialized | SHOULD NOT | server | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-server-no-requests-before-initialized | n/a: addresses the other side |
| lifecycle-capabilities-establish-session-features | (definitional) | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-capabilities-establish-session-features | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-capabilities-establish-session-features |
| lifecycle-respect-negotiated-version-and-capabilities-must | MUST | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-respect-negotiated-version-and-capabilities-must | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-respect-negotiated-version-and-capabilities-must |
| lifecycle-stdio-client-shutdown-sequence | SHOULD | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-stdio-client-shutdown-sequence |
| lifecycle-stdio-server-may-exit | MAY | server | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-stdio-server-may-exit | n/a: addresses the other side |
| lifecycle-http-shutdown-closing-connection | (definitional) | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-http-shutdown-closing-connection | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-http-shutdown-closing-connection |
| lifecycle-prepared-for-error-cases | SHOULD | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-prepared-for-error-cases | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-prepared-for-error-cases |
| lifecycle-timeouts-establish | SHOULD | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-timeouts-establish | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-timeouts-establish |
| lifecycle-timeouts-cancel-on-expiry | SHOULD | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-timeouts-cancel-on-expiry | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-timeouts-cancel-on-expiry |
| lifecycle-timeouts-per-request-configurable | SHOULD | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-timeouts-per-request-configurable | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-timeouts-per-request-configurable |
| lifecycle-timeouts-may-reset-on-progress | MAY | both | n/a: catalogue marks it n/a | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-timeouts-may-reset-on-progress |
| lifecycle-timeouts-max-regardless-of-progress | SHOULD | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-timeouts-max-regardless-of-progress | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-timeouts-max-regardless-of-progress |
| lifecycle-cancel-not-initialize | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-cancel-not-initialize |
| lifecycle-instructions-may-go-in-system-prompt | MAY | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-instructions-may-go-in-system-prompt |
| lifecycle-capability-sets-open | (definitional) | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-capability-sets-open | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-capability-sets-open |
| lifecycle-implementation-name-version-required | REQUIRED (schema field) | both | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-implementation-name-version-required | tested: conformance.TestLifecycleClient/2025-11-25/lifecycle/lifecycle-implementation-name-version-required |
| lifecycle-include-context-only-if-declared | SHOULD | server | tested: conformance.TestLifecycleServer/2025-11-25/lifecycle/lifecycle-include-context-only-if-declared | n/a: addresses the other side |
logging
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| logging-declare-capability | MUST | server | tested: conformance.TestUtilitiesServer/2025-11-25/logging/logging-declare-capability | n/a: addresses the other side |
| logging-client-may-setlevel | MAY | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-11-25/logging/logging-client-may-setlevel |
| logging-setlevel-at-or-above | should (schema) | server | tested: conformance.TestUtilitiesServer/2025-11-25/logging/logging-setlevel-at-or-above | n/a: addresses the other side |
| logging-unset-server-chooses | MAY | server | tested: conformance.TestUtilitiesServer/2025-11-25/logging/logging-unset-server-chooses | n/a: addresses the other side |
| logging-errors-setlevel | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-11-25/logging/logging-errors-setlevel | n/a: addresses the other side |
| logging-server-hygiene | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-11-25/logging/logging-server-hygiene | n/a: addresses the other side |
| logging-no-secrets-pii | MUST NOT | both (emitter) | tested: conformance.TestUtilitiesServer/2025-11-25/logging/logging-no-secrets-pii | tested: conformance.TestUtilitiesClient/2025-11-25/logging/logging-no-secrets-pii |
| logging-impl-validate-control | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-11-25/logging/logging-impl-validate-control | tested: conformance.TestUtilitiesClient/2025-11-25/logging/logging-impl-validate-control |
messages
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| messages-all-messages-follow-jsonrpc-2 | MUST | both | tested: conformance.TestJSONRPCFramesEveryRevision/2025-11-25/messages/all-messages-follow-jsonrpc-2-server-httpconformance.TestJSONRPCFramesEveryRevision/2025-11-25/messages/all-messages-follow-jsonrpc-2-server-stdioconformance.TestMessagesServer/2025-11-25/messages/messages-all-messages-follow-jsonrpc-2 | tested: conformance.TestJSONRPCFramesEveryRevision/2025-11-25/messages/all-messages-follow-jsonrpc-2-client |
| messages-other-components-may | MAY | both | tested: conformance.TestMessagesServer/2025-11-25/messages/messages-other-components-may | tested: conformance.TestMessagesClient/2025-11-25/messages/messages-other-components-may |
| messages-request-id-string-or-integer | MUST | both | tested: conformance.TestMessagesServer/2025-11-25/messages/messages-request-id-string-or-integer | tested: conformance.TestMessagesClient/2025-11-25/messages/messages-request-id-string-or-integer |
| messages-request-id-not-null | MUST NOT | both | tested: conformance.TestMessagesServer/2025-11-25/messages/messages-request-id-not-null | tested: conformance.TestMessagesClient/2025-11-25/messages/messages-request-id-not-null |
| messages-request-id-unique-per-session | MUST NOT | both | tested: conformance.TestMessagesServer/2025-11-25/messages/messages-request-id-unique-per-session | tested: conformance.TestMessagesClient/2025-11-25/messages/messages-request-id-unique-per-session |
| messages-result-response-same-id | MUST | both | tested: conformance.TestMessagesServer/2025-11-25/messages/messages-result-response-same-id | tested: conformance.TestMessagesClient/2025-11-25/messages/messages-result-response-same-id |
| messages-result-response-has-result | MUST | both | tested: conformance.TestMessagesServer/2025-11-25/messages/messages-result-response-has-result | tested: conformance.TestMessagesClient/2025-11-25/messages/messages-result-response-has-result |
| messages-result-any-object | MAY | both | tested: conformance.TestMessagesServer/2025-11-25/messages/messages-result-any-object | tested: conformance.TestMessagesClient/2025-11-25/messages/messages-result-any-object |
| messages-notification-no-response | MUST NOT | both | tested: conformance.TestMessagesServer/2025-11-25/messages/messages-notification-no-response | tested: conformance.TestMessagesClient/2025-11-25/messages/messages-notification-no-response |
| messages-notification-no-id | MUST NOT | both | tested: conformance.TestMessagesServer/2025-11-25/messages/messages-notification-no-id | tested: conformance.TestMessagesClient/2025-11-25/messages/messages-notification-no-id |
| transport-jsonrpc-utf8 | MUST | both | tested: conformance.TestMessagesServer/2025-11-25/messages/transport-jsonrpc-utf8 | tested: conformance.TestMessagesClient/2025-11-25/messages/transport-jsonrpc-utf8 |
meta
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| meta-no-assumptions-on-reserved-keys | MUST NOT | both | tested: conformance.TestMessagesServer/2025-11-25/meta/meta-no-assumptions-on-reserved-keys | tested: conformance.TestMessagesClient/2025-11-25/meta/meta-no-assumptions-on-reserved-keys |
| meta-prefix-label-format | MUST | both | tested: conformance.TestMessagesServer/2025-11-25/meta/meta-prefix-label-format | tested: conformance.TestMessagesClient/2025-11-25/meta/meta-prefix-label-format |
| meta-reverse-dns-should | SHOULD | both | tested: conformance.TestMessagesServer/2025-11-25/meta/meta-reverse-dns-should | tested: conformance.TestMessagesClient/2025-11-25/meta/meta-reverse-dns-should |
| meta-reserved-prefix-second-label | (reservation) | both | tested: conformance.TestMessagesServer/2025-11-25/meta/meta-reserved-prefix-second-label | tested: conformance.TestMessagesClient/2025-11-25/meta/meta-reserved-prefix-second-label |
| meta-name-format | MUST / MAY | both | tested: conformance.TestMessagesServer/2025-11-25/meta/meta-name-format | tested: conformance.TestMessagesClient/2025-11-25/meta/meta-name-format |
| meta-schema-may-reserve-names | (reservation) | both | tested: conformance.TestMessagesServer/2025-11-25/meta/meta-schema-may-reserve-names | tested: conformance.TestMessagesClient/2025-11-25/meta/meta-schema-may-reserve-names |
| meta-progress-token-receiver-not-obligated | (definitional) | both | tested: conformance.TestMessagesServer/2025-11-25/meta/meta-progress-token-receiver-not-obligated | tested: conformance.TestMessagesClient/2025-11-25/meta/meta-progress-token-receiver-not-obligated |
| meta-icons-client-mime-support | MUST / SHOULD | client | n/a: addresses the other side | n/a: renders no icons |
| meta-icons-consumer-security | MUST | client | n/a: addresses the other side | n/a: never fetches icons; passes metadata through |
| meta-icons-reject-unsafe-schemes | MUST | client | n/a: addresses the other side | n/a: does not dereference). Downstream host must do it |
| meta-icons-may-limit-size | MAY | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| meta-icons-same-origin | SHOULD | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
pagination
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| pagination-no-fixed-page-size | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-11-25/pagination/pagination-no-fixed-page-size |
| pagination-stable-cursors | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-11-25/pagination/pagination-stable-cursors | n/a: addresses the other side |
| pagination-invalid-cursor-graceful | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-11-25/pagination/pagination-invalid-cursor-graceful | n/a: addresses the other side |
| pagination-missing-nextcursor-end | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-11-25/pagination/pagination-missing-nextcursor-end |
| pagination-support-both-flows | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-11-25/pagination/pagination-support-both-flows |
| pagination-cursor-opaque | MUST | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-11-25/pagination/pagination-cursor-opaque |
| pagination-no-persist-across-sessions | MUST | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2025-11-25/pagination/pagination-no-persist-across-sessions |
| pagination-invalid-cursor-32602 | SHOULD | server | tested: conformance.TestUtilitiesServer/2025-11-25/pagination/pagination-invalid-cursor-32602 | n/a: addresses the other side |
ping
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| ping-receiver-responds-promptly-empty | MUST | both | tested: conformance.TestLifecycleServer/2025-11-25/ping/ping-receiver-responds-promptly-empty | tested: conformance.TestLifecycleClient/2025-11-25/ping/ping-receiver-responds-promptly-empty |
| ping-either-party-may-initiate | (definitional) | both | tested: conformance.TestLifecycleServer/2025-11-25/ping/ping-either-party-may-initiate | tested: conformance.TestLifecycleClient/2025-11-25/ping/ping-either-party-may-initiate |
| ping-sender-may-consider-stale | MAY | both | n/a: catalogue marks it n/a | n/a: does not ping |
| ping-periodic-pings | SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| ping-frequency-configurable | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-timeouts-appropriate | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-avoid-excessive | SHOULD | both | tested: conformance.TestLifecycleServer/2025-11-25/ping/ping-avoid-excessive | tested: conformance.TestLifecycleClient/2025-11-25/ping/ping-avoid-excessive |
| ping-timeout-is-connection-failure | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-multiple-failures-may-reset | MAY | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-log-failures | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| ping-schema-receiver-must-respond | must (lowercase) | both | tested: conformance.TestLifecycleServer/2025-11-25/ping/ping-schema-receiver-must-respond | tested: conformance.TestLifecycleClient/2025-11-25/ping/ping-schema-receiver-must-respond |
progress
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| progress-token-string-or-int | MUST | both (requester) | n/a: as a server mcpx never requests progress; a host’s token is relayed back verbatim | tested: conformance.TestUtilitiesClient/2025-11-25/progress/progress-token-string-or-int |
| progress-token-unique-sender | MUST | both | tested: conformance.TestUtilitiesServer/2025-11-25/progress/progress-token-unique-sender | tested: conformance.TestUtilitiesClient/2025-11-25/progress/progress-token-unique-sender |
| progress-receiver-may-send | MAY | both | n/a: optional feature mcpx does not implement | n/a: never receives requests with tokens |
| progress-must-increase | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2025-11-25/progress/progress-must-increase | n/a: catalogue marks it n/a |
| progress-message-human-readable | SHOULD | both (notifier) | tested: conformance.TestUtilitiesServer/2025-11-25/progress/progress-message-human-readable | n/a: catalogue marks it n/a |
| progress-only-active-tokens | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2025-11-25/progress/progress-only-active-tokens | n/a: catalogue marks it n/a |
| progress-receiver-may-skip | MAY | both | tested: conformance.TestUtilitiesServer/2025-11-25/progress/progress-receiver-may-skip | tested: conformance.TestUtilitiesClient/2025-11-25/progress/progress-receiver-may-skip |
| progress-task-token-lives-with-task | MUST | both (receiver) | tested: conformance.TestUtilitiesServer/2025-11-25/progress/progress-task-token-lives-with-task | n/a: catalogue marks it n/a |
| progress-task-same-token | MUST | both (receiver) | tested: conformance.TestUtilitiesServer/2025-11-25/progress/progress-task-same-token | n/a: catalogue marks it n/a |
| progress-task-stop-at-terminal | MUST | both (receiver) | tested: conformance.TestUtilitiesServer/2025-11-25/progress/progress-task-stop-at-terminal | n/a: catalogue marks it n/a |
| progress-track-tokens | SHOULD | both | n/a: catalogue marks it n/a | tested: conformance.TestUtilitiesClient/2025-11-25/progress/progress-track-tokens |
| progress-rate-limit | SHOULD | both | tested: conformance.TestUtilitiesServer/2025-11-25/progress/progress-rate-limit | tested: conformance.TestUtilitiesClient/2025-11-25/progress/progress-rate-limit |
| progress-stop-after-completion | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2025-11-25/progress/progress-stop-after-completion | n/a: catalogue marks it n/a |
prompts
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2025-11-25/prompts/prompts-declare-capability | n/a: addresses the other side |
| prompts-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/prompts/prompts-list-changed-notify | n/a: addresses the other side |
| prompts-image-base64-mime | MUST | server | tested: conformance.TestFeaturesServer/2025-11-25/prompts/prompts-image-base64-mime | n/a: addresses the other side |
| prompts-audio-base64-mime | MUST (not bolded) | server | tested: conformance.TestFeaturesServer/2025-11-25/prompts/prompts-audio-base64-mime | n/a: addresses the other side |
| prompts-embedded-must-include | MUST | server | tested: conformance.TestFeaturesServer/2025-11-25/prompts/prompts-embedded-must-include | n/a: addresses the other side |
| prompts-validate-args | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/prompts/prompts-validate-args | n/a: addresses the other side |
| prompts-respect-capability-negotiation | SHOULD | both | tested: conformance.TestFeaturesServer/2025-11-25/prompts/prompts-respect-capability-negotiation | tested: conformance.TestFeaturesClient/2025-11-25/prompts/prompts-respect-capability-negotiation |
| prompts-validate-io | MUST | both | tested: conformance.TestFeaturesServer/2025-11-25/prompts/prompts-validate-io; mcpclient.TestValidatePromptResult | covered |
prompts / errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-errors-32602-32603 | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/prompts/prompts-errors-32602-32603 | n/a: addresses the other side |
prompts / pagination
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-client-paginate | SHOULD | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-11-25/prompts/prompts-client-paginate |
resources
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2025-11-25/resources/resources-declare-capability | n/a: addresses the other side |
| resources-subscribe-cap-meaning-legacy | (definition) | server | tested: e2e.TestResourceSubscriptionsReachUpstream/2025-11-25/resources/http-subscribe-delivers-upstream-updates-on-the-get-stream@2025-03-26,2025-06-18,2025-11-25e2e.TestResourceSubscriptionsReachUpstream/2025-11-25/resources/stdio-subscribe-delivers-upstream-updates-under-the-mcpx-uri@2024-11-05,2025-03-26,2025-06-18,2025-11-25 | n/a: addresses the other side |
| resources-cap-features-independent | MAY | server | tested: conformance.TestFeaturesServer/2025-11-25/resources/resources-cap-features-independent | n/a: addresses the other side |
| resources-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/resources/resources-list-changed-notify | n/a: addresses the other side |
| resources-updated-may-be-subresource | (note, schema) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-11-25/resources/resources-updated-may-be-subresource |
| resources-https-only-if-client-fetchable | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/resources/resources-https-only-if-client-fetchable | n/a: addresses the other side |
| resources-https-else-other-scheme | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/resources/resources-https-else-other-scheme | n/a: addresses the other side |
| resources-file-xdg-mime-may | MAY | server | tested: conformance.TestFeaturesServer/2025-11-25/resources/resources-file-xdg-mime-may | n/a: addresses the other side |
| resources-custom-scheme-rfc3986 | MUST | server | tested: cli.TestMCPXResourceURIs/2025-11-25/resources/resources-custom-scheme-rfc3986 | n/a: addresses the other side |
| resources-sec-validate-uris | MUST | server | tested: conformance.TestFeaturesServer/2025-11-25/resources/resources-sec-validate-uris | n/a: addresses the other side |
| resources-sec-access-control | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| resources-sec-binary-encoded | MUST | server | tested: conformance.TestFeaturesServer/2025-11-25/resources/resources-sec-binary-encoded | n/a: addresses the other side |
| resources-sec-permissions | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| resources-annotations-defs | (definition) | both | tested: conformance.TestFeaturesServer/2025-11-25/resources/resources-annotations-defs | gap #207: mcpclient.Resource has no annotations field; they are dropped on parse |
resources / errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-not-found-32002 | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/resources/resources-not-found-32002 | n/a: addresses the other side |
| resources-internal-32603 | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/resources/resources-internal-32603 | n/a: addresses the other side |
resources / subscriptions
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-subscribe-rpc | (optional) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-11-25/resources/resources-subscribe-rpc |
| resources-unsubscribe-follows-subscribe | should (lowercase, schema) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-11-25/resources/resources-unsubscribe-follows-subscribe |
roots
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| roots-client-declare-capability-at-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/roots/roots-client-declare-capability-at-initialize |
| roots-client-listchanged-must-notify | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/roots/roots-client-listchanged-must-notify |
| roots-uri-must-be-file-scheme | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-only-expose-permitted-roots | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-validate-uris-path-traversal | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-implement-access-controls | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-monitor-accessibility | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-server-handle-unavailable-roots | SHOULD | server | n/a: mcpx does not consume roots | n/a: addresses the other side |
| roots-server-respect-boundaries | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-validate-paths-against-roots | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-client-prompt-consent-before-exposing | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-provide-root-management-ui | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-validate-accessibility-before-exposing | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-monitor-root-changes | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-server-check-capability-before-use | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-handle-list-changes | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-cache-root-information | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-client-return-jsonrpc-errors | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/roots/roots-client-return-jsonrpc-errors |
sampling
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| sampling-human-in-loop-can-deny | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/sampling/sampling-human-in-loop-can-deny |
| sampling-app-review-ui | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-app-view-edit-prompts | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-app-review-responses | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-client-declare-capability-at-initialize | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/sampling/sampling-client-declare-capability-at-initialize |
| sampling-client-declare-tools-capability | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/sampling/sampling-client-declare-tools-capability |
| sampling-server-no-tools-without-capability | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2025-11-25/sampling/sampling-server-no-tools-without-capability | n/a: addresses the other side |
| sampling-client-error-on-tools-without-capability | MUST | client | n/a: addresses the other side | tested: mcpclient.TestSamplingDeclarations/2025-11-25/sampling/tools-without-capability-is-32602 |
| sampling-server-includecontext-only-with-context-capability | SHOULD NOT | server | tested: conformance.TestClientFeaturesServer/2025-11-25/sampling/sampling-server-includecontext-only-with-context-capability | n/a: addresses the other side |
| sampling-server-avoid-includecontext-values | SHOULD | server | gap #210: an upstream sampling request is relayed as-is: tools and includeContext reach a client that did not declare sampling.tools or sampling.context | n/a: addresses the other side |
| sampling-tool-result-message-only-tool-results | MUST | both | tested: conformance.TestClientFeaturesServer/2025-11-25/sampling/sampling-tool-result-message-only-tool-results | tested: conformance.TestClientFeaturesClient/2025-11-25/sampling/sampling-tool-result-message-only-tool-results |
| sampling-tool-use-result-balance | MUST | both | tested: conformance.TestClientFeaturesServer/2025-11-25/sampling/sampling-tool-use-result-balance | tested: conformance.TestClientFeaturesClient/2025-11-25/sampling/sampling-tool-use-result-balance |
| sampling-server-answer-every-tooluse | MUST | server | tested: conformance.TestClientFeaturesServer/2025-11-25/sampling/sampling-server-answer-every-tooluse | n/a: addresses the other side |
| sampling-toolresult-id-must-match-tooluse | MUST | both | tested: conformance.TestClientFeaturesServer/2025-11-25/sampling/sampling-toolresult-id-must-match-tooluse | tested: conformance.TestClientFeaturesClient/2025-11-25/sampling/sampling-toolresult-id-must-match-tooluse |
| sampling-toolchoice-required-model-must-use-tool | MUST | client | n/a: addresses the other side | n/a: mcpx never declares tools |
| sampling-toolchoice-none-model-must-not-use-tools | MUST NOT | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| sampling-parallel-tool-toggle-extension-only | MAY | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| sampling-tooluse-meta-preserve | SHOULD | client (text says “Clients”; see Conflicts) | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/sampling/sampling-tooluse-meta-preserve |
| sampling-toolresult-structured-conform-outputschema | SHOULD | both | tested: conformance.TestClientFeaturesServer/2025-11-25/sampling/sampling-toolresult-structured-conform-outputschema | tested: conformance.TestClientFeaturesClient/2025-11-25/sampling/sampling-toolresult-structured-conform-outputschema |
| sampling-client-may-sample-fewer-tokens | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-ignore-model-preferences | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-modify-system-prompt | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-ignore-includecontext | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hints-evaluated-in-order | MUST | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hints-over-priorities | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hint-name-substring | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hint-may-map-provider | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-stopreason-custom-values | MAY | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/sampling/sampling-stopreason-custom-values |
| sampling-client-errors-user-rejected-and-tool-shape | SHOULD | client | n/a: addresses the other side | gap #1836: only the -32602 content half remains: sampling messages/results are not yet checked for a missing tool result or tool results mixed with other content (Phase 2 of #1834); the -1 half is covered – a refusal reaches the upstream as -1 (typed CodedError, #1835) |
| sampling-client-user-approval-controls | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-both-validate-message-content | SHOULD | both | tested: conformance.TestSamplingValidation/2025-11-25/sampling/sampling-both-validate-message-content | tested: conformance.TestSamplingValidation/2025-11-25/sampling/sampling-both-validate-message-content |
| sampling-client-respect-hints | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-rate-limit | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2025-11-25/sampling/sampling-client-rate-limit |
| sampling-both-handle-sensitive-data | MUST | both | tested: daemon.TestElicitEventsAreSummaryOnly@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28elicit.TestOpenPrunesTerminalRowsPastTheAgeBound@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28 | tested: daemon.TestElicitEventsAreSummaryOnly@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28elicit.TestOpenPrunesTerminalRowsPastTheAgeBound@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28 |
| sampling-both-iteration-limits-tool-loops | SHOULD | both | tested: conformance.TestClientFeaturesServer/2025-11-25/sampling/sampling-both-iteration-limits-tool-loops | tested: conformance.TestClientFeaturesClient/2025-11-25/sampling/sampling-both-iteration-limits-tool-loops |
subscriptions
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| subscriptions-list-changed-should-notify | SHOULD | server | tested: conformance.TestStatefulServer/2025-11-25/subscriptions/subscriptions-list-changed-should-notify | n/a: addresses the other side |
| subscriptions-resources-updated-only-if-subscribed | should (schema) | server | tested: conformance.TestStatefulServer/2025-11-25/subscriptions/subscriptions-resources-updated-only-if-subscribed | n/a: addresses the other side |
tasks
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tasks-declare-capability | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-declare-capability | n/a: optional feature mcpx does not implement |
| tasks-augment-only-if-declared | SHOULD | both (requestor) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-augment-only-if-declared | tested: conformance.TestStatefulClient/2025-11-25/tasks/tasks-augment-only-if-declared |
| tasks-no-capability-no-tasks | SHOULD NOT | both (requestor) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-no-capability-no-tasks | tested: conformance.TestStatefulClient/2025-11-25/tasks/tasks-no-capability-no-tasks |
| tasks-tool-no-cap-no-augment | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestStatefulClient/2025-11-25/tasks/tasks-tool-no-cap-no-augment |
| tasks-tool-forbidden-default | MUST NOT / SHOULD | both | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-tool-forbidden-default | n/a: catalogue marks it n/a |
| tasks-tool-optional | MAY | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| tasks-tool-required | MUST | both | n/a: never required | n/a: mcpx reads upstream execution.taskSupport to serve its own clients, but never task-augments the upstream call; a required upstream tool that enforces it would fail |
| tasks-requestor-may-ttl | MAY | both (requestor) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-requestor-may-ttl | n/a: catalogue marks it n/a |
| tasks-poll-respect-interval | SHOULD | both (requestor) | n/a: catalogue marks it n/a | n/a: no upstream tasks |
| tasks-poll-until-terminal | SHOULD | both (requestor) | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| tasks-sse-client-may-disconnect | MAY / MUST | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| tasks-status-notification-optional | MAY / MUST NOT | both | n/a: optional feature mcpx does not implement | tested: conformance.TestStatefulClient/2025-11-25/tasks/tasks-status-notification-optional |
| tasks-status-notification-no-related-task | SHOULD NOT | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-status-notification-no-related-task | n/a: catalogue marks it n/a |
| tasks-receiver-without-cap-ignore | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-receiver-without-cap-ignore | n/a: mcpx declares no client task caps; unverified whether it strips task from an upstream sampling/createMessage |
| tasks-receiver-may-require | MAY | both (receiver) | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| tasks-id-string-receiver-unique | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-id-string-receiver-unique | n/a: catalogue marks it n/a |
| tasks-begin-working | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-begin-working | n/a: catalogue marks it n/a |
| tasks-valid-transitions | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-valid-transitions | n/a: catalogue marks it n/a |
| tasks-terminal-immutable | MUST NOT | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-terminal-immutable | n/a: catalogue marks it n/a |
| tasks-input-required-move | SHOULD | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-input-required-move | n/a: catalogue marks it n/a |
| tasks-input-request-related-task | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-input-request-related-task | n/a: catalogue marks it n/a |
| tasks-requestor-input-required-call-result | SHOULD | both (requestor) | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| tasks-leave-input-required | SHOULD | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-leave-input-required | n/a: catalogue marks it n/a |
| tasks-sse-any-stream | SHOULD | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| tasks-sse-no-upgrade-get | SHOULD NOT | server | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-sse-no-upgrade-get | n/a: addresses the other side |
| tasks-timestamps | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-timestamps | n/a: catalogue marks it n/a |
| tasks-ttl-override-report | MAY / MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-ttl-override-report | n/a: catalogue marks it n/a |
| tasks-delete-after-ttl | MAY | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-delete-after-ttl | n/a: catalogue marks it n/a |
| tasks-poll-interval | MAY / SHOULD | both | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-poll-interval | n/a: catalogue marks it n/a |
| tasks-return-createtaskresult | MUST / SHOULD | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-return-createtaskresult | n/a: catalogue marks it n/a |
| tasks-result-terminal-final | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-result-terminal-final | n/a: catalogue marks it n/a |
| tasks-result-blocks | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-result-blocks | n/a: catalogue marks it n/a |
| tasks-result-exact | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-result-exact | n/a: catalogue marks it n/a |
| tasks-related-task-all-messages | MUST | both | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-related-task-all-messages | n/a: catalogue marks it n/a |
| tasks-taskid-param-authoritative | MUST / SHOULD NOT | both | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-taskid-param-authoritative | n/a: catalogue marks it n/a |
| tasks-no-related-task-in-get-list-cancel-results | SHOULD NOT | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-no-related-task-in-get-list-cancel-results | n/a: catalogue marks it n/a |
| tasks-result-response-related-task | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-result-response-related-task | n/a: catalogue marks it n/a |
| tasks-list-paginate | SHOULD / MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-list-paginate | n/a: catalogue marks it n/a |
| tasks-list-cursor-opaque | MUST | both (requestor) | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| tasks-get-implies-list | MUST | both (receiver) | gap #209: a task started on a connection without an identity can be read by tasks/get and is never listed | n/a: catalogue marks it n/a |
| tasks-cancel-terminal-reject | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-cancel-terminal-reject | n/a: catalogue marks it n/a |
| tasks-cancel-transition-before-response | SHOULD / MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-cancel-transition-before-response | n/a: catalogue marks it n/a |
| tasks-cancelled-sticky | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-cancelled-sticky | n/a: catalogue marks it n/a |
| tasks-cancelled-may-delete | MAY / SHOULD NOT | both | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-cancelled-may-delete | n/a: catalogue marks it n/a |
| tasks-protocol-errors | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-protocol-errors | n/a: catalogue marks it n/a |
| tasks-informative-errors | SHOULD | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-informative-errors | n/a: catalogue marks it n/a |
| tasks-failed-status-message | SHOULD | both (receiver) | gap #209: a task failed by an isError tool result carries no statusMessage | n/a: catalogue marks it n/a |
| tasks-result-same-error | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-result-same-error | n/a: catalogue marks it n/a |
| tasks-bind-auth-context | MUST | both (receiver) | n/a: optional feature mcpx does not implement | n/a: catalogue marks it n/a |
| tasks-document-unbound | SHOULD | both (receiver) | gap #209: nothing user-facing documents that an unowned task is reachable by anyone holding its id | n/a: catalogue marks it n/a |
| tasks-unbound-secure-ids | MUST | both (receiver) | tested: conformance.TestStatefulServer/2025-11-25/tasks/tasks-unbound-secure-ids | n/a: catalogue marks it n/a |
| tasks-unidentified-no-list | SHOULD NOT | both (receiver) | tested: mcpserver.TestTasksPerEra/2025-11-25/tasks/another-connection-cannot-list-or-read-it | n/a: catalogue marks it n/a |
| tasks-bound-reject-foreign | MUST | both (receiver) | n/a: no context | n/a: catalogue marks it n/a |
| tasks-rate-limit | SHOULD | both (receiver) | gap #209: tasks/* has no rate limit | n/a: catalogue marks it n/a |
| tasks-resource-limits | SHOULD | both (receiver) | gap #209: no maximum ttl and no cap on concurrent tasks per requestor | n/a: catalogue marks it n/a |
| tasks-audit | SHOULD | both | gap #209: task creation, completion and retrieval are not logged | n/a: catalogue marks it n/a |
tools
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tools-human-in-loop-deny | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-ui-expose-indicate-confirm | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-declare-capability | n/a: addresses the other side |
| tools-list-changed-means-emit | (definition) | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-list-changed-means-emit | n/a: addresses the other side |
| tools-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-list-changed-notify | n/a: addresses the other side |
| tools-annotations-untrusted | MUST | client | n/a: addresses the other side | tested: daemon.TestAnnotationClearsCallOnlyForLocallyDeclaredServers@2026-07-28 |
| tools-annotations-hints-no-decisions | should (lowercase, schema) | client | n/a: addresses the other side | tested: daemon.TestAnnotationClearsCallOnlyForLocallyDeclaredServers@2026-07-28 |
| tools-inputschema-no-params-recommended | RECOMMENDED | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-inputschema-no-params-recommended | n/a: addresses the other side |
| tools-name-length | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-name-length | n/a: addresses the other side |
| tools-name-case-sensitive | SHOULD | both | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-name-case-sensitive | tested: conformance.TestFeaturesClient/2025-11-25/tools/tools-name-case-sensitive |
| tools-name-charset | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-name-charset | n/a: addresses the other side |
| tools-name-no-special | SHOULD NOT | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-name-no-special | n/a: addresses the other side |
| tools-name-unique-in-server | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-name-unique-in-server | n/a: addresses the other side |
| tools-result-iserror-for-tool-errors | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-result-iserror-for-tool-errors | n/a: addresses the other side |
| tools-result-lookup-errors-protocol | should (lowercase) | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-result-lookup-errors-protocol | n/a: addresses the other side |
| tools-error-unknown-tool-protocol | (classification) | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-error-unknown-tool-protocol | n/a: addresses the other side |
| tools-error-input-validation-execution | (classification) | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-error-input-validation-execution | n/a: addresses the other side |
| tools-client-exec-errors-to-llm | SHOULD | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-11-25/tools/tools-client-exec-errors-to-llm |
| tools-client-protocol-errors-to-llm-may | MAY | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2025-11-25/tools/tools-client-protocol-errors-to-llm-may |
| tools-embedded-resource-may | MAY | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-embedded-resource-may | n/a: addresses the other side |
| tools-embedded-implies-resources-cap | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-embedded-implies-resources-cap | n/a: addresses the other side |
| tools-resource-link-may | MAY | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-resource-link-may | n/a: addresses the other side |
| tools-resource-link-may-not-listed | (note) | both | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-resource-link-may-not-listed | tested: conformance.TestFeaturesClient/2025-11-25/tools/tools-resource-link-may-not-listed |
| tools-structured-also-text | SHOULD | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-structured-also-text | n/a: addresses the other side |
| tools-structured-object-only | (type, schema) | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-structured-object-only | n/a: addresses the other side |
| tools-output-schema-conform | MUST | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-output-schema-conform | n/a: addresses the other side |
| tools-output-schema-client-validate | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-output-schema-object-root | (type, schema) | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-output-schema-object-root | n/a: addresses the other side |
| tools-display-name-precedence | (rule) | client | n/a: addresses the other side | gap #207: tool title and annotations.title are carried but nothing that displays a tool uses them |
| tools-server-sec-validate-inputs | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| tools-client-sec | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
tools / json-schema
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tools-inputschema-valid-not-null | MUST | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-inputschema-valid-not-null | n/a: addresses the other side |
| tools-inputschema-type-object-root | REQUIRED (schema) | server | tested: conformance.TestFeaturesServer/2025-11-25/tools/tools-inputschema-type-object-root | n/a: addresses the other side |
transport-http-sse
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| http-sse-server-backcompat-host-both | should (lowercase) | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| http-sse-client-backcompat-probe-400-404-405 | should (lowercase) | client | n/a: addresses the other side | tested: mcpclient.TestHTTPSSETransport/2025-11-25/transport-http-sse/backcompat-probe-refusal-is-recognisedpool.TestHTTPSSEFallback/2025-11-25/transport-http-sse/client-falls-back-to-get-endpoint-and-caches-it |
transport-stdio
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| transport-client-should-support-stdio | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-stdio/transport-client-should-support-stdio |
| transport-custom-preserve-jsonrpc-and-lifecycle | MUST | both | n/a: mcpx serves MCP only over stdio and Streamable HTTP; its /v1 REST surface is not an MCP transport | n/a: catalogue marks it n/a |
| transport-custom-document-patterns | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| stdio-no-embedded-newlines | MUST NOT | both | tested: conformance.TestTransportServer/2025-11-25/transport-stdio/stdio-no-embedded-newlines | tested: conformance.TestTransportClient/2025-11-25/transport-stdio/stdio-no-embedded-newlines |
| stdio-message-is-single | MUST (implied) | both | tested: conformance.TestTransportServer/2025-11-25/transport-stdio/stdio-message-is-single | tested: conformance.TestTransportClient/2025-11-25/transport-stdio/stdio-message-is-single |
| stdio-server-may-log-to-stderr | MAY | server | tested: conformance.TestTransportServer/2025-11-25/transport-stdio/stdio-server-may-log-to-stderr | n/a: addresses the other side |
| stdio-client-may-capture-stderr | MAY | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-stdio/stdio-client-may-capture-stderr |
| stdio-client-stderr-not-error | SHOULD NOT | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-stdio/stdio-client-stderr-not-error |
| stdio-server-stdout-only-mcp | MUST NOT | server | tested: conformance.TestTransportServer/2025-11-25/transport-stdio/stdio-server-stdout-only-mcp | n/a: addresses the other side |
| stdio-client-stdin-only-mcp | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-stdio/stdio-client-stdin-only-mcp |
transport-streamable-http
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| streamable-http-server-single-endpoint-post-get | MUST | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-single-endpoint-post-get | n/a: addresses the other side |
| streamable-http-server-validate-origin | MUST | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-validate-origin | n/a: addresses the other side |
| streamable-http-server-403-invalid-origin | MUST | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-403-invalid-origin | n/a: addresses the other side |
| streamable-http-server-bind-localhost | SHOULD | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-bind-localhost | n/a: addresses the other side |
| streamable-http-server-authenticate | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| streamable-http-client-post-every-message | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-streamable-http/streamable-http-client-post-every-message |
| streamable-http-client-accept-both | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-streamable-http/streamable-http-client-accept-both |
| streamable-http-post-body-single | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-streamable-http/streamable-http-post-body-single |
| streamable-http-server-202-for-non-request | MUST | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-202-for-non-request | n/a: addresses the other side |
| streamable-http-server-error-status-if-rejected | MUST | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-error-status-if-rejected | n/a: addresses the other side |
| streamable-http-server-json-or-sse-for-request | MUST | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-json-or-sse-for-request | n/a: addresses the other side |
| streamable-http-client-supports-json-and-sse | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-streamable-http/streamable-http-client-supports-json-and-sse |
| streamable-http-sse-includes-response | SHOULD | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-sse-includes-response | n/a: addresses the other side |
| streamable-http-post-sse-may-carry-requests | MAY | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-post-sse-may-carry-requests | n/a: addresses the other side |
| streamable-http-server-may-terminate-on-session-expiry | MAY | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-may-terminate-on-session-expiry | n/a: addresses the other side |
| streamable-http-server-terminates-sse-after-response | SHOULD | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-terminates-sse-after-response | n/a: addresses the other side |
| streamable-http-sse-priming-event | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| streamable-http-server-may-close-connection-keep-stream | MAY | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| streamable-http-client-polls-after-close | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestLegacyHTTPStreamResumption/2025-11-25/transport/client-resumes-closed-stream-with-last-event-id-after-retry |
| streamable-http-server-sends-retry-before-close | SHOULD | server | n/a: never closes early | n/a: addresses the other side |
| streamable-http-client-respects-retry | MUST | client | n/a: addresses the other side | tested: mcpclient.TestLegacyHTTPStreamResumption/2025-11-25/transport/client-resumes-closed-stream-with-last-event-id-after-retry |
| streamable-http-disconnect-not-cancel | SHOULD NOT | both | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-disconnect-not-cancel | n/a: catalogue marks it n/a |
| streamable-http-client-cancel-by-notification | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-streamable-http/streamable-http-client-cancel-by-notification |
| streamable-http-server-may-resume | MAY | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| streamable-http-client-may-get | MAY | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| streamable-http-get-accept-sse | MUST | client | n/a: addresses the other side | n/a: no GET |
| streamable-http-get-sse-or-405 | MUST | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-get-sse-or-405 | n/a: addresses the other side |
| streamable-http-get-stream-unrelated | SHOULD | server | n/a: 405 | n/a: addresses the other side |
| streamable-http-get-no-responses | MUST NOT | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| streamable-http-get-close-any-time | MAY | both | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-get-close-any-time | tested: conformance.TestTransportClient/2025-11-25/transport-streamable-http/streamable-http-get-close-any-time |
| streamable-http-multiple-streams | MAY | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-streamable-http/streamable-http-multiple-streams |
| streamable-http-no-broadcast | MUST / MUST NOT | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-no-broadcast | n/a: addresses the other side |
| streamable-http-event-id-unique | MUST | server | n/a: no ids | n/a: addresses the other side |
| streamable-http-event-id-encodes-stream | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| streamable-http-client-resume-with-last-event-id | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestLegacyHTTPStreamResumption/2025-11-25/transport/client-resumes-closed-stream-with-last-event-id-after-retry@2025-03-26,2025-06-18,2025-11-25 |
| streamable-http-server-no-cross-stream-replay | MUST NOT | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| streamable-http-server-may-assign-session | MAY | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-may-assign-session | n/a: addresses the other side |
| streamable-http-session-id-secure | SHOULD | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-session-id-secure | n/a: addresses the other side |
| streamable-http-session-id-visible-ascii | MUST | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-session-id-visible-ascii | n/a: addresses the other side |
| streamable-http-client-handles-session-securely | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-streamable-http/streamable-http-client-handles-session-securely |
| streamable-http-client-echoes-session | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-streamable-http/streamable-http-client-echoes-session |
| streamable-http-server-400-missing-session | SHOULD | server | n/a: mcpx does not require one (stateless fallback | n/a: addresses the other side |
| streamable-http-server-404-terminated-session | MUST | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-404-terminated-session | n/a: addresses the other side |
| streamable-http-client-reinit-on-404 | MUST | client | n/a: addresses the other side | tested: mcpclient.TestLegacySession404Recovery/2025-11-25/transport/client-reinitializes-on-session-404-and-retries-once@2025-03-26,2025-06-18,2025-11-25 |
| streamable-http-client-delete-session | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-streamable-http/streamable-http-client-delete-session |
| streamable-http-server-may-405-delete | MAY | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-may-405-delete | n/a: addresses the other side |
| streamable-http-protocol-version-header-is-negotiated | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2025-11-25/transport-streamable-http/streamable-http-protocol-version-header-is-negotiated |
| streamable-http-server-assumes-2025-03-26 | SHOULD | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-assumes-2025-03-26 | n/a: addresses the other side |
| streamable-http-server-400-unsupported-version-header | MUST | server | tested: conformance.TestTransportServer/2025-11-25/transport-streamable-http/streamable-http-server-400-unsupported-version-header | n/a: addresses the other side |
versioning
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| lifecycle-client-sends-supported-version | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-11-25/versioning/lifecycle-client-sends-supported-version |
| lifecycle-client-sends-latest-version | SHOULD | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-11-25/versioning/lifecycle-client-sends-latest-version |
| lifecycle-client-may-support-older | MAY | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-11-25/versioning/lifecycle-client-may-support-older |
| lifecycle-server-echoes-supported-version | MUST | server | tested: conformance.TestLifecycleServer/2025-11-25/versioning/lifecycle-server-echoes-supported-version | n/a: addresses the other side |
| lifecycle-server-counteroffers-version | MUST | server | tested: conformance.TestLifecycleServer/2025-11-25/versioning/lifecycle-server-counteroffers-version | n/a: addresses the other side |
| lifecycle-server-counteroffers-latest | SHOULD | server | tested: conformance.TestLifecycleServer/2025-11-25/versioning/lifecycle-server-counteroffers-latest | n/a: addresses the other side |
| lifecycle-client-disconnects-on-unsupported-version | SHOULD (prose) / MUST (schema) | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-11-25/versioning/lifecycle-client-disconnects-on-unsupported-version |
| lifecycle-http-protocol-version-header | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2025-11-25/versioning/lifecycle-http-protocol-version-header |
2026-07-28
(security; not in area list)
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| security-implementors-guidelines | SHOULD | both | tested: conformance.TestMessagesServer/2026-07-28/security/security-implementors-guidelines | n/a: catalogue marks it n/a |
authorization
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| authorization-http-should-conform-stdio-should-not | SHOULD / SHOULD NOT | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authorization-custom-auth-may | MAY | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-optional | OPTIONAL | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-http-should-conform | SHOULD | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-stdio-use-environment | SHOULD NOT | both | gap not implemented: #1975 | gap not implemented: #1950 |
| authz-alt-transport-best-practice | MUST | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-as-implements-oauth21 | MUST | auth-server | n/a: addresses the other side | n/a: addresses the other side |
| authz-dcr-may-deprecated | MAY | client, auth-server | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-bearer-header | MUST | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-token-every-request | MUST | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-no-token-in-query | MUST NOT | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-server-validates-token | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-server-validates-audience | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-client-sends-only-own-as-tokens | MUST NOT | client | n/a: addresses the other side | gap not implemented: #1950 |
| authz-accept-only-own-tokens | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-no-token-passthrough | MUST NOT | server | gap not implemented: #1975 | n/a: addresses the other side |
| authz-error-status-codes | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-server-prm-rfc9728 | MUST | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-prm-lists-authorization-servers | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-as-metadata-rfc8414-or-oidc | MUST | both | n/a: catalogue marks it n/a | n/a: optional feature mcpx does not implement |
| authz-prm-discovery-header-or-well-known | MUST | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-client-parses-www-authenticate | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-client-as-discovery-order | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-client-validates-issuer-in-metadata | MUST / MUST NOT | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-client-per-as-registration-state | MUST / MUST NOT | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-resource-indicator | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-canonical-uri-guidance | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-secure-token-storage | MUST | both | n/a: catalogue marks it n/a | gap not implemented: #1950 |
| authz-as-short-lived-and-rotate | SHOULD / MUST | auth-server | n/a: addresses the other side | n/a: addresses the other side |
| authz-https-and-redirect-uris | MUST | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| authz-pkce-verify-s256 | MUST | client, auth-server | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-open-redirection | MUST / SHOULD | client, auth-server | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-proxy-consent-per-client | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-client-registration-required | MUST | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-registration-priority | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-cimd | SHOULD / MUST | client, auth-server | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-preregistration | SHOULD | client | n/a: addresses the other side | n/a: no OAuth client |
| authz-dcr-application-type | MUST / SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-credentials-bound-to-issuer | MUST / MUST NOT | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-scope-in-challenge | SHOULD / MUST / MUST NOT | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-scope-selection-order | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-insufficient-scope-403 | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-all-scopes-single-challenge | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-step-up | SHOULD / MAY | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-server-scope-hierarchy | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| authz-cimd-as-security | MUST / SHOULD | auth-server | n/a: addresses the other side | n/a: addresses the other side |
| authz-iss-validation | MUST / SHOULD / MUST NOT | client, auth-server | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| authz-refresh-tokens | MUST / SHOULD / MAY / MUST NOT / SHOULD NOT | client, server | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
| authz-follow-security-considerations | MUST | both | n/a: optional feature mcpx does not implement | n/a: optional feature mcpx does not implement |
caching
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| caching-server-must-hint | MUST* | server | tested: mcpserver.TestModernEnvelope/2026-07-28/caching/resources-read-has-ttlMs-and-cacheScopemcpserver.TestModernEnvelope/2026-07-28/caching/server-discover-has-ttlMs-and-cacheScopemcpserver.TestModernEnvelope/2026-07-28/caching/tools-list-has-ttlMs-and-cacheScope | n/a: addresses the other side |
| caching-input-required-no-hints | descr. | server | tested: mcpserver.TestMRTRServerRules/2026-07-28/mrtr/requestState-is-bound-to-the-originating-request | n/a: addresses the other side |
| caching-key-method-params | MUST NOT | client | n/a: addresses the other side | n/a: not applicable by construction – the pool catalogue cache is keyed by the configured server, not by method+params, and resources/read results are not cached by the pool (#1604) |
| caching-no-cache-mrtr-retries | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestStatefulClient/2026-07-28/caching/caching-no-cache-mrtr-retries |
| caching-ttl-zero-stale | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestListToolsWithHintsAggregatesPages@2026-07-28pool.TestPoolCacheTTLZeroStale@2026-07-28 |
| caching-ttl-positive-fresh | SHOULD | client | n/a: addresses the other side | tested: pool.TestPoolCacheTTLPositiveExpires@2026-07-28pool.TestPoolCacheTTLPositiveFresh@2026-07-28 |
| caching-ttl-absent-zero | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestListPromptsWithHintsAbsent@2026-07-28pool.TestPoolCacheTTLAbsentStale@2026-07-28 |
| caching-ttl-negative-zero | SHOULD | client | n/a: addresses the other side | tested: pool.TestPoolCacheTTLNegativeStale@2026-07-28 |
| caching-ttl-non-negative | MUST | server | tested: conformance.TestStatefulServer/2026-07-28/caching/caching-ttl-non-negative | n/a: addresses the other side |
| caching-stale-refetch | SHOULD | client | n/a: addresses the other side | tested: pool.TestPoolCacheConcurrentExpiredCollapse@2026-07-28pool.TestPoolCacheTTLZeroStale@2026-07-28 |
| caching-ttl-not-poll | SHOULD NOT / MUST | client | n/a: addresses the other side | tested: conformance.TestStatefulClient/2026-07-28/caching/caching-ttl-not-poll |
| caching-may-refetch-early-serve-stale | MAY | client | n/a: addresses the other side | n/a: a MAY mcpx does not take – refresh is synchronous on read (decision on #1601); stale is served only when a refetch of an expired entry fails (#1603) |
| caching-public-shareable | MAY | both | n/a: catalogue marks it n/a | n/a: informational – permits sharing a public result across users; imposes nothing |
| caching-private-no-cross-auth | MUST NOT | both (any cache) | n/a: catalogue marks it n/a | tested: daemon.TestLoadCacheNeverSeedsPrivateEntry@2026-07-28daemon.TestSaveCacheSkipsPrivatePools@2026-07-28pool.TestPoolCacheStaleServedOnRefetchError@2026-07-28 |
| caching-notification-invalidates | descr. (lowercase should) | client | n/a: addresses the other side | tested: daemon.TestListChangedInvalidatesPoolCache@2026-07-28 |
| caching-page-refetch | SHOULD | client | n/a: addresses the other side | n/a: not applicable by construction – the pool caches one aggregated catalogue per configured server, never per page; an expired catalogue is re-fetched whole (#1601, #1604) |
| caching-page-same-scope | MUST | server | tested: mcpserver.TestModernEnvelope/2026-07-28/caching/every-page-has-the-same-cacheScope | n/a: addresses the other side |
| caching-server-access-control | MUST* / MUST NOT* | server | n/a: until hints are emitted; mcpx has no per-primitive access control (see authorization area | n/a: addresses the other side |
| cache-ttl-scope-required | REQUIRED (schema) | server | tested: mcpserver.TestModernEnvelope/2026-07-28/caching/prompts-list-has-ttlMs-and-cacheScopemcpserver.TestModernEnvelope/2026-07-28/caching/resources-list-has-ttlMs-and-cacheScopemcpserver.TestModernEnvelope/2026-07-28/caching/resources-read-has-ttlMs-and-cacheScopemcpserver.TestModernEnvelope/2026-07-28/caching/resources-templates-list-has-ttlMs-and-cacheScopemcpserver.TestModernEnvelope/2026-07-28/caching/server-discover-has-ttlMs-and-cacheScopemcpserver.TestModernEnvelope/2026-07-28/caching/tools-list-has-ttlMs-and-cacheScope | n/a: addresses the other side |
cancellation
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| cancellation-ref-client-issued-in-progress | MUST | both | tested: mcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/server-teardown-sends-cancelled-then-result | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-ref-client-issued-in-progress |
| cancellation-server-should-stop-free-no-response | SHOULD | server | tested: conformance.TestUtilitiesServer/2026-07-28/cancellation/cancellation-server-should-stop-free-no-response | n/a: addresses the other side |
| cancellation-server-may-ignore | MAY | server | tested: conformance.TestUtilitiesServer/2026-07-28/cancellation/cancellation-server-may-ignore | n/a: addresses the other side |
| cancellation-client-ignore-late-response | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-client-ignore-late-response |
| cancellation-handle-races | MUST | both | tested: conformance.TestUtilitiesServer/2026-07-28/cancellation/cancellation-handle-races | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-handle-races |
| cancellation-log-reasons | SHOULD | both | gap #77: a cancellation reason reaches Server.OnCancel, which nothing outside tests sets; it is never logged | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-log-reasons |
| cancellation-ui-indicate | SHOULD | both | n/a: no UI | n/a: no UI |
| cancellation-ignore-invalid | SHOULD | both | tested: conformance.TestUtilitiesServer/2026-07-28/cancellation/cancellation-ignore-invalid | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-ignore-invalid |
| cancellation-processing-should-cease | SHOULD | both (receiver) | tested: conformance.TestUtilitiesServer/2026-07-28/cancellation/cancellation-processing-should-cease | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-processing-should-cease |
| cancellation-client-should-send | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-client-should-send |
| cancellation-server-must-send-cancelled-on-listen-teardown | MUST | server | tested: mcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/server-teardown-sends-cancelled-then-result | n/a: addresses the other side |
| cancellation-server-only-for-listen | MUST NOT / MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-stdio/stdio-server-cancelled-only-for-listenmcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/server-teardown-sends-cancelled-then-result | n/a: addresses the other side |
| cancellation-http-disconnect-is-cancel | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-close-is-cancel | n/a: addresses the other side |
| cancellation-http-no-messages-after-close | MUST NOT / SHOULD | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-close-is-cancelconformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-silent-after-cancel | n/a: addresses the other side |
| cancellation-stdio-client-must-send | MUST | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-stdio-client-must-send |
| cancellation-timeouts-establish | SHOULD | both | tested: conformance.TestUtilitiesServer/2026-07-28/cancellation/cancellation-timeouts-establish | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-timeouts-establish |
| cancellation-timeout-then-cancel | SHOULD | both | tested: conformance.TestUtilitiesServer/2026-07-28/cancellation/cancellation-timeout-then-cancel | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-timeout-then-cancel |
| cancellation-timeouts-per-request-configurable | SHOULD | both | tested: conformance.TestUtilitiesServer/2026-07-28/cancellation/cancellation-timeouts-per-request-configurable | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-timeouts-per-request-configurable |
| cancellation-max-timeout-despite-progress | SHOULD (+MAY) | both | tested: conformance.TestUtilitiesServer/2026-07-28/cancellation/cancellation-max-timeout-despite-progress | tested: conformance.TestUtilitiesClient/2026-07-28/cancellation/cancellation-max-timeout-despite-progress |
completion
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| completion-declare-capability | MUST | server | tested: conformance.TestUtilitiesServer/2026-07-28/completion/completion-declare-capability | n/a: addresses the other side |
| completion-max-100 | descr. + schema | server | tested: conformance.TestUtilitiesServer/2026-07-28/completion/completion-max-100 | n/a: addresses the other side |
| completion-server-should | SHOULD | server | tested: daemon.TestHandleCompleteRateLimitHotChangeAndZeroDisables@alldaemon.TestHandleCompleteRateLimitRefusesBeforePool@alldaemon.TestHandleCompleteServerWindowBindsAcrossCallers@alldaemon.TestLocalCompletionCapAfterRanking@alldaemon.TestLocalCompletionTiersPrompts@alldaemon.TestLocalCompletionTiersResources@all | n/a: addresses the other side |
| completion-client-should | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2026-07-28/completion/completion-client-should |
| completion-security | MUST | both | gap #1968: partial: completion inputs are validated and completion/complete is rate limited per caller (completion.perMinute) and per server (completion.serverPerMinute) since #2387, but there is no per-caller access control for sensitive suggestions and no completion-specific information-disclosure control; waiting on principals #1968 / permissive-auth #1972 | n/a: catalogue marks it n/a |
| completion-errors | SHOULD | server | tested: conformance.TestUtilitiesServer/2026-07-28/completion/completion-errors | n/a: addresses the other side |
| completion-context-arguments | should (lowercase) | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2026-07-28/completion/completion-context-arguments |
discover
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| discover-client-may-call | MAY | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2026-07-28/discover/discover-client-may-call |
| discover-supportedversions | REQUIRED (schema) | server | tested: conformance.TestLifecycleServer/2026-07-28/discover/discover-supportedversions | n/a: addresses the other side |
| discover-client-choose-from-list | should (lowercase) | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2026-07-28/discover/discover-client-choose-from-list |
| discover-capabilities | REQUIRED (schema) | server | tested: conformance.TestLifecycleServer/2026-07-28/discover/discover-capabilities | n/a: addresses the other side |
| discover-serverinfo-in-meta | SHOULD | server | tested: conformance.TestLifecycleServer/2026-07-28/discover/discover-serverinfo-in-meta | n/a: addresses the other side |
| discover-serverinfo-not-for-behavior | SHOULD NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2026-07-28/discover/discover-serverinfo-not-for-behavior |
| discover-instructions-optional | MAY | server | tested: conformance.TestLifecycleServer/2026-07-28/discover/discover-instructions-optional | n/a: addresses the other side |
discover / caching
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| discover-cacheable | REQUIRED (schema) | server | tested: conformance.TestLifecycleServer/2026-07-28/discover/discover-cacheable | n/a: addresses the other side |
discover / meta
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| discover-resulttype | MUST | server | tested: conformance.TestLifecycleServer/2026-07-28/discover/discover-resulttype | n/a: addresses the other side |
| discover-request-meta-required | MUST | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2026-07-28/discover/discover-request-meta-required |
elicitation
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| elicitation-server-no-sensitive-info-in-form-mode | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-server-no-sensitive-info-in-form-mode | n/a: addresses the other side |
| elicitation-server-sensitive-via-url-mode | MUST | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-server-sensitive-via-url-mode | n/a: addresses the other side |
| elicitation-client-show-requesting-server | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/elicitation/elicitation-client-show-requesting-server |
| elicitation-client-form-review-modify-responses | MUST | client | n/a: addresses the other side | tested: cli.TestRenderAnswerReviewMasksSensitiveFields@2025-11-25,2026-07-28cli.TestRenderQuestionFormShowsSchemaDefaults@2025-11-25,2026-07-28 |
| elicitation-client-decline-cancel-options | MUST | client | n/a: addresses the other side | tested: elicit.TestAnExpiredQuestionBecomesCancelNotDecline@2025-06-18,2025-11-25,2026-07-28elicit.TestAnsweringAfterTheProcessDiedStillWorks@2025-06-18,2025-11-25,2026-07-28 |
| elicitation-client-url-show-domain-and-consent | MUST | client | n/a: addresses the other side | tested: cli.TestRenderQuestionURLShowsRegistrableDomain@2025-11-25,2026-07-28cli.TestRenderQuestionURLWarnsOnPunycodeHost@2025-11-25,2026-07-28 |
| elicitation-client-declare-capability-per-request-meta | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/elicitation/elicitation-client-declare-capability-per-request-meta |
| elicitation-empty-capability-means-form-only | (definition) | both | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-empty-capability-means-form-only | tested: conformance.TestClientFeaturesClient/2026-07-28/elicitation/elicitation-empty-capability-means-form-only |
| elicitation-client-support-at-least-one-mode | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/elicitation/elicitation-client-support-at-least-one-mode |
| elicitation-server-no-undeclared-modes | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-server-no-undeclared-modes | n/a: addresses the other side |
| elicitation-server-may-request-during-client-request | MAY | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-server-may-request-during-client-request | n/a: addresses the other side |
| elicitation-request-must-include-mode-and-message | MUST | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-request-must-include-mode-and-message | n/a: addresses the other side |
| elicitation-server-may-omit-mode-for-form | MAY | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-server-may-omit-mode-for-form | n/a: addresses the other side |
| elicitation-client-missing-mode-is-form | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/elicitation/elicitation-client-missing-mode-is-form |
| elicitation-form-request-shape | MUST | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-form-request-shape | n/a: addresses the other side |
| elicitation-form-schema-flat-primitives | MUST (schema-structural) | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-form-schema-flat-primitives | n/a: addresses the other side |
| elicitation-client-prepopulate-defaults | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestModernElicitationAppliesSchemaDefaults/2026-07-28/elicitation/client-applies-schema-defaults |
| elicitation-url-request-shape | MUST | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-url-request-shape | n/a: addresses the other side |
| elicitation-url-must-be-valid | MUST | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-url-must-be-valid | n/a: addresses the other side |
| elicitation-client-manual-retry-cancel-controls | SHOULD | client | n/a: addresses the other side | tested: e2e.TestElicitationsCanBeListedAndAnsweredFromTheCommandLine@2025-11-25,2026-07-28 |
| elicitation-no-32042-in-2026 | MUST NOT | both | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-no-32042-in-2026 | tested: conformance.TestClientFeaturesClient/2026-07-28/elicitation/elicitation-no-32042-in-2026 |
| elicitation-server-stored-state-bound-to-user | MUST | server | gap #257: mcpx has no authenticated user: state is bound to the request, the session id is whatever the caller sends | n/a: addresses the other side |
| elicitation-state-storage-protected | MUST | server | tested: elicit.TestOpenAtCreatesOwnerOnlyStore@2025-11-25,2026-07-28elicit.TestOpenAtMigratesLegacyStore@2025-11-25,2026-07-28 | n/a: addresses the other side |
| elicitation-remote-user-id-from-authorization | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| elicitation-server-must-not-self-authorize-via-url | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-server-must-not-self-authorize-via-url | n/a: addresses the other side |
| elicitation-third-party-creds-not-via-client | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-third-party-creds-not-via-client | n/a: addresses the other side |
| elicitation-no-token-passthrough | MUST NOT | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| elicitation-user-authorizes-server-directly | MUST | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-user-authorizes-server-directly | n/a: addresses the other side |
| elicitation-server-not-transmit-url-creds-to-client | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-server-not-transmit-url-creds-to-client | n/a: addresses the other side |
| elicitation-server-handle-decline-cancel-failure | SHOULD NOT / MUST | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-server-handle-decline-cancel-failure | n/a: addresses the other side |
| elicitation-server-bind-to-client-and-user | MUST | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-server-bind-to-client-and-user | n/a: addresses the other side |
| elicitation-client-user-approval-controls | SHOULD | client | n/a: addresses the other side | tested: elicit.TestRoutingSendsCredentialsToAHumanAndTheRestToTheAgent@2025-06-18,2025-11-25,2026-07-28 |
| elicitation-client-allow-decline-any-time | SHOULD | client | n/a: addresses the other side | tested: elicit.TestAnsweringAfterTheProcessDiedStillWorks@2025-06-18,2025-11-25,2026-07-28 |
| elicitation-client-clear-what-and-why | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/elicitation/elicitation-client-clear-what-and-why |
| elicitation-client-validate-responses | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestElicitationAnswersValidatedAgainstRequestedSchema/2026-07-28/elicitation/input-required-wrong-typed-answer-is-rejected |
| elicitation-server-validate-received-data | SHOULD | server | tested: mcpclient.TestElicitationAnswersValidatedAgainstRequestedSchema/2026-07-28/elicitation/input-required-wrong-typed-answer-is-rejected | n/a: addresses the other side |
| elicitation-url-no-sensitive-user-info | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-url-no-sensitive-user-info | n/a: addresses the other side |
| elicitation-url-not-pre-authenticated | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-url-not-pre-authenticated | n/a: addresses the other side |
| elicitation-no-clickable-urls-in-form | SHOULD NOT | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-no-clickable-urls-in-form | n/a: addresses the other side |
| elicitation-url-https-outside-dev | SHOULD | server | tested: conformance.TestClientFeaturesServer/2026-07-28/elicitation/elicitation-url-https-outside-dev | n/a: addresses the other side |
| elicitation-client-handle-urls-carefully | MUST | client | n/a: addresses the other side | tested: cli.TestRenderQuestionURLShowsRegistrableDomain@2025-11-25,2026-07-28cli.TestRenderQuestionURLWarnsOnPunycodeHost@2025-11-25,2026-07-28 |
| elicitation-client-no-url-prefetch | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/elicitation/elicitation-client-no-url-prefetch |
| elicitation-client-no-open-without-consent | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/elicitation/elicitation-client-no-open-without-consent |
| elicitation-client-show-full-url | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/elicitation/elicitation-client-show-full-url |
| elicitation-client-open-url-securely | MUST | client | n/a: addresses the other side | tested: elicit.TestRoutingSendsCredentialsToAHumanAndTheRestToTheAgent@2025-06-18,2025-11-25,2026-07-28 |
| elicitation-client-highlight-domain | SHOULD | client | n/a: addresses the other side | tested: cli.TestRenderQuestionURLShowsRegistrableDomain@2025-11-25,2026-07-28 |
| elicitation-client-warn-punycode | SHOULD | client | n/a: addresses the other side | tested: cli.TestRenderQuestionURLWarnsOnPunycodeHost@2025-11-25,2026-07-28 |
| elicitation-client-no-clickable-except-url-field | SHOULD NOT | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/elicitation/elicitation-client-no-clickable-except-url-field |
| elicitation-server-no-trust-client-user-id | MUST NOT | server | gap #257: mcpx has no authenticated user: state is bound to the request, the session id is whatever the caller sends | n/a: addresses the other side |
| elicitation-server-follow-best-practices | SHOULD | server | gap #255: requestState signing is sound and question storage is owner-only (0700/0600, #1676); there is no user identity | n/a: addresses the other side |
| elicitation-url-verify-opening-user | MUST | server | n/a: mcpx issues no URL; the upstream that does sees mcpx’s identity, not the end user’s | n/a: addresses the other side |
| elicitation-url-same-user-start-and-complete | MUST | server | n/a: see Implied: shared pooled upstream | n/a: addresses the other side |
| elicitation-url-identity-resilient-to-url-tampering | MUST | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| messages-error-response-same-id-unless-unreadable | MUST | both | tested: conformance.TestMessagesServer/2026-07-28/errors/messages-error-response-same-id-unless-unreadable | tested: conformance.TestMessagesClient/2026-07-28/errors/messages-error-response-same-id-unless-unreadable |
| errors-error-has-code-and-message | MUST | both | tested: conformance.TestMessagesServer/2026-07-28/errors/errors-error-has-code-and-message | tested: conformance.TestMessagesClient/2026-07-28/errors/errors-error-has-code-and-message |
| errors-codes-integers | MUST | both | tested: conformance.TestMessagesServer/2026-07-28/errors/errors-codes-integers | tested: conformance.TestMessagesClient/2026-07-28/errors/errors-codes-integers |
| errors-message-concise-single-sentence | SHOULD | both | tested: conformance.TestMessagesServer/2026-07-28/errors/errors-message-concise-single-sentence | n/a: sends no errors except to server requests |
| errors-data-may | MAY | both | tested: conformance.TestMessagesServer/2026-07-28/errors/errors-data-may | n/a: catalogue marks it n/a |
| errors-standard-jsonrpc-codes | (definitional) | both | tested: conformance.TestMessagesServer/2026-07-28/errors/errors-standard-jsonrpc-codes | n/a: catalogue marks it n/a |
| errors-legacy-subrange-no-new-codes | MUST NOT / SHOULD NOT | both | tested: conformance.TestMessagesServer/2026-07-28/errors/errors-legacy-subrange-no-new-codes | tested: conformance.TestMessagesClient/2026-07-28/errors/errors-legacy-subrange-no-new-codes |
| errors-receivers-no-meaning-for-legacy-subrange | MUST NOT | both | tested: conformance.TestMessagesServer/2026-07-28/errors/errors-receivers-no-meaning-for-legacy-subrange | tested: conformance.TestMessagesClient/2026-07-28/errors/errors-receivers-no-meaning-for-legacy-subrange |
| errors-reserved-subrange-only-spec-codes | MUST NOT / MUST | both | tested: conformance.TestMessagesServer/2026-07-28/errors/errors-reserved-subrange-only-spec-codes | n/a: catalogue marks it n/a |
| errors-do-not-emit-32002-and-32042 | MUST NOT | both | tested: conformance.TestMessagesServer/2026-07-28/errors/errors-do-not-emit-32002-and-32042 | n/a: catalogue marks it n/a |
| errors-client-accept-32002-from-legacy | SHOULD | client | n/a: addresses the other side | tested: conformance.TestMessagesClient/2026-07-28/errors/errors-client-accept-32002-from-legacy |
| errors-local-errors-not-mistaken-for-peer | should (lowercase) | both | gap #206: an upstream server’s failure reaches the client as mcpx’s own isError text or -32603, indistinguishable from mcpx failing | n/a: catalogue marks it n/a |
| errors-new-codes-outside-jsonrpc-range | SHOULD | both | tested: conformance.TestMessagesServer/2026-07-28/errors/errors-new-codes-outside-jsonrpc-range | n/a: catalogue marks it n/a |
| errors-method-not-found-for-unadvertised-capability | (schema guidance) | server | gap #257: mcpx answers methods whatever was negotiated (accept liberally, revisions.go); the schema guidance asks for -32601 | n/a: addresses the other side |
| errors-missing-required-client-capability | MUST | server | n/a: in practice: mcpx never requires a client capability; questions a client cannot answer go to the broker | n/a: addresses the other side |
| versioning-unsupported-version-http-400 | MUST | server | tested: conformance.TestMessagesServer/2026-07-28/errors/versioning-unsupported-version-http-400 | n/a: addresses the other side |
| errors-header-mismatch-http-400 | MUST | server | tested: conformance.TestMessagesServer/2026-07-28/errors/errors-header-mismatch-http-400 | n/a: addresses the other side |
errors (roots/sampling/elicitation)
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| mrtr-missing-capability-error-32021 | MUST | server | gap #257: through the gateway (mcpx_call) a call that needs input from a client that declared none goes to the broker instead of failing with -32021; the specification contradicts itself here (docs/spec/revision-conflicts.md). In pass-through mode the client’s declaration is relayed and the upstream’s -32021 returned (mcpserver.TestAPassThroughUpstreamsMissingCapabilityIsRelayed) | n/a: addresses the other side |
json-schema
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| json-schema-default-2020-12 | (definitional) | both | tested: conformance.TestMessagesServer/2026-07-28/json-schema/json-schema-default-2020-12 | n/a: catalogue marks it n/a |
| json-schema-may-declare-dialect | MAY | both | tested: conformance.TestMessagesServer/2026-07-28/json-schema/json-schema-may-declare-dialect | tested: conformance.TestMessagesClient/2026-07-28/json-schema/json-schema-may-declare-dialect |
| json-schema-support-2020-12 | MUST | both | tested: mcpclient.TestCallToolRejectsInvalidArgumentsBeforeTheTransport@all | n/a: passes through |
| json-schema-validate-per-dialect-and-error-on-unsupported | MUST | both | tested: mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@allschemaval.TestDeclaredDialectsCompile@allschemaval.TestUnsupportedDialect@all | tested: mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@allschemaval.TestDeclaredDialectsCompile@allschemaval.TestUnsupportedDialect@all |
| json-schema-document-dialects | SHOULD | both | tested: schemaval.TestDeclaredDialectsCompile@allschemaval.TestUnsupportedDialect@all | tested: schemaval.TestDeclaredDialectsCompile@allschemaval.TestUnsupportedDialect@all |
| json-schema-recommend-2020-12 | RECOMMENDED | both | tested: conformance.TestMessagesServer/2026-07-28/json-schema/json-schema-recommend-2020-12 | n/a: catalogue marks it n/a |
| json-schema-schemas-valid | MUST | both | tested: conformance.TestMessagesServer/2026-07-28/json-schema/json-schema-schemas-valid | n/a: catalogue marks it n/a |
| json-schema-no-network-ref | MUST NOT | both | tested: conformance.TestMessagesServer/2026-07-28/json-schema/json-schema-no-network-ref | tested: conformance.TestMessagesClient/2026-07-28/json-schema/json-schema-no-network-ref |
| json-schema-remote-ref-opt-in-off-by-default | MAY / MUST / SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| json-schema-reject-unresolved-ref | SHOULD | both | tested: mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@allschemaval.TestRemoteRefMakesNoRequests@allschemaval.TestUnresolvedInternalRef@all | tested: mcpclient.TestListToolsExcludesUncompilableSchemasWithWarning@allschemaval.TestRemoteRefMakesNoRequests@allschemaval.TestUnresolvedInternalRef@all |
| json-schema-bound-composition-cost | SHOULD | both | tested: schemaval.TestSchemaTooLarge@all | tested: schemaval.TestSchemaTooLarge@all |
| tools-schema-default-2020-12 | (definition) | both | tested: conformance.TestMessagesServer/2026-07-28/json-schema/tools-schema-default-2020-12 | tested: conformance.TestMessagesClient/2026-07-28/json-schema/tools-schema-default-2020-12 |
lifecycle
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| lifecycle-capability-sets-open | (definitional) | both | tested: conformance.TestLifecycleServer/2026-07-28/lifecycle/lifecycle-capability-sets-open | tested: conformance.TestLifecycleClient/2026-07-28/lifecycle/lifecycle-capability-sets-open |
| lifecycle-implementation-name-version-required | REQUIRED (schema field) | both | tested: conformance.TestLifecycleServer/2026-07-28/lifecycle/lifecycle-implementation-name-version-required | tested: conformance.TestLifecycleClient/2026-07-28/lifecycle/lifecycle-implementation-name-version-required |
| lifecycle-include-context-only-if-declared | SHOULD | server | tested: conformance.TestLifecycleServer/2026-07-28/lifecycle/lifecycle-include-context-only-if-declared | n/a: addresses the other side |
| lifecycle-stateless-no-reliance-on-prior-requests | MUST NOT | server | tested: conformance.TestLifecycleServer/2026-07-28/lifecycle/lifecycle-stateless-no-reliance-on-prior-requests | n/a: addresses the other side |
| lifecycle-stateless-multiple-conversations | SHOULD | server | tested: conformance.TestLifecycleServer/2026-07-28/lifecycle/lifecycle-stateless-multiple-conversations | n/a: addresses the other side |
| lifecycle-stateless-no-connection-reuse-required | SHOULD NOT | server | tested: conformance.TestLifecycleServer/2026-07-28/lifecycle/lifecycle-stateless-no-connection-reuse-required | n/a: addresses the other side |
| lifecycle-stateless-client-process-lifetime | SHOULD NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2026-07-28/lifecycle/lifecycle-stateless-client-process-lifetime |
| lifecycle-stateless-explicit-handles | MUST | both | tested: conformance.TestLifecycleServer/2026-07-28/lifecycle/lifecycle-stateless-explicit-handles | n/a: catalogue marks it n/a |
logging
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| logging-declare-capability | MUST | server | tested: conformance.TestUtilitiesServer/2026-07-28/logging/logging-declare-capability | n/a: addresses the other side |
| logging-no-message-without-loglevel | MUST NOT | server | tested: conformance.TestUtilitiesServer/2026-07-28/logging/logging-no-message-without-loglevel | n/a: addresses the other side |
| logging-request-scoped-only | MUST NOT | server | tested: conformance.TestUtilitiesServer/2026-07-28/logging/logging-request-scoped-only | n/a: addresses the other side |
| logging-may-send-before-response | MAY | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| logging-invalid-loglevel-reject-request | SHOULD | server | tested: conformance.TestUtilitiesServer/2026-07-28/logging/logging-invalid-loglevel-reject-request | n/a: addresses the other side |
| logging-server-hygiene | SHOULD | server | tested: conformance.TestUtilitiesServer/2026-07-28/logging/logging-server-hygiene | n/a: addresses the other side |
| logging-no-secrets-pii | MUST NOT | both (emitter) | tested: conformance.TestUtilitiesServer/2026-07-28/logging/logging-no-secrets-pii | tested: conformance.TestUtilitiesClient/2026-07-28/logging/logging-no-secrets-pii |
| logging-impl-validate-control | SHOULD | both | tested: conformance.TestUtilitiesServer/2026-07-28/logging/logging-impl-validate-control | tested: conformance.TestUtilitiesClient/2026-07-28/logging/logging-impl-validate-control |
| logging-deprecated | SHOULD NOT / SHOULD | both | tested: conformance.TestUtilitiesServer/2026-07-28/logging/logging-deprecated | n/a: catalogue marks it n/a |
messages
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| messages-all-messages-follow-jsonrpc-2 | MUST | both | tested: conformance.TestJSONRPCFramesEveryRevision/2026-07-28/messages/all-messages-follow-jsonrpc-2-server-httpconformance.TestJSONRPCFramesEveryRevision/2026-07-28/messages/all-messages-follow-jsonrpc-2-server-stdioconformance.TestMessagesServer/2026-07-28/messages/messages-all-messages-follow-jsonrpc-2 | tested: conformance.TestJSONRPCFramesEveryRevision/2026-07-28/messages/all-messages-follow-jsonrpc-2-client |
| messages-other-components-may | MAY | both | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-other-components-may | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-other-components-may |
| messages-request-id-string-or-integer | MUST | both | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-request-id-string-or-integer | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-request-id-string-or-integer |
| messages-request-id-not-null | MUST NOT | both | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-request-id-not-null | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-request-id-not-null |
| messages-request-id-unique-among-in-flight | MUST NOT | both | n/a: modern server sends no requests | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-request-id-unique-among-in-flight |
| messages-requests-client-to-server-only | (definitional) | both | n/a: on modern path; on legacy path mcpx still sends elicitation/sampling requests (allowed there | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-requests-client-to-server-only |
| messages-servers-must-not-initiate-requests | MUST NOT | server | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-servers-must-not-initiate-requests | n/a: addresses the other side |
| messages-result-response-same-id | MUST | both | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-result-response-same-id | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-result-response-same-id |
| messages-result-response-has-result | MUST | both | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-result-response-has-result | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-result-response-has-result |
| messages-result-any-object | MAY | both | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-result-any-object | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-result-any-object |
| messages-notification-no-response | MUST NOT | both | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-notification-no-response | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-notification-no-response |
| messages-notification-no-id | MUST NOT | both | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-notification-no-id | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-notification-no-id |
| messages-patterns-notifications-scoped-to-request | MAY | server | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-patterns-notifications-scoped-to-request | n/a: addresses the other side |
| messages-resulttype-required | MUST | server | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-resulttype-required | n/a: addresses the other side |
| messages-resulttype-extension-values | MAY / MUST | both | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-resulttype-extension-values | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-resulttype-extension-values |
| messages-resulttype-unknown-invalid | MUST | client | n/a: addresses the other side | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-resulttype-unknown-invalid |
| messages-resulttype-absent-means-complete | MUST | client | n/a: addresses the other side | tested: conformance.TestMessagesClient/2026-07-28/messages/messages-resulttype-absent-means-complete |
| messages-resulttype-server-must-include | MUST | server | tested: conformance.TestMessagesServer/2026-07-28/messages/messages-resulttype-server-must-include | n/a: addresses the other side |
| transport-jsonrpc-utf8 | MUST | both | tested: conformance.TestMessagesServer/2026-07-28/messages/transport-jsonrpc-utf8 | tested: conformance.TestMessagesClient/2026-07-28/messages/transport-jsonrpc-utf8 |
| transport-binding-message-directions | MUST | both | tested: conformance.TestMessagesServer/2026-07-28/messages/transport-binding-message-directions | tested: conformance.TestMessagesClient/2026-07-28/messages/transport-binding-message-directions |
| transport-binding-may-mirror-metadata | MAY | both | tested: conformance.TestMessagesServer/2026-07-28/messages/transport-binding-may-mirror-metadata | tested: conformance.TestMessagesClient/2026-07-28/messages/transport-binding-may-mirror-metadata |
meta
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| meta-no-assumptions-on-reserved-keys | MUST NOT | both | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-no-assumptions-on-reserved-keys | tested: conformance.TestMessagesClient/2026-07-28/meta/meta-no-assumptions-on-reserved-keys |
| meta-prefix-label-format | MUST | both | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-prefix-label-format | tested: conformance.TestMessagesClient/2026-07-28/meta/meta-prefix-label-format |
| meta-reverse-dns-should | SHOULD | both | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-reverse-dns-should | tested: conformance.TestMessagesClient/2026-07-28/meta/meta-reverse-dns-should |
| meta-reserved-prefix-second-label | (reservation) | both | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-reserved-prefix-second-label | tested: conformance.TestMessagesClient/2026-07-28/meta/meta-reserved-prefix-second-label |
| meta-name-format | MUST / MAY | both | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-name-format | tested: conformance.TestMessagesClient/2026-07-28/meta/meta-name-format |
| meta-schema-may-reserve-names | (reservation) | both | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-schema-may-reserve-names | tested: conformance.TestMessagesClient/2026-07-28/meta/meta-schema-may-reserve-names |
| meta-progress-token-receiver-not-obligated | (definitional) | both | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-progress-token-receiver-not-obligated | tested: conformance.TestMessagesClient/2026-07-28/meta/meta-progress-token-receiver-not-obligated |
| meta-request-params-meta-required | MUST / REQUIRED | client | n/a: addresses the other side | tested: conformance.TestMessagesClient/2026-07-28/meta/meta-request-params-meta-required |
| meta-server-rejects-missing-required-field | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| meta-client-should-send-clientinfo | SHOULD | client | n/a: addresses the other side | tested: conformance.TestMessagesClient/2026-07-28/meta/meta-client-should-send-clientinfo |
| meta-server-no-undeclared-client-capabilities | MUST NOT | server | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-server-no-undeclared-client-capabilities | n/a: addresses the other side |
| meta-server-no-infer-caps-from-prior | MUST NOT | server | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-server-no-infer-caps-from-prior | n/a: addresses the other side |
| meta-server-should-send-serverinfo | SHOULD | server | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-server-should-send-serverinfo | n/a: addresses the other side |
| meta-info-not-for-behavior-or-security | SHOULD NOT | both | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-info-not-for-behavior-or-security | tested: conformance.TestMessagesClient/2026-07-28/meta/meta-info-not-for-behavior-or-security |
| meta-subscription-id-on-listen-notifications | MUST | server | tested: conformance.TestMessagesServer/2026-07-28/meta/meta-subscription-id-on-listen-notifications | n/a: addresses the other side |
| meta-otel-keys-w3c-format | MUST | both | n/a: does not emit); passes through? not verified | n/a: catalogue marks it n/a |
| meta-icons-client-mime-support | MUST / SHOULD | client | n/a: addresses the other side | n/a: renders no icons |
| meta-icons-consumer-security | MUST | client | n/a: addresses the other side | n/a: never fetches icons; passes metadata through |
| meta-icons-reject-unsafe-schemes | MUST | client | n/a: addresses the other side | n/a: does not dereference). Downstream host must do it |
| meta-icons-may-limit-size | MAY | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| meta-icons-same-origin | SHOULD | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| meta-request-required-fields | MUST | client | n/a: addresses the other side | tested: conformance.TestMessagesClient/2026-07-28/meta/meta-request-required-fields |
mrtr
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| mrtr-server-requests-via-mrtr | MUST | server | tested: mcpserver.TestElicitationPerEra/2026-07-28/elicitation/inputRequests-carry-no-elicitationId | n/a: addresses the other side |
| mrtr-requeststate-opaque-client | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestStatefulClient/2026-07-28/mrtr/mrtr-requeststate-opaque-client |
| mrtr-supported-requests-only | MAY / MUST NOT | server | tested: conformance.TestStatefulServer/2026-07-28/mrtr/mrtr-supported-requests-only | n/a: addresses the other side |
| mrtr-inputrequests-keys-values | MUST | server | tested: conformance.TestStatefulServer/2026-07-28/mrtr/mrtr-inputrequests-keys-values | n/a: addresses the other side |
| mrtr-requeststate-attacker-controlled | MUST | server | tested: mcpserver.TestMRTRServerRules/2026-07-28/mrtr/tampered-requestState-is-rejected | n/a: addresses the other side |
| mrtr-requeststate-replay-fields | SHOULD | server | tested: mcpserver.TestMRTRServerRules/2026-07-28/mrtr/requestState-is-bound-to-the-originating-request | n/a: addresses the other side |
| mrtr-single-use-enforced | MUST | server | n/a: binds only a server whose requestState must be consumed once; mcpx’s is re-minted every round and replaying it re-enters the same running call | n/a: addresses the other side |
| mrtr-at-least-one-field | MUST | server | tested: conformance.TestStatefulServer/2026-07-28/mrtr/mrtr-at-least-one-field | n/a: addresses the other side |
| mrtr-only-declared-capabilities | MUST NOT | server | tested: conformance.TestStatefulServer/2026-07-28/mrtr/mrtr-only-declared-capabilities | n/a: addresses the other side |
| mrtr-no-assume-retry | MUST NOT / MAY | server | tested: mcpserver.TestMRTRServerRules/2026-07-28/mrtr/missing-input-response-asks-again | n/a: addresses the other side |
| mrtr-client-fulfil-before-retry | MUST / MAY | client | n/a: addresses the other side | tested: conformance.TestStatefulClient/2026-07-28/mrtr/mrtr-client-fulfil-before-retry |
| mrtr-client-echo-state | MUST / MUST NOT | client | n/a: addresses the other side | tested: conformance.TestStatefulClient/2026-07-28/mrtr/mrtr-client-echo-state |
| mrtr-new-id-on-retry | MUST | client | n/a: addresses the other side | tested: conformance.TestStatefulClient/2026-07-28/mrtr/mrtr-new-id-on-retry |
| mrtr-no-cross-request-use | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestStatefulClient/2026-07-28/mrtr/mrtr-no-cross-request-use |
| mrtr-server-validate-responses | SHOULD | server | tested: conformance.TestStatefulServer/2026-07-28/mrtr/mrtr-server-validate-responses | n/a: addresses the other side |
| mrtr-input-request-type-restricted | MUST | server | tested: conformance.TestStatefulServer/2026-07-28/mrtr/mrtr-input-request-type-restricted | n/a: addresses the other side |
| mrtr-server-reask-missing-rather-than-error | SHOULD | server | tested: mcpserver.TestMRTRServerRules/2026-07-28/mrtr/missing-input-response-asks-again | n/a: addresses the other side |
pagination
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| pagination-no-fixed-page-size | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2026-07-28/pagination/pagination-no-fixed-page-size |
| pagination-stable-cursors | SHOULD | server | tested: conformance.TestUtilitiesServer/2026-07-28/pagination/pagination-stable-cursors | n/a: addresses the other side |
| pagination-invalid-cursor-graceful | SHOULD | server | tested: conformance.TestUtilitiesServer/2026-07-28/pagination/pagination-invalid-cursor-graceful | n/a: addresses the other side |
| pagination-missing-nextcursor-end | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2026-07-28/pagination/pagination-missing-nextcursor-end |
| pagination-support-both-flows | SHOULD | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2026-07-28/pagination/pagination-support-both-flows |
| pagination-cursor-opaque | MUST | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2026-07-28/pagination/pagination-cursor-opaque |
| pagination-empty-cursor-not-end | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2026-07-28/pagination/pagination-empty-cursor-not-end |
| pagination-invalid-cursor-32602 | SHOULD | server | tested: conformance.TestUtilitiesServer/2026-07-28/pagination/pagination-invalid-cursor-32602 | n/a: addresses the other side |
progress
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| progress-token-string-or-int | MUST | both (requester) | n/a: as a server mcpx never requests progress; a host’s token is relayed back verbatim | tested: conformance.TestUtilitiesClient/2026-07-28/progress/progress-token-string-or-int |
| progress-token-unique-client | MUST | client | n/a: addresses the other side | tested: conformance.TestUtilitiesClient/2026-07-28/progress/progress-token-unique-client |
| progress-server-may-send | MAY | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| progress-must-increase | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2026-07-28/progress/progress-must-increase | n/a: catalogue marks it n/a |
| progress-message-human-readable | SHOULD | both (notifier) | tested: conformance.TestUtilitiesServer/2026-07-28/progress/progress-message-human-readable | n/a: catalogue marks it n/a |
| progress-only-active-tokens | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2026-07-28/progress/progress-only-active-tokens | n/a: catalogue marks it n/a |
| progress-server-may-skip | MAY | server | tested: conformance.TestUtilitiesServer/2026-07-28/progress/progress-server-may-skip | n/a: addresses the other side |
| progress-track-tokens | SHOULD | both | n/a: catalogue marks it n/a | tested: conformance.TestUtilitiesClient/2026-07-28/progress/progress-track-tokens |
| progress-rate-limit | SHOULD | both | tested: conformance.TestUtilitiesServer/2026-07-28/progress/progress-rate-limit | tested: conformance.TestUtilitiesClient/2026-07-28/progress/progress-rate-limit |
| progress-stop-after-completion | MUST | both (notifier) | tested: conformance.TestUtilitiesServer/2026-07-28/progress/progress-stop-after-completion | n/a: catalogue marks it n/a |
| progress-http-notifications-relate-to-request | MUST | server | tested: conformance.TestUtilitiesServer/2026-07-28/progress/progress-http-notifications-relate-to-request | n/a: addresses the other side |
prompts
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-declare-capability-discover | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-declare-capability-discover | n/a: addresses the other side |
| prompts-list-must-respond-when-declared | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-list-must-respond-when-declared | n/a: addresses the other side |
| prompts-list-not-per-connection | MUST NOT | server | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-list-not-per-connection | n/a: addresses the other side |
| prompts-list-may-vary-by-auth | MAY | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| prompts-list-changed-notify-to-listeners | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-list-changed-notify-to-listeners | n/a: addresses the other side |
| prompts-image-base64-mime | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-image-base64-mime | n/a: addresses the other side |
| prompts-audio-base64-mime | MUST (not bolded) | server | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-audio-base64-mime | n/a: addresses the other side |
| prompts-resource-link-may | MAY | server | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-resource-link-may | n/a: addresses the other side |
| prompts-embedded-must-include | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-embedded-must-include | n/a: addresses the other side |
| prompts-validate-args | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-validate-args | n/a: addresses the other side |
| prompts-respect-capability-negotiation | SHOULD | both | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-respect-capability-negotiation | tested: conformance.TestFeaturesClient/2026-07-28/prompts/prompts-respect-capability-negotiation |
| prompts-validate-io | MUST | both | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-validate-io; mcpclient.TestValidatePromptResult | covered |
prompts / errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-errors-32602-32603 | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-errors-32602-32603 | n/a: addresses the other side |
prompts / mrtr
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-get-input-required-may | MAY | server | tested: conformance.TestFeaturesServer/2026-07-28/prompts/prompts-get-input-required-may | n/a: addresses the other side |
prompts / pagination
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| prompts-client-paginate | SHOULD | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2026-07-28/prompts/prompts-client-paginate |
resources
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-declare-capability | n/a: addresses the other side |
| resources-subscribe-cap-meaning-2026 | (definition) | server | tested: e2e.TestResourceSubscriptionsReachUpstream/2026-07-28/subscriptions/listen-delivers-upstream-updates-and-acks-only-what-it-can | n/a: addresses the other side |
| resources-cap-features-independent | MAY | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-cap-features-independent | n/a: addresses the other side |
| resources-list-must-respond-when-declared | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-list-must-respond-when-declared | n/a: addresses the other side |
| resources-list-not-per-connection | MUST NOT | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-list-not-per-connection | n/a: addresses the other side |
| resources-list-may-vary-by-auth | MAY | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| resources-list-changed-notify | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-list-changed-notify | n/a: addresses the other side |
| resources-read-multiple-may | MAY | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-read-multiple-may | n/a: addresses the other side |
| resources-read-https-direct-may | may (lowercase) | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| resources-updated-may-be-subresource | (note, schema) | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2026-07-28/resources/resources-updated-may-be-subresource |
| resources-https-only-if-client-fetchable | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-https-only-if-client-fetchable | n/a: addresses the other side |
| resources-https-else-other-scheme | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-https-else-other-scheme | n/a: addresses the other side |
| resources-file-xdg-mime-may | MAY | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-file-xdg-mime-may | n/a: addresses the other side |
| resources-custom-scheme-rfc3986 | MUST | server | tested: cli.TestMCPXResourceURIs/2026-07-28/resources/resources-custom-scheme-rfc3986 | n/a: addresses the other side |
| resources-no-empty-contents-for-missing | MUST NOT | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-no-empty-contents-for-missing | n/a: addresses the other side |
| resources-sec-validate-uris | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-sec-validate-uris | n/a: addresses the other side |
| resources-sec-access-control | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| resources-sec-binary-encoded | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-sec-binary-encoded | n/a: addresses the other side |
| resources-sec-permissions | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| resources-sec-file-traversal | MUST | server | n/a: mcpx serves no file:// resources of its own; artifacts are addressed by opaque ids | n/a: addresses the other side |
| resources-annotations-defs | (definition) | both | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-annotations-defs | gap #207: mcpclient.Resource has no annotations field; they are dropped on parse |
resources / errors
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-internal-32603 | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-internal-32603 | n/a: addresses the other side |
| resources-not-found-32602 | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-not-found-32602 | n/a: addresses the other side |
| resources-client-accept-32002 | SHOULD | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2026-07-28/resources/resources-client-accept-32002 |
resources / mrtr
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-read-input-required-may | MAY | server | tested: conformance.TestFeaturesServer/2026-07-28/resources/resources-read-input-required-may | n/a: addresses the other side |
resources / subscriptions
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| resources-subscribe-via-listen | (mechanism) | client | n/a: addresses the other side | tested: mcpclient.TestModernListenStream/2026-07-28/subscriptions/resource-subscription-reopens-listen |
roots
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| roots-client-declare-capability-per-request-meta | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/roots/roots-client-declare-capability-per-request-meta |
| roots-server-list-via-input-required | MUST (via MRTR note) | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| roots-uri-must-be-file-scheme | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-only-expose-permitted-roots | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-validate-uris-path-traversal | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-implement-access-controls | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-monitor-accessibility | MUST | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-server-handle-unavailable-roots | SHOULD | server | n/a: mcpx does not consume roots | n/a: addresses the other side |
| roots-server-respect-boundaries | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-validate-paths-against-roots | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-client-prompt-consent-before-exposing | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-provide-root-management-ui | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-validate-accessibility-before-exposing | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-client-monitor-root-changes | SHOULD | client | n/a: addresses the other side | n/a: mcpx exposes no roots – the daemon configures none, so roots/list is always empty and there is nothing to validate, monitor or consent to |
| roots-server-check-capability-before-use | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-server-cache-root-information | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| roots-deprecated-new-impls-should-not-adopt | SHOULD NOT | both | n/a: optional feature mcpx does not implement | tested: conformance.TestClientFeaturesClient/2026-07-28/roots/roots-deprecated-new-impls-should-not-adopt |
| roots-deprecated-existing-should-migrate | SHOULD | both | n/a: catalogue marks it n/a | tested: conformance.TestClientFeaturesClient/2026-07-28/roots/roots-deprecated-existing-should-migrate |
sampling
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| sampling-human-in-loop-can-deny | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-human-in-loop-can-deny |
| sampling-app-review-ui | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-app-view-edit-prompts | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-app-review-responses | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-client-declare-capability-per-request-meta | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-client-declare-capability-per-request-meta |
| sampling-client-declare-tools-capability | MUST | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-client-declare-tools-capability |
| sampling-server-no-tools-without-capability | MUST NOT | server | tested: conformance.TestClientFeaturesServer/2026-07-28/sampling/sampling-server-no-tools-without-capability | n/a: addresses the other side |
| sampling-client-error-on-tools-without-capability | MUST | client | n/a: addresses the other side | tested: mcpclient.TestModernSamplingShape/2026-07-28/sampling/tools-without-capability-is-32602 |
| sampling-server-includecontext-only-with-context-capability | SHOULD NOT | server | tested: conformance.TestClientFeaturesServer/2026-07-28/sampling/sampling-server-includecontext-only-with-context-capability | n/a: addresses the other side |
| sampling-server-avoid-includecontext-values | SHOULD | server | gap #210: an upstream sampling request is relayed as-is: tools and includeContext reach a client that did not declare sampling.tools or sampling.context | n/a: addresses the other side |
| sampling-deprecated-new-impls-should-not-adopt | SHOULD NOT | both | tested: conformance.TestClientFeaturesServer/2026-07-28/sampling/sampling-deprecated-new-impls-should-not-adopt | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-deprecated-new-impls-should-not-adopt |
| sampling-tool-result-message-only-tool-results | MUST | both | tested: conformance.TestClientFeaturesServer/2026-07-28/sampling/sampling-tool-result-message-only-tool-results | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-tool-result-message-only-tool-results |
| sampling-tool-use-result-balance | MUST | both | tested: conformance.TestClientFeaturesServer/2026-07-28/sampling/sampling-tool-use-result-balance | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-tool-use-result-balance |
| sampling-server-answer-every-tooluse | MUST | server | tested: conformance.TestClientFeaturesServer/2026-07-28/sampling/sampling-server-answer-every-tooluse | n/a: addresses the other side |
| sampling-toolresult-id-must-match-tooluse | MUST | both | tested: conformance.TestClientFeaturesServer/2026-07-28/sampling/sampling-toolresult-id-must-match-tooluse | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-toolresult-id-must-match-tooluse |
| sampling-toolchoice-required-model-must-use-tool | MUST | client | n/a: addresses the other side | n/a: mcpx never declares tools |
| sampling-toolchoice-none-model-must-not-use-tools | MUST NOT | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| sampling-parallel-tool-toggle-extension-only | MAY | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| sampling-tooluse-meta-preserve | SHOULD | client (text says “Clients”; see Conflicts) | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-tooluse-meta-preserve |
| sampling-toolresult-structured-conform-outputschema | SHOULD | both | tested: conformance.TestClientFeaturesServer/2026-07-28/sampling/sampling-toolresult-structured-conform-outputschema | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-toolresult-structured-conform-outputschema |
| sampling-message-must-have-role-and-content | MUST | both | tested: conformance.TestClientFeaturesServer/2026-07-28/sampling/sampling-message-must-have-role-and-content | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-message-must-have-role-and-content |
| sampling-messages-not-retained-between-requests | SHOULD NOT | client | n/a: addresses the other side | gap #255: terminal sampling rows are now pruned past the #1677 age bound, but a sampling request’s messages are still retained in elicit.db between separate requests until that bound; the SHOULD NOT is not met |
| sampling-client-must-respect-maxtokens | MUST | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-sample-fewer-tokens | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-ignore-temperature-stop-metadata | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-ignore-model-preferences | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-modify-system-prompt | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-may-ignore-includecontext | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hints-evaluated-in-order | MUST | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hints-over-priorities | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hint-name-substring | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-hint-may-map-provider | MAY | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-stopreason-custom-values | MAY | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-stopreason-custom-values |
| sampling-client-user-approval-controls | SHOULD | client | n/a: addresses the other side | gap #256: a sampling request is routed to the agent audience; nothing lets a person review or edit the prompt or the result |
| sampling-both-validate-message-content | SHOULD | both | tested: conformance.TestSamplingValidation/2026-07-28/sampling/sampling-both-validate-message-content | tested: conformance.TestSamplingValidation/2026-07-28/sampling/sampling-both-validate-message-content |
| sampling-client-respect-hints | SHOULD | client | n/a: addresses the other side | n/a: mcpx runs no model; it relays the request unchanged to whatever answers it (the pass-through tests), which is where model choice and generation happen |
| sampling-client-rate-limit | SHOULD | client | n/a: addresses the other side | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-client-rate-limit |
| sampling-both-handle-sensitive-data | MUST | both | tested: daemon.TestElicitEventsAreSummaryOnly@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28elicit.TestOpenPrunesTerminalRowsPastTheAgeBound@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28 | tested: daemon.TestElicitEventsAreSummaryOnly@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28elicit.TestOpenPrunesTerminalRowsPastTheAgeBound@2024-11-05,2025-03-26,2025-06-18,2025-11-25,2026-07-28 |
| sampling-both-iteration-limits-tool-loops | SHOULD | both | tested: conformance.TestClientFeaturesServer/2026-07-28/sampling/sampling-both-iteration-limits-tool-loops | tested: conformance.TestClientFeaturesClient/2026-07-28/sampling/sampling-both-iteration-limits-tool-loops |
subscriptions
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| subscriptions-list-changed-via-listen | SHOULD | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-sse-notifications-must-relatemcpserver.TestHTTPListenIsAStream/2026-07-28/subscriptions/http-listen-response-is-an-open-sse-stream | n/a: addresses the other side |
| subscriptions-only-requested-types | MUST NOT | server | tested: mcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/concurrent-listens-are-kept-apartmcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/unrequested-type-is-never-sent | n/a: addresses the other side |
| subscriptions-ack-first-with-id | MUST / MUST NOT | server | tested: mcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/acknowledged-first-with-subscriptionId-in-meta | n/a: addresses the other side |
| subscriptions-stdio-ack-order-per-id | MAY | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| subscriptions-ack-reports-agreed-subset | descr. (schema-required field) | server | tested: mcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/acknowledged-first-with-subscriptionId-in-meta | n/a: addresses the other side |
| subscriptions-client-check-ack | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestModernListenStream/2026-07-28/subscriptions/client-checks-acknowledged-subset |
| subscriptions-tag-every-notification | MUST | server | tested: mcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/concurrent-listens-are-kept-apartmcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/every-notification-carries-the-subscriptionId | n/a: addresses the other side |
| subscriptions-stdio-client-correlate | MUST | client | n/a: addresses the other side | tested: mcpclient.TestModernListenStream/2026-07-28/transport-stdio/client-correlates-subscription-id |
| subscriptions-multiple-concurrent | MAY | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| subscriptions-server-teardown-respond | SHOULD | server | tested: mcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/server-teardown-sends-cancelled-then-result | n/a: addresses the other side |
| subscriptions-listen-result-meta | schema-required | server | tested: mcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/server-teardown-sends-cancelled-then-result | n/a: addresses the other side |
| subscriptions-client-reconnect-on-drop | MAY | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| subscriptions-stdio-resubscribe | MUST | client | n/a: addresses the other side | n/a: catalogue marks it n/a |
| subscriptions-request-scoped-not-on-listen | descr. (bold “not”) | server | tested: mcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/unrequested-type-is-never-sent | n/a: addresses the other side |
tasks
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| ext-tasks-client-declare-per-request | must (informal) | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| ext-tasks-server-advertise-discover | must (informal) | server | tested: mcpserver.TestCapabilitiesPerRevision/2026-07-28/capabilities/tasks-only-as-extension | n/a: addresses the other side |
| ext-tasks-never-unsolicited | must (informal) | server | tested: mcpserver.TestTasksPerEra/2026-07-28/tasks/no-task-without-the-extension-task-param-ignored | n/a: addresses the other side |
| ext-tasks-server-decides | descr. | server | tested: mcpserver.TestAQuestionAskedUpFrontIsAnsweredBeforeTheTaskExists@2026-07-28mcpserver.TestATaskSupportingToolBecomesATaskWithoutWaitingTaskAfter@2026-07-28mcpserver.TestTasksPerEra/2026-07-28/tasks/fast-call-is-answered-directlymcpserver.TestTasksPerEra/2026-07-28/tasks/server-directed-CreateTaskResult-is-flat | n/a: addresses the other side |
| ext-tasks-durable-before-response | must (informal) | server | tested: mcpserver.TestInjectedStoreKeepsMCPTasksAcrossReopen@2026-07-28 | n/a: addresses the other side |
| ext-tasks-createtaskresult-shape | must (informal) | server | tested: mcpserver.TestTasksPerEra/2026-07-28/tasks/server-directed-CreateTaskResult-is-flat | n/a: addresses the other side |
| ext-tasks-get-carries-result | must (informal) | server | tested: mcpserver.TestTasksPerEra/2026-07-28/tasks/server-directed-CreateTaskResult-is-flatmcpserver.TestTasksPerEra/2026-07-28/tasks/tool-isError-is-completed-not-failed | n/a: addresses the other side |
| ext-tasks-get-input-requests | descr. | server | tested: conformance.TestStatefulServer/2026-07-28/tasks/ext-tasks-get-input-requests | n/a: addresses the other side |
| ext-tasks-update | must (informal) | server | tested: mcpserver.TestATaskAskingAQuestionIsInputRequiredAndTasksUpdateAnswersIt@2026-07-28mcpserver.TestTasksPerEra/2026-07-28/tasks/update-and-cancel-ack-empty-unknown-is-32602 | n/a: addresses the other side |
| ext-tasks-cancel-cooperative | must (informal) | server | gap #209: tasks/cancel forces the cancelled status rather than letting the work reach its own terminal status | n/a: addresses the other side |
| ext-tasks-client-polymorphic | must (informal) | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| ext-tasks-client-poll | must (informal) | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| ext-tasks-client-persist-ids | must (informal) | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| ext-tasks-notifications-via-listen | descr. (MAY) | both | tested: mcpserver.TestListenDeliversTaskStatusNotifications@2026-07-28mcpserver.TestListenReportsATaskThatAlreadyFinished@2026-07-28mcpserver.TestSubscriptionsListen/2026-07-28/subscriptions/taskIds-without-the-extension-is-32021 | n/a: a MAY mcpx does not take: it declares no tasks extension upstream |
tools
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tools-human-in-loop-deny | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-ui-expose-indicate-confirm | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-declare-capability | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-declare-capability | n/a: addresses the other side |
| tools-list-changed-means-emit | (definition) | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-list-changed-means-emit | n/a: addresses the other side |
| tools-list-changed-notify-to-listeners | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-list-changed-notify-to-listeners | n/a: addresses the other side |
| tools-list-must-respond-when-declared | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-list-must-respond-when-declared | n/a: addresses the other side |
| tools-list-not-per-connection | MUST NOT | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-list-not-per-connection | n/a: addresses the other side |
| tools-list-may-vary-by-auth | MAY | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| tools-list-may-be-empty-change | MAY | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-list-may-be-empty-change | n/a: addresses the other side |
| tools-list-deterministic-order | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-list-deterministic-order | n/a: addresses the other side |
| tools-annotations-untrusted | MUST | client | n/a: addresses the other side | tested: daemon.TestAnnotationClearsCallOnlyForLocallyDeclaredServers@2026-07-28 |
| tools-annotations-hints-no-decisions | should (lowercase, schema) | client | n/a: addresses the other side | tested: daemon.TestAnnotationClearsCallOnlyForLocallyDeclaredServers@2026-07-28 |
| tools-inputschema-no-params-recommended | RECOMMENDED | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-inputschema-no-params-recommended | n/a: addresses the other side |
| tools-x-mcp-header-may | MAY | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| tools-x-mcp-header-not-empty | MUST NOT | server | n/a: no tool mcpx serves declares an x-mcp-header parameter | n/a: addresses the other side |
| tools-x-mcp-header-tchar | MUST | server | n/a: no tool mcpx serves declares an x-mcp-header parameter | n/a: addresses the other side |
| tools-x-mcp-header-no-ctl | MUST NOT | server | n/a: no tool mcpx serves declares an x-mcp-header parameter | n/a: addresses the other side |
| tools-x-mcp-header-unique-ci | MUST | server | n/a: no tool mcpx serves declares an x-mcp-header parameter | n/a: addresses the other side |
| tools-x-mcp-header-primitive-only | MUST | server | n/a: no tool mcpx serves declares an x-mcp-header parameter | n/a: addresses the other side |
| tools-x-mcp-header-int-safe-range | MUST | server | n/a: no tool mcpx serves declares an x-mcp-header parameter | n/a: addresses the other side |
| tools-x-mcp-header-statically-reachable | MUST | server | n/a: no tool mcpx serves declares an x-mcp-header parameter | n/a: addresses the other side |
| tools-x-mcp-header-reject-excludes | MUST | client | n/a: addresses the other side | tested: mcpclient.TestModernHTTPHeaders/2026-07-28/transport/client-excludes-tool-with-invalid-x-mcp-header |
| tools-x-mcp-header-reject-log | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestModernHTTPHeaders/2026-07-28/transport/client-excludes-tool-with-invalid-x-mcp-header |
| tools-x-mcp-header-stdio-may-ignore | MAY | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2026-07-28/tools/tools-x-mcp-header-stdio-may-ignore |
| tools-x-mcp-header-no-secrets | SHOULD NOT | server | n/a: no tool mcpx serves declares an x-mcp-header parameter | n/a: addresses the other side |
| tools-name-length | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-name-length | n/a: addresses the other side |
| tools-name-case-sensitive | SHOULD | both | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-name-case-sensitive | tested: conformance.TestFeaturesClient/2026-07-28/tools/tools-name-case-sensitive |
| tools-name-charset | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-name-charset | n/a: addresses the other side |
| tools-name-no-special | SHOULD NOT | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-name-no-special | n/a: addresses the other side |
| tools-name-unique-in-server | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-name-unique-in-server | n/a: addresses the other side |
| tools-aggregator-disambiguate | SHOULD | client | n/a: addresses the other side | tested: e2e.TestAliasExposesASubsetOfTheSameServer@2026-07-28e2e.TestDuplicateNamespaceIsRejected@2026-07-28 |
| tools-aggregator-not-by-servername | SHOULD NOT | client | n/a: addresses the other side | tested: e2e.TestDuplicateNamespaceIsRejected@2026-07-28 |
| tools-result-iserror-for-tool-errors | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-result-iserror-for-tool-errors | n/a: addresses the other side |
| tools-result-lookup-errors-protocol | should (lowercase) | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-result-lookup-errors-protocol | n/a: addresses the other side |
| tools-error-unknown-tool-protocol | (classification) | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-error-unknown-tool-protocol | n/a: addresses the other side |
| tools-error-input-validation-execution | (classification) | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-error-input-validation-execution | n/a: addresses the other side |
| tools-client-exec-errors-to-llm | SHOULD | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2026-07-28/tools/tools-client-exec-errors-to-llm |
| tools-client-protocol-errors-to-llm-may | MAY | client | n/a: addresses the other side | tested: conformance.TestFeaturesClient/2026-07-28/tools/tools-client-protocol-errors-to-llm-may |
| tools-embedded-resource-may | MAY | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-embedded-resource-may | n/a: addresses the other side |
| tools-embedded-implies-resources-cap | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-embedded-implies-resources-cap | n/a: addresses the other side |
| tools-resource-link-may | MAY | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-resource-link-may | n/a: addresses the other side |
| tools-resource-link-may-not-listed | (note) | both | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-resource-link-may-not-listed | tested: conformance.TestFeaturesClient/2026-07-28/tools/tools-resource-link-may-not-listed |
| tools-structured-also-text | SHOULD | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-structured-also-text | n/a: addresses the other side |
| tools-structured-any-json | (type) | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-structured-any-json | n/a: addresses the other side |
| tools-output-schema-conform | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-output-schema-conform | n/a: addresses the other side |
| tools-output-schema-client-validate | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| tools-display-name-precedence | (rule) | client | n/a: addresses the other side | gap #207: tool title and annotations.title are carried but nothing that displays a tool uses them |
| tools-server-sec-validate-inputs | MUST | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
tools / json-schema
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tools-inputschema-valid-not-null | MUST | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-inputschema-valid-not-null | n/a: addresses the other side |
| tools-inputschema-type-object-root | REQUIRED (schema) | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-inputschema-type-object-root | n/a: addresses the other side |
| tools-client-sec-ref-resolution | SHOULD | client | n/a: addresses the other side | n/a: mcpx never validates arguments against an upstream inputSchema, so it resolves no $ref for validation |
tools / mrtr
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| tools-call-input-required-may | MAY | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-call-input-required-may | n/a: addresses the other side |
| tools-call-inputrequests-declared-only | MUST NOT | server | tested: conformance.TestFeaturesServer/2026-07-28/tools/tools-call-inputrequests-declared-only | n/a: addresses the other side |
transport-http-sse
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| http-sse-deprecated-no-new-adoption | SHOULD NOT | both | tested: conformance.TestTransportServer/2026-07-28/transport-http-sse/http-sse-deprecated-no-new-adoption | tested: conformance.TestTransportClient/2026-07-28/transport-http-sse/http-sse-deprecated-no-new-adoption |
| http-sse-server-backcompat-host-both | should (lowercase) | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| http-sse-client-backcompat-probe-non-modern-body | should (lowercase) | client | n/a: addresses the other side | tested: mcpclient.TestHTTPEraProbe/2026-07-28/http-compat/legacy-refused-too-is-typed-for-http-sse |
transport-stdio
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| transport-custom-preserve-patterns-and-metadata | MUST | both | n/a: no custom MCP transport | n/a: catalogue marks it n/a |
| transport-custom-document-patterns | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| transport-custom-byte-stream-reuses-stdio-framing | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| stdio-no-embedded-newlines | MUST NOT | both | tested: conformance.TestTransportServer/2026-07-28/transport-stdio/stdio-no-embedded-newlines | tested: conformance.TestTransportClient/2026-07-28/transport-stdio/stdio-no-embedded-newlines |
| stdio-message-is-single | MUST (implied) | both | tested: conformance.TestTransportServer/2026-07-28/transport-stdio/stdio-message-is-single | tested: conformance.TestTransportClient/2026-07-28/transport-stdio/stdio-message-is-single |
| stdio-server-may-log-to-stderr | MAY | server | tested: conformance.TestTransportServer/2026-07-28/transport-stdio/stdio-server-may-log-to-stderr | n/a: addresses the other side |
| stdio-client-may-capture-stderr | MAY | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-stdio/stdio-client-may-capture-stderr |
| stdio-client-stderr-not-error | SHOULD NOT | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-stdio/stdio-client-stderr-not-error |
| stdio-server-stdout-only-mcp | MUST NOT | server | tested: conformance.TestTransportServer/2026-07-28/transport-stdio/stdio-server-stdout-only-mcp | n/a: addresses the other side |
| stdio-client-stdin-only-mcp | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-stdio/stdio-client-stdin-only-mcp |
| stdio-client-no-responses | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-stdio/stdio-client-no-responses |
| stdio-server-no-requests | MUST NOT | server | tested: conformance.TestTransportServer/2026-07-28/transport-stdio/stdio-server-no-requests | n/a: addresses the other side |
| stdio-client-correlates-subscription-id | MUST | client | n/a: addresses the other side | tested: mcpclient.TestModernListenStream/2026-07-28/transport-stdio/client-correlates-subscription-id |
| stdio-client-cancel-by-notification | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-stdio/stdio-client-cancel-by-notification |
| stdio-server-stop-cancelled-work | SHOULD | server | tested: conformance.TestTransportServer/2026-07-28/transport-stdio/stdio-server-stop-cancelled-work | n/a: addresses the other side |
| stdio-server-silent-after-cancel | MUST NOT | server | tested: conformance.TestTransportServer/2026-07-28/transport-stdio/stdio-server-silent-after-cancel | n/a: addresses the other side |
| stdio-server-cancelled-only-for-listen | MUST NOT | server | tested: conformance.TestTransportServer/2026-07-28/transport-stdio/stdio-server-cancelled-only-for-listen | n/a: addresses the other side |
| stdio-client-shutdown-sequence | SHOULD | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-stdio/stdio-client-shutdown-sequence |
| stdio-server-exit-on-eof | SHOULD | server | tested: conformance.TestTransportServer/2026-07-28/transport-stdio/stdio-server-exit-on-eof | n/a: addresses the other side |
| stdio-server-may-close-stdout | MAY | server | tested: conformance.TestTransportServer/2026-07-28/transport-stdio/stdio-server-may-close-stdout | n/a: addresses the other side |
| stdio-client-restart-crashed-server | SHOULD | client | n/a: addresses the other side | gap #257: unverified: the in-flight call fails and the pool respawns only on the next acquire, subject to its start-failure cooldown |
| stdio-dual-era-client-probe-discover | SHOULD | client | n/a: addresses the other side | n/a: optional feature mcpx does not implement |
| stdio-no-fallback-on-modern-error | MUST NOT (informal “Do not”) | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-stdio/stdio-no-fallback-on-modern-error |
| stdio-modern-only-client-probe | RECOMMENDED | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-stdio/stdio-modern-only-client-probe |
transport-streamable-http
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| streamable-http-server-single-endpoint-post | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-server-single-endpoint-post | n/a: addresses the other side |
| streamable-http-server-validate-origin | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-server-validate-origin | n/a: addresses the other side |
| streamable-http-server-403-invalid-origin | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-server-403-invalid-origin | n/a: addresses the other side |
| streamable-http-server-bind-localhost | SHOULD | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-server-bind-localhost | n/a: addresses the other side |
| streamable-http-server-authenticate | SHOULD | server | n/a: optional feature mcpx does not implement | n/a: addresses the other side |
| streamable-http-client-post-every-message | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-streamable-http/streamable-http-client-post-every-message |
| streamable-http-client-accept-both | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-streamable-http/streamable-http-client-accept-both |
| streamable-http-post-body-request-or-notification | MUST / MUST NOT | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-streamable-http/streamable-http-post-body-request-or-notification |
| streamable-http-client-sends-metadata-headers | MUST | client | n/a: addresses the other side | tested: mcpclient.TestModernHTTPHeaders/2026-07-28/transport/client-sends-standard-and-param-headers |
| streamable-http-server-202-for-non-request | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-server-202-for-non-request | n/a: addresses the other side |
| streamable-http-server-error-status-if-rejected | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-server-error-status-if-rejected | n/a: addresses the other side |
| streamable-http-server-json-or-sse-for-request | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-server-json-or-sse-for-request | n/a: addresses the other side |
| streamable-http-client-supports-json-and-sse | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-streamable-http/streamable-http-client-supports-json-and-sse |
| streamable-http-sse-notifications-must-relate | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-sse-notifications-must-relate | n/a: addresses the other side |
| streamable-http-sse-no-requests | MUST NOT | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-sse-no-requests | n/a: addresses the other side |
| streamable-http-server-terminates-sse-after-response | SHOULD | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-server-terminates-sse-after-response | n/a: addresses the other side |
| streamable-http-close-is-cancel | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-close-is-cancel | n/a: addresses the other side |
| streamable-http-stop-cancelled-work | SHOULD | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-stop-cancelled-work | n/a: addresses the other side |
| streamable-http-silent-after-cancel | MUST NOT | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-silent-after-cancel | n/a: addresses the other side |
| streamable-http-no-resumability | — (statement) | both | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-no-resumability | tested: conformance.TestTransportClient/2026-07-28/transport-streamable-http/streamable-http-no-resumability |
| streamable-http-client-reissue-broken-request | MUST (changelog) | client | n/a: addresses the other side | tested: mcpclient.TestModernReissueBrokenRequest/2026-07-28/transport/client-reissues-broken-request-once |
| streamable-http-protocol-version-header-every-post | MUST | client | n/a: addresses the other side | tested: conformance.TestTransportClient/2026-07-28/transport-streamable-http/streamable-http-protocol-version-header-every-post |
| streamable-http-protocol-version-header-matches-meta | MUST | both | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-protocol-version-header-matches-meta | tested: conformance.TestTransportClient/2026-07-28/transport-streamable-http/streamable-http-protocol-version-header-matches-meta |
| streamable-http-unsupported-version-400 | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-unsupported-version-400 | n/a: addresses the other side |
| streamable-http-missing-capability-400 | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-missing-capability-400 | n/a: addresses the other side |
| streamable-http-unknown-method-404 | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-unknown-method-404 | n/a: addresses the other side |
| streamable-http-missing-version-header-legacy | MAY / MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-missing-version-header-legacy | n/a: addresses the other side |
| streamable-http-standard-headers-required | REQUIRED | client | n/a: addresses the other side | tested: mcpclient.TestTheVersionHeaderFollowsTheFrame/2026-07-28/transport/standard-headers-mcp-method-and-mcp-name |
| streamable-http-mcp-name-base64 | MUST | client | n/a: addresses the other side | tested: mcpclient.TestHeaderValueEncoding/2026-07-28/transport/value-encoding-examples |
| streamable-http-x-mcp-header-server-may | MAY | server | n/a: but mcpx re-exports upstream tool schemas — an upstream x-mcp-header passes through, obliging mcpx’s HTTP clients and mcpx’s own validation (see Implied | n/a: addresses the other side |
| streamable-http-x-mcp-header-client-must-mirror | MUST | client | n/a: addresses the other side | tested: mcpclient.TestParamHeaders/2026-07-28/transport/param-headers-mirror-and-omit-null |
| streamable-http-x-mcp-header-constraints | MUST / MUST NOT | server | n/a: unless passing through upstream annotations | n/a: addresses the other side |
| streamable-http-client-rejects-invalid-x-mcp-header | MUST | client | n/a: addresses the other side | tested: mcpclient.TestModernHTTPHeaders/2026-07-28/transport/client-excludes-tool-with-invalid-x-mcp-headermcpclient.TestToolHeaderAnnotations/2026-07-28/transport/client-rejects-invalid-x-mcp-header |
| streamable-http-param-value-encoding | MUST | client | n/a: addresses the other side | tested: mcpclient.TestHeaderValueEncoding/2026-07-28/transport/value-encoding-examples |
| streamable-http-server-decodes-before-compare | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-server-decodes-before-compare | n/a: addresses the other side |
| streamable-http-client-header-construction | MUST | client | n/a: addresses the other side | tested: mcpclient.TestParamHeaders/2026-07-28/transport/param-headers-mirror-and-omit-null |
| streamable-http-client-relist-on-header-mismatch | SHOULD | client | n/a: addresses the other side | tested: mcpclient.TestModernHTTPHeaders/2026-07-28/transport/client-relists-and-retries-on-header-mismatch |
| streamable-http-intermediary-forwards-unknown-param | MUST | intermediary | n/a: mcpx is not an HTTP intermediary; it terminates MCP and re-originates | n/a: catalogue marks it n/a |
| streamable-http-server-validates-param-headers | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-server-validates-param-headers | n/a: addresses the other side |
| streamable-http-header-names-case-insensitive | MUST | both | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-header-names-case-insensitive | tested: conformance.TestTransportClient/2026-07-28/transport-streamable-http/streamable-http-header-names-case-insensitive |
| streamable-http-server-rejects-header-body-mismatch | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-server-rejects-header-body-mismatch | n/a: addresses the other side |
| streamable-http-header-mismatch-400-jsonrpc | MUST | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-header-mismatch-400-jsonrpc | n/a: addresses the other side |
| streamable-http-numeric-compare | SHOULD | server | n/a: catalogue marks it n/a | n/a: addresses the other side |
| streamable-http-intermediary-http-error | MUST | intermediary | n/a: catalogue marks it n/a | tested: conformance.TestTransportClient/2026-07-28/transport-streamable-http/streamable-http-intermediary-http-error |
| streamable-http-intermediary-reject-unvalidated-version | SHOULD | intermediary | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| streamable-http-x-accel-buffering | SHOULD | server | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-x-accel-buffering | n/a: addresses the other side |
| streamable-http-sse-comment-keepalive | should/must (lowercase) | both | tested: conformance.TestTransportServer/2026-07-28/transport-streamable-http/streamable-http-sse-comment-keepalive | tested: conformance.TestTransportClient/2026-07-28/transport-streamable-http/streamable-http-sse-comment-keepalive |
| streamable-http-modern-only-server-legacy-traffic | SHOULD | server | n/a: as written (mcpx is dual-era, not modern-only) — but mcpx mints a session on server/discover for modern clients by choice (to bind requestState | n/a: addresses the other side |
versioning
| requirement | level | side | mcpx as server | mcpx as client |
|---|---|---|---|---|
| versioning-implementations-support-base-versioning-patterns | MUST | both | tested: conformance.TestLifecycleServer/2026-07-28/versioning/versioning-implementations-support-base-versioning-patterns | tested: conformance.TestLifecycleClient/2026-07-28/versioning/versioning-implementations-support-base-versioning-patterns |
| meta-extension-ids-follow-meta-rules | MUST | both | tested: conformance.TestLifecycleServer/2026-07-28/versioning/meta-extension-ids-follow-meta-rules | n/a: declares no extensions |
| versioning-server-unsupported-version-error | MUST | server | tested: conformance.TestLifecycleServer/2026-07-28/versioning/versioning-server-unsupported-version-error | n/a: addresses the other side |
| versioning-client-retry-with-supported | SHOULD | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2026-07-28/versioning/versioning-client-retry-with-supportedmcpclient.TestVersionRetry/2026-07-28/versioning/client-retries-with-supported-version |
| versioning-server-must-implement-discover | MUST | server | tested: conformance.TestLifecycleServer/2026-07-28/versioning/versioning-server-must-implement-discover | n/a: addresses the other side |
| versioning-client-may-discover-first | MAY | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2026-07-28/versioning/versioning-client-may-discover-first |
| versioning-extension-fallback-or-reject | MUST | both | tested: conformance.TestLifecycleServer/2026-07-28/versioning/versioning-extension-fallback-or-reject | n/a: catalogue marks it n/a |
| versioning-extensions-document-fallback | SHOULD | both | n/a: catalogue marks it n/a | n/a: catalogue marks it n/a |
| versioning-dual-era-server-may | MAY | server | tested: conformance.TestLifecycleServer/2026-07-28/versioning/versioning-dual-era-server-may | n/a: addresses the other side |
| versioning-era-cache | SHOULD / MAY | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2026-07-28/versioning/versioning-era-cache |
| versioning-modern-only-server-names-versions-to-initialize | SHOULD | server | n/a: dual-era); for an initialize naming an unsupported version it does name the legacy list | n/a: addresses the other side |
| versioning-modern-client-probe-legacy-stdio | SHOULD | client | n/a: addresses the other side | n/a: unless protocol: force-discover; then discover is first (yes |
| versioning-dual-era-client-probe-discover-first-stdio | SHOULD | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2026-07-28/versioning/versioning-dual-era-client-probe-discover-first-stdio |
| versioning-fallback-not-keyed-to-one-code | MUST NOT | client | n/a: addresses the other side | tested: conformance.TestLifecycleClient/2026-07-28/versioning/versioning-fallback-not-keyed-to-one-code |
| versioning-dual-era-http-detect | MAY / SHOULD | client | n/a: addresses the other side | n/a: legacy first |
| versioning-dual-era-server-selects-by-opening | (normative description) | server | tested: conformance.TestLifecycleServer/2026-07-28/versioning/versioning-dual-era-server-selects-by-opening | n/a: addresses the other side |
| versioning-dual-era-both-concurrently | MAY | server | tested: conformance.TestLifecycleServer/2026-07-28/versioning/versioning-dual-era-both-concurrently | n/a: addresses the other side |
| deprecated-should-not-adopt | SHOULD NOT / SHOULD | both | tested: conformance.TestLifecycleServer/2026-07-28/versioning/deprecated-should-not-adopt | tested: conformance.TestLifecycleClient/2026-07-28/versioning/deprecated-should-not-adopt |
Authorization rows: accepts, or requires refusal (#1972)
61 authorization rows are split per side of the wire into 82 bound row-sides: 69 permissive and 13 validation. A permissive row-side is satisfied by an interface that accepts every credential (#1950) — endpoint shape, discovery documents, registration, flows. A validation row-side cannot pass against such an interface: it needs a real check, named below, to refuse or challenge a credential (#1972).
Validation row-sides per check: invalid 6, expired 5, wrong-resource 3, scope 5. Input: internal/conformance/authsplit.json; edits belong there, never in this file.
| requirement | side | verdict |
|---|---|---|
| authorization-http-should-conform-stdio-should-not | server | permissive |
| authorization-http-should-conform-stdio-should-not | client | permissive |
| authorization-custom-auth-may | server | permissive |
| authorization-custom-auth-may | client | permissive |
| streamable-http-auth-discovery-version-header | client | permissive |
| authz-optional | server | permissive |
| authz-optional | client | permissive |
| authz-http-should-conform | server | permissive |
| authz-http-should-conform | client | permissive |
| authz-stdio-use-environment | server | permissive |
| authz-stdio-use-environment | client | permissive |
| authz-alt-transport-best-practice | server | permissive |
| authz-alt-transport-best-practice | client | permissive |
| authz-implement-oauth21 | server | permissive |
| authz-implement-oauth21 | client | permissive |
| authz-dcr-should | server | permissive |
| authz-dcr-should | client | permissive |
| authz-dcr-may-deprecated | client | permissive |
| authz-rfc8414-server-should-client-must | server | permissive |
| authz-rfc8414-server-should-client-must | client | permissive |
| authz-grant-types | server | permissive |
| authz-401-when-unauthorized | server | validation: invalid — an unproven or invalid credential must be refused with 401 |
| authz-base-url-discards-path | client | permissive |
| authz-default-endpoints-fallback | client | permissive |
| authz-bearer-header | client | permissive |
| authz-token-every-request | client | permissive |
| authz-no-token-in-query | client | permissive |
| authz-server-validates-token | server | validation: invalid, expired — tokens are validated; invalid or expired ones must get 401 |
| authz-server-validates-audience | server | validation: wrong-resource — the token must be issued for this server as audience |
| authz-client-sends-only-own-as-tokens | client | permissive |
| authz-accept-only-own-tokens | server | validation: wrong-resource — only tokens valid for this server’s resources may be accepted |
| authz-no-token-passthrough | server | permissive |
| authz-error-status-codes | server | validation: invalid, expired, wrong-resource, scope — the refusal status code (401/403/400) for every authorization error |
| authz-2025-03-26-security-list | server | validation: invalid, expired — servers enforce token expiration and validate redirect URIs |
| authz-2025-03-26-security-list | client | permissive |
| authz-2025-03-26-implementation-reqs | server | validation: expired — token rotation and limited lifetimes are enforced server-side |
| authz-2025-03-26-implementation-reqs | client | permissive |
| authz-third-party-session-binding | server | validation: invalid, expired — third-party token status validated and expiration handled |
| authz-server-prm-rfc9728 | server | permissive |
| authz-server-prm-rfc9728 | client | permissive |
| authz-prm-lists-authorization-servers | server | permissive |
| authz-as-metadata-rfc8414 | server | permissive |
| authz-as-metadata-rfc8414 | client | permissive |
| authz-as-metadata-rfc8414-or-oidc | server | permissive |
| authz-as-metadata-rfc8414-or-oidc | client | permissive |
| authz-401-www-authenticate | server | validation: invalid — the 401 refusal must carry the WWW-Authenticate metadata pointer |
| authz-prm-discovery-header-or-well-known | server | permissive |
| authz-prm-discovery-header-or-well-known | client | permissive |
| authz-client-parses-www-authenticate | client | permissive |
| authz-client-rfc8414-discovery | client | permissive |
| authz-client-as-discovery-order | client | permissive |
| authz-client-validates-issuer-in-metadata | client | permissive — metadata is checked deterministically; no credential is inspected |
| authz-client-per-as-registration-state | client | permissive |
| authz-resource-indicator | client | permissive |
| authz-canonical-uri-guidance | server | permissive |
| authz-canonical-uri-guidance | client | permissive |
| authz-secure-token-storage | server | permissive |
| authz-secure-token-storage | client | permissive |
| authz-https-and-redirect-uris | server | permissive |
| authz-https-and-redirect-uris | client | permissive |
| authz-pkce | client | permissive |
| authz-pkce-verify-s256 | client | permissive — the client refuses to proceed on AS capability, not on a presented credential |
| authz-open-redirection | client | permissive |
| authz-proxy-consent-per-client | server | permissive — user consent is a flow gate, not a credential check; no credential is inspected |
| authz-client-registration-required | client | permissive |
| authz-registration-priority | client | permissive |
| authz-cimd | client | permissive |
| authz-preregistration | client | permissive |
| authz-dcr-application-type | client | permissive |
| authz-credentials-bound-to-issuer | client | permissive |
| authz-scope-in-challenge | server | validation: scope — the refusal challenge must name the scope required |
| authz-scope-in-challenge | client | permissive |
| authz-scope-selection-order | client | permissive |
| authz-insufficient-scope-403 | server | validation: scope — insufficient scope must be refused with 403 insufficient_scope |
| authz-all-scopes-single-challenge | server | validation: scope — one refusal challenge listing every scope the operation needs |
| authz-step-up | client | permissive — the client reacts to a refusal it is given; it validates nothing itself |
| authz-server-scope-hierarchy | server | validation: scope — scope hierarchies must be accounted for when enforcing scope |
| authz-iss-validation | client | permissive — discovery/issuer metadata is checked deterministically; no credential is inspected |
| authz-refresh-tokens | server | permissive |
| authz-refresh-tokens | client | permissive |
| authz-follow-security-considerations | server | permissive |
| authz-follow-security-considerations | client | permissive |
The conformance matrix
docs/spec/conformance-matrix.md says, for every normative requirement of every MCP revision from 2024-11-05 to
2026-07-28, and for each side mcpx plays, whether a named test holds mcpx to it, why it does not bind mcpx, or which
issue tracks mcpx not meeting it. go test ./internal/conformance fails when any cell has none of the three. This is
how it is put together, and what building it found.
Pieces
- Catalogue.
docs/spec/requirements.mdis the prose catalogue (788 rows, from the five area sweeps).go run ./internal/conformance/cmd/genreqturns it intointernal/conformance/requirements.json: revision ranges expanded, the side column read as server/client applicability. Anything the catalogue does not plainly mark n/a is taken to apply – a row wrongly marked as applying shows up as a missing test and gets looked at; one wrongly marked n/a is never looked at again. Re-judgements live inoverrides/*.json, each with its reason. Authorization rows arenot-implemented:#253: mcpx implements no OAuth. - Coverage.
coverage_*.go, one file per area. A cover names a test as<pkg>.<TestName>/<subtest>; the subtest’s leading revision is the revision it covers, or an explicit@rev,...for a test not named per revision.SReq/CReqcover the requirement-named subtestsforReqproduces. - Gaps. A
Gapnames a requirement, side, optional revisions, an issue, and why. The tests in this package consult the gap list at run time: a gapped cell’s subtest skips withgap: <issue>, so the failing check is kept and runs again the moment the entry is removed.MCPX_CONFORMANCE_RUN_GAPS=1runs them all anyway – which is how the gaps closed by #239 were found and removed. - The check.
TestMatrixregeneratesrequirements.jsonand the matrix and compares both with the committed files (MCPX_UPDATE_MATRIX=1rewrites them), parses the Go test sources to refuse a cover that names a test which does not exist or which skips as a gap, and fails listing every cell with no cover and no gap. It also fails when a tested cell’s catalogue note claims a gap (anygap/GAPin the note, recorded asclaimsinrequirements.json): the note and the test cannot both be right, so neither is allowed to silently outrank the other. The matrix names the sha256 of its inputs (the catalogue, overrides andcoverage*.go) rather than a commit, since a file cannot name the commit that contains it; the comparison is what keeps it current.MCPX_MATRIX_AREAS=tools,resourcesnarrows the list.go run ./internal/conformance/cmd/coveringruns exactly the covering tests, onego test -runper package (-nprints the commands).
Why forReq takes its revisions from the catalogue
A test that loops over its own list of revisions can claim one it never runs. forReq(t, side, id, fn) reads the
requirement’s revisions from requirements.json and runs fn once per revision as <rev>/<area>/<id>, which is
exactly the name the cover expands to. The cover and the run cannot drift.
The harness
harness_test.go is shared by every area: mcpx’s server over stdio pipes (stdioServer) and over a real listener
(httpServer), with a complete fake backend, a filtering notifier and a scriptable Asker; mcpx’s client against a
scripted peer of any revision (newPeer), in process or behind Streamable HTTP (httpPeer). Every frame either side
emits is validated with mcpspec – strictly for mcpx’s server frames, so a key a revision does not define fails the
test that provoked it.
What building it found and fixed (internal/mcpserver)
Each fix has a test in this package that fails without it.
- A question mcpx put to a legacy client was not bounded by the ask deadline: a client that never answered held the
call for good, and the question was never cancelled. It is now asked under the deadline, and its expiry sends
notifications/cancelled. - A batch of 2026-07-28 requests on a stdio process that had not been initialized was answered as a 2025-03-26 batch. An element that names its own revision now decides.
logging/setLevelwith a level that is not one of the eight answered{}; it is-32602now. A valid level is still accepted though mcpx sends no log messages.completion.maxValuescould raise a completion past the 100 values every revision allows.- The batch refusal was two sentences; error messages are one.
- An invalid list cursor restarted at the first page; it is
-32602now, in every list includingtasks/list. - (
internal/cli/serve.go)mcpx://<ns>/<uri>concatenated the upstream URI unencoded, so a space,#or?in it made something that is not a URI, or not the one meant. Characters a path may not hold are percent-encoded,%included, and decoded again on read, completion and subscribe. tasks/cancelof a finished task succeeded; 2025-11-25 makes it-32602.- An unknown tool name was an
isErrorresult; it is-32602, before any task or question starts. prompts/getdid not check required arguments; a missing one is now-32602before anything runs or asks. (The-32602/-32603classification of backend failures landed at the same time in #245; the two are merged.) The missingbackend == nilguard is added.
Surprises
- Through 2025-06-18 the schema requires an error response’s
idto be a string or integer, and JSON-RPC requiresnullwhen the request could not be read. No frame satisfies both; mcpx omits the id (C4 inrevision-conflicts.md). - 2026-07-28 removed
ping. mcpx still answers one (accept liberally) and its client no longer sends one. - Two
ServeStdioloops on oneServershared its default connection, so a question could reach the wrong one. No process does this – one stdio server per process – but a test that tried it did; a second loop now gets its own.
Upstream era probe
How mcpx decides whether a server it fronts speaks the modern (2026-07-28,
per-request _meta) protocol or a legacy (initialize) one, and how it
remembers the answer.
Sources: versioning, stdio, Streamable HTTP.
Values
upstream.protocol, and a server’s own protocol key, which overrides it,
take one of the values below. Each is named for the first request mcpx sends,
not for an era’s relative age; the older names are still accepted wherever the
value is read (config file, MCPX_UPSTREAM_PROTOCOL, --upstream-protocol,
PUT /v1/settings) and are normalised to the canonical one, which is what
mcpx settings, mcpx config and GET /v1/protocol report. Case does not
matter. Any other value is refused with this list.
| canonical | meaning | aliases (accepted, normalised to the canonical) |
|---|---|---|
prefer-discover (default) | server/discover first, fall back to initialize | modern, prefer-modern, prefer-stateless, prefer-newest |
prefer-initialize | initialize first, fall back to server/discover | legacy, prefer-legacy, prefer-session, prefer-oldest |
force-discover | server/discover only | force-modern, force-stateless |
force-initialize | initialize only | force-legacy, force-session |
follow | unchanged | — |
What each value does against a server that speaks only the old
(initialize, 2025-11-25 and earlier) protocol, both, or only the new
(server/discover, 2026-07-28) one. “Legacy session” means an initialize
handshake; “modern session” means per-request _meta with no handshake. With
the era cache on, the prefer-* and follow values try the cached era first
on later starts; the result is the same.
| value | old-only server | dual-version server | new-only server |
|---|---|---|---|
prefer-discover | discover is refused (or unanswered within upstream.probeTimeout on stdio), then initialize: legacy session | discover answers: modern session | modern session |
prefer-initialize | legacy session, no discover sent | initialize answers: legacy session, so the server’s 2026-07-28 features are not used | initialize is refused, then discover: modern session (fails if a stdio server exits on the refused initialize) |
force-discover | fails to connect; no fallback | modern session | modern session |
force-initialize | legacy session | legacy session | fails to connect; no fallback |
follow | as prefer-discover: one legacy session, shared by every caller | modern session for 2026-07-28 callers plus a second, legacy session for callers on an older revision (Follow) | modern session; the legacy session is refused, so older callers share the modern one |
Order
The default is discover first (upstream.protocol=prefer-discover; a server’s
own protocol key overrides it). Legacy first used to be the default on the
argument that nearly every server is legacy and the probe costs each of them a
round trip. The spec prescribes the opposite order for a dual-era client, and
the era cache turns the probe into a one-time cost per server configuration,
so the argument no longer holds.
Follow
protocol: follow (per server, or upstream.protocol=follow) is prefer-discover
for the server’s own session, plus a second, legacy-only session (force-initialize
– initialize, no probe) used for any call whose caller reached mcpx in a
revision before 2026-07-28. The point is server-to-client requests: a dual-era
server can push elicitation/create or sampling/createMessage only on a
legacy session, and a legacy caller can only be asked by a request. Over the
modern session such a server answers its legacy-only tools -32601 (the
official suite’s test_elicitation, test_sampling).
- Who is legacy. The caller’s revision travels with the call: mcpx’s MCP
server records it per request,
mcpx servesends it to the daemon asX-Mcpx-Caller-Protocolon/v1/calland/v1/ask, and the pool reads it. A call with no caller revision (the CLI,/v1direct, schema refresh) is modern. - Pool. The two sessions are two instances under the same scope key; the
key is still the caller’s identity, and the era is a second axis. Each lane
has its own
max, so a global scope (max 1) holds one of each, and a legacy call the server never finishes cannot stall modern callers. A legacy caller also takes a modern-lane instance that settled legacy (a legacy-only server needs no second session). Eviction stays within a lane. - Fallback. If the legacy-only start fails (a modern-only server refuses
initialize), the pool remembers it for its lifetime and legacy callers share the modern session, as underprefer-discover. - Era cache. The legacy session is forced, so it neither reads nor writes the cache; the cache keeps describing the server’s own era.
- Attribution. A legacy HTTP server’s request on a POST’s response stream is handed to the answer handler with that request’s context, so the daemon attributes it to the call that provoked it rather than inferring it from who else shares the session (it could not when two calls were in flight).
- Why not the default. For a stdio server the second session is a second process for the same key – a second browser, a second index – which the pool otherwise never does. Opt in where the server is cheap to run twice or where legacy callers need its questions.
stdio
server/discover is sent with the full _meta (newest version mcpx offers,
client capabilities, client info). Then:
| answer | era | next |
|---|---|---|
a result with a supportedVersions array | modern | newest mutual version; none mutual is an error |
-32020, -32021, -32022 | modern | -32022 retries with the newest mutual version from data.supported, each version at most once; otherwise an error. Never initialize. |
| any other error | legacy | initialize + notifications/initialized |
| a success that is not a DiscoverResult | legacy | a catch-all handler answered under legacy semantics |
nothing within upstream.probeTimeout | undecided | initialize is sent alongside the pending discover; whichever settles the era first wins |
| the connection closes | legacy | ErrClosedDuringProbe; the pool starts a new process and connects legacy-only |
The timeout does not abandon the discover because the commonest slow server is
a modern one being downloaded by npx. Giving up on its answer at 2s would
classify it legacy and then cache that.
Streamable HTTP
The probe is a POST of server/discover with MCP-Protocol-Version equal to
the _meta version and no session id. A non-2xx body that is a JSON-RPC
response whose id matches the request is delivered as that response, so a
-32022 reaches the retry logic. Otherwise the transport returns
*HTTPStatusError (status, body, parsed JSON-RPC error). A 4xx with a
recognised modern error is modern; any other 4xx falls back to initialize;
connection failures and 5xx are errors and nothing is cached. When
initialize is itself refused with a bare 400/404/405 the error is a
*LegacyHTTPRefusedError, which is the hook for falling back to the
deprecated HTTP+SSE transport (not implemented here). There is no probe
timeout on HTTP: status codes decide, and a slow server is only slow.
Era cache
Key: a SHA-256 of the process definition only – command, args, env, cwd,
transport, url, headers (JSON-encoded, so map keys are sorted). Not
PoolID(): that includes leasing knobs and hashes headers in map iteration
order, so it differs between runs.
Value: era, negotiated version, when, and how it was learned. Stored in memory
and in <state>/upstream-eras.json (upstream.eraCache, default on), written
atomically (temp file + rename, mode 0600) with a version field. A missing,
truncated, non-JSON or other-version file is treated as empty and rewritten on
the next start.
A cached legacy server gets initialize first and no discover at all. A
cached modern server gets the normal probe. If the cached era turns out wrong
the probe continues in the normal order, the cache is rewritten, and the pool
emits server.era.stale. force-* never reads or writes the cache.
GET /v1/protocol reports per upstream server the effective preference, the
live era and negotiated version, eraSource (probe, cache, forced), and
the cached record.
Who an MCP request is from
mcpx’s own MCP server passes every call on to an upstream server, and which
instance of that server answers depends on its scope: one per session, per
cwd, per pid, per repository, or per call. So every request has to answer the
question “who is asking?”, and the answer has to come from the transport, not
from the process.
What went wrong
The backend used to ask the process. mcpSession() returned
$MCPX_SESSION_ID or mcp-<pid>, and callContext() filled in the process’s
cwd and pid. That is right for mcpx serve, where one process serves one
client over stdio. It is wrong inside the daemon, which serves /mcp for
every HTTP client from one process: every client, every Mcp-Session-Id,
resolved to session:mcp-<daemonpid>, cwd:<daemon's cwd> and
pid:<daemonpid>. Two agents talking to one daemon shared one browser, one
REPL, one set of cookies, and saw each other’s mcpx_exec artifacts in
resources/list (conflict #2 in the WP7 record). Over stdio the same test
gave separate instances, so the two transports disagreed about what scope: session means.
What mcpx does now
mcpserver resolves an Identity for each request from the connection it
arrived on, and puts it in the request’s context
(internal/mcpserver/identity.go). The backend builds the daemon call context
from that (mcpCaller in internal/cli/serve.go):
| request arrives on | identity | session | cwd / pid |
|---|---|---|---|
stdio (mcpx serve) | the process | $MCPX_SESSION_ID, else mcp-<pid> | the process’s |
| a legacy Streamable HTTP session | its Mcp-Session-Id | mcp-<session id> | unknown |
any request carrying _meta["dev.mcpx/session"] | that name | the name, verbatim | unknown |
any request carrying _meta["ai.opencode/sessionID"] (and no dev.mcpx/session) | that id | the id, verbatim | unknown |
| a 2026-07-28 request without it, or a legacy POST outside a session | none | none: a fresh call id | unknown |
The rows are in precedence order except that an explicit session key
wins over the connection’s own identity. When a request carries more
than one listed key, the first key in transport.sessionMetaKeys order
whose value is a non-empty string wins.
Why cwd and pid are unknown over HTTP
The daemon’s cwd and pid are not the client’s, and the transport carries
neither. A scope that needs one (cwd, repo, worktree, pid) degrades to
per-call and logs the degradation once, which is the direction that can’t
leak one client’s state into another. A client’s roots would be a better
source for cwd. That needs a roots/list round trip that mcpx doesn’t make
yet.
Why a modern request gets no identity
2026-07-28 has no sessions. The streamable-http page tells a server to ignore
Mcp-Session-Id and not to mint one, so no connection-level identity exists.
mcpx doesn’t invent one: an unnamed request is its own scope, which for a
session-scoped upstream means a new instance per request.
The session keys: transport.sessionMetaKeys
For a client that wants continuity, such as an agent host that keeps one
browser across its calls, mcpx reads a session name (a string) from any
request of any revision, under the keys listed in the
transport.sessionMetaKeys setting, in order. The default list is
dev.mcpx/session — mcpx’s own key, which uses mcpx’s prefix because the
specification reserves io.modelcontextprotocol/ and defines no session
key of its own — followed by ai.opencode/sessionID, the key OpenCode v2
sends on every tools/call, so each OpenCode session is scoped as its
own session instead of sharing the daemon’s. A deployment can replace
the list (dropping either key, or adding a host’s own) in configuration;
an empty or non-string value is ignored, as if the key were absent. The value is used verbatim as the session id, so a client can join
the same session a CLI user names with --session or $MCPX_SESSION_ID.
That is deliberate. Anyone who can reach the daemon can already run any tool
as its user (see the warning the daemon prints for a non-loopback address), so
a session name is a label and grants no authority.
ai.opencode/sessionID and transport.sessionMetaKeys
opencode v2 puts _meta["ai.opencode/sessionID"] on every tools/call,
including calls made from inside its code mode, so a client that never
injects MCPX_SESSION_ID can still get one stateful scope per opencode
session. mcpx reads a precedence list of _meta keys, transport.sessionMetaKeys
(default dev.mcpx/session,ai.opencode/sessionID): the first key present on a
request wins, and an empty value counts as absent. dev.mcpx/session is first
so a name chosen for mcpx beats the id a host attaches automatically; a client
that sends only the opencode key is keyed by it verbatim, exactly as it would
be by dev.mcpx/session. The setting is hot: a change applies to the next
request, not the next daemon start. Send a key outside the list and it is
ignored, which is the documented fallback rather than an error.
What it touches
Identity reaches every backend path that picks an instance: mcpx_call,
mcpx_exec (its artifact session), resources/read, prompts/get,
completion/complete, the artifact listing in resources/list, and the ask
path (/v1/ask with the same context). A caller with no identity lists no
artifacts, because the daemon reads an empty session as “everyone’s”.
The tests are
2025-11-25/transport/http-mcp-clients-do-not-share-scoped-instances,
2026-07-28/transport/client-named-session-is-kept-across-requests,
2026-07-28/transport/unnamed-requests-are-their-own-scope (e2e, against a
session-scoped fakemcp), and the TestEachConnectionHasItsOwnIdentity cases
in internal/mcpserver/wp11_test.go.
What mcpx sends, per revision
mcpx serves five revisions from one server: 2024-11-05, 2025-03-26, 2025-06-18
and 2025-11-25 through an initialize handshake, and 2026-07-28 through
per-request _meta. Everything is built in the newest shape and rewritten at
one edge (downgrade in internal/mcpserver/revisions.go), because building
several shapes and choosing is how they drift apart. This page records what
that edge does and what was wrong before it did.
The modern envelope
Every 2026-07-28 result gets resultType (MUST), and
_meta["io.modelcontextprotocol/serverInfo"] (SHOULD), merged into whatever
_meta the result already carries. server/discover, the four list methods
and resources/read also get ttlMs and cacheScope, which the caching page
makes a MUST on complete results – and only complete ones: an
input_required or a task handle carries none.
| method | cacheScope | ttlMs from |
|---|---|---|
server/discover, tools/list, prompts/list, resources/templates/list | public | protoMessages.listMaxAge |
resources/list | private (it includes the caller’s session artifacts) | protoMessages.listMaxAge |
resources/read | private | protoMessages.readMaxAge (0: a resource is whatever upstream says now) |
Legacy clients get none of it; downgrade strips all four fields.
server/discover was answered with protocolVersions and a top-level
serverInfo – neither is a DiscoverResult field – so no real modern
client could read it. It is supportedVersions now, with the identity in
_meta.
A modern request missing io.modelcontextprotocol/clientCapabilities, or
naming a log level that does not exist, is -32602. mcpx emits no
notifications/message at all, so the rule that none go to a request without
a level holds trivially; a test pins it.
Per-revision fields
Checked against each revision’s schema.ts, for everything mcpx sends:
| field | first revision | older clients get |
|---|---|---|
| audio content | 2025-03-26 | a text block describing it |
Tool.annotations, completions capability | 2025-03-26 | nothing |
title (tools, prompts, arguments, resources), resource_link, structuredContent, outputSchema | 2025-06-18 | nothing; a link becomes an embedded resource, structured content becomes text |
icons, execution, core tasks capability, url elicitation | 2025-11-25 | nothing |
extensions capability, resultType, cache hints, serverInfo in _meta | 2026-07-28 | nothing |
Two mistakes this found. extensions was sent to 2025-11-25, whose
ServerCapabilities has no such field. And resources/templates/list sent
each template as {uri: ...}; every revision’s ResourceTemplate spells it
uriTemplate, so the list was invalid everywhere.
2024-11-05 is served over stdio and Streamable HTTP. Its own HTTP+SSE transport is not: 2025-03-26 replaced it and 2026-07-28 says new implementations SHOULD NOT adopt it.
Serving 2024-11-05 does not make it the default for a message that names no
revision on the transport. A Streamable HTTP request with no
MCP-Protocol-Version header and no session, and a stdio batch before
initialize, are taken as 2025-03-26 (Headerless): 2024-11-05 has no
Streamable HTTP, so nothing on /mcp can be a 2024-11-05 request, the
2025-06-18/2025-11-25 transport pages say a server SHOULD assume 2025-03-26
there (2026-07-28: MAY), and a batch can only come from a client that believes
batches exist – 2024-11-05’s schema has no batch type, 2025-03-26 says a
server MUST accept them. Oldest still governs how a result is spelled for a
stdio client that declared nothing, where guessing downward is the safe
direction.
initialize
Every legacy lifecycle page: if the server does not support the requested
version it MUST respond with another it supports. mcpx answered -32022
instead – a 2026-07-28 code a legacy client cannot interpret, and no version
to fall back to. An unknown or modern version now gets the latest legacy one
mcpx serves, and the client decides.
subscriptions/listen
The acknowledgement comes first and carries the listen request id in
_meta["io.modelcontextprotocol/subscriptionId"] with the agreed filter; every
notification on the stream carries the same key. A connection holds as many
streams as the client opens, keyed by request id – mcpx kept one and
replaced it on each listen. A notification kind no filter field names is
dropped on the way out, whatever the notifier produced.
Over Streamable HTTP the listen POST’s response is the stream: an SSE response
held open, with a comment every transport.sseKeepAlive (one keep-alive for every stream mcpx serves), until the
client closes it (the transport’s cancellation). mcpx used to return at once,
ending the response with only the acknowledgement on it. On stdio the client
ends a stream with notifications/cancelled naming the listen id.
When mcpx ends a stream itself – the connection is going away, or the event
source stopped – the specification contradicts itself. The subscriptions page
says the server SHOULD send a successful subscriptions/listen result; the
cancellation page says it MUST send notifications/cancelled for the listen id
and MUST NOT send that notification for anything else. mcpx sends the
cancellation first, while the request is still in progress, then the result,
which a client that acted on the cancellation is told to ignore.
Legacy resources/subscribe keeps its own untagged stream.
Resource not found
-32602 to a 2026-07-28 client, -32002 to every earlier one, both with
data.uri. The backend reports not-found as mcpserver.ErrResourceNotFound
for four causes: not an mcpx:// URI, no namespace in it, a namespace that is
not configured, or an upstream server that said -32002/-32602. Anything
else is -32603; every failure used to be -32602, blaming the client’s URI
for an upstream timeout. The upstream cases are recognised by the daemon’s
error text, because the daemon returns them as text.
Error codes
mcpx’s server emits -32700, -32600..-32603, -32021 (a listen asking
for taskIds without the tasks extension), -32022 (modern, unsupported
version), and -32002 to legacy clients only. Nothing else in the reserved
range; a test sweeps both eras.
Tasks
2025-11-25 core tasks are client-directed: a task field on tools/call,
{task} back, tasks/list and a blocking tasks/result. Declared only to
2025-11-25; honoured from any legacy client.
The 2026-07-28 extension is server-directed, and forbids the other way: a
server MUST ignore the task field and MUST NOT return a task to a client that
did not declare io.modelcontextprotocol/tasks on that request. Whether a
call becomes a task follows the tool’s execution.taskSupport, which a
pass-through upstream declares and mcpx forwards on tools/list:
required, from a client that did not declare the extension:-32021withdata.requiredCapabilities.extensions["io.modelcontextprotocol/tasks"], before anything runs.optionalorrequired: the call gets 250 ms (Timing.TaskEager) to finish or to ask its first question. Finished, it is answered directly; a question is put to the client inline (input_requiredon the request, no task yet), and the retry carrying the answer goes through the same window. Past it, the call is a task. A question the call asks after that parks the task ininput_required, with the question ininputRequestsontasks/get;tasks/updateanswers it key by key – answered keys leaveinputRequestsat once, keys not outstanding are ignored – and the task goes back toworking.tasks/cancelabandons the call behind it.- anything else: run in line, and answered with a task only if it has not
finished within
protoMessages.taskAfter. A client that can answer questions inline is never handed one of these. forbidden: never a task.
The CreateTaskResult is flat (resultType: "task", ttlMs,
pollIntervalMs). tasks/get inlines the result or error; a tool result with
isError is completed, not failed (the store’s 2025-11-25 rule is the
opposite and is translated), and an upstream’s JSON-RPC error is failed with
that error. tasks/update and tasks/cancel acknowledge with an empty
result, tasks/cancel also on a task already finished. Mcp-Name on
tasks/get, tasks/update and tasks/cancel mirrors the taskId; one that
contradicts it is -32020, one left out is tolerated. tasks/list and
tasks/result are -32601 to a modern client: the SEP says so, which
overrides accepting liberally.
A task started on a legacy connection with an identity belongs to it: another connection cannot list it, and gets not-found for its id. Before, one server-wide store meant any HTTP client could list every other client’s tasks and read their results.
Elicitation
A 2026-07-28 client is never sent elicitationId; it was relayed verbatim
inside inputRequests. A 2025-11-25 url-mode request REQUIRES one, so a
question relayed from an upstream that did not supply it gets mcpx-<id>.
requestState
Bound to the request that carries it – its method and a SHA-256 of its
parameters without _meta, inputResponses and requestState – plus expiry,
under the per-process HMAC key. It was bound to an Mcp-Session-Id, which
2026-07-28 does not have: mcpx minted a session on server/discover to have
something to bind to, and a client that skipped discover could not resume at
all. That minting is gone. mcpx has no authenticated principal to add, so a
state is replayable by anyone who holds it on the same request, within its
TTL; the MRTR page notes that binding does not by itself make a state
single-use.
tools/list order
The fixed tools come first in their written order; contributed tools – adapters, OpenAPI operations, /v1 operations – are sorted by name once, when they are added, so the list is the same across calls and restarts.
The official conformance suite, run against mcpx
created: 2026-09-30T12:00:00-05:00
last-updated: 2026-10-01T19:30:00-05:00
increment: 4
status: standard
tags: area:protocol, area:spec
description: what modelcontextprotocol/conformance says about mcpx as a
server and as a client, measured against the suite's own
fixture server behind mcpx in pass-through mode, and what each
remaining failure is.
In CI
.github/workflows/mcpx-conformance.yml runs every leg on each pull request and push to main
that touches mcpx – one job per leg, about a minute each. A leg is one side of the protocol (mcpx
as a server, or as a client) checked against one spec revision. The suite has frozen requirement
sets for 2025-11-25 and 2026-07-28; for 2025-03-26 and 2025-06-18 it has only the scenarios tagged
for each, selected with --spec-version, so those legs are smaller (about 3 and 56 checks).
server-all is every server scenario it has. The suite does not know 2024-11-05, although mcpx
carries its schema. Client legs run for all four: frozen sets for the two newest, tagged scenarios (--suite all --spec-version) for the two older. A leg’s job fails when any
check fails, lists the failures on the run’s summary page, and uploads them as an artifact.
It is not a merge block: no branch protection requires it.
A spec parity job compares the revisions mcpx supports (mcpx protocol --json, .asServer.supported) with those the suite knows (its error for an
unknown --spec-version names them all). Neither list is written in this
repository. A mismatch warns, with a deadline counted from the last match on
main – 840 hours when only one side has extra revisions, 360 when both do –
and after it the job fails. Revisions one side is expected to have alone are
listed at the top of the workflow (SPEC_PARITY_ALLOW_ONLY_MCPX,
SPEC_PARITY_ALLOW_ONLY_SUITE); today that is 2024-11-05, which mcpx serves
and the suite has never tested. An entry both sides come to have, or that its
side drops, fails the job at once until it is removed. See
scripts/spec-parity.py.
Each leg’s summary shows every status the suite recorded: passed, failed,
skipped, warning and info. A skipped check passes nothing, so the records
job fails any run with more skipped checks than the record on main, and the
record’s skip count only moves down. The table below lists every skip still
standing, by name; --suite all skipped 7 of 280 checks at 650bf19 and 1 of
280 after resources/directory/read landed (LEGS=server-all scripts/conformance.sh, counted from checks.json). The Go tests have the
same rule, through the repository variable MCPX_GO_TESTS written by
scripts/gotest-report.py. The record is 0: every gap whose test was skipped
has been closed, and the tests that only mean anything on macOS are compiled
only there rather than counted as skipped on Linux. Both kinds of record keep the names of
what is skipped, so a rise reports exactly which test or check is new.
Skipped, and why
The aim is zero failed and zero skipped. Every skip still standing is listed here with its reason; anything not listed is a regression the records catch.
| where | what | why it stays |
|---|---|---|
| client 2026-07-28 | http-standard-headers for initialize and notifications/initialized (2) | A 2026-07-28 client never sends either: the revision removed the handshake. The suite checks the header on every method it knows and has no way to mark these not applicable. Permanent. |
server 2026-07-28, --suite all | tasks-status-notifications (1 each) | The suite skips it unconditionally: src/scenarios/server/tasks/notifications.ts returns SKIPPED with “pending subscriptions/listen rewrite”. mcpx does deliver task status on a subscriptions/listen stream; TestListenDeliversTaskStatusNotifications covers it. Until the suite is rewritten. |
Skipped checks: 4 of 1,014, all above. Skipped Go tests: 0.
Exceptions that were skips and are not any more: every conformance gap
(#200, #201, #203, #209, #212, #77) whose test was skipped while the gap
stood, all 42 skills checks (the last six, sep-2640-skills-directory,
since mcpx serves resources/directory/read and mcpx-basics has an
examples/ subfolder for the subdirectory check to find), the 2026-07-28 header checks
for resources/read and prompts/get (the conformance adapter now reads
every resource and gets every prompt in that scenario); one Go subtest for a
method that was never applicable (initialize on a legacy connection, now not
created); the spec example the schema rejects (now asserted rejected, so the
day the spec fixes it the test fails and says to remove the entry); and two
macOS-only filesystem tests, now compiled only on macOS rather than counted
as skipped on Linux.
Records. Each leg keeps two records in a repository variable, each always one whole run. best is the lowest share of checks that did not pass – failed plus skipped – at a total no smaller than the record’s; a tie goes to the run with more checks passed. largest is the run with the most checks. Skips count against a run because a skipped check passes nothing: when only failures counted, a run that cut skips from 44 to 1 at zero failures tied the record and replaced nothing.
Each leg’s total is every check the suite recorded, from its checks.json
files, warnings included: the summary line’s passed + failed leaves warnings
out, so a failure that became a warning looked like a smaller run.
On main each leg’s counts are compared with two records kept in the repository variable
MCPX_CONFORMANCE_<LEG> – the best failed/total ratio and the largest total – and a run that
goes backwards on either gets a large warning on its summary. The rules are in
scripts/conformance-record.py. Saving the records needs the secret
MCPX_CONFORMANCE_VARS_TOKEN, a fine-grained token with Variables: read and write on this
repository; GITHUB_TOKEN cannot write variables.
modelcontextprotocol/conformance (npm @modelcontextprotocol/conformance)
is the suite the specification’s own process leans on: SEP-2484 makes a
merged scenario a condition of a Standards Track SEP reaching Final, and
SEP-1730 grades the official SDKs by it. Its requirements/<revision>.yaml
sets are frozen, so a number from one of them means the same thing next year.
The 2026-07-28 scenarios are on the alpha line (0.2.0-alpha.11, suite commit
7169291); latest is a 0.1.x that predates that revision.
scripts/conformance.sh runs it:
CONFORMANCE_DIR=/path/to/conformance scripts/conformance.sh [out-dir]
- Server leg. The suite’s own reference fixture,
examples/servers/typescript/everything-server.tsin the clone, started the way itspackage.jsonstarts it (tsx everything-server.ts,PORTset), sits behindmcpx daemon --port … --passthrough demo. Pass-through (mcp.passthrough,../protocol.md§2.5) offers the fixture’s tools, prompts and resources under their own names, so a scenario asking fortest_simple_textortest://static-textgets them through mcpx and its checks reach mcpx’s protocol handling. The 2025-11-25 leg has its own daemon that talks 2025-11-25 to the fixture as well: the fixture’s legacy tools push requests to their client, which only a legacy session carries.--suite alland 2026-07-28 use a daemon that talks 2026-07-28 upstream. The suite has no stdio server mode, somcpx serveis not covered here;schema_sweep_test.gocovers it instead. - Client leg.
internal/conformance/officialclientwrites an isolated config naming the scenario server as an HTTP upstream and drives the realmcpxbinary against it, so everything the scenario sees on the wire came from mcpx’s own client.
Every daemon and the fixture are stopped on exit.
Where the tasks fixtures come from. The tasks-extension scenarios
(src/scenarios/server/tasks/*.ts) ask for greet, slow_compute,
failing_job, protocol_error_job, confirm_delete, multi_input and
test_tool_with_task. Nothing in the suite defines them: everything-server.ts
does not, and the suite’s known-sdks.ts points each SDK at its own
conformance server (test/conformance/src/everythingServer.ts in
typescript-sdk, conformance/everything-server in go-sdk). They are SDK
fixtures.
mcpx has its own: internal/testsupport/taskmcp, a stdio server defining
exactly those tools with the behaviour each scenario’s “Required server
fixtures” block documents. The daemon fronts it next to everything-server.ts
with --passthrough demo,tasks, so one /mcp offers both surfaces under their
own names (mcp.passthrough takes a list and refuses a name two upstreams
share). taskmcp speaks 2025-11-25 and knows nothing of tasks: each tool
declares execution.taskSupport and does its work – sleeps, fails, asks –
and whether a call becomes a task, and how its questions travel, is mcpx’s
doing. Two of its behaviours are chosen for that reason: confirm_delete,
multi_input and protocol_error_job work for a second before they ask or
fail, so the call has become a task by then; test_tool_with_task asks at
once, so the question is answered inline before any task exists. That is the
distinction the scenarios draw, seen from a gateway that cannot see into the
tool.
taskmcp also lists echo_region, whose string parameter region carries
x-mcp-header: "Region", because http-custom-header-server-validation
reports all of its checks as untestable without such a tool. mcpx relists it
unchanged and validates the Mcp-Param-Region header itself, before the
call is forwarded.
Results
Every column is a run of scripts/conformance.sh against a daemon built from
that commit. Raw output is per leg in the out-dir (<leg>/out.txt,
<leg>/results/**/checks.json), and failures.txt has one line per failed
check. Columns before 64f3471 put mcpx’s test fake (one tool, echo) behind
the daemon; 64f3471 is that setup re-measured as the “before” of this
change; the last column is the fixture in pass-through.
| leg | 05c78b2 (first run) | 408bc2b | 60068c6 | 64f3471 (fake) | mcpx-fix2 (fixtures, pass-through) |
|---|---|---|---|---|---|
server --spec-version 2025-03-26 | not run | not run | not run | not run | 3 passed, 0 failed |
server --spec-version 2025-06-18 | not run | not run | not run | not run | 56 / 0 |
server --requirements 2025-11-25 | 47 passed, 19 failed | 45 / 21 | 45 / 21 | 45 / 21 | 80 / 0 |
server --requirements 2026-07-28 | 60 / 104 | 117 / 54 | 109 / 62 | 110 / 62 | 195 / 0 |
server --suite all | 84 / 106 | 136 / 61 | 128 / 67 | 131 / 67 | 229 / 0 |
client --spec-version 2025-03-26 | not run | not run | not run | not run | 0 / 7 (both scenarios are OAuth) |
client --spec-version 2025-06-18 | not run | not run | not run | not run | 3 / 9 |
client --requirements 2025-11-25 | 5 / 64 | 20 / 56 | 20 / 48 | 20 / 56 | 20 / 56 |
client --requirements 2026-07-28 | 23 / 84 | 62–63 / 68–69 | 63 / 59 | 63 / 68 | 63 / 68 |
No server check fails, in any leg. The --suite all leg needed one thing
beyond the fixtures: its daemon fronts the upstream with protocol: follow,
so a caller on a pre-2026 revision gets a legacy upstream session and the
fixture’s server-to-client requests (elicitation, sampling) reach it. Under
the default prefer-discover those four scenarios fail, because a 2026-07-28 upstream
session cannot carry a request to its client. See docs/spec/era-probe.md.
Every client failure is an OAuth scenario under the suite’s auth/; mcpx has
no OAuth client (#253). The workflow holds each client leg to exactly these
counts with nothing failing outside auth/.
Totals moved by more than the failures did, because the fixture makes the suite run checks it skipped before (a scenario stops at its first missing fixture). Read the failure lists, not the totals.
What running against a real fixture found in mcpx
Each is fixed, with a test that fails without the fix:
- An upstream’s question never reached the client once two calls shared an
instance. A question raised by a 2026-07-28 upstream was attributed to the
waiting call by
(server, instance)alone; with a second call in flight on the instance – one earlier client that never resumed was enough – it was left to the broker, and the request hung until the suite’s timeout (“This operation was aborted”, 12input-required-result-*checks). It is now attributed through the call’s own context (internal/daemon/routes_proto.goaskFor;TestAQuestionOnACallsContextIsThatCalls). - Several questions in one round reached the client one round at a time,
renamed to mcpx’s ids. An upstream’s
inputRequestsare now answered concurrently and relayed together under the upstream’s own keys (mcpclient.resolveInput,mcpserver.wireKey;TestInputRequestsOfOneRoundAreAskedTogetherUnderTheirKeys,TestInputRequestKeysKeepTheUpstreamsNames). - Two rounds of one call got the same
requestStatewhen minted within a second (sep-2322-multi-round-r2); the state now carries a nonce (TestRequestStateDiffersPerRound). resources/listcarried every resource template as a resource with an empty URI (resources-list: “Resource 0: missing uri”); before pass-through it readmcpx://<ns>/, which no check caught (TestResourcesListOmitsTemplates).- A template’s
{variables}were percent-encoded by themcpx://rewrite, turning…/{id}into a fixed URI (TestResourcesListOmitsTemplates). resources/templates/liston a cold daemon listed no server’s templates: it read the schema cache without filling it, asresources/listdoes (TestTemplatesListedColdIncludeTheServers).- A resource read answered on the ask path came back as
mcpx://mcpx://<server>//<uri>(TestAskPathResourceURIsAreNamespacedOnce).
And the pass-through mode itself: TestPassthroughExposesOneUpstreamUnrenamed,
TestPassthroughToolQuestionsReachTheClient.
Unknown tool is -32602 (#284) on every revision mcpx serves:
TestUnknownToolIsAProtocolError (in-process, 2025-11-25 and 2026-07-28) and
TestUnknownToolIsAProtocolErrorOverHTTP (over /mcp, each of 2024-11-05,
2025-03-26, 2025-06-18, 2025-11-25 through its own session, and 2026-07-28),
both in internal/mcpserver, which CI’s go test ./... runs.
Client-leg flake, fixed in the adapter
elicitation-sep1034-client-defaults passed alone and failed in the parallel
--requirements run: the adapter looked for pending questions by running
mcpx elicit list every 200 ms, a process start per tick, and under load a
tick outlived the scenario. It now reads the broker’s store in process every
20 ms and answers there (officialclient answerElicitations). Three
consecutive client runs: 20/56 and 63/68 each time, every non-auth/*
scenario passing in all three.
What the remaining failures are
Server, 2025-11-25 (3)
| scenario / check | message | kind |
|---|---|---|
tools-call-with-logging | No log notifications received | mcpx defect: no relay of upstream notifications/message (#212) |
tools-call-with-progress | No progress notifications received | mcpx defect: no relay of upstream notifications/progress (#212) |
server-sse-polling / scenario-timeout | did not complete within 30000ms | open: the fixture closes the POST stream to mcpx (SEP-1699); mcpx reconnects with Last-Event-ID (the fixture logs it) and no replayed response arrives, so the call never finishes. Not scored for 2025-11-25 (pending); root cause not established |
Server, 2026-07-28 (12 failed, 2 warnings, with taskmcp fronted)
Every tasks-extension check passes. Before taskmcp, 27 of them failed as
no tool named "…" or as untestable downstream of one.
| scenario / check | message | kind |
|---|---|---|
tools-call-with-progress | No progress notifications received | mcpx defect (#212) |
server-stateless / sep-2575-http-server-no-independent-requests-on-stream, sep-2575-server-no-log-without-loglevel | no frames from the streaming / logging tool | mcpx defect: same relay gap (#212) |
server-stateless / sep-2575-server-sends-prompts-list-changed-on-subscription (WARNING) | no notifications/prompts/list_changed on the listen stream | mcpx gap: an upstream’s list change on its listen stream is not subscribed to and relayed |
server-stateless / sep-2575-server-rejects-undeclared-capability, sep-2575-missing-capability-http-400 | executed test_missing_capability although the client did not declare sampling | mcpx defect: the upstream sees mcpx’s own client capabilities, not the calling client’s; a pass-through call should carry the caller’s |
input-required-result-basic-list-roots / sep-2322-list-roots-incomplete; input-required-result-multiple-input-requests / sep-2322-multiple-inputs-incomplete (2 of 3 asked) | no roots/list inputRequest | decision to revisit: mcpx answers an upstream’s roots/list from its own configured roots and never relays it |
input-required-result-ignore-extra-params / sep-2322-ignore-unexpected-params (WARNING) | no complete result | mcpx gap: a request carrying inputResponses without a requestState is treated as a new call; the fixture accepts such answers directly, mcpx does not forward them |
Server, --suite all (17)
The 2026-07-28 list, plus five legacy-tool checks that only the 2025-11-25
daemon can pass: tools-call-elicitation, tools-call-sampling,
elicitation-sep1034-defaults, elicitation-sep1330-enums (“Server did not
request … from client”) and tools-call-with-logging. --suite all runs
against the 2026-07-28 daemon, and over 2026-07-28 the fixture itself answers
its legacy tools’ pushed requests -32601.
Client, both sets
Every remaining failure is auth/*: mcpx has no OAuth client (#253), and its
per-server auth block is parsed and never applied (#240).
What the suite does not see
mcpx serveover stdio: no stdio server mode in the suite.- Legacy revisions other than 2025-11-25: there are no requirement sets for
them.
internal/mcpserver/requirements_test.goand the schema sweep cover 2024-11-05 through 2025-06-18.
The per-requirement status, with the test that verifies each row, is conformance-matrix.md.
MCP normative requirements, every revision, both sides
WP6 output 1. Merged from five area sweeps (wp6-raw/A..E.md, which keep the per-area source lists, conventions and implied-details notes). Sources: prose of every page under docs/specification/<rev>/, schema/<rev>/schema.ts doc comments, docs/extensions/tasks/overview.mdx.
Columns: id | revs (a range a..b when unchanged) | side | level | area | quote | URL | applies to mcpx as server | as client | changes (non-empty = the obligation differs in another revision or is contradicted elsewhere; the full list is revision-conflicts.md).
Conventions carried from the sweeps: level (schema) / (implied) / (lowercase) / (changelog) qualifiers mark where the keyword came from; side also uses auth-server and intermediary where the spec addresses them. The two mcpx columns are a reviewer’s notes, not a status: the status of each requirement is conformance-matrix.md, computed from the tests. A note containing the word gap (any case) claims mcpx does not meet the requirement; TestMatrix (internal/conformance) fails when a cell carrying that claim has a covering test, so a note cannot keep claiming a gap after the fix lands, nor a test pass while a note says it should not. Anchors are heading slugs derived by hand, not checked against the live site. 2024-11-05 and 2025-03-26 have no schema page; schema-only rows for them link GitHub schema.ts.
Counts
788 rows (815 before de-duplication; 27 cross-area duplicates folded, listed at the end). 418 rows carry a changes note.
A row counts in every revision its range covers.
| revision | total | auth-server | both | client | intermediary | server |
|---|---|---|---|---|---|---|
| 2024-11-05 | 183 | 0 | 63 | 59 | 0 | 61 |
| 2025-03-26 | 253 | 0 | 85 | 80 | 0 | 88 |
| 2025-06-18 | 292 | 2 | 93 | 95 | 0 | 102 |
| 2025-11-25 | 464 | 3 | 166 | 140 | 0 | 155 |
| 2026-07-28 | 556 | 3 | 123 | 183 | 3 | 244 |
Most 2025-11-25 growth is the tasks page (57 rows) and the elicitation rewrite; most 2026-07-28 growth is discover, versioning, MRTR, subscriptions, caching and the transport rewrite.
Lifecycle, versioning, messages, errors, _meta, JSON Schema, ping
| id | revs | side | level | area | quote | url | mcpx-as-server | mcpx-as-client | changes |
|---|---|---|---|---|---|---|---|---|---|
| messages-all-messages-follow-jsonrpc-2 | 2024-11-05..2026-07-28 | both | MUST | messages | “All messages between MCP clients and servers MUST follow the JSON-RPC 2.0 specification.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#messages | yes, except batches (see batch row) and parse-error id (see error-id row) | yes | 2024-11-05 also states it on basic/messages (“All messages in MCP MUST follow…”). “Follow JSON-RPC 2.0” imports batch support and id: null on parse errors, which MCP later narrows — see Conflicts C1, C4 |
| lifecycle-implementations-support-base-and-lifecycle | 2024-11-05..2025-11-25 | both | MUST | lifecycle | “All implementations MUST support the base protocol and lifecycle management components.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/index | yes | yes | Replaced in 2026-07-28 by next row |
| versioning-implementations-support-base-versioning-patterns | 2026-07-28 | both | MUST | versioning | “All implementations MUST support the base protocol, versioning, and the message patterns.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index | partial: request/response and MRTR yes; subscriptions/listen yes; versioning partial (discover shape wrong, see versioning rows) | partial: modern path exists; does not retry on -32022 | Lifecycle requirement dropped; “message patterns” includes MRTR and subscribe/notify |
| messages-other-components-may | 2024-11-05..2026-07-28 | both | MAY | messages | “Other components MAY be implemented based on the specific needs of the application.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index | yes | yes | |
| messages-request-id-string-or-integer | 2024-11-05..2026-07-28 | both | MUST | messages | “Requests MUST include a string or integer ID.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#requests | yes (server-initiated ids are negative integers) | yes (atomic int counter) | |
| messages-request-id-not-null | 2024-11-05..2026-07-28 | both | MUST NOT | messages | “Unlike base JSON-RPC, the ID MUST NOT be null.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#requests | yes | yes | |
| messages-request-id-unique-per-session | 2024-11-05..2025-11-25 | both | MUST NOT | messages | “The request ID MUST NOT have been previously used by the requestor within the same session.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/index#requests | yes (monotonic counter per Conn) | yes (monotonic counter per Client) | Relaxed in 2026-07-28 (next row). Conflict C2 |
| messages-request-id-unique-among-in-flight | 2026-07-28 | both | MUST NOT | messages | “The request ID MUST NOT match the ID of any other request the sender has issued and not yet received a response for.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#requests | n/a (modern server sends no requests) | yes | Reuse after response becomes legal; changelog item 9 still says re-issue “with a new request ID” — Conflict C2 |
| messages-requests-client-to-server-only | 2026-07-28 | both | (definitional) | messages | “Requests are sent from the client to the server, to initiate an operation.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#requests | n/a on modern path; on legacy path mcpx still sends elicitation/sampling requests (allowed there) | yes: answers ping/roots/elicitation/sampling requests from legacy servers | 2024-11-05..2025-11-25: “from the client to the server or vice versa”. Normative MUST NOT is in patterns row below |
| messages-servers-must-not-initiate-requests | 2026-07-28 | server | MUST NOT | messages | “Servers MUST NOT initiate JSON-RPC requests, and clients do not send JSON-RPC responses.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/index | yes on modern requests (input_required instead); legacy connections still get wire requests | n/a | New. Legal-then-forbidden: every legacy server-initiated request (ping, sampling, roots, elicitation). Conflict C3 |
| messages-response-same-id | 2024-11-05..2025-06-18 | both | MUST | messages | “Responses MUST include the same ID as the request they correspond to.” | https://modelcontextprotocol.io/specification/2025-06-18/basic/index#responses | yes for requests; no for parse errors (id omitted) | yes | Split in 2025-11-25 into result/error rows; error ID exception added. Conflict C4 |
| messages-response-result-xor-error | 2024-11-05..2025-06-18 | both | MUST / MUST NOT | messages | “Either a result or an error MUST be set. A response MUST NOT set both.” | https://modelcontextprotocol.io/specification/2025-06-18/basic/index#responses | yes (Result is omitempty: a nil result would violate; not observed on handled paths) | yes | Re-expressed as two shapes in 2025-11-25 |
| messages-result-response-same-id | 2025-11-25..2026-07-28 | both | MUST | messages | “Result responses MUST include the same ID as the request they correspond to.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#result-responses | yes | yes | |
| messages-result-response-has-result | 2025-11-25..2026-07-28 | both | MUST | messages | “Result responses MUST include a result field.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#result-responses | yes | yes | |
| messages-result-any-object | 2025-03-26..2026-07-28 | both | MAY | messages | “The result MAY follow any JSON object structure.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#result-responses | yes | yes | 2024-11-05: lowercase “Results can follow any JSON object structure” (basic/index) |
| messages-error-response-same-id-unless-unreadable | 2025-11-25..2026-07-28 | both | MUST | errors | “Error responses MUST include the same ID as the request they correspond to (except in error cases where the ID could not be read due a malformed request).” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-responses | yes (parse error omits id — permitted here by schema id?) | yes | Schema JSONRPCErrorResponse.id became optional in 2025-11-25; before, JSONRPCError.id: RequestId was required. Conflict C4 |
| errors-error-has-code-and-message | 2024-11-05..2026-07-28 | both | MUST | errors | “Error responses MUST include an error field with a code and message.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-responses | yes | yes | 2024-11-05 lowercase “errors must include an error code and message at minimum”; 2025-03-26..2025-06-18 “errors MUST include an error code and message at minimum” |
| errors-codes-integers | 2024-11-05..2026-07-28 | both | MUST | errors | “Error codes MUST be integers.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-responses | yes | yes | |
| errors-message-concise-single-sentence | 2024-11-05..2026-07-28 | both | SHOULD | errors | “The message SHOULD be limited to a concise single sentence.” (schema) | https://modelcontextprotocol.io/specification/2026-07-28/schema#error | partly: some messages embed Go error strings | n/a (sends no errors except to server requests) | |
| errors-data-may | 2026-07-28 | both | MAY | errors | “Error responses MAY include a data member with additional information of any type” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-responses | yes | n/a | Earlier revisions: schema data?: unknown, no prose |
| errors-standard-jsonrpc-codes | 2026-07-28 | both | (definitional) | errors | “MCP uses the standard JSON-RPC 2.0 error codes (-32700, -32600 to -32603) for general protocol failures.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes | yes | n/a | |
| errors-legacy-subrange-no-new-codes | 2026-07-28 | both | MUST NOT / SHOULD NOT | errors | “New codes MUST NOT be allocated in this sub-range, and new implementations SHOULD NOT use codes from this sub-range at all.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes | yes (uses none in -32000..-32019) | yes | Schema comment calls the sub-range “implementation-defined”, not “legacy” — Conflict C10 |
| errors-receivers-no-meaning-for-legacy-subrange | 2026-07-28 | both | MUST NOT | errors | “Apart from -32002 (see below), receivers MUST NOT assume any specific meaning for these codes.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes | yes | yes (treats as opaque upstream errors) | Legacy SDK codes (e.g. -32001 request timeout in TS SDK) lose any shared meaning |
| errors-reserved-subrange-only-spec-codes | 2026-07-28 | both | MUST NOT / MUST | errors | “Implementations MUST NOT emit any code from this sub-range that is not defined by this specification and MUST use defined codes only with their specified meanings.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes | yes (-32022 only, and with its meaning; also emits -32022 to legacy initialize — see negotiation rows) | n/a | 2025-11-25 used -32042 (URL elicitation) in this sub-range; 2026 draft history renumbered -32001/-32003/-32004 → -32020/-32021/-32022 |
| errors-do-not-emit-32002-and-32042 | 2026-07-28 | both | MUST NOT | errors | “Implementations of this protocol version MUST NOT emit these codes:” (-32002, -32042) | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes | yes (mcpx emits neither) | n/a | Both were legal (and -32002 the specified code) in earlier revisions. Conflict C11 |
| errors-client-accept-32002-from-legacy | 2026-07-28 | client | SHOULD | errors | “Clients SHOULD still accept -32002 from servers implementing earlier versions.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes | n/a | yes (passes upstream errors through) | |
| errors-local-errors-not-mistaken-for-peer | 2026-07-28 | both | should (lowercase) | errors | “Implementations surfacing local errors in JSON-RPC-shaped structures should ensure they cannot be mistaken for errors received from the peer.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes | partly: proxy re-emits upstream failures as its own JSON-RPC errors / isError results; origin is text-only | n/a | Not RFC 2119 (lowercase) but matters for a proxy: mcpx is exactly the case of relaying local vs peer errors |
| errors-new-codes-outside-jsonrpc-range | 2026-07-28 | both | SHOULD | errors | “New error codes for purposes not defined by this specification SHOULD be allocated outside the JSON-RPC reserved range (-32768 to -32000)” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes | yes (defines none) | n/a | |
| errors-method-not-found-for-unadvertised-capability | 2026-07-28 | server | (schema guidance) | errors | “a server returns this error when a client invokes a method the server does not implement — either a genuinely unknown method, or one gated behind a server capability the server did not advertise” (schema) | https://modelcontextprotocol.io/specification/2026-07-28/schema#methodnotfounderror | no: mcpx answers methods it did not advertise for that revision (accept-liberally, e.g. resources/subscribe from modern) | n/a | Directly opposes mcpx’s “accept liberally” rule for gated methods; wording is descriptive, not RFC keyword |
| messages-notification-no-response | 2025-03-26..2026-07-28 | both | MUST NOT | messages | “The receiver MUST NOT send a response.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#notifications | yes (handle returns nil for notifications; HTTP 202) | yes | Implicit from JSON-RPC in 2024-11-05 |
| messages-notification-no-id | 2024-11-05..2026-07-28 | both | MUST NOT | messages | “Notifications MUST NOT include an ID.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#notifications | yes | yes | |
| messages-batch-may-send-must-receive | 2025-03-26 | both | MAY / MUST | messages | “MCP implementations MAY support sending JSON-RPC batches, but MUST support receiving JSON-RPC batches.” | https://modelcontextprotocol.io/specification/2025-03-26/basic/index#batching | no: stdio server unmarshals each line into one request object; an array gets -32700 | yes on HTTP responses (mcpclient/http.go accepts a batch array); stdio not checked | Removed in 2025-06-18 (changelog “Remove support for JSON-RPC batching”). Conflict C1 |
| lifecycle-initialize-not-in-batch | 2025-03-26 | client | MUST NOT | lifecycle | “The initialize request MUST NOT be part of a JSON-RPC batch” | https://modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle#initialization | n/a | yes (never batches) | Only meaningful in 2025-03-26 |
| lifecycle-initialization-first-interaction | 2024-11-05..2025-11-25 | both | MUST | lifecycle | “The initialization phase MUST be the first interaction between client and server.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization | not enforced: requests before/without initialize are served as 2025-03-26 (accept-liberally) | yes (legacy mode sends initialize first) | Removed in 2026-07-28 (no handshake); a dual-era server cannot enforce it because modern requests have no initialize |
| lifecycle-client-sends-initialize | 2024-11-05..2025-11-25 | client | MUST | lifecycle | “The client MUST initiate this phase by sending an initialize request containing:” version, capabilities, implementation info | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization | n/a | yes | Removed 2026-07-28 |
| lifecycle-server-responds-caps-and-info | 2024-11-05..2025-11-25 | server | MUST | lifecycle | “The server MUST respond with its own capabilities and information” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization | yes (capabilities per negotiated revision, serverInfo, instructions) | n/a | Removed 2026-07-28 |
| lifecycle-client-sends-initialized-after-init | 2024-11-05..2025-11-25 | client | MUST | lifecycle | “After successful initialization, the client MUST send an initialized notification” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization | n/a (accepts notifications/initialized and bare initialized, does not require it) | yes | Removed 2026-07-28 |
| lifecycle-client-no-requests-before-init-response | 2024-11-05..2025-11-25 | client | SHOULD NOT | lifecycle | “The client SHOULD NOT send requests other than pings before the server has responded to the initialize request.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization | n/a | yes | 2026 dual-era clients send server/discover before initialize as a probe — legal only because that probe precedes the decision to be legacy |
| lifecycle-server-no-requests-before-initialized | 2024-11-05..2025-11-25 | server | SHOULD NOT | lifecycle | “The server SHOULD NOT send requests other than pings and logging before receiving the initialized notification.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization | yes (asks only inside a client call, which follows initialize) | n/a | “logging” is a notification, not a request — Conflict C8 |
| lifecycle-client-sends-supported-version | 2024-11-05..2025-11-25 | client | MUST | versioning | “In the initialize request, the client MUST send a protocol version it supports.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation | n/a | yes (2025-11-25) | |
| lifecycle-client-sends-latest-version | 2024-11-05..2025-11-25 | client | SHOULD | versioning | “This SHOULD be the latest version supported by the client.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation | n/a | yes (latest legacy; 2026-07-28 cannot be sent over initialize) | |
| lifecycle-client-may-support-older | 2024-11-05..2025-11-25 | client | MAY | versioning | “The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well.” (schema) | https://modelcontextprotocol.io/specification/2025-11-25/schema#initializerequestparams | n/a | yes (accepts any version back — see client-disconnect row) | |
| lifecycle-server-echoes-supported-version | 2024-11-05..2025-11-25 | server | MUST | versioning | “If the server supports the requested protocol version, it MUST respond with the same version.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation | yes (legacy versions echoed) | n/a | |
| lifecycle-server-counteroffers-version | 2024-11-05..2025-11-25 | server | MUST | versioning | “Otherwise, the server MUST respond with another protocol version it supports.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation | no: negotiate() returns “” and mcpx answers -32022 with the legacy list (e.g. a 2024-11-05 client, or 2026-07-28 over initialize) instead of a result carrying another version | n/a | The page’s own example answers with an error (-32602) instead — Conflict C5. mcpx’s -32022 is a 2026-only code sent on a legacy exchange |
| lifecycle-server-counteroffers-latest | 2024-11-05..2025-11-25 | server | SHOULD | versioning | “This SHOULD be the latest version supported by the server.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation | no (no counter-offer at all) | n/a | |
| lifecycle-client-disconnects-on-unsupported-version | 2024-11-05..2025-11-25 | client | SHOULD (prose) / MUST (schema) | versioning | prose: “If the client does not support the version in the server’s response, it SHOULD disconnect.” schema: “If the client cannot support this version, it MUST disconnect.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation | n/a | no: initializeLegacy records ir.ProtocolVersion as Negotiated without checking it (a 2024-11-05 or unknown answer is accepted) | Prose vs schema — Conflict C6 |
| lifecycle-http-protocol-version-header | 2025-06-18..2025-11-25 | client | MUST | versioning | “If using HTTP, the client MUST include the MCP-Protocol-Version: <protocol-version> HTTP header on all subsequent requests to the MCP server.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation | n/a | not verified here (transport owner) | 2026-07-28: header on every request, must match _meta (transport area) |
| lifecycle-capabilities-establish-session-features | 2024-11-05..2025-11-25 | both | (definitional) | lifecycle | “Client and server capabilities establish which optional protocol features will be available during the session.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#capability-negotiation | yes (per-Conn caps recorded at initialize) | yes | 2026: per-request clientCapabilities; server caps via server/discover |
| lifecycle-respect-negotiated-version-and-capabilities-should | 2024-11-05..2025-03-26 | both | SHOULD | lifecycle | “Both parties SHOULD: Respect the negotiated protocol version; Only use capabilities that were successfully negotiated” | https://modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle#operation | yes (downgrade(), capability gates) | yes | Upgraded to MUST in 2025-06-18 (changelog item 9) |
| lifecycle-respect-negotiated-version-and-capabilities-must | 2025-06-18..2025-11-25 | both | MUST | lifecycle | “Both parties MUST: Respect the negotiated protocol version; Only use capabilities that were successfully negotiated” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#operation | partly: sends only negotiated shapes; accepts un-negotiated methods (tasks/*, subscriptions/listen) by design — receiving is not “using” | partly: only uses server caps it saw; Complete gated on completions | Conflict C7 (accept-liberally vs MUST). 2026: architecture “Both parties must respect declared capabilities throughout the interaction” (lowercase) |
| lifecycle-stdio-client-shutdown-sequence | 2024-11-05..2025-11-25 | client | SHOULD | lifecycle | “the client SHOULD initiate shutdown by: 1. First, closing the input stream to the child process … 2. … SIGTERM … 3. Sending SIGKILL” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#stdio | n/a | yes (mcpclient/stdio.go closes stdin, waits with timeout, then kills) — ordering beyond that not verified | Moved to transports/stdio in 2026-07-28 (transport area) |
| lifecycle-stdio-server-may-exit | 2024-11-05..2025-11-25 | server | MAY | lifecycle | “The server MAY initiate shutdown by closing its output stream to the client and exiting.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#stdio | yes (exits on stdin EOF) | yes (treats Recv error as connection failure) | |
| lifecycle-http-shutdown-closing-connection | 2024-11-05..2025-11-25 | both | (definitional) | lifecycle | “For HTTP transports, shutdown is indicated by closing the associated HTTP connection(s).” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#http | yes (+ DELETE drops session) | yes | |
| lifecycle-prepared-for-error-cases | 2024-11-05..2025-11-25 | both | SHOULD | lifecycle | “Implementations SHOULD be prepared to handle these error cases:” (version mismatch, capability failure, request timeouts) | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#error-handling | partly | partly: version mismatch not handled (see disconnect row) | 2024-11-05 list: “Initialize request timeout”, “Shutdown timeout”; 2025-03-26+: “Request timeouts” |
| lifecycle-timeouts-all-requests-2024 | 2024-11-05 | both | SHOULD | lifecycle | “Implementations SHOULD implement appropriate timeouts for all requests, to prevent hung connections and resource exhaustion.” | https://modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle#error-handling | n/a (2024-11-05 not served) | yes (pool call timeout, HTTP client timeout) | Replaced by the Timeouts section in 2025-03-26 |
| lifecycle-timeouts-establish | 2025-03-26..2025-11-25 | both | SHOULD | lifecycle | “Implementations SHOULD establish timeouts for all sent requests, to prevent hung connections and resource exhaustion.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts | yes (proto.askTimeout on server-initiated requests) | yes | 2026-07-28 moves the same text to basic/patterns/cancellation#timeouts (cancellation owner) |
| lifecycle-timeouts-cancel-on-expiry | 2025-03-26..2025-11-25 | both | SHOULD | lifecycle | “the sender SHOULD issue a cancellation notification for that request and stop waiting for a response.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts | not verified | no: mcpclient/http.go comment records cancellation was made best-effort then removed | 2026 wording: “the sender SHOULD cancel the request” (cancellation page) |
| lifecycle-timeouts-per-request-configurable | 2025-03-26..2025-11-25 | both | SHOULD | lifecycle | “SDKs and other middleware SHOULD allow these timeouts to be configured on a per-request basis.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts | partly (global settings) | yes (CallTimeout) | mcpx is “other middleware” |
| lifecycle-timeouts-may-reset-on-progress | 2025-03-26..2025-11-25 | both | MAY | lifecycle | “Implementations MAY choose to reset the timeout clock when receiving a progress notification” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts | n/a | not verified | |
| lifecycle-timeouts-max-regardless-of-progress | 2025-03-26..2025-11-25 | both | SHOULD | lifecycle | “implementations SHOULD always enforce a maximum timeout, regardless of progress notifications” | https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts | yes (askTimeout, askRounds) | yes | |
| lifecycle-cancel-not-initialize | 2024-11-05..2025-11-25 | client | MUST NOT | lifecycle | “A client MUST NOT attempt to cancel its initialize request.” (schema) | https://modelcontextprotocol.io/specification/2025-11-25/schema#cancellednotification | n/a | yes | Gone in 2026 (no initialize) |
| lifecycle-instructions-may-go-in-system-prompt | 2024-11-05..2025-11-25 | client | MAY | lifecycle | “For example, this information MAY be added to the system prompt.” (schema, InitializeResult.instructions) | https://modelcontextprotocol.io/specification/2025-11-25/schema#initializeresult | yes (sends instructions) | yes (stores Instructions) | 2026: DiscoverResult.instructions, no RFC keyword. Field appears in 2024-11-05 schema although the 2024-11-05 lifecycle example lacks it (example added 2025-03-26) |
| lifecycle-capability-sets-open | 2024-11-05..2026-07-28 | both | (definitional) | lifecycle | “Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities.” (schema) | https://modelcontextprotocol.io/specification/2026-07-28/schema#clientcapabilities | yes (tolerates unknown keys) | yes | 2026 adds extensions map; tasks leaves core caps (2025-11-25 only) |
| lifecycle-implementation-name-version-required | 2024-11-05..2026-07-28 | both | REQUIRED (schema field) | lifecycle | name: string; version: string required on Implementation | https://modelcontextprotocol.io/specification/2026-07-28/schema#implementation | yes | yes | title added 2025-06-18; icons, description, websiteUrl added 2025-11-25. mcpx sends none of the optional fields |
| lifecycle-include-context-only-if-declared | 2025-11-25..2026-07-28 | server | SHOULD | lifecycle | “If not declared, servers SHOULD only use includeContext: \"none\" (or omit it).” (schema, ClientCapabilities.sampling.context) | https://modelcontextprotocol.io/specification/2026-07-28/schema#clientcapabilities | yes (passes upstream sampling through; does not add includeContext) | n/a | Sampling-area detail located in capability schema |
| ping-receiver-responds-promptly-empty | 2024-11-05..2025-11-25 | both | MUST | ping | “The receiver MUST respond promptly with an empty response” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#behavior-requirements | yes ({}; plus resultType if request had modern _meta) | yes (answer() returns {}) | Removed in 2026-07-28 (changelog item 5; absent from schema). Conflict C3 |
| ping-either-party-may-initiate | 2024-11-05..2025-11-25 | both | (definitional) | ping | “Either the client or server can initiate a ping by sending a ping request.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#overview | mcpx never pings its clients | Client.Ping exists but no periodic caller found | |
| ping-sender-may-consider-stale | 2024-11-05..2025-11-25 | both | MAY | ping | “If no response is received within a reasonable timeout period, the sender MAY: Consider the connection stale; Terminate the connection; Attempt reconnection procedures” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#behavior-requirements | n/a | n/a (does not ping) | |
| ping-periodic-pings | 2024-11-05..2025-11-25 | both | SHOULD | ping | “Implementations SHOULD periodically issue pings to detect connection health” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#implementation-considerations | no (not-implemented-by-choice: HTTP is per-request, stdio exits on EOF) | no (not-implemented-by-choice; pool detects dead processes on use) | Uncertain whether a pool health check uses ping — not found in grep |
| ping-frequency-configurable | 2024-11-05..2025-11-25 | both | SHOULD | ping | “The frequency of pings SHOULD be configurable” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#implementation-considerations | n/a | n/a | |
| ping-timeouts-appropriate | 2024-11-05..2025-11-25 | both | SHOULD | ping | “Timeouts SHOULD be appropriate for the network environment” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#implementation-considerations | n/a | n/a | |
| ping-avoid-excessive | 2024-11-05..2025-11-25 | both | SHOULD | ping | “Excessive pinging SHOULD be avoided to reduce network overhead” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#implementation-considerations | yes | yes | |
| ping-timeout-is-connection-failure | 2024-11-05..2025-11-25 | both | SHOULD | ping | “Timeouts SHOULD be treated as connection failures” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#error-handling | n/a | n/a | |
| ping-multiple-failures-may-reset | 2024-11-05..2025-11-25 | both | MAY | ping | “Multiple failed pings MAY trigger connection reset” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#error-handling | n/a | n/a | |
| ping-log-failures | 2024-11-05..2025-11-25 | both | SHOULD | ping | “Implementations SHOULD log ping failures for diagnostics” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#error-handling | n/a | n/a | |
| ping-schema-receiver-must-respond | 2024-11-05..2025-11-25 | both | must (lowercase) | ping | “The receiver must promptly respond, or else may be disconnected.” (schema) | https://modelcontextprotocol.io/specification/2025-11-25/schema#pingrequest | yes | yes | |
| meta-reserved-for-mcp | 2024-11-05..2025-03-26 | both | (definitional) | meta | “This result property is reserved by the protocol to allow clients and servers to attach additional metadata to their responses.” (schema) | GH(2025-03-26) Result._meta | yes | yes | No key-format rules before 2025-06-18; progressToken the only named key |
| meta-no-assumptions-on-reserved-keys | 2025-06-18..2026-07-28 | both | MUST NOT | meta | “implementations MUST NOT make assumptions about values at these keys.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | yes | yes | 2025-06-18/2025-11-25: “MUST NOT” not bolded, same force |
| meta-prefix-label-format | 2025-06-18..2026-07-28 | both | MUST | meta | “If specified, MUST be a series of labels separated by dots (.), followed by a slash (/).” / “Labels MUST start with a letter and end with a letter or digit” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | yes (only io.modelcontextprotocol/*) | yes | |
| meta-reserved-prefix-2025-06-18 | 2025-06-18 | both | (reservation) | meta | “Any prefix beginning with zero or more valid labels, followed by modelcontextprotocol or mcp, followed by any valid label, is reserved for MCP use.” | https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta | n/a | n/a | Replaced 2025-11-25 by reverse-DNS rule; the two rules reserve different sets — Conflict C9 |
| meta-reverse-dns-should | 2025-11-25..2026-07-28 | both | SHOULD | meta | “Implementations SHOULD use reverse DNS notation (e.g., com.example/ rather than example.com/).” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | yes | yes | |
| meta-reserved-prefix-second-label | 2025-11-25..2026-07-28 | both | (reservation) | meta | “Any prefix where the second label is modelcontextprotocol or mcp is reserved for MCP use.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | yes | yes | Conflict C9 |
| meta-name-format | 2025-06-18..2026-07-28 | both | MUST / MAY | meta | “Unless empty, MUST begin and end with an alphanumeric character ([a-z0-9A-Z]).” / “MAY contain hyphens (-), underscores (_), dots (.), and alphanumerics in between.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | yes | yes | |
| meta-schema-may-reserve-names | 2025-06-18..2026-07-28 | both | (reservation) | meta | “definitions in the schema may reserve particular names for purpose-specific metadata” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | yes | yes | 2026 page drops this sentence from prose; retained in schema MetaObject comment |
| meta-progress-token-receiver-not-obligated | 2024-11-05..2026-07-28 | both | (definitional) | meta | “The receiver is not obligated to provide these notifications.” (schema, _meta.progressToken) | https://modelcontextprotocol.io/specification/2026-07-28/schema#requestmetaobject | yes | yes | Unprefixed reserved key — exception to prefix rule, never stated as such (only traceparent etc. are called an “exception”) |
| meta-request-params-meta-required | 2026-07-28 | client | MUST / REQUIRED | meta | “fields marked as required MUST be included on every request.” (protocolVersion, clientCapabilities) | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | n/a | yes (withMeta on every modern request) | Schema makes RequestParams._meta non-optional, hence every request needs params — Conflict C12 |
| meta-server-rejects-missing-required-field | 2026-07-28 | server | MUST | meta | “A request missing any required field is malformed; the server MUST reject it with JSON-RPC error code -32602 (Invalid params). On HTTP, the response status MUST be 400 Bad Request.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | no (not-implemented-by-choice: a request with no _meta is treated as legacy / 2025-03-26, which a dual-era server has to do) | n/a | Conflicts with versioning matrix “exact code is implementation-defined” for initialize — Conflict C13 |
| meta-client-should-send-clientinfo | 2026-07-28 | client | SHOULD | meta | “Clients SHOULD include io.modelcontextprotocol/clientInfo on every request unless specifically configured not to do so.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | n/a | yes | |
| meta-server-no-undeclared-client-capabilities | 2026-07-28 | server | MUST NOT | meta | “A server MUST NOT rely on capabilities the client has not declared.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | yes (Peer.CanElicit/CanSample read per-request caps) | n/a | Legacy analogue is lifecycle “Only use capabilities that were successfully negotiated” |
| meta-server-no-infer-caps-from-prior | 2026-07-28 | server | MUST NOT | meta | “Servers MUST NOT infer capabilities from prior requests.” (schema) | https://modelcontextprotocol.io/specification/2026-07-28/schema#requestmetaobject | yes for modern requests; a request without _meta on a Conn that initialized falls back to Conn caps (legacy semantics) | n/a | |
| errors-missing-required-client-capability | 2026-07-28 | server | MUST | errors | “the server MUST return a MissingRequiredClientCapabilityError (-32021) whose data.requiredCapabilities lists the missing capabilities. On HTTP, the response status MUST be 400 Bad Request.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | n/a in practice: mcpx never requires a client capability; questions a client cannot answer go to the broker | n/a | |
| meta-server-should-send-serverinfo | 2026-07-28 | server | SHOULD | meta | “Servers SHOULD include the following io.modelcontextprotocol/* field in every result’s _meta” (serverInfo) | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | no: not stamped on results; server/discover puts serverInfo top-level instead of _meta | n/a | Replaces InitializeResult.serverInfo |
| meta-info-not-for-behavior-or-security | 2026-07-28 | both | SHOULD NOT | meta | “Implementations SHOULD NOT use them to change the behavior of the client or server, and SHOULD NOT rely on them for security decisions.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | yes (clientInfo not used for decisions, as far as read) | yes | Earlier revisions silent; legacy SDKs commonly branch on clientInfo.name |
| meta-subscription-id-on-listen-notifications | 2026-07-28 | server | MUST | meta | “the server MUST include io.modelcontextprotocol/subscriptionId in _meta” (listen-stream notifications) | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | no: id sent only in notifications/subscriptions/acknowledged params, not _meta of each pushed notification | n/a | Subscriptions owner should confirm |
| meta-otel-keys-w3c-format | 2026-07-28 | both | MUST | meta | “When present, their values MUST follow W3C Trace Context and W3C Baggage formats respectively.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | n/a (does not emit); passes through? not verified | n/a | Unprefixed exception to the prefix rule |
| meta-extension-ids-follow-meta-rules | 2026-07-28 | both | MUST | versioning | “Extension identifiers MUST follow the _meta key naming rules, with a mandatory prefix.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#extension-negotiation | yes (io.modelcontextprotocol/tasks) | n/a (declares no extensions) | |
| meta-icons-client-mime-support | 2025-11-25..2026-07-28 | client | MUST / SHOULD | meta | “Clients that support rendering icons MUST support at least the following MIME types:” png, jpeg; “SHOULD also support” svg, webp | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#icons | n/a | n/a (renders no icons) | |
| meta-icons-consumer-security | 2025-11-25..2026-07-28 | client | MUST | meta | “Consumers of icon metadata MUST take appropriate security precautions when handling icons to prevent compromise” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#icons | n/a | n/a (never fetches icons; passes metadata through) | |
| meta-icons-reject-unsafe-schemes | 2025-11-25..2026-07-28 | client | MUST | meta | “Clients MUST reject icon URIs that use unsafe schemes and redirects, such as javascript:, file:, ftp:, ws:, or local app URI schemes.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#icons | n/a | n/a (does not dereference). Downstream host must do it | |
| meta-icons-may-limit-size | 2025-11-25..2026-07-28 | client | MAY | meta | “Consumers MAY set limits for image and content size.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#icons | n/a | n/a | |
| meta-icons-same-origin | 2025-11-25..2026-07-28 | client | SHOULD | meta | “Consumers SHOULD take steps to ensure URLs serving icons are from the same domain as the client/server or a trusted domain.” (schema) | https://modelcontextprotocol.io/specification/2026-07-28/schema#icon | n/a | n/a | A proxy re-serving upstream icons changes “the server” origin — see Implied I12 |
| json-schema-default-2020-12 | 2025-11-25..2026-07-28 | both | (definitional) | json-schema | “When a schema does not include a $schema field, it defaults to JSON Schema 2020-12” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-dialect | yes (own schemas are dialect-neutral) | n/a | Earlier revisions: no dialect stated |
| json-schema-may-declare-dialect | 2025-11-25..2026-07-28 | both | MAY | json-schema | “Schemas MAY include a $schema field to specify a different dialect” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-dialect | yes (passes upstream $schema through) | yes | |
| json-schema-support-2020-12 | 2025-11-25..2026-07-28 | both | MUST | json-schema | “Clients and servers MUST support JSON Schema 2020-12 for schemas without an explicit $schema field” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements | not verified: mcpx does not appear to validate tool arguments against schemas (no validator in mcpserver) | n/a (passes through) | |
| json-schema-validate-per-dialect-and-error-on-unsupported | 2025-11-25..2026-07-28 | both | MUST | json-schema | “Clients and servers MUST validate schemas according to their declared or default dialect. They MUST handle unsupported dialects gracefully by returning an appropriate error” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements | no (no validation; a proxy forwarding opaque schemas arguably does not “handle” them) | no | Uncertain what “validate schemas” binds a pass-through proxy to |
| json-schema-document-dialects | 2025-11-25..2026-07-28 | both | SHOULD | json-schema | “Clients and servers SHOULD document which schema dialects they support” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#implementation-requirements | yes: documented in docs/schema-validation.md (Dialect policy table) | yes: documented in docs/schema-validation.md (Dialect policy table) | |
| json-schema-recommend-2020-12 | 2025-11-25..2026-07-28 | both | RECOMMENDED | json-schema | “Implementors are RECOMMENDED to use JSON Schema 2020-12.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-dialect | yes | n/a | |
| json-schema-schemas-valid | 2025-11-25..2026-07-28 | both | MUST | json-schema | “Schemas MUST be valid according to their declared or default dialect” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#schema-validation | yes for own tools (compacted literal schemas, test-checked); upstream schemas passed through unchecked | n/a | |
| json-schema-no-network-ref | 2026-07-28 | both | MUST NOT | json-schema | “Implementations MUST NOT automatically dereference $ref values that resolve to a network URI.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#ref-resolution | yes (never dereferences) | yes | New in 2026 (SEP-2106) |
| json-schema-remote-ref-opt-in-off-by-default | 2026-07-28 | both | MAY / MUST / SHOULD | json-schema | “Implementations MAY offer an opt-in mode that fetches non-local $refs but it MUST be disabled by default and SHOULD enforce an allowlist of hosts” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#ref-resolution | n/a | n/a | |
| json-schema-reject-unresolved-ref | 2026-07-28 | both | SHOULD | json-schema | “Schemas that fail to validate due to an unresolved external $ref SHOULD be rejected rather than silently treated as permissive.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#ref-resolution | no (no validation) | no | |
| json-schema-bound-composition-cost | 2026-07-28 | both | SHOULD | json-schema | “Implementations SHOULD apply reasonable bounds, such as a maximum schema depth, a cap on the total number of subschemas, or a per-validation time budget” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#composition-keyword-resource-use | yes: schemaval bounds schema documents at 1 MiB (MaxSchemaBytes) | yes: schemaval bounds schema documents at 1 MiB (MaxSchemaBytes) | |
| versioning-server-unsupported-version-error | 2026-07-28 | server | MUST | versioning | “it MUST respond with an UnsupportedProtocolVersionError listing the versions it does support” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#protocol-version-negotiation | yes (-32022, data.supported, data.requested) | n/a | |
| versioning-unsupported-version-http-400 | 2026-07-28 | server | MUST | errors | “For HTTP, the response status code MUST be 400 Bad Request.” (schema, UnsupportedProtocolVersionError) | https://modelcontextprotocol.io/specification/2026-07-28/schema#unsupportedprotocolversionerror | no: ServeHTTP writes every JSON-RPC response with 200 | n/a | Same MUST on HeaderMismatchError and MissingRequiredClientCapabilityError |
| errors-header-mismatch-http-400 | 2026-07-28 | server | MUST | errors | “For HTTP, the response status code MUST be 400 Bad Request.” (schema, HeaderMismatchError) | https://modelcontextprotocol.io/specification/2026-07-28/schema#headermismatcherror | partly: 400, but body is {"error": "..."} not a JSON-RPC error with -32020 | n/a | A dual-era client then cannot recognise it as a modern error and falls back to legacy — see I7 |
| versioning-client-retry-with-supported | 2026-07-28 | client | SHOULD | versioning | “The client SHOULD select a mutually supported version from the supported list and retry the request, or surface an error to the user if no compatible version exists.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#protocol-version-negotiation | n/a | no: isVersionError stops and returns the error; no retry from data.supported | |
| versioning-server-must-implement-discover | 2026-07-28 | server | MUST | versioning | “Servers MUST implement server/discover.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#protocol-version-negotiation | partial: answers, but with protocolVersions (spec: supportedVersions), serverInfo top-level (spec: _meta), no ttlMs/cacheScope | partial: parses protocolVersions, so a spec-conformant server’s supportedVersions is missed → “no shared protocol version” | Discover owner should re-check; mcpx client and server share the same wrong field name, so they interoperate with each other only |
| versioning-client-may-discover-first | 2026-07-28 | client | MAY | versioning | “Clients MAY call it before sending any other requests to learn the server’s supported versions up front, but are not required to” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#protocol-version-negotiation | n/a | yes (always does, in modern mode) | |
| versioning-extension-fallback-or-reject | 2026-07-28 | both | MUST | versioning | “the supporting party MUST either revert to core protocol behavior or reject the request with an appropriate error.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#extension-negotiation | yes for tasks: a task handle only when the client asked with task; otherwise core result | n/a | Tasks extension says servers may return handles unsolicited (changelog item 6) — tasks owner to reconcile |
| versioning-extensions-document-fallback | 2026-07-28 | both | SHOULD | versioning | “Extensions SHOULD document their expected fallback behavior.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#extension-negotiation | n/a | n/a | Binds extension authors |
| versioning-dual-era-server-may | 2026-07-28 | server | MAY | versioning | “A server that wishes to support both legacy clients … and modern clients … MAY implement both behaviors.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#backward-compatibility-with-initialization-based-versions | yes (dual-era) | n/a | |
| versioning-era-cache | 2026-07-28 | client | SHOULD / MAY | versioning | “Clients SHOULD cache the result for the lifetime of the server process (stdio) or origin (HTTP), and MAY persist it across restarts of the same server configuration, re-probing if the cached assumption later fails.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#backward-compatibility-with-initialization-based-versions | n/a | partly: Client.Era per connection (= per process on stdio); not cached per HTTP origin or across restarts (each pooled instance probes) | See I1 |
| versioning-modern-only-server-names-versions-to-initialize | 2026-07-28 | server | SHOULD | versioning | “A server that supports only modern versions SHOULD name the protocol versions it supports in any error it returns to an initialize request, on any transport” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#backward-compatibility-with-initialization-based-versions | n/a (dual-era); for an initialize naming an unsupported version it does name the legacy list | n/a | |
| versioning-modern-client-probe-legacy-stdio | 2026-07-28 | client | SHOULD | versioning | “On stdio, clients SHOULD send server/discover first to fail deterministically” (Modern client, Legacy server) | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#compatibility-matrix | n/a | n/a unless protocol: force-discover; then discover is first (yes) | Also stdio page: “probing is still RECOMMENDED” |
| versioning-dual-era-client-probe-discover-first-stdio | 2026-07-28 | client | SHOULD | versioning | “A client that supports both modern … and a legacy version … SHOULD probe with server/discover before sending any other request” (stdio page) | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#backward-compatibility | n/a | no, by choice: probes legacy initialize first (“nearly every server in existence is legacy”) | Versioning page defers to binding pages; the SHOULD lives in the transport page |
| versioning-fallback-not-keyed-to-one-code | 2026-07-28 | client | MUST NOT | versioning | “The fallback MUST NOT be keyed to one specific error code” (stdio page) | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#backward-compatibility | n/a | yes (falls back on any error except -32022) | mcpx inverts the probe order, so the rule applies to its legacy→modern fallback by analogy only |
| versioning-dual-era-http-detect | 2026-07-28 | client | MAY / SHOULD | versioning | “MAY detect which era the server implements by attempting a modern request first. On 400 Bad Request, the client SHOULD inspect the response body before falling back” (streamable-http page) | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility | n/a | n/a (legacy first) | Versioning matrix says “4xx”, binding page says “400” — Conflict C14 |
| versioning-dual-era-server-selects-by-opening | 2026-07-28 | server | (normative description) | versioning | “A request carrying modern per-request _meta is served statelessly according to this revision. An initialize request selects legacy semantics, scoped to the stdio process (stdio) or the session (HTTP)” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#compatibility-matrix | yes, plus a third case the spec does not define: no _meta and no initialize → served as 2025-03-26 | n/a | See I4 |
| versioning-dual-era-both-concurrently | 2026-07-28 | server | MAY | versioning | “A dual-era server MAY serve both eras concurrently on the same endpoint or process.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#compatibility-matrix | yes | n/a | |
| lifecycle-stateless-no-reliance-on-prior-requests | 2026-07-28 | server | MUST NOT | lifecycle | “Servers MUST NOT rely on prior requests over the same connection to establish context (e.g., capabilities, protocol version, client identity).” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#statelessness | yes for version/caps of modern requests | n/a | Opposite of legacy lifecycle, where the initialize context governs the whole session |
| lifecycle-stateless-multiple-conversations | 2026-07-28 | server | SHOULD | lifecycle | “Servers SHOULD be prepared to handle requests associated with multiple tasks, threads, or conversations.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#statelessness | yes | n/a | |
| lifecycle-stateless-no-connection-reuse-required | 2026-07-28 | server | SHOULD NOT | lifecycle | “Servers SHOULD NOT require that a client reuse the same connection or process to perform related operations.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#statelessness | no: requestState is HMAC-bound to the connection it was issued on, and a request with no session gets no token (questions go to the broker) | n/a | Deliberate security trade-off in mcpx (docs/protocol.md §3.3) — see I3 |
| lifecycle-stateless-client-process-lifetime | 2026-07-28 | client | SHOULD NOT | lifecycle | “Clients SHOULD NOT use an individual task, thread, or conversation as the lifetime boundary for the stdio process.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#statelessness | n/a | yes (pooled by scope key, kept warm across calls) | |
| lifecycle-stateless-explicit-handles | 2026-07-28 | both | MUST | lifecycle | “State that needs to span multiple requests (e.g., long-running tasks, application-level handles) MUST be referenced by an explicit identifier the client passes on each request.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#statelessness | yes (task ids, requestState) — but HTTP also issues Mcp-Session-Id to modern clients on server/discover, which 2026 removed | n/a | Changelog item 1: servers “use explicit, server-minted handles passed as ordinary tool arguments” |
| messages-patterns-notifications-scoped-to-request | 2026-07-28 | server | MAY | messages | “While the request is in flight, the server MAY send notifications scoped to it, such as notifications/progress and notifications/message.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/index#request-and-response | yes | n/a | |
| messages-resulttype-required | 2026-07-28 | server | MUST | messages | “The result MUST include a resultType field to indicate the type of the result.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#result-responses | yes (stamped complete when request carried modern _meta) | n/a | Includes EmptyResult (schema EmptyResult = Result) |
| messages-resulttype-extension-values | 2026-07-28 | both | MAY / MUST | messages | “Extensions MAY add additional ResultType values. The set of supported ResultType values MUST be created from the set defined in the core protocol and include any additional values of supported extensions that are advertised via capabilities.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#resulttype | yes (only core values) | partly | |
| messages-resulttype-unknown-invalid | 2026-07-28 | client | MUST | messages | “A resultType of any value unrecognized by the client MUST be considered invalid.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#resulttype | n/a | no: anything other than input_required is treated as complete (client.go:665) | |
| messages-resulttype-absent-means-complete | 2026-07-28 | client | MUST | messages | “clients MUST treat an absent resultType as \"complete\".” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#resulttype | n/a | yes | Schema words it as “a server implementing an earlier protocol version” — a 2026 server omitting it is non-conformant but indistinguishable; see I9 |
| messages-resulttype-server-must-include | 2026-07-28 | server | MUST | messages | “Servers implementing this protocol version MUST include this field.” (schema, Result.resultType) | https://modelcontextprotocol.io/specification/2026-07-28/schema#result | yes on modern requests; stripped for legacy by downgrade() | n/a | |
| authorization-http-should-conform-stdio-should-not | 2025-03-26..2026-07-28 | both | SHOULD / SHOULD NOT | authorization | “Implementations using an HTTP-based transport SHOULD conform to this specification, whereas implementations using STDIO transport SHOULD NOT follow this specification” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#auth | not-implemented-by-choice (no OAuth; loopback / unix socket) | not-implemented-by-choice (no OAuth to upstreams) | Listed here because it is on basic/index; authorization owner covers detail |
| authorization-custom-auth-may | 2024-11-05..2026-07-28 | both | MAY | authorization | “clients and servers MAY negotiate their own custom authentication and authorization strategies.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#auth | n/a | n/a | 2024-11-05: auth “not currently part of the core MCP specification” |
| security-implementors-guidelines | 2024-11-05..2026-07-28 | both | SHOULD | (security; not in area list) | “implementors SHOULD: 1. Build robust consent and authorization flows into their applications …” | https://modelcontextprotocol.io/specification/2026-07-28/index#implementation-guidelines | partly (elicitation broker; no auth on /mcp beyond loopback) | n/a | The “Key Principles” list uses lowercase must — informative. 2026 drops “LLM Sampling Controls” principle |
| deprecated-should-not-adopt | 2026-07-28 | both | SHOULD NOT / SHOULD | versioning | “new implementations SHOULD NOT adopt it, and existing implementations SHOULD migrate before the feature’s earliest removal.” | https://modelcontextprotocol.io/specification/2026-07-28/deprecated#deprecated | Roots/Sampling/Logging/HTTP+SSE: mcpx relays sampling, accepts logging/setLevel, does not serve HTTP+SSE | declares roots and (conditionally) sampling to upstreams | Deprecated: Roots, Sampling, Logging, DCR (2026-07-28); includeContext thisServer/allServers (2025-11-25); HTTP+SSE (2025-03-26) |
Transports (stdio, HTTP+SSE, Streamable HTTP) and authorization
| id | revs | side | level | area | quote | url | mcpx-as-server | mcpx-as-client | changes |
|---|---|---|---|---|---|---|---|---|---|
| transport-jsonrpc-utf8 | 2025-03-26..2026-07-28 | both | MUST | messages | “JSON-RPC messages MUST be UTF-8 encoded.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#messages | yes — Go encoding/json emits UTF-8 | yes — same | Absent from 2024-11-05 transports page (JSON-RPC implies it). Moved to transports/index in 2026. |
| transport-client-should-support-stdio | 2024-11-05..2025-11-25 | client | SHOULD | transport-stdio | “Clients SHOULD support stdio whenever possible.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports | n/a | yes — StdioTransport | Sentence dropped in 2026-07-28 (index lists stdio without the SHOULD). |
| transport-custom-preserve-jsonrpc-and-lifecycle | 2024-11-05..2025-11-25 | both | MUST | transport-stdio | “MUST ensure they preserve the JSON-RPC message format and lifecycle requirements defined by MCP” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#custom-transports | yes — mcpx’s only non-standard surface is /v1 REST, which is not an MCP transport | n/a | Replaced in 2026 by next row (“lifecycle” → “message patterns” + “per-request metadata model”). |
| transport-custom-preserve-patterns-and-metadata | 2026-07-28 | both | MUST | transport-stdio | “MUST preserve the JSON-RPC message format, the message patterns, and the per-request metadata model” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#custom-transports | n/a (no custom MCP transport) | n/a | |
| transport-custom-document-patterns | 2024-11-05..2026-07-28 | both | SHOULD | transport-stdio | “Custom transports SHOULD document their specific connection establishment and message exchange patterns” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#custom-transports | n/a | n/a | 2026 wording: “connection establishment, message framing, and cancellation patterns”. |
| transport-custom-byte-stream-reuses-stdio-framing | 2026-07-28 | both | SHOULD | transport-stdio | “Custom transports that run over a reliable bidirectional byte stream … SHOULD reuse the stdio framing” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#custom-transports | n/a | n/a | Also stated on the stdio page. |
| transport-binding-message-directions | 2026-07-28 | both | MUST | messages | “A binding MUST deliver client-sent requests and notifications to the server, and server-sent responses and notifications to the client.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#messages | yes for modern clients; for legacy sessions mcpx also sends requests (allowed by the legacy revision) | yes | New: no server→client requests, no client→server responses. Contradicts 2025-* (see Conflicts C1). |
| transport-binding-may-mirror-metadata | 2026-07-28 | both | MAY | messages | “A binding MAY additionally mirror selected body fields into envelope metadata.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#request-metadata | — | — | “The body remains the source of truth”. |
| stdio-no-embedded-newlines | 2024-11-05..2026-07-28 | both | MUST NOT | transport-stdio | “Messages are delimited by newlines, and MUST NOT contain embedded newlines.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio | yes — json.Encoder escapes newlines | yes | |
| stdio-message-may-be-batch | 2025-03-26 | both | MAY (implied) | transport-stdio | “Messages may be JSON-RPC requests, notifications, responses—or a JSON-RPC batch containing one or more requests and/or notifications.” | https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio | yes — ServeStdio answers a 2025-03-26 batch with an array (isBatch, internal/mcpserver/server.go); tested, see conformance-matrix.md | partial — never sends batches; does not parse batch replies on stdio | Removed 2025-06-18 (Conflicts C2). |
| stdio-message-is-single | 2025-06-18..2026-07-28 | both | MUST (implied) | transport-stdio | “Messages are individual JSON-RPC requests, notifications, or responses.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio | yes | yes | 2026: “Each message is a single JSON-RPC request, notification, or response.” |
| stdio-server-may-log-to-stderr | 2024-11-05..2026-07-28 | server | MAY | transport-stdio | “The server MAY write UTF-8 strings to its standard error (stderr) for logging purposes.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio | yes | n/a | 2025-11-25+ broadened: “for any logging purposes including informational, debug, and error messages”. |
| stdio-client-may-capture-stderr | 2024-11-05..2026-07-28 | client | MAY | transport-stdio | “Clients MAY capture, forward, or ignore this logging.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio | n/a | yes — ring buffer + optional StderrTo | |
| stdio-client-stderr-not-error | 2025-11-25..2026-07-28 | client | SHOULD NOT | transport-stdio | “SHOULD NOT assume stderr output indicates error conditions.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio | n/a | yes — stderr only attached to error messages when the process already failed | New in 2025-11-25. |
| stdio-server-stdout-only-mcp | 2024-11-05..2026-07-28 | server | MUST NOT | transport-stdio | “The server MUST NOT write anything to its stdout that is not a valid MCP message.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio | yes (logs go to stderr; not exhaustively verified) | n/a | |
| stdio-client-stdin-only-mcp | 2024-11-05..2026-07-28 | client | MUST NOT | transport-stdio | “The client MUST NOT write anything to the server’s stdin that is not a valid MCP message.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio | n/a | yes | |
| stdio-client-no-responses | 2026-07-28 | client | MUST NOT | transport-stdio | “The client MUST NOT write JSON-RPC responses.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#sending-messages | n/a | yes to a modern server (mcpx answers only legacy servers’ requests) | Legal and required in 2024-11-05..2025-11-25 (C1). |
| stdio-server-no-requests | 2026-07-28 | server | MUST NOT | transport-stdio | “The server MUST NOT write JSON-RPC requests to stdout.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#receiving-messages | yes — modern clients get input_required; wire requests only to legacy clients | n/a | C1. |
| stdio-client-correlates-subscription-id | 2026-07-28 | client | MUST | transport-stdio | “Clients MUST correlate these using the io.modelcontextprotocol/subscriptionId field in _meta” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#receiving-messages | n/a | uncertain — not checked whether mcpx opens subscriptions/listen upstream | |
| stdio-client-cancel-by-notification | 2026-07-28 | client | MUST | transport-stdio | “To cancel an in-flight request, the client MUST send a notifications/cancelled notification referencing the request’s ID.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#cancellation | n/a | yes — sent on its own background context | Also cancellation page. |
| stdio-server-stop-cancelled-work | 2026-07-28 | server | SHOULD | transport-stdio | “Servers SHOULD stop work on a cancelled request as soon as practical” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#cancellation | yes — notifications/cancelled cancels the request context the backend runs under (Conn.track); tested, see conformance-matrix.md | n/a | |
| stdio-server-silent-after-cancel | 2026-07-28 | server | MUST NOT | transport-stdio | “MUST NOT send any further messages for it.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#cancellation | uncertain — mcpx may still write the response when the upstream call returns | n/a | Earlier revs: cancellation page says receivers “SHOULD” not respond (not my area). |
| stdio-server-cancelled-only-for-listen | 2026-07-28 | server | MUST NOT | transport-stdio | “On stdio, the server also sends this notification, solely to terminate a subscriptions/listen stream … Servers MUST NOT use this notification to cancel any other request.” (schema.ts CancelledNotification) | https://modelcontextprotocol.io/specification/2026-07-28/schema#cancellednotification | uncertain — check stopListen on stdio | n/a | Conflicts with cancellation page scope (C12). |
| stdio-client-shutdown-sequence | 2026-07-28 | client | SHOULD | transport-stdio | “The client SHOULD initiate shutdown by: 1. Closing the input stream … 2. Waiting … 3. … forcibly terminating” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#shutdown | n/a | yes — close stdin, SIGTERM pgid, then SIGKILL | Earlier revs put this on the lifecycle page (not my area). |
| stdio-server-exit-on-eof | 2026-07-28 | server | SHOULD | transport-stdio | “Servers SHOULD exit promptly when their standard input is closed or reads return end-of-file.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#shutdown | yes — scan loop ends, waits for in-flight | n/a | |
| stdio-server-may-close-stdout | 2026-07-28 | server | MAY | transport-stdio | “The server MAY initiate shutdown by closing its output stream to the client and exiting.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#shutdown | — | must tolerate — yes (Recv returns exit error) | |
| stdio-client-restart-crashed-server | 2026-07-28 | client | SHOULD | transport-stdio | “If the server process exits unexpectedly, the client SHOULD restart it.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#unexpected-termination | n/a | uncertain — pool respawns on next use (not verified) | Relies on statelessness; for a legacy server the restart needs a new initialize (implied). |
| stdio-dual-era-client-probe-discover | 2026-07-28 | client | SHOULD | transport-stdio | “SHOULD probe with server/discover before sending any other request, setting its preferred modern version in _meta.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#backward-compatibility | n/a | not-implemented-by-choice — default probes legacy initialize first (protocol: prefer-discover flips it) | Versioning page matrix repeats this. |
| stdio-no-fallback-on-modern-error | 2026-07-28 | client | MUST NOT (informal “Do not”) | transport-stdio | “Use one of the versions in its advertised supported list. Do not fall back to initialize.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#backward-compatibility | n/a | yes — isVersionError stops the fallback (string match on -32022) | |
| stdio-modern-only-client-probe | 2026-07-28 | client | RECOMMENDED | transport-stdio | “A client that only supports modern versions does not need to probe, but probing is still RECOMMENDED” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#backward-compatibility | n/a | force-discover probes discover — yes | |
| http-sse-server-validate-origin | 2024-11-05 | server | MUST | transport-http-sse | “Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks” | https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#security-warning | not-implemented-by-choice — mcpx does not serve HTTP+SSE | n/a | |
| http-sse-server-bind-localhost | 2024-11-05 | server | SHOULD | transport-http-sse | “When running locally, servers SHOULD bind only to localhost (127.0.0.1)” | https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#security-warning | not-implemented-by-choice | n/a | |
| http-sse-server-authenticate | 2024-11-05 | server | SHOULD | transport-http-sse | “Servers SHOULD implement proper authentication for all connections” | https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#security-warning | not-implemented-by-choice | n/a | |
| http-sse-server-two-endpoints | 2024-11-05 | server | MUST | transport-http-sse | “The server MUST provide two endpoints: 1. An SSE endpoint … 2. A regular HTTP POST endpoint” | https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse | not-implemented-by-choice — floor is 2025-03-26 | n/a | |
| http-sse-server-endpoint-event | 2024-11-05 | server | MUST | transport-http-sse | “When a client connects, the server MUST send an endpoint event containing a URI for the client to use for sending messages.” | https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse | not-implemented-by-choice | gap if a 2024-11-05 remote is configured — no SSE client | |
| http-sse-client-post-to-endpoint | 2024-11-05 | client | MUST | transport-http-sse | “All subsequent client messages MUST be sent as HTTP POST requests to this endpoint.” | https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse | n/a | not-implemented (no HTTP+SSE client) | |
| http-sse-server-messages-as-sse-events | 2024-11-05 | server | MUST (implied) | transport-http-sse | “Server messages are sent as SSE message events, with the message content encoded as JSON in the event data.” | https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse | not-implemented-by-choice | not-implemented | |
| http-sse-deprecated-no-new-adoption | 2026-07-28 | both | SHOULD NOT | transport-http-sse | “New implementations SHOULD NOT adopt it; existing implementations SHOULD migrate to Streamable HTTP.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#httpsse-transport-2024-11-05 | yes | yes | “Replaced” since 2025-03-26 without a keyword; formally Deprecated (SEP-2596) in 2026. |
| http-sse-server-backcompat-host-both | 2025-03-26..2026-07-28 | server | should (lowercase) | transport-http-sse | “Continue to host both the SSE and POST endpoints of the old transport, alongside the new "MCP endpoint"” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#httpsse-transport-2024-11-05 | not-implemented-by-choice | n/a | Optional; “possible to combine the old POST endpoint and the new MCP endpoint”. |
| http-sse-client-backcompat-probe-any-4xx | 2025-03-26..2025-06-18 | client | should (lowercase) | transport-http-sse | “If it fails with an HTTP 4xx status code (e.g., 405 Method Not Allowed or 404 Not Found): Issue a GET request … expecting … an endpoint event” | https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#backwards-compatibility | n/a | not-implemented — no GET fallback | Narrowed twice (C9). |
| http-sse-client-backcompat-probe-400-404-405 | 2025-11-25 | client | should (lowercase) | transport-http-sse | “If it fails with the following HTTP status codes "400 Bad Request", "404 Not Found" or "405 Method Not Allowed"” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#backwards-compatibility | n/a | not-implemented | C9. |
| http-sse-client-backcompat-probe-non-modern-body | 2026-07-28 | client | should (lowercase) | transport-http-sse | “fails with HTTP status code 400 Bad Request, 404 Not Found, or 405 Method Not Allowed and the response body is not a recognized modern JSON-RPC error” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#httpsse-transport-2024-11-05 | n/a | not-implemented | C9. Probe is now “POST a request” (not InitializeRequest). |
| streamable-http-server-single-endpoint-post-get | 2025-03-26..2025-11-25 | server | MUST | transport-streamable-http | “The server MUST provide a single HTTP endpoint path … that supports both POST and GET methods.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http | yes — GET “supported” by answering 405, which the GET section permits | n/a | |
| streamable-http-server-single-endpoint-post | 2026-07-28 | server | MUST | transport-streamable-http | “The server MUST provide a single HTTP endpoint path … that supports POST.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http | yes | n/a | GET dropped. |
| streamable-http-server-validate-origin | 2025-03-26..2026-07-28 | server | MUST | transport-streamable-http | “Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#security–endpoint | yes — ServeHTTP refuses a foreign Origin (originAllowed, internal/mcpserver/server.go, #198); /v1 too (internal/daemon/origin.go, #267); tested, see conformance-matrix.md | n/a | |
| streamable-http-server-403-invalid-origin | 2025-11-25..2026-07-28 | server | MUST | transport-streamable-http | “If the Origin header is present and invalid, servers MUST respond with HTTP 403 Forbidden.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#security–endpoint | yes — 403 with an id-less JSON-RPC error (as above); tested, see conformance-matrix.md | n/a | Added 2025-11-25 (PR #1439). Body MAY be JSON-RPC error with no id. |
| streamable-http-server-bind-localhost | 2025-03-26..2026-07-28 | server | SHOULD | transport-streamable-http | “When running locally, servers SHOULD bind only to localhost (127.0.0.1)” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#security–endpoint | yes — daemon default loopback; --address 0.0.0.0 is opt-in with a warning | n/a | |
| streamable-http-server-authenticate | 2025-03-26..2026-07-28 | server | SHOULD | transport-streamable-http | “Servers SHOULD implement proper authentication for all connections.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#security–endpoint | not-implemented-by-choice — /mcp is unauthenticated, like /v1 | n/a | |
| streamable-http-client-post-every-message | 2025-03-26..2026-07-28 | client | MUST | transport-streamable-http | “Every JSON-RPC message sent from the client MUST be a new HTTP POST request to the MCP endpoint.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages | n/a | yes | |
| streamable-http-client-accept-both | 2025-03-26..2026-07-28 | client | MUST | transport-streamable-http | “The client MUST include an Accept header, listing both application/json and text/event-stream” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages | does not check (lenient) | yes | |
| streamable-http-post-body-may-batch | 2025-03-26 | client | MUST | transport-streamable-http | “The body of the POST request MUST be one of the following: A single JSON-RPC request, notification, or response; An array batching one or more requests and/or notifications; An array batching one or more responses” | https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#sending-messages-to-the-server | gap for 2025-03-26 — batch body → parse error 400 | yes (never batches); parses batch replies | Batching removed 2025-06-18 (C2). |
| streamable-http-post-body-single | 2025-06-18..2025-11-25 | client | MUST | transport-streamable-http | “The body of the POST request MUST be a single JSON-RPC request, notification, or response.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | yes | yes | |
| streamable-http-post-body-request-or-notification | 2026-07-28 | client | MUST / MUST NOT | transport-streamable-http | “The body of the HTTP POST MUST be a single JSON-RPC request or notification. The client MUST NOT send JSON-RPC responses.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages | accepts responses anyway (for legacy sessions) — liberal accept | yes to modern servers | C1. |
| streamable-http-client-sends-metadata-headers | 2026-07-28 | client | MUST | transport-streamable-http | “The client MUST include the request metadata headers on each POST request.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages | n/a | yes — Mcp-Method/Mcp-Name and Mcp-Param-* on every modern POST (internal/mcpclient/headers.go); tested, see conformance-matrix.md | Notification POST headers undefined (C11). |
| streamable-http-server-202-for-non-request | 2025-03-26..2026-07-28 | server | MUST | transport-streamable-http | “If the server accepts the input, the server MUST return HTTP status code 202 Accepted with no body.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages | yes — notifications and client responses → 202 | tolerates 202 and 204 | Input set: responses/notifications (2025-03-26: “solely of (any number of)”), response/notification (2025-06-18..2025-11-25), notification only (2026). |
| streamable-http-server-error-status-if-rejected | 2025-03-26..2026-07-28 | server | MUST | transport-streamable-http | “If the server cannot accept the input, it MUST return an HTTP error status code (e.g., 400 Bad Request).” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages | yes — unknown reply id → 400 (body is not JSON-RPC; MAY) | n/a | Body MAY be a JSON-RPC error with no id. |
| streamable-http-server-json-or-sse-for-request | 2025-03-26..2026-07-28 | server | MUST | transport-streamable-http | “the server MUST either return Content-Type: text/event-stream … or Content-Type: application/json” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages | yes — JSON unless it must ask/stream, then SSE lazily | n/a | |
| streamable-http-client-supports-json-and-sse | 2025-03-26..2026-07-28 | client | MUST | transport-streamable-http | “The client MUST support both these cases.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages | n/a | yes | |
| streamable-http-sse-includes-response | 2025-03-26..2025-11-25 | server | SHOULD | transport-streamable-http | “The SSE stream SHOULD eventually include a JSON-RPC response for the JSON-RPC request sent in the POST body.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | yes | n/a | 2025-03-26: one response per request, MAY be batched. |
| streamable-http-post-sse-may-carry-requests | 2025-03-26..2025-11-25 | server | MAY | transport-streamable-http | “The server MAY send JSON-RPC requests and notifications before sending the JSON-RPC response. These messages SHOULD relate to the originating client request.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | yes — elicitation/create/sampling on the POST’s stream (legacy sessions) | yes — handles requests found on the stream | Forbidden in 2026 (C1). |
| streamable-http-sse-notifications-must-relate | 2026-07-28 | server | MUST | transport-streamable-http | “These notifications MUST relate to the originating client request.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#receiving-messages | yes | n/a | Was SHOULD in 2025-* (C3). |
| streamable-http-sse-no-requests | 2026-07-28 | server | MUST NOT | transport-streamable-http | “The server MUST NOT send independent JSON-RPC requests on this stream.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#receiving-messages | yes for modern requests | n/a | C1. |
| streamable-http-sse-not-closed-before-response | 2025-03-26..2025-06-18 | server | SHOULD NOT | transport-streamable-http | “The server SHOULD NOT close the SSE stream before sending the JSON-RPC response for the received JSON-RPC request, unless the session expires.” | https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server | yes | n/a | Replaced 2025-11-25 by polling rules (C4). |
| streamable-http-server-may-terminate-on-session-expiry | 2025-11-25 | server | MAY | transport-streamable-http | “The server MAY terminate the SSE stream if the session expires.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | — | — | |
| streamable-http-server-terminates-sse-after-response | 2025-03-26..2026-07-28 | server | SHOULD | transport-streamable-http | “After the JSON-RPC response has been sent, the server SHOULD terminate the SSE stream.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#receiving-messages | yes | n/a | 2025-03-26/06-18 “close”; 2025-11-25 “terminate” (stream vs connection distinction); 2026 “The final JSON-RPC response SHOULD terminate the stream.” Not for subscriptions/listen streams. |
| streamable-http-sse-priming-event | 2025-11-25 | server | SHOULD | transport-streamable-http | “The server SHOULD immediately send an SSE event consisting of an event ID and an empty data field in order to prime the client to reconnect” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | not-implemented-by-choice — no event ids, no resumability | must tolerate an empty data event — yes (skipped) | Removed 2026 (C5). |
| streamable-http-server-may-close-connection-keep-stream | 2025-11-25 | server | MAY | transport-streamable-http | “the server MAY close the connection (without terminating the SSE stream) at any time” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | not used | gap — a server that does this loses mcpx the response (no reconnect) | C4, C5. |
| streamable-http-client-polls-after-close | 2025-11-25 | client | SHOULD | transport-streamable-http | “The client SHOULD then "poll" the SSE stream by attempting to reconnect.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | n/a | yes — reconnects with GET and Last-Event-ID after retry (internal/mcpclient/http.go); tested, see conformance-matrix.md | |
| streamable-http-server-sends-retry-before-close | 2025-11-25 | server | SHOULD | transport-streamable-http | “it SHOULD send an SSE event with a standard retry field before closing the connection.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | n/a (never closes early) | n/a | |
| streamable-http-client-respects-retry | 2025-11-25 | client | MUST | transport-streamable-http | “The client MUST respect the retry field, waiting the given number of milliseconds before attempting to reconnect.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | n/a | vacuously yes (never reconnects); retry: lines ignored | |
| streamable-http-disconnect-not-cancel | 2025-03-26..2025-11-25 | both | SHOULD NOT | transport-streamable-http | “Disconnection SHOULD NOT be interpreted as the client cancelling its request.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | uncertain — handler runs on r.Context(), so a disconnect cancels mcpx-side waiting for a legacy client too | n/a | Inverted in 2026 (C6). |
| streamable-http-client-cancel-by-notification | 2025-03-26..2025-11-25 | client | SHOULD | transport-streamable-http | “To cancel, the client SHOULD explicitly send an MCP CancelledNotification.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | n/a | yes | 2026: “no notifications/cancelled message is expected” (C6). |
| streamable-http-server-may-resume | 2025-03-26..2025-11-25 | server | MAY | transport-streamable-http | “To avoid message loss due to disconnection, the server MAY make the stream resumable.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server | not-implemented-by-choice | n/a | Removed 2026 (C5). |
| streamable-http-close-is-cancel | 2026-07-28 | server | MUST | transport-streamable-http | “Closing the SSE response stream MUST be treated by the server as cancellation of that request.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#cancellation | partial — request ctx cancels; upstream call not interrupted | n/a | Cancellation page: “The server MUST treat a client disconnect as cancellation” (covers JSON responses too). C6. |
| streamable-http-stop-cancelled-work | 2026-07-28 | server | SHOULD | transport-streamable-http | “The server SHOULD stop work on the cancelled request as soon as practical” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#cancellation | yes — a closed POST stream cancels the backend call; tested, see conformance-matrix.md | n/a | |
| streamable-http-silent-after-cancel | 2026-07-28 | server | MUST NOT | transport-streamable-http | “MUST NOT send any further messages for it.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#cancellation | yes (stream is gone) | n/a | |
| streamable-http-client-may-get | 2025-03-26..2025-11-25 | client | MAY | transport-streamable-http | “The client MAY issue an HTTP GET to the MCP endpoint.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server | n/a | not-implemented-by-choice — never opens GET (so unsolicited list_changed from legacy HTTP upstreams is missed) | Removed 2026. |
| streamable-http-get-accept-sse | 2025-03-26..2025-11-25 | client | MUST | transport-streamable-http | “The client MUST include an Accept header, listing text/event-stream” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server | n/a | n/a (no GET) | |
| streamable-http-get-sse-or-405 | 2025-03-26..2025-11-25 | server | MUST | transport-streamable-http | “The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, or else return HTTP 405 Method Not Allowed” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server | yes — 405 | n/a | |
| streamable-http-get-stream-unrelated | 2025-03-26..2025-11-25 | server | SHOULD | transport-streamable-http | “These messages SHOULD be unrelated to any concurrently-running JSON-RPC request from the client.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server | n/a (405) | n/a | |
| streamable-http-get-no-responses | 2025-03-26..2025-11-25 | server | MUST NOT | transport-streamable-http | “The server MUST NOT send a JSON-RPC response on the stream unless resuming a stream associated with a previous client request.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server | n/a | n/a | |
| streamable-http-get-close-any-time | 2025-03-26..2025-11-25 | both | MAY | transport-streamable-http | “The server MAY close the SSE stream at any time. The client MAY close the SSE stream at any time.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server | — | — | 2025-11-25 adds: closing connection without terminating stream SHOULD follow polling (send retry). |
| streamable-http-multiple-streams | 2025-03-26..2025-11-25 | client | MAY | transport-streamable-http | “The client MAY remain connected to multiple SSE streams simultaneously.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#multiple-connections | must handle — yes (one goroutine per stream) | yes | |
| streamable-http-no-broadcast | 2025-03-26..2025-11-25 | server | MUST / MUST NOT | transport-streamable-http | “The server MUST send each of its JSON-RPC messages on only one of the connected streams; that is, it MUST NOT broadcast” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#multiple-connections | yes — each frame goes to its request’s stream | n/a | 2026: implied by request-scoped streams, but listen stream + request stream could both qualify (see Implied). |
| streamable-http-event-id-unique | 2025-03-26..2025-11-25 | server | MUST | transport-streamable-http | “If present, the ID MUST be globally unique across all streams within that session—or all streams with that specific client” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery | n/a (no ids) | n/a | |
| streamable-http-event-id-encodes-stream | 2025-11-25 | server | SHOULD | transport-streamable-http | “Event IDs SHOULD encode sufficient information to identify the originating stream” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery | n/a | n/a | |
| streamable-http-client-resume-with-last-event-id | 2025-03-26..2025-11-25 | client | SHOULD | transport-streamable-http | “it SHOULD issue an HTTP GET to the MCP endpoint, and include the Last-Event-ID header” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery | n/a | not-implemented | 2025-11-25: “Resumption is always via HTTP GET with Last-Event-ID” regardless of POST/GET origin. |
| streamable-http-server-no-cross-stream-replay | 2025-03-26..2025-11-25 | server | MUST NOT | transport-streamable-http | “The server MUST NOT replay messages that would have been delivered on a different stream.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery | n/a | n/a | |
| streamable-http-no-resumability | 2026-07-28 | both | — (statement) | transport-streamable-http | “Resumable SSE streams via Last-Event-ID are not supported.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#receiving-messages | yes | yes | C5. |
| streamable-http-client-reissue-broken-request | 2026-07-28 | client | MUST (changelog) | transport-streamable-http | “A broken response stream loses the in-flight request; clients MUST re-issue it as a new request with a new request ID” | https://modelcontextprotocol.io/specification/2026-07-28/changelog | n/a | not verified — pool retry behaviour unknown | MUST exists only in the changelog, not on the transport page (C10). |
| streamable-http-server-may-assign-session | 2025-03-26..2025-11-25 | server | MAY | transport-streamable-http | “A server using the Streamable HTTP transport MAY assign a session ID at initialization time, by including it in an Mcp-Session-Id header on the HTTP response containing the InitializeResult.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management | yes — on initialize | n/a | 2025-11-25 spells it MCP-Session-Id (case-insensitive). mcpx also mints on server/discover (2026: SHOULD NOT). |
| streamable-http-session-id-secure | 2025-03-26..2025-11-25 | server | SHOULD | transport-streamable-http | “The session ID SHOULD be globally unique and cryptographically secure” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management | yes — 16 random bytes (falls back to literal sess-0 if crypto/rand fails) | n/a | |
| streamable-http-session-id-visible-ascii | 2025-03-26..2025-11-25 | server | MUST | transport-streamable-http | “The session ID MUST only contain visible ASCII characters (ranging from 0x21 to 0x7E).” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management | yes — sess-<hex> | n/a | |
| streamable-http-client-handles-session-securely | 2025-11-25 | client | MUST | transport-streamable-http | “The client MUST handle the session ID in a secure manner” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management | n/a | yes (in memory only; trace redaction not checked) | New 2025-11-25. |
| streamable-http-client-echoes-session | 2025-03-26..2025-11-25 | client | MUST | transport-streamable-http | “clients using the Streamable HTTP transport MUST include it in the Mcp-Session-Id header on all of their subsequent HTTP requests.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management | n/a | yes — also sends it to modern servers (2026 server SHOULD ignore) | |
| streamable-http-server-400-missing-session | 2025-03-26..2025-11-25 | server | SHOULD | transport-streamable-http | “Servers that require a session ID SHOULD respond to requests without an Mcp-Session-Id header (other than initialization) with HTTP 400 Bad Request.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management | n/a — mcpx does not require one (stateless fallback) | n/a | |
| streamable-http-server-404-terminated-session | 2025-03-26..2025-11-25 | server | MUST | transport-streamable-http | “The server MAY terminate the session at any time, after which it MUST respond to requests containing that session ID with HTTP 404 Not Found.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management | yes — an ended or unknown session id is 404; tested, see conformance-matrix.md | n/a | |
| streamable-http-client-reinit-on-404 | 2025-03-26..2025-11-25 | client | MUST | transport-streamable-http | “it MUST start a new session by sending a new InitializeRequest without a session ID attached.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management | n/a | yes — a session 404 triggers one re-initialize (no session id on it) and one retry on the new session; tested, see conformance-matrix.md | Under 2026 a 404 means “method not found” (C7). |
| streamable-http-client-delete-session | 2025-03-26..2025-11-25 | client | SHOULD | transport-streamable-http | “Clients that no longer need a particular session … SHOULD send an HTTP DELETE to the MCP endpoint with the Mcp-Session-Id header” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management | accepts — 204 | yes — on Close | |
| streamable-http-server-may-405-delete | 2025-03-26..2025-11-25 | server | MAY | transport-streamable-http | “The server MAY respond to this request with HTTP 405 Method Not Allowed” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management | chooses 204 | tolerates any status | |
| streamable-http-protocol-version-header-is-negotiated | 2025-06-18..2025-11-25 | client | SHOULD | transport-streamable-http | “The protocol version sent by the client SHOULD be the one negotiated during initialization.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header | n/a | yes — the header carries the negotiated revision; tested, see conformance-matrix.md | |
| streamable-http-server-assumes-2025-03-26 | 2025-06-18..2025-11-25 | server | SHOULD | transport-streamable-http | “if the server does not receive an MCP-Protocol-Version header, and has no other way to identify the version … the server SHOULD assume protocol version 2025-03-26.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header | yes — 2025-03-26 is mcpx’s floor/default | n/a | 2026 makes it MAY and conditional (C8). |
| streamable-http-server-400-unsupported-version-header | 2025-06-18..2025-11-25 | server | MUST | transport-streamable-http | “If the server receives a request with an invalid or unsupported MCP-Protocol-Version, it MUST respond with 400 Bad Request.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header | yes — an unsupported MCP-Protocol-Version is 400; tested, see conformance-matrix.md | n/a | |
| streamable-http-auth-discovery-version-header | 2025-03-26 | client | SHOULD | authorization | “MCP clients SHOULD include the header MCP-Protocol-Version: <protocol-version> during Server Metadata Discovery” | https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery-headers | n/a | n/a (no OAuth) | Header appears in auth before the transport defines it (2025-06-18); example value is 2024-11-05 (C13). |
| streamable-http-protocol-version-header-every-post | 2026-07-28 | client | MUST | transport-streamable-http | “Every POST request to the MCP endpoint MUST include an MCP-Protocol-Version header.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#protocol-version-header | n/a | yes | Now includes the first request (no handshake). |
| streamable-http-protocol-version-header-matches-meta | 2026-07-28 | both | MUST | transport-streamable-http | “The header value MUST match the io.modelcontextprotocol/protocolVersion field … the server MUST reject the request with 400 Bad Request and a HeaderMismatch JSON-RPC error” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#protocol-version-header | partial — 400, but body {"error": "..."}, not JSON-RPC -32020 | yes — header follows frame _meta | schema.ts RequestMetaObject repeats (without naming HeaderMismatch). |
| streamable-http-unsupported-version-400 | 2026-07-28 | server | MUST | transport-streamable-http | “it MUST respond with 400 Bad Request and an UnsupportedProtocolVersionError listing its supported versions.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#protocol-version-header | uncertain — -32022 exists; HTTP status for it not verified | n/a | schema.ts: “For HTTP, the response status code MUST be 400 Bad Request.” |
| streamable-http-missing-capability-400 | 2026-07-28 | server | MUST | transport-streamable-http | “For HTTP, the response status code MUST be 400 Bad Request.” (schema.ts MissingRequiredClientCapabilityError) | https://modelcontextprotocol.io/specification/2026-07-28/schema#missingrequiredclientcapabilityerror | uncertain — mcpx asks via broker rather than erroring | n/a | |
| streamable-http-unknown-method-404 | 2026-07-28 | server | MUST | transport-streamable-http | “If the server does not implement the requested RPC method, it MUST respond with 404 Not Found and a JSON-RPC error with code -32601” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#protocol-version-header | yes — -32601 is HTTP 404 on the modern path; tested, see conformance-matrix.md | must not mistake it for a dead session/legacy server — partial (any 404 → error) | C7. |
| streamable-http-missing-version-header-legacy | 2026-07-28 | server | MAY / MUST | transport-streamable-http | “MAY treat a request that omits the header as protocol version 2025-03-26. A server that does not support such clients MUST reject a request without the header” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#protocol-version-header | yes (MAY branch) | n/a | C8. |
| streamable-http-standard-headers-required | 2026-07-28 | client | REQUIRED | transport-streamable-http | “Mcp-Method … All requests; Mcp-Name … tools/call, resources/read, prompts/get requests. These headers are REQUIRED for compliance.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#standard-request-headers | n/a | yes (standardHeaders, internal/mcpclient/headers.go); tested, see conformance-matrix.md | SEP-2243. |
| streamable-http-mcp-name-base64 | 2026-07-28 | client | MUST | transport-streamable-http | “If the Mcp-Name source value cannot be safely represented as a plain ASCII header value, clients MUST encode it using the Base64 sentinel format” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#standard-request-headers | n/a | yes (mcpheaders.Encode, internal/mcpheaders); tested, see conformance-matrix.md | |
| streamable-http-x-mcp-header-server-may | 2026-07-28 | server | MAY | transport-streamable-http | “MCP servers MAY designate specific tool parameters to be mirrored into HTTP headers using an x-mcp-header extension property” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#custom-headers-from-tool-parameters | not used; but mcpx re-exports upstream tool schemas — an upstream x-mcp-header passes through, obliging mcpx’s HTTP clients and mcpx’s own validation (see Implied) | — | |
| streamable-http-x-mcp-header-client-must-mirror | 2026-07-28 | client | MUST | transport-streamable-http | “clients MUST support this feature. When a server’s tool definition includes x-mcp-header annotations, conforming clients MUST mirror the designated parameter values into HTTP headers.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#custom-headers-from-tool-parameters | n/a | yes (mcpheaders.Values, internal/mcpheaders); tested, see conformance-matrix.md | |
| streamable-http-x-mcp-header-constraints | 2026-07-28 | server | MUST / MUST NOT | transport-streamable-http | “MUST NOT be empty; MUST match HTTP field-name token syntax; … case-insensitively unique; … only … primitive types … Parameters with type number are not permitted; … statically reachable” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#schema-extension | n/a unless passing through upstream annotations | n/a | Integer values MUST be within ±(2^53−1). |
| streamable-http-client-rejects-invalid-x-mcp-header | 2026-07-28 | client | MUST | transport-streamable-http | “Clients using the Streamable HTTP transport MUST reject tool definitions where any x-mcp-header value violates these constraints. … MUST exclude the invalid tool from the result of tools/list.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#schema-extension | n/a | yes (checkToolHeaders, internal/mcpclient/modern.go); tested, see conformance-matrix.md | “SHOULD log a warning”; stdio clients MAY ignore annotations. |
| streamable-http-param-value-encoding | 2026-07-28 | client | MUST | transport-streamable-http | “Clients MUST encode parameter values before including them in HTTP headers … clients MUST use Base64 encoding of the UTF-8 representation” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#value-encoding | n/a | yes (mcpheaders.Encode); tested, see conformance-matrix.md | Markers =?base64?…?= “MUST appear exactly as shown (lowercase)”; sentinel-lookalike plain values MUST also be encoded. |
| streamable-http-server-decodes-before-compare | 2026-07-28 | server | MUST | transport-streamable-http | “servers MUST decode an encoded Mcp-Name or Mcp-Param-{Name} value before comparing it to the corresponding request body value” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#value-encoding | yes — encoded header values are decoded before comparison; tested, see conformance-matrix.md | n/a | Also intermediaries “that need to inspect”. |
| streamable-http-client-header-construction | 2026-07-28 | client | MUST | transport-streamable-http | “When constructing a tools/call request via HTTP transport, the client MUST: 1. Extract … 2. Append the Mcp-Method header … 5. Append a Mcp-Param-{Name}: {Value} header” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#client-behavior | n/a | yes (mcpheaders.Values); tested, see conformance-matrix.md | Omit header when value null/absent (table). |
| streamable-http-client-relist-on-header-mismatch | 2026-07-28 | client | SHOULD | transport-streamable-http | “the client SHOULD call tools/list to check for changes to the tool’s inputSchema, then retry” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#client-behavior | n/a | yes — re-lists tools and retries once on -32020 (toolCallHeaders, internal/mcpclient/modern.go); tested, see conformance-matrix.md | |
| streamable-http-intermediary-forwards-unknown-param | 2026-07-28 | intermediary | MUST | transport-streamable-http | “Intermediate servers that do not recognize an Mcp-Param-{Name} header MUST forward it and otherwise ignore it” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#server-behavior-for-custom-headers | n/a — mcpx is not an HTTP intermediary; it terminates MCP and re-originates | n/a | |
| streamable-http-server-validates-param-headers | 2026-07-28 | server | MUST | transport-streamable-http | “Any server that processes the message body MUST validate that encoded header values … match … Servers MUST reject requests with a 400 Bad Request HTTP status and JSON-RPC error code -32020” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#server-behavior-for-custom-headers | yes — Mcp-Param-* headers are checked against the x-mcp-header annotations of the tool named (checkParamHeaders, internal/mcpserver/transport.go, via mcpheaders.Check); tested, see conformance-matrix.md | n/a | Server MUST reject a recognized Mcp-Param with invalid chars; MUST reject when header omitted but body has value; MUST NOT expect header for null/absent. |
| streamable-http-header-names-case-insensitive | 2026-07-28 | both | MUST | transport-streamable-http | “Clients and servers MUST use case-insensitive comparisons for header names. Header values … are case-sensitive.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#case-sensitivity | yes (Go canonicalizes) | yes | Explains why Mcp-Session-Id→MCP-Session-Id rename (2025-11-25) is harmless. |
| streamable-http-server-rejects-header-body-mismatch | 2026-07-28 | server | MUST | transport-streamable-http | “Servers that process the request body MUST reject requests where the values specified in the headers do not match the corresponding values in the request body.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#server-validation | partial — only version | n/a | Includes “A required standard header … is missing”. |
| streamable-http-header-mismatch-400-jsonrpc | 2026-07-28 | server | MUST | transport-streamable-http | “servers MUST return HTTP status 400 Bad Request and MUST include a JSON-RPC error response using the following error code: -32020” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#server-validation | yes — 400 with a JSON-RPC -32020 body carrying the request id; tested, see conformance-matrix.md | n/a | Code was -32001 in drafts (changelog minor 12). |
| streamable-http-numeric-compare | 2026-07-28 | server | SHOULD | transport-streamable-http | “servers SHOULD compare the header value and the body value numerically rather than as strings” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#server-validation | n/a | n/a | |
| streamable-http-intermediary-http-error | 2026-07-28 | intermediary | MUST | transport-streamable-http | “Intermediaries MUST return an appropriate HTTP error status … but are not required to return a JSON-RPC error response.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#server-validation | n/a | client must tolerate non-JSON-RPC 400 — yes | |
| streamable-http-intermediary-reject-unvalidated-version | 2026-07-28 | intermediary | SHOULD | transport-streamable-http | “Intermediaries that enforce policy based on mirrored headers … SHOULD reject the request rather than trusting unvalidated header values.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#server-validation | n/a | n/a | |
| streamable-http-x-accel-buffering | 2026-07-28 | server | SHOULD | transport-streamable-http | “When initiating an SSE stream, servers SHOULD include the X-Accel-Buffering: no header” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#receiving-messages | yes | n/a | |
| streamable-http-sse-comment-keepalive | 2026-07-28 | both | should/must (lowercase) | transport-streamable-http | “servers are encouraged to periodically emit an SSE comment line … clients must ignore such lines and must not treat them as malformed input.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#receiving-messages | not verified for subscriptions/listen | yes — : lines skipped | Inside a <Note>; lowercase. |
| streamable-http-modern-only-server-legacy-traffic | 2026-07-28 | server | SHOULD | transport-streamable-http | “HTTP GET or DELETE to the MCP endpoint: respond with 405 Method Not Allowed. An Mcp-Session-Id header …: ignore it, and do not mint or echo session IDs. A Last-Event-ID header: ignore it” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#earlier-streamable-http-revisions | n/a as written (mcpx is dual-era, not modern-only) — but mcpx mints a session on server/discover for modern clients by choice (to bind requestState) | n/a | Scoped to “a server that supports only this revision”. |
| authz-optional | 2025-03-26..2026-07-28 | both | OPTIONAL | authorization | “Authorization is OPTIONAL for MCP implementations.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#protocol-requirements | not-implemented-by-choice — mcpx does no OAuth | not-implemented-by-choice | Every other authz row applies only “When supported”. |
| authz-http-should-conform | 2025-03-26..2026-07-28 | both | SHOULD | authorization | “Implementations using an HTTP-based transport SHOULD conform to this specification.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#protocol-requirements | not-implemented-by-choice | not-implemented-by-choice — static headers | |
| authz-stdio-use-environment | 2025-03-26..2026-07-28 | both | SHOULD NOT | authorization | “Implementations using an STDIO transport SHOULD NOT follow this specification, and instead retrieve credentials from the environment.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#protocol-requirements | yes | yes — env auth type for stdio children | |
| authz-alt-transport-best-practice | 2025-03-26..2026-07-28 | both | MUST | authorization | “Implementations using alternative transports MUST follow established security best practices for their protocol.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#protocol-requirements | n/a | n/a | |
| authz-implement-oauth21 | 2025-03-26 | both | MUST | authorization | “MCP auth implementations MUST implement OAuth 2.1 with appropriate security measures for both confidential and public clients.” | https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#overview | not-implemented-by-choice | not-implemented-by-choice | 2025-06-18+: subject becomes “Authorization servers” (next row). |
| authz-as-implements-oauth21 | 2025-06-18..2026-07-28 | auth-server | MUST | authorization | “Authorization servers MUST implement OAuth 2.1 with appropriate security measures for both confidential and public clients.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#overview | n/a | n/a | |
| authz-dcr-should | 2025-03-26..2025-06-18 | both | SHOULD | authorization | “MCP clients and servers SHOULD support the OAuth 2.0 Dynamic Client Registration Protocol” | https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#dynamic-client-registration | not-implemented-by-choice | not-implemented-by-choice | 2025-06-18 subject “MCP clients and authorization servers”. Demoted to MAY 2025-11-25 (C14). |
| authz-dcr-may-deprecated | 2025-11-25..2026-07-28 | client, auth-server | MAY | authorization | “MCP clients and authorization servers MAY support the OAuth 2.0 Dynamic Client Registration Protocol” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#dynamic-client-registration | n/a | not-implemented-by-choice | Deprecated 2026 (PR #2858). |
| authz-rfc8414-server-should-client-must | 2025-03-26 | both | SHOULD / MUST | authorization | “MCP servers SHOULD and MCP clients MUST implement OAuth 2.0 Authorization Server Metadata (RFC8414). Servers that do not support Authorization Server Metadata MUST follow the default URI schema.” | https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#overview | not-implemented-by-choice | not-implemented-by-choice | MCP server = AS in 2025-03-26; separated 2025-06-18 (C15). |
| authz-grant-types | 2025-03-26 | server | SHOULD | authorization | “MCP servers SHOULD support the OAuth grant types that best align with the intended audience.” | https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#oauth-grant-types | not-implemented-by-choice | n/a | |
| authz-401-when-unauthorized | 2025-03-26 | server | MUST | authorization | “When authorization is required and not yet proven by the client, servers MUST respond with HTTP 401 Unauthorized.” | https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#example-authorization-code-grant | not-implemented-by-choice | must react to 401 — reports error | Later revs keep 401 via error table + WWW-Authenticate. |
| authz-base-url-discards-path | 2025-03-26 | client | MUST | authorization | “The authorization base URL MUST be determined from the MCP server URL by discarding any existing path component.” | https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#authorization-base-url | n/a | not-implemented-by-choice | Removed 2025-06-18; later revs use path-aware well-known URIs (C15). |
| authz-default-endpoints-fallback | 2025-03-26 | client | MUST | authorization | “clients MUST use the following default endpoint paths … Clients MUST first attempt to discover endpoints via the metadata document before falling back” | https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery | not-implemented-by-choice | not-implemented-by-choice | /authorize, /token, /register; removed 2025-06-18. |
| authz-bearer-header | 2025-03-26..2026-07-28 | client | MUST | authorization | “MCP client MUST use the Authorization request header field … Authorization: Bearer <access-token>” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-requirements | n/a | partial — static configured bearer is sent this way; not OAuth-obtained | |
| authz-token-every-request | 2025-03-26..2026-07-28 | client | MUST | authorization | “authorization MUST be included in every HTTP request from client to server” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-requirements | n/a | yes for configured headers (POST and DELETE) | 2025-*: “even if they are part of the same logical session”; clause dropped 2026 (no sessions). |
| authz-no-token-in-query | 2025-03-26..2026-07-28 | client | MUST NOT | authorization | “Access tokens MUST NOT be included in the URI query string” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-requirements | n/a | conflict — mcpauth offers a query auth type; using it with a token violates this | |
| authz-server-validates-token | 2025-03-26..2026-07-28 | server | MUST | authorization | “MCP servers, acting in their role as an OAuth 2.1 resource server, MUST validate access tokens … Invalid or expired tokens MUST receive a HTTP 401 response.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-handling | not-implemented-by-choice | n/a | 2025-03-26: “Resource servers MUST validate”. |
| authz-server-validates-audience | 2025-06-18..2026-07-28 | server | MUST | authorization | “MCP servers MUST validate that access tokens were issued specifically for them as the intended audience” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-handling | not-implemented-by-choice | n/a | Repeated in Security Considerations (Token Audience Binding; Access Token Privilege Restriction). |
| authz-client-sends-only-own-as-tokens | 2025-06-18..2026-07-28 | client | MUST NOT | authorization | “MCP clients MUST NOT send tokens to the MCP server other than ones issued by the MCP server’s authorization server.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-handling | n/a | operator’s responsibility (static config per server) | |
| authz-accept-only-own-tokens | 2025-06-18..2026-07-28 | server | MUST | authorization | “MCP servers MUST only accept tokens that are valid for use with their own resources. MCP servers MUST NOT accept or transit any other tokens.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-handling | not-implemented-by-choice (accepts no tokens) | n/a | 2025-06-18 said “Authorization servers MUST only accept tokens…” — subject corrected 2025-11-25 (C16). |
| authz-no-token-passthrough | 2025-06-18..2026-07-28 | server | MUST NOT | authorization | “The MCP server MUST NOT pass through the token it received from the MCP client.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#access-token-privilege-restriction | yes — downstream request headers are not forwarded upstream; upstream credentials come from config (believed; not traced end to end) | n/a | Key rule for a proxy. |
| authz-error-status-codes | 2025-03-26..2026-07-28 | server | MUST | authorization | “Servers MUST return appropriate HTTP status codes for authorization errors: 401 … 403 … 400” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#error-handling | not-implemented-by-choice | n/a | |
| authz-2025-03-26-security-list | 2025-03-26 | both | MUST / SHOULD | authorization | “1. Clients MUST securely store tokens … 2. Servers SHOULD enforce token expiration and rotation 3. All authorization endpoints MUST be served over HTTPS 4. Servers MUST validate redirect URIs … 5. Redirect URIs MUST be either localhost URLs or HTTPS URLs” | https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#security-considerations | not-implemented-by-choice | not-implemented-by-choice | Later revs move 3–5 to auth-server/client rows below. |
| authz-2025-03-26-implementation-reqs | 2025-03-26 | both | MUST / REQUIRED / SHOULD | authorization | “1. Implementations MUST follow OAuth 2.1 security best practices 2. PKCE is REQUIRED for all clients 3. Token rotation SHOULD be implemented 4. Token lifetimes SHOULD be limited” | https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#implementation-requirements | not-implemented-by-choice | not-implemented-by-choice | |
| authz-third-party-session-binding | 2025-03-26 | server | MAY / MUST | authorization | “MCP servers MAY support delegated authorization through third-party authorization servers … MCP servers implementing third-party authorization MUST: 1. Maintain secure mapping … 2. Validate third-party token status … 3. … lifecycle management 4. Handle third-party token expiration and renewal” | https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#session-binding-requirements | not-implemented-by-choice | n/a | Also “servers MUST: 1. Validate all redirect URIs 2. Securely store third-party credentials 3. … session timeout handling 4. Consider … token chaining 5. … error handling” (#security-considerations-1). Section removed 2025-06-18; replaced by token-passthrough ban. |
| authz-server-prm-rfc9728 | 2025-06-18..2026-07-28 | both | MUST | authorization | “MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC9728). MCP clients MUST use OAuth 2.0 Protected Resource Metadata for authorization server discovery.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#overview | not-implemented-by-choice | not-implemented-by-choice | |
| authz-prm-lists-authorization-servers | 2025-06-18..2026-07-28 | server | MUST | authorization | “The Protected Resource Metadata document returned by the MCP server MUST include the authorization_servers field containing at least one authorization server.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery#authorization-server-location | not-implemented-by-choice | n/a | |
| authz-as-metadata-rfc8414 | 2025-06-18 | both | MUST | authorization | “Authorization servers MUST provide OAuth 2.0 Authorization Server Metadata (RFC8414). MCP clients MUST use the OAuth 2.0 Authorization Server Metadata.” | https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#overview | n/a | not-implemented-by-choice | Relaxed 2025-11-25 to RFC8414 or OIDC (next row). |
| authz-as-metadata-rfc8414-or-oidc | 2025-11-25..2026-07-28 | both | MUST | authorization | “MCP authorization servers MUST provide at least one of the following discovery mechanisms: RFC8414 … OpenID Connect Discovery 1.0. MCP clients MUST support both discovery mechanisms” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#overview | n/a | not-implemented-by-choice | |
| authz-401-www-authenticate | 2025-06-18 | server | MUST | authorization | “MCP servers MUST use the HTTP header WWW-Authenticate when returning a 401 Unauthorized to indicate the location of the resource server metadata URL” | https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location | not-implemented-by-choice | n/a | Relaxed 2025-11-25 (SEP-985) to header or well-known (C17). |
| authz-prm-discovery-header-or-well-known | 2025-11-25..2026-07-28 | both | MUST | authorization | “MCP servers MUST implement one of the following discovery mechanisms … MCP clients MUST support both discovery mechanisms and use the resource metadata URL from the parsed WWW-Authenticate headers when present; otherwise, they MUST fall back to constructing and requesting the well-known URIs in the order listed above.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery#protected-resource-metadata-discovery-requirements | not-implemented-by-choice | not-implemented-by-choice | Order: path-inserted /.well-known/oauth-protected-resource/<path>, then root. |
| authz-client-parses-www-authenticate | 2025-06-18..2026-07-28 | client | MUST | authorization | “MCP clients MUST be able to parse WWW-Authenticate headers and respond appropriately to HTTP 401 Unauthorized responses” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery#protected-resource-metadata-discovery-requirements | n/a | not-implemented-by-choice (401 is surfaced as an error) | |
| authz-client-rfc8414-discovery | 2025-06-18 | client | MUST | authorization | “MCP clients MUST follow the OAuth 2.0 Authorization Server Metadata RFC8414 specification to obtain the information required to interact with the authorization server.” | https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#server-metadata-discovery | n/a | not-implemented-by-choice | |
| authz-client-as-discovery-order | 2025-11-25..2026-07-28 | client | MUST | authorization | “MCP clients MUST attempt multiple well-known endpoints … For issuer URLs with path components … clients MUST try endpoints in the following priority order” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery#authorization-server-metadata-discovery | n/a | not-implemented-by-choice | |
| authz-client-validates-issuer-in-metadata | 2026-07-28 | client | MUST / MUST NOT | authorization | “the issuer value in the document MUST be identical to the issuer identifier used to construct the well-known URL. If they differ, the client MUST NOT use the metadata.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery#authorization-server-metadata-discovery | n/a | not-implemented-by-choice | New 2026. |
| authz-client-per-as-registration-state | 2026-07-28 | client | MUST / MUST NOT | authorization | “Clients MUST maintain separate registration state (client credentials, tokens) per authorization server and MUST NOT assume that credentials valid for one authorization server will be accepted by another.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery#authorization-server-location | n/a | not-implemented-by-choice | |
| authz-resource-indicator | 2025-06-18..2026-07-28 | client | MUST | authorization | “MCP clients MUST implement Resource Indicators for OAuth 2.0 as defined in RFC 8707 … The resource parameter: 1. MUST be included in both authorization requests and token requests. 2. MUST identify the MCP server … 3. MUST use the canonical URI” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#resource-parameter-implementation | n/a | not-implemented-by-choice | Plus “MUST send this parameter regardless of whether authorization servers support it.” |
| authz-canonical-uri-guidance | 2025-06-18..2026-07-28 | both | SHOULD | authorization | “MCP clients SHOULD provide the most specific URI … implementations SHOULD accept uppercase scheme and host components … SHOULD consistently use the form without the trailing slash” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#canonical-server-uri | n/a | n/a | |
| authz-secure-token-storage | 2025-06-18..2026-07-28 | both | MUST | authorization | “Clients and servers MUST implement secure token storage and follow OAuth best practices” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#token-theft | n/a | applies to configured secrets — mcpauth redacts in traces; storage is the config file (operator’s) | |
| authz-as-short-lived-and-rotate | 2025-06-18..2026-07-28 | auth-server | SHOULD / MUST | authorization | “Authorization servers SHOULD issue short-lived access tokens … For public clients, authorization servers MUST rotate refresh tokens” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#token-theft | n/a | n/a | |
| authz-https-and-redirect-uris | 2025-06-18..2026-07-28 | both | MUST | authorization | “1. All authorization server endpoints MUST be served over HTTPS. 1. All redirect URIs MUST be either localhost or use HTTPS.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#communication-security | n/a | n/a | |
| authz-pkce | 2025-06-18 | client | MUST | authorization | “MCP clients MUST implement PKCE according to OAuth 2.1 Section 7.5.2.” | https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-code-protection | n/a | not-implemented-by-choice | |
| authz-pkce-verify-s256 | 2025-11-25..2026-07-28 | client, auth-server | MUST | authorization | “MUST verify PKCE support before proceeding … MUST use the S256 code challenge method when technically capable … If code_challenge_methods_supported is absent … MCP clients MUST refuse to proceed.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#authorization-code-protection | n/a | not-implemented-by-choice | AS offering OIDC Discovery “MUST include code_challenge_methods_supported”. |
| authz-open-redirection | 2025-06-18..2026-07-28 | client, auth-server | MUST / SHOULD | authorization | “MCP clients MUST have redirect URIs registered … Authorization servers MUST validate exact redirect URIs … MCP clients SHOULD use and verify state parameters … Authorization servers MUST take precautions” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#open-redirection | n/a | not-implemented-by-choice | |
| authz-proxy-consent-per-client | 2025-06-18..2026-07-28 | server | MUST | authorization | “MCP proxy servers using static client IDs MUST obtain user consent for each dynamically registered client before forwarding to third-party authorization servers” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#confused-deputy-problem | not-implemented-by-choice — mcpx is a proxy but runs no OAuth for either side | n/a | Directly addresses mcpx’s shape if it ever adds OAuth. |
| authz-client-registration-required | 2026-07-28 | client | MUST | authorization | “Before initiating the authorization flow, MCP clients MUST obtain a client ID through one of three registration mechanisms” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#client-registration | n/a | not-implemented-by-choice | |
| authz-registration-priority | 2025-11-25..2026-07-28 | client | SHOULD | authorization | “Clients supporting all options SHOULD follow the following priority order: 1. … pre-registered … 2. … Client ID Metadata Documents … 3. … Dynamic Client Registration … 4. Prompt the user” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration | n/a | not-implemented-by-choice | |
| authz-cimd | 2025-11-25..2026-07-28 | client, auth-server | SHOULD / MUST | authorization | “MCP clients and authorization servers SHOULD support OAuth Client ID Metadata Documents … Clients MUST host their metadata document at an HTTPS URL … The client_id URL MUST use the "https" scheme and contain a path component … MUST include at least … client_id, client_name, redirect_uris … MUST ensure the client_id value … matches” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#implementation-requirements | n/a | not-implemented-by-choice | AS: MUST validate client_id match, redirect URIs, JSON structure; SHOULD fetch, cache. Clients “SHOULD check for this capability and MAY fall back”. |
| authz-preregistration | 2025-11-25..2026-07-28 | client | SHOULD | authorization | “MCP clients SHOULD support an option for static client credentials such as those supplied by a pre-registration flow.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#pre-registration | n/a | n/a (no OAuth client) | |
| authz-dcr-application-type | 2026-07-28 | client | MUST / SHOULD | authorization | “MCP clients MUST specify an appropriate application_type during Dynamic Client Registration … Native applications … SHOULD use application_type: \"native\" … MCP clients MUST be prepared to handle registration failures” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#application-type-and-redirect-uri-constraints | n/a | not-implemented-by-choice | SEP-837. |
| authz-credentials-bound-to-issuer | 2026-07-28 | client | MUST / MUST NOT | authorization | “MUST associate those credentials with the specific authorization server that issued them, keyed by the authorization server’s issuer identifier … clients MUST NOT reuse client credentials from a different authorization server and MUST re-register” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#authorization-server-binding | n/a | not-implemented-by-choice | Pre-registered mismatch: “SHOULD surface an error”. |
| authz-scope-in-challenge | 2025-11-25..2026-07-28 | both | SHOULD / MUST / MUST NOT | authorization | “MCP servers SHOULD include a scope parameter in the WWW-Authenticate header … Clients MUST NOT assume any particular set relationship between the challenged scope set and scopes_supported. Clients MUST treat the scopes provided in the challenge as authoritative” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#scope-selection-strategy | not-implemented-by-choice | not-implemented-by-choice | 2026 adds “When re-authorizing, clients SHOULD include these scopes alongside any previously granted scopes”. |
| authz-scope-selection-order | 2025-11-25..2026-07-28 | client | SHOULD | authorization | “MCP clients SHOULD follow this priority order for scope selection: 1. Use scope parameter … 2. … use all scopes defined in scopes_supported” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#scope-selection-strategy | n/a | not-implemented-by-choice | |
| authz-insufficient-scope-403 | 2025-11-25..2026-07-28 | server | SHOULD | authorization | “the server SHOULD respond with: HTTP 403 Forbidden … WWW-Authenticate header with … error=\"insufficient_scope\" … scope= … resource_metadata” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#runtime-insufficient-scope-errors | not-implemented-by-choice | n/a | What scope contains changed (C18). |
| authz-all-scopes-single-challenge | 2026-07-28 | server | SHOULD | authorization | “servers SHOULD include all scopes required for the current operation in a single challenge.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#runtime-insufficient-scope-errors | not-implemented-by-choice | n/a | |
| authz-step-up | 2025-11-25..2026-07-28 | client | SHOULD / MAY | authorization | “Clients SHOULD respond to these errors by requesting a new access token with an increased set of scopes … Clients SHOULD implement retry limits” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#step-up-authorization-flow | n/a | not-implemented-by-choice | 2026 step 2: union of previously requested and challenged scopes (C18). |
| authz-server-scope-hierarchy | 2026-07-28 | server | MUST | authorization | “Servers MUST account for scope hierarchies, where a broader scope implies narrower ones” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#step-up-authorization-flow | not-implemented-by-choice | n/a | |
| authz-cimd-as-security | 2025-11-25..2026-07-28 | auth-server | MUST / SHOULD | authorization | “authorization servers MUST consider the security implications … SHOULD consider SSRF … SHOULD display additional warnings for localhost-only redirect URIs … MUST clearly display the redirect URI hostname” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#client-id-metadata-document-security | n/a | n/a | |
| authz-iss-validation | 2026-07-28 | client, auth-server | MUST / SHOULD / MUST NOT | authorization | “the client MUST record the issuer value … authorization servers SHOULD include the iss parameter … MUST advertise … authorization_response_iss_parameter_supported … clients MUST apply the validation in RFC9207 Section 2.4 before transmitting the authorization code … MUST NOT apply scheme or host case folding … on mismatch the client MUST NOT act on or display error” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#authorization-response-validation | n/a | not-implemented-by-choice | SEP-2468. Table: supported=true + iss absent → reject. |
| authz-refresh-tokens | 2026-07-28 | client, server | MUST / SHOULD / MAY / MUST NOT / SHOULD NOT | authorization | “MUST keep refresh tokens confidential … SHOULD include refresh_token in their grant_types … MAY add offline_access … MUST NOT assume refresh tokens will be issued … MCP Servers … SHOULD NOT include offline_access” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#refresh-tokens | not-implemented-by-choice | not-implemented-by-choice | |
| authz-follow-security-considerations | 2025-06-18..2026-07-28 | both | MUST | authorization | “Implementations MUST follow OAuth 2.1 security best practices as laid out in OAuth 2.1 Section 7.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations | not-implemented-by-choice | not-implemented-by-choice | 2026 also: “Implementations of this specification MUST follow the normative security requirements in Security Considerations”. |
Cancellation, progress, pagination, logging, completion, tasks, caching, MRTR, subscriptions
| id | revs | side | level | area | quote | url | mcpx-as-server | mcpx-as-client | changes |
|---|---|---|---|---|---|---|---|---|---|
| cancellation-ref-same-direction-in-progress | 2024-11-05..2025-11-25 | both | MUST | cancellation | “Cancellation notifications MUST only reference requests that: Were previously issued in the same direction; Are believed to still be in-progress” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#behavior-requirements | yes — mcpx cancels only its own negative-id server→client requests on timeout (mcpserver/conn.go:263-266) | yes — cancels only its own timed-out request id (mcpclient/client.go:710-716) | narrowed in 2026 (next row) |
| cancellation-ref-client-issued-in-progress | 2026-07-28 | both | MUST | cancellation | “Cancellation notifications MUST only reference requests that: Were previously issued by the client; Are believed to still be in-progress” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#behavior-requirements | yes (vacuous: sends none to modern clients) | yes | “same direction” → “by the client” |
| cancellation-client-must-not-cancel-initialize | 2024-11-05..2025-11-25 | client | MUST NOT | cancellation | “The initialize request MUST NOT be cancelled by clients” (schema: “A client MUST NOT attempt to cancel its initialize request.”) | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#behavior-requirements | n/a | no — initialize goes through call→roundTrip, which sends notifications/cancelled on any ctx timeout (mcpclient/client.go:269, :710-716) | removed in 2026 (no initialize) |
| cancellation-task-use-tasks-cancel | 2025-11-25 | both | MUST | cancellation | “For task-augmented requests, the tasks/cancel request MUST be used instead of the notifications/cancelled notification.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#behavior-requirements | yes (mcpx never cancels a task it requested) | yes (never task-augments upstream) | new in 2025-11-25; gone in 2026 core |
| cancellation-requestid-required-non-task-forbidden-task | 2025-11-25 | both | MUST / MUST NOT | cancellation | schema CancelledNotificationParams.requestId: “This MUST be provided for cancelling non-task requests. This MUST NOT be used for cancelling tasks (use the tasks/cancel request instead).” | https://modelcontextprotocol.io/specification/2025-11-25/schema#cancellednotificationparams | yes | yes | schema-only phrasing; 2024–2025-06 schemas have no such text |
| cancellation-receiver-should-stop-free-no-response | 2024-11-05..2025-11-25 | both | SHOULD | cancellation | “Receivers of cancellation notifications SHOULD: Stop processing the cancelled request; Free associated resources; Not send a response for the cancelled request” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#behavior-requirements | no — recorded only (mcpserver/server.go:1521-1538); upstream call not interrupted (docs/protocol.md §6) | not-implemented — mcpclient has no handler for an inbound notifications/cancelled (legacy server cancelling its own elicitation) | receiver narrowed to server in 2026 |
| cancellation-server-should-stop-free-no-response | 2026-07-28 | server | SHOULD | cancellation | “Servers receiving cancellation notifications SHOULD: Stop processing…; Free associated resources; Not send a response…” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#behavior-requirements | no — as above | n/a | side narrowed |
| cancellation-receiver-may-ignore | 2024-11-05..2025-11-25 | both | MAY | cancellation | “Receivers MAY ignore cancellation notifications if: The referenced request is unknown; Processing has already completed; The request cannot be cancelled” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#behavior-requirements | yes | yes | |
| cancellation-server-may-ignore | 2026-07-28 | server | MAY | cancellation | “Servers MAY ignore cancellation notifications if: …” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#behavior-requirements | yes | n/a | side narrowed |
| cancellation-sender-ignore-late-response | 2024-11-05..2025-11-25 | both | SHOULD | cancellation | “The sender of the cancellation notification SHOULD ignore any response to the request that arrives afterward” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#behavior-requirements | yes — pending entry deleted on timeout (conn.go:249-253) | yes — pending entry deleted (client.go:710-713) | |
| cancellation-client-ignore-late-response | 2026-07-28 | client | SHOULD | cancellation | “The client SHOULD ignore any response to the cancelled request that arrives afterward” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#behavior-requirements | n/a | yes | side narrowed |
| cancellation-handle-races | 2024-11-05..2026-07-28 | both | MUST | cancellation | “Both parties MUST handle these race conditions gracefully” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#timing-considerations | yes (cancel is a map write; late cancel harmless) | yes | |
| cancellation-log-reasons | 2024-11-05..2026-07-28 | both | SHOULD | cancellation | “Both parties SHOULD log cancellation reasons for debugging” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#implementation-notes | yes — reason kept and passed to OnCancel | no evidence of logging inbound reasons | |
| cancellation-ui-indicate | 2024-11-05..2026-07-28 | both | SHOULD | cancellation | “Application UIs SHOULD indicate when cancellation is requested” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#implementation-notes | n/a (no UI) | n/a (no UI) | |
| cancellation-ignore-invalid | 2024-11-05..2026-07-28 | both | SHOULD | cancellation | “Invalid cancellation notifications SHOULD be ignored: Unknown request IDs; Already completed requests; Malformed notifications” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#error-handling | yes — malformed params dropped (server.go:1526) | yes (ignored entirely) | |
| cancellation-processing-should-cease | 2024-11-05..2026-07-28 | both (receiver) | SHOULD | cancellation | schema: “This notification indicates that the result will be unused, so any associated processing SHOULD cease.” | https://modelcontextprotocol.io/specification/2026-07-28/schema#cancellednotification | no (see stop row) | not-implemented | |
| cancellation-client-should-send | 2026-07-28 | client | SHOULD | cancellation | “A client SHOULD send a cancellation notification to indicate that a request it previously issued should be terminated.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation | n/a | yes | new; conflicts with HTTP rule, see Conflicts C3 |
| cancellation-server-must-send-cancelled-on-listen-teardown | 2026-07-28 | server | MUST | cancellation | “A server MUST send notifications/cancelled referencing a subscriptions/listen request ID when it tears down that subscription stream” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation | no — nothing sends it; comment at server.go:623-627 claims “the transport sends the terminating notifications/cancelled”, no such code exists (only conn.go:265, which is for elicitation timeouts) | n/a | new; conflicts with subscriptions page, see C1 |
| cancellation-server-only-for-listen | 2026-07-28 | server | MUST NOT / MUST | cancellation | “Servers MUST NOT send notifications/cancelled for any other purpose.” + “Server-sent cancellation notifications MUST reference a subscriptions/listen request” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#behavior-requirements | yes for modern peers — conn.go:265 only fires on legacy wire requests; a modern connection never gets a server request | n/a | legal→forbidden vs ≤2025-11-25, see C2 |
| cancellation-http-disconnect-is-cancel | 2026-07-28 | server | MUST | cancellation | “Streamable HTTP: Closing the SSE response stream is the cancellation signal. The server MUST treat a client disconnect as cancellation of that request.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#transport-specific-cancellation | no — no path from request ctx to the upstream call (docs/protocol.md §6) | n/a | reverses ≤2025-11-25 transport rule — C4 |
| cancellation-http-no-messages-after-close | 2026-07-28 | server | MUST NOT / SHOULD | cancellation | “The server SHOULD stop work on the cancelled request as soon as practical and MUST NOT send any further messages for it.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#cancellation | yes (stream gone, nothing written) — stop-work SHOULD: no | n/a | |
| cancellation-stdio-client-must-send | 2026-07-28 | client | MUST | cancellation | “stdio: There is no per-request stream to close. The client MUST send a notifications/cancelled notification referencing the request ID.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#transport-specific-cancellation | n/a | yes on stdio; on legacy-era HTTP it also POSTs notifications/cancelled; on a 2026-07-28 HTTP upstream it sends none — cancelled() returns early for an HTTP transport with a meta version set (internal/mcpclient/modern.go:582) and closing the response stream is the cancellation signal, which 2026 calls the expected behaviour for that transport | new |
| cancellation-timeouts-establish | 2026-07-28 | both | SHOULD | cancellation | “Implementations SHOULD establish timeouts for all sent requests” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#timeouts | yes (proto.askTimeout) | yes (pool.callTimeout) | new |
| cancellation-timeout-then-cancel | 2026-07-28 | both | SHOULD | cancellation | “the sender SHOULD cancel the request and stop waiting for a response” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#timeouts | yes (legacy only) | yes | new |
| cancellation-timeouts-per-request-configurable | 2026-07-28 | both | SHOULD | cancellation | “SDKs and other middleware SHOULD allow these timeouts to be configured on a per-request basis.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#timeouts | no — global settings | no — per-server/global | new; mcpx is “middleware” |
| cancellation-max-timeout-despite-progress | 2026-07-28 | both | SHOULD (+MAY) | cancellation | “Implementations MAY choose to reset the timeout clock when receiving a progress notification … SHOULD always enforce a maximum timeout” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#timeouts | yes (fixed timeout, no reset) | yes | new |
| progress-token-string-or-int | 2024-11-05..2026-07-28 | both (requester) | MUST | progress | “Progress tokens MUST be a string or integer value” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress#progress-flow | n/a (as a server mcpx never requests progress; a host’s token is relayed back verbatim) | yes — the token mcpx sends upstream is a string, mcpx-<n> (#212) | |
| progress-token-unique-sender | 2024-11-05..2025-11-25 | both | MUST | progress | “Progress tokens can be chosen by the sender using any means, but MUST be unique across all active requests.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#progress-flow | yes (vacuous) | yes (vacuous) | |
| progress-token-unique-client | 2026-07-28 | client | MUST | progress | “Progress tokens can be chosen by the client using any means, but MUST be unique across all active requests.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress#progress-flow | n/a | yes (vacuous) | sender→client |
| progress-receiver-may-send | 2024-11-05..2025-11-25 | both | MAY | progress | “The receiver MAY then send progress notifications containing: The original progress token; The current progress value so far; An optional “total” value“ | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#progress-flow | not-implemented-by-choice — no notifications/progress in mcpserver; upstream progress is not forwarded downstream | n/a (never receives requests with tokens) | |
| progress-server-may-send | 2026-07-28 | server | MAY | progress | “The server MAY send progress notifications to report the status of requests the client has issued.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress | not-implemented-by-choice | n/a | client can no longer send progress — C6 |
| progress-must-increase | 2024-11-05..2026-07-28 | both (notifier) | MUST | progress | “The progress value MUST increase with each notification, even if the total is unknown.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress#progress-flow | yes (vacuous) | n/a | schema says lowercase “should increase” — C7 |
| progress-message-human-readable | 2025-03-26..2026-07-28 | both (notifier) | SHOULD | progress | “The message field SHOULD provide relevant human readable progress information.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress#progress-flow | yes (vacuous) | n/a | message added 2025-03-26 |
| progress-only-active-tokens | 2024-11-05..2026-07-28 | both (notifier) | MUST | progress | “Progress notifications MUST only reference tokens that: Were provided in an active request; Are associated with an in-progress operation” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress#behavior-requirements | yes (vacuous) | n/a | |
| progress-receiver-may-skip | 2024-11-05..2025-11-25 | both | MAY | progress | “Receivers of progress requests MAY: Choose not to send any progress notifications; Send notifications at whatever frequency…; Omit the total value if unknown” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#behavior-requirements | yes | yes | |
| progress-server-may-skip | 2026-07-28 | server | MAY | progress | “Servers receiving a request with a progress token MAY: Choose not to send any progress notifications; …” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress#behavior-requirements | yes | n/a | side narrowed |
| progress-task-token-lives-with-task | 2025-11-25 | both (receiver) | MUST | progress | “the progressToken provided in the original request MUST continue to be used for progress notifications throughout the task’s lifetime, even after the CreateTaskResult has been returned” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#behavior-requirements | yes (vacuous) | n/a | removed from 2026 core |
| progress-task-same-token | 2025-11-25 | both (receiver) | MUST | progress | “Progress notifications for tasks MUST use the same progressToken that was provided in the initial task-augmented request” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#behavior-requirements | yes (vacuous) | n/a | |
| progress-task-stop-at-terminal | 2025-11-25 | both (receiver) | MUST | progress | “Progress notifications for tasks MUST stop after the task reaches a terminal status (completed, failed, or cancelled)” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#behavior-requirements | yes (vacuous) | n/a | |
| progress-track-tokens | 2024-11-05..2026-07-28 | both | SHOULD | progress | “Senders and receivers SHOULD track active progress tokens” (2026: “Clients and servers …”) | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress#implementation-notes | n/a | no — OnProgress passes every token through untracked (client.go:487-493) | wording only |
| progress-rate-limit | 2024-11-05..2026-07-28 | both | SHOULD | progress | “Both parties SHOULD implement rate limiting to prevent flooding” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress#implementation-notes | yes (sends none) | no inbound rate limiting | |
| progress-stop-after-completion | 2024-11-05..2026-07-28 | both (notifier) | MUST | progress | “Progress notifications MUST stop after completion” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress#implementation-notes | yes (vacuous) | n/a | |
| progress-http-notifications-relate-to-request | 2026-07-28 | server | MUST | progress | “The server MAY send JSON-RPC notifications — for example, notifications/progress or notifications/message — before the final response. These notifications MUST relate to the originating client request.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#receiving-messages | yes (vacuous) | n/a | transport page; listed here because it scopes progress/log delivery |
| pagination-no-fixed-page-size | 2024-11-05..2026-07-28 | client | MUST NOT | pagination | “Page size is determined by the server, and clients MUST NOT assume a fixed page size” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination#pagination-model | n/a | yes — loops on nextCursor (client.go:740-770, :815-825) | |
| pagination-stable-cursors | 2024-11-05..2026-07-28 | server | SHOULD | pagination | “Servers SHOULD: Provide stable cursors” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination#implementation-guidelines | partial — cursor is base64 o:<offset> (server.go:1499-1501); shifts if the aggregated list changes between pages | n/a | |
| pagination-invalid-cursor-graceful | 2024-11-05..2026-07-28 | server | SHOULD | pagination | “Handle invalid cursors gracefully” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination#implementation-guidelines | yes (silently restarts at 0) | n/a | |
| pagination-missing-nextcursor-end | 2024-11-05..2026-07-28 | client | SHOULD | pagination | “Clients SHOULD: Treat a missing nextCursor as the end of results” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination#implementation-guidelines | n/a | yes | |
| pagination-support-both-flows | 2024-11-05..2026-07-28 | client | SHOULD | pagination | “Support both paginated and non-paginated flows” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination#implementation-guidelines | n/a | yes | |
| pagination-cursor-opaque | 2024-11-05..2026-07-28 | client | MUST | pagination | “Clients MUST treat cursors as opaque tokens: Don’t make assumptions about cursor format; Don’t attempt to parse or modify cursors” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination#implementation-guidelines | n/a | yes | |
| pagination-no-persist-across-sessions | 2024-11-05..2025-11-25 | client | MUST | pagination | “Don’t persist cursors across sessions” | https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination#implementation-guidelines | n/a | yes (cursors are loop-local) | removed in 2026 (replaced by next row) |
| pagination-empty-cursor-not-end | 2026-07-28 | client | MUST NOT | pagination | “Don’t make any determination based on cursor value other than whether a non-null value was provided (e.g. an empty string is a valid cursor and thus MUST NOT be treated as the end of results)” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination#implementation-guidelines | yes (mcpx never emits "") | no — if res.NextCursor == "" { break } (client.go:744, :766, :820); the string field cannot distinguish absent from "" | new; legal-to-end → forbidden — C5 |
| pagination-invalid-cursor-32602 | 2024-11-05..2026-07-28 | server | SHOULD | pagination | “Invalid cursors SHOULD result in an error with code -32602 (Invalid params).” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination#error-handling | no — undecodable cursor → page 0, out-of-range → empty page (server.go:1483-1491) | n/a | |
| logging-declare-capability | 2024-11-05..2026-07-28 | server | MUST | logging | “Servers that emit log message notifications MUST declare the logging capability” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#capabilities | yes (vacuous: emits none, declares none — server.go:1398-1403). docs/protocol.md §2.2 says logging ✓ for legacy and “removed in 2026-07-28”; both wrong vs code and vs spec (2026 deprecates, keeps logging? in schema line 808) | n/a | 2026 adds deprecation banner |
| logging-client-may-setlevel | 2024-11-05..2025-11-25 | client | MAY | logging | “To configure the minimum log level, clients MAY send a logging/setLevel request” | https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#setting-log-level | accepted, {} (server.go:565-571) | yes — SetLevel (client.go:574) | removed in 2026 (changelog item 5) |
| logging-setlevel-at-or-above | 2024-11-05..2025-11-25 | server | should (schema) | logging | schema: “The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message.” | https://modelcontextprotocol.io/specification/2025-11-25/schema#setlevelrequestparams | yes (vacuous) | n/a | |
| logging-unset-server-chooses | 2024-11-05..2025-11-25 | server | MAY | logging | schema: “If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.” | https://modelcontextprotocol.io/specification/2025-11-25/schema#loggingmessagenotification | yes | n/a (mcpx receives upstream logs from any era) | legal→forbidden in 2026 — C8 |
| logging-no-message-without-loglevel | 2026-07-28 | server | MUST NOT | logging | “include io.modelcontextprotocol/logLevel in the request’s _meta. The server MUST NOT emit notifications/message for a request that does not include this field.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#per-request-log-level | yes (vacuous) | not-implemented — never sends logLevel upstream, yet listens for notifications/message (client.go:480-486), so from a conforming modern server it gets none | new; schema RequestMetaObject repeats it |
| logging-request-scoped-only | 2026-07-28 | server | MUST NOT | logging | “notifications/message is request-scoped: the server MUST NOT deliver it on a subscriptions/listen stream or on any stream other than the one carrying the response to the request that set the log level.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#per-request-log-level | yes (vacuous) | n/a | new |
| logging-may-send-before-response | 2026-07-28 | server | MAY | logging | “the server MAY send notifications/message notifications at or above the requested level on the response stream of that request, before the final response.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#per-request-log-level | not-implemented-by-choice | n/a | new |
| logging-errors-setlevel | 2024-11-05..2025-11-25 | server | SHOULD | logging | “Servers SHOULD return standard JSON-RPC errors …: Invalid log level: -32602 (Invalid params); Configuration errors: -32603 (Internal error)” | https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging#error-handling | no — logging/setLevel returns {} for any level | n/a | |
| logging-invalid-loglevel-reject-request | 2026-07-28 | server | SHOULD | logging | “If the io.modelcontextprotocol/logLevel value carried in a request’s _meta is not a recognized log level, the server SHOULD reject that request with a standard JSON-RPC error” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#error-handling | no — _meta.logLevel never inspected | n/a | now rejects the carrying request (e.g. a tools/call) |
| logging-server-hygiene | 2024-11-05..2026-07-28 | server | SHOULD | logging | “Servers SHOULD: Rate limit log messages; Include relevant context in data field; Use consistent logger names; Remove sensitive information” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#implementation-considerations | yes (vacuous) | n/a | |
| logging-no-secrets-pii | 2024-11-05..2026-07-28 | both (emitter) | MUST NOT | logging | “Log messages MUST NOT contain: Credentials or secrets; Personal identifying information; Internal system details that could aid attacks” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#security | yes (vacuous) | relevant only if mcpx ever relays upstream notifications/message downstream — it does not | |
| logging-impl-validate-control | 2024-11-05..2026-07-28 | both | SHOULD | logging | “Implementations SHOULD: Rate limit messages; Validate all data fields; Control log access; Monitor for sensitive content” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#security | yes (vacuous) | partial | |
| logging-deprecated | 2026-07-28 | both | SHOULD NOT / SHOULD | logging | “New implementations SHOULD NOT adopt it; existing implementations SHOULD migrate to logging to stderr for stdio transports, or to OpenTelemetry” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging | yes (does not adopt) | n/a | new (SEP-2577) |
| completion-declare-capability | 2025-03-26..2026-07-28 | server | MUST | completion | “Servers that support completions MUST declare the completions capability” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion#capabilities | yes (server.go:1402) | yes — asks upstream only if it declared completions (docs/protocol.md §4.3) | absent in 2024-11-05 (no capability existed) — C9 |
| completion-max-100 | 2024-11-05..2026-07-28 | server | descr. + schema | completion | “Maximum 100 items per response”; schema: “Must not exceed 100 items.” @maxItems 100 | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion#completion-results | unverified — did not check truncation in s.complete | should truncate upstream values if relaying | |
| completion-server-should | 2024-11-05..2026-07-28 | server | SHOULD | completion | “Servers SHOULD: Return suggestions sorted by relevance; Implement fuzzy matching where appropriate; Rate limit completion requests; Validate all inputs” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion#implementation-considerations | yes — suggestions ranked by relevance (exact > prefix > substring tiers, rankCompletions in internal/daemon/ops.go, #2388) and completion requests rate limited per caller (completion.perMinute) and per server (completion.serverPerMinute) at the /v1 choke point (#2387); inputs validated (see completion-errors) | n/a | retired the stale wording that said values were matched by substring and sorted alphabetically — local matching was prefix-only (hasPrefixFold) before #2388 |
| completion-client-should | 2024-11-05..2026-07-28 | client | SHOULD | completion | “Clients SHOULD: Debounce rapid completion requests; Cache completion results where appropriate; Handle missing or partial results gracefully” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion#implementation-considerations | n/a | partial — falls back to cache with "source":"cache" | |
| completion-security | 2024-11-05..2026-07-28 | both | MUST | completion | “Implementations MUST: Validate all completion inputs; Implement appropriate rate limiting; Control access to sensitive suggestions; Prevent completion-based information disclosure” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion#security | partial — inputs validated and rate limiting implemented per caller (completion.perMinute) and per server (completion.serverPerMinute) (#2387); control of access to sensitive suggestions and prevention of completion-based information disclosure still pending principals (#1968) / permissive-auth (#1972) | n/a | |
| completion-errors | 2025-03-26..2026-07-28 | server | SHOULD | completion | “Method not found: -32601 (Capability not supported); Invalid prompt name: -32602; Missing required arguments: -32602; Internal errors: -32603” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion#error-handling | unverified | n/a | added 2025-03-26 |
| completion-context-arguments | 2025-06-18..2026-07-28 | client | should (lowercase) | completion | “clients should include previous completions in the context.arguments object to provide context for subsequent requests.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion#requesting-completions | n/a | pass-through of caller’s params | added 2025-06-18 |
| tasks-declare-capability | 2025-11-25 | both (receiver) | MUST | tasks | “Servers and clients that support task-augmented requests MUST declare a tasks capability during initialization.” (schema: “receivers MUST declare support for task augmentation of specific request types”) | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#capabilities | yes | not-implemented-by-choice (mcpx declares no client tasks; upstream cannot task-augment sampling/elicitation to it) | → extension in 2026 |
| tasks-augment-only-if-declared | 2025-11-25 | both (requestor) | SHOULD | tasks | “Requestors SHOULD only augment requests with a task if the corresponding capability has been declared by the receiver.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#capability-negotiation | yes (never augments) | yes (never augments) | |
| tasks-no-capability-no-tasks | 2025-11-25 | both (requestor) | SHOULD NOT | tasks | “If capabilities.tasks is not defined, the peer SHOULD NOT attempt to create tasks during requests.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#capability-negotiation | yes | yes | |
| tasks-tool-no-cap-no-augment | 2025-11-25 | client | MUST NOT | tasks | “If a server’s capabilities do not include tasks.requests.tools.call, then clients MUST NOT attempt to use task augmentation on that server’s tools” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation | n/a | yes | |
| tasks-tool-forbidden-default | 2025-11-25 | both | MUST NOT / SHOULD | tasks | “If execution.taskSupport is not present or "forbidden", clients MUST NOT attempt to invoke the tool as a task. Servers SHOULD return a -32601 (Method not found) error if a client attempts to do so. This is the default behavior.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation | no — mcpx publishes no execution.taskSupport on any tool (no occurrence in code), so every tool defaults to forbidden, yet it accepts task on every tools/call (server.go:481-501) instead of -32601. Net: its tasks.requests.tools.call declaration is unusable by a conforming client | n/a | |
| tasks-tool-optional | 2025-11-25 | client | MAY | tasks | “If execution.taskSupport is "optional", clients MAY invoke the tool as a task or as a normal request.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation | not-implemented (never advertises optional) | n/a | |
| tasks-tool-required | 2025-11-25 | both | MUST | tasks | “If execution.taskSupport is "required", clients MUST invoke the tool as a task. Servers MUST return a -32601 (Method not found) error if a client does not attempt to do so.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation | n/a (never required) | n/a — mcpx reads upstream execution.taskSupport to serve its own clients, but never task-augments the upstream call; a required upstream tool that enforces it would fail | conflicts with -32600 MAY — C10 |
| tasks-requestor-may-ttl | 2025-11-25 | both (requestor) | MAY | tasks | “Requestors MAY include a ttl value indicating the desired task lifetime duration (in milliseconds) since its creation.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#creating-tasks | honoured (wantsTask) | n/a | |
| tasks-poll-respect-interval | 2025-11-25 | both (requestor) | SHOULD | tasks | “Requestors SHOULD respect the pollInterval provided in responses when determining polling frequency.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#getting-tasks | n/a | n/a (no upstream tasks) | |
| tasks-poll-until-terminal | 2025-11-25 | both (requestor) | SHOULD | tasks | “Requestors SHOULD continue polling until the task reaches a terminal status … or until encountering the input_required status.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#getting-tasks | n/a | n/a | |
| tasks-sse-client-may-disconnect | 2025-11-25 | client | MAY / MUST | tasks | “clients MAY disconnect from an SSE stream opened by the server in response to a tasks/get request at any time … all implementations MUST continue to comply with the existing Streamable HTTP transport specification.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#getting-tasks | yes (tasks/get answered as JSON) | n/a | same note on tasks/result |
| tasks-status-notification-optional | 2025-11-25 | both | MAY / MUST NOT | tasks | “Receivers MAY send notifications/tasks/status notifications when a task’s status changes.” “Requestors MUST NOT rely on receiving the notifications/tasks/status notification” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-notifications | not-implemented-by-choice (sends none) | yes — dropped, polls (docs/protocol.md §4.2) | |
| tasks-status-notification-no-related-task | 2025-11-25 | both (receiver) | SHOULD NOT | tasks | “the notifications/tasks/status notification SHOULD NOT include the io.modelcontextprotocol/related-task metadata” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-notifications | yes (vacuous) | n/a | |
| tasks-receiver-without-cap-ignore | 2025-11-25 | both (receiver) | MUST | tasks | “Receivers that do not declare the task capability for a request type MUST process requests of that type normally, ignoring any task-augmentation metadata if present.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-support-and-handling | yes — task honoured only on tools/call | n/a — mcpx declares no client task caps; unverified whether it strips task from an upstream sampling/createMessage | |
| tasks-receiver-may-require | 2025-11-25 | both (receiver) | MAY | tasks | “Receivers that declare the task capability for a request type MAY return an error for non-task-augmented requests” / “-32600 (Invalid request)” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-support-and-handling | not used | n/a | -32600 here vs -32601 for tools — C10 |
| tasks-id-string-receiver-unique | 2025-11-25 | both (receiver) | MUST | tasks | “Task IDs MUST be a string value.” “…MUST be generated by the receiver…” “…MUST be unique among all tasks controlled by the receiver.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-id-requirements | yes — tsk- + 16 hex from crypto/rand (tasks/tasks.go:86-90) | n/a | |
| tasks-begin-working | 2025-11-25 | both (receiver) | MUST | tasks | “Tasks MUST begin in the working status when created.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-status-lifecycle | yes | n/a | |
| tasks-valid-transitions | 2025-11-25 | both (receiver) | MUST | tasks | “Receivers MUST only transition tasks through the following valid paths” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-status-lifecycle | yes — SetStatus is a no-op on terminal; not otherwise validated (tasks.go:185-191) | n/a | |
| tasks-terminal-immutable | 2025-11-25 | both (receiver) | MUST NOT | tasks | “Tasks with a completed, failed, or cancelled status are in a terminal state and MUST NOT transition to any other status” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-status-lifecycle | yes (tasks.go:122-124, :201) | n/a | extension says cancel may still end non-cancelled — C11 |
| tasks-input-required-move | 2025-11-25 | both (receiver) | SHOULD | tasks | “When the task receiver has messages for the requestor that are necessary to complete the task, the receiver SHOULD move the task to the input_required status.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#input-required-status | yes (SetTaskStatus) | n/a | |
| tasks-input-request-related-task | 2025-11-25 | both (receiver) | MUST | tasks | “The receiver MUST include the io.modelcontextprotocol/related-task metadata in the request to associate it with the task.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#input-required-status | no — no related-task anywhere in mcpx; an elicitation/create raised by a task-run tools/call goes out untagged | n/a | |
| tasks-requestor-input-required-call-result | 2025-11-25 | both (requestor) | SHOULD | tasks | “When the requestor encounters the input_required status, it SHOULD preemptively call tasks/result.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#input-required-status | n/a | n/a | |
| tasks-leave-input-required | 2025-11-25 | both (receiver) | SHOULD | tasks | “When the receiver receives all required input, the task SHOULD transition out of input_required status” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#input-required-status | unverified | n/a | |
| tasks-sse-any-stream | 2025-11-25 | client | SHOULD | tasks | “clients SHOULD expect messages to be delivered on any SSE stream, including the HTTP GET stream.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#input-required-status | n/a | n/a | |
| tasks-sse-no-upgrade-get | 2025-11-25 | server | SHOULD NOT | tasks | “Where possible, servers SHOULD NOT upgrade to an SSE stream in response to a tasks/get request” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#input-required-status | yes | n/a | |
| tasks-timestamps | 2025-11-25 | both (receiver) | MUST | tasks | “Receivers MUST include a createdAt … timestamp in all task responses” / “…MUST include a lastUpdatedAt …” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#ttl-and-resource-management | yes (tasks.go:30-31) | n/a | |
| tasks-ttl-override-report | 2025-11-25 | both (receiver) | MAY / MUST | tasks | “Receivers MAY override the requested ttl duration.” “Receivers MUST include the actual ttl duration (or null for unlimited) in tasks/get responses.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#ttl-and-resource-management | yes | n/a | |
| tasks-delete-after-ttl | 2025-11-25 | both (receiver) | MAY | tasks | “After a task’s ttl lifetime has elapsed, receivers MAY delete the task and its results, regardless of the task status.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#ttl-and-resource-management | yes (tasks.go:148-153, also cancels the work) | n/a | |
| tasks-poll-interval | 2025-11-25 | both | MAY / SHOULD | tasks | “Receivers MAY include a pollInterval value … Requestors SHOULD respect this value when provided.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#ttl-and-resource-management | yes (1000 ms) | n/a | |
| tasks-return-createtaskresult | 2025-11-25 | both (receiver) | MUST / SHOULD | tasks | “Receivers that accept a task-augmented request MUST return a CreateTaskResult as the response. This result SHOULD be returned as soon as possible after accepting the task.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#result-retrieval | yes ({"task": …}, server.go:500) | n/a | |
| tasks-result-terminal-final | 2025-11-25 | both (receiver) | MUST | tasks | “…for a task in a terminal status … it MUST return the final result of the underlying request, whether that is a successful result or a JSON-RPC error.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#result-retrieval | yes (mcpserver/tasks.go:104-118) | n/a | |
| tasks-result-blocks | 2025-11-25 | both (receiver) | MUST | tasks | “…in any other non-terminal status (working or input_required), it MUST block the response until the task reaches a terminal status.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#result-retrieval | yes (st.Result(ctx,…)) | n/a | removed in extension (no tasks/result) |
| tasks-result-exact | 2025-11-25 | both (receiver) | MUST | tasks | “receivers MUST return from tasks/result exactly what the underlying request would have returned” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#result-retrieval | yes; except a cancelled task’s tasks/result returns whatever the aborted goroutine yielded — unverified | n/a | |
| tasks-related-task-all-messages | 2025-11-25 | both | MUST | tasks | “All requests, notifications, and responses related to a task MUST include the io.modelcontextprotocol/related-task key in their _meta field” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#associating-task-related-messages | no | n/a | internally contradicted — C12 |
| tasks-taskid-param-authoritative | 2025-11-25 | both | MUST / SHOULD NOT | tasks | “the taskId parameter in the request MUST be used as the source of truth … Requestors SHOULD NOT include io.modelcontextprotocol/related-task metadata in these requests, and receivers MUST ignore such metadata” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#associating-task-related-messages | yes | n/a | |
| tasks-no-related-task-in-get-list-cancel-results | 2025-11-25 | both (receiver) | SHOULD NOT | tasks | “for the tasks/get, tasks/list, and tasks/cancel operations, receivers SHOULD NOT include io.modelcontextprotocol/related-task metadata in the result messages” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#associating-task-related-messages | yes | n/a | |
| tasks-result-response-related-task | 2025-11-25 | both (receiver) | MUST | tasks | “The tasks/result operation MUST include this metadata in its response, as the result structure itself does not contain the task ID.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#related-task-metadata | no — reply(result) untagged (mcpserver/tasks.go:118) | n/a | |
| tasks-list-paginate | 2025-11-25 | both (receiver) | SHOULD / MUST | tasks | “Receivers SHOULD use cursor-based pagination…” “Receivers MUST include a nextCursor in the response if more tasks are available.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-listing | yes (mcpserver/tasks.go:89-95) | n/a | tasks/list removed in extension |
| tasks-list-cursor-opaque | 2025-11-25 | both (requestor) | MUST | tasks | “Requestors MUST treat cursors as opaque tokens and not attempt to parse or modify them.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-listing | n/a | n/a | |
| tasks-get-implies-list | 2025-11-25 | both (receiver) | MUST | tasks | “If a task is retrievable via tasks/get for a requestor, it MUST be retrievable via tasks/list for that requestor.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-listing | yes (one global store) | n/a | |
| tasks-cancel-terminal-reject | 2025-11-25 | both (receiver) | MUST | tasks | “Receivers MUST reject cancellation requests for tasks already in a terminal status … with error code -32602 (Invalid params).” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-cancellation | no — Cancel returns the terminal snapshot as success (tasks/tasks.go:194-206, mcpserver/tasks.go:120-125) | n/a | |
| tasks-cancel-transition-before-response | 2025-11-25 | both (receiver) | SHOULD / MUST | tasks | “receivers SHOULD attempt to stop the task execution and MUST transition the task to cancelled status before sending the response.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-cancellation | yes | n/a | extension: cooperative, not obliged — C11 |
| tasks-cancelled-sticky | 2025-11-25 | both (receiver) | MUST | tasks | “Once a task is cancelled, it MUST remain in cancelled status even if execution continues to completion or fails.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-cancellation | yes (tasks.go:122-124) | n/a | reversed by extension — C11 |
| tasks-cancelled-may-delete | 2025-11-25 | both | MAY / SHOULD NOT | tasks | “receivers MAY delete cancelled tasks at their discretion at any time” “Requestors SHOULD NOT rely on cancelled tasks being retained” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-cancellation | yes | n/a | |
| tasks-protocol-errors | 2025-11-25 | both (receiver) | MUST | tasks | “Receivers MUST return standard JSON-RPC errors …: Invalid or nonexistent taskId …: -32602; Invalid or nonexistent cursor in tasks/list: -32602; Attempt to cancel a task already in a terminal status: -32602; Internal errors: -32603” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#protocol-errors | partial — unknown taskId → -32602 yes; bad cursor → silently page 0 (no); terminal cancel → success (no) | n/a | |
| tasks-informative-errors | 2025-11-25 | both (receiver) | SHOULD | tasks | “Receivers SHOULD provide informative error messages to describe the cause of errors.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#protocol-errors | yes (“no task X; it may have expired”) | n/a | |
| tasks-failed-status-message | 2025-11-25 | both (receiver) | SHOULD | tasks | “The tasks/get response SHOULD include a statusMessage field with diagnostic information about the failure.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-execution-errors | partial — set for JSON-RPC faults, empty for isError tool results (tasks.go:127-139) | n/a | |
| tasks-result-same-error | 2025-11-25 | both (receiver) | MUST | tasks | “If the underlying request resulted in a JSON-RPC error, tasks/result MUST return that same JSON-RPC error.” / “…MUST return a successful JSON-RPC response containing that result.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-execution-errors | yes | n/a | |
| tasks-bind-auth-context | 2025-11-25 | both (receiver) | MUST | tasks | “When an authorization context is provided, receivers MUST bind tasks to said context.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-isolation-and-access-control | not-implemented-by-choice — mcpx has no authorization context (no OAuth) | n/a | |
| tasks-document-unbound | 2025-11-25 | both (receiver) | SHOULD | tasks | “receivers SHOULD document this limitation clearly, as task results may be accessible to any requestor that can guess the task ID.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-isolation-and-access-control | not found in docs/protocol.md | n/a | |
| tasks-unbound-secure-ids | 2025-11-25 | both (receiver) | MUST | tasks | “If context-binding is unavailable, receivers MUST generate cryptographically secure task IDs with enough entropy to prevent guessing” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-isolation-and-access-control | borderline — CSPRNG yes, 64 bits; “enough” undefined | n/a | |
| tasks-unidentified-no-list | 2025-11-25 | both (receiver) | SHOULD NOT | tasks | “receivers that cannot identify requestors SHOULD NOT declare the tasks.list capability” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-isolation-and-access-control | no — declares tasks.list (server.go:1411-1412) and tasks/list returns every task in the daemon across all connections | n/a | |
| tasks-bound-reject-foreign | 2025-11-25 | both (receiver) | MUST | tasks | “If context-binding is available, receivers MUST reject tasks/get, tasks/result, and tasks/cancel requests for tasks that do not belong to the same authorization context…” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-isolation-and-access-control | n/a (no context) | n/a | |
| tasks-rate-limit | 2025-11-25 | both (receiver) | SHOULD | tasks | “receivers SHOULD implement rate limiting on task operations to prevent denial-of-service and enumeration attacks.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#task-isolation-and-access-control | no | n/a | |
| tasks-resource-limits | 2025-11-25 | both (receiver) | SHOULD | tasks | “Enforce limits on concurrent tasks per requestor; Enforce maximum ttl durations …; Clean up expired tasks promptly; Document maximum supported ttl…” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#resource-management | partial — expiry yes; no max ttl clamp found, no concurrency cap | n/a | |
| tasks-audit | 2025-11-25 | both | SHOULD | tasks | “Receivers SHOULD: Log task creation, completion, and retrieval events…” / “Requestors SHOULD: Log task lifecycle events…” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#audit-and-logging | unverified | n/a | |
| ext-tasks-client-declare-per-request | 2026-07-28 (ext) | client | must (informal) | tasks | “The client includes io.modelcontextprotocol/tasks in its per-request capabilities.” | docs/extensions/tasks/overview.mdx#how-tasks-work | n/a | not-implemented-by-choice | replaces 2025-11-25 initialize capability |
| ext-tasks-server-advertise-discover | 2026-07-28 (ext) | server | must (informal) | tasks | “Include the extension in your server/discover capabilities” | docs/extensions/tasks/overview.mdx#for-mcp-servers | yes (extensions["io.modelcontextprotocol/tasks"], server.go:1415-1417) | n/a | |
| ext-tasks-never-unsolicited | 2026-07-28 (ext) | server | must (informal) | tasks | “Before returning a CreateTaskResult, verify that the client included the extension in its per-request capabilities. Never return a task to a client that did not declare support.” | docs/extensions/tasks/overview.mdx#for-mcp-servers | partial — returns a task only when the request carried a task param; never checks the extension capability | n/a | changelog wording “unsolicited” — C13 |
| ext-tasks-server-decides | 2026-07-28 (ext) | server | descr. | tasks | “The server decides per-request whether to create a task. Clients opt in once via the extension capability … No per-tool warmup or per-request flag.” | docs/extensions/tasks/overview.mdx#why-not-just-block | no — mcpx uses the 2025-11-25 per-request task flag, which the extension removed | n/a | model reversed vs 2025-11-25 |
| ext-tasks-durable-before-response | 2026-07-28 (ext) | server | must (informal) | tasks | “The task must be durably created before sending the response.” | docs/extensions/tasks/overview.mdx#for-mcp-servers | no in the crash sense — in-memory per-daemon store (tasks/tasks.go:73-76) | n/a | |
| ext-tasks-createtaskresult-shape | 2026-07-28 (ext) | server | must (informal) | tasks | “respond with resultType: "task" and a Task object containing a unique taskId, initial status, ttlMs, and pollIntervalMs.” | docs/extensions/tasks/overview.mdx#for-mcp-servers | no — {"task":{…,"ttl","pollInterval"}} + resultType:"complete" stamped by server.go:452-453 | n/a | field renames vs 2025-11-25 |
| ext-tasks-get-carries-result | 2026-07-28 (ext) | server | must (informal) | tasks | “Return the current task state on each poll. For terminal states, include the result (on completed) or error (on failed) field.” | docs/extensions/tasks/overview.mdx#for-mcp-servers | no — result only via tasks/result | n/a | tasks/result removed (changelog 6) |
| ext-tasks-get-input-requests | 2026-07-28 (ext) | server | descr. | tasks | “If the task moves to input_required, the tasks/get response includes an inputRequests map” | docs/extensions/tasks/overview.mdx#how-tasks-work | no | n/a | |
| ext-tasks-update | 2026-07-28 (ext) | server | must (informal) | tasks | “Accept inputResponses keyed to outstanding inputRequests. Acknowledge with an empty result. Ignore responses for unknown or already-satisfied keys.” | docs/extensions/tasks/overview.mdx#for-mcp-servers | not-implemented (tasks/update absent → -32601) | n/a | new method |
| ext-tasks-cancel-cooperative | 2026-07-28 (ext) | server | must (informal) | tasks | “Acknowledge cancellation requests with an empty result. Honor them when possible, but cancellation is cooperative — the task may still reach a non-cancelled terminal status.” | docs/extensions/tasks/overview.mdx#for-mcp-servers | no — returns Task snapshot, forces cancelled | n/a | reverses 2025-11-25 — C11 |
| ext-tasks-client-polymorphic | 2026-07-28 (ext) | client | must (informal) | tasks | “be prepared to receive either the standard result or a CreateTaskResult with resultType: "task".” | docs/extensions/tasks/overview.mdx#for-mcp-clients | n/a | not-implemented-by-choice (does not declare the extension, so should never receive one) | |
| ext-tasks-client-poll | 2026-07-28 (ext) | client | must (informal) | tasks | “Call tasks/get with the returned taskId, respecting the pollIntervalMs value. Continue polling until the task reaches a terminal status” | docs/extensions/tasks/overview.mdx#for-mcp-clients | n/a | not-implemented-by-choice | |
| ext-tasks-client-persist-ids | 2026-07-28 (ext) | client | must (informal) | tasks | “Store task IDs durably so polling can resume after a client crash or restart.” | docs/extensions/tasks/overview.mdx#for-mcp-clients | n/a | not-implemented-by-choice | |
| ext-tasks-notifications-via-listen | 2026-07-28 (ext) | both | descr. (MAY) | tasks | “Servers can push status updates via notifications/tasks. Clients opt into these through the subscriptions/listen mechanism.” | docs/extensions/tasks/overview.mdx#notifications | yes — taskIds agreed for known visible tasks; notifications/tasks carries the DetailedTask (mcpserver/listen.go watchTasks) | not-implemented | notifications/tasks/status → notifications/tasks; not in SubscriptionFilter of core schema |
| caching-server-must-hint | 2026-07-28 | server | MUST* | caching | “Servers MUST include caching hints on results with resultType: "complete" returned by the following operations: server/discover, tools/list, prompts/list, resources/list, resources/templates/list, resources/read” (schema: ttlMs, cacheScope required on CacheableResult) | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#cacheable-results | no — ttlMs/cacheScope appear nowhere in mcpx | n/a | new; changelog item 5 omits server/discover — C14 |
| caching-input-required-no-hints | 2026-07-28 | server | descr. | caching | “Interim results with resultType: "input_required" … are not cacheable and carry no caching hints.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#cacheable-results | yes | n/a | |
| caching-key-method-params | 2026-07-28 | client | MUST NOT | caching | “Clients MUST NOT serve a cached response for a request whose method or parameters differ from the request that produced it.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#cache-key | n/a | yes as far as seen (per-server list caches, invalidated on list_changed) | |
| caching-no-cache-mrtr-retries | 2026-07-28 | client | MUST NOT | caching | “requests carrying inputResponses or requestState—MUST NOT be cached” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#cache-key | n/a | yes (only lists cached; resources/read retries not cached — unverified) | |
| caching-ttl-zero-stale | 2026-07-28 | client | SHOULD | caching | “If ttlMs is 0, the response SHOULD be considered immediately stale.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#time-to-live-ttl-field | n/a | no — ttlMs ignored; list cache lives until list_changed | |
| caching-ttl-positive-fresh | 2026-07-28 | client | SHOULD | caching | “If ttlMs is positive, the client SHOULD consider the result fresh for that many milliseconds after receiving the response.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#time-to-live-ttl-field | n/a | no | |
| caching-ttl-absent-zero | 2026-07-28 | client | SHOULD | caching | “If ttlMs is absent, clients SHOULD assume a default of 0 (immediately stale)” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#time-to-live-ttl-field | n/a | no — legacy upstream lists are cached indefinitely | |
| caching-ttl-negative-zero | 2026-07-28 | client | SHOULD | caching | “If ttlMs is negative, clients SHOULD ignore it and treat it as 0.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#time-to-live-ttl-field | n/a | no | |
| caching-ttl-non-negative | 2026-07-28 | server | MUST | caching | “Servers MUST provide a ttlMs value that is >= 0.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#time-to-live-ttl-field | no (absent) | n/a | |
| caching-stale-refetch | 2026-07-28 | client | SHOULD | caching | “Once the TTL expires, the response is stale and the client SHOULD re-fetch on next access.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#freshness-calculation | n/a | no | |
| caching-ttl-not-poll | 2026-07-28 | client | SHOULD NOT / MUST | caching | “Clients SHOULD NOT treat TTL as a polling interval… Implementations that do choose to poll MUST apply jitter and backoff.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#freshness-calculation | n/a | yes (no TTL polling) | |
| caching-may-refetch-early-serve-stale | 2026-07-28 | client | MAY | caching | “Clients MAY re-fetch before the TTL expires…” “Clients MAY serve stale responses if errors occur during re-fetching” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#freshness-calculation | n/a | yes (serves cache on error) | |
| caching-public-shareable | 2026-07-28 | both | MAY | caching | “"public" … Any client, shared gateway, or caching proxy MAY store and serve the cached response to any user.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#cache-scope-field | n/a | relevant — mcpx is a “shared gateway” | |
| caching-private-no-cross-auth | 2026-07-28 | both (any cache) | MUST NOT | caching | “Caches MUST NOT be shared across authorization contexts (e.g. a different access token requires a different cache).” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#cache-scope-field | n/a | at risk — mcpx ignores cacheScope and re-serves an upstream’s cached lists/reads to every downstream client; safe only while each upstream sees one mcpx credential and every downstream is the same user | |
| caching-notification-invalidates | 2026-07-28 | client | descr. (lowercase should) | caching | “the notification invalidates the cached response and it should be considered immediately stale.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#interaction-with-notifications | n/a | yes (client.go:494-499 invalidate) — but modern upstreams only notify over subscriptions/listen, which mcpx never opens | |
| caching-page-refetch | 2026-07-28 | client | SHOULD | caching | “When a cached page expires, the client SHOULD re-fetch that page using its cursor.” / “…SHOULD re-fetch from the beginning (without a cursor).” / “If a cursor becomes invalid … SHOULD discard all cached pages and re-fetch from the beginning.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#interaction-with-pagination | n/a | partial — always re-fetches whole list | |
| caching-page-same-scope | 2026-07-28 | server | MUST | caching | “Servers MUST apply the same cacheScope to all response pages for a given list request.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#interaction-with-pagination | no (absent) | n/a | |
| caching-server-access-control | 2026-07-28 | server | MUST* / MUST NOT* | caching | “Servers MUST be aware that responses with a "public" cacheScope may be shared…” “MUST apply appropriate per-primitive access controls, and MUST NOT rely on cacheScope alone to prevent unauthorized access to primitives.” | https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching#security-considerations | n/a until hints are emitted; mcpx has no per-primitive access control (see authorization area) | n/a | |
| mrtr-server-requests-via-mrtr | 2026-07-28 | server | MUST | mrtr | “Servers MUST send server-to-client requests (such as roots/list, sampling/createMessage, or elicitation/create) using the MRTR pattern. The previous pattern of server-initiated requests is no longer supported.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr | yes for modern clients (mcpserver/ask.go:262); legacy clients get wire requests, correctly | n/a | legal→forbidden — C2 |
| mrtr-requeststate-opaque-client | 2026-07-28 | client | MUST NOT | mrtr | “Clients MUST NOT inspect, parse, modify, or make any assumptions about its contents.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#inputrequiredresult | n/a | yes (mcpclient/modern.go:144-186 copies bytes) | |
| mrtr-supported-requests-only | 2026-07-28 | server | MAY / MUST NOT | mrtr | “Servers MAY send InputRequiredResult responses on the following client requests: prompts/get, resources/read, tools/call” “Servers MUST NOT send InputRequiredResult responses on any other client requests.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#supported-requests | yes (only tools/call) | should reject/ignore on others — unverified | |
| mrtr-inputrequests-keys-values | 2026-07-28 | server | MUST | mrtr | “inputRequests keys … MUST be unique within the scope of the request.” “values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow | yes | n/a | |
| mrtr-requeststate-attacker-controlled | 2026-07-28 | server | MUST | mrtr | “servers MUST treat requestState as an attacker-controlled input. If requestState influences authorization, resource access, or business logic, servers MUST protect its integrity (e.g. HMAC or AEAD) and MUST reject state that fails verification.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow | yes — HMAC-SHA256, per-process key (mcpserver/state.go) | n/a | |
| mrtr-requeststate-replay-fields | 2026-07-28 | server | SHOULD | mrtr | “servers SHOULD include … the authenticated principal …; a short expiry (TTL) …; an identifier for the originating request, e.g. the method name and a digest of its salient parameters” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow | partial — connection id (no principal exists), expiry, call id; no method/params digest | n/a | |
| mrtr-single-use-enforced | 2026-07-28 | server | MUST | mrtr | “Servers for which a given requestState must be consumed at most once … MUST enforce that invariant server-side.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow | unverified whether a resumed call id can be resumed twice | n/a | |
| mrtr-at-least-one-field | 2026-07-28 | server | MUST | mrtr | “Servers MUST include at least one of inputRequests or requestState in every InputRequiredResult response.” (schema same) | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow | yes | n/a | |
| mrtr-only-declared-capabilities | 2026-07-28 | server | MUST NOT | mrtr | “Servers MUST NOT send an inputRequests that the client has not declared support for in its capabilities.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow | yes (per-request caps, docs/protocol.md §3.1) | n/a | |
| mrtr-no-assume-retry | 2026-07-28 | server | MUST NOT / MAY | mrtr | “Servers MUST NOT assume that clients will fulfill the inputRequests or retry the original request. Servers MAY choose to return an InputRequiredResult on multiple attempts” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow | yes (proto.askTTL, askRounds) | n/a | |
| mrtr-client-fulfil-before-retry | 2026-07-28 | client | MUST / MAY | mrtr | “the client MUST construct the requested inputs before retrying the original request. If the InputRequiredResult does not contain the inputRequests field, the client MAY retry the original request immediately.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#client-requirements-basic-workflow | n/a | yes | |
| mrtr-client-echo-state | 2026-07-28 | client | MUST / MUST NOT | mrtr | “the client MUST echo back the exact value of that field when retrying … If the InputRequiredResult does not contain a requestState field, the client MUST NOT include one in the retry.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#client-requirements-basic-workflow | n/a | yes (modern.go:172-186) | |
| mrtr-new-id-on-retry | 2026-07-28 | client | MUST | mrtr | “The JSON-RPC id MUST be different between the initial request and the retry” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#client-requirements-basic-workflow | n/a | yes (fresh id per round trip) | |
| mrtr-no-cross-request-use | 2026-07-28 | client | MUST NOT | mrtr | “They MUST NOT be used for any other request that the client may be sending in parallel.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#client-requirements-basic-workflow | n/a | yes | |
| mrtr-server-validate-responses | 2026-07-28 | server | SHOULD | mrtr | “Servers SHOULD validate that the data provided by the client is a valid InputResponses object” / “…SHOULD ignore any information it does not recognize” / “…SHOULD respond with a new InputRequiredResult requesting the missing information again, rather than returning an error.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#error-handling | unverified | n/a | |
| subscriptions-list-changed-should-notify | 2024-11-05..2025-11-25 | server | SHOULD | subscriptions | “When the list of available resources changes, servers that declared the listChanged capability SHOULD send a notification” (same for tools, prompts) | https://modelcontextprotocol.io/specification/2025-11-25/server/resources#list-changed-notification | partial — declared only when the connection can push (server.go:1389-1403); unverified that a legacy connection receives list_changed without a listen stream (restartListen is only called from listen/subscribe) | yes — consumes and invalidates | delivered unsolicited ≤2025-11-25; only via listen in 2026 |
| subscriptions-list-changed-via-listen | 2026-07-28 | server | SHOULD | subscriptions | “capability SHOULD send a notification to clients that have opened a subscriptions/listen stream with…” | https://modelcontextprotocol.io/specification/2026-07-28/server/tools#list-changed-notification | partial (envelope wrong, see below) | not-implemented — mcpx never opens subscriptions/listen upstream | |
| subscriptions-resources-updated-only-if-subscribed | 2024-11-05..2025-11-25 | server | should (schema) | subscriptions | schema: “This should only be sent if the client previously sent a resources/subscribe request.” | https://modelcontextprotocol.io/specification/2025-11-25/schema#resourceupdatednotification | yes | yes — sends resources/subscribe (client.go:532-542) | resources/subscribe removed 2026 (changelog 4) |
| subscriptions-only-requested-types | 2026-07-28 | server | MUST NOT | subscriptions | “The server MUST NOT send notification types the client has not explicitly requested.” (schema SubscriptionFilter, SubscriptionsListenRequestParams same) | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#opening-a-stream | yes (cli/serve.go:726-742) — but legacy resources/subscribe calls restartListen with a URI-only filter, dropping any list-changed kinds the same connection had | n/a | |
| subscriptions-ack-first-with-id | 2026-07-28 | server | MUST / MUST NOT | subscriptions | “The server MUST send notifications/subscriptions/acknowledged as the first message carrying the subscription’s ID in _meta under io.modelcontextprotocol/subscriptionId, and MUST NOT send any notification on the subscription before it.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#acknowledgment | no — id placed in params.subscriptionId, not _meta (server.go:637-639); ack is pushed after restartListen starts the goroutine, so a first event can race ahead of it | n/a | new |
| subscriptions-stdio-ack-order-per-id | 2026-07-28 | server | MAY | subscriptions | “On stdio … this ordering is defined per subscription ID and not per channel: messages belonging to other subscriptions MAY be interleaved before it.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#acknowledgment | n/a | n/a | |
| subscriptions-ack-reports-agreed-subset | 2026-07-28 | server | descr. (schema-required field) | subscriptions | “The notifications field in the acknowledgment reflects the subset the server agreed to honor. Notification types the server does not support are omitted.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#acknowledgment | no — notifications (required in SubscriptionsAcknowledgedNotificationParams) is omitted | n/a | |
| subscriptions-client-check-ack | 2026-07-28 | client | SHOULD | subscriptions | “The client SHOULD check the acknowledged filter against what it requested and handle any unsupported types gracefully.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#acknowledgment | n/a | not-implemented | |
| subscriptions-tag-every-notification | 2026-07-28 | server | MUST | subscriptions | basic/index: “On notifications delivered via a subscriptions/listen stream, the server MUST include io.modelcontextprotocol/subscriptionId in _meta”; schema NotificationMetaObject: “server MUST include this key on every notification delivered via a subscriptions/listen stream” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | no — no code writes io.modelcontextprotocol/subscriptionId | n/a | subscriptions page itself only says “carry” (no keyword) |
| subscriptions-stdio-client-correlate | 2026-07-28 | client | MUST | subscriptions | “On stdio, where all messages share a single channel, clients MUST use this field to correlate notifications with their originating subscription.” (stdio.mdx repeats) | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#receiving-notifications | n/a | not-implemented (no listen upstream) | |
| subscriptions-multiple-concurrent | 2026-07-28 | client | MAY | subscriptions | “A client MAY have multiple active subscriptions concurrently … Each subscription is identified by the JSON-RPC request ID of its subscriptions/listen request” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#multiple-concurrent-subscriptions | no — one stream per Conn; a second listen silently replaces the first (server.go:1421-1444), and the first listen request is never answered or cancelled | n/a | |
| subscriptions-server-teardown-respond | 2026-07-28 | server | SHOULD | subscriptions | “The server tears it down (e.g., during shutdown) — it SHOULD send a successful subscriptions/listen response to signal a graceful end … then close the stream.” / Graceful Closure: “it SHOULD respond to the original subscriptions/listen request with a completion result before closing the stream.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#graceful-closure | no — listen handler returns nil and nothing ever answers it (server.go:640) | n/a | contradicts cancellation MUST — C1 |
| subscriptions-listen-result-meta | 2026-07-28 | server | schema-required | subscriptions | schema SubscriptionsListenResult { _meta: SubscriptionsListenResultMetaObject } — “io.modelcontextprotocol/subscriptionId: RequestId” (required), “equals this response’s id” | https://modelcontextprotocol.io/specification/2026-07-28/schema#subscriptionslistenresult | no (no response) | n/a | |
| subscriptions-client-reconnect-on-drop | 2026-07-28 | client | MAY | subscriptions | “a transport that closes without it indicates an unexpected disconnect, which the client MAY treat as a trigger to reconnect.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#graceful-closure | n/a | n/a | |
| subscriptions-stdio-resubscribe | 2026-07-28 | client | MUST | subscriptions | “On stdio, if the connection is terminated and then re-established, the client MUST re-send subscriptions/listen … — the server holds no subscription state across reconnections.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#graceful-closure | yes (state dies with Conn) | n/a | |
| subscriptions-request-scoped-not-on-listen | 2026-07-28 | server | descr. (bold “not”) | subscriptions | “Request-scoped notifications like notifications/progress and notifications/message are not delivered on the listen stream — they flow only on the response stream of the request they relate to.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#receiving-messages | yes (vacuous) | n/a |
Tools, resources, prompts, discover
| id | revs | side | level | area | quote | url | mcpx-as-server | mcpx-as-client | changes |
|---|---|---|---|---|---|---|---|---|---|
| tools-human-in-loop-deny | 2024-11-05 – 2026-07-28 | client | SHOULD | tools | “there SHOULD always be a human in the loop with the ability to deny tool invocations” | 2026-07-28/server/tools#user-interaction-model | no — host-application duty; mcpx is not the host | not-implemented-by-choice — mcpx is not the UI; elicitation broker is the only human channel | |
| tools-ui-expose-indicate-confirm | 2024-11-05 – 2026-07-28 | client | SHOULD | tools | “Applications SHOULD: Provide UI that makes clear which tools are being exposed… Insert clear visual indicators… Present confirmation prompts” | 2026-07-28/server/tools#user-interaction-model | no — host duty | not-implemented-by-choice — no UI in the proxy path | |
| tools-declare-capability | 2024-11-05 – 2026-07-28 | server | MUST | tools | “Servers that support tools MUST declare the tools capability” | 2026-07-28/server/tools#capabilities | yes — declared all revs (protocol.md §2.2) | no (server obligation); mcpx relies on it | where declared changes: initialize (legacy) vs DiscoverResult (2026); tools page doesn’t say where |
| tools-list-changed-means-emit | 2024-11-05 – 2026-07-28 | server | (definition) | tools | “listChanged indicates whether the server will emit notifications when the list of available tools changes” | 2026-07-28/server/tools#capabilities | yes — declared only when a notifier exists (§2.2) | yes — should only expect notifications when declared | |
| tools-list-changed-notify | 2024-11-05 – 2025-11-25 | server | SHOULD | tools | “servers that declared the listChanged capability SHOULD send a notification” | 2025-11-25/server/tools#list-changed-notification | yes | no | changed in 2026 (only to listen streams) |
| tools-list-changed-notify-to-listeners | 2026-07-28 | server | SHOULD | tools | “SHOULD send a notification to clients that have opened a subscriptions/listen stream with toolsListChanged: true” | 2026-07-28/server/tools#list-changed-notification | yes — “only the four notification kinds the filter names” | yes — must open listen with toolsListChanged to receive | CHANGED: legacy sends on session; 2026 only on opted-in stream |
| tools-list-must-respond-when-declared | 2026-07-28 | server | MUST | tools | “Servers that declare the tools capability MUST respond to tools/list requests with the set of tools currently available to the requesting client” | 2026-07-28/server/tools#capabilities | yes | no | new in 2026 (implicit before) |
| tools-list-not-per-connection | 2026-07-28 | server | MUST NOT | tools | “MUST NOT vary per-connection or as a side effect of other requests on the connection” | 2026-07-28/server/tools#capabilities | yes — must hold; check: mcpx Conn state (subscriptions, caps) must not filter the list | no | CHANGED: legacy servers could legitimately vary per session |
| tools-list-may-vary-by-auth | 2026-07-28 | server | MAY | tools | “The set MAY vary by the authorization presented on the request” | 2026-07-28/server/tools#capabilities | not-implemented-by-choice — mcpx does no auth | no | new |
| tools-list-may-be-empty-change | 2026-07-28 | server | MAY | tools | “This set MAY be empty and MAY change over time” | 2026-07-28/server/tools#capabilities | yes | yes — tolerate empty lists | |
| tools-list-deterministic-order | 2026-07-28 | server | SHOULD | tools | “Servers SHOULD return tools in a deterministic order” | 2026-07-28/server/tools#capabilities | yes — must verify s.Tools() ordering is stable (pagination via opaque cursor depends on it anyway) | no | new (changelog “Minor” #3) |
| tools-annotations-untrusted | 2025-03-26 – 2026-07-28 | client | MUST | tools | “clients MUST consider tool annotations to be untrusted unless they come from trusted servers” | 2026-07-28/server/tools#tool | yes — mcpx forwards upstream annotations; downstream must treat them as upstream’s, mcpx must not launder trust | yes — mcpx must not act on upstream annotations | new in 2025-03-26 (annotations added) |
| tools-annotations-hints-no-decisions | 2025-03-26 – 2026-07-28 | client | should (lowercase, schema) | tools | “Clients should never make tool use decisions based on ToolAnnotations received from untrusted servers.” | 2026-07-28/schema#toolannotations | n/a | yes | schema-only, lowercase |
| tools-inputschema-valid-not-null | 2025-11-25 – 2026-07-28 | server | MUST | tools / json-schema | “MUST be a valid JSON Schema object (not null)” | 2026-07-28/server/tools#tool | yes — generated ops must emit object schemas, forwarded ones should be checked | yes — tolerate/flag invalid upstream | explicit from 2025-11-25; schema required type:"object" since 2024-11-05 |
| tools-inputschema-type-object-root | 2024-11-05 – 2026-07-28 | server | REQUIRED (schema) | tools / json-schema | “Tool arguments are always JSON objects, so type: \"object\" is required at the root.” | 2026-07-28/schema#tool | yes | yes | 2026 comment adds “any JSON Schema 2020-12 keyword may appear”; earlier schemas only allowed properties/required shape |
| tools-inputschema-no-params-recommended | 2025-11-25 – 2026-07-28 | server | RECOMMENDED | tools | “{ \"type\": \"object\", \"additionalProperties\": false } - Recommended” | 2026-07-28/server/tools#tool | yes | no | |
| tools-schema-default-2020-12 | 2025-11-25 – 2026-07-28 | both | (definition) | json-schema | “Defaults to 2020-12 if no $schema field is present” | 2026-07-28/server/tools#tool | yes | yes | 2025-11-25 is when $schema/dialects arrive |
| tools-x-mcp-header-may | 2026-07-28 | server | MAY | tools | “Properties MAY include an x-mcp-header annotation to expose parameter values as HTTP headers” | 2026-07-28/server/tools#tool | not-implemented-by-choice — mcpx-generated tools need none; forwarded upstream schemas may carry it | yes — must handle when upstream declares it | new |
| tools-x-mcp-header-not-empty | 2026-07-28 | server | MUST NOT | tools | “MUST NOT be empty” | 2026-07-28/server/tools#x-mcp-header | yes if forwarding upstream schemas verbatim | yes (validation side, see reject row) | new |
| tools-x-mcp-header-tchar | 2026-07-28 | server | MUST | tools | “MUST match HTTP field-name token syntax (1*tchar…)” | 2026-07-28/server/tools#x-mcp-header | same | same | new |
| tools-x-mcp-header-no-ctl | 2026-07-28 | server | MUST NOT | tools | “MUST NOT contain control characters, including carriage return… or line feed” | 2026-07-28/server/tools#x-mcp-header | same | same | new |
| tools-x-mcp-header-unique-ci | 2026-07-28 | server | MUST | tools | “MUST be case-insensitively unique among all x-mcp-header values in the inputSchema” | 2026-07-28/server/tools#x-mcp-header | same | same | new |
| tools-x-mcp-header-primitive-only | 2026-07-28 | server | MUST | tools | “MUST only be applied to parameters with primitive types (integer, string, boolean). Parameters with type number are not permitted.” | 2026-07-28/server/tools#x-mcp-header | same | same | new |
| tools-x-mcp-header-int-safe-range | 2026-07-28 | server | MUST | tools | “Integer values MUST be within the safe range for integers represented using IEEE754 double-precision” | 2026-07-28/server/tools#x-mcp-header | same | same | new |
| tools-x-mcp-header-statically-reachable | 2026-07-28 | server | MUST | tools | “MUST only be applied to properties that are statically reachable from the schema root” | 2026-07-28/server/tools#x-mcp-header | same | same | new |
| tools-x-mcp-header-reject-excludes | 2026-07-28 | client | MUST | tools | “Rejection means the client MUST exclude the invalid tool from the result of tools/list” | 2026-07-28/server/tools#x-mcp-header | no | yes — excluded from what mcpx re-exposes too | new |
| tools-x-mcp-header-reject-log | 2026-07-28 | client | SHOULD | tools | “Clients SHOULD log a warning when rejecting a tool definition, including the tool name and the reason” | 2026-07-28/server/tools#x-mcp-header | no | yes | new |
| tools-x-mcp-header-stdio-may-ignore | 2026-07-28 | client | MAY | tools | “Clients using other transports (e.g., stdio) MAY ignore x-mcp-header annotations entirely” | 2026-07-28/server/tools#x-mcp-header | no | yes — stdio upstreams | new |
| tools-x-mcp-header-no-secrets | 2026-07-28 | server | SHOULD NOT | tools | “Server developers SHOULD NOT mark sensitive parameters (passwords, API keys, tokens, PII) with x-mcp-header” | 2026-07-28/server/tools#x-mcp-header | yes (own tools) | no | new |
| tools-name-length | 2025-11-25 – 2026-07-28 | server | SHOULD | tools | “Tool names SHOULD be between 1 and 128 characters in length (inclusive).” | 2026-07-28/server/tools#tool-names | yes — namespaced names (server+tool) can exceed 128; check | no (tolerate) | new 2025-11-25 (SEP-986) |
| tools-name-case-sensitive | 2025-11-25 – 2026-07-28 | both | SHOULD | tools | “Tool names SHOULD be considered case-sensitive.” | 2026-07-28/server/tools#tool-names | yes | yes | new 2025-11-25 |
| tools-name-charset | 2025-11-25 – 2026-07-28 | server | SHOULD | tools | “The following SHOULD be the only allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z), digits (0-9), underscore (_), hyphen (-), and dot (.)” | 2026-07-28/server/tools#tool-names | yes — namespacing separator must be one of _ - . | yes — upstream names outside charset must be tolerated/mapped | new 2025-11-25 |
| tools-name-no-special | 2025-11-25 – 2026-07-28 | server | SHOULD NOT | tools | “Tool names SHOULD NOT contain spaces, commas, or other special characters.” | 2026-07-28/server/tools#tool-names | yes | no | new |
| tools-name-unique-in-server | 2025-11-25 – 2026-07-28 | server | SHOULD | tools | “Tool names SHOULD be unique within a server.” | 2026-07-28/server/tools#tool-names | yes — mcpx is one server to its client; aggregated names must be unique | no | new |
| tools-aggregator-disambiguate | 2026-07-28 | client | SHOULD | tools | “Clients or proxies that aggregate tools from multiple servers… SHOULD implement a disambiguation strategy such as prefixing tool names with a server identifier.” | 2026-07-28/server/tools#tool-names | yes — addressed at mcpx by name (“proxies”) | yes | new; applies in spirit to all revs |
| tools-aggregator-not-by-servername | 2026-07-28 | client | SHOULD NOT | tools | “The server name (from serverInfo) is not guaranteed to be unique across servers and SHOULD NOT be relied upon for disambiguation.” | 2026-07-28/server/tools#tool-names | yes — prefix with mcpx’s config key, not upstream serverInfo.name | yes | new |
| tools-call-input-required-may | 2026-07-28 | server | MAY | tools / mrtr | “Servers MAY respond to tools/call with an InputRequiredResult” | 2026-07-28/server/tools#input-required-tool-results | yes — viaAsk (§3.3) | yes — must resolve and retry | new |
| tools-call-inputrequests-declared-only | 2026-07-28 | server | MUST NOT | tools / mrtr | “Servers MUST NOT send an inputRequests that the client has not declared support for in its capabilities.” (mrtr.mdx) | 2026-07-28/basic/patterns/mrtr | yes — §3.1 table enforces it | no | new; cross-page, affects tools/call, prompts/get, resources/read |
| tools-result-iserror-for-tool-errors | 2024-11-05 – 2026-07-28 | server | SHOULD | tools | “Any errors that originate from the tool SHOULD be reported inside the result object, with isError set to true, not as an MCP protocol-level error response.” | 2026-07-28/schema#calltoolresult | yes — server.go tools/call returns isError | yes — forwards upstream isError unchanged | schema comment, unchanged |
| tools-result-lookup-errors-protocol | 2024-11-05 – 2026-07-28 | server | should (lowercase) | tools | “any errors in finding the tool… should be reported as an MCP error response” | 2026-07-28/schema#calltoolresult | yes — an unknown tool is -32602 (internal/mcpserver/server.go no tool named, #284); tested, see conformance-matrix.md | yes | |
| tools-error-unknown-tool-protocol | 2024-11-05 – 2026-07-28 | server | (classification) | tools | “Protocol Errors … Unknown tool” ; example code -32602 | 2026-07-28/server/tools#error-handling | yes (as above); tested, see conformance-matrix.md | yes | |
| tools-error-invalid-args-protocol | 2024-11-05 – 2025-06-18 | server | (classification) | tools | “Protocol Errors: Standard JSON-RPC errors for issues like: … Invalid arguments” | 2025-06-18/server/tools#error-handling | yes — mcpx returns isError for dispatch failures, which the later rule prefers; acceptable to legacy clients | yes | CHANGED in 2025-11-25 |
| tools-error-input-validation-execution | 2025-11-25 – 2026-07-28 | server | (classification) | tools | “Tool Execution Errors … Input validation errors (e.g., date in wrong format, value out of range)”; only “Malformed requests (requests that fail to satisfy CallToolRequest schema)” stay protocol errors | 2026-07-28/server/tools#error-handling | yes | yes | CHANGED from “Invalid arguments” (SEP-1303) |
| tools-client-exec-errors-to-llm | 2025-11-25 – 2026-07-28 | client | SHOULD | tools | “Clients SHOULD provide tool execution errors to language models to enable self-correction.” | 2026-07-28/server/tools#error-handling | no | yes — must pass isError results through, not convert to protocol errors | new 2025-11-25 |
| tools-client-protocol-errors-to-llm-may | 2025-11-25 – 2026-07-28 | client | MAY | tools | “Clients MAY provide protocol errors to language models” | 2026-07-28/server/tools#error-handling | no | yes | new |
| tools-embedded-resource-may | 2024-11-05 – 2026-07-28 | server | MAY | tools | “Resources MAY be embedded” | 2026-07-28/server/tools#embedded-resources | yes — resource_link→resource downgrade (§2.3) | yes | |
| tools-embedded-implies-resources-cap | 2025-06-18 – 2026-07-28 | server | SHOULD | tools | “Servers that use embedded resources SHOULD implement the resources capability” | 2026-07-28/server/tools#embedded-resources | yes — declared; but downgraded links become embedded resources whose URIs are upstream’s, not readable via mcpx unless proxied | no | new 2025-06-18 |
| tools-resource-link-may | 2025-06-18 – 2026-07-28 | server | MAY | tools | “A tool MAY return links to Resources” | 2026-07-28/server/tools#resource-links | yes (≥2025-06-18); downgraded for 2025-03-26 | yes | new 2025-06-18 |
| tools-resource-link-may-not-listed | 2025-06-18 – 2026-07-28 | both | (note) | tools | “Resource links returned by tools are not guaranteed to appear in the results of a resources/list request.” | 2026-07-28/server/tools#resource-links | yes | yes | |
| tools-structured-also-text | 2025-06-18 – 2026-07-28 | server | SHOULD | tools | “For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.” | 2026-07-28/server/tools#structured-content | yes — and for 2025-03-26 structuredContent removed + rendered (§2.3) | no | new 2025-06-18 |
| tools-structured-any-json | 2026-07-28 | server | (type) | tools | “This can be any JSON value (object, array, string, number, boolean, or null)” | 2026-07-28/server/tools#structured-content | yes — must NOT send non-object structuredContent to 2025-06-18/2025-11-25 clients | yes — accept any JSON from modern upstream | CHANGED: 2025-06-18/11-25 schema { [key: string]: unknown } (object only) |
| tools-structured-object-only | 2025-06-18 – 2025-11-25 | server | (type, schema) | tools | “structuredContent?: { [key: string]: unknown };” | 2025-11-25/schema#calltoolresult | yes — downgrade must wrap/strip non-object values | yes | CHANGED in 2026 |
| tools-output-schema-conform | 2025-06-18 – 2026-07-28 | server | MUST | tools | “Servers MUST provide structured results that conform to this schema.” | 2026-07-28/server/tools#output-schema | yes — for forwarded tools mcpx passes upstream’s; if it rewrites structuredContent it must still conform | no | |
| tools-output-schema-client-validate | 2025-06-18 – 2026-07-28 | client | SHOULD | tools | “Clients SHOULD validate structured results against this schema.” | 2026-07-28/server/tools#output-schema | no | yes — not-implemented-by-choice likely; pass-through | |
| tools-output-schema-object-root | 2025-06-18 – 2025-11-25 | server | (type, schema) | tools | “Currently restricted to type: "object" at the root level.” | 2025-11-25/schema#tool | yes — must not forward non-object outputSchema to legacy clients | yes | CHANGED in 2026: “This can be any valid JSON Schema 2020-12.” |
| tools-display-name-precedence | 2025-06-18 – 2026-07-28 | client | (rule) | tools | “Display name precedence order is: title, annotations.title, then name.” | 2026-07-28/schema#tool | yes | yes | |
| tools-server-sec-validate-inputs | 2024-11-05 – 2026-07-28 | server | MUST | tools | “Servers MUST: Validate all tool inputs; Implement proper access controls; Rate limit tool invocations; Sanitize tool outputs” | 2026-07-28/server/tools#security-considerations | yes — rate limiting / access control: not-implemented-by-choice (loopback, no auth, protocol.md §5) | no | |
| tools-client-sec | 2024-11-05 – 2025-11-25 | client | SHOULD | tools | “Clients SHOULD: Prompt for user confirmation…; Show tool inputs…; Validate tool results before passing to LLM; Implement timeouts for tool calls; Log tool usage for audit purposes” | 2025-11-25/server/tools#security-considerations | no | yes — timeouts + logging (logstore) yes; confirmation not-implemented-by-choice | |
| tools-client-sec-ref-resolution | 2026-07-28 | client | SHOULD | tools / json-schema | “Follow the $ref resolution requirements when validating tool inputs and outputs” | 2026-07-28/server/tools#security-considerations | no | yes if validating; otherwise n/a | added 2026 |
| resources-declare-capability | 2024-11-05 – 2026-07-28 | server | MUST | resources | “Servers that support resources MUST declare the resources capability” | 2026-07-28/server/resources#capabilities | yes | no | |
| resources-subscribe-cap-meaning-legacy | 2024-11-05 – 2025-11-25 | server | (definition) | resources | “subscribe: whether the client can subscribe to be notified of changes to individual resources.” | 2025-11-25/server/resources#capabilities | yes — declared | yes — call resources/subscribe only if declared | CHANGED meaning 2026 |
| resources-subscribe-cap-meaning-2026 | 2026-07-28 | server | (definition) | resources | “subscribe : whether the server supports resource-specific update notifications for resources requested through subscriptions/listen using the resourceSubscriptions filter.” | 2026-07-28/server/resources#capabilities | yes | yes — only send resourceSubscriptions if declared | CHANGED |
| resources-cap-features-independent | 2024-11-05 – 2026-07-28 | server | MAY | resources | “Servers may advertise either feature independently, together or neither.” / “Servers that support neither… may omit it” | 2026-07-28/server/resources#capabilities | yes | yes — resources: {} is valid | |
| resources-list-must-respond-when-declared | 2026-07-28 | server | MUST | resources | “Servers that declare the resources capability MUST respond to resources/list requests with the set of resources currently available to the requesting client.” | 2026-07-28/server/resources#capabilities | yes — returns [] when no backend | no | new |
| resources-list-not-per-connection | 2026-07-28 | server | MUST NOT | resources | “MUST NOT vary per-connection or as a side effect of other requests on the connection” | 2026-07-28/server/resources#capabilities | yes | no | CHANGED (new constraint) |
| resources-list-may-vary-by-auth | 2026-07-28 | server | MAY | resources | “The set MAY vary by the authorization presented on the request” | 2026-07-28/server/resources#capabilities | not-implemented-by-choice — no auth | no | new |
| resources-list-changed-notify | 2024-11-05 – 2026-07-28 | server | SHOULD | resources | “servers that declared the listChanged capability SHOULD send a notification” | 2026-07-28/server/resources#list-changed-notification | yes | yes | text unchanged in 2026 but see Conflicts C3 |
| resources-read-multiple-may | 2026-07-28 | server | MAY | resources | “Servers MAY return multiple resource contents in response to a single resources/read request.” | 2026-07-28/server/resources#reading-resources | yes (single today) | yes — must accept arrays of >1 | explicit only in 2026; schema always allowed an array |
| resources-read-input-required-may | 2026-07-28 | server | MAY | resources / mrtr | “Servers MAY also respond to resources/read with an InputRequiredResult” | 2026-07-28/server/resources#reading-resources | yes — viaAsk | yes | new |
| resources-read-https-direct-may | 2026-07-28 | client | may (lowercase) | resources | “if the scheme of uri is https://, clients may fetch the resource directly from the web” | 2026-07-28/server/resources#reading-resources | no | not-implemented-by-choice | new in 2026 prose |
| resources-subscribe-rpc | 2024-11-05 – 2025-11-25 | client | (optional) | resources / subscriptions | “The protocol supports optional subscriptions to resource changes. Clients can subscribe to specific resources” (resources/subscribe) | 2025-11-25/server/resources#subscriptions | yes — accepted in any era (protocol.md §2.1) | yes — legacy upstreams | REMOVED in 2026 |
| resources-unsubscribe-follows-subscribe | 2024-11-05 – 2025-11-25 | client | should (lowercase, schema) | resources / subscriptions | “This should follow a previous resources/subscribe request.” | 2025-11-25/schema#unsubscriberequest | yes | yes | removed 2026 |
| resources-subscribe-via-listen | 2026-07-28 | client | (mechanism) | resources / subscriptions | “Clients subscribe to change notifications for specific resources by sending a subscriptions/listen request with the resource URIs listed in notifications.resourceSubscriptions.” | 2026-07-28/server/resources#subscriptions | yes — any era | yes — modern upstreams | CHANGED: replaces resources/subscribe |
| resources-updated-may-be-subresource | 2024-11-05 – 2026-07-28 | client | (note, schema) | resources | “This might be a sub-resource of the one that the client actually subscribed to.” | 2026-07-28/schema#resourceupdatednotification | yes — fan-out must match by prefix/subresource, not equality | yes | |
| resources-https-only-if-client-fetchable | 2024-11-05 – 2026-07-28 | server | SHOULD | resources | “Servers SHOULD use this scheme only when the client is able to fetch and load the resource directly from the web on its own” | 2026-07-28/server/resources#https | yes | no | |
| resources-https-else-other-scheme | 2024-11-05 – 2026-07-28 | server | SHOULD | resources | “servers SHOULD prefer to use another URI scheme, or define a custom one” | 2026-07-28/server/resources#https | yes | no | |
| resources-file-xdg-mime-may | 2024-11-05 – 2026-07-28 | server | MAY | resources | “MCP servers MAY identify file:// resources with an XDG MIME type, like inode/directory” | 2026-07-28/server/resources#file | yes | yes — accept | |
| resources-custom-scheme-rfc3986 | 2025-06-18 – 2026-07-28 | server | MUST | resources | “Custom URI schemes MUST be in accordance with RFC3986” | 2026-07-28/server/resources#custom-uri-schemes | yes — any mcpx-minted URIs (namespacing upstream URIs) must stay RFC3986 | no | new 2025-06-18 |
| resources-not-found-32002 | 2024-11-05 – 2025-11-25 | server | SHOULD | resources / errors | “Servers SHOULD return standard JSON-RPC errors for common failure cases: Resource not found: -32002” | 2025-11-25/server/resources#error-handling | yes — -32002 with data.uri through 2025-11-25, -32602 in 2026-07-28 (codeResourceNotFoundLegacy); tested, see conformance-matrix.md | yes — accept -32002 | CHANGED 2026 |
| resources-internal-32603 | 2024-11-05 – 2026-07-28 | server | SHOULD | resources / errors | “Internal errors: -32603” / “Servers SHOULD return -32603 for internal errors.” | 2026-07-28/server/resources#error-handling | yes — backend read failures are -32603 (codeInternal); tested, see conformance-matrix.md | yes | |
| resources-not-found-32602 | 2026-07-28 | server | MUST | resources / errors | “If the requested resource does not exist, servers MUST return a JSON-RPC error with code -32602 (Invalid Params).” | 2026-07-28/server/resources#error-handling | yes | yes | CHANGED from SHOULD -32002 |
| resources-client-accept-32002 | 2026-07-28 | client | SHOULD | resources / errors | “clients SHOULD also accept -32002 as a resource not found error” | 2026-07-28/server/resources#error-handling | no | yes — legacy upstreams | new |
| resources-no-empty-contents-for-missing | 2026-07-28 | server | MUST NOT | resources | “Servers MUST NOT return an empty contents array for a non-existent resource.” | 2026-07-28/server/resources#error-handling | yes | yes — treat empty as “exists, no content” | new (applies sensibly to all eras) |
| resources-sec-validate-uris | 2024-11-05 – 2026-07-28 | server | MUST | resources | “Servers MUST validate all resource URIs” | 2026-07-28/server/resources#security-considerations | yes | no | |
| resources-sec-access-control | 2024-11-05 – 2026-07-28 | server | SHOULD | resources | “Access controls SHOULD be implemented for sensitive resources” | 2026-07-28/server/resources#security-considerations | not-implemented-by-choice — no auth | no | |
| resources-sec-binary-encoded | 2024-11-05 – 2026-07-28 | server | MUST | resources | “Binary data MUST be properly encoded” | 2026-07-28/server/resources#security-considerations | yes — mcpx read path returns text only; blobs from upstream must be passed as base64 blob | yes | |
| resources-sec-permissions | 2024-11-05 – 2026-07-28 | server | SHOULD | resources | “Resource permissions SHOULD be checked before operations” | 2026-07-28/server/resources#security-considerations | not-implemented-by-choice | no | |
| resources-sec-file-traversal | 2026-07-28 | server | MUST | resources | “Servers MUST sanitize file paths to prevent directory traversal attacks when serving file:// resources” | 2026-07-28/server/resources#security-considerations | yes only if mcpx serves file:// itself; forwarding delegates to upstream | no | new |
| resources-annotations-defs | 2025-06-18 – 2026-07-28 | both | (definition) | resources | “priority: A number from 0.0 to 1.0”; “audience… Valid values are \"user\" and \"assistant\"”; “lastModified: An ISO 8601 formatted timestamp” | 2026-07-28/server/resources#annotations | yes — pass through | yes | lastModified new in 2025-06-18; audience/priority in schema since 2024-11-05 |
| prompts-declare-capability | 2024-11-05 – 2025-11-25 | server | MUST | prompts | “Servers that support prompts MUST declare the prompts capability during initialization” | 2025-11-25/server/prompts#capabilities | yes | no | CHANGED location |
| prompts-declare-capability-discover | 2026-07-28 | server | MUST | prompts | “Servers that support prompts MUST declare the prompts capability in their DiscoverResult” | 2026-07-28/server/prompts#capabilities | yes — but mcpx’s DiscoverResult field names are wrong (see discover rows) | no | CHANGED |
| prompts-list-must-respond-when-declared | 2026-07-28 | server | MUST | prompts | “Servers that declare the prompts capability MUST respond to prompts/list requests with the set of prompts currently available to the requesting client.” | 2026-07-28/server/prompts#capabilities | yes | no | new |
| prompts-list-not-per-connection | 2026-07-28 | server | MUST NOT | prompts | “MUST NOT vary per-connection or as a side effect of other requests on the connection” | 2026-07-28/server/prompts#capabilities | yes | no | new |
| prompts-list-may-vary-by-auth | 2026-07-28 | server | MAY | prompts | “The set MAY vary by the authorization presented on the request” | 2026-07-28/server/prompts#capabilities | not-implemented-by-choice | no | new |
| prompts-list-changed-notify | 2024-11-05 – 2025-11-25 | server | SHOULD | prompts | “servers that declared the listChanged capability SHOULD send a notification” | 2025-11-25/server/prompts#list-changed-notification | yes | yes | CHANGED 2026 |
| prompts-list-changed-notify-to-listeners | 2026-07-28 | server | SHOULD | prompts | “SHOULD send a notification to clients that have opened a subscriptions/listen stream with promptsListChanged: true” | 2026-07-28/server/prompts#list-changed-notification | yes | yes | CHANGED |
| prompts-get-input-required-may | 2026-07-28 | server | MAY | prompts / mrtr | “Servers MAY also respond to prompts/get with an InputRequiredResult” | 2026-07-28/server/prompts#getting-a-prompt | yes — viaAsk | yes | new |
| prompts-image-base64-mime | 2024-11-05 – 2026-07-28 | server | MUST | prompts | “The image data MUST be base64-encoded and include a valid MIME type.” | 2026-07-28/server/prompts#image-content | yes (forwarded) | yes | |
| prompts-audio-base64-mime | 2025-03-26 – 2026-07-28 | server | MUST (not bolded) | prompts | “The audio data MUST be base64-encoded and include a valid MIME type.” | 2026-07-28/server/prompts#audio-content | yes | yes | new 2025-03-26 |
| prompts-resource-link-may | 2026-07-28 | server | MAY | prompts | “Prompt messages MAY include links to Resources” | 2026-07-28/server/prompts#resource-links | yes | yes | prose new in 2026; see Conflicts C9 |
| prompts-embedded-must-include | 2024-11-05 – 2026-07-28 | server | MUST | prompts | “Resources can contain either text or binary (blob) data and MUST include: A valid resource URI; The appropriate MIME type; Either text content or base64-encoded blob data” | 2026-07-28/server/prompts#embedded-resources | yes | yes | see Conflicts C8 (schema: mimeType optional) |
| prompts-errors-32602-32603 | 2024-11-05 – 2026-07-28 | server | SHOULD | prompts / errors | “Invalid prompt name: -32602… Missing required arguments: -32602… Internal errors: -32603” | 2026-07-28/server/prompts#error-handling | yes — unknown prompt -32602, backend failure -32603; tested, see conformance-matrix.md | yes | |
| prompts-validate-args | 2024-11-05 – 2026-07-28 | server | SHOULD | prompts | “Servers SHOULD validate prompt arguments before processing” | 2026-07-28/server/prompts#implementation-considerations | yes | no | |
| prompts-client-paginate | 2024-11-05 – 2026-07-28 | client | SHOULD | prompts / pagination | “Clients SHOULD handle pagination for large prompt lists” | 2026-07-28/server/prompts#implementation-considerations | no | yes — follow nextCursor upstream | |
| prompts-respect-capability-negotiation | 2024-11-05 – 2026-07-28 | both | SHOULD | prompts | “Both parties SHOULD respect capability negotiation” | 2026-07-28/server/prompts#implementation-considerations | yes | yes | |
| prompts-validate-io | 2024-11-05 – 2026-07-28 | both | MUST | prompts | “Implementations MUST carefully validate all prompt inputs and outputs to prevent injection attacks or unauthorized access to resources.” | 2026-07-28/server/prompts#security | yes | yes | |
| meta-request-required-fields | 2026-07-28 | client | MUST | meta | “Every request MUST include the required _meta fields” (tools/resources/prompts page notes) | 2026-07-28/server/tools | yes (checks inbound) | yes — every modern upstream request | new |
| cache-ttl-scope-required | 2026-07-28 | server | REQUIRED (schema) | caching | ttlMs: number; cacheScope: "public" | "private"; (non-optional on CacheableResult) | 2026-07-28/schema#discoverresult | yes — ttlMs/cacheScope on discover, the three lists, templates/list and resources/read (internal/mcpserver/server.go cacheable); tested, see conformance-matrix.md | yes — may use for upstream cache | new |
| discover-client-may-call | 2026-07-28 | client | MAY | discover | “Clients MAY call it but are not required to” (schema); “Calling server/discover is optional for clients” | 2026-07-28/server/discover#when-to-call | no | yes | new |
| discover-supportedversions | 2026-07-28 | server | REQUIRED (schema) | discover | “supportedVersions: Protocol versions the server supports.” | 2026-07-28/server/discover#discoverresult | yes — supportedVersions (#224); tested, see conformance-matrix.md | yes — read supportedVersions | new |
| discover-client-choose-from-list | 2026-07-28 | client | should (lowercase) | discover | “The client should choose one of these for subsequent requests.” | 2026-07-28/server/discover#discoverresult | no | yes | new |
| discover-capabilities | 2026-07-28 | server | REQUIRED (schema) | discover | “capabilities: Capabilities the server supports” | 2026-07-28/server/discover#discoverresult | yes | yes | replaces InitializeResult.capabilities |
| discover-serverinfo-in-meta | 2026-07-28 | server | SHOULD | discover | “_meta['io.modelcontextprotocol/serverInfo']: Name and version of the server software. Servers SHOULD include this field.” | 2026-07-28/server/discover#discoverresult | yes — _meta[io.modelcontextprotocol/serverInfo], no top-level serverInfo; tested, see conformance-matrix.md | yes | CHANGED location vs InitializeResult.serverInfo (top-level, required) |
| discover-serverinfo-not-for-behavior | 2026-07-28 | client | SHOULD NOT | discover | “Clients SHOULD NOT use it to change their behavior, and SHOULD NOT rely on it for security decisions.” | 2026-07-28/server/discover#discoverresult | no | yes | new |
| discover-instructions-optional | 2026-07-28 | server | MAY | discover | “instructions: Optional natural-language guidance for LLMs”; schema: “should not duplicate information already in tool descriptions” | 2026-07-28/server/discover#discoverresult | yes | yes | legacy counterpart in InitializeResult |
| discover-cacheable | 2026-07-28 | server | REQUIRED (schema) | discover / caching | “This operation supports caching.” ; DiscoverResult extends CacheableResult | 2026-07-28/server/discover#response | yes — ttlMs/cacheScope; tested, see conformance-matrix.md | yes | new |
| discover-resulttype | 2026-07-28 | server | MUST | discover / meta | example "resultType": "complete"; Result: “Servers implementing this protocol version MUST include this field.” | 2026-07-28/server/discover#response | yes if discover goes through the resultType stamping path — verify | yes | new |
| discover-request-meta-required | 2026-07-28 | client | MUST | discover / meta | “The request carries no body parameters beyond the standard _meta” (+ required _meta fields) | 2026-07-28/server/discover#request | yes | yes | new |
Roots, sampling, elicitation
| id | revs | side | level | area | quote | url | mcpx-as-server | mcpx-as-client | changes |
|---|---|---|---|---|---|---|---|---|---|
| roots-client-declare-capability-at-initialize | 2024-11-05 – 2025-11-25 | client | MUST | roots | “Clients that support roots MUST declare the roots capability during initialization” | https://modelcontextprotocol.io/specification/2025-11-25/client/roots#capabilities | n/a (mcpx never asks a downstream client for roots) | yes — declared in initialize (roots:{listChanged:false}) | replaced in 2026-07-28 by per-request _meta |
| roots-client-declare-capability-per-request-meta | 2026-07-28 | client | MUST | roots | “Clients that support roots MUST declare the roots capability in _meta.io.modelcontextprotocol/clientCapabilities on each request” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#capabilities | n/a | yes — withMeta stamps caps on every modern request; note mcpx sends roots:{listChanged:false} where the 2026 schema type is roots?: {} (extra key; harmless under open objects) | new; listChanged gone |
| roots-client-listchanged-must-notify | 2024-11-05 – 2025-11-25 | client | MUST | roots | “When roots change, clients that support listChanged MUST send a notification” | https://modelcontextprotocol.io/specification/2025-11-25/client/roots#root-list-changes | n/a | no — mcpx declares listChanged:false, so the obligation never attaches | removed in 2026-07-28 (notifications/roots/list_changed removed, changelog item 5) |
| roots-server-list-via-input-required | 2026-07-28 | server | MUST (via MRTR note) | roots | “servers send an InputRequiredResult containing a roots/list request” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#listing-roots | not-implemented-by-choice — mcpx answers roots itself and never asks downstream | yes — resolveInput answers roots/list inside inputRequests via the same answer() as legacy | legacy: server sends roots/list as a JSON-RPC request |
| roots-uri-must-be-file-scheme | 2024-11-05 – 2026-07-28 | client | MUST | roots | “This MUST be a file:// URI in the current specification.” (schema: “This must start with file:// for now.”) | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#root | n/a | yes — roots come from config; whether config rejects non-file:// was not verified | |
| roots-client-only-expose-permitted-roots | 2024-11-05 – 2026-07-28 | client | MUST | roots | “Clients MUST: - Only expose roots with appropriate permissions” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#security-considerations | n/a | yes — operator-configured roots are the permission decision | |
| roots-client-validate-uris-path-traversal | 2024-11-05 – 2026-07-28 | client | MUST | roots | “Validate all root URIs to prevent path traversal” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#security-considerations | n/a | yes — not verified that configured roots are validated | |
| roots-client-implement-access-controls | 2024-11-05 – 2026-07-28 | client | MUST | roots | “Implement proper access controls” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#security-considerations | n/a | yes | contradicts 2026 intro “informational guidance rather than an access-control mechanism” (see Conflicts) |
| roots-client-monitor-accessibility | 2024-11-05 – 2026-07-28 | client | MUST | roots | “Monitor root accessibility” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#security-considerations | n/a | yes — not done (static config) | |
| roots-server-handle-unavailable-roots | 2024-11-05 – 2026-07-28 | server | SHOULD | roots | “Servers SHOULD: - Handle cases where roots become unavailable” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#security-considerations | n/a (mcpx does not consume roots) | n/a (peer’s duty) | |
| roots-server-respect-boundaries | 2024-11-05 – 2026-07-28 | server | SHOULD | roots | “Respect root boundaries during operations” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#security-considerations | n/a | n/a (peer’s duty) | repeated under Implementation Guidelines |
| roots-server-validate-paths-against-roots | 2024-11-05 – 2026-07-28 | server | SHOULD | roots | “Validate all paths against provided roots” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#security-considerations | n/a | n/a (peer’s duty) | |
| roots-client-prompt-consent-before-exposing | 2024-11-05 – 2026-07-28 | client | SHOULD | roots | “Prompt users for consent before exposing roots to servers” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#implementation-guidelines | n/a | yes — config is the consent; no prompt | |
| roots-client-provide-root-management-ui | 2024-11-05 – 2026-07-28 | client | SHOULD | roots | “Provide clear user interfaces for root management” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#implementation-guidelines | n/a | yes — config file only | |
| roots-client-validate-accessibility-before-exposing | 2024-11-05 – 2026-07-28 | client | SHOULD | roots | “Validate root accessibility before exposing” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#implementation-guidelines | n/a | yes | |
| roots-client-monitor-root-changes | 2024-11-05 – 2026-07-28 | client | SHOULD | roots | “Monitor for root changes” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#implementation-guidelines | n/a | yes | 2026 keeps this although it removed the change notification |
| roots-server-check-capability-before-use | 2024-11-05 – 2026-07-28 | server | SHOULD | roots | “Check for roots capability before usage” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#implementation-guidelines | n/a | n/a (peer’s duty) | hardened to MUST NOT in 2026 by mrtr (row mrtr-server-no-undeclared-input-requests) |
| roots-server-handle-list-changes | 2024-11-05 – 2025-11-25 | server | SHOULD | roots | “Handle root list changes gracefully” | https://modelcontextprotocol.io/specification/2025-11-25/client/roots#implementation-guidelines | n/a | n/a | removed in 2026-07-28 |
| roots-server-cache-root-information | 2024-11-05 – 2026-07-28 | server | SHOULD | roots | “Cache root information appropriately” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots#implementation-guidelines | n/a | n/a | in 2026 there is no session to cache against (see Implied) |
| roots-client-return-jsonrpc-errors | 2024-11-05 – 2025-11-25 | client | SHOULD | roots | “Clients SHOULD return standard JSON-RPC errors for common failure cases: - Client does not support roots: -32601 (Method not found) - Internal errors: -32603” | https://modelcontextprotocol.io/specification/2025-11-25/client/roots#error-handling | n/a | yes — unknown methods get -32601; roots always answered | 2026: “the client does not need to replay the initial call with an error message” — no error path |
| roots-deprecated-new-impls-should-not-adopt | 2026-07-28 | both | SHOULD NOT | roots | “New implementations SHOULD NOT adopt it” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots | not-implemented-by-choice (never asks) | yes — mcpx is an existing implementation; keeps answering | new (SEP-2577) |
| roots-deprecated-existing-should-migrate | 2026-07-28 | both | SHOULD | roots | “existing implementations SHOULD migrate to passing directories or files via tool parameters, resource URIs, or server configuration” | https://modelcontextprotocol.io/specification/2026-07-28/client/roots | n/a | yes — configured roots already are server configuration; migration is upstream’s | new |
| sampling-human-in-loop-can-deny | 2024-11-05 – 2026-07-28 | client | SHOULD | sampling | “there SHOULD always be a human in the loop with the ability to deny sampling requests” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#user-interaction-model | n/a (downstream’s duty; mcpx forwards) | yes — delegated to broker/downstream client; mcpx itself has no model | |
| sampling-app-review-ui | 2024-11-05 – 2026-07-28 | client | SHOULD | sampling | “Provide UI that makes it easy and intuitive to review sampling requests” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#user-interaction-model | n/a | yes — delegated | |
| sampling-app-view-edit-prompts | 2024-11-05 – 2026-07-28 | client | SHOULD | sampling | “Allow users to view and edit prompts before sending” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#user-interaction-model | n/a | yes — delegated | |
| sampling-app-review-responses | 2024-11-05 – 2026-07-28 | client | SHOULD | sampling | “Present generated responses for review before delivery” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#user-interaction-model | n/a | yes — delegated | |
| sampling-client-declare-capability-at-initialize | 2024-11-05 – 2025-11-25 | client | MUST | sampling | “Clients that support sampling MUST declare the sampling capability during initialization” | https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#capabilities | n/a | yes — declared only when a handler is installed | replaced in 2026-07-28 |
| sampling-client-declare-capability-per-request-meta | 2026-07-28 | client | MUST | sampling | “Clients that support sampling MUST declare the sampling capability in _meta.io.modelcontextprotocol/clientCapabilities on each request” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#capabilities | n/a | yes | new |
| sampling-client-declare-tools-capability | 2025-11-25 – 2026-07-28 | client | MUST | sampling | “Clients MUST declare support for tool use via the sampling.tools capability to receive tool-enabled sampling requests.” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#tools-in-sampling | n/a | yes — mcpx never declares sampling.tools | new in 2025-11-25 |
| sampling-server-no-tools-without-capability | 2025-11-25 – 2026-07-28 | server | MUST NOT | sampling | “Servers MUST NOT send tool-enabled sampling requests to Clients that have not declared support for tool use via the sampling.tools capability.” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#tools-in-sampling | yes — Question.Sendable refuses a tools-carrying sampling question unless the peer declared sampling.tools (Peer.CanSampleTools) | n/a (peer’s duty) | new in 2025-11-25 |
| sampling-client-error-on-tools-without-capability | 2025-11-25 – 2026-07-28 | client | MUST | sampling | “The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared.” (schema, on tools and on toolChoice) | https://modelcontextprotocol.io/specification/2026-07-28/schema#createmessagerequestparams | n/a | yes — checked in mcpclient answer(): a request carrying tools or toolChoice is refused -32602 naming sampling.tools before the handler runs, in both eras (in 2026 the refusal surfaces as the call’s error, there being no error slot in inputResponses) | new in 2025-11-25 |
| sampling-server-includecontext-only-with-context-capability | 2025-11-25 – 2026-07-28 | server | SHOULD NOT | sampling | “Servers SHOULD avoid using these values … and SHOULD NOT use them unless the client declares sampling.context capability.” (schema: “If not declared, servers SHOULD only use includeContext: \"none\" (or omit it).”) | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#capabilities | yes — Question.paramsFor drops includeContext unless the peer declared sampling.context (Peer.CanSampleContext) | n/a (peer’s duty); mcpx declares no sampling.context | “soft-deprecated” in 2025-11-25 → “deprecated” (SEP-2596) in 2026-07-28; obligation text unchanged |
| sampling-server-avoid-includecontext-values | 2025-11-25 – 2026-07-28 | server | SHOULD | sampling | “Servers SHOULD avoid using these values (e.g. can just omit includeContext since it defaults to \"none\")” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#capabilities | yes (as above) | n/a | |
| sampling-deprecated-new-impls-should-not-adopt | 2026-07-28 | both | SHOULD NOT | sampling | “New implementations SHOULD NOT adopt it; existing implementations SHOULD migrate to integrating directly with LLM provider APIs.” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling | yes — mcpx forwards; pass-through, not adoption | yes | new (SEP-2577) |
| sampling-tool-result-message-only-tool-results | 2025-11-25 – 2026-07-28 | both | MUST | sampling | “When a user message contains tool results (type: "tool_result"), it MUST contain ONLY tool results.” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#tool-result-messages | yes — pass-through; mcpx does not rewrite messages | yes — pass-through | new |
| sampling-tool-use-result-balance | 2025-11-25 – 2026-07-28 | both | MUST | sampling | “every assistant message containing ToolUseContent blocks MUST be followed by a user message that consists entirely of ToolResultContent blocks, with each tool use … matched by a corresponding tool result” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#tool-use-and-result-balance | yes — pass-through | yes — pass-through | new |
| sampling-server-answer-every-tooluse | 2025-11-25 – 2026-07-28 | server | MUST | sampling | “Servers MUST ensure that when replying to a stopReason: \"toolUse\", each ToolUseContent item is responded to with a ToolResultContent item with a matching toolUseId” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#security-considerations | yes — upstream’s content, forwarded | n/a | new |
| sampling-toolresult-id-must-match-tooluse | 2025-11-25 – 2026-07-28 | both | MUST | sampling | “This MUST match the ID from a previous ToolUseContent.” | https://modelcontextprotocol.io/specification/2026-07-28/schema#toolresultcontent | yes — ids must not be rewritten in transit | yes | new |
| sampling-toolchoice-required-model-must-use-tool | 2025-11-25 – 2026-07-28 | client | MUST | sampling | “{mode: \"required\"}: Model MUST use at least one tool before completing” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#tool-choice-modes | n/a | n/a — mcpx never declares tools | new |
| sampling-toolchoice-none-model-must-not-use-tools | 2025-11-25 – 2026-07-28 | client | MUST NOT | sampling | “{mode: \"none\"}: Model MUST NOT use any tools” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#tool-choice-modes | n/a | n/a | new |
| sampling-parallel-tool-toggle-extension-only | 2025-11-25 – 2026-07-28 | client | MAY | sampling | “Implementations wrapping providers that support disabling parallel tool use MAY expose this as an extension, but it is not part of the core MCP specification.” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#parallel-tool-use | n/a | n/a | |
| sampling-tooluse-meta-preserve | 2025-11-25 – 2026-07-28 | client (text says “Clients”; see Conflicts) | SHOULD | sampling | “Clients SHOULD preserve this field when including tool uses in subsequent sampling requests to enable caching optimizations.” (same on ToolResultContent._meta) | https://modelcontextprotocol.io/specification/2026-07-28/schema#toolusecontent | yes — forward _meta on content blocks untouched | yes | new |
| sampling-toolresult-structured-conform-outputschema | 2025-11-25 – 2026-07-28 | both | SHOULD | sampling | “If the tool defined an outputSchema, this SHOULD conform to that schema.” | https://modelcontextprotocol.io/specification/2026-07-28/schema#toolresultcontent | yes — pass-through | yes — pass-through | |
| sampling-message-must-have-role-and-content | 2026-07-28 | both | MUST | sampling | “Sampling messages MUST contain a role field of \"user\" or \"assistant\"; and a content field representing the message data.” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#messages | yes | yes — daemon fills a missing role/model in results (hooks.go:223-229) | prose new in 2026; schema had the fields required since 2024-11-05 |
| sampling-messages-not-retained-between-requests | 2026-07-28 | client | SHOULD NOT | sampling | “The list of messages in a sampling request SHOULD NOT be retained between separate requests.” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#messages | n/a | yes — broker persists questions in SQLite (docs/elicitation.md §3); a sampling question’s messages are retained (conflict with this SHOULD NOT; unverified whether params are stored) | new in 2026 |
| sampling-client-must-respect-maxtokens | 2026-07-28 | client | MUST | sampling | “The client MUST respect the maxTokens parameter.” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#sampling-parameters | yes — forward maxTokens unchanged | yes — delegated to whoever samples | new in 2026 prose |
| sampling-client-may-sample-fewer-tokens | 2024-11-05 – 2026-07-28 | client | MAY | sampling | “The client MAY choose to sample fewer tokens than the requested maximum.” | https://modelcontextprotocol.io/specification/2026-07-28/schema#createmessagerequestparams | n/a | yes | 2024-11-05/2025-03-26 wording “as requested by the server. The client MAY choose to sample fewer tokens than requested.” |
| sampling-client-may-ignore-temperature-stop-metadata | 2026-07-28 | client | MAY | sampling | “The client MAY modify or ignore temperature, stopSequences and metadata.” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#sampling-parameters | n/a | yes | new prose |
| sampling-client-may-ignore-model-preferences | 2024-11-05 – 2026-07-28 | client | MAY | sampling | “The server’s preferences for which model to select. The client MAY ignore these preferences.” | https://modelcontextprotocol.io/specification/2026-07-28/schema#createmessagerequestparams | n/a | yes | |
| sampling-client-may-modify-system-prompt | 2024-11-05 – 2026-07-28 | client | MAY | sampling | “The client MAY modify or omit this prompt.” (2026 prose adds “without communicating this to the server”) | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#system-prompt | n/a | yes — daemon uses systemPrompt as the question’s message (hooks.go:194) | 2026 adds prose section |
| sampling-client-may-ignore-includecontext | 2024-11-05 – 2026-07-28 | client | MAY | sampling | “The client MAY modify or ignore this field without communicating this to the server.” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#context-inclusion | n/a | yes — mcpx has no context to include | schema-only until 2026 |
| sampling-hints-evaluated-in-order | 2024-11-05 – 2026-07-28 | client | MUST | sampling | “If multiple hints are specified, the client MUST evaluate them in order (such that the first match is taken).” | https://modelcontextprotocol.io/specification/2026-07-28/schema#modelpreferences | yes — forward hints order unchanged | yes — delegated | |
| sampling-hints-over-priorities | 2024-11-05 – 2026-07-28 | client | SHOULD | sampling | “The client SHOULD prioritize these hints over the numeric priorities, but MAY still use the priorities to select from ambiguous matches.” | https://modelcontextprotocol.io/specification/2026-07-28/schema#modelpreferences | n/a | yes — delegated | |
| sampling-hint-name-substring | 2024-11-05 – 2026-07-28 | client | SHOULD | sampling | “The client SHOULD treat this as a substring of a model name” | https://modelcontextprotocol.io/specification/2026-07-28/schema#modelhint | n/a | yes — delegated | |
| sampling-hint-may-map-provider | 2024-11-05 – 2026-07-28 | client | MAY | sampling | “Clients MAY map hints to equivalent models from different providers” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#model-hints | n/a | yes | |
| sampling-stopreason-custom-values | 2024-11-05 – 2026-07-28 | client | MAY | sampling | “implementations MAY provide their own arbitrary values” (schema: “open string”) | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#result-fields | yes — must pass unknown stopReason through | yes | prose added 2026; toolUse value added 2025-11-25 |
| sampling-client-errors-user-rejected | 2024-11-05 – 2025-06-18 | client | SHOULD | sampling | “Clients SHOULD return errors for common failure cases” (example code -1, “User rejected sampling request”) | https://modelcontextprotocol.io/specification/2025-06-18/client/sampling#error-handling | yes — downstream error must be relayed upstream as an error | yes — “a decline becomes an error, since the specification has no decline result for sampling” (docs/protocol.md §4.1) | |
| sampling-client-errors-user-rejected-and-tool-shape | 2025-11-25 | client | SHOULD | sampling | “User rejected sampling request: -1 - Tool result missing in request: -32602 (Invalid params) - Tool results mixed with other content: -32602 (Invalid params)” | https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#error-handling | yes (relay) | yes — -1/-32602 not specifically produced; handler errors become -32603 (modern.go:125) | removed in 2026-07-28 |
| sampling-client-user-approval-controls | 2024-11-05 – 2026-07-28 | client | SHOULD | sampling | “Clients SHOULD implement user approval controls” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#security-considerations | n/a | yes — broker policy/audience | |
| sampling-both-validate-message-content | 2024-11-05 – 2026-07-28 | both | SHOULD | sampling | “Both parties SHOULD validate message content” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#security-considerations | yes | yes | |
| sampling-client-respect-hints | 2024-11-05 – 2026-07-28 | client | SHOULD | sampling | “Clients SHOULD respect model preference hints” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#security-considerations | n/a | yes — delegated | |
| sampling-client-rate-limit | 2024-11-05 – 2026-07-28 | client | SHOULD | sampling | “Clients SHOULD implement rate limiting” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#security-considerations | n/a | yes — proto.samplingPerMinute (60) per server and proto.samplingSessionPerMinute (20) per session, fixed window; a refused request reaches no handler (#2192) | |
| sampling-both-handle-sensitive-data | 2024-11-05 – 2026-07-28 | both | MUST | sampling | “Both parties MUST handle sensitive data appropriately” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#security-considerations | yes — prompts/completions pass through mcpx and its log | yes — same | |
| sampling-both-iteration-limits-tool-loops | 2025-11-25 – 2026-07-28 | both | SHOULD | sampling | “Both parties SHOULD implement iteration limits for tool loops” | https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#security-considerations | yes — proto.askRounds covers 2026 retries | yes | new |
| elicitation-server-no-sensitive-info | 2025-06-18 | server | MUST NOT | elicitation | “Servers MUST NOT use elicitation to request sensitive information.” | https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#user-interaction-model | yes — mcpx cannot vet upstream content; forwards form questions as-is | n/a (peer’s duty) | narrowed to form mode in 2025-11-25 |
| elicitation-server-no-sensitive-info-in-form-mode | 2025-11-25 – 2026-07-28 | server | MUST NOT | elicitation | “Servers MUST NOT use form mode elicitation to request sensitive information such as passwords, API keys, access tokens, or payment credentials” (restated: “Servers MUST NOT request sensitive information (passwords, API keys, etc.) via form mode”) | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#user-interaction-model | yes — pass-through of upstream’s form; mcpx’s own questions must comply | n/a | |
| elicitation-server-sensitive-via-url-mode | 2025-11-25 – 2026-07-28 | server | MUST | elicitation | “Servers MUST use URL mode for interactions involving such sensitive information” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#user-interaction-model | yes — mcpx raises no url-mode question of its own (docs/protocol.md §6) | n/a | new in 2025-11-25 |
| elicitation-app-show-requesting-server | 2025-06-18 | client | SHOULD | elicitation | “Applications SHOULD: - Provide UI that makes it clear which server is requesting information” (also “Clients SHOULD provide clear indication of which server is requesting information”) | https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#security-considerations | yes — message rewritten “ | yes — broker records Server | promoted to MUST in 2025-11-25 |
| elicitation-client-show-requesting-server | 2025-11-25 – 2026-07-28 | client | MUST | elicitation | “Clients MUST provide clear indication of which server is requesting information” (and “MCP clients MUST: - Provide UI that makes it clear which server is requesting information”) | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#security-considerations | yes — rewrite above; without it the downstream would only see “mcpx” | yes — delegated | was SHOULD |
| elicitation-app-review-modify-responses | 2025-06-18 | client | SHOULD | elicitation | “Allow users to review and modify their responses before sending” | https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#user-interaction-model | n/a | yes — delegated | promoted |
| elicitation-client-form-review-modify-responses | 2025-11-25 – 2026-07-28 | client | MUST | elicitation | “For form mode, allow users to review and modify their responses before sending” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#user-interaction-model | n/a | yes — delegated; broker auto-policy answers bypass review (see Implied) | was SHOULD |
| elicitation-app-decline-cancel-options | 2025-06-18 | client | SHOULD | elicitation | “Respect user privacy and provide clear decline and cancel options” | https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#user-interaction-model | n/a | yes — mcpx elicit decline/cancel | promoted |
| elicitation-client-decline-cancel-options | 2025-11-25 – 2026-07-28 | client | MUST | elicitation | “Respect user privacy and provide clear decline and cancel options” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#user-interaction-model | n/a | yes — broker keeps decline ≠ cancel; expiry answers cancel | was SHOULD |
| elicitation-client-url-show-domain-and-consent | 2025-11-25 – 2026-07-28 | client | MUST | elicitation | “For URL mode, clearly display the target domain/host and gather user consent before navigation to the target URL” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#user-interaction-model | n/a | yes — mcpx declares form only upstream, so url mode should not arrive; broker/TUI must meet it if it does | new |
| elicitation-client-declare-capability-at-initialize | 2025-06-18 – 2025-11-25 | client | MUST | elicitation | “Clients that support elicitation MUST declare the elicitation capability during initialization” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#capabilities | n/a | yes — elicitation: {} | replaced in 2026 |
| elicitation-client-declare-capability-per-request-meta | 2026-07-28 | client | MUST | elicitation | “Clients that support elicitation MUST declare the elicitation capability in _meta.io.modelcontextprotocol/clientCapabilities on each request” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#capabilities | yes — mcpx must read the capability from this request’s _meta, not a prior one | yes | new |
| elicitation-empty-capability-means-form-only | 2025-11-25 – 2026-07-28 | both | (definition) | elicitation | “For backwards compatibility, an empty capabilities object is equivalent to declaring support for form mode only” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#capabilities | yes — CanElicit("url") requires elicitation.url | yes — mcpx’s {} therefore declares form only | new |
| elicitation-client-support-at-least-one-mode | 2025-11-25 – 2026-07-28 | client | MUST | elicitation | “Clients declaring the elicitation capability MUST support at least one mode (form or url).” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#capabilities | n/a | yes — form | new |
| elicitation-server-no-undeclared-modes | 2025-11-25 – 2026-07-28 | server | MUST NOT | elicitation | “Servers MUST NOT send elicitation requests with modes that are not supported by the client.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#capabilities | yes — CanElicit gates url on elicitation.url and on revision; url questions otherwise stay with the broker | n/a | new |
| elicitation-server-may-request-during-client-request | 2026-07-28 | server | MAY | elicitation | “Servers MAY request information from a user during the processing of a client request, by sending an InputRequiredResult containing an elicitation/create request.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#elicitation-requests | yes — modern downstream gets input_required | yes — resolveInput | legacy: a server-initiated request |
| elicitation-request-must-include-mode-and-message | 2025-11-25 – 2026-07-28 | server | MUST | elicitation | “All elicitation requests MUST include the following parameters: … mode … Optional for form mode … message” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#elicitation-requests | yes | n/a | self-contradictory on mode (Conflicts) |
| elicitation-server-may-omit-mode-for-form | 2025-11-25 – 2026-07-28 | server | MAY | elicitation | “For backwards compatibility, servers MAY omit the mode field for form mode elicitation requests.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#elicitation-requests | yes — mcpx strips mode for 2025-06-18 clients | n/a | |
| elicitation-client-missing-mode-is-form | 2025-11-25 – 2026-07-28 | client | MUST | elicitation | “Clients MUST treat requests without a mode field as form mode.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#elicitation-requests | n/a | yes — hooks.go:133 defaults to form | |
| elicitation-form-request-shape | 2025-11-25 – 2026-07-28 | server | MUST | elicitation | “Form mode elicitation requests MUST either specify mode: \"form\" or omit the mode field, and include these additional parameters: requestedSchema” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#form-mode-elicitation-requests | yes | n/a | 2025-06-18: message+requestedSchema required by schema only |
| elicitation-form-schema-flat-primitives | 2025-06-18 – 2026-07-28 | server | MUST (schema-structural) | elicitation | “form mode elicitation schemas are limited to flat objects with primitive properties only” / schema: “Only top-level properties are allowed, without nesting.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#requested-schema | yes — pass-through; a 2025-06-18 downstream cannot parse default, oneOf, arrays (added 2025-11-25) — not downgraded by mcpx (unverified) | n/a | 2025-11-25 adds defaults, titled/untitled single/multi-select enums; enumNames becomes LegacyTitledEnumSchema |
| elicitation-client-prepopulate-defaults | 2025-11-25 – 2026-07-28 | client | SHOULD | elicitation | “Clients that support defaults SHOULD pre-populate form fields with these values.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#requested-schema | n/a | yes — delegated | new (SEP-1034); keyword not bolded |
| elicitation-url-request-shape-with-id | 2025-11-25 | server | MUST | elicitation | “URL mode elicitation requests MUST specify mode: \"url\", a message, and include these additional parameters: url … elicitationId” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-mode-elicitation-requests | yes — a url question from a 2026 upstream forwarded to a 2025-11-25 downstream needs a synthesized elicitationId (unverified that mcpx does this) | n/a | elicitationId removed in 2026-07-28 |
| elicitation-url-request-shape | 2026-07-28 | server | MUST | elicitation | “URL mode elicitation requests MUST specify mode: \"url\", a message, and include these additional parameters: url” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#url-mode-elicitation-requests | yes — paramsFor strips elicitationId only for clients older than 2025-11-25, so a 2025-11-25 upstream’s id reaches a 2026 downstream (extra field) | n/a | changed |
| elicitation-url-must-be-valid | 2025-11-25 – 2026-07-28 | server | MUST | elicitation | “The url parameter MUST contain a valid URL.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#url-mode-elicitation-requests | yes — pass-through | n/a | |
| elicitation-client-elicitationid-opaque | 2025-11-25 | client | MUST | elicitation | “The ID of the elicitation, which must be unique within the context of the server. The client MUST treat this ID as an opaque value.” | https://modelcontextprotocol.io/specification/2025-11-25/schema#elicitrequesturlparams | n/a | yes — carried, not parsed | removed 2026 |
| elicitation-server-may-send-complete-notification | 2025-11-25 | server | MAY | elicitation | “Servers MAY send a notifications/elicitation/complete notification when an out-of-band interaction started by URL mode elicitation is completed.” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#completion-notifications-for-url-mode-elicitation | yes — events.go:264 emits it downstream | yes — client.go:501 listens | removed 2026 |
| elicitation-complete-only-to-initiating-client | 2025-11-25 | server | MUST | elicitation | “MUST only send the notification to the client that initiated the elicitation request.” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#completion-notifications-for-url-mode-elicitation | yes — must route by the connection the question went to, not broadcast (unverified which) | n/a | removed 2026 |
| elicitation-complete-must-carry-id | 2025-11-25 | server | MUST | elicitation | “MUST include the elicitationId established in the original elicitation/create request.” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#completion-notifications-for-url-mode-elicitation | yes — id must be the one the downstream saw | n/a | removed 2026 |
| elicitation-client-ignore-unknown-complete | 2025-11-25 | client | MUST | elicitation | “MUST ignore notifications referencing unknown or already-completed IDs.” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#completion-notifications-for-url-mode-elicitation | n/a | yes | removed 2026 |
| elicitation-client-may-auto-retry-on-complete | 2025-11-25 | client | MAY | elicitation | “MAY wait for this notification to automatically retry requests that received a URLElicitationRequiredError” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#completion-notifications-for-url-mode-elicitation | n/a | yes | removed 2026 |
| elicitation-client-manual-retry-cancel-controls | 2025-11-25 – 2026-07-28 | client | SHOULD | elicitation | “Clients SHOULD provide manual controls that let the user retry or cancel the original request (or otherwise resume interacting with the client).” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#example-request-sensitive-data | n/a | yes — mcpx elicit CLI/TUI | 2025-11-25: “SHOULD still provide … if the notification never arrives” |
| elicitation-server-may-return-url-required-error | 2025-11-25 | server | MAY | elicitation | “the server MAY return a URLElicitationRequiredError (code -32042)” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-elicitation-required-error | yes — relay upstream -32042 only to a downstream that declared elicitation.url (unverified) | yes — must parse -32042 data (unverified) | removed 2026; code reserved |
| elicitation-url-required-error-only-when-required | 2025-11-25 | server | MUST NOT | elicitation | “The server MUST NOT return this error except when URL mode elicitation is required.” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-elicitation-required-error | yes | n/a | removed 2026 |
| elicitation-url-required-error-lists-elicitations | 2025-11-25 | server | MUST | elicitation | “The error MUST include a list of elicitations that are required to complete before the original can be retried.” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-elicitation-required-error | yes | n/a | removed 2026 |
| elicitation-url-required-error-url-mode-with-id | 2025-11-25 | server | MUST | elicitation | “Any elicitations returned in the error MUST be URL mode elicitations and have an elicitationId property.” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-elicitation-required-error | yes | n/a | removed 2026 |
| elicitation-server-must-return-32042 | 2025-11-25 | server | MUST | elicitation | “Servers MUST return standard JSON-RPC errors for common failure cases: - When a request cannot be processed until an elicitation is completed: -32042” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#error-handling | yes | n/a | conflicts with the MAY above (Conflicts) |
| elicitation-client-32602-on-undeclared-mode | 2025-11-25 | client | MUST | elicitation | “Clients MUST return standard JSON-RPC errors … Server sends an elicitation/create request with a mode not declared in client capabilities: -32602 (Invalid params)” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#error-handling | n/a | yes — an undeclared mode is -32602 (elicitModeDeclared, internal/mcpclient/modern.go); tested, see conformance-matrix.md | removed 2026 |
| elicitation-no-32042-in-2026 | 2026-07-28 | both | MUST NOT | elicitation | “Implementations of this protocol version MUST NOT emit these codes: … -32042 — URL elicitation required (2025-11-25 only).” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes | yes — must not relay a legacy upstream’s -32042 verbatim to a 2026 downstream | yes | new |
| elicitation-server-state-bound-to-user | 2025-11-25 | server | MUST | elicitation | “Servers implementing elicitation MUST securely associate this state with individual users” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#statefulness | yes — broker binds questions to session/connection | n/a | 2026 makes it conditional |
| elicitation-server-stored-state-bound-to-user | 2026-07-28 | server | MUST | elicitation | “if state is stored, servers implementing elicitation MUST securely associate this state with individual users” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#statefulness | yes — mcpx stores (broker + daemon task) so it binds; requestState carries connection id | n/a | conditional |
| elicitation-state-not-session-id-alone | 2025-11-25 | server | MUST NOT | elicitation | “State MUST NOT be associated with session IDs alone” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#statefulness | yes — GAP: mcpx binds to MCPX_SESSION_ID/connection, i.e. session identity alone (no user auth exists) | n/a | removed 2026 |
| elicitation-state-storage-protected | 2025-11-25 – 2026-07-28 | server | MUST | elicitation | “State storage MUST be protected against unauthorized access” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#statefulness | yes — broker SQLite is local-user only; /v1/elicit over a non-loopback port is not protected | n/a | |
| elicitation-remote-user-id-from-authorization | 2025-11-25 – 2026-07-28 | server | MUST | elicitation | “For remote MCP servers, user identification MUST be derived from credentials acquired via MCP authorization when possible (e.g. sub claim)” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#statefulness | not-implemented-by-choice — mcpx does no OAuth; “when possible” is not met on --address 0.0.0.0 | n/a | |
| elicitation-server-must-not-self-authorize-via-url | 2025-11-25 – 2026-07-28 | server | MUST NOT | elicitation | “MCP servers MUST NOT rely on URL mode elicitation to authorize users for themselves.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#url-mode-elicitation-for-oauth-flows | yes — mcpx raises none | n/a | |
| elicitation-third-party-creds-not-via-client | 2025-11-25 – 2026-07-28 | server | MUST NOT | elicitation | “The third-party credentials MUST NOT transit through the MCP client” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#understanding-the-distinction | yes — mcpx is in the path only for the URL, not the credential | yes | |
| elicitation-no-token-passthrough | 2025-11-25 – 2026-07-28 | server | MUST NOT | elicitation | “The MCP server MUST NOT use the client’s credentials for the third-party service” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#understanding-the-distinction | not-implemented-by-choice (no OAuth) | n/a | |
| elicitation-user-authorizes-server-directly | 2025-11-25 – 2026-07-28 | server | MUST | elicitation | “The user MUST authorize the MCP server directly” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#understanding-the-distinction | yes — pass-through URL opens the upstream directly | n/a | |
| elicitation-server-not-transmit-url-creds-to-client | 2025-11-25 – 2026-07-28 | server | MUST NOT | elicitation | “The MCP server MUST NOT transmit credentials obtained through URL mode elicitation to the MCP client.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#understanding-the-distinction | yes | n/a | |
| elicitation-server-handle-decline-cancel-failure | 2026-07-28 | server | SHOULD NOT / MUST | elicitation | “Servers SHOULD NOT assume that elicitation requests will always succeed, and MUST handle cases where the user declines or cancels the elicitation, or where the client fails to process the request.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#error-handling | yes — mcpx as server re-asks up to askRounds / hands back cancel | n/a | replaces 2025-11-25 error-code MUSTs |
| elicitation-server-bind-to-client-and-user | 2025-11-25 – 2026-07-28 | server | MUST | elicitation | “Servers MUST bind elicitation requests to the client and user identity” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#security-considerations | yes — per-connection correlation; ambiguous shared scope goes to broker (docs/protocol.md §3.4) | n/a | new in 2025-11-25 |
| elicitation-client-user-approval-controls | 2025-06-18 – 2026-07-28 | client | SHOULD | elicitation | “Clients SHOULD implement user approval controls” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#security-considerations | n/a | yes — broker policy | |
| elicitation-client-allow-decline-any-time | 2025-06-18 – 2026-07-28 | client | SHOULD | elicitation | “Clients SHOULD allow users to decline elicitation requests at any time” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#security-considerations | n/a | yes | |
| elicitation-client-rate-limit | 2025-06-18 – 2025-11-25 | client | SHOULD | elicitation | “Clients SHOULD implement rate limiting” | https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#security-considerations | n/a | yes — proto.elicitationPerMinute (30) per server, fixed window; a refused request reaches no handler and is not answered cancel (#2192) | removed in 2026-07-28 |
| elicitation-client-clear-what-and-why | 2025-06-18 – 2026-07-28 | client | SHOULD | elicitation | “Clients SHOULD present elicitation requests in a way that makes it clear what information is being requested and why” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#security-considerations | yes — forward message intact (only prefixed) | yes — delegated | |
| elicitation-both-validate-against-schema | 2025-06-18 | both | SHOULD | elicitation | “Both parties SHOULD validate elicitation content against the provided schema” | https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#security-considerations | yes | yes | split per side in 2025-11-25 |
| elicitation-client-validate-responses | 2025-11-25 – 2026-07-28 | client | SHOULD | elicitation | “Clients SHOULD validate all responses against the provided schema” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#form-mode-security | n/a | yes — broker records problems on answers (routes_proto.go:446) | |
| elicitation-server-validate-received-data | 2025-11-25 – 2026-07-28 | server | SHOULD | elicitation | “Servers SHOULD validate received data matches the requested schema” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#form-mode-security | yes — mcpx as server should validate before relaying upstream | n/a | |
| elicitation-url-no-sensitive-user-info | 2025-11-25 – 2026-07-28 | server | MUST NOT | elicitation | “MUST NOT include sensitive information about the end-user, including credentials, personally identifiable information, etc., in the URL” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | yes — pass-through; mcpx must not append its own session/ids to the URL | n/a | |
| elicitation-url-not-pre-authenticated | 2025-11-25 – 2026-07-28 | server | MUST NOT | elicitation | “MUST NOT provide a URL which is pre-authenticated to access a protected resource” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | yes | n/a | |
| elicitation-no-clickable-urls-in-form | 2025-11-25 – 2026-07-28 | server | SHOULD NOT | elicitation | “SHOULD NOT include URLs intended to be clickable in any field of a form mode elicitation request.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | yes — the “(via mcpx)” prefix adds no URL | n/a | |
| elicitation-url-https-outside-dev | 2025-11-25 – 2026-07-28 | server | SHOULD | elicitation | “SHOULD use HTTPS URLs for non-development environments.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | yes — pass-through | n/a | |
| elicitation-client-handle-urls-carefully | 2025-11-25 – 2026-07-28 | client | MUST | elicitation | “Clients implementing URL mode elicitation MUST handle URLs carefully to prevent users from unknowingly clicking malicious links.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | n/a | yes — if the TUI/plugin offers to open a URL (docs/elicitation.md §5.6) | |
| elicitation-client-no-url-prefetch | 2025-11-25 – 2026-07-28 | client | MUST NOT | elicitation | “MUST NOT automatically pre-fetch the URL or any of its metadata.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | yes — a proxy must not fetch/validate the URL in transit either (implied) | yes | |
| elicitation-client-no-open-without-consent | 2025-11-25 – 2026-07-28 | client | MUST NOT | elicitation | “MUST NOT open the URL without explicit consent from the user.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | n/a | yes — broker auto rules must never “accept” a url question (implied) | |
| elicitation-client-show-full-url | 2025-11-25 – 2026-07-28 | client | MUST | elicitation | “MUST show the full URL to the user for examination before consent.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | yes — URL must reach the downstream unmodified | yes | |
| elicitation-client-open-url-securely | 2025-11-25 – 2026-07-28 | client | MUST | elicitation | “MUST open the URL provided by the server in a secure manner that does not enable the client or LLM to inspect the content or user inputs.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | n/a | yes — url questions must not route to the agent audience | |
| elicitation-client-highlight-domain | 2025-11-25 – 2026-07-28 | client | SHOULD | elicitation | “SHOULD highlight the domain of the URL to mitigate subdomain spoofing.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | n/a | yes | |
| elicitation-client-warn-punycode | 2025-11-25 – 2026-07-28 | client | SHOULD | elicitation | “SHOULD have warnings for ambiguous/suspicious URIs (i.e., containing Punycode).” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | n/a | yes | |
| elicitation-client-no-clickable-except-url-field | 2025-11-25 – 2026-07-28 | client | SHOULD NOT | elicitation | “SHOULD NOT render URLs as clickable in any field of an elicitation request, except for the url field in a URL elicitation request” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#safe-url-handling | n/a | yes | |
| elicitation-server-no-trust-client-user-id | 2025-11-25 – 2026-07-28 | server | MUST NOT | elicitation | “Servers MUST NOT rely on client-provided user identification without server verification, as this can be forged.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#identifying-the-user | yes — GAP-ish: MCPX_SESSION_ID is client-provided; the signed requestState binds to a connection mcpx issued, which is the verification | n/a | |
| elicitation-server-follow-best-practices | 2025-11-25 – 2026-07-28 | server | SHOULD | elicitation | “Instead, servers SHOULD follow security best practices.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#identifying-the-user | yes | n/a | |
| elicitation-url-verify-opening-user | 2025-11-25 – 2026-07-28 | server | MUST | elicitation | “The MCP Server MUST verify the identity of the user who opens the URL before accepting information.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#phishing | n/a — mcpx issues no URL; the upstream that does sees mcpx’s identity, not the end user’s | n/a | |
| elicitation-url-same-user-start-and-complete | 2025-11-25 – 2026-07-28 | server | MUST | elicitation | “the server MUST ensure that the user who started the elicitation request … is the same user who completes the authorization flow.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#phishing | n/a (see Implied: shared pooled upstream) | n/a | |
| elicitation-url-identity-resilient-to-url-tampering | 2025-11-25 – 2026-07-28 | server | MUST | elicitation | “the server MUST ensure that the mechanism to determine the user’s identity is resilient to attacks where an attacker can modify the elicitation URL.” | https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#phishing | n/a | n/a | |
| elicitation-task-related-id-shared | 2025-11-25 | server | MUST | elicitation | “an elicitation that a task-augmented tool call depends on MUST share the same related task ID with that tool call’s task.” | https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#associating-task-related-messages | yes — when mcpx forwards an upstream question for a task-augmented downstream call, _meta["io.modelcontextprotocol/related-task"] must name mcpx’s task id, not upstream’s (unverified) | yes — must accept/ignore it | tasks core only in 2025-11-25 |
| mrtr-input-request-type-restricted | 2026-07-28 | server | MUST | mrtr | “inputRequests values are request objects that MUST be one of ElicitRequest, CreateMessageRequest, or ListRootsRequest” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#server-requirements-basic-workflow | yes — ping or custom upstream requests cannot be carried downstream in 2026 | yes — others answered -32601 in legacy; in 2026 no slot to refuse | new |
| mrtr-server-reask-missing-rather-than-error | 2026-07-28 | server | SHOULD | mrtr | “the server SHOULD respond with a new InputRequiredResult requesting the missing information again, rather than returning an error.” | https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#error-handling | yes | n/a | |
| mrtr-missing-capability-error-32021 | 2026-07-28 | server | MUST | errors (roots/sampling/elicitation) | “If processing a request requires a capability the client did not include in io.modelcontextprotocol/clientCapabilities, the server MUST return a MissingRequiredClientCapabilityError (-32021)” | https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta | yes — conflicts with mcpx’s broker fallback when the downstream lacks elicitation (see Conflicts) | yes — must surface -32021 from upstream (mcpx always declares elicitation, so should not see it for elicitation) | new |
Folded duplicates
Same requirement found by two sweeps with the same revision range; the second id was dropped in favour of the first.
| dropped | kept |
|---|---|
| stdio-fallback-not-keyed-to-one-code | versioning-fallback-not-keyed-to-one-code |
| streamable-http-client-sends-protocol-version-header | lifecycle-http-protocol-version-header |
| streamable-http-dual-era-client-detect | versioning-dual-era-http-detect |
| cancellation-schema-server-stdio-only-listen | stdio-server-cancelled-only-for-listen |
| mrtr-server-never-initiates | messages-servers-must-not-initiate-requests |
| tools-x-mcp-header-client-reject | streamable-http-client-rejects-invalid-x-mcp-header |
| tools-call-retry-new-id | mrtr-new-id-on-retry |
| resources-updated-only-after-subscribe | subscriptions-resources-updated-only-if-subscribed |
| resources-listen-no-unrequested | subscriptions-only-requested-types |
| resources-listen-subscriptionid | meta-subscription-id-on-listen-notifications |
| meta-missing-required-reject | meta-server-rejects-missing-required-field |
| meta-no-infer-capabilities | meta-server-no-infer-caps-from-prior |
| meta-no-rely-undeclared-cap | meta-server-no-undeclared-client-capabilities |
| meta-missing-cap-error | errors-missing-required-client-capability |
| meta-clientinfo-should | meta-client-should-send-clientinfo |
| meta-serverinfo-every-result | meta-server-should-send-serverinfo |
| meta-info-not-for-behavior | meta-info-not-for-behavior-or-security |
| meta-resulttype-required | messages-resulttype-server-must-include |
| meta-resulttype-absent-is-complete | messages-resulttype-absent-means-complete |
| cache-private-no-shared | caching-private-no-cross-auth |
| discover-must-implement | versioning-server-must-implement-discover |
| discover-dual-client-stdio-first | versioning-dual-era-client-probe-discover-first-stdio |
| mrtr-server-must-use-mrtr-for-client-features | mrtr-server-requests-via-mrtr |
| mrtr-server-no-undeclared-input-requests | mrtr-only-declared-capabilities |
| mrtr-input-required-only-on-three-methods | mrtr-supported-requests-only |
| mrtr-server-must-not-assume-fulfilment | mrtr-no-assume-retry |
| mrtr-client-must-construct-inputs-before-retry | mrtr-client-fulfil-before-retry |
Pairs that look alike but cover different ranges were kept apart on purpose, because the requirement moved page in 2026-07-28 (timeouts moved from lifecycle to cancellation; stdio shutdown moved from lifecycle to transports/stdio).
Revision conflicts and spec-internal contradictions
WP6 output 1b. Everything where a message or behaviour legal in one revision is forbidden or required differently in another, plus places where one revision contradicts itself (page vs page, prose vs schema, changelog vs page). Per-area sections are the sweeps’ own lists, lightly re-headed; ids refer to requirements.md.
How mcpx chooses (#307)
Where a conflict below is not settled by the peer’s stated revision, mcpx is
meant to decide it by spec.precedence (revisions in the order their rules
win; default newest first, --mcp-spec <rev> moves one to the front) and
spec.lenient (revisions not held strictly; default none), through
internal/spec. See ../protocol.md §1.1.
Decided by those settings today:
- Progress
messageto a 2024-11-05 client (2024-11-05’s schema has nomessage; every later revision does). Sent by default; stripped only when 2024-11-05 is first and strict.internal/mcpserver/relay.go. - Headline 2, server-initiated requests from a 2026-07-28 server. Dropped
while 2026-07-28 is strict (default); answered when it is in
spec.lenient.internal/mcpclient/client.go.
Every headline item now carries a disposition in the index below (Phase 1 of #2209): peer-settled (the peer’s stated revision decides it, in code), precedence-governed (decided by the settings above), a recorded reading (mcpx follows one named reading of contradictory spec texts), or undisposed (an open question is named instead of a silent choice). Per-area items in sections A-E are dispositioned in place (Phase 1 of #2209, complete): every item carries a disposition line of one of the four kinds above, and undisposed items name the specific open question instead of a silent choice.
Disposition index — headline items
- Recorded reading — on stdio send the cancel (MUST) and, for a graceful end, the result; on HTTP answer the listen request and close the stream. Reading recorded in the item and in the comment block at internal/mcpserver/listen.go.
- Precedence-governed —
spec.Current().Governs("2026-07-28")at internal/mcpclient/client.go (server-initiated requests dropped while 2026-07-28 is strict; MRTR carriage in section E). Also in the “Decided” list above. - Peer-settled — disconnect split by era at internal/mcpserver/server.go (disconnect SHOULD NOT cancel on legacy, MUST in 2026).
- Recorded reading — the legacy MUST governs legacy
initialize:negotiate()counter-offers a supported version;-32022applies to modern_meta-versioned requests only. See A/C5. - Undisposed — open question (#1481 decision): the client choke keeps
-32602(internal/mcpclient/schemacheck.go, internal/daemon/callerr.go); should the serve seam era-splitisErrorfor >=2025-11-25 hosts while /v1 and the client API keep-32602? - Peer-settled — era-split codes in code: legacy
-32002(internal/mcpserver/server.go,codeResourceNotFoundLegacy), 2026-32602. - Undisposed — open question:
downgrade()(internal/mcpserver/revisions.go) deletesstructuredContentonly for pre-2025-06-18; a non-object value to a 2025-06-18..2025-11-25 (object-only) host passes unchanged. Render into content, wrap, or error? Check the conformance rows first. - Peer-settled —
nextPage(internal/mcpclient/client.go): a missing cursor ends results in every revision; in 2026 an empty string is a valid cursor, not the end. - Undisposed — open question: the logging gate. The client relay gates on a requested level (internal/mcpclient/relay.go,
moreVerbose); if no path relays upstream logs to a host that requested no level, this item is moot in the relay direction and should be closed saying so, with the site. - Peer-settled — legacy session-404 re-initialisation is era-keyed in internal/mcpclient’s legacy session recovery (internal/mcpclient/client.go:1138-1145): a session-carrying 404 re-initialises on the legacy path, a modern-era 404 surfaces; on the server side an unknown method on the modern path is answered 404 with
-32601(internal/mcpserver/server.go:879). See section B item C7. - Peer-settled —
batchRefused(internal/mcpserver/transport.go): batches are refused outside 2025-03-26, the one revision that mandates receiving them. - Recorded reading — the schema and caching page govern over the changelog’s list:
server/discoverresults areCacheableResults, and mcpx implements it that way — the discover result (internal/mcpserver/server.go) emitssupportedVersions, and the modern envelope addsttlMs/cacheScopeto it as to every cacheable result (server.go:762-763; covered byTestModernEnvelope). - Undisposed — both halves stay open, with the actual behaviour now recorded for one: cancelled-task finality (core vs extension) has no verified site (section C item C11), and the missing-required-task code (
-32601vs-32600) is not implemented either way — a non-augmented call executes ordinarily (section C item C10, internal/mcpserver/tasks.go:237-245).
Headline items
- Subscription teardown, 2026-07-28 (three sources disagree).
basic/patterns/cancellation: a server MUST sendnotifications/cancelledreferencing thesubscriptions/listenid when it tears the stream down, and MUST NOT sendnotifications/cancelledfor any other purpose.basic/patterns/subscriptions: the server SHOULD send a successfulsubscriptions/listenresponse to signal a graceful end, and never mentions a cancel.schema.tsCancelledNotification: on stdio the server sends it, solely to end a listen stream; the Streamable HTTP page saysnotifications/cancelledis used only on stdio. Reading that satisfies all three: on stdio, send the cancel (MUST) and, for a graceful end, the result; on HTTP, answer the listen request and close the stream. That is the recorded reading, not current mcpx behaviour:closeListen/announceEnd(internal/mcpserver/listen.go:354-377) send both frames on every transport, including modern HTTP, so the HTTP side is an implementation gap against this reading, not settled behaviour. See C1 in section C. - Server-initiated requests. Legal on every legacy revision (sampling, roots, elicitation, ping); forbidden in 2026-07-28, where they travel as MRTR
inputRequestsin a result. - Disconnect as cancellation (Streamable HTTP). 2025-03-26..2025-11-25: a disconnect SHOULD NOT be treated as cancellation. 2026-07-28: MUST be.
- Legacy
initializewith an unsupported version. Every legacy revision: the server MUST answer with a version it supports.-32022exists only in 2026-07-28 and applies to_meta-versioned requests. - Tool input validation errors. Protocol error up to 2025-06-18;
isErrortool result from 2025-11-25. - Resource not found.
-32002(SHOULD) up to 2025-11-25;-32602(MUST) in 2026-07-28. structuredContent/outputSchema. Object-only in 2025-06-18..2025-11-25; any JSON value / any 2020-12 schema in 2026-07-28.- Empty-string cursor. Unspecified before; in 2026-07-28 MUST NOT be treated as end of results.
- Logging without a requested level. MAY send before 2026-07-28; MUST NOT after.
logging/setLevelis removed in 2026-07-28 while logging is only deprecated. - HTTP 404. Session ended (client MUST re-initialize) in 2025-03-26..2025-11-25; method not found in 2026-07-28.
- Batches. Receiving JSON-RPC batches MUST be supported in 2025-03-26 only; removed in 2025-06-18.
server/discovercache hints. The 2026-07-28 changelog’s list of results needingttlMs/cacheScopeomitsserver/discover; the caching page and the schema require them onDiscoverResult.- Tasks. 2025-11-25 core: a cancelled task stays
cancelled; extension: may still end in another terminal status. Missing-required-task error code:-32601on the tool-level rule vs-32600in the error list. The changelog calls extension tasks unsolicited; the extension page forbids returning a task to a client that did not declare support.
A. Lifecycle, versioning, messages, errors, _meta, ping
Across revisions (legal in one, forbidden or different in another)
C1. JSON-RPC batches: silent → MUST receive → removed.
- Disposition: Peer-settled — batches are answered only for a 2025-03-26 peer and refused for every other revision (
batchRefused/runBatch, internal/mcpserver/transport.go:106-116); see headline 11. The-32700behaviour recorded below predates that split. - 2024-11-05: messages must “follow the JSON-RPC 2.0 specification” (which defines batches) but the schema’s
JSONRPCMessagehas no array form. https://modelcontextprotocol.io/specification/2024-11-05/basic/messages - 2025-03-26: “MCP implementations MAY support sending JSON-RPC batches, but MUST support receiving JSON-RPC batches.” https://modelcontextprotocol.io/specification/2025-03-26/basic/index#batching — and “The initialize request MUST NOT be part of a JSON-RPC batch … This also permits backwards compatibility with prior protocol versions that do not explicitly support JSON-RPC batches.” https://modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle#initialization
- 2025-06-18: changelog “Remove support for JSON-RPC batching” https://modelcontextprotocol.io/specification/2025-06-18/changelog#major-changes ; Batching section and
JSONRPCBatchRequestdeleted. Every later revision still says “MUST follow the JSON-RPC 2.0 specification” without excluding batches. - Effect: a batch is mandatory-to-accept only for 2025-03-26. A server serving 2025-03-26 and 2025-06-18 cannot know the version before parsing, so it has to accept arrays on the wire anyway (the version is inside the elements). mcpx rejects arrays (-32700).
C2. Request-ID uniqueness scope.
- Disposition: Undisposed — open question: does mcpx’s client reuse request IDs after completion on a 2026-07-28 connection, and can a stale
subscriptions/listennotification then collide with a new listen reusing the ID? No site verified in Phase 1; check request-ID allocation in internal/mcpclient. - 2024-11-05..2025-11-25: “The request ID MUST NOT have been previously used by the requestor within the same session.” https://modelcontextprotocol.io/specification/2025-11-25/basic/index#requests
- 2026-07-28: “The request ID MUST NOT match the ID of any other request the sender has issued and not yet received a response for.” https://modelcontextprotocol.io/specification/2026-07-28/basic/index#requests
- Reuse after completion is legal in 2026, illegal in legacy. Internal tension in 2026: changelog item 9 says a broken stream’s request must be re-issued “as a new request with a new request ID” (https://modelcontextprotocol.io/specification/2026-07-28/changelog#major-changes), and
subscriptionIdis “the JSON-RPC ID of thesubscriptions/listenrequest” (schema NotificationMetaObject) — with reuse legal once a listen ends, a stale notification can carry an ID now used by a different listen.
C3. Server-initiated requests, including ping.
- Disposition: Peer-settled — the peer’s era decides: legacy peers receive wire requests; a modern connection never does (server-initiated requests are governed/dropped per headline 2, internal/mcpclient/client.go), and methods a revision removed are answered
-32601(internal/mcpserver/revisions.go:139). - 2024-11-05..2025-11-25: requests go “from the client to the server or vice versa”; ping: “Either the client or server can initiate a ping” and “The receiver MUST respond promptly with an empty response”. https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping#behavior-requirements
- 2026-07-28: “Servers MUST NOT initiate JSON-RPC requests” https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/index ; changelog “Remove
ping,logging/setLevel, andnotifications/roots/list_changed.” https://modelcontextprotocol.io/specification/2026-07-28/changelog#major-changes - A 2026 client receiving
pinghas no defined behaviour; a 2026 server receivingpingwould answer -32601 (unknown method), which a legacy client reads as a dead peer (the very bug mcpx fixed in its client).
C4. Error-response id when the request could not be read.
- Disposition: Recorded reading — mcpx omits
idon a parse-error response, following the 2025-11-25+ schema reading over JSON-RPC’snulland the legacy schema’s required non-nullid; the legacy-schema invalidity is accepted and recorded here. - JSON-RPC 2.0 (imported by “MUST follow”): id “MUST be Null” when it could not be detected.
- 2024-11-05..2025-06-18 schema:
JSONRPCError { id: RequestId }(required, non-null); prose: “Responses MUST include the same ID as the request they correspond to.” GH(2025-06-18) / https://modelcontextprotocol.io/specification/2025-06-18/basic/index#responses — and “the ID MUST NOT benull” is stated for requests only. - 2025-11-25..2026-07-28:
JSONRPCErrorResponse { id?: RequestId }, “(except in error cases where the ID could not be read due a malformed request)” https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-responses - So a parse-error response is unrepresentable in the legacy schema (neither
nullnor absent allowed),nullper JSON-RPC, and absent per 2025-11-25+ schema (which still admits nonull). mcpx omitsid— right for 2025-11-25+, schema-invalid for 2025-03-26/2025-06-18.
C5. Version negotiation: MUST counter-offer vs example error (lifecycle page, all legacy revisions).
- Disposition: Recorded reading — the legacy MUST governs:
negotiate()(internal/mcpserver/server.go) counter-offers a supported version;-32022applies to modern_meta-versioned requests only. See headline 4. - Prose: “Otherwise, the server MUST respond with another protocol version it supports.” https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
- Same page, “Example initialization error”:
"code": -32602, "message": "Unsupported protocol version", "data": {"supported": [...], "requested": "1.0.0"}https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#error-handling - The example is a server doing what the MUST forbids. 2026 then standardises that example’s shape under -32022 for modern requests only. mcpx now follows the legacy MUST:
negotiate()(internal/mcpserver/server.go) counter-offers a supported version (its latest when the request is unknown or modern);-32022is used only for modern_meta-versioned requests, as 2026 specifies. (When this item was recorded, mcpx returned -32022 on legacyinitializetoo.)
C6. Client on an unsupported negotiated version: SHOULD vs MUST (prose vs schema, 2024-11-05..2025-11-25).
- Disposition: Undisposed — open question: when a server negotiates a version mcpx’s client cannot support, does the client disconnect (prose SHOULD) or treat disconnect as mandatory (schema MUST)? No site verified in Phase 1; check the client negotiation path in internal/mcpclient.
- Prose: “If the client does not support the version in the server’s response, it SHOULD disconnect.” https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#version-negotiation
- Schema
InitializeResult.protocolVersion: “If the client cannot support this version, it MUST disconnect.” https://modelcontextprotocol.io/specification/2025-11-25/schema#initializeresult - Unchanged in all four legacy revisions.
C7. Using un-negotiated capabilities: SHOULD → MUST (2025-06-18).
- Disposition: Undisposed — open question: does mcpx’s server refuse a capability-gated method the peer never negotiated on a 2026-07-28 request (the MethodNotFoundError comment’s reading), or does its accept-liberally rule apply across eras? No site verified in Phase 1.
- 2024-11-05, 2025-03-26: “Both parties SHOULD: … Only use capabilities that were successfully negotiated” https://modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle#operation
- 2025-06-18, 2025-11-25: “Both parties MUST: …” https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#operation ; changelog “Change SHOULD to MUST in Lifecycle Operation”.
- A server accepting an un-negotiated method (mcpx’s accept-liberally rule) is not clearly “using” a capability; a client calling one clearly is. The 2026 MethodNotFoundError comment (“or one gated behind a server capability the server did not advertise”) says the server should refuse such calls — https://modelcontextprotocol.io/specification/2026-07-28/schema#methodnotfounderror — contradicting accept-liberally for 2026.
C9. Reserved _meta prefixes changed incompatibly (2025-06-18 → 2025-11-25).
- Disposition: Undisposed — open question: does mcpx validate or generate
_metaprefixes per era, and would a 2025-06-18 peer’s legalio.modelcontextprotocol/data be misread as protocol data? No site verified in Phase 1. - 2025-06-18: “Any prefix beginning with zero or more valid labels, followed by
modelcontextprotocolormcp, followed by any valid label, is reserved …modelcontextprotocol.io/,mcp.dev/,api.modelcontextprotocol.org/, andtools.mcp.com/are all reserved.” https://modelcontextprotocol.io/specification/2025-06-18/basic/index#meta - 2025-11-25+: “Any prefix where the second label is
modelcontextprotocolormcpis reserved …io.modelcontextprotocol/… However,com.example.mcp/is NOT reserved” https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta io.modelcontextprotocol/— the prefix of every 2026 protocol key — is not reserved under the 2025-06-18 rule (nothing followsmodelcontextprotocol), so a 2025-06-18 implementation could legally have used it for its own data. Converselymodelcontextprotocol.io/andtools.mcp.com/are reserved in 2025-06-18 and free in 2025-11-25.
C11. Error codes -32002 and -32042.
- Disposition: Peer-settled — dual-era code choice is in code: legacy
-32002(codeResourceNotFoundLegacy, internal/mcpserver/server.go) vs 2026-32602(headline 6), and-32042belongs to the 2025-11-25 era only — the client propagates it untouched (internal/mcpclient/client.go:630); the server-side return gap is recorded at section E, spec-internal item 2. - 2025-11-25 (and earlier for -32002): resource-not-found is -32002;
URL_ELICITATION_REQUIRED = -32042under “Implementation-specific JSON-RPC error codes [-32000, -32099]”. https://modelcontextprotocol.io/specification/2025-11-25/schema#urlelicitationrequirederror - 2026-07-28: “Implementations of this protocol version MUST NOT emit these codes:
-32002…-32042” https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes - 2025-11-25 called -32000..-32099 wholly implementation-specific; 2026 carves -32020..-32099 out as spec-only. Any legacy SDK code in -32020..-32099 other than -32042 becomes forbidden to emit under 2026. A dual-era server must choose the code by the era of the request.
C15. Lifecycle requirement itself.
- Disposition: Peer-settled — the dual-era server keys lifecycle off the request’s era: legacy
initializeis served on the legacy path, while the modern path takes_meta-versioned requests and answers unknown/removed methods-32601(internal/mcpserver/server.go:879, :916). - 2024-11-05..2025-11-25: “All implementations MUST support the base protocol and lifecycle management components.” https://modelcontextprotocol.io/specification/2025-11-25/basic/index
- 2026-07-28: “All implementations MUST support the base protocol, versioning, and the message patterns.” — and
initializeis an unknown method to a modern-only server. https://modelcontextprotocol.io/specification/2026-07-28/basic/index
C16. Where capabilities live, and whether they persist.
- Disposition: Undisposed — open question: does any modern-path handler read capabilities stored from an earlier request on the same connection, contrary to the 2026 statelessness rule? Legacy session state is era-correct; no modern-path site verified in Phase 1.
- Legacy: capabilities “establish which optional protocol features will be available during the session” https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#capability-negotiation
- 2026: “Servers MUST NOT rely on prior requests … (e.g., capabilities, protocol version, client identity)” and schema “Servers MUST NOT infer capabilities from prior requests.” https://modelcontextprotocol.io/specification/2026-07-28/basic/index#statelessness
- Remembering caps is required in one era and forbidden in the other; a dual-era server must key the rule off the request, not the connection.
C17. serverInfo placement and instructions.
- Disposition: Recorded reading — mcpx follows the 2026 placement (see the item text):
supportedVersionsin the discover result,serverInfoin_meta, andttlMs/cacheScopefrom the modern envelope. - Legacy:
InitializeResult.serverInfo(required),instructions?top-level. - 2026:
serverInfoonly in_meta["io.modelcontextprotocol/serverInfo"](SHOULD, every result);DiscoverResult.instructionstop-level. https://modelcontextprotocol.io/specification/2026-07-28/server/discover#discoverresult - mcpx’s discover answer now uses the 2026 placement:
supportedVersionsin the result andserverInfoin_meta(added by the envelope to every modern result). The discover result also carriesttlMs/cacheScope, added by the modern envelope as for every cacheable result (headline 12).
C18. Timeouts relocated and reworded.
- Disposition: Undisposed — open question: on a 2026-07-28 HTTP request, does mcpx’s sender cancel a timed-out request by closing the stream rather than sending
notifications/cancelled? The relocation makes the two eras’ timeout behaviour differ; no timeout-path site verified in Phase 1. - 2025-03-26..2025-11-25 lifecycle: “the sender SHOULD issue a cancellation notification for that request” https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts
- 2026-07-28 cancellation page: “the sender SHOULD cancel the request and stop …” https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#timeouts (cancellation owner has the details; the change matters because on HTTP 2026 cancellation is closing the stream, not a notification).
Within one revision
C8. “requests other than pings and logging” (2024-11-05..2025-11-25). “The server SHOULD NOT send requests other than pings and logging before receiving the initialized notification.” https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization — logging is notifications/message, a notification, so the exception is to a rule about requests. Read literally, notifications are unrestricted before initialized; read as intended, “messages” was meant and progress/list_changed notifications are discouraged.
- Disposition: Recorded reading — the literal reading is recorded: the sentence restricts requests, so notifications before
initializedare not restricted by it, whatever the drafters intended by “logging”.
C10. -32000..-32019: “legacy” (prose) vs “implementation-defined” (schema), 2026-07-28.
- Disposition: Undisposed — open question: does mcpx emit any code in
-32000..-32019of its own on a 2026-07-28 request (prose: SHOULD NOT; schema/changelog: grandfathered)? No emissions audit done in Phase 1. - Prose: “
-32000to-32019— legacy. … new implementations SHOULD NOT use codes from this sub-range at all.” https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes - Schema comment: “
-32000to-32019: implementation-defined. Existing SDKs and implementations use codes here for their own purposes; the specification will never define codes in this sub-range” — no SHOULD NOT. Changelog item 12: “-32000to-32019remains implementation-defined (existing SDK usage is grandfathered)”. https://modelcontextprotocol.io/specification/2026-07-28/changelog#minor-changes - Also prose “Apart from
-32002… receivers MUST NOT assume any specific meaning” — but -32002 is in the legacy sub-range, and the same page tells clients to accept it as resource-not-found: a single spec-assigned meaning inside a range the schema says “the specification will never define codes in”.
C12. Request params optional (prose/type) vs required _meta (schema), 2026-07-28.
- Disposition: Undisposed — open question: does mcpx’s client ever send a params-less 2026-07-28 request, and does its server reject a 2026 request whose
_metalacks the required fields? No site verified in Phase 1. - basic/index shows
params?: { [key: string]: unknown }for requests. https://modelcontextprotocol.io/specification/2026-07-28/basic/index#requests - Schema
RequestParams { _meta: RequestMetaObject }with requiredprotocolVersion/clientCapabilities; “fields marked as required MUST be included on every request”. So every 2026 request hasparams, including ones legacy defined without params. The genericRequest.params?is kept “to allow unofficial extensions”.
C13. Error for a legacy initialize at a modern-only server, 2026-07-28.
- Disposition: Recorded reading — mcpx answers an unknown method on the modern path with
-32601(internal/mcpserver/server.go:879), i.e. the versioning matrix’s unknown-method reading, not the-32602the malformed-request MUST picks. - basic/index: “A request missing any required field is malformed; the server MUST reject it with JSON-RPC error code
-32602(Invalid params). On HTTP, the response status MUST be400 Bad Request.” https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta - versioning matrix (Legacy/Modern): “the server rejects
initializewith a JSON-RPC error; the exact code is implementation-defined (initializeis an unknown method and the request also lacks the required_metafields).” https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#compatibility-matrix - stdio page: legacy servers answer pre-initialize probes “commonly
-32601or-32602”. Unknown-method (-32601) and malformed (-32602) are both reachable; the MUST picks one, the matrix says either.
C14. HTTP era detection: 400 vs 4xx, 2026-07-28.
- Disposition: Undisposed — open question: which statuses trigger mcpx client era fallback — only
400(binding page) or any4xxwithout a recognised modern error body (matrix)? See also section B item C9. - Streamable HTTP: “On
400 Bad Request, the client SHOULD inspect the response body before falling back” https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility - Versioning matrix (Dual-era/Legacy): “the modern request returns a
4xxwithout a recognized modern error body, and the client falls back” https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#compatibility-matrix - A legacy 2025-03-26..2025-11-25 server answering a session-less non-initialize POST commonly returns 400 or 404 (session required / unknown session); the binding page only covers 400.
C19. “Extensions … negotiated during initialization” in a revision with no initialization, 2026-07-28.
- Disposition: Recorded reading — the versioning page governs over the index page’s sentence: extensions are negotiated per request via capabilities and
server/discover, there being no initialization in 2026-07-28. - index.mdx: “Extensions are always opt-in and require explicit support from both client and server, negotiated during initialization.” https://modelcontextprotocol.io/specification/2026-07-28/index#extensions
- versioning: extensions are advertised in capabilities, i.e. per request (
clientCapabilities.extensions) and viaserver/discover. https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#extension-negotiation
C20. Architecture page vs statelessness, 2026-07-28.
- Disposition: Recorded reading — the architecture page does not govern: servers declare features on
server/discover, per-request declaration is the client’s, and sampling’s deprecation status is as in deprecated.mdx. - architecture: “servers declare their supported features on each request” https://modelcontextprotocol.io/specification/2026-07-28/architecture/index#capability-negotiation — servers declare on
server/discoveronly; per-request declaration is the client’s. - architecture keeps “Built on JSON-RPC, MCP provides a protocol focused on context exchange and sampling coordination” while Sampling is Deprecated (deprecated.mdx).
- architecture: “Receiving resource update notifications requires opening a
subscriptions/listenstream” vs changelog: the server “acknowledges” — no server capability gate stated for update notifications, whileServerCapabilities.resources.subscribestill exists in schema.
C21. Notifications direction vs server-initiated cancellation, 2026-07-28.
- Disposition: Recorded reading — covered by the headline 1 reading: the server-sent
notifications/cancelledthat terminates asubscriptions/listenstream is the one exception to the patterns page’s direction rule. - patterns/index: “The client sends JSON-RPC requests and notifications. The server answers each request with a JSON-RPC response … optionally preceded by notifications scoped to that request.” https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/index
- schema CancelledNotification: “On stdio, the server also sends this notification, solely to terminate a
subscriptions/listenstream” and CancelledNotificationParams.requestId “MUST correspond to the ID of a request the client previously issued” — a server-sent cancel that refers to the client’s request, i.e. not “scoped to” a request it is answering but terminating it. (Cancellation/subscriptions owners hold the full version of this.)
C22. resultType unknown-value rule vs open type, 2026-07-28.
- Disposition: Undisposed — open question: how does mcpx’s client treat an unrecognised
resultType, and an absent one on a response to a request that carriedprotocolVersion: 2026-07-28? No site verified in Phase 1. - basic/index: “A
resultTypeof any value unrecognized by the client MUST be considered invalid.” https://modelcontextprotocol.io/specification/2026-07-28/basic/index#resulttype - schema:
type ResultType = "complete" | "input_required" | string;and “Servers implementing this protocol version MUST include this field. For backward compatibility, when a client receives a result from a server implementing an earlier protocol version … the client MUST treat the absent field as\"complete\".” A client cannot tell a pre-2026 server from a 2026 server that forgot the field, and on a response to a request that carriedprotocolVersion: 2026-07-28the server by definition is not “implementing an earlier protocol version” — yet the only rule given is absent ⇒ complete.
C23. server/discover field names: versioning page vs mcpx (not a spec-internal conflict; recorded because it breaks interop). Spec: supportedVersions (schema DiscoverResult, discover.mdx, versioning page text “learn the server’s supported versions”). When recorded, mcpx server and client both used protocolVersions and top-level serverInfo. Fixed: the server emits supportedVersions (internal/mcpserver/server.go, server/discover case) with serverInfo in _meta, and the client parses supportedVersions (internal/mcpclient/probe.go). No longer an interop break; kept as the record of one.
- Disposition: Recorded reading — resolved by fixing mcpx to the spec’s names (see the item text): the server emits
supportedVersions(internal/mcpserver/server.go) and the client parses it (internal/mcpclient/probe.go). Kept as the record of the break.
B. Transports and authorization
C1. Server-to-client requests / client-to-server responses: legal ≤2025-11-25, forbidden 2026-07-28.
- Disposition: Peer-settled — frame legality is keyed to the connection’s era in code: a modern connection carries no server-initiated wire requests (see section A item C3), so the forbidden frames cannot arise on it; legacy connections permit them.
- 2025-11-25: “The server MAY send JSON-RPC requests and notifications before sending the JSON-RPC response.” — https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server ; POST body “MUST be a single JSON-RPC request, notification, or response” (same anchor).
- 2026-07-28: “The server MUST NOT send independent JSON-RPC requests on this stream.” — https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#receiving-messages ; “The client MUST NOT send JSON-RPC responses.” — #sending-messages ; stdio: “The server MUST NOT write JSON-RPC requests to
stdout.” / “The client MUST NOT write JSON-RPC responses.” — https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#receiving-messages - Consequence: on one mcpx endpoint/process, whether a frame is legal depends on the connection’s era, not the endpoint.
C2. JSON-RPC batching: required-to-accept in 2025-03-26, removed 2025-06-18.
- Disposition: Peer-settled — same site as section A item C1:
batchRefused/runBatch(internal/mcpserver/transport.go:106-116); batches are received for 2025-03-26 only. - 2025-03-26 stdio: “Messages may be … a JSON-RPC batch” — https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio ; HTTP: “An array batching one or more requests and/or notifications” — #sending-messages-to-the-server
- 2025-06-18: “Messages are individual JSON-RPC requests, notifications, or responses.” ; “MUST be a single JSON-RPC request, notification, or response.” — https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio . Changelog: “Remove support for JSON-RPC batching (PR #416)”.
- 2024-11-05 transports page is silent on batching (JSON-RPC 2.0 allows it).
C3. Relatedness of messages on a POST’s SSE stream: SHOULD → MUST.
- Disposition: Undisposed — open question: does mcpx’s server scope every notification on a POST’s SSE stream to the originating request on a 2026-07-28 connection (MUST), and only by default on legacy (SHOULD)? No site verified in Phase 1.
- 2025-11-25: “These messages SHOULD relate to the originating client request.” (#sending-messages-to-the-server)
- 2026-07-28: “These notifications MUST relate to the originating client request.” (#receiving-messages). Unrelated server messages have no home except
subscriptions/listen.
C4. Closing a POST SSE stream before the response.
- Disposition: Undisposed — open question: per era, when may mcpx’s server close a POST SSE stream before the response — is the 2025-11-25 connection/stream distinction implemented, and is early close on 2026 treated as losing the request? No site verified in Phase 1.
- 2025-03-26..2025-06-18: “The server SHOULD NOT close the SSE stream before sending the JSON-RPC response … unless the session expires.” — https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#sending-messages-to-the-server
- 2025-11-25: “the server MAY close the connection (without terminating the SSE stream) at any time” (after an event id) — https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server . Legal only via the connection/stream distinction, which requires resumability.
- 2026-07-28: no resumability, and closing the stream is cancellation (C6), so a server can no longer close early without losing the request.
C5. Resumability / Last-Event-ID / event IDs: optional 2025-03-26..2025-11-25 (with priming SHOULD in 2025-11-25), removed 2026.
- Disposition: Undisposed — legacy resumability is implemented on the client (GET resume with
Last-Event-ID, internal/mcpclient/http.go:332, :407); open question: does the modern path ignoreLast-Event-IDas the 2026 backcompat text prescribes? No modern-path site verified in Phase 1. - 2025-11-25: “The server SHOULD immediately send an SSE event consisting of an event ID and an empty
datafield” ; “Resumption is always via HTTP GET withLast-Event-ID.” — #resumability-and-redelivery - 2026-07-28: “Resumable SSE streams via
Last-Event-IDare not supported.” (#receiving-messages); modern-only server “SHOULD” ignoreLast-Event-ID(#earlier-streamable-http-revisions).
C6. Disconnect semantics inverted.
- Disposition: Peer-settled — the era split is in code (headline 3): in a session a dropped connection is not a cancellation (legacy SHOULD NOT; the client cancels explicitly), while a sessionless disconnect is treated as the cancellation signal (internal/mcpserver/server.go, session disconnect block).
- 2025-03-26..2025-11-25: “Disconnection SHOULD NOT be interpreted as the client cancelling its request. To cancel, the client SHOULD explicitly send an MCP
CancelledNotification.” — https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server - 2026-07-28: “Closing the SSE response stream MUST be treated by the server as cancellation of that request.” — https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#cancellation ; “on Streamable HTTP, closing the SSE response stream is itself the cancellation signal and no
notifications/cancelledmessage is expected” (#sending-messages Note); cancellation page: “The server MUST treat a client disconnect as cancellation of that request.” — https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#transport-specific-cancellation - Spec-internal (2026): the streamable-http page ties cancellation to “Closing the SSE response stream”; the cancellation page to “a client disconnect” — the latter also covers
application/jsonresponses, which have no SSE stream. A dual-era server must key the disconnect rule to the request’s era.
C7. HTTP 404 meaning.
- Disposition: Peer-settled — era-keyed on both sides: the client recovers a session-carrying 404 by re-initialising only on the legacy path, and surfaces a modern-era 404 (internal/mcpclient/client.go:1138-1145); the server answers an unknown method on the modern path with 404 +
-32601(internal/mcpserver/server.go:879). See headline 10. - 2025-03-26..2025-11-25: 404 = session terminated; “When a client receives HTTP 404 in response to a request containing an
Mcp-Session-Id, it MUST start a new session” — https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management ; backcompat: 404 on POST ⇒ try HTTP+SSE GET (#backwards-compatibility). - 2026-07-28: 404 = unknown method: “it MUST respond with
404 Not Foundand a JSON-RPC error with code-32601” — https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#protocol-version-header . A 2025-11-25 client that still holds a session id and gets this 404 is required to re-initialize; a 2025-11-25-rule client probing with POST falls to HTTP+SSE. Only the 2026 rule (“and the response body is not a recognized modern JSON-RPC error”) disambiguates. - Also a change against JSON-RPC-over-HTTP practice in 2025-*, where a method-not-found error was an ordinary response (status not specified; in practice 200).
C8. Missing MCP-Protocol-Version header.
- Disposition: Undisposed — open question: does mcpx assume
2025-03-26for a header-less legacy request (2025-06-18..2025-11-25 SHOULD) and reject a header-less 2026 request as a header mismatch (-32020)? No site verified in Phase 1. - 2025-06-18..2025-11-25: server “SHOULD assume protocol version
2025-03-26” — https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#protocol-version-header - 2026-07-28: “MAY treat a request that omits the header as protocol version
2025-03-26. A server that does not support such clients MUST reject a request without the header” (#protocol-version-header); Server Validation lists a missingMCP-Protocol-Versionas a HeaderMismatch failure (#server-validation) → 400/-32020. Also 2025-11-25: “invalid or unsupported” header → 400 with no body format; 2026: unsupported → 400 +UnsupportedProtocolVersionError, mismatch → 400 +HeaderMismatch. - 2025-06-18 header rule applies to “all subsequent requests” (after initialize); 2026: “Every POST request”.
C9. HTTP+SSE fallback trigger narrowed twice.
- Disposition: Undisposed — open question: which status/body combinations make mcpx’s client fall back to HTTP+SSE against each era’s rule, including the 2026 recognised-modern-error-body condition? No site verified in Phase 1.
- 2025-03-26..2025-06-18: “If it fails with an HTTP 4xx status code (e.g., 405 Method Not Allowed or 404 Not Found)” — https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#backwards-compatibility
- 2025-11-25: “"400 Bad Request", "404 Not Found" or "405 Method Not Allowed"” — https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#backwards-compatibility (401/403 no longer trigger it — matters for OAuth-protected servers).
- 2026-07-28: those codes “and the response body is not a recognized modern JSON-RPC error” — https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#httpsse-transport-2024-11-05 . A 2025-11-25-rule client probing a modern-only 2026 server gets 400 (missing headers → HeaderMismatch) and then GETs, gets 405, and fails with a misleading diagnosis.
C10. Re-issue-after-broken-stream MUST lives only in the changelog.
- Disposition: Undisposed — open question: does mcpx’s client re-issue a request lost to a broken modern stream under a new request ID, and does it guard non-idempotent
tools/callre-issue? No site verified in Phase 1. - Changelog major 9: “clients MUST re-issue it as a new request with a new request ID” — https://modelcontextprotocol.io/specification/2026-07-28/changelog
- Transport page only: “Resumable SSE streams via
Last-Event-IDare not supported.” — no MUST; stdio page: “in-flight requests are simply lost and the client can retry them” (no keyword) — https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#unexpected-termination . Also re-issuing a non-idempotenttools/callis not qualified anywhere.
C11. Notification POSTs in 2026 have no defined header requirements, yet all POSTs need them.
- Disposition: Undisposed — open question: what header requirements does mcpx apply to notification POSTs from a 2026-07-28 peer, given the revision defines none? No site verified in Phase 1.
- “The client MUST include the request metadata headers on each POST request.” / “Every POST request to the MCP endpoint MUST include an
MCP-Protocol-Versionheader.” vs Note: “header requirements for notification POSTs are not defined by this revision.” andMcp-Method“Required For: All requests” — https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages . The same Note says no client→server notifications exist on HTTP in 2026 at all, while the rules for accepting them (202) remain.
C12. Server-sent notifications/cancelled for subscription teardown: scope disagreement (2026-07-28).
- Disposition: Recorded reading — the headline 1 reading governs: on stdio the server sends
notifications/cancelledand, for a graceful end, the listen result; on HTTP it answers the listen request and closes the stream. See section C item C1. Implementation gap: mcpx does not follow that transport split —announceEnd(internal/mcpserver/listen.go:365-377) sendsnotifications/cancelledand then the listen result unconditionally, whatever the transport (for a modern HTTP stream the listen stream’ssendis the HTTP SSE sender, listen.go:183). - schema.ts
CancelledNotification: “On stdio, the server also sends this notification, solely to terminate a subscriptions/listen stream” — https://modelcontextprotocol.io/specification/2026-07-28/schema#cancellednotification - cancellation page: “A server MUST send
notifications/cancelledreferencing asubscriptions/listenrequest ID when it tears down that subscription stream” (no transport qualifier) — https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation - schema.ts
SubscriptionsListenResult: “signalling that the subscription has ended gracefully … this result is sent only when the server tears the subscription down” — so graceful teardown has two mandated signals (a result response and a cancelled notification), and on stdio the notification is also the only server→client message the 2026 index’s “server-sent responses and notifications” permits beyond responses. The stdio page’s list of “three kinds of messages” the server writes does not include a server-sentnotifications/cancelled. (Detail belongs to the subscriptions/cancellation owner; recorded here because it is transport-scoped.)
C13. MCP-Protocol-Version header in 2025-03-26 authorization before the transport defines it.
- Disposition: Undisposed — open question (spec-internal/historical): does mcpx send
MCP-Protocol-Versionon authorization discovery requests while omitting it on 2025-03-26 MCP requests, as that era’s own pages imply? mcpx’s discovery surface was not audited in Phase 1. - 2025-03-26 authorization: “MCP clients SHOULD include the header
MCP-Protocol-Version: <protocol-version>during Server Metadata Discovery” with exampleMCP-Protocol-Version: 2024-11-05— https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#server-metadata-discovery-headers - 2025-03-26 transports page defines no such header; it arrives in 2025-06-18 (#protocol-version-header), whose backcompat rule assumes a header-less request is 2025-03-26 — i.e. a 2025-03-26 client following its own auth page sends a header on discovery but not on MCP requests.
C14. Dynamic Client Registration: SHOULD → MAY → deprecated.
- Disposition: Undisposed — open question: does mcpx implement Dynamic Client Registration at all, and if so under which era’s strength? Its authorization surface was not audited in Phase 1.
- 2025-03-26..2025-06-18: “SHOULD support the OAuth 2.0 Dynamic Client Registration Protocol” — https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#dynamic-client-registration
- 2025-11-25: “MAY support” — https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#dynamic-client-registration
- 2026-07-28: “Dynamic Client Registration is deprecated.” — https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration#dynamic-client-registration . Yet 2026 adds a new MUST inside it (
application_type). - 2026 security-considerations still links the proxy-consent MUST to “dynamically registered client” — a MUST scoped to a deprecated mechanism.
C15. Where the authorization server lives / discovery.
- Disposition: Undisposed — open question: which discovery rule does mcpx’s client follow per era — 2025-03-26 default paths or Protected Resource Metadata with path-inserted well-known URIs? Not audited in Phase 1.
- 2025-03-26: MCP server is (or fronts) the AS; “authorization base URL MUST be determined … by discarding any existing
pathcomponent”; default/authorize,/token,/registerfallbacks MUST be used — https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#authorization-base-url - 2025-06-18+: MCP server is a resource server only; clients “MUST use OAuth 2.0 Protected Resource Metadata for authorization server discovery” and no default-path fallback exists — https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#overview . 2025-11-25+ uses path-inserted well-known URIs (keeps the path the 2025-03-26 rule discarded).
C16. Token acceptance subject.
- Disposition: Undisposed — open question: where does mcpx validate token audience — authorization server or resource server — for each era? Not audited in Phase 1.
- 2025-06-18: “Authorization servers MUST only accept tokens that are valid for use with their own resources.” — https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#token-handling
- 2025-11-25+: “MCP servers MUST only accept tokens that are valid for use with their own resources.” — https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#token-handling
C17. WWW-Authenticate on 401.
- Disposition: Undisposed — open question: does mcpx’s server emit
WWW-Authenticateon 401, publish Protected Resource Metadata at the well-known URI, or both? Not audited in Phase 1. - 2025-06-18: “MCP servers MUST use the HTTP header
WWW-Authenticatewhen returning a 401 Unauthorized” — https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location - 2025-11-25+: “MCP servers MUST implement one of the following discovery mechanisms” (header or well-known) — https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery#protected-resource-metadata-discovery-requirements . A 2025-06-18 client that only parses the header fails against a well-known-only 2025-11-25 server.
C18. Who accumulates scopes on step-up.
- Disposition: Undisposed — open question: on an insufficient-scope step-up, does mcpx’s client union previously granted scopes itself (2026 rule) or rely on the server including them (2025-11-25 recommendation)? Not audited in Phase 1.
- 2025-11-25 (server side): “Recommended approach: Include both existing relevant scopes and newly required scopes to prevent clients from losing previously granted permissions” ; client step 2 uses the Scope Selection Strategy — https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors
- 2026-07-28: “servers are not required to include the client’s previously granted scopes” ; “Scope accumulation across operations is a client-side responsibility” ; client step 2: “computing the union of the client’s previously requested scope set and the scopes from the current challenge” — https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#runtime-insufficient-scope-errors . A 2025-11-25 client against a 2026 server that emits per-operation scopes drops earlier grants.
C19. Sessions: mintable 2025-03-26..2025-11-25, removed 2026 — while 2026 still scopes legacy state “to the session (HTTP)”.
- Disposition: Peer-settled — sessions exist only on the legacy path in code (session minting, session-scoped state, and the session disconnect rule, internal/mcpserver/server.go); the modern path is per-request and stateless.
- 2026 changelog: “Remove protocol-level sessions and the
Mcp-Session-Idheader” ; streamable-http: modern-only server “ignore it, and do not mint or echo session IDs” (#earlier-streamable-http-revisions). - 2026 versioning: “An
initializerequest selects legacy semantics, scoped to the stdio process (stdio) or the session (HTTP)” — https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#backward-compatibility-with-initialization-based-versions . Not a contradiction for dual-era servers (they implement the legacy page too), but the 2026 transport page defines no session, so “the session (HTTP)” is defined only by the 2025-11-25 page it links.
C20. “Supports both POST and GET” satisfied by 405. 2025-* says the endpoint “MUST … support both POST and GET” (#streamable-http) and, two sections later, that the server may “return HTTP 405 Method Not Allowed, indicating that the server does not offer an SSE stream” (#listening-for-messages-from-the-server). Spec-internal; the practical reading is that 405 is a conforming answer to GET.
- Disposition: Recorded reading — as the item states, the practical reading is that 405 is a conforming answer to GET; “support both” is read through the listening section, not against it.
C21. Header name casing drift. 2025-03-26..2025-06-18 Mcp-Session-Id; 2025-11-25 MCP-Session-Id; 2026 backcompat text back to Mcp-Session-Id. Harmless (2026: header names “MUST use case-insensitive comparisons”) but any implementation that string-compares header names breaks.
- Disposition: Recorded reading — header names are compared case-insensitively (the 2026 rule, and Go
net/httpheader handling throughout mcpx), so the casing drift is harmless here.
C. Cancellation, progress, pagination, logging, completion, tasks, caching, MRTR, subscriptions
C1 — 2026-07-28 server-initiated subscription teardown: three texts, three answers (spec-internal).
- Disposition: Recorded reading — headline 1: on stdio send
notifications/cancelled(the MUST), then the listen result for a graceful end (the SHOULD); on HTTP send the listen result and close the stream. Implementation gap (not settled behaviour): mcpx does not implement that transport split — the comment block at internal/mcpserver/listen.go (closeListen, :354) records honouring the MUST first and the result second with no transport qualifier, andannounceEnd(:365-377) sends both frames unconditionally; for a modern HTTP stream the listen stream’ssendis the HTTP SSE sender (listen.go:183), so mcpx sendsnotifications/cancelledon HTTP too, following the cancellation page’s unqualified MUST over the schema’s stdio scoping. - Cancellation page, intro (no heading): “A server MUST send
notifications/cancelledreferencing asubscriptions/listenrequest ID when it tears down that subscription stream (see [Subscriptions][subscriptions]). Servers MUST NOT sendnotifications/cancelledfor any other purpose.” and Behavior Requirements: “Server-sent cancellation notifications MUST reference asubscriptions/listenrequest, to terminate that subscription stream” — https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation , …#behavior-requirements - Subscriptions page, Cancellation: “The server tears it down (e.g., during shutdown) — it SHOULD send a successful
subscriptions/listenresponse to signal a graceful end (see Graceful Closure), then close the stream.” and Graceful Closure: “When the server ends a subscription on its own initiative (for example, during shutdown), it SHOULD respond to the originalsubscriptions/listenrequest with a completion result before closing the stream.” It never mentions a server-sentnotifications/cancelled, and ends “See [Cancellation][cancellation] for the full rules.” — https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions#cancellation , #graceful-closure - Schema
CancelledNotification: “On stdio, the server also sends this notification, solely to terminate a subscriptions/listen stream … Servers MUST NOT use this notification to cancel any other request.” — scopes the server-sent cancel to stdio; the prose MUST has no transport qualifier. https://modelcontextprotocol.io/specification/2026-07-28/schema#cancellednotification - Streamable HTTP note: “The only client-sent notification in the core protocol,
notifications/cancelled, is used only on the stdio transport” — https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#sending-messages-to-the-server (heading before “Receiving Messages”; anchor unverified). - Unresolved questions: (a) MUST cancel, SHOULD respond, or both? Doing both means sending
notifications/cancelledfor request id N and a result for id N, while the same cancellation page tells a receiver of a cancellation that the canceller “SHOULD ignore any response … that arrives afterward” — here the canceller and responder are the same party. (b) On HTTP, is the server-sent cancel required (prose) or not sent at all (schema “On stdio”)? (c) TheSubscriptionsListenResultdoc says it is “sent only when the server tears the subscription down”, consistent with the subscriptions page, not with the cancellation page. Safest reading for an implementer: on stdio sendnotifications/cancelled(satisfies the MUST) then the listen result (satisfies the SHOULD, and the client can tell graceful from abrupt); on HTTP send the listen result and close the SSE stream. mcpx today does the stdio behaviour on every transport (see the disposition above) — recorded as an implementation gap against this reading, not as the reading being implemented.
C2 — server-sent requests and server-sent cancellation: legal ≤2025-11-25 → forbidden 2026-07-28.
- Disposition: Peer-settled — the server’s own
notifications/cancelledfor a timed-outelicitation/create(internal/mcpserver/conn.go:396) can only fire on a legacy-era connection, because a modern connection never carries the wire request it would cancel (see the item text). - ≤2025-11-25: “Either side can send a cancellation notification to indicate that a previously-issued request should be terminated.” https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
- 2026: “Servers MUST NOT send
notifications/cancelledfor any other purpose.” (above), and “Servers MUST NOT initiate JSON-RPC requests” https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns ; “The previous pattern of server-initiated requests is no longer supported. This is a breaking change.” https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr - Consequence for a dual-era server (mcpx):
conn.go:265(cancel own timed-outelicitation/create) must stay reachable only on legacy-era connections. It is today, because a modern connection never gets a wire request.
C3 — 2026-07-28 client cancellation on HTTP (spec-internal).
- Disposition: Recorded reading — era-split in code: as a client mcpx POSTs
notifications/cancelledon stdio and legacy-era HTTP, but for a 2026-07-28 HTTP upstream it sends no notification —cancelled()returns early when the transport is an HTTPTransport with a meta version set (internal/mcpclient/modern.go:582), so closing the response stream is the cancellation signal there, as the transport section describes; notification cancellation remains for the other transports and eras.TestModernHTTPCancellationClosesTheStream(internal/mcpclient/http_conformance_test.go) verifies no cancellation notification is POSTed for a modern HTTP upstream. - “A client SHOULD send a cancellation notification to indicate that a request it previously issued should be terminated.” https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation
- vs “Streamable HTTP: Closing the SSE response stream is the cancellation signal. … No
notifications/cancelledmessage is required or expected.” …#transport-specific-cancellation, and the streamable-http note that notification POST header requirements “are not defined by this revision”. - The intro SHOULD is unqualified; the transport section contradicts it on HTTP. mcpx’s code splits by era: no notification on a 2026-07-28 HTTP upstream (stream close carries the cancellation, internal/mcpclient/modern.go:582); the notification POST remains for stdio and legacy-era HTTP.
C4 — HTTP disconnect meaning: “not cancellation” (SHOULD NOT) 2025-03-26..2025-11-25 → “is cancellation” (MUST) 2026-07-28.
- Disposition: Peer-settled — same era split as section B item C6 / headline 3 (internal/mcpserver/server.go, session disconnect block).
- 2025-11-25: “Disconnection SHOULD NOT be interpreted as the client cancelling its request.” / “To cancel, the client SHOULD explicitly send an MCP
CancelledNotification.” https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server (same text 2025-03-26, 2025-06-18). - 2026: “The server MUST treat a client disconnect as cancellation of that request.” https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#transport-specific-cancellation ; “Closing the SSE response stream MUST be treated by the server as cancellation” https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#cancellation
- A dual-era HTTP server must pick behaviour by the request’s era.
C5 — empty cursor: allowed to mean “end” ≤2025-11-25 → forbidden 2026-07-28.
- Disposition: Peer-settled — headline 8:
nextPage(internal/mcpclient/client.go) treats a missing cursor as the end in every revision and an empty-string cursor as a valid cursor in 2026-07-28. - ≤2025-11-25 only says “Treat a missing
nextCursoras the end of results” (an empty-string cursor was unaddressed; common SDK practice treats""as end). - 2026: “an empty string is a valid cursor and thus MUST NOT be treated as the end of results” https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/pagination#implementation-guidelines . Also 2026 drops “Don’t persist cursors across sessions” (no sessions).
C6 — who may send progress: either side ≤2025-11-25 → server only 2026-07-28.
- Disposition: Peer-settled — follows section C item C2: a client has incoming requests to report progress on only in the legacy era; in 2026 progress is server-sent only.
- ≤2025-11-25: “Either side can send progress notifications” https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress
- 2026: “The server MAY send progress notifications to report the status of requests the client has issued.” https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress . Follows from C2 (a client has no incoming requests to report on).
C7 — progress monotonicity strength (prose vs schema, all revisions).
- Disposition: Recorded reading — as the item states, the prose MUST governs over the schema’s lowercase “should”.
- Prose: “The
progressvalue MUST increase with each notification” https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress#progress-flow - Schema: “The progress thus far. This should increase every time progress is made, even if the total is unknown.” https://modelcontextprotocol.io/specification/2026-07-28/schema#progressnotificationparams . Lowercase, not RFC — treat prose as governing.
C8 — log messages without a requested level: MAY ≤2025-11-25 → MUST NOT 2026-07-28.
- Disposition: Undisposed — headline 9: the client relay gates on a requested level (
moreVerbose, internal/mcpclient/relay.go); whether any path relays upstream logs to a host that requested no level — which would settle this item as moot in the relay direction — is the open question named there. - ≤2025-11-25 schema: “If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically.” https://modelcontextprotocol.io/specification/2025-11-25/schema#loggingmessagenotification
- 2026: “The server MUST NOT emit
notifications/messagefor a request that does not include this field.” https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/logging#per-request-log-level . Also session-wide → per-request, andlogging/setLevelremoved (changelog 5). Invalid level: ≤2025-11-25 rejects thesetLevelcall; 2026 “SHOULD reject that request” — i.e. thetools/call(or whatever) carrying the bad_meta. - mcpx
docs/protocol.md§2.2 says logging is “removed in 2026-07-28”; the spec deprecates it (still in schemaServerCapabilities.logging, still has a MUST-declare rule). Onlylogging/setLevelis removed.
C9 — completions capability: absent 2024-11-05 → MUST declare 2025-03-26+. A 2024-11-05 server could answer completion/complete with no capability; from 2025-03-26 “Servers that support completions MUST declare the completions capability”. A client gating on the capability will never ask a 2024-11-05 server unless it implements the era exception mcpx implements (see the disposition below). mcpx’s server serves from 2024-11-05, so the un-gated case is live on its server side too.
- Disposition: Peer-settled — the client gates
completion/completeon the declaredcompletionscapability, with the era exception implemented: a 2024-11-05 upstream (which had no capability to declare) is asked anyway, and its-32601is read as the same absence a later server signals by not declaring (internal/mcpclient/request.go:52-65, covered by request_test.go). The server side is not moot: mcpx serves from 2024-11-05 (Oldest, internal/mcpserver/revisions.go:14).
C10 — “task required but not used” error code (2025-11-25, spec-internal).
- Disposition: Undisposed — the actual behaviour is recorded, and it follows neither text: a non-task-augmented legacy call executes ordinarily, because
maybeTask()returns nil before any task-support check when the call does not request a task (internal/mcpserver/tasks.go:237-240); the-32601branch at :241-245 covers the opposite case, an augmented call to a tool whose support is “forbidden”. Open question: should the missing-required-task case refuse with-32601(tool-level MUST) or-32600(error list), or is ordinary execution the intended reading? See headline 13. - Tool-level: “If
execution.taskSupportis"required"… Servers MUST return a-32601(Method not found) error if a client does not attempt to do so.” https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation - Protocol errors: “receivers MAY return …: Non-task-augmented request when receiver requires task augmentation for that request type:
-32600(Invalid request)” …#protocol-errors (and the “Task augmentation required” example there uses -32600). Fortools/callthe MUST (-32601) should win; for other request types -32600.
C11 — cancelled task finality: MUST stay cancelled (2025-11-25) → may end non-cancelled (2026 extension).
- Disposition: Undisposed — headline 13, first half: whether mcpx’s tasks implementation holds a cancelled task in
cancelled(2025-11-25 core) or permits a later non-cancelledterminal status (extension) was not verified from internal/mcpserver/tasks.go in Phase 1. - 2025-11-25: “…MUST transition the task to
cancelledstatus before sending the response.” “Once a task is cancelled, it MUST remain incancelledstatus even if execution continues to completion or fails.” …#task-cancellation ; and thetasks/cancelresponse is the Task. - Extension: “Acknowledge cancellation requests with an empty result. Honor them when possible, but cancellation is cooperative — the task may still reach a non-
cancelledterminal status.” docs/extensions/tasks/overview.mdx#for-mcp-servers ; lifecycle table: “cancelled| The operation was cancelled (not always honored).” - Also 2025-11-25 “Receivers MUST reject cancellation requests for tasks already in a terminal status …
-32602” has no counterpart in the overview.
C12 — related-task on task-operation messages (2025-11-25, spec-internal).
- Disposition: Recorded reading — the carve-out reading in the item text: the MUST covers messages of the underlying work and the
tasks/resultresponse; task-management messages follow their SHOULD NOTs. - “All requests, notifications, and responses related to a task MUST include the
io.modelcontextprotocol/related-taskkey” …#associating-task-related-messages ; “All requests, responses, and notifications associated with a task MUST include…” …#related-task-metadata - vs “Requestors SHOULD NOT include
io.modelcontextprotocol/related-taskmetadata in these requests” (tasks/get, /result, /cancel), “receivers SHOULD NOT include … in the result messages” (tasks/get, /list, /cancel), and “thenotifications/tasks/statusnotification SHOULD NOT include theio.modelcontextprotocol/related-taskmetadata”. Read as: the MUST covers messages of the underlying work (e.g. the elicitation a task raises and its response), plus thetasks/resultresponse (explicit MUST); task-management messages are carved out. The text does not say so.
C13 — extension opt-in wording (changelog vs extension page).
- Disposition: Recorded reading — “unsolicited” is read as “without a
taskparam”, not “without declaring the extension”: a task is never returned to a client that did not declare support (see the item text). - Changelog 2026-07-28 item 6: the extension “allows servers to return task handles unsolicited without per-request opt-in” https://modelcontextprotocol.io/specification/2026-07-28/changelog
- Overview: “Never return a task to a client that did not declare support.” and “Clients opt in once via the extension capability … No per-tool warmup or per-request flag.” — but the capability itself is sent in per-request
_meta(“Include the extension in its per-request capabilities”). “Unsolicited” means “without ataskparam”, not “without declaring the extension”. Model change vs 2025-11-25: requestor-driven (taskparam + tooltaskSupport) → server-driven. - Also 2025-11-25
tasks/result,tasks/listand client-side tasks (task-augmented sampling/elicitation) do not exist in the extension;notifications/tasks/status→notifications/tasksviasubscriptions/listen, yet coreSubscriptionFilterhas no field for it (presumably extension-defined; not verifiable without ext-tasks).
C14 — which results must carry cache hints (changelog vs caching page).
- Disposition: Recorded reading — headline 12: the caching page and the schema govern over the changelog’s list;
server/discoverresults areCacheableResults, and mcpx’s modern envelope emitsttlMs/cacheScopeon them (headline 12). - Caching page includes
server/discoverin the MUST list; changelog item 5 lists “tools/list,prompts/list,resources/list,resources/read, andresources/templates/list” only. Page governs. Also the page saysttlMsabsent “should only occur in older server versions” while schema makesttlMs/cacheScoperequired — consistent (absence ⇒ legacy server). AndCacheableResultdoc: “how long … the client MAY cache this response before re-fetching” vs page “how long … the client MAY consider the result fresh” — wording only.
C15 — 2026-07-28 “stream stays open … until the client cancels it” vs server teardown. Subscriptions intro: “the stream stays open and delivers notifications until the client cancels it”, then lists server teardown and transport close as other endings. Descriptive inconsistency only.
- Disposition: Recorded reading — descriptive inconsistency only, as the item states; the enumerated endings (client cancel, server teardown, transport close) govern over the intro’s shorthand.
D. Tools, resources, prompts, discover
C1 — tool input-validation errors: protocol error → execution error (cross-revision).
- Disposition: Undisposed — headline 5: the client choke keeps
-32602(internal/mcpclient/schemacheck.go, internal/daemon/callerr.go); whether the serve seam era-splitsisErrorfor >=2025-11-25 hosts is the open question named there. 2024-11-05…2025-06-18: “Protocol Errors: Standard JSON-RPC errors for issues like: … Invalid arguments” and execution errors are “Invalid input data” — https://modelcontextprotocol.io/specification/2025-06-18/server/tools#error-handling. 2025-11-25+: “Tool Execution Errors … Input validation errors (e.g., date in wrong format, value out of range)”; protocol errors are only “Malformed requests (requests that fail to satisfy CallToolRequest schema)” — https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling. Changelog: “Clarify that input validation errors should be returned as Tool Execution Errors rather than Protocol Errors” (SEP-1303). A server emitting-32602for a bad date is following 2025-06-18 and deviating from 2025-11-25.isError:trueis legal in both, so the conservative choice is isError.
C2 — resource-not-found code: -32002 SHOULD → -32602 MUST (cross-revision).
- Disposition: Peer-settled — headline 6: the code is picked per request revision in code (legacy
-32002,codeResourceNotFoundLegacy, internal/mcpserver/server.go; 2026-32602). ≤2025-11-25: “Resource not found:-32002” (SHOULD) — https://modelcontextprotocol.io/specification/2025-11-25/server/resources#error-handling. 2026-07-28: “servers MUST return a JSON-RPC error with code-32602”; “clients SHOULD also accept-32002” — https://modelcontextprotocol.io/specification/2026-07-28/server/resources#error-handling. Note 2026 also forbids new use of-32000..-32019codes except grandfathered (basic/index#error-codes), and-32002is in that range. A dual-era server must pick the code per request revision.
C3 — resources list_changed delivery: 2026 resources page vs 2026 subscriptions rules (spec-internal).
- Disposition: Recorded reading — follow the schema, as the item states:
resources/list_changedis sent only on a listen stream whose filter requested it; the resources page’s unqualified SHOULD does not govern. 2026 resources: “servers that declared thelistChangedcapability SHOULD send a notification” (no stream qualifier) — https://modelcontextprotocol.io/specification/2026-07-28/server/resources#list-changed-notification. 2026 tools/prompts say “to clients that have opened asubscriptions/listenstream withtoolsListChanged: true”, and SubscriptionFilter says “the server MUST NOT send notification types the client has not explicitly requested here” — https://modelcontextprotocol.io/specification/2026-07-28/schema#subscriptionfilter. Read literally, the resources page permits an unsolicited push that the schema forbids; the resources page was not updated. Follow the schema: only on a listen stream withresourcesListChanged: true.
C4 — resources.subscribe capability meaning (cross-revision).
- Disposition: Undisposed — open question: is the legacy
resources/subscribeRPC answered only on legacy connections, and is the same capability flag read as the listen-filter meaning on 2026-07-28? No site verified in Phase 1. Legacy: client may callresources/subscribe(https://modelcontextprotocol.io/specification/2025-11-25/server/resources#capabilities). 2026: “whether the server supports resource-specific update notifications for resources requested through subscriptions/listen using the resourceSubscriptions filter” (https://modelcontextprotocol.io/specification/2026-07-28/server/resources#capabilities).resources/subscribe/unsubscribeare removed (“Replaces the formerresources/subscribeRPC”, schema#subscriptionfilter; changelog item 4). Same flag, different method behind it.
C5 — structuredContent / outputSchema shape (cross-revision; prose vs schema in 2025-06-18/11-25).
- Disposition: Undisposed — headline 7:
downgrade()(internal/mcpserver/revisions.go:237) deletesstructuredContentonly for pre-2025-06-18; what happens to a non-object value headed for a 2025-06-18..2025-11-25 host is the open question named there. 2025-06-18/2025-11-25 schema:structuredContent?: { [key: string]: unknown }; outputSchema “Currently restricted to type: "object" at the root level.” — https://modelcontextprotocol.io/specification/2025-11-25/schema#calltoolresult. 2026-07-28: “This can be any JSON value (object, array, string, number, boolean, or null)”; outputSchema “can be any valid JSON Schema 2020-12” — https://modelcontextprotocol.io/specification/2026-07-28/server/tools#structured-content. A 2026 upstream’s array result is illegal to send to a 2025-11-25 client verbatim. (The 2025-06-18/11-25 prose says “a JSON object”; consistent with its schema.)
C6 — list/changed delivery and per-connection variance (cross-revision).
- Disposition: Undisposed — open question: does mcpx’s server deliver list_changed per era (session-wide legacy vs opted-in listen streams in 2026), and can its served lists vary per connection against the 2026 MUST NOT? No site verified in Phase 1. Legacy list_changed is sent on the session to any client; 2026 only to opted-in listen streams (tools/prompts pages). 2026 adds “MUST NOT vary per-connection” for tools/prompts/resources lists — https://modelcontextprotocol.io/specification/2026-07-28/server/tools#capabilities; legacy imposes nothing, so session-scoped tool sets were legal before.
C7 — where capabilities are declared (page vs page, 2026).
- Disposition: Undisposed — open question: does mcpx’s
DiscoverResultdeclare thepromptscapability in the result itself, as the 2026 prompts page requires, and thetools/resourcescapabilities observably at all? No site verified in Phase 1. 2026 prompts: “MUST declare thepromptscapability in theirDiscoverResult” — https://modelcontextprotocol.io/specification/2026-07-28/server/prompts#capabilities. 2026 tools/resources: “MUST declare thetoolscapability” with no location — https://modelcontextprotocol.io/specification/2026-07-28/server/tools#capabilities. Meanwhile discover prose makes calling discover optional (“a client may invoke any RPC inline”). So a client that never calls discover never sees server capabilities, yet tools/resources/prompts listChanged/subscribe semantics depend on them. Not a contradiction, but the capability is only observable via discover (or the listen ack’s echoed filter).
C8 — embedded resource “MUST include … MIME type” vs schema (prose vs schema, all revs).
- Disposition: Recorded reading — as the item states: senders include the MIME type; receivers accept its absence, the schema’s optional field governing interop.
Prompts prose: “MUST include: A valid resource URI; The appropriate MIME type; …” — https://modelcontextprotocol.io/specification/2026-07-28/server/prompts#embedded-resources. Schema
ResourceContents.mimeType?is optional in every revision. Senders should include it; receivers must accept its absence.
C9 — resource_link in prompts (prose vs schema, 2025-06-18/2025-11-25).
- Disposition: Recorded reading — the schema governs:
ResourceLinkis a legalPromptMessagecontent block from 2025-06-18, whether or not that era’s page documents it (verified in the item text).PromptMessage.content: ContentBlockincludesResourceLinkin the 2025-06-18 and 2025-11-25 schemas, but only the 2026 prompts page documents “Prompt messages MAY include links to Resources” — https://modelcontextprotocol.io/specification/2026-07-28/server/prompts#resource-links. (Verified: 2025-06-18 schemaPromptMessage.content: ContentBlock, andContentBlockincludesResourceLink.)
C10 — discover call: MAY vs SHOULD (page-internal, conditional).
- Disposition: Recorded reading — MAY in general, SHOULD for dual-era clients, and mcpx follows the SHOULD by default: an uncached connection under the default preference (
PreferModern, internal/mcpclient/client.go:321-325) probesserver/discoverfirst (internal/mcpclient/probe.go:296).initializegoes first only under a legacy preference or a cached legacy era. “Callingserver/discoveris optional for clients” / schema “Clients MAY call it” vs “A client that supports both modern… and legacy… servers SHOULD sendserver/discoverfirst” — https://modelcontextprotocol.io/specification/2026-07-28/server/discover#when-to-call. Reconciles as: MAY in general, SHOULD for dual-era clients on stdio. mcpx (dual-era client) probesinitializefirst by design.
C11 — CacheableResult coverage: changelog vs schema.
- Disposition: Recorded reading — headline 12: the schema governs over the changelog;
server/discoveralso requiresttlMs/cacheScope, and mcpx’s modern envelope emits both on the discover result. Changelog: “RequirettlMsandcacheScopefields on results returned bytools/list,prompts/list,resources/list,resources/read, andresources/templates/list” — https://modelcontextprotocol.io/specification/2026-07-28/changelog. Schema:DiscoverResult extends CacheableResult, and discover.mdx “This operation supports caching.” Changelog omits discover (and prompts/get). Schema governs: discover also requires both fields.
C12 — serverInfo placement (cross-revision) and required-ness.
- Disposition: Recorded reading — same disposition as section A item C17: mcpx follows the 2026 placement on the modern path and the required top-level field on the legacy path.
InitializeResult.serverInfo is a required top-level field in legacy; in 2026 it moves to
_meta['io.modelcontextprotocol/serverInfo']and is optional SHOULD (https://modelcontextprotocol.io/specification/2026-07-28/server/discover#discoverresult). Same forprotocolVersion(single) →supportedVersions(list).
C13 — tool name guidance vs aggregator reality.
- Disposition: Undisposed — open question: when mcpx prefixes upstream tool names for disambiguation, what happens to names that would exceed 128 chars or leave the charset? No site verified in Phase 1.
“Tool names SHOULD be unique within a server” + charset
[A-Za-z0-9_.-]+ ≤128 chars (2025-11-25+) vs the 2026 note that proxies “SHOULD implement a disambiguation strategy such as prefixing tool names with a server identifier” — prefixing makes the 128 limit and charset binding on the proxy’s separator and on upstream names that are already long or non-conforming. Not contradictory; a squeeze.
C14 — isError vs “tool not found” (schema vs mcpx, not spec-internal). Recorded here because it is easy to misread: the CallToolResult comment keeps “errors in finding the tool … should be reported as an MCP error response” in every revision; only argument validation moved (C1).
- Disposition: Recorded reading — as the item states: errors in finding the tool are MCP error responses in every revision; only argument validation moved (section D item C1).
E. Roots, sampling, elicitation
Cross-revision changes (a message legal in one revision is illegal or meaningless in another)
- Carriage of all three requests. 2024-11-05–2025-11-25: server sends a JSON-RPC request
- Disposition: Peer-settled — mcpx bridges the eras by translation: wire requests on legacy connections,
inputRequests/inputResponsesentries on modern ones (internal/mcpserver/ask.go, internal/mcpserver/conn.go). ("To request information from a user, servers send an \elicitation/create` request.“, https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#elicitation-requests). 2026-07-28:“The previous pattern of server-initiated requests is no longer supported. This is a breaking change.”(https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr).ClientResultin the 2026 schema isEmptyResultonly — there is no client→server response message type at all. A proxy bridging eras must translate wire request ⇄inputRequestsentry, and wire response ⇄inputResponses` entry.
- Capability declaration. “during initialization” (≤2025-11-25) → “in
_meta.io.modelcontextprotocol/clientCapabilities
- Disposition: Peer-settled — era-keyed in code: declared at
initializeon legacy connections and in per-request_metaclient capabilities on modern ones, for roots, sampling and elicitation alike. on each request“ (2026-07-28), for roots, sampling and elicitation. Same URLs as rows*-declare-capability-*.
roots.listChangedandnotifications/roots/list_changed. ≤2025-11-25: “When roots change, clients that support
- Disposition: Peer-settled — the notification exists only in the legacy era; methods a revision removed are answered
-32601on the modern path (internal/mcpserver/revisions.go:139; internal/mcpserver/server.go:916).listChangedMUST send a notification“ (https://modelcontextprotocol.io/specification/2025-11-25/client/roots#root-list-changes). 2026-07-28: capability isroots?: {}; changelog item 5: “Removeping,logging/setLevel, andnotifications/roots/list_changed” (https://modelcontextprotocol.io/specification/2026-07-28/changelog).
- Roots semantics. ≤2025-11-25: “Roots define the boundaries of where servers can operate within the filesystem”.
- Disposition: Recorded reading — for modern peers mcpx follows the 2026 reading (roots are informational guidance, not an access-control boundary); the legacy boundary wording governs legacy peers only. 2026-07-28: “They are informational guidance rather than an access-control mechanism. The protocol does not enforce that servers stay within roots.” (https://modelcontextprotocol.io/specification/2026-07-28/client/roots).
- Roots, Sampling deprecated (SEP-2577) in 2026-07-28: “New implementations SHOULD NOT adopt it”
- Disposition: Recorded reading — deprecation is read as “should not adopt anew”: mcpx continues to implement roots and sampling because its legacy peers require them; it does not treat the features as removed. (https://modelcontextprotocol.io/specification/2026-07-28/deprecated). Elicitation is not deprecated.
- Sampling error path. ≤2025-11-25: “Clients SHOULD return errors for common failure cases” with
-1for a
- Disposition: Undisposed — open question: under MRTR there is no error channel (see the spec-internal item 6 below); how does mcpx’s client signal a declined or failed sampling request to a 2026-07-28 server — absence of a retry only? No site verified in Phase 1.
rejected request (https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#error-handling). 2026-07-28:
“If an error occurs or the user declines the sampling request, the client does not need to replay the initial call with
an error message” (https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#error-handling). Roots has
the same change (
-32601/-32603→ no error path).
- Tools in sampling exist only from 2025-11-25 (
tools,toolChoice,tool_use/tool_resultcontent, content
- Disposition: Undisposed — open question: does mcpx shape sampling messages per peer era (single-block content for pre-2025-11-25 peers, arrays and tool content after), as it shapes
structuredContentindowngrade()(internal/mcpserver/revisions.go)? The sampling path was not verified in Phase 1. arrays,stopReason: "toolUse"). 2024-11-05SamplingMessage.contentisTextContent | ImageContent(single block); 2025-03-26 adds audio; arrays only from 2025-11-25. A 2025-11-25+ result with arraycontentis unparseable by an older peer.
includeContextvalues. Plain in ≤2025-06-18; “soft-deprecated. Servers SHOULD avoid using these values …”
- Disposition: Recorded reading — as the item states, the two texts are consistent in substance; the soft-deprecation date (2025-11-25) is the operative one.
(https://modelcontextprotocol.io/specification/2025-11-25/client/sampling#capabilities); “deprecated under the feature
lifecycle policy (SEP-2596)” in 2026-07-28. Schema 2026
@deprecatedsays “as of protocol version 2025-11-25” while the 2026 changelog says “Reclassify … (soft-deprecated since protocol version2025-11-25) as Deprecated” — consistent in substance, differing in which date is “deprecated”.
- Elicitation sensitive information. 2025-06-18: “Servers MUST NOT use elicitation to request sensitive
- Disposition: Undisposed — open question: does mcpx gate elicitation modes per era — form mode barred from sensitive information from 2025-11-25, URL mode required for it, the 2025-06-18 all-modes ban before that? The elicitation path (internal/mcpserver/ask.go) was not audited for this in Phase 1. information.“ (all modes; https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#user-interaction-model). 2025-11-25+: only form mode is forbidden, and URL mode is required for it.
- Client UI obligations promoted SHOULD → MUST in 2025-11-25: which-server indication, review/modify (form),
- Disposition: Undisposed — open question (host-side): mcpx’s client surfaces elicitation to its host UI; whether the 2025-11-25 MUSTs (which-server indication, review/modify, decline/cancel) are all met by that surface was not audited in Phase 1. decline/cancel options. 2025-06-18 “Applications SHOULD: …” vs 2025-11-25 “MCP clients MUST: …” (https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#user-interaction-model).
- Elicitation request shape. 2025-06-18
params: {message, requestedSchema}— nomode, nodefault, enum is
- Disposition: Undisposed — open question: are elicitation params shaped per peer era (
mode/elicitationId/url mode withheld from a 2025-06-18 peer,elicitationIdwithheld from 2026-07-28)? No site verified in Phase 1.{enum, enumNames}. 2025-11-25 addsmode, url mode,elicitationId, defaults,oneOf/array enums,LegacyTitledEnumSchema. 2026-07-28 removeselicitationId. Sendingmode/elicitationIdto a 2025-06-18 peer is sending fields its schema lacks; sending url mode to it is sending a request it cannot represent.
elicitationId,notifications/elicitation/complete,URLElicitationRequiredError(-32042) exist only in
- Disposition: Peer-settled — the trio exists in the 2025-11-25 era only, and 2026-07-28 forbids emitting
-32042; mcpx’s client handles and propagates-32042on that era’s path (internal/mcpclient/client.go:1549-1586), while the server-side return gap is recorded at the spec-internal item 2 below. 2025-11-25. 2026-07-28 changelog item 11 removes the first two; basic/index: “Implementations of this protocol version MUST NOT emit these codes: …-32042” (https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes).
- Elicitation statefulness. 2025-11-25: “Most practical uses of elicitation require that the server maintain state”
- Disposition: Undisposed — open question: does mcpx’s URL-mode elicitation keep server state keyed by anything other than a session ID alone (2025-11-25 rule), and does it rely on no state at all in 2026 form mode? Not audited in Phase 1.
- “State MUST NOT be associated with session IDs alone” (https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#statefulness). 2026-07-28: “Elicitations do not require that the server maintain state … However, if state is stored …” and the session-ID rule is gone (sessions no longer exist) (https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#statefulness).
- Elicitation error handling. 2025-11-25 prescribes
-32042(server) and-32602for an undeclared mode (client).
- Disposition: Undisposed — open question: which error surface does mcpx use per era —
-32042/-32602in 2025-11-25 (but see the spec-internal item 2 below) vs decline/cancel handling with no codes in 2026-07-28? Cross-reference headline 5’s serve-seam question. 2026-07-28 replaces both with “Servers SHOULD NOT assume … and MUST handle cases where the user declines or cancels …” (https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#error-handling).
- Client rate limiting for elicitation: SHOULD in 2025-06-18 and 2025-11-25, dropped in 2026-07-28 (sampling keeps it).
- Disposition: Undisposed — open question: does mcpx’s client rate-limit elicitation requests at all, and if so is the limit era-scoped now that 2026-07-28 drops the SHOULD? Not audited in Phase 1.
- New 2026-only sampling MUSTs: “Sampling messages MUST contain a
role… and acontentfield”, “The client
- Disposition: Undisposed — open question: does mcpx’s client enforce the 2026 sampling MUSTs (
roleandcontentpresent,maxTokensrespected, no retention between requests) on MRTR-carried sampling? Not audited in Phase 1. MUST respect themaxTokensparameter“, “SHOULD NOT be retained between separate requests” (https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#messages, https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#sampling-parameters).
- Task-augmented sampling/elicitation (
params.task,tasks.requests.sampling.createMessage,
- Disposition: Undisposed — headline 13 area: task augmentation exists only in 2025-11-25 core; whether mcpx strips or rejects a
taskparam headed for a 2026-07-28 peer was not verified in Phase 1.tasks.requests.elicitation.create) exist in 2025-11-25 core (https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks). The 2026-07-28CreateMessageRequestParamsandElicitRequest*Paramshave notaskfield, and 2026ClientCapabilitieshas notasks.
Spec-internal contradictions
mode“MUST include” yet optional (2025-11-25, 2026-07-28): “All elicitation requests MUST include the following
- Disposition: Recorded reading — as the item states: only
messageis unconditionally required;modedefaults to"form"and may be omitted for form mode. parameters: …mode… Optional for form mode (defaults to\"form\"if omitted).“ followed by “servers MAY omit themodefield” (https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#elicitation-requests). Resolution by reading: onlymessageis unconditionally required.
-32042: MAY vs MUST (2025-11-25). “the server MAY return aURLElicitationRequiredError”
- Disposition: Recorded reading (reading chosen; implementation gap recorded) — mcpx’s reading is the MUST: a request that cannot be processed until an elicitation completes should return
-32042. The server does not yet do it: a URL question the legacy client cannot answer stays with the broker while the server waits (internal/mcpserver/ask.go:192-207), and the gap is recorded in the conformance registry (elicitation-server-must-return-32042, internal/conformance/coverage_clientfeatures.go, #214). The client side does handle receiving-32042(internal/mcpclient/client.go:1549-1586). (https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-elicitation-required-error) vs “Servers MUST return standard JSON-RPC errors for common failure cases: - When a request cannot be processed until an elicitation is completed:-32042” (https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#error-handling). Also the schema marksURL_ELICITATION_REQUIREDandURLElicitationRequiredError@internal, and the 2025-11-25 changelog links#url-elicitation-requests, an anchor that does not exist (heading is “URL Mode Elicitation Requests”).
- Roots: guidance vs access control (2026-07-28). “They are informational guidance rather than an access-control
- Disposition: Recorded reading — the roots page’s main text governs (informational guidance) over the security-considerations boilerplate; see the cross-revision item 4 above. mechanism“ (https://modelcontextprotocol.io/specification/2026-07-28/client/roots) vs “Clients MUST: … Implement proper access controls” and servers “SHOULD … Validate all paths against provided roots” (https://modelcontextprotocol.io/specification/2026-07-28/client/roots#security-considerations).
- Roots: change monitoring without a change signal (2026-07-28). “Clients SHOULD: … Monitor for root changes”
- Disposition: Undisposed — open question: in 2026-07-28 there is no signal to monitor with; does mcpx’s client re-list roots per request instead of caching them, and if it caches, what invalidates the cache? Not audited in Phase 1. (https://modelcontextprotocol.io/specification/2026-07-28/client/roots#implementation-guidelines) survives, but there is no longer any way to tell the server; and “Cache root information appropriately” has no invalidation signal.
- Deprecated but MUST declare (2026-07-28): roots and sampling pages say new implementations “SHOULD NOT adopt it”
- Disposition: Recorded reading — as the item states, the texts are consistent read as “if you implement it anyway”: support entails the per-request declaration MUST; non-adoption is the SHOULD NOT’s subject. while still “Clients that support roots MUST declare … on each request” — consistent only if read as “if you implement it anyway”.
- Tools-without-capability: client MUST error, but 2026 has no error channel. Schema 2026: “The client MUST return an
- Disposition: Undisposed — open question (same gap as the cross-revision item 6 above): the only expressible client “error” under MRTR is not retrying, or a client-side failure; which one mcpx’s client produces for an undeclared
sampling.toolsfield was not verified in Phase 1. error if this field is provided but ClientCapabilities.sampling.tools is not declared.“ (https://modelcontextprotocol.io/specification/2026-07-28/schema#createmessagerequestparams). In 2026 the request arrives insideInputRequiredResult.inputRequests;InputResponseisCreateMessageResult | ListRootsResult | ElicitResult— no error member — and the sampling page says “the client does not need to replay the initial call with an error message”. The only expressible “error” is not retrying, or a client-side failure.
- Elicitation “client fails to process” in 2026. “MUST handle cases where … the client fails to process the
- Disposition: Undisposed — open question: under MRTR the server sees only absence; does mcpx’s server re-request missing elicitation input (the mrtr SHOULD) or fail the original request? Not audited in Phase 1.
request“ (https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#error-handling) — under MRTR the
server cannot observe that; it sees only absence of a retry, or a retry missing that key (mrtr: “SHOULD respond with a
new
InputRequiredResultrequesting the missing information again”).
- Missing capability: error vs. omit (2026-07-28). basic/index: “the server MUST return a
- Disposition: Undisposed — open question: when mcpx’s server requires elicitation a client has not declared, does it return
-32021(MissingRequiredClientCapabilityError), and when it can degrade, does it degrade silently? Not audited in Phase 1.MissingRequiredClientCapabilityError(-32021)“ (https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta) vs mrtr: “Servers MUST NOT send aninputRequeststhat the client has not declared support for” and elicitation: “Servers SHOULD NOT assume that elicitation requests will always succeed”. Together they force: a server that requires elicitation errors-32021; one that can degrade must degrade silently. Neither says which a server that could use elicitation but has a fallback must pick.
- Elicitation page vs mrtr on where elicitation may occur. “Servers MAY request information from a user during the
- Disposition: Undisposed — open question: which client requests may carry an
InputRequiredResultfrom mcpx’s server — any (elicitation page) or onlyprompts/get,resources/read,tools/call(mrtr)? Check the ask path in internal/mcpserver/ask.go. processing of a client request“ (any request) vs mrtr “Servers MUST NOT sendInputRequiredResultresponses on any other client requests” thanprompts/get,resources/read,tools/call(https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr#supported-requests).
_metapreservation addressed to the wrong side. SchemaToolUseContent._meta/ToolResultContent._meta:
- Disposition: Undisposed — open question: mcpx’s server constructs subsequent sampling requests; does it preserve
ToolUseContent._meta/ToolResultContent._metawhen doing so, as the mis-addressed SHOULD intends? Not audited in Phase 1. “Clients SHOULD preserve this field when including tool uses in subsequent sampling requests” (https://modelcontextprotocol.io/specification/2026-07-28/schema#toolusecontent) — subsequent sampling requests are constructed by the server.
maxTokens: MUST respect vs MAY sample fewer (2026-07-28). Compatible (a ceiling), but “respect” is undefined
- Disposition: Recorded reading — as the item states, the texts are compatible:
maxTokensis a ceiling; sampling fewer is respecting it. beyond not exceeding it (https://modelcontextprotocol.io/specification/2026-07-28/client/sampling#sampling-parameters vs schemaCreateMessageRequestParams.maxTokens).
- 2025-11-25 StringSchema example uses
pattern("pattern": "^[A-Za-z]+$",
- Disposition: Recorded reading — the type governs over the example:
patternis not a 2025-11-25StringSchemakeyword, whatever the example shows; the 2026 example’s removal confirms the reading. https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#requested-schema), a keyword the 2025-11-25StringSchematype does not define. Removed from the 2026 example.
- Unconditional user binding (2025-11-25, 2026-07-28): “Servers MUST bind elicitation requests to the
- Disposition: Undisposed — open question: how does mcpx bind an elicitation request to a user identity on stdio or without authorization, where no identity exists? Not audited in Phase 1. client and user identity“ while there is no user identity on stdio / without authorization; “when possible” appears only in the Statefulness bullet (https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#security-considerations).
- Sampling “Result Fields” vs tool-result constraint (2026-07-28): result
contentmay be “multiple tool uses or
- Disposition: Recorded reading — as the item states: the user-message ONLY-tool-results rule is not applied to result content; a proxy must not apply it there. mixed content“ (assistant side) — not a contradiction with “user message … MUST contain ONLY tool results”, but a proxy must not apply the user-message rule to results.
- URL-mode requestState and “stateless” claim (2026-07-28): Statefulness says elicitation does not require server
- Disposition: Undisposed — open question (same area as the cross-revision item 13 above): token custody makes the OAuth server stateful regardless of elicitation’s statelessness; whether mcpx’s URL-mode flow stores
requestStateserver-side was not audited in Phase 1. state, while the OAuth section still requires “The MCP server is responsible for tokens … (in other words, the MCP server must be stateful)” (https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation#understanding-the-distinction).
What the specification asks of a server
created: 2026-09-30T08:00:00-05:00
last-updated: 2026-09-30T16:00:00-05:00
increment: 3
status: standard
tags: area:protocol, area:spec
description: per revision, what an MCP server MUST, SHOULD and MAY do,
including the obligations the prose implies but never states.
This document is about the specification, not about mcpx. It exists because
the spec’s MUSTs are scattered: across the lifecycle, transport, versioning and
utility pages, and across the doc comments in schema.ts, which carry several
rules the prose never repeats. A server written from the feature pages alone
misses most of them. What mcpx does is summarised in a line where it matters
and documented in ../protocol.md; the per-requirement
status, each with its test, is conformance-matrix.md.
The feature-by-revision table (which method, field, code and header exists
where) is ../compare/matrix-revisions.md,
the character of each revision is
../compare/what-a-server-should-be.md,
and every place two sources disagree is
../compare/conflicts.md; none of that is repeated
here. The client side is client-obligations.md.
This document is the distilled form. The authority for what mcpx actually
does is the code — internal/mcpserver/revisions.go holds the floors,
ceilings and removals as data, and internal/mcpspec validates real traffic
against the official schemas for all five revisions. A matrix in a document
and a matrix in code agree on the day they are written and never again, so
where they differ, the code is right and this file is stale.
Revisions are written by date. Legacy means 2024-11-05 through 2025-11-25,
which share an initialize handshake. Modern means 2026-07-28, which has
no handshake.
URLs abbreviate https://modelcontextprotocol.io/specification/ as spec/.
1. Lifecycle
Legacy (2024-11-05 .. 2025-11-25)
The session begins with initialize
(spec/2025-11-25/basic/lifecycle#initialization).
- A server MUST respond to
initializewith its capabilities andserverInfo. - If it supports the requested
protocolVersionit MUST echo that version. Otherwise it MUST answer with another version it supports, which SHOULD be its latest (#version-negotiation). The answer is a result, not an error, and the client decides whether it can continue. Answering with an error takes that decision away from the client. The modern error code-32022did not exist in any legacy revision, so a legacy client has no rule for recognising it. - Before
notifications/initialized, a server SHOULD NOT send anything except pings and logging. - A server MUST NOT send a request or notification outside the capabilities it declared. Nor may it use a client capability the client did not declare. Sampling, roots and elicitation are all gated on the client’s declaration.
- Shutdown is transport-level. On stdio the client closes stdin, and a server MAY also exit on its own. The legacy HTTP transports have no shutdown message.
The negotiated version, the client’s capabilities, the log level and the
resources/subscribe set are session state. §3.4 covers where a dual-era
server keeps it.
Modern (2026-07-28)
There is no handshake. Every request carries
_meta["io.modelcontextprotocol/protocolVersion"] and
_meta["io.modelcontextprotocol/clientCapabilities"], and both are REQUIRED
(spec/2026-07-28/basic/versioning).
- A server MUST implement
server/discover(spec/2026-07-28/server/discover). TheDiscoverResultis{supportedVersions, capabilities, instructions?, _meta: {"io.modelcontextprotocol/serverInfo": …}, ttlMs, cacheScope, resultType}. The field issupportedVersions, notprotocolVersions, andserverInfolives in_meta, not at the top level. Discovery is a cacheable result, sottlMsandcacheScopeare required here too, even though the changelog’s list omitsserver/discover. - A request whose version the server does not support MUST be rejected
with
-32022, and the supported versions go indata.supported. - A request that omits the required
_metafields MUST be rejected with-32602. - A server MUST NOT infer client capabilities from earlier requests (schema
RequestMetaObject). The capabilities on this request are the only ones that exist for it. - Every result MUST carry
resultType. A client treats an absentresultTypeas"complete". - A server SHOULD put its
serverInfoin every result’s_meta. - A server MUST NOT send requests to the client at all. Questions for the
client travel as MRTR
inputRequestsinside a result (§4.4).
Statelessness is a rule, not a transport detail. State that spans requests “MUST be referenced by an explicit identifier the client passes on each request”, and “an open connection, such as a STDIO process, is not a conversation or session”. A server “SHOULD NOT require that a client reuse the same connection or process”. Three consequences follow, none of them spelled out:
- A
requestState, a task id or a subscription cannot be validated by comparing it with the connection it arrived on. If a server needs to bind a handle to a caller, the binding has to be inside the handle (signed claims: principal, expiry, originating method and parameters) or come from the authorization identity. mcpx bindsrequestStateto the method, a digest of the parameters and an expiry, and to no connection (messages.md). - A retry may reach a different process or instance. Any integrity key a handle depends on has to be shared by every process that might receive the retry. With a per-process key the retry still fails verification, which is safe, but the work is lost.
- The server’s capabilities are advertised once, by
server/discover, and cached forttlMs. The client’s capabilities are restated on every request and may change from one request to the next. There is no symmetric restatement: a client acts on server capabilities that may be up tottlMsstale, and a server acts on client capabilities that are exactly current.
What “stateless” does and does not forbid, item by item, and where a proxy
legitimately keeps state, is ../compare/stateless.md.
2. Versioning and eras
The 2026 versioning page makes era “a property of the server, not of an individual request”. A client probes once, then caches the result for the lifetime of the process (stdio) or origin (HTTP), and MAY persist it across restarts of the same configuration.
For a pool of processes of one configuration, that sentence has a
consequence the spec does not draw. Every process in the pool must answer the
same era. If two members disagree, the binary changed underneath the pool, and
the client’s cached answer is wrong for part of it. Since the cache key is the
configuration rather than the process, a server must not be dual-era on some
launches and modern-only on others. Each freshly spawned stdio process still
needs its own initialize if it is legacy, because a new process is a new
session, even though the era answer is inherited.
A dual-era server answers both initialize and _meta-versioned requests.
It faces three kinds of inbound request, and the spec defines two of them:
| inbound | spec says | what the server has to do |
|---|---|---|
_meta carries a protocol version | modern semantics, stateless | ignore any legacy session state; use this request’s capabilities |
initialize, or a request on a session that was initialised | legacy semantics, scoped to the process (stdio) or session (HTTP) | keep the version and capabilities from initialize |
| neither | modern: MUST reject -32602; legacy: “not the first interaction” | unspecified. mcpx serves it as 2025-03-26, a choice rather than a rule (../compare/stateless.md §4) |
A modern-only server that receives initialize SHOULD name its supported
versions in the error. A dual-era server has to decide per request whether a
Mcp-Session-Id header is legacy state or noise. The reliable signal is the
body: a _meta protocol version means modern.
3. Transports
3.1 stdio (all revisions)
- Messages are newline-delimited JSON-RPC and MUST NOT contain embedded newlines.
- The server MUST NOT write anything to stdout that is not a valid MCP message. stderr is for logs, and in 2025-11-25 and later it MAY carry any output, which clients must not treat as an error.
- Modern: stdio is still stateless.
initializemakes the whole pipe legacy for its lifetime, because one process serves one client. In 2026 a server sendsnotifications/cancelledonly to end asubscriptions/listenstream, and only on stdio (§4.2). - The 2026 page recommends newline-delimited JSON for custom transports over Unix sockets and TCP as well. Only process launch, stderr, EOF shutdown and restart are specific to stdio.
3.2 HTTP+SSE (2024-11-05; deprecated)
The server exposes an SSE endpoint and a POST endpoint.
- On connect it MUST send an
endpointevent naming the POST URL. - Every server message travels as an SSE
messageevent.
From 2025-03-26 on, a server that still offers this transport does so only for older clients, and new implementations SHOULD NOT adopt it.
3.3 Streamable HTTP (2025-03-26 .. 2026-07-28)
One endpoint answers POST and, in the legacy revisions, GET.
- Origin. Every revision: a server MUST validate
Originto prevent DNS rebinding, and from 2025-11-25 an invalid origin gets 403. On a loopback, unauthenticated server this is the only defence the transport offers against any web page the user visits. Binding to loopback alone does not stop DNS rebinding. (mcpx checks it on/mcpand on the daemon’s/v1routes beside it:transport.md,internal/daemon/origin.go.) - Local binding. A local server SHOULD bind to 127.0.0.1 and SHOULD authenticate.
- POST responses. A POST carrying only responses or notifications gets
202. A POST carrying a request gets either
application/jsonor an SSE stream, and the client must accept both. - Batches. 2025-03-26 alone requires a server to accept JSON-RPC batches. 2025-06-18 removed them.
- Sessions (legacy). The server MAY assign
Mcp-Session-Idin theinitializeresponse. After that it SHOULD reject requests without the id with 400, and it MUST answer 404 for an id it has terminated. The 404 is how a client learns it has to re-initialise. - Protocol version header (2025-06-18 and later). The server MUST
reject an unsupported
MCP-Protocol-Versionwith 400. With no header, it assumes 2025-03-26 (SHOULD, and MAY in 2026). - Resumability and redelivery (legacy). Event ids and
Last-Event-IDresumption are optional. Where they are offered, a message MUST NOT be broadcast on more than one stream. - Modern (2026-07-28).
- No sessions, no GET stream, no resumption. A modern-only server should
neither mint nor echo
Mcp-Session-Id. Mcp-MethodandMcp-Nameheaders mirror the body, and so doMcp-Param-*headers for tool parameters annotatedx-mcp-header. The server that processes the body MUST validate them and reject a mismatch with-32020(HTTP 400).- An unknown method gets 404 with
-32601. - Errors that the schema assigns HTTP statuses MUST be JSON-RPC error
bodies. A bare
{"error": …}or a 200-status error makes a dual-era client misclassify the server as legacy and fall back toinitialize. Era detection is only as reliable as the server’s error bodies. - A client disconnect MUST be treated as cancellation. In every legacy revision it SHOULD NOT be (§4.2).
- No sessions, no GET stream, no resumption. A modern-only server should
neither mint nor echo
3.4 Where a dual-era server keeps legacy state
The 2026 versioning page scopes an initialize “to the stdio process (stdio)
or the session (HTTP)”. So legacy state is per process on stdio and per
session on HTTP:
- On stdio, there is no smaller unit. A second
initializeon the same process had no defined meaning in any legacy revision. - On HTTP, each
Mcp-Session-Idcarries its own negotiated version, client capabilities, log level, subscriptions and pending server-to-client requests. Modern requests on the same endpoint must not see any of it. - A legacy HTTP client that ignores the session header has no state beyond one POST. The server can ask it a question only on that POST’s own SSE stream, and the answer arrives on a separate POST that cannot be routed back without a session.
3.5 Authorization (2025-03-26 and later, HTTP only)
Authorization is an HTTP concern. A stdio server gets its credentials from the environment and SHOULD NOT use the OAuth flow.
An HTTP server that implements authorization:
- MUST publish Protected Resource Metadata. From 2025-06-18 it answers 401
with
WWW-Authenticatenaming that metadata. - MUST validate that tokens were issued for it (audience).
- MUST NOT pass a client’s token through to an upstream API. This is the confused-deputy rule.
- A proxy that uses a static client id upstream owes per-client consent (2025-06-18 and later).
A server that does no authorization has none of these obligations. It also
has no principal to bind requestState, tasks or elicitations to, which
matters in §4.4 and §5.
4. Messages and utilities
4.1 JSON-RPC and errors
- A request id MUST NOT be null (all revisions).
- A request id MUST NOT be reused within a session (legacy). In 2026 reuse is legal, but a server that also speaks legacy, or that correlates subscriptions by id, should never reuse one.
- A parse error omits
idfrom 2025-11-25 on. The 2025-03-26 and 2025-06-18 schemas have no conformant form for it. - Error codes are era-scoped:
-32002(resource not found) is legacy; 2026 uses-32602.-32042(URL elicitation required) exists only in 2025-11-25.-32020,-32021and-32022exist only in 2026.- A server sends a peer only the codes that peer’s revision defines.
_metakeys underio.modelcontextprotocol/are reserved from 2025-11-25. In 2025-06-18 that prefix was not reserved, so a peer on that revision may use it for its own data. Send reserved keys only to modern peers.
4.2 Cancellation
-
Legacy. Either side may send
notifications/cancelled, but never forinitialize. The receiver SHOULD stop work and MUST NOT respond afterwards. A late or unknown cancellation is ignored. -
2026-07-28. Cancellation depends on the transport:
- HTTP: closing the response stream is the cancellation. The server MUST treat a disconnect as cancellation and MUST NOT send further messages for that request.
- stdio: the client sends
notifications/cancelled. - Server-sent cancellation: a server MUST NOT send
notifications/cancelledfor anything except ending asubscriptions/listenstream.
-
Subscription teardown is contradictory in 2026.
- The cancellation page: the server MUST send
notifications/cancelledreferencing the listen id. - The subscriptions page: it SHOULD send a successful
subscriptions/listenresult instead. - The
CancelledNotificationdoc comment: the server-sent cancel happens on stdio.
Only one of the three is a MUST, and only one ordering satisfies it: a cancellation may name only a request that is still in progress, so the notification has to go first and the result after it. That also satisfies the SHOULD, and it costs nothing — a client that acted on the cancellation ignores a late response, exactly as the cancellation page tells it to, and a client that waits for the result still gets it. The schema’s “on stdio” then reads as the case it was written for rather than as a restriction, because on HTTP closing the SSE stream is a second, redundant signal, not a substitute for the one the MUST names. mcpx sends both on both transports (
internal/mcpserver/listen.go,announceEnd). The quotes are in conflicts C-21. - The cancellation page: the server MUST send
-
A proxy has to propagate cancellation. When the downstream cancels, the proxy cancels upstream:
- legacy upstream:
notifications/cancelled; - modern HTTP upstream: close the stream;
- modern stdio upstream:
notifications/cancelled.
An upstream call parked behind MRTR has no downstream stream to watch. Its lifetime ends only when its
requestStateor task TTL expires. - legacy upstream:
4.3 Progress, logging, pagination, completion, ping
- Progress. Sent only for a
progressTokenthe client supplied, andprogressMUST increase. In 2026 it travels only on that request’s stream. - Logging.
- Legacy: sent only if the
loggingcapability was declared. Before anylogging/setLevela server MAY send at its own level. - 2026: logging is deprecated,
logging/setLevelis removed, and the level arrives in the request’s_meta. A server MUST NOT log for a request that set no level. - A dual-era server tracks the
setLevelvalue per legacy session and ignores it for modern requests.
- Legacy: sent only if the
- Pagination.
- Cursors are opaque. An invalid cursor SHOULD get
-32602, not a silent restart from the first page. - In 2026 every page of one list MUST carry the same
cacheScope, and a client MUST NOT treat an empty-string cursor as the end. - An aggregating server’s cursors are stable only if its merged order is
deterministic, and the 2026 changelog asks for deterministic
tools/listorder.
- Cursors are opaque. An invalid cursor SHOULD get
- Completion.
- Declare
completions. - Return at most 100 values, sorted by relevance.
- Apply rate limiting on the server side.
- Declare
- Ping.
- Legacy: a server MUST answer promptly, and MAY send pings of its own.
- 2026 removes
ping, and servers may not send requests at all. Liveness becomes process liveness (stdio) and per-request success (HTTP).server/discoveris the cheap probe.
4.4 Multi-round-trip requests (MRTR, 2026-07-28)
A modern server that needs input from the client (elicitation, sampling or
roots) returns resultType: "input_required". The result carries
inputRequests and an opaque requestState, and the client retries the same
method under a new id with inputResponses and the requestState.
input_requiredis allowed only ontools/call,prompts/getandresources/read. A question that arises during any other method cannot reach a modern client.- The server MUST NOT put an input request in
inputRequestsunless that request declared the matching client capability. A retry carries its own capabilities, and the check is repeated on every round. - The server MUST NOT assume the retry will come. It MUST treat
requestStateas attacker-controlled, and SHOULD make it tamper-evident and bound to the principal, an expiry, and the originating method and parameters (§1). - If the call cannot proceed without a capability the client did not
declare, the server answers
-32021withdata.requiredCapabilities. A server with a fallback does not have to, but then it owes an eventual result or error, never a hang.
4.5 Tasks
- 2025-11-25 (core).
- A server that declares
tasks.requests.tools.callMUST respect each tool’sexecution.taskSupport, and a tool without it is forbidden by default. So the capability alone enables nothing: a server has to mark tools. - Task ids MUST be unique.
tasks/resultMUST block until the task is terminal, and MUST carryio.modelcontextprotocol/related-task.- A cancelled task MUST stay
cancelled. tasks/cancelon a terminal task MUST return-32602.tasks/listmust be scoped by authorization context.
- A server that declares
- 2026-07-28 (extension
io.modelcontextprotocol/tasks).- The server returns
resultType: "task", and the result arrives ontasks/get. - A task must be durably created before the response is sent.
- A server must never return a task to a client that did not declare
the extension.
resultType: "task"is the only thing that distinguishes a task handle from a real result, and a client that did not negotiate the extension has no schema for it — so returning one unasked hands it a result it cannot parse. The changelog’s “unsolicited” reads otherwise; see conflicts C-29. This is also whytasks/listis absent from the extension: polling a client-hosted task would itself be the server→client request 2026-07-28 forbids (../compare/register/capabilities.md). - The extension’s methods from a client that did not declare it are
-32021withdata.requiredCapabilities.
- The server returns
- What the prose implies. A task handle outlives the request that created
it, and the connection. Task state cannot live in connection or session
state, and
tasks/listvisibility cannot be keyed by connection. “Durably” is stronger than “in memory”. A pool of processes serving one configuration must either share the task store or routetasks/*to the owning process.
4.6 Caching (2026-07-28)
Cacheable results carry ttlMs and cacheScope, and both are REQUIRED:
server/discover, tools/list, resources/list, resources/templates/list,
prompts/list and resources/read.
- A
privateresult MUST NOT be served to another authorization context. A gateway is the “shared” cache the schema names. - An aggregator computes its own scope: private if any contributing upstream
page is private. Its
ttlMsshould not exceed the smallest upstream TTL. - Lists MUST NOT vary per connection.
4.7 Subscriptions (2026-07-28)
subscriptions/listen replaces resources/subscribe, the legacy GET stream
and unsolicited list_changed notifications.
- The request carries a
notificationsfilter. - The server acknowledges with
notifications/subscriptions/acknowledged, which echoes the honoured filter. The subscription id travels in_meta, and the acknowledgement MUST be the first message for that subscription. - A server MUST NOT send a notification type the filter did not request.
Every notification on the stream carries
_meta["io.modelcontextprotocol/subscriptionId"]. - A client may hold several concurrent listens, keyed by the listen
request’s id. Each has its own filter, acknowledgement and teardown. The
server keeps
listenId → {filter, stream}and fans each event out to every matching stream. A new listen must never replace an existing one. - On stdio the tag is the only way to tell streams apart. On HTTP each listen is its own SSE response, and the tag is still required.
- A listen request normally never gets a response, and the general “establish timeouts for all requests” rule has no exception for it. Keep-alives (SSE comments) exist so intermediaries do not time it out.
- The 2026 resources page still says to send
list_changed“when the list changes” with no mention of a listen stream. The schema’s filter rule takes precedence.
5. Features
Tools
- Invalid input. Up to 2025-06-18, invalid input is a protocol error. From
2025-11-25 it is a tool result with
isError: true, so the model can correct itself. An unknown tool is a protocol error (-32602) in every revision. - Structured output.
structuredContentandoutputSchemaarrive in 2025-06-18. With anoutputSchema, the server MUST return conformingstructuredContent, and SHOULD also include it as text.- Both must be objects in 2025-06-18 and 2025-11-25. 2026 allows any JSON value and any 2020-12 schema.
- Names. 2025-11-25 adds constraints on tool names.
- Headers. 2026 adds
x-mcp-headerparameters that are mirrored to headers. A server re-listing another server’s tools inherits theirx-mcp-headerannotations, and with them the obligation to validate the mirrored headers (§3.3). - Proxies. Human-in-the-loop confirmation is a host SHOULD, and a proxy must not claim it on the host’s behalf.
Resources
- URIs are validated.
- Not-found is
-32002(legacy) or-32602(2026). resources/updatedgoes only to clients that subscribed: throughresources/subscribein legacy revisions, and through a listen filter that names the resource in 2026.- The 2026 prompts page requires an embedded resource to carry a MIME type,
but the schema makes
mimeTypeoptional. Send it.
Prompts
- Arguments are validated.
- 2026 is the only revision whose prompts page requires the capability to be
declared in
DiscoverResult. Declare it there regardless.
6. Accept liberally, send conservatively, field by field
A server that also serves older revisions must not send a peer anything its revision did not define. Unknown fields are usually ignored, but no revision guarantees it.
| carried | send to | otherwise |
|---|---|---|
resultType | 2026 requests | omit; accept absence as complete |
structuredContent | ≥ 2025-06-18; must be an object for 2025-06-18 and 2025-11-25 | render into content as text |
outputSchema with a non-object root | 2026 | withhold the tool’s schema, or wrap it |
resource_link block | ≥ 2025-06-18 | embedded resource |
audio block | ≥ 2025-03-26 | drop, or describe as text |
tool annotations | ≥ 2025-03-26 | strip |
title, resource lastModified | ≥ 2025-06-18 | strip |
icons, description, websiteUrl on Implementation | ≥ 2025-11-25 | strip |
ttlMs, cacheScope | 2026 (REQUIRED there) | omit |
serverInfo | 2026 in result _meta | InitializeResult.serverInfo |
io.modelcontextprotocol/* _meta keys | ≥ 2025-11-25, and only keys that revision defines | omit |
| error codes | only those the peer’s revision defines | nearest defined code |
| batches | never send | accept on input (2025-03-26 MUST; nothing forbids receiving them) |
| server → client requests | legacy peers that declared the capability | MRTR inputRequests for 2026 |
list_changed, resources/updated | legacy: the session; 2026: matching listen streams only | nothing |
| logs | legacy: at or above the session level; 2026: only when the request set a level | nothing |
Accepting is the other direction. A method beyond the negotiated revision
(for example subscriptions/listen from a legacy client) withholds nothing,
and answering it is not a violation. Declaring it is: a capability is a
promise about the revision in force. For 2026 peers the answer is narrower,
because an unknown method is -32601 with 404 on HTTP, and a modern peer
should expect exactly the methods 2026 defines.
7. Where mcpx stands
Measured, not asserted: the official suite’s results, and which of its
failures are defects, are official-suite.md; the
per-requirement status is conformance-matrix.md.
Two obligations on this page were corrected in mcpx because the suite found
them: a modern request without the required _meta is -32602, not -32020
(a missing body field is not a header disagreeing with a body), and the five
methods 2026-07-28 removed are -32601 (404 on HTTP) to a 2026-07-28 peer
(#250; ../protocol.md §2.1).
Two readings on this page are mcpx decisions, recorded with their reasons in
../protocol.md: a tool whose upstream may ask a question
the request cannot receive does not get -32021, because the broker answers
instead (§3.1); and a legacy resources/subscribe for a URI whose updates
cannot be delivered succeeds, with a warning event, because the legacy
revisions have no way to say “agreed, but nothing will come” (§4.3, #251).
mcpx as a server: the transport
What mcpx serve (stdio) and the daemon’s /mcp (Streamable HTTP) do on
the wire, revision by revision, and why. Everything here is tested; the test
names are <revision>/<area>/<requirement> in
internal/mcpserver/transport_test.go and internal/e2e/transport_test.go.
mcpx serves both eras at once: 2024-11-05, 2025-03-26, 2025-06-18 and
2025-11-25, which negotiate with initialize (2024-11-05 over stdio and
Streamable HTTP; its own HTTP+SSE server transport is not hosted), and 2026-07-28, which carries its version in
every request’s _meta. Over HTTP the two share one endpoint, so the first
thing the transport does with a POST is decide which era wrote it.
Which era a POST belongs to
A POST is modern if its body carries
_meta["io.modelcontextprotocol/protocolVersion"], or its
MCP-Protocol-Version header names a revision at or after 2026-07-28 (a date
mcpx does not implement still counts: that request is answered with -32022,
not treated as legacy). Everything else is legacy.
The header is enough on its own because a modern header on a body without the
_meta field is exactly the disagreement the modern header rules exist to
catch, and it should be refused as that rather than quietly served as legacy.
Streamable HTTP, 2026-07-28
- Mirrored headers.
MCP-Protocol-VersionandMcp-Methodare required on every request POST,Mcp-Nameontools/call,prompts/getandresources/read. Missing, different from the body, or carrying bytes a header may not carry (anything outside visible ASCII, space and tab) is400with-32020 HeaderMismatchand the request’s id.Mcp-Namein the=?base64?…?=sentinel is decoded before the comparison; the Base64 is strict – padding required, alphabet only – as the specification’s test-case table requires. Header names are matched case-insensitively (Go canonicalises them), values case-sensitively. A notification POST has no header requirements in this revision, so none are demanded, but any it sends must still agree. Mcp-Param-*headers. Ontools/call, everyx-mcp-headerannotation in the named tool’sinputSchema– mcpx re-lists upstream schemas verbatim, so an upstream’s annotation counts – is checked: a header that carries bytes a header may not, fails to decode, differs from the body value, is missing while the body has a value, or is present while the body has none, is400with-32020. Integers compare numerically. The rules live ininternal/mcpheaders, shared with the client side that sends these headers.- Status is a function of the response, in one place (
modernStatus):-32022,-32021,-32020,-32602,-32600,-32700are400;-32601is404; everything else200. Every-32602is400, not only the missing-_metaone the spec names, because a status that depended on which handler produced an invalid-params error would not be a function of the response, and every invalid-params error is a client error. - No sessions. An
Mcp-Session-Idon a modern request never binds it to a legacy session: an unknown one is ignored (no404), and none is echoed or minted –server/discoverused to mint one so arequestStatehad something to bind to; it is now bound to the request instead (messages.md). - No batches. An array body is
400 -32600. - Notifications are
202with no body; one that is not accepted is400with an id-less error. An unknown notification method is ignored (202): JSON-RPC gives a notification no reply, and every revision says to ignore what is not understood. - Closing the stream is cancellation. The request runs under the HTTP request’s context, so a client that disconnects cancels the upstream call (the test asserts the backend’s context ends, not only the handler’s), and nothing is written afterwards.
- No
Last-Event-ID, no SSE event ids.
Streamable HTTP, 2025-03-26 … 2025-11-25
- Sessions are minted by
initialize. A request naming an unknown, expired or deleted session is404, which tells the client to initialize again.DELETEwith a session ends it and closes its GET stream (204); without one it is400(mcpx does allow termination, so405would be wrong); for an unknown one404. A legacy request with no session header is served statelessly, as before. MCP-Protocol-Versionpresent and not a legacy revision mcpx implements is400with-32022and the legacy list. Absent means 2025-03-26 for a sessionless request. In a session, the version negotiated atinitializegoverns even if the header names another supported one: the spec says the client SHOULD send the negotiated version and the server may rely on “the protocol version negotiated during initialization”, so a disagreeing header is a client quirk to tolerate, not a reason to refuse.- Batches exist only in 2025-03-26, where a server MUST accept them;
2025-06-18 removed them. So a batch is accepted when the session negotiated
2025-03-26, or a sessionless request has no header or a 2025-03-26 one, and
is otherwise
400 -32600. Elements run concurrently; responses in a batch are delivered to waiting questions; the reply is a JSON array (or the elements’ replies as separate events if something had to stream); a batch of only notifications and responses gets202.initializeinside a batch is refused as that element (2025-03-26 lifecycle: it MUST NOT be batched), and mints nothing. - GET without a session, or with a modern header, is
405; with an unknown session404; otherwise it opens the session’s notification stream (text/event-stream,X-Accel-Buffering: no). One per session: a second is409, because the spec forbids broadcasting a message across streams and a stray second stream would silently take half the notifications. The stream carries a:comment everytransport.sseKeepAlive, closes onDELETE, and carriesnotifications/{tools,prompts,resources}/list_changed(which a legacy client opts into simply by mcpx declaringlistChanged) andnotifications/resources/updatedforresources/subscribe. A subscription made before the stream opens delivers once it does; with no stream open a notification is dropped, since mcpx keeps no redelivery buffer and the legacy transport lets a client choose not to listen. - Cancellation. In a session, a dropped connection is not a
cancellation — the legacy revisions say it SHOULD NOT be read as one — and
notifications/cancelledposted on the session cancels the request’s context and withholds its reply (the POST ends as an event stream with nothing on it, the only shape that carries no response). Without a session no cancellation could ever reach the request, so there the disconnect remains the signal. - Responses and notifications:
202no body; a response nothing is waiting for is400.
Origin (every revision with Streamable HTTP)
A request whose Origin is present and not allowed is 403 with an id-less
JSON-RPC error, on every method. Allowed: no Origin at all (every non-browser
client), the loopback hosts (transport defaults: localhost, 127.0.0.1,
::1) at any port over http or https, the daemon’s own daemon.address when
it is a specific address, and the exact origins in
transport.allowedOrigins. The daemon’s address is named rather than taken
from the request’s Host, which is precisely what DNS rebinding controls.
Origin: null is refused: it names nobody.
stdio, every revision
- Requests run concurrently, each under its own context. Before, they ran
in line, so a
notifications/cancelledfor a running request could not be read until that request had finished and cancellation did nothing.notifications/cancellednow cancels the context (which reaches the upstream call) and the reply is suppressed. Notifications andinitializestay in line: a notification must land before whatever follows it, andinitializesettles what every later reply looks like. Writes are serialised, one frame per line. Replies are no longer in request order, which JSON-RPC never promised; the one test that assumed it now matches by id. - Notifications are never answered, not even with an error.
- Batches: the same per-version rule as HTTP, from the connection’s
negotiated version (or 2025-03-26 before any
initialize); the reply is an array, nothing for notifications only, andinitializein a batch is an error element. - EOF stops reading. Requests already in flight get
transport.stdioDrainto answer, then are cancelled; the process then exits. A host that closes stdin after writing its requests still gets their answers. - Before
initialize: mcpx is dual-era and accepts liberally, soping(and anything else) beforeinitializeis answered under the oldest revision’s shapes. A modern client sends noinitializeat all, and refusing the legacy ones would make the stdio probe order matter for no gain. - stdout carries only MCP. Checked against the real binary: initialize, list, a successful call with a newline in its text, a tool error, an unknown tool and an unknown method, and every stdout line must be one JSON-RPC message.
- After a legacy
initialize,list_changedis forwarded as on the GET stream: it was declared, so it must be delivered.
Found along the way
- A legacy client was declared
listChanged: trueon stdio and never sent a singlelist_changed: nothing started a notifier for it. Onlyresources/subscribeand the modernsubscriptions/listendid. - Over HTTP a legacy session declared neither
subscribenorlistChanged, honestly, because there was no GET stream to deliver on; now there is, and both are declared and delivered. - stdio answered a notification with an unknown method with a method-not-found error carrying no id.
- The previous header check compared
MCP-Protocol-Versionwith_metaonly when both were present and answered with a non-JSON-RPC body, so a modern POST with no headers at all was served.