1. What did one flow cost under each revision?
I counted complete flows on loopback to see whether removing initialization and the session identifier reduced a short HTTP session, or moved bytes into request metadata and input retries.
Plain means tools/list followed by one tools/call. Input means one call whose handler asks the user one question. Ten means tools/list followed by ten calls in one session. The catalog contained three tools.
Every cross-revision row below is a raw message driver against Python SDK server (mcp 2.2.0). The main flows ran 20 times and the axis variations 5 times, 990 runs over 90 configurations. Every repeat was byte-identical. Each median therefore equals each single run.
HTTP heads include the request or status line and headers. Body columns exclude chunk framing.
I wrote this prediction down on 2026-09-30 at 00:14 UTC, five hours before the first counted run, and translated it for this post.
- P1: The plain flow has seven JSON-RPC messages under 2025-11-25 and four under 2026-07-28.
- P2: Each 2026-07-28 request carries 110 to 200 metadata bytes; the old handshake costs 400 to 900 body bytes once. A one-call flow should move fewer body bytes under the new revision.
- P3: After a request-count threshold between two and eight, 2026-07-28 should move more body bytes. A ten-call flow should be heavier.
- P4: Both input flows take four messages per call. The new flow should add 150 to 400 body bytes, with a larger gap as arguments grow.
- P5: New HTTP requests add 70 to 110 bytes of protocol headers each. HTTP framing should exceed half the old plain-flow total, while body differences stay small beside it.
- P6: Stdio adds one newline byte per message, leaving the message body to decide most of the total.
- P7: Fixed request IDs should produce identical bytes across 20 repeats, and any varying field should be found.
- P1 held: the plain flow counted seven JSON-RPC objects under 2025-11-25 and four under 2026-07-28.
- P2 failed: new HTTP request bodies carried 530 bytes against 353. Measured
_metaspans totaled 377 bytes across two POSTs. - P3 was about body bytes and failed: the new revision carried more body bytes from the first call. Its second half held: ten calls were heavier. On total HTTP bytes a linear estimate puts the crossover near call ten (section 6).
- P4 held for its range of 150 to 400: the new input flow added 270 body bytes, while its complete HTTP total was 791 bytes lower. I did not vary arguments in the input flow. Its last clause is untested.
- P5 is three claims. Per-request protocol headers were not counted separately. The first is untested. The second held: framing was 60 percent of the old plain total. The third held too: the body difference (177 request bytes) is small beside the framing. Under 2026-07-28 framing fell to 32 percent of the total.
- P6 held: stdio logs contain one newline byte per JSON-RPC message.
- P7 held: summary.tsv marks every repeated run byte-identical across 90 configurations and 990 runs.
| Flow | Revision | Server | JSON-RPC C→S / S→C | HTTP requests | Request head | Request body | Response head | Response body | Chunk bytes | SSE events | Total bytes |
|---|---|---|---|---|---|---|---|---|---|---|---|
| plain | 2025-11-25 | Python SDK server (mcp 2.2.0) | 4 / 3 | 4 | 821 | 353 | 990 | 866 | 34 | 3 | 3,064 |
| plain | 2026-07-28 | Python SDK server (mcp 2.2.0) | 2 / 2 | 2 | 418 | 530 | 252 | 865 | 0 | 0 | 2,065 |
| input | 2025-11-25 | Python SDK server (mcp 2.2.0) | 4 / 3 | 4 | 821 | 404 | 900 | 588 | 28 | 3 | 2,741 |
| input | 2026-07-28 | Python SDK server (mcp 2.2.0) | 2 / 2 | 2 | 436 | 675 | 252 | 587 | 0 | 0 | 1,950 |
| ten | 2025-11-25 | Python SDK server (mcp 2.2.0) | 13 / 12 | 13 | 2,855 | 1,211 | 3,420 | 1,904 | 133 | 12 | 9,523 |
| ten | 2026-07-28 | Python SDK server (mcp 2.2.0) | 11 / 11 | 11 | 2,371 | 3,088 | 1,386 | 2,721 | 0 | 0 | 9,566 |
The stdio rows below are also from raw driver runs against Python SDK server (mcp 2.2.0). Stdio has no HTTP heads or bodies; newline bytes count line endings in the JSON-RPC stream.
| Flow | Revision | JSON-RPC C→S / S→C | Newline bytes | Total bytes |
|---|---|---|---|---|
| plain | 2025-11-25 | 4 / 3 | 7 | 1,148 |
| plain | 2026-07-28 | 2 / 2 | 4 | 1,399 |
| input | 2025-11-25 | 4 / 3 | 7 | 921 |
| input | 2026-07-28 | 2 / 2 | 4 | 1,266 |
| ten | 2025-11-25 | 13 / 12 | 25 | 2,828 |
| ten | 2026-07-28 | 11 / 11 | 22 | 5,831 |
The call itself is four JSON-RPC messages under both revisions; the old end-to-end row shows seven because it includes initialize, its result, and notifications/initialized.
A short HTTP flow saved total bytes, while its request bodies grew.
2. What ran, and what did I leave out?
I measured package:dart_mcp at dart-lang/ai commit cdf9ad1cb50f148f8dd332eaea765e79e5d9b475. That checkout reports 0.6.0-wip; it is not a release. The latest pub.dev release is 0.5.2.
At this pin, package:dart_mcp serves 2025-11-25 over stdio and 2026-07-28 over Streamable HTTP. Its own rows cannot compare revisions over one transport.
I used Python SDK server (mcp 2.2.0) to fill those cells; every such table row is labeled.
I call each JSON-RPC object a message. An HTTP round trip is one request and its response, including a 202 response to a notification. It is a count, not elapsed time.
A byte is an application byte observed by the relay or stdio tap. HTTP totals include request and response lines, headers, bodies, and chunk framing.
The relay runs on loopback without TLS. Keepalive frames and retransmissions are excluded; I report no elapsed time.
The scripts, raw results in results/, and run logs are all in the published instrument folder.
The environment was captured from that checkout:
$ sw_vers
ProductName: macOS
ProductVersion: 26.3
BuildVersion: 25D125
$ uname -m
arm64
$ dart --version
Dart SDK version: 3.11.0 (stable) (Mon Feb 9 00:38:07 2026 -0800) on "macos_arm64"
$ venv/bin/python --version
Python 3.14.3
$ git -C ai rev-parse HEAD
cdf9ad1cb50f148f8dd332eaea765e79e5d9b475
$ grep ^version: ai/pkgs/dart_mcp/pubspec.yaml
version: 0.6.0-wip
$ venv/bin/python -c "import importlib.metadata as m; print(m.version(\"mcp\"))"
2.2.0
I contribute to package:dart_mcp and wrote the multi-version client and server used as the instrument (PR #676) and both ends of its Streamable HTTP transport (PR #572, PR #638).
The comparison is scoped to one pinned Dart package, one Python SDK server, and loopback traffic.
3. What does each revision require on the wire?
For 2025-11-25, the lifecycle specification requires initialization before normal operations:
The client MUST initiate this phase by sending an
initializerequest containing:
- Protocol version supported
- Client capabilities
- Client implementation information
After successful initialization, the client must send an initialized notification. For Streamable HTTP, a server may assign an MCP-Session-Id; if it does, later requests must include it. Lifecycle requirements at the pinned specification commit Initialized notification rule HTTP session rules at the pinned specification commit
For 2026-07-28, the versioning text says:
“There is no negotiation handshake. Every request carries its protocol version, and the server accepts or rejects each request independently:”
On Streamable HTTP, every POST includes MCP-Protocol-Version and Mcp-Method; tools/call also carries Mcp-Name. Request context travels in _meta. Pinned versioning text Pinned HTTP header rules
When a call needs input, the new revision returns input_required and the client retries. If the result contains requestState, the client must echo its exact value on that retry. Pinned retry rule
The protocol moves request context into each new-revision request and defines a retry for input.
4. Where did the plain-flow HTTP bytes go?
These exchanges are trimmed from the decoded logs; counts come from complete runs.
Python SDK server (mcp 2.2.0), 2025-11-25
client -> POST initialize
server -> HTTP 200, text/event-stream: initialize result
client -> POST notifications/initialized [MCP-Protocol-Version, Mcp-Session-Id]
server -> HTTP 202 Accepted
client -> POST tools/list [MCP-Protocol-Version, Mcp-Session-Id]
server -> HTTP 200, text/event-stream: tools/list result
client -> POST tools/call [MCP-Protocol-Version, Mcp-Session-Id]
server -> HTTP 200, text/event-stream: tools/call result
Python SDK server (mcp 2.2.0), 2026-07-28
client -> POST tools/list [MCP-Protocol-Version, Mcp-Method, _meta]
server -> HTTP 200, application/json: tools/list result
client -> POST tools/call [MCP-Protocol-Version, Mcp-Method, Mcp-Name, _meta]
server -> HTTP 200, application/json: tools/call result
For 2025-11-25, request heads, response heads, and chunk framing sum to 1,845 bytes of 3,064: (821 + 990 + 34) / 3,064, or 60 percent.
For 2026-07-28, they sum to 670 bytes of 2,065: (418 + 252 + 0) / 2,065, or 32 percent.
The new request bodies grew from 353 to 530 bytes. Response bodies were nearly equal, at 866 and 865 bytes. Four HTTP requests and three SSE events became two requests and no SSE events.
Two things moved those bytes. Two fewer requests took request heads from 821 to 418 bytes. Response heads fell from 990 to 252: two fewer responses, and this server answers 2025-11-25 calls with an SSE stream and 2026-07-28 calls with plain JSON. The older spec allows either answer. Part of that saving is a server choice.
The raw driver sends no DELETE when an old-revision session ends; the spec says clients SHOULD. A client that sends one adds a fifth request to the old rows. Under 2026-07-28 there is no session id and nothing to end with a DELETE. The pinned changelog removes protocol-level sessions
For one new-revision HTTP flow, the raw driver with the Dart server moved 2,211 bytes; the Python SDK server row moved 2,065. The package client row moved 2,394.
Its request head was 574 bytes against 418 for the raw driver, and its chunk framing was 46 bytes against 23.
On stdio, the Python SDK server plain flow moved 1,148 bytes under 2025-11-25 and 1,399 under 2026-07-28. There is no HTTP head layer there.
On this server, fewer requests and JSON answers instead of SSE covered the larger request bodies.
5. What happens when a tool asks for an answer?
Under 2025-11-25, the server sends elicitation/create on the SSE response opened by tools/call. The client posts its answer on a new request tied to the session. Under revision 2026-07-28, input_required comes back as JSON, and the client retries tools/call with its original arguments and inputResponses.
The traces and numbers in this section are the raw driver against Python SDK server (mcp 2.2.0). The tool here takes no requestState. The arrows in both images label body bytes; the totals also include HTTP lines and headers.
In logs for revision 2026-07-28, both POSTs are tools/call. The retry repeats the empty arguments and adds the accepted answer. The full HTTP input total fell from 2,741 to 1,950 bytes.
Request bodies alone rose from 404 to 675 bytes, a 271-byte increase. Combined request and response bodies rose by 270 bytes because response bodies fell from 588 to 587.
Under 2025-11-25, the flow sent three SSE events and used four HTTP requests. The new flow sent no SSE events and used two requests.
package:dart_mcp does the same job on both revisions in its own way. PR #654 routes server-to-client requests on older revisions, and PR #655 removed elicit, listRoots and createMessage from the server. A compatibility shim turns an input-required result into the older server request. On the client, PR #632 answers the input requests in an input_required result, and PR #637 lets handlers return one. None of the Dart code is in this section's numbers.
The retry enlarged request bodies while the complete input flow moved fewer HTTP bytes.
6. At what call count does the HTTP total cross?
Using Python SDK server (mcp 2.2.0) plain-flow totals, I estimated a fixed session cost and marginal bytes per added call from the measured one-call and ten-call endpoints. The fixed term includes tools/list and session setup.
| Revision | One-call total | Ten-call total | Fixed bytes per session | Marginal bytes per added call |
|---|---|---|---|---|
| 2025-11-25 | 3,064 | 9,523 | 2,346.3 | 717.7 |
| 2026-07-28 | 2,065 | 9,566 | 1,231.6 | 833.4 |
Only one call and ten calls were measured. If each added call costs the same, the totals meet at 9.63 calls: sessions of up to nine calls would be lighter under 2026-07-28. That middle part is an estimate. The measured ten-call totals were 9,566 against 9,523, 43 bytes heavier under the new revision.
Stdio goes the other way from the short HTTP result. At one call, the new revision moved 1,399 bytes against 1,148.
At ten calls, it moved 5,831 against 2,828. That gap is consistent with the per-request _meta: 377 bytes over both new-revision requests of the plain flow, 2,078 over eleven requests in the ten-call flow.
On HTTP, the short-flow lead narrows until the measured ten-call total is 43 bytes higher.
7. What does carried request state do to the retry?
This separate input-and-state flow uses the raw driver against Python SDK server (mcp 2.2.0). It calls a tool that does some work, asks for a confirmation, and carries a request state of the configured size. Its tool and arguments differ from section 5, which is why its zero-state totals (2,765 and 2,057) differ from the input rows there (2,741 and 1,950). The old revision has no requestState. Only the new rows grow with the state size.
| State bytes | Revision | Total bytes | Request body bytes | Response body bytes |
|---|---|---|---|---|
| 0 | 2025-11-25 | 2,765 | 429 | 586 |
| 0 | 2026-07-28 | 2,057 | 745 | 606 |
| 2,048 | 2025-11-25 | 2,771 | 432 | 589 |
| 2,048 | 2026-07-28 | 6,164 | 2,799 | 2,657 |
The specification treats requestState as opaque. When an input_required result contains it, the client must return the exact value with its retry. Pinned retry text
With no state, flow under revision 2026-07-28 moved 2,057 bytes against 2,765 under 2025-11-25. With 2,048 state bytes, it moved 6,164 against 2,771.
The response delivers state to the client, and the retry sends it back with the answer.
This is the Python SDK server's configured state representation, measured on loopback with this raw driver. It does not establish a universal encoding cost for requestState.
With a 2,048-byte state string on this server, the retry cost more than the saved setup.
8. How were the bytes counted?
The relay forwards socket data unchanged in both directions and logs each chunk as base64. It does not parse HTTP or add TLS.
#!/usr/bin/env python3
"""TCP relay: copies bytes unchanged in both directions, logs each chunk as one JSON line.
Usage: relay.py --listen HOST:PORT --upstream HOST:PORT --log FILE
"""
import argparse
import base64
import contextlib
import json
import selectors
import signal
import socket
import sys
def hostport(text):
host, _, port = text.rpartition(":")
return host, int(port)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--listen", required=True, type=hostport)
ap.add_argument("--upstream", required=True, type=hostport)
ap.add_argument("--log", required=True)
args = ap.parse_args()
signal.signal(signal.SIGTERM, signal.default_int_handler) # same path as Ctrl-C
sel = selectors.DefaultSelector()
server = socket.create_server(args.listen)
sel.register(server, selectors.EVENT_READ, None)
log = open(args.log, "w")
seq = conns = 0
open_dirs = {} # connection number -> directions still open
print("listening on %s:%d" % server.getsockname()[:2], file=sys.stderr, flush=True)
def record(conn, direction, **fields):
nonlocal seq
seq += 1 # one counter shared by all connections
log.write(json.dumps({"conn": conn, "seq": seq, "dir": direction, **fields}) + "\n")
log.flush()
try:
while True:
for key, _ in sel.select():
if key.data is None: # new client: open the matching upstream connection
client, _ = server.accept()
upstream = socket.create_connection(args.upstream)
conns += 1
open_dirs[conns] = 2
sel.register(client, selectors.EVENT_READ, (conns, "c2s", upstream))
sel.register(upstream, selectors.EVENT_READ, (conns, "s2c", client))
continue
conn, direction, peer = key.data
try:
data = key.fileobj.recv(65536)
except OSError:
data = b""
if data:
record(conn, direction, b64=base64.b64encode(data).decode())
with contextlib.suppress(OSError):
peer.sendall(data)
continue
record(conn, direction, eof=True)
sel.unregister(key.fileobj)
with contextlib.suppress(OSError):
peer.shutdown(socket.SHUT_WR) # pass the end of stream along
open_dirs[conn] -= 1
if open_dirs[conn] == 0:
key.fileobj.close()
peer.close()
except KeyboardInterrupt:
pass
finally:
log.close()
server.close()
for key in list(sel.get_map().values()):
key.fileobj.close()
if __name__ == "__main__":
main()
The HTTP counter reconstructs streams from logged chunks and separates heads, bodies, chunk framing, SSE events, and JSON-RPC messages. It reads logs without forwarding traffic or rewriting bytes. Published count.py
The stdio tap spawns a server child, copies stdin and stdout byte-for-byte, and logs chunks in the relay format. It leaves stderr inherited and does not inspect payloads or use sockets. Published tee_stdio.py
The stdio counter reconstructs both pipe streams and counts complete newline-terminated lines as JSON-RPC messages. It does not parse HTTP and measures _meta only on client-to-server lines.
#!/usr/bin/env python3
"""Summarise a stdio relay log (one JSON-RPC message per line) as the same TSV row as count.py.
Usage: count_stdio_log.py LOG [--label LABEL] [--header]
The log has the relay format; "conn" may be missing. Every chunk that ends with a newline
byte holds whole messages, and each newline byte is counted in newline_bytes.
"""
import argparse
from count import COLUMNS, emit, load_log, meta_bytes, rpc_count
def main():
ap = argparse.ArgumentParser(description="Summarise one stdio log as a TSV row.")
ap.add_argument("log")
ap.add_argument("--label", default="run")
ap.add_argument("--header", action="store_true", help="print the header line first")
args = ap.parse_args()
streams, total = load_log(args.log)
row = dict.fromkeys(COLUMNS, 0)
for (_, direction), buf in sorted(streams.items()):
lines = buf.split(b"\n")[:-1] # text after the last newline is not a message yet
row["newline_bytes"] += len(lines)
for line in lines:
row["jsonrpc_" + direction] += rpc_count(line)
if direction == "c2s":
row["meta_bytes_c2s"] += meta_bytes(line)
row.update(label=args.label, mcp_headers="none", total_bytes=total)
emit(row, args.header)
if __name__ == "__main__":
main()
The relay preserves application bytes; the counters classify the captured streams.
9. What did I catch before counting?
The matrix runner is Python, and it starts the Dart client with a state size. My first version built that argument as state_bytes or 256. Python treats zero as false. A zero-state run would have carried 256 state bytes under a label that said zero.
I caught it while writing the runner, before any counted run, and changed it to pass the configured integer as it is. The rows labeled 256 in the results are a separate, deliberate setting.
The runner error was fixed before it entered results.tsv.
10. What can these numbers not tell me?
Bytes are not tokens. I measured application bytes on loopback, without TLS or a model in the path. I did not measure elapsed time, retransmissions, keepalive frames, or IP packet bytes.
The cache-hint re-list was not measured.
I did not open the subscriptions/listen stream described by PR #636, or measure request-triggered notifications/message from PR #595. tools/list returned one page; PR #682 adds page walking.
A client helper for initializing a server across versions later landed on main in PR #690. I did not measure it.
The prior-work scan found two posts counting request-body bytes for one tools/call across revisions, both against 2025-06-18, and an SDK issue counting HTTP requests and TCP connections.
I could not find a counted whole-flow comparison against 2025-11-25 with message counts, HTTP round trips, request and response bytes, and header bytes from an SDK. Links welcome: rizz.dev, amitrix on dev.to, and the SDK issue.
A Hacker News discussion argues about protocol overhead. This notebook counts bytes for the stated flows on loopback.
These totals describe one pinned implementation, one server, and one transport at a time.
11. How do I reproduce one row?
Clone the published instrument at its pinned commit, then follow its README build and one-configuration run. I ran these steps from an empty directory before publishing; the full matrix reproduced all 1,800 median cells.
git clone --filter=blob:none --no-checkout https://github.com/Yusufihsangorgel/yusufihsangorgel.github.io site
git -C site sparse-checkout set mcp-count
git -C site checkout 83a170383621eb2f2db7e080c837585bb5604d69
cp -R site/mcp-count ~/mcp-count && cd ~/mcp-count
git clone https://github.com/dart-lang/ai ai
git -C ai checkout cdf9ad1cb50f148f8dd332eaea765e79e5d9b475
(cd ai/pkgs/dart_mcp && dart pub get)
(cd dart && dart pub get && dart analyze)
mkdir -p build
for n in server client_stdio client_http; do (cd dart && dart compile exe bin/$n.dart -o ../build/$n); done
uv venv --python 3.14 venv
uv pip install --python venv/bin/python -r pysdk/requirements.txt
python3 run_matrix.py --dart-build build --py venv/bin/python --py-server pysdk/server.py --out out-one \
--only pkg-dart-http-2026-07-28-plain-cion-none-a0-s0-c3
python3 tools/median.py out-one/results.tsv | awk -F '\t' '$2 == "total_bytes"'
Expected output from results/medians.tsv:
pkg-dart-http-2026-07-28-plain-cion-none-a0-s0-c3 total_bytes 2394 2394 2394
The pinned one-configuration command regenerates the package HTTP total_bytes row.
12. What should another SDK measure?
If you maintain another MCP SDK, run plain, input, and ten-call flows on every transport it supports. Please share the server and revision with the same message, request, header, body, framing, event, and total columns.
Response format and per-request metadata both moved these totals; another server may answer differently.


Top comments (0)