MCP Clients: What Each One Supports and How to Confirm What a Client Declares

MCP Clients: What Each One Supports and How to Confirm What a Client Declares

Before a server relies on a client, confirm three things: which tools, resources and prompts the client puts in front of its users; whether it answers server-initiated roots, sampling and elicitation requests; and which protocol revision it speaks. The tables list only capabilities stated by each vendor; an empty cell means the page is silent. To confirm what the client in front of you actually declares, run the probe server in section 5.

For the lifecycle and the messages that these declarations take part in, read the learning path and MCP architecture. For what a server offers, read tools, resources and prompts.

1. What “supports MCP” can mean

Three different things hide behind the phrase. Only the second and third are visible in the protocol.

LayerWhat the client doesWhere it is declaredHow a server finds out
PrimitivesCalls tools/list, resources/list and prompts/list and shows or uses the resultsNowhere: tools, resources and prompts are server capabilities, and a client has no capability named after themBy the calls that arrive, or from the vendor’s documentation
Client capabilitiesAnswers requests that the server starts: roots/list, sampling, elicitation (and tasks or extensions in some revisions)ClientCapabilities: in initialize up to 2025-11-25, in the _meta of every request in 2026-07-28By reading the declaration (section 4)
RevisionSpeaks one or more protocol revisionsThe MCP-Protocol-Version header, and initialize or server/discoverFrom the first requests of a connection (section 6)

The rule that makes the declaration worth reading: in 2025-11-25 both sides “Only use capabilities that were successfully negotiated”, and in 2026-07-28 a server “MUST NOT rely on capabilities the client has not declared”. If it needs one, it returns a MissingRequiredClientCapabilityError (-32021) with HTTP status 400. Because tools, resources and prompts carry no declaration, the matrix in section 2 rests on what vendors write, and section 5 shows how to measure the rest.

2. What each vendor’s page says

A ✓ means that the vendor’s own documentation states the feature; the number in brackets is the row of the wording table in section 3. An empty cell means the page does not say. It does not mean the client lacks the feature. Clients that were previously listed without a source are not carried over.

ClientToolsResourcesPromptsRootsSamplingElicitationRevision named
Claude Code✓ (1)✓ (2)✓ (3)✓ (4)✓ (5)2026-07-28 (6)
Cursor✓ (7)✓ (8)✓ (9)✓ (10)✓ (11)
VS Code✓ (12)✓ (13)✓ (14)✓ (15)✓ (16)✓ (17)
Zed✓ (18)✓ (18)
Gemini CLI✓ (19)✓ (20)✓ (21)
  • All five clients state tools. Zed alone leaves resources empty: its page names “Tools and Prompts” only (18).
  • Sampling is stated by VS Code only (16). Section 6 shows that Claude Code 2.1.292 does not declare it either, which agrees with its empty cell.
  • Roots and elicitation are stated together by Claude Code, Cursor and VS Code, and by nobody else in this table.
  • Only Claude Code names a protocol revision, 2026-07-28 (6). For the others the page is silent, so read what they send (section 6).

The pages that were read:

ClientPage readRetrieved
Claude Codecode.claude.com/docs/en/mcp2026-10-26
Cursorcursor.com/docs/mcp2026-10-26
VS Codecode.visualstudio.com/api/extension-guides/ai/mcp2026-10-26
Zedzed.dev/docs/ai/mcp2026-10-26
Gemini CLIgeminicli.com/docs/tools/mcp-server/2026-10-26

3. The vendors’ wording

Every quote below was fetched from its page and found there on 2026-10-26. Row c1 is not a matrix cell: it is a limit on one, used in section 8.

