flutter_mcp_ui_runtime 0.5.4
flutter_mcp_ui_runtime: ^0.5.4 copied to clipboard
Runtime for building dynamic Flutter UIs from JSON with lifecycle management, state handling, and MCP protocol support.
0.5.4 - 2026-07-31 #
Fixed — registerAction could not be called from outside the package #
MCPUIRuntime.registerAction(String type, ActionExecutor executor) is public, but neither ActionExecutor — the type it asks for — nor ActionResult — the type that must be returned — was exported from the library. A host had no way to name either, so the method was unreachable and custom action executors could not be supplied at all.
Both are now exported. Additive: no existing name moved or changed. A regression compiles a host-defined executor against the library barrel, which is the only thing that can catch this — every in-package test names the types through their source files and passes regardless.
Fixed — a definition's own initial state discarded entry.* and identity.* (§8.9) #
A host adopts the entry and identity, then the definition's initial state is installed as a wholesale replacement of the state container — which cleared the roots they had just been published into. Every entry.* / identity.* binding then resolved to null for any definition declaring state.initial.
The symptom is silence rather than an error. A deep-linked screen renders with blanks where the link's parameters belong and reads as an ordinary visit by someone who is not signed in, which is precisely the reading §8.9.6 reserves for a runtime that does not implement this section at all.
Both definition types were affected through different calls — an application replaces the container, a page initializes it — and every sample document is a page. Routing was never affected: a launch route is read from the session rather than from state, so the deep link landed on the right screen and only its parameters went missing. identity.* recovered on the next promotion, since that writes a single path; entry.* never returned, because nothing re-adopts it.
StateManager now takes reservations: reserveHostRoot(root) marks a root as held on behalf of someone other than the document, and both setState and initialize carry reserved roots across a replacement. EntrySession reserves entry and identity when it is constructed. A reserved key present in the incoming state does not win — §8.9.2 makes these read-only to the document, so a collision is shadowing rather than supplying.
The reservation is registered rather than named inside StateManager, so a container that holds no host facts — an embedded scope's own state tree (§6.11.3) — reserves nothing, and a document there may keep its own state under those names.
Why it shipped: the §8.9 suite booted definitions with no state.initial, which is the one shape that hides this, and the launch-route assertions read the session object rather than a binding. Both gaps are closed: the regressions now cover application and page, and assert through resolved bindings.
0.5.3 - 2026-07-28 — Composition Profile: rendering definitions from other origins (spec 1.4) #
Implements the consumer side of composition. Spec 1.3 §11.9 dashboard already said how an application presents itself when embedded — nothing said how an application embeds. This release adds that, so one bundle can compose several MCP servers into one product.
Fixed — lifecycle hooks (spec §1.5, §6.8, §9.9.1, §18) #
Auditing the seven hooks of §1.5.1 against this runtime found that no mount path implemented the full set, and that hooks were being dropped before execution ever came into it. A page written exactly the way §6.8.1 shows it — subscribe in onReady, release in onDestroy — parsed as a page with no hooks at all, so a device's live reading streamed when the page was embedded and sat blank when the same page was opened on its own, with every layer beneath reporting success.
Parsing. LifecycleDefinition.fromDefinition(definition) replaces fromJson(lifecycleObject):
- Reads both placements §1.5.3 allows — top-level hook fields on the definition and a grouped
lifecycleobject — and merges them. Only the grouped form was read before. - Accepts a single Action as well as an array (§1.5.1). A bare Action returned null, which is a hook that never runs.
- The canonical name is
onInit(§1.5.1).onInitializeis accepted as a deprecated alias. onEnter/onLeaveare not spec hooks — §6.8.3 already routes a page throughonMount/onUnmount— so they are folded onto those as deprecated aliases.- A hook named in both placements is an error per §1.5.3; until 0.6.0 it warns (
LifecycleDefinition.aliasWarnings) and keeps one copy, so a document that used to load still loads.fromJsonremains as a deprecated shim. Both removals land in 0.6.0 alongside the deprecations already announced for that release.
Execution. New LifecycleRunner owns hook order for every mount site; a site now says only when it mounted:
- Routed pages run
onInit/onReady/onDestroy, which they never did — they ran onlyonEnter/onMount/onLeave/onUnmount, two of which are not spec hooks. - Applications run
onMount/onUnmount, and takeonPause/onResumefrom the parsed definition rather than only from a legacy runtime-config path that a document could not reach. - Embedded
views release on unmount. The embedded path started subscriptions and never ended them, so a tile that went away left the node streaming to a scope nobody read. - Teardown has one owner — the widget that mounted the definition. Navigation used to tear the outgoing page down as well; with
onLeavefolded ontoonUnmountthat overlap would have fired the same hook up to three times andonDestroytwice.
Placements that were never implemented at all:
- Instance-level hooks on any widget (§6.8.2) — a widget's own
lifecycle: {}block was read nowhere, so it was silently dropped. NewLifecycleHostwraps a widget only when it declares hooks. - Template instances (§9.9.1, and a MUST in §18) — a template's
onMount/onUnmountfire once per instance.UseTemplateFactoryran neither.
Covered by test/runtime/lifecycle_matrix_test.dart: the same document mounted as a routed page, as an embedded view, and as an instance-level block must run the same hooks in the same order. Each path passed its own tests in isolation before, which is why the divergence stayed invisible.
Added — entry & identity (spec 1.4 §8.9) #
A definition is often reached from outside the app — a scanned code, a tag, a link — and the viewer may or may not be signed in. This adds the consumer side of that: what a document may read about its arrival, and the one transition it may request.
entry.*/identity.*bindings —entry.route,entry.params.*,entry.issuer.*,entry.grant.scope,entry.canSteward,entry.notice,identity.state,identity.subject.*,identity.canPromote. Read-only, andnullon a runtime whose host wired nothing (§8.9.6), so a document written against §8.9 degrades to its guest rendering instead of failing.identity.promote/identity.releaseactions — carry no credential in either direction; the result reports only whether the transition occurred. Unsupported (not failed) when the host wired no handler.- The value types (
EntryContext/IdentityContextand friends) live influtter_mcp_ui_corealongside the other spec types; this package re-exports them and owns the behaviour. A type that only a Flutter runtime can name is a type an authoring tool or validator cannot check. EntrySessionon the runtime — the host adopts an entry, adopts or replaces the principal, and registers promotion handlers. Replacing the principal re-evaluates bound expressions in place: the document is not rebuilt and its state is not discarded (§8.9.4).- Launch route —
MCPUIRuntime.initialize(..., launchRoute:)opens the definition at a requested page instead of its owninitialRoute, andentry:implies one when the entry named a route. The two are kept apart deliberately: §8.9.1 reserves theentry.*tree for definitions reached from outside, so an in-appnavigation.openAppsets only the route. Folding it into an entry would make every app-to-app open read as a scan to a document asking how it was reached. A route the document no longer declares is not honoured silently: the runtime falls back and reportsRouteManager.launchRouteMissing, because a stale binding that renders the home page looks exactly like a working one.
entry.params is a separate root from route.params by design — route parameters say where in the document the viewer is, entry parameters say what was scanned to get here, and only the latter survives internal navigation.
These bindings are not authority. They decide what a screen offers; every privileged operation is still authorized at the serving origin (§8.9.5).
Two guards found by mutating the harness rather than by review:
- A
stateaction targetingentry.*/identity.*is rejected (READ_ONLY_BINDING) instead of silently dropped. - Resolution has a dedicated branch ahead of the generic fallback. Without it the fallback searches page and app state first, so a document could set its own
page.entryand forge bothcanStewardand a launch route. Pinned bytest/entry/entry_identity_test.dart"document state cannot shadow entry.* or identity.*" — the first version of that suite passed with the branch removed, which is what surfaced the hole.
Added #
viewwidget (src/widgets/utility/view_factory.dart, spec §2.13.1) — embeds aDefinitionSourceanywhere in a tree. Accepts all four source forms (§1.9.1): an inline definition, aui://uri on the current origin, a qualified{ $ref, from }reference to another origin, or a binding to a definition held in state. Supportsprops/fallback/loading/onError/theme.MCPUIRuntime.registerDefinitionResolver(resolve)— the host seam, mirroringregisterStreamSource. The runtime never learns how a connection is opened; establishing outbound connections is a host capability (§6.11.1). Held onRenderer(root contexts are created on demand) and carried down byRenderContext.createChildContext.RenderContext.createEmbeddedScope({props, inheritTheme})— the isolated scope an embedded definition renders in (§6.11.3, §7.10.1). A freshStateManageris what makes the isolation real;propsis the only embedder→embedded channel, andlocalVariablesare deliberately not inherited.RouteValuewidened to anyDefinitionSource(src/routing/route_value.dart, spec §1.2.1) — a whole route may be another origin's page. Origin-carrying route values normalise to a page whose content is a singleview, so the route surface and the widget surface cannot drift apart; both go through one implementation. InlinePageDefinitionroute values (a v1.0 spec form this runtime had never supported) now work too, as does the v1.3{page, transition}wrapper.
Changed #
RouteInfo.pageUritypedString→dynamic. A route value is no longer necessarily a uri. Source-compatible (Dart implicitly downcasts fromdynamic), and a uri route still yields aString; only the new structural forms differ. No consumer outside this package's own tests reads it.- Page caching keys through
routeValueCacheKey()in both consumers (RouteManager,ApplicationShell). Structural routes key by route path, not by uri — two routes reading the same uri from different origins must not share a cache entry, which would render one device's UI on the other's route. The same key is used by the lifecycle lookups soonEnter/onLeave/pagePausekeep firing for composed routes. flutter_mcp_ui_corefloor raised^0.4.2 → ^0.4.3(the regenerated widgets schema carryingview). Consumers should bump to^0.5.3.
Security #
- Registering a resolver is how a host claims the Composition Profile. Without one,
viewfails closed and renders itsfallback— it does not resolve a foreign$refagainst the host's own origin, which would put one server's UI under another's identity (spec §7.10.1 rule 6, §18.7.3). - An embedded scope cannot read the embedder's state, and failure is contained per view: a dead origin renders that view's
fallbackwhile its siblings and the embedding page keep rendering.
Fixed — analyser cleanup shipped in the same cut (62 issues → 0) #
Not cosmetic-only; three of these change behaviour and are called out as such.
BuildContextused acrossawait(6 sites) —PermissionManager._promptUser/checkAndPromptandClientActionHandlercould raise a dialog on an element that had already been unmounted (a page popped while the action was in flight), which throws. All six now re-checkcontext.mountedfirst; a gone surface yields "no decision" instead of an exception. One pre-existing// ignore: use_build_context_synchronouslywas replaced by a real guard. Behaviour change: a permission prompt whose surface disappeared mid-flight is now declined rather than throwing.Radio/RadioListTilemigrated to aRadioGroupancestor — Flutter deprecated the per-widgetgroupValue/onChangedpair.radioGroupnow wraps its tiles in oneRadioGroup; the standaloneradiowidget carries its own single-item group so DSL documents are unchanged. Behaviour change: the widget tree gains aRadioGroupnode, which downstream widget tests that assert on tree shape may see.SemanticsService.announce→sendAnnouncement— the replacement requires aFlutterView, and these call sites are manager classes with noBuildContext, so they now resolve the same implicit view the deprecated call used internally. Behaviour change: with no implicit view (multi-window / view-less embedder) an announcement is skipped rather than asserted.cacheExtent→ScrollCacheExtent.pixels(...)inlistview_factory/virtualized_list(typed replacement; same pixel value, DSL key unchanged).Matrix4.scale/translate→scaleByDouble/translateByDouble.- Deferred-renderer placeholders in
phase_2_4_factoriesno longer bind unused locals (the property reads that record author intent remain). - Legacy-form bundle adapters (
bundle_page_adapter,bundle_ui_adapter,ui_definition) read deprecated inline fields on purpose — that is what makes older bundles keep working. Those reads now carry a single-line// ignore:with the reason stated, instead of appearing as unresolved debt. - Assorted lints:
curly_braces_in_flow_control_structures,prefer_const_constructors,unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers, a nullableTabControllerthat is non-nullable.
dart analyze lib is now No issues found, and the publish dry-run no longer reports an analyser warning.
Notes #
- Purely additive for existing documents:
fromabsent means the current origin, so every 1.3 document keeps its exact meaning. - Tests: 21 new (
test/widgets/v14/—view10,RouteValue11). 4341/4341 green (full suite, re-run after the cleanup above).
0.5.2 - 2026-07-19 — client.mcpStream channel type: MCP-server-pushed live streams (spec 1.3 §8.6.2) #
Added #
client.mcpStreamchannel type — the first channel that carries an MCP-server-pushed live stream. The prior five types cannot:client.pollre-invokes a tool on an interval (pull) andclient.websocketis a raw socket outside MCP.client.mcpStreamsubscribes a host-registered stream source identified by itsurischeme and forwards each server push through the normal channel machinery (lifecycle, backpressure,onMessage/onError/onConnect/onDisconnect).McpStreamChannel(src/channels/channel_types/mcp_stream_channel.dart) implements it;ChannelManager._createChannelgains the case with a late-bound resolver (the channel is created at page init but the host registers its source after runtime init, so resolution is deferred tostart()).MCPUIRuntime.registerStreamSource(scheme, open)— the host seam. A host wires a real transport per uri scheme (e.g.ble→ a BLE scan hub) while the runtime stays transport-agnostic; all schemes share one resolver on the channel manager. MirrorsregisterToolExecutor.
Changed #
flutter_mcp_ui_corefloor raised^0.4.1 → ^0.4.2(the regenerated page schema listingclient.mcpStream);mcp_bundlefloor raised^0.4.0 → ^0.4.8(floors-at-latest on cut). Consumers should bump to^0.5.2.
Notes #
- Purely additive and version-neutral: a runtime predating the type hits
_createChannel'sdefaultbranch and ignores the unknown type (graceful coexistence). Themajor.minorDSL-version gate (MCPUIDSLVersion) is unchanged —since v1.3.
0.5.1 - 2026-05-23 — spec compliance Round 2 + mcp_bundle 0.4.0 cascade #
Changed (cascade) #
mcp_bundlecaret bumped from^0.3.0to^0.4.0. The downstreamUiSection.pagesfield switched toMap<String, PageDefinition>; this runtime'sbundle/bundle_page_adapter.dartnow iteratesuiSection.pages.valuesso bundle activation still walks every page. Consumers should bump to^0.5.1.flutter_mcp_ui_corecaret bumped to^0.4.1(mcp_bundle cascade).
Added #
- MCP wire shape unwrap — tool responses shaped as
{content:[{type:'text', text:S}], isError:bool}now unwrap and JSON parseSbefore auto-merge (spec §3.10). StateChangeEvent.sourcecanonical enum (action/tool/subscription/system) per spec §3.11.mergeStatedefaults to'tool',StateActionExecutorto'action', resource notification to'subscription',updateAllto'system'.RenderContext.onResourceRead/onResourceList— separate host callbacks for spec §4.5read/listsub-actions (fallback toonResourceSubscribefor backward compat).onSubscriptionErrorcallback — spec §4.5 named field, dispatched when host subscribe handler throws.event.{uri, binding, message}child context.- Non-Map response
event.valuewrap — spec §4.4.2: list / scalar / null tool responses exposeevent.value = <response>(with otherevent.*keys null). - Common
clickfield auto-wrap (spec 1.3.4 §2.2) —WidgetFactory.applyCommonWrappersnow wraps any widget carrying aclick: Actionproperty in aGestureDetectorand dispatches the action throughRenderContext.actionHandleron tap. Universal across all 97 factories that callapplyCommonWrappers; pure layout / decoration widgets (box,card,linear,stack, ...) become tappable without nesting agestureDetectorwrapper.clickis resolved through the binding engine, so action maps may be supplied inline or via{{...}}binding. Click is applied BEFORE the enabled-state wrap soenabled: false(IgnorePointer) correctly suppresses the gesture surface. Widget-local activation slots (button.onTap,iconButton.onTap,richText.spans[].onTap, ...) remain canonical for those widgets and are unaffected. - 26 new regression cases under
test/spec_compliance/runtime_spec_fix_2026_05_14_test.dartlocking the above. - 6 new regression cases under
test/renderer/widget_factory_test.dartfor the commonclickfield: GestureDetector wrap shape, end-to-end tap → action dispatch, tooltip + click coexistence, backward-compat no-click pass-through, non-Map click ignore, andenabled:falsesuppression via IgnorePointer.
Deprecated #
- Envelope
{success, result, message}auto-unwrap — slated for removal in 0.6.0. Warning logged once per process. tools.<tool>.resultnamespaced mirror — slated for removal in 0.6.0. Warning logged once per process.
Changed #
runtime_engine.handleResourceNotification/handleMCPNotificationno longer applycontent[binding]heuristic — raw content stored at binding per spec §4.5.- Lifecycle
_renderContextnull short-circuit replaced with explicit error log.
Fixed #
- Storage, one-shot reads and permissions did not scope to the origin
either. A
resourceread from an embedded subtree returned the embedder's resource under the embedded document's uri (a wrong answer, not a missing one); two devices on one screen shared a single storage key space, so the second to write a key silently overwrote the first and each read the other's value back as its own; and an embedded document could prompt for — and be granted — a permission the embedding app never held, which let any embedded server escalate through the screen it was given. Reads now route throughregisterOriginResourceReader, storage keys are prefixed by the scope's origin, and a scoped action is refused before any prompt when the embedder does not hold the permission. - An embedded subtree rendered against its origin but did not act against
it.
viewbrought another origin's UI in; everytoolaction inside it still took the app's own path and landed on a session with no client for that device, so a composed screen looked finished and did nothing (observed assession.tool.no_clientwhile both tiles rendered).RenderContextnow carries an ambientorigin— set on the embedded scope, inherited by every descendant, never read from the renderer because an origin scopes a subtree rather than a tree — and a scopedtoolaction routes through the host'sregisterOriginToolCaller. A scoped subtree with no bridge reports rather than redirecting: running one device's tool name against another server is worse than failing. - The resolver did not reach the context a page renders with.
registerDefinitionResolverstores it on theRenderer, and eachRenderContextcopied the field at construction — but two of the five construction sites did not, including the router's, which is the path a bundle takes. A host that had claimed the Composition Profile therefore sawviewreport that the runtime does not implement it.RenderContextnow reads through to its renderer, so the profile reaches the tree by construction rather than by every construction site remembering; an explicitly-set resolver still wins. ApplicationDefinition.fromUIDefinitionnarrowedroutestoMap<String, String>, so an application carrying a v1.4 route that names another origin ({"$ref": ..., "from": {"connection": ...}}) threw while parsing and the app could not open at all. The error named a type cast, not routes, so nothing pointed at composition. Routes now keep their declared shape.- Tool responses arriving in MCP wire shape were merged as
content/isErrorliteral keys instead of unwrapped — now spec §3.10 compliant. TemplateParamDefinition.validatenow skips declared-type and enum checks when the supplied argument is a binding expression ("{{...}}"). Mirrors the same fix influtter_mcp_ui_core'sTemplateDefinition.validate; covers the extended-template (resolveExtended) path. Previously a template param declaredtype: booleanrejected every expression-bound argument (always a String at validate time), causinguseinvocations to surface theTemplate not found:placeholder. Spec §9.3.1 mandates only required / default / enum / validator and expressions resolve at runtime so cannot be compared against the enum list here either. Non-expression arguments still take the type / enum path unchanged. Regression: full templates test suite 88/88.TemplateRegistry._substituteValuenow type-preserves whole-value placeholders. A param value shaped as a single"{{name}}"(with no surrounding literal text) was previously stringified by_substituteStringviavalue?.toString()— a List param landed in the expanded template body as"[a, b]", a Map as"{x: 1}", an action Map ({type:"tool",...}) as"{type: tool, ...}". Downstream factories receiving those props could no longer cast back toList/Map<String, dynamic>?(observed runtime:'String' is not a subtype of type 'Map<String, dynamic>?'frominkWell.onTapwhen atemplate params.onActivateaction Map was forwarded into a nesteduse). The substitution now short-circuits whole-value placeholders to the raw param value; partial placeholders ("Hello {{name}}") still take the string path unchanged.ListViewWidgetFactory._ensureStableConstraintsis now orientation-aware. The defensive guard that wraps the ListView in aSizedBoxwhen the parent supplies an unbounded constraint previously only checkedhasBoundedHeight, so alistwithorientation: 'horizontal'mounted inside aRowwith aSpacer/Expandedsibling threwHorizontal viewport was given unbounded widthat layout time (followed by a cascade ofRenderBox was not laid outexceptions). The guard now branches onscrollDirection: horizontal lists checkhasBoundedWidthand fall back toSizedBox(width: MediaQuery.size.width); vertical lists keep the existinghasBoundedHeight/SizedBox(height: ...)path. Author intent is unchanged — explicitshrinkWrap: trueor a parent-supplied size still short-circuits the guard.textwidget pinstheme.color.onSurfacewhenstyle.coloris unspecified. Spec §5.4.2 deliberately omits acolorfield on typography roles (Material 3 separates typography from colour), sovariant-only / no-style.colortext returnedTextStyle(color: null)and Flutter fell through to the ambientDefaultTextStyle.of(context).color— which inherits from an ancestorThemewhose brightness can briefly diverge from the ThemeManager's effective mode during host tab transitions (an AppRendererScreen remount sees a staleMediaQuery/Theme(brightness:)frame before the host wrap re-applies its override). Visible in dark mode only — both ambient branches yield near-black under light, so the divergence is invisible there; in dark the same race surfaced as black-on-dark text frames after tab cycling. The factory now resolvestheme.color.onSurface(the M3 canonical text colour) through ThemeManager at build time, breaking the ambient dependency. Author-suppliedstyle.colorstill wins via the existingmergepath. Regression: 2 new widget cases intest/runtime/text_color_pin_test.dart(onSurface pinned despite light-Theme ancestor / inline style.color overrides the pin).ThemeManager.flutterThemeModenow honours_hostBrightnessOverrideunconditionally (in lockstep with_resolveEffectiveMode). Previously the override only applied when the bundle declaredmode: 'system', so a bundle withmode: 'dark'would resolvetheme.color.onSurfaceto the host-pinned brightness viagetColorValuewhileMaterialApp.themeModestill picked dark — the colour-token path and the ambientColorSchemecame from different brightnesses, producing the "ambient onSurface flips between frames" race the tab-cycle text fix above is the second half of. Regression: 5 new cases intest/runtime/text_color_pin_test.dart::flutterThemeMode honours host override unconditionally. Testtheme/theme_manager_test.dart::TC-TH-03 flutterThemeMode ignores host brightness ...was inverted to lock the new contract (renamed to... honours host brightness ...) — policy change 2026-05-21, AppPlayer-class hosts are themselves "the system" for embedded bundles.ThemeManager._resolveEffectiveModenow treats a non-null_hostBrightnessOverrideas the unconditional winner — previously the override only applied when_themeMode == 'system', so a bundle that hard-declaredtheme.mode: 'dark'ignored the host's light/dark toggle entirely. Worse, the toggle's apparent effect depended on race-timing betweensetTheme(appDef.theme)(driven by the bundle's manifest at mount) andsetHostBrightness(...)(driven by the host's chrome preview-mode pin) — whichever landed last won, producing an intermittent "text widget stays dark / surrounding chrome flips light" leak intools/builder/vibe_studio/vibe_studio_workspace.setHostBrightnessalso now notifies unconditionally (the previousif (_themeMode == 'system') notifyListeners()guard would swallow the brightness change when a bundle declared an explicit mode). AppPlayer-class hosts are themselves "the system" for embedded bundles — when the host pins brightness, the bundle MUST follow. The identical-override early-return guard (_hostBrightnessOverride == brightness) is preserved, so no spurious rebuild. Regression: 6 new cases intest/runtime/theme_host_override_priority_test.dart(light flips dark bundle / dark flips light bundle / clear restores declared / always-notify / identical no-op / fingerprint changes for renderer cache invalidation).RuntimeEngine.initializenow forwardsThemeManagermutations to engine listeners.setTheme/setThemeDefinition/setThemeMode/setHostBrightness/applyOverridecalls already invokedThemeManager.notifyListeners()(six call sites), but the engine neveraddListenered on_themeManager, so the notification stopped at the manager and widgets never rebuilt on a theme mutation. Hosts worked around this by bridging through_stateManageras a noop forward (sentinel:tools/builder/vibe_studio/.../dsl_workspace_view.dart). Specmcp_ui_dsl/spec/1.3/05_Theme.md§L56 ("theme.mode … changes trigger theme recomputation and re-render") makes this re-render obligatory; the engine now satisfies it natively. The listener tear-off (_themeListener = notifyListeners) is held on the engine and detached indestroy()— ThemeManager is process-singleton, so an unreleased listener would outlive a destroyed engine and firenotifyListenerson a disposed receiver. Multi-host theme isolation (per-runtime ThemeManager) is unrelated to this fix and remains a separate refactor track. Regression: 3 new cases intest/runtime/theme_forward_test.dart(theme mutation forwards / destroy detaches cleanly / host bridge workaround is no longer necessary).
0.5.0 - 2026-05-03 - Spec ↔ implementation alignment (1.3.3) #
- Channel callbacks:
onMessage(canonical,onDataretained as getter alias) + newonConnect/onDisconnectdispatch. chartadds donut / polar / bubble;codeEditoraccepts 7 themes (vsLight/vsDark/monokai/solarizedLight/solarizedDark/github/dracula) + 14 languages.lazy.trigger: visible(spec rename fromviewport);manualcase recognised.services.kind: polling/subscriptionaccepted (mapped to existingperiodic/continuous).template.stylesmap field;bottomBar/railnavigation canonical (legacybottomNavigation/bottomaliases retained).- Bumps
flutter_mcp_ui_coreto^0.4.0.
Fixed #
ThemeManager.getColorValue(slot)— falls back to the fromSeed-derived M3 28-role palette when the slot is absent from the bundle's rawtheme.colormap (spec §5.3 expects bundles to declare onlyseedplus a handful of overrides; the missing roles must derive). Previously the lookup only consulted_themeData = definition.toJson()(raw, no derive), so DSL bindings liketheme.color.surfaceContainerHighresolved tonullfor any bundle that omitted the role — even thoughtoFlutterTheme().colorScheme.surfaceContainerHighwas filled correctly. The two paths now match. Light / dark schemes are cached; cache is invalidated on everysetTheme/setThemeDefinition/resetTheme/reset/applyOverriderestore. Semantic roles (success/warning/infoand theiron*variants) are not on Flutter'sColorScheme, so the fallback returnsnullfor those — bundles must declare them explicitly.box(and the sharedBoxDecorationResolver) —decoration: {color: ...}is no longer silently dropped when neither top-levelcolornorbackgroundColoris supplied. The factory previously injectedcolor: nullinto the flat property bag, and the resolver's flat-vs-nested override pass treated thecontainsKey('color')hit as an explicit erasure of the nested value. Now: (a) the factory only overlayscolorwhen the merged top-level value is non-null, and (b) the resolver ignores null entries during the override pass — they cannot shadow nested fields. Same null-tolerant treatment applies togradient/image/border/borderRadius/boxShadow/shape/backdropBlur. Surface-toned boxes (e.g.decoration: {color: "surfaceContainerHigh"}) finally render against the M3 surface tonal scale rather than transparent.
0.4.4 - 2026-05-02 - M3 + Responsive consumption layer (bug fix) #
0.3.0 announced "Material 3 + Responsive" but the runtime side was never actually wired up. 0.4.4 delivers the consumption surface so the previously advertised features finally work.
Fixed #
- M3 token shorthand on
text.variant,box.padding,card.shape,card.elevation,button.elevation,icon.size/sizeToken— resolves through the correspondingtheme.<domain>.<token>. FormFactorScopeauto-wrap on the runtime root, soAppSpacing.of(context)/AppTypography.of/AppIconSizes.of/AppDensity.ofactually track the form factor.- Per-form-factor property override map (
{compact, medium, expanded, large, extraLarge, embedded, default}) resolves on every property, per spec § 14.2. - Linux:
event_listen_cb/event_cancel_cbreturn type aligned withFlMethodErrorResponse*so the plugin compiles against the currentflutter_linux.h.
Notes #
- Bumps
flutter_mcp_ui_coreto^0.3.2for the matching schema additions.
0.4.3 - 2026-05-01 - errorBoundary / errorRecovery onError spec violation fix #
Fixed #
errorBoundary(spec §2.13.11) — onError child context was registeringerror(string) instead of the canonicaleventvariable. Now registersevent: {error, stack}so{{event.error}}and{{event.stack}}resolve as the spec specifies.errorRecovery(spec §2.13.12) — same variable-name violation; now registersevent: {error, stack}. Stack trace is captured (was previously lost).errorBoundarywas re-firingonErroron every rebuild while the boundary remained in the error state. The action now dispatches exactly once per captured exception (in the post-frame callback that flips_hasError).
0.4.2 - 2026-05-01 - Tool response spec violation fix (§3.10 + §4.4.2) #
Fixed #
§3.10auto-merge —ToolActionExecutornow callsstateManager.mergeState(response)when the tool response is a Map andbindResultis not specified, instead of leaving fold to host code. Top-level keys of the response land directly on page state.§4.4.2onSuccess/onError variable — child context now exposes the canonicaleventvariable.{{event.<key>}}resolves to the response body insideonSuccess; insideonError,eventis{code, message, details}per spec. Previously the runtime registeredresponse/error(string), so the spec patterns silently failed.
Migration #
- DSL written against the previous (non-spec)
{{response.<key>}}/{{error.message}}shapes must move to{{event.<key>}}to keep working.
0.4.1 - 2026-04-30 - Template auto-registration + theme system fixes + spec alignment #
Fixed #
RuntimeEnginenow reads thetemplatesblock from the application / page definition duringinitializeand registers each entry into theTemplateRegistry(application scope for application roots, screen scope for standalone pages). Previously the block was silently ignored, so any{ "type": "use", "template": "<name>" }reference in the DSL failed to resolve and the widget went unrendered.ThemeManager.flutterThemeModenow honourssetHostBrightnessformode: 'system'resolution: when the embedder injects a brightness override, it returnsThemeMode.light/ThemeMode.darkaccordingly instead of always emittingThemeMode.system(which Flutter resolves against OS brightness only). AppPlayer-class hosts are "the system" for embedded bundles, so launcher light/dark toggles now propagate into nestedMaterialAppinstances.ThemeManager.toFlutterTheme(isDark: true)no longer falls back to the active (light)_definitionwhen the bundle declares nodarkvariant — it now returnsThemeDefinition.defaultDark()so the M3 default dark scheme is used. Previously bundles with an empty theme block could only render light, regardless of host brightness.- Template /
itemTemplateinstance state binding across close → reopen cycles. The singletonWidgetCachewas retaining widget instances whose event-handler closures captured the prior session's destroyedRenderContext(StateManager,ActionHandler). After a host-driven close → reopen, the rebuilt UI tree showed cached widgets whoseonTapmutations targeted the dead engine, so visible state never updated even though buttons appeared responsive. Fix has three parts working together:Renderer._hasEventHandlersnow recurses throughchild/children, so an ancestor container holding event-bound descendants is also flagged non-cacheable. Previously alinear/boxwrapping anonTapbutton could be cached even though its subtree's closures captured a stale context.'use'is added tononCacheableTypes. Eachusesite is an instance — its expansion MUST be a fresh widget subtree, not a shared cached widget across invocations or sessions.MCPUIRuntime.destroy()now callsWidgetCache.instance.clear()to drop all cached entries from the dying session, so the next session starts with a clean cache and cannot inherit closures bound to a destroyed engine.- The same fix covers the
list.itemTemplate(spec §9.6.1) instantiation path, since per-item expansions also produce fresh subtrees and the recursive event-handler check now catches buttons nested anywhere in the row template.
Changed (breaking — pre-launch spec alignment) #
ExtendedTemplateDefinitionwidget tree wrapper field renamedbody→contentto align with MCP UI DSL 1.3 §9.2.2 (the canonical key for the template's widget tree). The use-site invocation key (params:on theusewidget) is unchanged.TemplateRegistry.isTemplateReferencenow accepts only the canonicaltype: "use". Legacy aliases (type: "template"/type: "useTemplate") are removed in line with the spec's no-alias-accretion policy. Bundles using the legacy types must migrate totype: "use".
0.4.0 - 2026-04-29 - Render inspector hook #
- New
MCPUIRuntime.withInspector(...)for editor tooling — pairs each rendered widget with its source JSON node. Standard constructor unchanged; no overhead when no inspector is supplied.
0.3.0 - 2026-04-28 - MCP UI DSL 1.3 (Material 3 + Responsive) #
Changed (breaking) #
ThemeManagerrewritten on top of strongly-typedThemeDefinitionfromflutter_mcp_ui_core— drops the 1.2-era 11-slot raw map and parallel default scheme.- Theme bindings use the new path scheme (
theme.color.<slot>,theme.typography.<role>,theme.spacing.<token>,theme.shape.<family>,theme.elevation.<level>.shadow,theme.motion.duration.<key>). Legacytheme.colorScheme.*,theme.borderRadius.*,theme.spacing.medium,theme.elevation.smallare removed. widget_factorysemantic color slots aligned to M3 28-role + semantic family (nobackground/divider).- DSL version constant now sourced from
flutter_mcp_ui_coreMCPUIDSLVersion(runtime's ownDSLVersionenum removed). - License changed from Apache-2.0 to MIT.
Added #
McpUiThemeBuilder— convertsThemeDefinitioninto FlutterThemeData(ColorScheme,TextTheme,VisualDensity,CardThemeData,DialogThemeData).- HCT-seed-derived default theme (
SeedPalette.lightFromSeed/darkFromSeed). - Page-level theme override —
applyOverride(Map)deep-merges 14-domain JSON, returns restore callback (spec §5.7). - Responsive form factor scaffold —
FormFactorenum (compact / medium / expanded / large / embedded),FormFactorScope,ViewModeResolverpriority chain. - Four responsive token sets with
.of(context)accessors —AppSpacing,AppIconSizes,AppTypography,AppDensity. - Auto-adaptive navigation — drawer swaps to modal drawer (compact) / NavigationRail (medium) / permanent drawer (expanded+).
- New dependency:
mcp_bundle ^0.3.0.
0.2.5 #
Bug Fixes #
- Fixed resource subscription cleanup on runtime destroy to properly unsubscribe from all active resources
0.2.4 #
0.2.3 #
Documentation #
- Added important build instructions for dynamic icons
- Documented the need for
--no-tree-shake-iconsflag when building apps with dynamic icons
0.2.2 #
Bug Fixes #
- Fixed navigation state persistence to properly save and restore tab/navigation positions
- Added SharedPreferences support to CacheManager for actual disk persistence
- Fixed setState during build error in ApplicationShell navigation initialization
0.2.1 #
Bug Fixes #
- Fixed state initialization issue where page states were not properly loaded
- Unified state management by removing duplicate StateService and using StateManager directly
- Fixed page state initialization in routing system
0.2.0 #
Refactoring #
- Major internal refactoring for improved maintainability
- Enhanced code organization and structure
- Improved type safety and validation
- Better separation of concerns
0.1.0 #
Initial Release #
- Comprehensive runtime for building dynamic, reactive UIs through JSON specifications
- Support for 77+ Flutter widgets across 9 categories
- Built-in state management with automatic UI updates
- Expression binding system with support for nested paths and transforms
- Action system (state, tool, batch, conditional, navigation)
- Multiple instance support for different MCP servers
- Tool executor injection for external API integration
- Custom widget registration support
- Custom transform registration
- Theme management with light/dark mode support
- Navigation and routing system
- Dialog and notification services
- Background service management
- Lifecycle management
- Service registry pattern
- Based on MCP UI DSL v1.0 specification