Can you migrate to material_ui yet?
What changed, and why
For years, package:flutter/material.dart came with the SDK. You installed Flutter, and Material came with it, same version, same release schedule, no decision to make.
That is changing. Since Flutter 3.47, Material and Cupertino also exist as two packages on pub.dev,material_ui and [cupertino_ui] (pub.dev), published by flutter.dev and versioned on their own. The SDK still ships its own copy for now, but that copy is frozen.
The three architectural layers are untouched. What moved is the top of the framework layer.
!Flutter architecture before the split: Material and Cupertino sit inside the framework layer, above Widgets
!Flutter architecture after the split: material_ui and cupertino_ui are packages above the framework, whose top layer is now Widgets
Material and Cupertino used to sit inside the framework, on top of widgets.
You can now use them above it, as ordinary packages you add to pubspec.yaml.
Why now
Three reasons, all from the announcement by Craig Labenz on 9 September 2026.
1. Upgrading Flutter meant redoing your UI
Material shipped inside the SDK. So a Flutter upgrade, even one you wanted only for a security patch, brought Material changes with it. You could not take one without the other.
The team is blunt about what that cost:
> *a major source of developer friction that kept many teams on older Flutter
> releases*
and
> *the task could consume multiple weeks of developer effort and bottleneck
> entire repositories*
Teams stayed on old Flutter versions on purpose, because moving up meant weeks of fixing screens. Staying behind also meant missing the security patches they wanted in the first place.
2. A fix could wait sixteen weeks
Flutter ships a stable release every three months, and Material shipped with it.
So a Material fix had to catch the release cutoff. The post gives the worst case:
> *landing code one minute after the stable release cutoff can introduce a 16
> week delay until that change sees the light of a stable release*
One minute late meant sixteen weeks of waiting.
As separate packages, material_ui and cupertino_ui are planned to release weekly. And the post says work is already underway on official implementations of Liquid Glass and Material 3 Expressive, Apple's and Google's current design languages.
3. Material was keeping the useful parts to itself
This reason is less obvious than the other two, but its effect lasts the longest.
Flutter's core is the widgets library. Material is built on top of it.
When the Material team needed something the core did not have, they added it to Material. That was faster, and it worked. But only Material got it.
> *it proved too tempting to build foundational functionality in those design
> libraries*
Other design systems exist. The post names seven: macos_ui, fluent_ui,yaru, arna, chicago, VSP, shadcn. They needed some of the same pieces, and could not reuse Material's.
Now that Material is a package like the others, the missing pieces have to go into the core instead. The official migration guide puts it this way:
> *Decoupling lays the groundwork for a style-neutral Flutter core widget
> catalog, making it easier to build and maintain custom design systems.*
In plain terms: make the core work for any design system, not just Material and Cupertino.
The team's own description of the old arrangement is the clearest line in the post. Material and Cupertino had started to feel:
> more soldered-on than just included
And for years, improving this system was *"a top request from Flutter
developers"*.
---
What you actually get
Version 1.0.0 of material_ui was a copy of what was already in the SDK. The announcement says so directly:
> *released exact replicas on pub.dev as the 1.0.0 versions of
> pkg:material_ui and pkg:cupertino_ui*
It had the same widgets, with the same names and the same behaviour. Migrating is not meant to change how your app looks.
Here is how the team describes the move:
> *Our expectation is that this will be a drop-in maneuver, but please file an
> issue if you encounter any trouble.*
Note the word "expectation". We will come back to it.
What changes in your code
One line per file:
// before
import 'package:flutter/material.dart';
// after
import 'package:material_ui/material_ui.dart';
For most files, that is the whole change. Scaffold, AppBar, TextField,
ThemeData all keep their names.
Are they really shipping weekly?
Speed was the reason for the split. So it is worth checking whether the speed arrived. Counting from pub.dev on 8 October 2026:
| Package | Latest | Releases after 1.0.0 | One every |
|---|---|---|---|
| material_ui | 1.6.0 | 7, over 55 days | 8 days |
| cupertino_ui | 1.1.2 | 4, over 55 days | 14 days |
material_ui is shipping close to the weekly pace it promised, and a long way from a three-month cycle. cupertino_ui moves more slowly.
So on paper, migrating is one import line and nothing else. The rest of this article is what happened when I ran it.
---
The migration, step by step
The app under test
A small app with no third-party UI package. If something breaks, the migration is the only suspect.
It contains the pieces the migration touches: Material and Cupertino widgets, translations generated from .arb files, a language switcher, a barrel file that exports both design libraries, and two widgets that take a ColorScheme or a CupertinoThemeData as a constructor argument. Ten widget tests cover it. All ten pass before we start.
The code is in the repository,
with a tag for each state: first-app-without-migration, after-dart-fix and after-localizations-fix. Every command output quoted below is saved in its measurements/ folder.
Step 1: ask before you apply
dart fix --dry-run --code=migrate_design_widgets
17 proposed fixes in 14 files.
--dry-run changes nothing. It only lists what the tool plans to do. Sixteen fixes come from the migration rule. One comes from a second rule, missing_dependency, which targets pubspec.yaml.
Step 2: apply
dart fix --apply --code=migrate_design_widgets
17 fixes made in 14 files.
The same seventeen. Every change in the Dart files is an import line, like this one:
-import 'package:flutter/material.dart';
+import 'package:material_ui/material_ui.dart';
No widget, no constructor, no type was touched.
The pubspec.yaml was updated too, with any as the version:
+ cupertino_ui: any
+ material_ui: any
any accepts every version, including a future one that breaks your app. I replaced it with real constraints:
cupertino_ui: ^1.1.2
material_ui: ^1.6.0
What the tool did not touch
export directives. The barrel file is not in the diff:
// lib/core/ui.dart, unchanged after the migration
export 'package:flutter/cupertino.dart' hide RefreshCallback;
export 'package:flutter/material.dart';
The tool rewrites import lines, not export lines. This was reported in dart-lang/sdk#63968. The issue is marked as closed, but with Dart 3.13.5 the exports were still left as they were. The next part, Where it stops working, shows what this costs.
The localization import. lib/app/app.dart had two SDK imports:
material.dart and flutter_localizations.dart. Only the first one was rewritten. That is where the only error comes from.
The only error
This is the top of app.dart after the tool:
import 'package:material_ui/material_ui.dart'; // rewritten
import 'package:flutter_localizations/flutter_localizations.dart'; // not touched
Both libraries define a class called GlobalMaterialLocalizations.
flutter_localizations still has the old one, and material_ui brought its own copy of the Material translations. The file imports both, so Dart cannot tell which one you mean:
error - lib/app/app.dart:66:9 - The name 'GlobalMaterialLocalizations' is defined in the libraries
'package:flutter_localizations/src/material_localizations.dart (via ...)' and 'package:material_ui/src/global_material_localizations.dart (via ...)' - ambiguous_import
A second error follows on the same line, non_constant_list_element. It disappears with the first one.
The fix, in two steps.
1. Remove the flutter_localizations import from app.dart.
2. Replace the three old delegates with the list from material_ui:
// before
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
// after
localizationsDelegates: <LocalizationsDelegate<dynamic>>[
AppLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
],
GlobalMaterialLocalizations.delegates, with an s, is a list. The migration guide says it contains the Material, Cupertino and Widgets delegates, so the three old lines become one. The ... spreads that list into yours. The app's French tests confirm it: Material and Cupertino texts are translated.
AppLocalizations.delegate stays first. It carries your own app's texts, generated from your .arb files. The guide's example does not show this line, because its example app has no texts of its own. If yours does, keep it.
One more point if you use .arb files: keep flutter_localizations in your pubspec.yaml. Your own code no longer imports it, but the file that gen-l10n generates still does. Without the line, the app still builds, because material_ui brings the package along. But your project would then use a package it never asked for, and the lint that would warn you is switched off in generated files.
So keep it declared in pubspec.yaml, and never import it again in your own files. The import is what caused the error, not the declaration.
Did anything break?
Analyzing material_ui_migration... No issues found!
00:00 +10: All tests passed!
I also ran the same ten tests on the code from before the migration, in a separate git worktree, with the test imports switched back. Ten green there as well.
So: ten tests green before, ten green after. Seventeen automatic fixes, and one manual fix to make it compile. For an app without third-party UI packages, the documented path works.
The second pass
The guide says to run dart fix a second time to sort the imports. They do need it, because the migration breaks the alphabetical order:
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
But the second pass returned Nothing to fix!. Import order is checked by the lint rule directives_ordering, which is not part of flutter_lints. Without that rule, dart fix does not sort your imports.
Your dependencies stay the same
flutter pub outdated prints the same list before and after the migration. For example, material_color_utilities stays at 0.13.0 when 0.13.1 exists. In Flutter 3.47 the old Material is still inside the SDK, and the SDK holds this version. Migrating does not change that.
---
That is the good news. Here are the three places where it goes wrong:
a file left on the old import, an export the tool skipped, and the translation list that gen-l10n generates for you.
---
Where it stops working
The migration guide has a tool for apps that cannot migrate everything at once:
the compatibility bridge. You add it in MaterialApp.builder:
builder: (BuildContext context, Widget? child) {
return MaterialUiCompatibilityBridge(child: child!);
},
dart fix does not add it for you. The guide describes what it does:
> *This allows unmigrated child widgets and dependencies that read design state
> from context (such as Theme.of(context) or MaterialLocalizations.of(context)) to continue functioning properly.*
and what it cannot do:
> *the compatibility bridge cannot resolve type mismatches when a dependency
> exposes, accepts, or returns in-framework SDK types in its public API
> signatures (such as passing a FloatingActionButtonLocation, ColorScheme,
> or TextTheme as a parameter or callback return value).*
One thing the guide does not say: in material_ui 1.6.0, the bridge is already marked @Deprecated, as "a temporary migration utility" that "will be removed in a future release". The changelog does not mention it either. Only the analyzer warns you, when you add it. So whatever it fixes below, it fixes for now.
I broke the migrated app in three ways, on purpose, and checked whether the bridge helps.
1. A file left on the old import
This happens when one file stays on the old import, and with packages that have not migrated yet. To reproduce it, I put one file back on its old import:
git checkout first-app-without-migration -- lib/features/legacy/legacy_summary.dart
That file reads the theme and passes its ColorScheme to StatCard, a widget that is now migrated:
The argument type 'ColorScheme (where ColorScheme is defined in .../flutter/lib/src/material/color_scheme.dart)'
can't be assigned to the parameter type 'ColorScheme (where ColorScheme is defined in .../material_ui-1.6.0/lib/src/color_scheme.dart)'.
"ColorScheme can't be assigned to ColorScheme." Same name, two classes. The compiler has to show the file paths, because the name no longer tells them apart.
The bridge cannot help here, and the guide says so: it works through the context, and this value travels as a constructor argument. The only fix is to migrate the file. At least the compiler tells you.
2. An export the tool skipped
After the migration, the app's theme belongs to material_ui. But the barrel file, lib/core/ui.dart, still exports the old Material from the SDK. And one widget, Section, imports that barrel.
So when Section asks for the theme with Theme.of(context), it asks the old Material. The old Material cannot see the app's theme, because that theme now belongs to material_ui. Instead of failing, it gives back its own default theme.
No error, no warning, and all ten tests still pass. A test measured the colors:
| | Primary color | Title color |
|---|---|---|
| the app's theme | #465D91, my blue | #1A1B20 |
| what Section gets | #6750A4, the default purple | #1D1B20 |
The test also checked the color on screen: Section painted its title with the default one, #1D1B20.
In this app you cannot see the difference: Section only uses the title color, and the two are almost identical. But any widget behind the barrel that uses colorScheme.primary would show the default purple instead of your blue.
With the bridge: it works. The old Material now gets the app's colors, #465D91 and #1A1B20.
But it only fixes part of the theme. Its source (lib/src/migration_utility.dart) builds the old ThemeData from four values only: platform, visualDensity, colorScheme and textTheme. Everything else keeps the old Material's defaults. Component themes, like inputDecorationTheme, and theme extensions are not passed on.
The fix: migrate the two export lines by hand.
export 'package:cupertino_ui/cupertino_ui.dart' hide RefreshCallback;
export 'package:material_ui/material_ui.dart';
After that, dart analyze is clean and Section paints its title with the app's color, #1A1B20. No bridge needed. After running dart fix, search your project for export 'package:flutter/: the tool will not do it for you.
3. The translation list gen-l10n writes for you
gen-l10n generates a ready-made list of delegates in lib/l10n/app_localizations.dart. After the migration, it still writes the old ones:
import 'package:flutter_localizations/flutter_localizations.dart';
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
Those three come from flutter_localizations, the SDK. Passing this list straight to MaterialApp is a common shortcut: Zulip and kitchenowl both used it. So I did the same:
localizationsDelegates: AppLocalizations.localizationsDelegates,
It compiles. Eight tests pass, three fail, and the three that fail are the three that run in French. The app registers French texts for the SDK's Material widgets, but its widgets now come from material_ui, and they look for material_ui's texts. In English the app falls back on built-in English texts. In French, there is nothing to fall back on.
The messages point the wrong way:
- A MaterialLocalizations delegate that supports the fr locale was not found.
There is one. It is the SDK's.
No MaterialLocalizations found.
To introduce a MaterialLocalizations, either use a MaterialApp at the root of your application...
There is a MaterialApp. Neither message can say what is wrong, which is that two classes are called MaterialLocalizations.
It breaks in the TextField on the first screen, the same widget as in the kitchenowl report. My tests run in debug mode, where an assertion catches it. The kitchenowl report says that release builds only showed a generic null-check crash. I did not measure release mode.
With the bridge: no change. The same three French tests fail, with the same messages. The bridge registers the old flutter_localizations delegates, for widgets that still use the old Material. The migrated widgets need the new ones.
> Does your app speak more than one language?
>
> 1. Search your code: grep -rn "localizationsDelegates" lib/. If you see
> AppLocalizations.localizationsDelegates, you are affected.
> 2. Run the app with locale: const Locale('fr') (or any language other than
> English) in your MaterialApp. An English-only run will not show the bug.
> 3. Replace the generated list with:
> [AppLocalizations.delegate, ...GlobalMaterialLocalizations.delegates]. If that
> file still imports flutter_localizations, remove the import.
> 4. Never fix the generated file by hand. It is rewritten every time you add a
> text to your .arb files.
> 5. The compatibility bridge does not fix this one.
---
So, can you migrate yet?
For an app like the one tested here, with no third-party UI package: yes. Remember the word "expectation": the team expected "a drop-in maneuver", and for this app it almost is. Seventeen automatic fixes, one manual fix to make it compile, and the same ten tests green before and after. Almost, because of the three things below.
But dart fix does not finish the job. After running it, check three things yourself:
1. Your export lines. The tool skips them. A forgotten one gives no error and brings back the default theme without any warning.
2. Your translation list, if your app speaks more than English. The list that gen-l10n generates still points to the old Material, and it breaks every other language.
3. Your pubspec.yaml. Replace any with real version constraints. The compatibility bridge will not do this for you. It fixes the theme read through Theme.of(context), but not types passed as arguments, and not the generated translations. And it is already deprecated.
Should you migrate now, or wait? The SDK copy of Material is frozen. The package is not: it has released a new version about every week since August, with fixes the SDK copy will not get, like the showAboutDialog crash on the web (changelog). And the
Flutter 3.47 announcement schedules the old libraries for deprecation in the
November stable release. If you wait, you will migrate anyway, only closer to that deadline.
Which leaves the real question for most apps: can you migrate if your app depends on packages that have not migrated yet?
The first case above shows what such a package looks like from your side. If it passes a ColorScheme or a ThemeData through its API, the compiler refuses, and the bridge cannot help. The guide asks package authors to treat the migration as a major version bump. In one real app, only one of the eighteen dependencies that import Material had migrated when its team started (flutter_server_box#1591).
That is the next article. I will add real packages to this app and sort them: the ones that migrate on their own, the ones the bridge saves, and the ones that break.
3 comments
I counted one MCP server on the wire: without the handshake, HTTP stays lighter until call ten
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.
> 1. P1: The plain flow has seven JSON-RPC messages under 2025-11-25 and four under 2026-07-28.
> 2. 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.
> 3. 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.
> 4. 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.
> 5. 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.
> 6. P6: Stdio adds one newline byte per message, leaving the message body to decide most of the total.
> 7. 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 _meta spans 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 initialize request 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.
!1. client to server: initialize · POST · 183 B; 2. server to client: result · 200 · SSE 216 B; 3. client to server: notifications/initialized · POST · 54 B; 4. server to client: 202 · 0 B; 5. client to server: tools/call · POST · 87 B; 6. server to client: elicitation/create · 200 · SSE 244 B; 7. client to server: result · POST · 80 B; 8. server to client: 202 · 0 B; 9. server to client: result · 200 · SSE 128 B
!1. client to server: tools/call · POST · 301 B; 2. server to client: input_required · 200 · 368 B; 3. client to server: tools/call · POST · 374 B; 4. server to client: result · 200 · 219 B
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.
Mentions
@1440000bytes @cakewallet Btw cake's Android/Windows/Linux build is vulnerable & affected by TLS MitM github.com/dart-lang/sdk/security/advisories/GHSA…
Flutter 3.47.8 fixes a TLS flaw on Android and Windows Compromised eMudhra CA certificates could let attackers intercept HTTPS traffic from Flutter/Dart apps. Upgrade, rebuild and redeploy. Source: Dart SDK advisory github.com/dart-lang/sdk/security/advisories/GHSA… #Flutter
Flutter 3.47.8 tambal celah TLS di Android & Windows Sertifikat CA eMudhra yang bocor bisa dipakai untuk menyadap koneksi HTTPS aplikasi Flutter/Dart. Upgrade, build ulang, lalu rilis ulang aplikasinya. Sumber: Dart SDK advisory github.com/dart-lang/sdk/security/advisories/GHSA… #Flutter
TLS MitM vulnerability on Android, Windows, and Linux due to compromised eMudhra intermediate CA certificates Hi A security vulnerability affects Dart and Flutter applications that establish TLS/HTTPS connections on Android, Windows, and certain Linux environments. Compromised eMudhra intermediate certificate authorities could allow an attacker intercepting network traffic to generate fake certificates for legitimate domains. Versions earlier than Dart 3.13.7 and Flutter 3.47.8 are affected, including certain beta versions. Web, iOS, and macOS applications are not affected according to the published advisory. It is recommended to update the SDK, recompile the applications, and redeploy the affected binaries. the full security advisory
dart:ioと端末のネットワーク設定 はじめに 2026年10月、eMudhraが発行した中間CA証明書が、鍵生成時の乱数の弱さによって侵害されていたことが公表されました。Dart SDKにも、この件のセキュリティアドバイザリが出ています。 github.com/dart-lang/sdk/security/advisories/GHSA… アドバイザリによると、AndroidとWindowsの dart:io は、SDKに組み込まれたBoringSSLで証明書チェーンを検証しており、OSが管理する中間証明書のブロックリストを読みません。そのため対処には、修正済みのSDK(Dart 3.13.7 / Flutter 3.47.8)でアプリを再ビルドし、配布し直す必要があります。一方で、iOSとmacOSは、AppleのSecurity.frameworkで検証しているため影響を受けません。Android端末には、Google Play Services経由で同等の更新が配信されていますが、dart:io で通信するアプリはその更新を使えません。 設計がそうなっているから、といえばそうなのですが、直感に反します。AndroidでもiOSのように、OSで管理される証明書を利用できないのでしょうか。 一部で知られているように、端末のproxy設定が反映されない問題があります。気づくタイミングが切羽詰まっている状況になることも多く、知っている方は若干辛い記憶なのではないでしょうか? flutter#26359 これも不思議です。Android端末上から通信をしているのに、その端末に設定されたproxyが反映されません。AndroidのNative SDKを使って開発していた頃には、全く意識する必要がなかったのに、です。 そして、結構厄介です。開発者の手元だと問題が起きないが、QAメンバーからの問い合わせで発覚したり。ユーザーから、特定環境で利用できない報告で発覚したりします。どうして。 なにが原因なのか dart:ioにおける、以下の特徴が原因です。 socketからTLSまで、ネットワーク処理をDart SDKの中に自前で実装している dart:io の実装からはJNIを呼べないため、AndroidのJava API(proxyの設定や TrustManager)に手が届かない proxyは、http_proxy などの環境変数しか見ず、OSの設定画面で指定したproxyを読まない Androidでは、ルート証明書を端末のディレクトリから直接読み、証明書の検証もSDK内蔵のBoringSSLで行う つまりAndroidでは、自前の実装がOSの仕組みを素通りするので、OSのネットワーク設定が効きません。それなら、まあ、しょうがないか…。 proxy、ルート証明書、証明書の検証 Security advisoriesにもある通り、今回の問題はAndroidとWindowsで発生し、iOS・macOSでは発生していません。これはiOS・macOSの dart:io が、証明書の検証をOSのSecurity.frameworkに任せていることに起因しています。 以下、よくわかっていなかったのでOpusにコードを読ませながら確認してみました。 proxy: 環境変数しか見ない (AndroidとiOS) HttpClient には、接続先のURLを受け取って、使うproxyを文字列で返す findProxy という関数を設定できます。 api.flutter.dev 返すのは、"PROXY host:port"(proxyを使う)か "DIRECT"(直接つなぐ)です。 この関数のデフォルトの findProxyFromEnvironment が見るのは、http_proxy・https_proxy・no_proxy という環境変数だけです。ターミナルで export http_proxy=... と設定する、あの環境変数です。この処理は、AndroidとiOSで共通です。 github.com/dart-lang/sdk/blob/3.13.7/sdk/lib/_htt… 本来、AndroidのWi-Fi設定やiOSの設定アプリで指定したproxyは、環境変数としては渡されません。このため、OSのAPIを経由して取得する必要があります。結果、dart:io においては、proxyの検知が働かないようです。 http#1414 sdk#41376 自前で実装しようとしても、ちょっとした壁があります。findProxy は同期関数です。AIに調べてもらったのですが、AndroidはPAC(Proxy Auto-Config)を同期処理で実装できそうです。しかし、iOS・macOSでPACを評価するAPIは、非同期になっています。結果、両OSをサポートする実装が難しくなっています。1 ルート証明書: ディレクトリを直接読む (Android) Android向けのTLS処理は、Linuxと同じ security_context_linux.cc の中にあります。証明書は、端末のディレクトリから直接読みます。2 github.com/dart-lang/sdk/blob/3.13.7/runtime/bin/… OSの設定を通らないので、こんな問題が出ています。 dart-lang/sdk#48056: 設定で無効にしたCA証明書を信頼し続ける dart-lang/sdk#50435: ユーザーが入れたCA証明書を信頼しない Android 14からはルート証明書が、Google Play system updatesで更新されるConscryptのAPEXモジュールに移りました。Dart 3.13.7から /apex/com.android.conscrypt/cacerts を読むようになるそうなので、この問題は徐々に解消していくと思われます。3 証明書の検証: OSのTrustManagerを通らない (Android) 冒頭のeMudhraに関する諸問題の、根本がここです。検証をSDKに内蔵するBoringSSLが行なっているため、Androidの TrustManager を利用しません。 結果、OSが配る中間証明書のブロックリストが効かない、という事態になったようです。Flutter 3.47.8(Dart 3.13.7)での修正は、次の2つです。 dart/568000: Dartに内蔵するBoringSSLを更新し、侵害されたeMudhraの中間CA証明書をブロックする dart/64696: Android 14以降で、更新可能なConscrypt APEXのルート証明書ストアを使う 中間証明書のブロックリストは、アプリに組み込まれたBoringSSLの中にあります。ルート証明書はOSの更新に追従するようになりましたが、中間証明書は追従しません。もちろん中間証明書のブロックリストが頻繁に更新されるなんてことはない、はずです。ただ、この違いは把握しておく必要があると思われます。 一方で、cronet_httpを使っていた場合は、証明書の検証をAndroidの TrustManager が行います(後述の補足を参照)。Android端末にはGoogle Play Services経由で同等の更新が配信されているので、Cronetで通信していたアプリは、SDKの更新やアプリの再ビルドを待たずに守られていたことになります。ただし、Google Play Servicesがない端末など、OSの更新を受け取れない端末は対象外です。 proxyの深掘り 証明書については、改善された点がある一方で、一層の覚悟も求められることになりました。では、proxyはどうでしょうか? sdk#50434 proxyの問題は、P3のIssueで追跡されています。issueのコメントを確認すると、AndroidではJNIを呼ぶしかない、という点が問題のようです。 We could also solve this issue in HttpClient. This would actually be fairly easy on iOS but harder on Android (AFAIK, the only way would be to make JNI calls to get the proxy configuration). sdk#50434 There is currently not a good way to make JNI calls from the dart:io implementation. flutter#20376 Android OSがJavaやKotlin向けに提供しているAPIなので、FFIではなくJNIが必要になり難しい、ということと筆者は理解しています。これはffigenとjnigenの安定度の違いを見たことがある方なら、まあそうだろうなって印象になると思います。 というわけで、何かしらの回避策が必要です。 かつての回避策: system_proxy かつて、Flutterのアプリを手探りで実装していた頃には、system_proxy などでOSのproxy設定を取ってくるのが典型的な対応でした。main() でなんか書いたことがあるな? って方は、多分これです。 github.com/kaivean/system_proxy/blob/0ad8536e39e3… ただ、このパッケージがやるのはproxyの取得だけで、使い方はアプリ任せです。 proxyを通すと、今度はTLSが問題になります。CharlesやTLSインスペクションのように、proxyがHTTPSを中継する環境では、proxyの証明書を信頼しないと通信できません。でも dart:io は、ユーザーが入れた証明書を信頼しません。そこで、こういうコードが書かれがちです。 HttpClient() ..findProxy = (uri) => 'PROXY $host:$port' ..badCertificateCallback = (cert, host, port) => true; badCertificateCallback が true を返すと、信頼できない証明書でも接続します。つまり、どんな証明書でも通るので、中間者攻撃に無防備になります。もちろん、適切に実装をすれば問題はありません。4 ただ、本来OSが適切に管理するべき通信が、アプリ実装者の手に委ねられてしまうのは危険です。2026年も終わろうとしている現在、そして、あれこれセキュリティに注目が集まる現在では推奨されないと感じています。 cronet_httpとcupertino_http OSが管理するproxyや証明書を使うのであれば、多くのAndroidやiOSのネイティブアプリと同じ通信経路を辿ればOKです。と書くとMethodChannelでリクエストを送ることをイメージするかもしれません。大丈夫です。Dartチームが用意しています。 パッケージ プラットフォーム 中身 cronet_http Android Cronet(Chromiumのネットワークスタック) cupertino_http iOS / macOS Foundation URL Loading System(URLSession) どちらのREADMEも、dart:io に対する利点の1つ目にproxy対応を挙げています。 cronet_http: "It automatically supports Android platform features such as HTTP proxies." cupertino_http: "It automatically supports iOS/macOS platform features such as VPNs and HTTP proxies." Charlesに通信が出ないという flutter/flutter#20376 も、iOSでは cupertino_http が使えるよ、というコメントでcloseされています。また、特にAndroidであれば、 cronet_http を使うことで証明書の問題も回避することができます。OkHttp版の開発も進んでいるので、この機会にぜひ検討してみてください。 補足 Opusに調べてもらった、それぞれのツールでproxyと証明書がどう扱われるかになります。本題からズレるので、気になる方はどうぞ。 Cronet(cronet_http)は、Androidのproxyと証明書をどう扱っているか proxy Cronetは初期化のときに、CreateProxyConfigService でproxyの設定の取得元を作ります。ここで呼ぶ CreateSystemProxyConfigService は、Androidでは ProxyConfigServiceAndroid を返します。 github.com/chromium/chromium/blob/55adf20e5753fc9… ProxyConfigServiceAndroid は、JNIでJavaの System.getProperty を呼び、次のシステムプロパティからproxyの設定を組み立てます(proxy_config_service_android.cc#L71-L173)。コード中のコメントによると、Javaの標準の ProxySelector とほぼ同じ読み方です。 http.proxyHost / http.proxyPort、https.proxyHost / https.proxyPort(なければ proxyHost / proxyPort) socksProxyHost / socksProxyPort http.nonProxyHosts など(proxyを使わないホストの一覧) 設定の変更は、Java側の ProxyChangeListener で受け取ります。PACが設定されている場合、CronetはPACのURLを使わず(set_exclude_pac_url(true))、Androidが用意するローカルのHTTP proxyに任せます(crbug.com/432539)。そのため、PACを評価する仕組み(proxy resolver)自体を持ちません(CreateWithoutProxyResolver)。 証明書の検証 Android向けのChromiumは、Chrome Root Storeを使う独自の検証器と、OSの検証器を選べます。ただし、Cronetのビルドでは chrome_root_store_optional = is_android && !is_cronet_build となり、この選択肢がありません。 github.com/chromium/chromium/blob/5691202e214341a… そのため CreateSystemVerifyProc が使われ(cert_verifier.cc#L30-L64)、Androidでは CertVerifyProcAndroid になります(cert_verify_proc.cc#L378-L379)。CertVerifyProcAndroid はJNIでJavaの AndroidNetworkLibrary.verifyServerCertificates を呼び(cert_verify_proc_android.cc#L100、AndroidNetworkLibrary.java#L120-L127)、最後は X509Util が、OSの TrustManagerFactory のデフォルトのトラストマネージャーで checkServerTrusted を呼びます。 github.com/chromium/chromium/blob/195962358a83d3d… ルート証明書の選び方も、チェーンの検証も、Androidの仕組みに任されているわけです。 なお、ここで読んでいるのはChromiumのソース(Cronetを同梱する場合のもの)です。Google Play Servicesが提供するCronetも同じ作りだと考えられますが、そのソースは公開されていません。 URLSession(cupertino_http)は、iOSのproxyと証明書をどう扱っているか proxy URLSessionConfiguration の connectionProxyDictionary は、デフォルトが NULL で、その場合はシステムの設定が使われます。 The default value is NULL, which means that tasks use the default system settings. developer.apple.com cupertino_httpは、connectionProxyDictionary を設定する公開APIを持たず、内部でも設定していません。そのため、defaultSessionConfiguration() などから作った CupertinoClient は、システムのproxy設定に従います。 証明書の検証 cupertino_httpは、認証チャレンジを受け取るdelegate(URLSession:task:didReceiveChallenge:completionHandler:)を実装していません。この場合、URLSessionはデフォルトの処理を行います(NSURLSession.h のコメント)。サーバー証明書の検証に、アプリ側の判定は入りません。 httpパッケージとdioでの差し替え 筆者がネイティブのHTTPクライアント推しなので、簡単ではありますが、置き換え方法を紹介します。 httpパッケージ package:http の Client() は、Web以外では IOClient、つまり dart:io の HttpClient を使います。何もしなければ、同じ問題を抱えたままです。 github.com/dart-lang/http/blob/http-v1.5.0/pkgs/h… READMEの "Choosing an implementation" のとおり、プラットフォームごとに Client を差し替えます。 import 'package:cronet_http/cronet_http.dart'; import 'package:cupertino_http/cupertino_http.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; import 'package:http/io_client.dart'; Client createHttpClient() { switch (defaultTargetPlatform) { case TargetPlatform.android: final engine = CronetEngine.build(); return CronetClient.fromCronetEngine(engine, closeEngine: true); case TargetPlatform.iOS || TargetPlatform.macOS: final config = URLSessionConfiguration.defaultSessionConfiguration(); return CupertinoClient.fromSessionConfiguration(config); case TargetPlatform.fuchsia || TargetPlatform.linux || TargetPlatform.windows: return IOClient(); } } あとは Client をRiverpodのProviderで配布する、などすればOKです。もしも http.get() や Client() を直接呼んでいるところがあり、置き換えが難しいならば、runWithClient で包みます。ただし、Flutterではコールバックが同じZoneで実行される保証がないため、包んだ範囲でも IOClient に戻ることがあります。 pub.dev dio dioのデフォルトも、dart:io を使う IOHttpClientAdapter です。native_dio_adapter を使うと、cronet_http と cupertino_http に切り替えることができます。 final dio = Dio()..httpClientAdapter = NativeAdapter(); Androidでは CronetAdapter、iOS・macOSでは CupertinoAdapter、それ以外では従来どおり dart:io を使います。5 注意点 見逃しがちな点がいくつかあります。 観点 何が変わるか 見直すこと 画像の読み込み Image.network は dart:io のまま Client を渡せるパッケージに置き換える User-Agent クライアントごとにデフォルトの値が違う すべてのクライアントで明示的に指定する 例外 SocketException で捕まえられなくなる。dioではエラーの種類が変わる ClientException で捕まえる。DioException.type で分けている処理を見直す 画像の読み込み Flutter frameworkは任意のパッケージに依存しないので、Image.network は dart:io の HttpClient を直接使います。差し替える口はありません。6 画像も、Client を渡せるパッケージに置き換える必要があります。以下のようなパッケージが候補です。 パッケージ Client の差し替え ディスクキャッシュ http_image_provider できる なし extended_image できない(内部で dart:io の HttpClient を持つ) あり cached_network_image できる(flutter_cache_managerの HttpFileService) あり taro できる(TaroHttpClient) あり http_image_providerは、httpパッケージのREADMEが紹介している Flutter HTTP example application で使われています。一応、公式が提案しているパッケージです。 また cached_network_image は、ウィジェットごとに cacheManager: を渡すと渡し忘れが出るので、defaultCacheManager ごと差し替えるのが確実です。7 CachedNetworkImageProvider.defaultCacheManager = CacheManager( Config( 'networkImages', fileService: HttpFileService(httpClient: createHttpClient()), ), ); ちなみに、筆者は「画像だけ dart:io に戻る」状態をなんとかするだけなら、もっと軽量で行けるだろうと taro を作ってメンテナンスしています。デフォルトは dart:io ですが、TaroHttpClient を実装すれば Client に差し替えられます。サンプルも書いてあるので、よければご利用ください。 User-Agent 何も指定しないと、User-Agentはこうなります。 クライアント デフォルトのUser-Agent dart:io Dart/3.x (dart:io) Cronet <パッケージ名>/<versionCode> (Linux; U; Android 15; ja_JP; <端末名>; Build/<ID>; Cronet/<バージョン>) URLSession <アプリ名>/<ビルド番号> CFNetwork/<バージョン> Darwin/<バージョン> 「APIはCronet、画像は dart:io」のように混ざると、同じアプリから2種類のUser-Agentが飛びます。揃えたいなら、クライアントごとに指定します。 // Android CronetEngine.build(userAgent: userAgent); // iOS / macOS URLSessionConfiguration.defaultSessionConfiguration() ..httpAdditionalHeaders = {'User-Agent': userAgent}; // それ以外(dart:io) HttpClient()..userAgent = userAgent; native_dio_adapterなら、createCronetEngine と createCupertinoConfiguration に同じ設定を渡します。 例外 失敗したときの例外の型も変わります。 IOClient CronetClient CupertinoClient ClientException(SocketException も実装) CronetException とそのサブクラス NSErrorClientException IOClient の例外は、互換性のために SocketException も実装しています。なので on SocketException catch で捕まえているコードは、差し替えると素通りします。3つに共通するのは ClientException です。 dioでは、タイムアウトは常に receiveTimeout、通信の失敗は unknown になり、connectionTimeout や connectionError は返ってきません。 その他 cronet_httpとcupertino_httpが対応しているのは、Android・iOS・macOSだけです。WindowsのWinHTTP対応の要望(dart-lang/http#1837)には、Dartチームが取り組む可能性は低い、という回答でした。 おわりに package:http、というより dart:io には、あまり語られないものの把握しておくべきクセがあります。proxyや証明書のことを考えると、ネイティブのHTTPクライアントを利用するのが安牌なのではないかなと思っています。 🤏知ってることをまとめようと思い、AIに下調べと裏どりをさせてみたところ、自分も知らない話がたくさん出てきました。アプリ作るの、難しくない…? 脚注 dart-lang/sdk#44971 ↩︎ コード中のコメントによると、証明書をバイナリに組み込まないのは、バイナリのサイズを約240KB減らすためです。 ↩︎ それまでは /system/etc/security/cacerts を読んでいたそうです ↩︎ 環境ごとに適用を切り替えたり、証明書を限定したりすればOKのはず。 ↩︎ dio#2456 を入れたので、一応timeoutも効きます。 ↩︎ debugNetworkImageHttpClientProvider という差し替え口はありますが、デバッグビルド専用です。 ↩︎ flutter_cache_managerへの直接の依存が必要です。ディスクに保存するときの縮小(maxWidthDiskCache)も使う場合は、ImageCacheManager をmixinしたクラスにします。 ↩︎
My first core contribution Soon. core#1030
Flutterの修正版が来たら即アプデ推奨🙏 sdk#64644
@mrgnhnt96_dev github.com/dart-lang/build/blob/master/docs/writi… and build#5225 should do it :)
Can you migrate to material_ui yet? What changed, and why For years, package:flutter/material.dart came with the SDK. You installed Flutter, and Material came with it, same version, same release schedule, no decision to make. That is changing. Since Flutter 3.47, Material and Cupertino also exist as two packages on pub.dev,material_ui and [cupertino_ui] (pub.dev), published by flutter.dev and versioned on their own. The SDK still ships its own copy for now, but that copy is frozen. The three architectural layers are untouched. What moved is the top of the framework layer. !Flutter architecture before the split: Material and Cupertino sit inside the framework layer, above Widgets !Flutter architecture after the split: material_ui and cupertino_ui are packages above the framework, whose top layer is now Widgets Material and Cupertino used to sit inside the framework, on top of widgets. You can now use them above it, as ordinary packages you add to pubspec.yaml. Why now Three reasons, all from the announcement by Craig Labenz on 9 September 2026. 1. Upgrading Flutter meant redoing your UI Material shipped inside the SDK. So a Flutter upgrade, even one you wanted only for a security patch, brought Material changes with it. You could not take one without the other. The team is blunt about what that cost: > *a major source of developer friction that kept many teams on older Flutter > releases* and > *the task could consume multiple weeks of developer effort and bottleneck > entire repositories* Teams stayed on old Flutter versions on purpose, because moving up meant weeks of fixing screens. Staying behind also meant missing the security patches they wanted in the first place. 2. A fix could wait sixteen weeks Flutter ships a stable release every three months, and Material shipped with it. So a Material fix had to catch the release cutoff. The post gives the worst case: > *landing code one minute after the stable release cutoff can introduce a 16 > week delay until that change sees the light of a stable release* One minute late meant sixteen weeks of waiting. As separate packages, material_ui and cupertino_ui are planned to release weekly. And the post says work is already underway on official implementations of Liquid Glass and Material 3 Expressive, Apple's and Google's current design languages. 3. Material was keeping the useful parts to itself This reason is less obvious than the other two, but its effect lasts the longest. Flutter's core is the widgets library. Material is built on top of it. When the Material team needed something the core did not have, they added it to Material. That was faster, and it worked. But only Material got it. > *it proved too tempting to build foundational functionality in those design > libraries* Other design systems exist. The post names seven: macos_ui, fluent_ui,yaru, arna, chicago, VSP, shadcn. They needed some of the same pieces, and could not reuse Material's. Now that Material is a package like the others, the missing pieces have to go into the core instead. The official migration guide puts it this way: > *Decoupling lays the groundwork for a style-neutral Flutter core widget > catalog, making it easier to build and maintain custom design systems.* In plain terms: make the core work for any design system, not just Material and Cupertino. The team's own description of the old arrangement is the clearest line in the post. Material and Cupertino had started to feel: > more soldered-on than just included And for years, improving this system was *"a top request from Flutter developers"*. --- What you actually get Version 1.0.0 of material_ui was a copy of what was already in the SDK. The announcement says so directly: > *released exact replicas on pub.dev as the 1.0.0 versions of > pkg:material_ui and pkg:cupertino_ui* It had the same widgets, with the same names and the same behaviour. Migrating is not meant to change how your app looks. Here is how the team describes the move: > *Our expectation is that this will be a drop-in maneuver, but please file an > issue if you encounter any trouble.* Note the word "expectation". We will come back to it. What changes in your code One line per file: // before import 'package:flutter/material.dart'; // after import 'package:material_ui/material_ui.dart'; For most files, that is the whole change. Scaffold, AppBar, TextField, ThemeData all keep their names. Are they really shipping weekly? Speed was the reason for the split. So it is worth checking whether the speed arrived. Counting from pub.dev on 8 October 2026: | Package | Latest | Releases after 1.0.0 | One every | |---|---|---|---| | material_ui | 1.6.0 | 7, over 55 days | 8 days | | cupertino_ui | 1.1.2 | 4, over 55 days | 14 days | material_ui is shipping close to the weekly pace it promised, and a long way from a three-month cycle. cupertino_ui moves more slowly. So on paper, migrating is one import line and nothing else. The rest of this article is what happened when I ran it. --- The migration, step by step The app under test A small app with no third-party UI package. If something breaks, the migration is the only suspect. It contains the pieces the migration touches: Material and Cupertino widgets, translations generated from .arb files, a language switcher, a barrel file that exports both design libraries, and two widgets that take a ColorScheme or a CupertinoThemeData as a constructor argument. Ten widget tests cover it. All ten pass before we start. The code is in the repository, with a tag for each state: first-app-without-migration, after-dart-fix and after-localizations-fix. Every command output quoted below is saved in its measurements/ folder. Step 1: ask before you apply dart fix --dry-run --code=migrate_design_widgets 17 proposed fixes in 14 files. --dry-run changes nothing. It only lists what the tool plans to do. Sixteen fixes come from the migration rule. One comes from a second rule, missing_dependency, which targets pubspec.yaml. Step 2: apply dart fix --apply --code=migrate_design_widgets 17 fixes made in 14 files. The same seventeen. Every change in the Dart files is an import line, like this one: -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; No widget, no constructor, no type was touched. The pubspec.yaml was updated too, with any as the version: + cupertino_ui: any + material_ui: any any accepts every version, including a future one that breaks your app. I replaced it with real constraints: cupertino_ui: ^1.1.2 material_ui: ^1.6.0 What the tool did not touch export directives. The barrel file is not in the diff: // lib/core/ui.dart, unchanged after the migration export 'package:flutter/cupertino.dart' hide RefreshCallback; export 'package:flutter/material.dart'; The tool rewrites import lines, not export lines. This was reported in dart-lang/sdk#63968. The issue is marked as closed, but with Dart 3.13.5 the exports were still left as they were. The next part, Where it stops working, shows what this costs. The localization import. lib/app/app.dart had two SDK imports: material.dart and flutter_localizations.dart. Only the first one was rewritten. That is where the only error comes from. The only error This is the top of app.dart after the tool: import 'package:material_ui/material_ui.dart'; // rewritten import 'package:flutter_localizations/flutter_localizations.dart'; // not touched Both libraries define a class called GlobalMaterialLocalizations. flutter_localizations still has the old one, and material_ui brought its own copy of the Material translations. The file imports both, so Dart cannot tell which one you mean: error - lib/app/app.dart:66:9 - The name 'GlobalMaterialLocalizations' is defined in the libraries 'package:flutter_localizations/src/material_localizations.dart (via ...)' and 'package:material_ui/src/global_material_localizations.dart (via ...)' - ambiguous_import A second error follows on the same line, non_constant_list_element. It disappears with the first one. The fix, in two steps. 1. Remove the flutter_localizations import from app.dart. 2. Replace the three old delegates with the list from material_ui: // before localizationsDelegates: const <LocalizationsDelegate<dynamic>>[ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, GlobalWidgetsLocalizations.delegate, ], // after localizationsDelegates: <LocalizationsDelegate<dynamic>>[ AppLocalizations.delegate, ...GlobalMaterialLocalizations.delegates, ], GlobalMaterialLocalizations.delegates, with an s, is a list. The migration guide says it contains the Material, Cupertino and Widgets delegates, so the three old lines become one. The ... spreads that list into yours. The app's French tests confirm it: Material and Cupertino texts are translated. AppLocalizations.delegate stays first. It carries your own app's texts, generated from your .arb files. The guide's example does not show this line, because its example app has no texts of its own. If yours does, keep it. One more point if you use .arb files: keep flutter_localizations in your pubspec.yaml. Your own code no longer imports it, but the file that gen-l10n generates still does. Without the line, the app still builds, because material_ui brings the package along. But your project would then use a package it never asked for, and the lint that would warn you is switched off in generated files. So keep it declared in pubspec.yaml, and never import it again in your own files. The import is what caused the error, not the declaration. Did anything break? Analyzing material_ui_migration... No issues found! 00:00 +10: All tests passed! I also ran the same ten tests on the code from before the migration, in a separate git worktree, with the test imports switched back. Ten green there as well. So: ten tests green before, ten green after. Seventeen automatic fixes, and one manual fix to make it compile. For an app without third-party UI packages, the documented path works. The second pass The guide says to run dart fix a second time to sort the imports. They do need it, because the migration breaks the alphabetical order: import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; But the second pass returned Nothing to fix!. Import order is checked by the lint rule directives_ordering, which is not part of flutter_lints. Without that rule, dart fix does not sort your imports. Your dependencies stay the same flutter pub outdated prints the same list before and after the migration. For example, material_color_utilities stays at 0.13.0 when 0.13.1 exists. In Flutter 3.47 the old Material is still inside the SDK, and the SDK holds this version. Migrating does not change that. --- That is the good news. Here are the three places where it goes wrong: a file left on the old import, an export the tool skipped, and the translation list that gen-l10n generates for you. --- Where it stops working The migration guide has a tool for apps that cannot migrate everything at once: the compatibility bridge. You add it in MaterialApp.builder: builder: (BuildContext context, Widget? child) { return MaterialUiCompatibilityBridge(child: child!); }, dart fix does not add it for you. The guide describes what it does: > *This allows unmigrated child widgets and dependencies that read design state > from context (such as Theme.of(context) or MaterialLocalizations.of(context)) to continue functioning properly.* and what it cannot do: > *the compatibility bridge cannot resolve type mismatches when a dependency > exposes, accepts, or returns in-framework SDK types in its public API > signatures (such as passing a FloatingActionButtonLocation, ColorScheme, > or TextTheme as a parameter or callback return value).* One thing the guide does not say: in material_ui 1.6.0, the bridge is already marked @Deprecated, as "a temporary migration utility" that "will be removed in a future release". The changelog does not mention it either. Only the analyzer warns you, when you add it. So whatever it fixes below, it fixes for now. I broke the migrated app in three ways, on purpose, and checked whether the bridge helps. 1. A file left on the old import This happens when one file stays on the old import, and with packages that have not migrated yet. To reproduce it, I put one file back on its old import: git checkout first-app-without-migration -- lib/features/legacy/legacy_summary.dart That file reads the theme and passes its ColorScheme to StatCard, a widget that is now migrated: The argument type 'ColorScheme (where ColorScheme is defined in .../flutter/lib/src/material/color_scheme.dart)' can't be assigned to the parameter type 'ColorScheme (where ColorScheme is defined in .../material_ui-1.6.0/lib/src/color_scheme.dart)'. "ColorScheme can't be assigned to ColorScheme." Same name, two classes. The compiler has to show the file paths, because the name no longer tells them apart. The bridge cannot help here, and the guide says so: it works through the context, and this value travels as a constructor argument. The only fix is to migrate the file. At least the compiler tells you. 2. An export the tool skipped After the migration, the app's theme belongs to material_ui. But the barrel file, lib/core/ui.dart, still exports the old Material from the SDK. And one widget, Section, imports that barrel. So when Section asks for the theme with Theme.of(context), it asks the old Material. The old Material cannot see the app's theme, because that theme now belongs to material_ui. Instead of failing, it gives back its own default theme. No error, no warning, and all ten tests still pass. A test measured the colors: | | Primary color | Title color | |---|---|---| | the app's theme | #465D91, my blue | #1A1B20 | | what Section gets | #6750A4, the default purple | #1D1B20 | The test also checked the color on screen: Section painted its title with the default one, #1D1B20. In this app you cannot see the difference: Section only uses the title color, and the two are almost identical. But any widget behind the barrel that uses colorScheme.primary would show the default purple instead of your blue. With the bridge: it works. The old Material now gets the app's colors, #465D91 and #1A1B20. But it only fixes part of the theme. Its source (lib/src/migration_utility.dart) builds the old ThemeData from four values only: platform, visualDensity, colorScheme and textTheme. Everything else keeps the old Material's defaults. Component themes, like inputDecorationTheme, and theme extensions are not passed on. The fix: migrate the two export lines by hand. export 'package:cupertino_ui/cupertino_ui.dart' hide RefreshCallback; export 'package:material_ui/material_ui.dart'; After that, dart analyze is clean and Section paints its title with the app's color, #1A1B20. No bridge needed. After running dart fix, search your project for export 'package:flutter/: the tool will not do it for you. 3. The translation list gen-l10n writes for you gen-l10n generates a ready-made list of delegates in lib/l10n/app_localizations.dart. After the migration, it still writes the old ones: import 'package:flutter_localizations/flutter_localizations.dart'; static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[ delegate, GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, GlobalWidgetsLocalizations.delegate, ]; Those three come from flutter_localizations, the SDK. Passing this list straight to MaterialApp is a common shortcut: Zulip and kitchenowl both used it. So I did the same: localizationsDelegates: AppLocalizations.localizationsDelegates, It compiles. Eight tests pass, three fail, and the three that fail are the three that run in French. The app registers French texts for the SDK's Material widgets, but its widgets now come from material_ui, and they look for material_ui's texts. In English the app falls back on built-in English texts. In French, there is nothing to fall back on. The messages point the wrong way: - A MaterialLocalizations delegate that supports the fr locale was not found. There is one. It is the SDK's. No MaterialLocalizations found. To introduce a MaterialLocalizations, either use a MaterialApp at the root of your application... There is a MaterialApp. Neither message can say what is wrong, which is that two classes are called MaterialLocalizations. It breaks in the TextField on the first screen, the same widget as in the kitchenowl report. My tests run in debug mode, where an assertion catches it. The kitchenowl report says that release builds only showed a generic null-check crash. I did not measure release mode. With the bridge: no change. The same three French tests fail, with the same messages. The bridge registers the old flutter_localizations delegates, for widgets that still use the old Material. The migrated widgets need the new ones. > Does your app speak more than one language? > > 1. Search your code: grep -rn "localizationsDelegates" lib/. If you see > AppLocalizations.localizationsDelegates, you are affected. > 2. Run the app with locale: const Locale('fr') (or any language other than > English) in your MaterialApp. An English-only run will not show the bug. > 3. Replace the generated list with: > [AppLocalizations.delegate, ...GlobalMaterialLocalizations.delegates]. If that > file still imports flutter_localizations, remove the import. > 4. Never fix the generated file by hand. It is rewritten every time you add a > text to your .arb files. > 5. The compatibility bridge does not fix this one. --- So, can you migrate yet? For an app like the one tested here, with no third-party UI package: yes. Remember the word "expectation": the team expected "a drop-in maneuver", and for this app it almost is. Seventeen automatic fixes, one manual fix to make it compile, and the same ten tests green before and after. Almost, because of the three things below. But dart fix does not finish the job. After running it, check three things yourself: 1. Your export lines. The tool skips them. A forgotten one gives no error and brings back the default theme without any warning. 2. Your translation list, if your app speaks more than English. The list that gen-l10n generates still points to the old Material, and it breaks every other language. 3. Your pubspec.yaml. Replace any with real version constraints. The compatibility bridge will not do this for you. It fixes the theme read through Theme.of(context), but not types passed as arguments, and not the generated translations. And it is already deprecated. Should you migrate now, or wait? The SDK copy of Material is frozen. The package is not: it has released a new version about every week since August, with fixes the SDK copy will not get, like the showAboutDialog crash on the web (changelog). And the Flutter 3.47 announcement schedules the old libraries for deprecation in the November stable release. If you wait, you will migrate anyway, only closer to that deadline. Which leaves the real question for most apps: can you migrate if your app depends on packages that have not migrated yet? The first case above shows what such a package looks like from your side. If it passes a ColorScheme or a ThemeData through its API, the compiler refuses, and the bridge cannot help. The guide asks package authors to treat the migration as a major version bump. In one real app, only one of the eighteen dependencies that import Material had migrated when its team started (flutter_server_box#1591). That is the next article. I will add real packages to this app and sort them: the ones that migrate on their own, the ones the bridge saves, and the ones that break.
I counted one MCP server on the wire: without the handshake, HTTP stays lighter until call ten 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. > 1. P1: The plain flow has seven JSON-RPC messages under 2025-11-25 and four under 2026-07-28. > 2. 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. > 3. 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. > 4. 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. > 5. 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. > 6. P6: Stdio adds one newline byte per message, leaving the message body to decide most of the total. > 7. 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 _meta spans 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 initialize request 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. !1. client to server: initialize · POST · 183 B; 2. server to client: result · 200 · SSE 216 B; 3. client to server: notifications/initialized · POST · 54 B; 4. server to client: 202 · 0 B; 5. client to server: tools/call · POST · 87 B; 6. server to client: elicitation/create · 200 · SSE 244 B; 7. client to server: result · POST · 80 B; 8. server to client: 202 · 0 B; 9. server to client: result · 200 · SSE 128 B !1. client to server: tools/call · POST · 301 B; 2. server to client: input_required · 200 · 368 B; 3. client to server: tools/call · POST · 374 B; 4. server to client: result · 200 · 219 B 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.