#ClientCoversWhat the vendor’s page says
1Claude CodeTools“MCP servers give Claude Code access to your tools, databases, and APIs.”
2Claude CodeResources“MCP servers can expose resources that you can reference using @ mentions”
3Claude CodePrompts“MCP servers can expose prompts that become available as commands in Claude Code.”
4Claude CodeRoots“Claude Code answers roots/list with the session’s launch directory”
5Claude CodeElicitation“MCP servers can request structured input from you mid-task using elicitation.”
6Claude CodeRevision“which adds MCP protocol revision 2026-07-28”
7CursorTools“Tools Supported Functions for the AI model to execute”
8CursorResources“Resources Supported Structured data sources that can be read and referenced”
9CursorPrompts“Prompts Supported Templated messages and workflows for users”
10CursorRoots“Roots Supported Server-initiated inquiries into URI or filesystem boundaries”
11CursorElicitation“Elicitation Supported Server-initiated requests for additional information from users”
12VS CodeTools“Tools: extend agent mode with extra tools”
13VS CodeResources“Resources: provide data and content that users can add as chat context or interact with directly in VS Code”
14VS CodePrompts“Prompts: add reusable prompts as slash commands in chat”
15VS CodeRoots“Roots: provide information about the user’s workspace root folder(s)”
16VS CodeSampling“Sampling: make language model requests using the user’s configured models and subscription”
17VS CodeElicitation“Elicitation: request input from the user”
18ZedTools, Prompts“Zed currently supports MCP’s Tools and Prompts features.”
19Gemini CLITools“An MCP server is an application that exposes tools and resources to the Gemini CLI”
20Gemini CLIResources“Gemini CLI discovers these automatically and gives you the possibility to reference them in the chat.”
21Gemini CLIPrompts“MCP servers can expose predefined prompts that can be executed as slash commands within Gemini CLI.”
c1Claude CodeLimit“elicitation/create is served only in interactive terminal sessions, and roots/list everywhere”

4. What a client declares, revision by revision

ClientCapabilities is the one place where a client says what it will answer. The table is computed from schema/<revision>/schema.json at the pinned commit; ✓ lists the sub-fields that the revision’s schema defines, and — means the capability does not exist in that revision.

Capability2024-11-052025-03-262025-06-182025-11-252026-07-28
roots✓ (listChanged)✓ (listChanged)✓ (listChanged)✓ (listChanged)✓
sampling✓✓✓✓ (context, tools)✓ (context, tools)
elicitation——✓✓ (form, url)✓ (form, url)
tasks———✓ (cancel, list, requests)—
extensions————✓
experimental✓✓✓✓✓
  • Where it travels. Up to 2025-11-25 the client declares once, in initialize. In 2026-07-28 it puts the declaration in _meta.io.modelcontextprotocol/clientCapabilities on every request, and a client that supports roots “MUST declare the roots capability” there on each request. There is no initialize in that revision; see the learning path.
  • Elicitation modes. For backward compatibility an empty elicitation object means form mode only. A server MUST NOT send an elicitation request in a mode the client has not declared (2025-11-25).
  • Sampling sub-capabilities (2025-11-25). A server MUST NOT send tool-enabled sampling requests unless the client declared sampling.tools, and SHOULD NOT use includeContext values thisServer or allServers unless it declared sampling.context.
  • Roots in 2026-07-28. The request is delivered inside an InputRequiredResult, in inputRequests. The schema now defines roots as an object without fields.
  • tasks is a client capability in 2025-11-25 only. In 2026-07-28 it is no longer part of the core protocol.
  • Deprecation (2026-07-28). Roots and sampling are deprecated by SEP-2577. They remain in the specification, but new implementations SHOULD NOT adopt them; elicitation is not deprecated. Design a new server so that it also works with a client that declares neither roots nor sampling.

5. A probe server that records the declaration

The probe is a standard-library HTTP server. It answers just enough protocol for a client to connect, offers empty tool, resource and prompt lists so that a client has a reason to ask for them, and prints one JSON line per request: the method, the protocol revision, the client’s name and version, and the capabilities it declares. It issues no session id, so run one client at a time.

python3 client_probe_server.py --era modern --port 8240   # server/discover answered, initialize refused
python3 client_probe_server.py --era legacy --port 8240   # initialize answered, server/discover refused

Point the client at http://127.0.0.1:8240/mcp by adding it as a remote HTTP server in the client’s own MCP settings. For Claude Code that is claude mcp add --transport http probe http://127.0.0.1:8240/mcp. A client that speaks both eras probes server/discover first and falls back to initialize, so the legacy probe shows the legacy declaration of any such client; a legacy-only client is refused by the modern probe, which tells you its era. A hosted client cannot reach 127.0.0.1; use a tunnel for it, and stop the probe when you are done.

client_probe_server.py
#!/usr/bin/env python3
"""Probe MCP server: record what a connecting client says about itself and which list methods it calls.

Usage: client_probe_server.py [--era modern|legacy] [--port 8240]
Point a client at http://127.0.0.1:8240/mcp. Every POST prints one JSON line:
  method, protocol (the MCP-Protocol-Version header, or the version the client offers in `initialize`),
  version (the protocolVersion in the `_meta` of a modern request), client (name and version),
  capabilities (what the client declares: roots, sampling, elicitation, ...).
`--era modern` answers `server/discover` (revision 2026-07-28; a client names itself in the `_meta` of every
request) and refuses `initialize`. `--era legacy` refuses `server/discover` and answers `initialize` (a client names
itself once, in the handshake). The server offers empty tool, resource and prompt lists, so a client that wants
any of the three has a reason to ask. Standard library only.
"""
import argparse
import json
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PREFIX = "io.modelcontextprotocol/"
MODERN = "2026-07-28"
LEGACY = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]
CAPABILITIES = {"tools": {}, "resources": {}, "prompts": {}}
LISTS = {"tools/list": "tools", "resources/list": "resources", "prompts/list": "prompts"}


def make_handler(era, log):
    class Handler(BaseHTTPRequestHandler):
        def log_message(self, *args):  # keep stdout for the records
            pass

        def reply(self, status, body=None):
            data = json.dumps(body).encode() if body is not None else b""
            self.send_response(status)
            if body is not None:
                self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        def do_GET(self):  # no server-initiated stream: the spec allows 405
            self.reply(405)

        do_DELETE = do_GET

        def do_POST(self):
            try:
                msg = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
                method, params = msg["method"], msg.get("params") or {}
            except (ValueError, KeyError, TypeError):
                return self.reply(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "bad request"}})
            meta = params.get("_meta") or {}
            entry = {"method": method, "protocol": self.headers.get("MCP-Protocol-Version") or params.get("protocolVersion"),
                     "version": meta.get(PREFIX + "protocolVersion"),
                     "client": meta.get(PREFIX + "clientInfo") or params.get("clientInfo"),
                     "capabilities": meta.get(PREFIX + "clientCapabilities", params.get("capabilities"))}
            log({k: v for k, v in entry.items() if v is not None})
            if "id" not in msg:  # a notification gets no body
                return self.reply(202)
            result, error = None, None
            if method == "server/discover" and era == "modern":
                result = {"resultType": "complete", "supportedVersions": [MODERN], "capabilities": CAPABILITIES,
                          "ttlMs": 0, "cacheScope": "private"}
            elif method == "initialize" and era == "legacy":
                offered = params.get("protocolVersion")
                result = {"protocolVersion": offered if offered in LEGACY else LEGACY[0], "capabilities": CAPABILITIES,
                          "serverInfo": {"name": "client-probe", "version": "1"}}
            elif method in LISTS:
                result = {LISTS[method]: []}
                if era == "modern":
                    result.update(resultType="complete", ttlMs=0, cacheScope="private")
            else:
                error = {"code": -32601, "message": "Method not found: %s" % method}
            body = {"jsonrpc": "2.0", "id": msg["id"]}
            body.update({"error": error} if error else {"result": result})
            self.reply(200, body)

    return Handler


def summarize(record):
    """One readable line per record: method, protocol, client, and the capability names it declares."""
    parts = ["%-26s" % record["method"], "protocol=%s" % record.get("protocol")]
    client = record.get("client")
    if client:
        parts.append("client=%s/%s" % (client.get("name"), client.get("version")))
    if "capabilities" in record:
        parts.append("declares=%s" % (sorted(record["capabilities"]) or "nothing"))
    return "  " + " ".join(parts).rstrip()


def serve(era, port, log):
    return ThreadingHTTPServer(("127.0.0.1", port), make_handler(era, log))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--era", choices=["modern", "legacy"], default="modern")
    ap.add_argument("--port", type=int, default=8240)
    args = ap.parse_args()
    server = serve(args.era, args.port, lambda e: print(json.dumps(e, sort_keys=True), flush=True))
    print("probe (%s era) on http://127.0.0.1:%d/mcp" % (args.era, args.port), file=sys.stderr, flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()

6. Run results

The official Python SDK

probe_runs.py starts the probe inside the script and connects mcp.client.Client of the official SDK to it four times: modern and legacy probe, each without callbacks and with callbacks for sampling, roots and elicitation. The three list calls are issued by the script; a real client issues its own.

Probe serverClient callbacksRequests, in orderDeclared
modernnoneserver/discover → tools/list, resources/list, prompts/listnothing
modernsampling, roots, elicitationserver/discover → tools/list, resources/list, prompts/listelicitation, roots, sampling
legacynoneserver/discover → initialize → notifications/initialized → tools/list, resources/list, prompts/listnothing
legacysampling, roots, elicitationserver/discover → initialize → notifications/initialized → tools/list, resources/list, prompts/listelicitation, roots, sampling
  • The SDK declares what its callbacks allow and nothing else. “The SDK supports sampling” therefore means that the application passed a sampling callback.
  • Against the legacy probe the SDK sends server/discover, gets a refusal, and then sends initialize with 2025-11-25. Its mode auto does this on purpose. A refused server/discover is normal in the legacy era and no sign of failure.
  • Its default clientInfo is mcp/0.1.0. A product built on the SDK may therefore not be identifiable by name alone.
probe_runs.py
#!/usr/bin/env python3
"""Connect the official Python SDK client (mcp==2.3.0) to the probe server in four configurations and print what
the probe recorded. The list calls are issued by this script; a real client issues its own, and that is what you read.

Usage: probe_runs.py      Exit code 0 = every run recorded what its configuration implies, 1 = it did not.
"""
import asyncio
import sys
import threading

from mcp import types
from mcp.client import Client

import client_probe_server as probe

PORT = 8241


async def no_sampling(context, params):
    return types.ErrorData(code=-32601, message="not used in this run")


async def no_roots(context):
    return types.ErrorData(code=-32601, message="not used in this run")


async def no_elicitation(context, params):
    return types.ErrorData(code=-32601, message="not used in this run")


def run(era, callbacks):
    records = []
    server = probe.serve(era, PORT, records.append)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    kwargs = {"sampling_callback": no_sampling, "list_roots_callback": no_roots,
              "elicitation_callback": no_elicitation} if callbacks else {}

    async def drive():
        async with Client("http://127.0.0.1:%d/mcp" % PORT, **kwargs) as client:
            await client.list_tools()
            await client.list_resources()
            await client.list_prompts()

    try:
        asyncio.run(drive())
    finally:
        server.shutdown()
        server.server_close()
        thread.join()
    return records


def main():
    failed = 0
    for era in ("modern", "legacy"):
        for callbacks in (False, True):
            records = run(era, callbacks)
            print("run: era=%s callbacks=%s" % (era, "sampling,roots,elicitation" if callbacks else "none"))
            for record in records:
                print(probe.summarize(record))
            methods = [r["method"] for r in records]
            declared = [r for r in records if "capabilities" in r]
            wanted = ["elicitation", "roots", "sampling"] if callbacks else []
            ok = (methods[:1] == ["server/discover"]  # the SDK always probes first
                  and ("initialize" in methods) == (era == "legacy")  # only a legacy server gets the handshake
                  and [m for m in methods if m.endswith("/list")] == ["tools/list", "resources/list", "prompts/list"]
                  and bool(declared) and all(sorted(r["capabilities"]) == wanted for r in declared))
            print("  check: %s" % ("as configured" if ok else "NOT as configured"))
            failed += 0 if ok else 1
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
Recorded output: probe_runs.py
run: era=modern callbacks=none
  server/discover            protocol=2026-07-28 client=mcp/0.1.0 declares=nothing
  tools/list                 protocol=2026-07-28 client=mcp/0.1.0 declares=nothing
  resources/list             protocol=2026-07-28 client=mcp/0.1.0 declares=nothing
  prompts/list               protocol=2026-07-28 client=mcp/0.1.0 declares=nothing
  check: as configured
run: era=modern callbacks=sampling,roots,elicitation
  server/discover            protocol=2026-07-28 client=mcp/0.1.0 declares=['elicitation', 'roots', 'sampling']
  tools/list                 protocol=2026-07-28 client=mcp/0.1.0 declares=['elicitation', 'roots', 'sampling']
  resources/list             protocol=2026-07-28 client=mcp/0.1.0 declares=['elicitation', 'roots', 'sampling']
  prompts/list               protocol=2026-07-28 client=mcp/0.1.0 declares=['elicitation', 'roots', 'sampling']
  check: as configured
run: era=legacy callbacks=none
  server/discover            protocol=2026-07-28 client=mcp/0.1.0 declares=nothing
  initialize                 protocol=2025-11-25 client=mcp/0.1.0 declares=nothing
  notifications/initialized  protocol=2025-11-25
  tools/list                 protocol=2025-11-25
  resources/list             protocol=2025-11-25
  prompts/list               protocol=2025-11-25
  check: as configured
run: era=legacy callbacks=sampling,roots,elicitation
  server/discover            protocol=2026-07-28 client=mcp/0.1.0 declares=['elicitation', 'roots', 'sampling']
  initialize                 protocol=2025-11-25 client=mcp/0.1.0 declares=['elicitation', 'roots', 'sampling']
  notifications/initialized  protocol=2025-11-25
  tools/list                 protocol=2025-11-25
  resources/list             protocol=2025-11-25
  prompts/list               protocol=2025-11-25
  check: as configured

Claude Code 2.1.292

claude_code_probe.py adds the probe to the real claude CLI, runs claude mcp list (its health check) and prints what arrived. HOME, XDG_CONFIG_HOME and CLAUDE_CONFIG_DIR point at a throw-away directory, so your own configuration is neither read nor changed. Measured on macOS on 2026-10-26.

Probe serverclaude mcp listRequests, in orderDeclared in full
legacyConnectedserver/discover → initialize → notifications/initialized → tools/list{"elicitation": {"form": {}, "url": {}}, "roots": {"listChanged": true}}
modernConnectedserver/discover → tools/list{"elicitation": {"form": {}, "url": {}}, "roots": {"listChanged": true}}
  • Claude Code probes server/discover with revision 2026-07-28 first, and against the legacy probe falls back to initialize with 2025-11-25. It speaks both eras, and the vendor page names 2026-07-28 (6).
  • It declares elicitation (with form and url) and roots (with listChanged), and no sampling. That equals cells 4 and 5 and the empty sampling cell of section 2: the vendor page and the wire agree.
  • In the modern run it still sends roots.listChanged, although the 2026-07-28 schema defines roots without fields. The schema does not forbid extra fields; a server should ignore this one.
  • claude mcp list requested tools/list only. It is a health check, not a session, so the missing resources/list and prompts/list prove nothing (section 7).
claude_code_probe.py
#!/usr/bin/env python3
"""Connect the real Claude Code CLI to the probe server and print what it declares.

`claude mcp list` health-checks every configured server, so adding the probe and running it once records the
client's own handshake. HOME, XDG_CONFIG_HOME and CLAUDE_CONFIG_DIR point at a throw-away directory, so your own
Claude Code configuration and servers are neither read nor changed. Needs the `claude` CLI on PATH; exit code 3
when it is missing.
Usage: claude_code_probe.py [--save FILE]   (--save writes the full records of both runs as JSON)
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path

import client_probe_server as probe

PORT = 8243
URL = "http://127.0.0.1:%d/mcp" % PORT


def run_era(era, env, cwd):
    records = []
    server = probe.serve(era, PORT, records.append)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        subprocess.run(["claude", "mcp", "add", "--transport", "http", "-s", "local", "probe", URL],
                       cwd=cwd, env=env, check=True, capture_output=True, text=True, timeout=120)
        listed = subprocess.run(["claude", "mcp", "list"], cwd=cwd, env=env, capture_output=True, text=True, timeout=120)
        status = [line for line in listed.stdout.splitlines() if line.startswith("probe:")]
        subprocess.run(["claude", "mcp", "remove", "probe", "-s", "local"], cwd=cwd, env=env, capture_output=True, text=True, timeout=120)
    finally:
        server.shutdown()
        server.server_close()
        thread.join()
    return records, status[0].split(" - ")[-1] if status else "no status line"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--save")
    args = ap.parse_args()
    if not shutil.which("claude"):
        print("claude CLI not found on PATH", file=sys.stderr)
        return 3
    version = subprocess.run(["claude", "--version"], capture_output=True, text=True, timeout=60).stdout.strip()
    print("claude --version: %s" % version)
    saved = {"version": version}
    with tempfile.TemporaryDirectory() as home, tempfile.TemporaryDirectory() as cwd:
        env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=os.path.join(home, ".config"),
                   CLAUDE_CONFIG_DIR=os.path.join(home, ".claude"))
        for era in ("legacy", "modern"):
            records, status = run_era(era, env, cwd)
            saved[era] = records
            print("run: probe era=%s, claude mcp list says: %s" % (era, status))
            for record in records:
                print(probe.summarize(record))
            first = next((r for r in records if "capabilities" in r), None)
            if first:
                print("  declared in full: %s" % json.dumps(first["capabilities"], sort_keys=True))
    if args.save:
        Path(args.save).write_text(json.dumps(saved, indent=1, sort_keys=True) + "\n", encoding="utf-8")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Recorded output: claude_code_probe.py
claude --version: 2.1.292 (Claude Code)
run: probe era=legacy, claude mcp list says: ✔ Connected
  server/discover            protocol=2026-07-28 client=claude-code/2.1.292 declares=['elicitation', 'roots']
  initialize                 protocol=2025-11-25 client=claude-code/2.1.292 declares=['elicitation', 'roots']
  notifications/initialized  protocol=2025-11-25
  tools/list                 protocol=2025-11-25
  declared in full: {"elicitation": {"form": {}, "url": {}}, "roots": {"listChanged": true}}
run: probe era=modern, claude mcp list says: ✔ Connected
  server/discover            protocol=2026-07-28 client=claude-code/2.1.292 declares=['elicitation', 'roots']
  tools/list                 protocol=2026-07-28 client=claude-code/2.1.292 declares=['elicitation', 'roots']
  declared in full: {"elicitation": {"form": {}, "url": {}}, "roots": {"listChanged": true}}

7. Reading the log

You see in the logIt meansWhat to do
server/discover with protocol=2026-07-28, then list calls, no initializeA modern-era client talking to a modern server. Each request carries the declaration in its _metaRead the declaration from every request; keep no per-connection state for it
server/discover refused, then initialize with an older protocolA legacy client, or a modern one falling back (the SDK and Claude Code both do). The declaration is made once, in initializeStore it per connection at initialize. Do not log the refused server/discover as an error
declares has no samplingThe client will not answer sampling requestsMake sampling optional: offer a path that does not call the client’s model. If a request cannot be served without it, answer with -32021 in 2026-07-28
elicitation has form and urlBoth modes are allowed. An empty {} means form only (2025-11-25)Pick the mode from the declaration and never send one that is missing
roots is declared, but you see no roots/list callThe declaration says the client will answer. It does not ask by itselfAsk when you need it: with a request in the legacy era, inside inputRequests in 2026-07-28
Only tools/list was requestedNot proof that resources or prompts are unsupported: this may be a health check, or the host asks when the user doesCompare with the vendor’s page (section 3) and a real session
Requests after initialize carry no client name or capabilitiesIn the legacy era the declaration is made once per connectionCorrelate by connection, not by request

8. Failure modes

FailureWhy it happensFix
The server sends sampling or elicitation without checking the declarationThe client never declared it. Both specs forbid relying on undeclared capabilitiesCheck capabilities before each such request and branch; in 2026-07-28 an unmet need is a -32021 error
An empty cell is read as “not supported”The matrix records what a page says, and a page can be silent about something the product doesTreat empty as unknown and measure it with the probe
A copied matrix goes staleVendors change their pages and their productsDate every cell, keep the quote, and fetch the page again before you rely on it
A declared capability does not work in some modeThe vendor limits it: Claude Code serves elicitation “only in interactive terminal sessions” (c1)Read the limits on the vendor’s page; test the mode your users run
One revision is assumed per clientClients speak several: both clients in section 6 probe the modern revision first and fall backSupport both eras, or follow the compatibility matrix on the learning path
Sub-capabilities are ignoredsampling.tools, sampling.context and elicitation.url exist only in later revisionsTest the sub-field, not only the capability

9. Add or re-check a row

  1. Find the vendor’s own page for the feature. If it states the feature, copy the sentence exactly and note the URL and the date; if it does not, leave the cell empty.
  2. Run the probe against the client (section 5). Compare declares with what the page says about roots, sampling and elicitation. When they disagree, one of the two is wrong; trust the wire for what the client sends, and the vendor for what the product does with it.
  3. Write down the client’s version next to the measurement.
  4. Repeat steps 1 and 2 when the client upgrades, and when the page changes.

Versions and Verification

Vendor wording was fetched on 2026-10-26 from the pages listed in section 2. Spec wording and schemas come from one pinned commit of the official repository (dae694f6; revision 2026-07-28 and the four before it). The probe runs used Python 3.14 and mcp==2.3.0 on macOS, and the Claude Code CLI 2.1.292. Vendors change their pages and their products; every number here is dated, and section 9 says how to re-check one. An earlier version of this page answered the first question with a long list of ✅ and ❌ and named no source for any cell; this version keeps only what a vendor’s own page states and quotes it for every cell.

Sources