diff --git a/AGENTS.md b/AGENTS.md index cfb110c9..151cb582 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **mcp_flutter** (4644 symbols, 10513 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **mcp_flutter** (4681 symbols, 10358 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CHANGELOG.md b/CHANGELOG.md index 137095f4..8b0871ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ ### Added +- `fmtk` short CLI alias for `flutter-mcp-toolkit`, including release artifacts and install script smoke checks. - Gating CI: `make check-intentcall-integration` + `.github/workflows/intentcall_eval.yml` job `intentcall-integration` (full intentcall matrix, contracts, skills grep, migrate/init/codegen `--check`). - `make macos-validate-runtime` helper (`tool/evals/run_macos_validate_runtime.sh`) for I5 macOS dogfood. - intentcall: `xsoulspace_lints` (`library.yaml` / `app.yaml`); `make analyze`; pre-release warnings on all packages ([intentcall/PRE_RELEASE.md](intentcall/PRE_RELEASE.md)); IntentCall consumer guide. @@ -91,7 +92,8 @@ - Raised workspace Dart SDK floor to `>=3.12.0 <4.0.0` across packages and updated fixture expectations. - Added Flutter SDK floor `>=3.44.0 <4.0.0` for Flutter packages (`mcp_toolkit`, `flutter_test_app`) and bumped server Docker toolchain images/checks to `dart:3.12.0-sdk`. -- `ToolRegistration` / `ResourceRegistration` canonical types moved to `intentcall_mcp`; kernel re-exports (extract-friendly). +- **Breaking:** `mcp_server_dart/lib/flutter_mcp_core.dart` no longer promises compatibility for removed private session/state/snapshot internals. Downstream code should import `intentcall_session` for `IntentSessionManager`, `StateStore`, `StateLockManager`, `SafeFileWriter`, and `IntentSnapshotStore`; Flutter MCP keeps only its server-local `FlutterSessionConnector` adapter. +- `ToolRegistration` / `ResourceRegistration` canonical types moved to `intentcall_core`; kernel re-exports (extract-friendly). - Dogfood harness paths use `--dart-define=INTENTCALL_HARNESS_ROOT` / `INTENTCALL_VISUAL_RECONSTRUCT_ROOT`. - `MigrateAgentEntriesMigrator` moved to `intentcall_core` (shared by CLI and MCP tool). - `intentcall_testing`: ecsly-style builder → `invokeWire` → `AgentResult.envelope` test. diff --git a/README.md b/README.md index de6b7274..b2fca2dd 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,20 @@ _Inspect and drive a running Flutter app from your AI assistant._ `flutter-mcp-toolkit` is a Dart MCP server + Flutter package that lets AI Agents (Codex, Zed, Cursor, Intent, Claude Code, Cline, etc..) take (semantic snapshots, tap widgets, type into forms, hot-reload, and read logs from a Flutter app) or create __its own tools and resources at runtime__ using MCP Toolkit — without leaving the conversation and work with Flutter apps in closed feedback loop - see example of it described in [OpenAI Agentic Harness](https://openai.com/index/harness-engineering/). +![Watercolor comic infographic explaining flutter-mcp-toolkit: install fmtk, add it to a Flutter app, connect an AI agent, then inspect, tap, reload, and prove changes in a close feedback loop.](docs/assets/flutter-mcp-toolkit-infographic.png) + +The picture's story: the toolkit gives an AI assistant a shared window and control loop into a running Flutter app, so it can inspect state, act like a user, hot reload, read proof, and use custom tools from your app instead of guessing. ![View Screenshots](docs/view_screenshots.gif) -> ![NOTICE]: Version 4 is currently a prerelease train. Use `4.0.0-dev.2` only if you are intentionally testing the new architecture; otherwise stay on the latest stable 3.x release until `4.0.0` is promoted. +> ![NOTICE]: Version 4 is currently a prerelease train. Use `4.0.0-dev.5` only if you are intentionally testing the new architecture; otherwise stay on the latest stable 3.x release until `4.0.0` is promoted. ## Get started in 4 steps ```bash # 1. Install the binary curl -fsSL https://raw.githubusercontent.com/Arenukvern/mcp_flutter/main/install.sh | bash +# Installs flutter-mcp-toolkit plus the short fmtk alias for repeated CLI loops. # 2. Add the toolkit to your Flutter app cd my-flutter-app @@ -77,7 +81,7 @@ Maintainers submitting to official stores: [marketplace submission runbook](docs ## What it does -The toolkit exposes 27 MCP tools (under the `fmt_*` capability prefix) across four categories: +The default toolkit surface exposes 30 MCP tools under the `fmt_*` capability prefix across four categories: - **Inspection** — semantic snapshot, view details, errors, screenshots, VM info - **Interaction** — tap, scroll, type, fill forms, hot-reload, navigate, wait_for diff --git a/decisions/0012_fmtk_cli_alias_and_harness_boundary.mdx b/decisions/0012_fmtk_cli_alias_and_harness_boundary.mdx new file mode 100644 index 00000000..2776e75c --- /dev/null +++ b/decisions/0012_fmtk_cli_alias_and_harness_boundary.mdx @@ -0,0 +1,102 @@ +# ADR 0012 — `fmtk` CLI alias and harness boundary + +- **Status:** Accepted +- **Shipped:** Unreleased +- **Authoritative source:** `mcp_server_dart/pubspec.yaml`, `install.sh`, `tool/release/build_release_artifacts.sh` + +## Context + +`flutter-mcp-toolkit` is explicit and searchable, but it is long for repeated +agent and terminal loops. The MCP tool surface already uses the compact `fmt_` +capability prefix, so agents and developers naturally reach for a shorter CLI +handle when running `doctor`, `exec`, `batch`, and `validate-runtime`. + +The short name cannot be too clever: + +- `fmt` collides with the standard Unix text formatter (`/usr/bin/fmt` on + macOS). +- `fmcp` is readable, but a PyPI package with that name already exists. +- Renaming the canonical binary would break install docs, plugin manifests, and + searchability. +- Shipping many aliases increases docs and support surface. + +At the same time, `scripts/run_exec_sweep.sh` showed a second pressure: repeated +debug/eval batteries want compact commands and good logging, but they should +not turn every step into a new public CLI or MCP verb. Repeatable scenario +documents belong in `flutter_harness`; the toolkit should stay focused on live +Flutter command primitives, CLI/MCP parity, diagnostics, and runtime validation. + +## Decision + +Ship `fmtk` as a short CLI alias for `flutter-mcp-toolkit`. + +`fmtk ` runs the same entrypoint as `flutter-mcp-toolkit `. The long +name remains canonical in install, onboarding, plugin config, marketplace, and +PATH troubleshooting docs. The MCP server binary remains +`flutter-mcp-toolkit-server`; package names, MCP registry keys, and MCP tools +continue unchanged. MCP remains the typed `fmt_*` surface. We do not add a +generic MCP `run_tool`. + +## Considered Options + +- **`fmt` alias:** rejected because it collides with the standard Unix text + formatter (`/usr/bin/fmt` locally). +- **`fmcp` alias:** rejected because `fmcp` already exists on PyPI. +- **Both `fmcp` and `fmtk`:** rejected because multiple short aliases enlarge + docs, support, completions, and install smoke surface without enough AX gain. +- **Rename `flutter-mcp-toolkit`:** rejected because the long name is still the + searchable canonical identity for installs, plugin configs, registry keys, + and onboarding. +- **`fmtk` alias:** accepted because it is compact, reads as Flutter MCP + Toolkit, and no checked pub.dev/PyPI package or unscoped npm package conflict + was found for this Dart CLI alias. + +Debug/eval workflows should first use existing surfaces: + +- `--log-level debug` +- `--output-dir` +- `--save-images` +- `doctor --json` +- `validate-runtime` +- `batch` +- `exec --name diagnose` / diagnostics bundle + +If a debug/eval battery becomes reusable across projects, graduate it to +`flutter_harness` as Harness Script or a harness example instead of adding a +scenario language to `mcp_flutter`. + +## Consequences + +**Good:** + +- Agents and developers get shorter repeated commands: `fmtk doctor --json`, + `fmtk exec --name status --args '{}'`, `fmtk batch ...`. +- The explicit `flutter-mcp-toolkit` name remains stable for install, + searchability, and docs. +- `mcp_flutter` and `flutter_harness` keep a cleaner boundary: primitives here, + reusable scenarios in the harness repo. + +**Bad:** + +- Release and install artifacts now have one more binary to build, smoke test, + and document. +- Users may ask whether `fmtk` or `flutter-mcp-toolkit` is preferred; docs must + consistently call `fmtk` the short alias. + +**Neutral:** + +- Existing MCP clients, plugin configs, package names, and `fmt_*` tool names do + not change. + +## Notes + +- Name-collision check at decision time: `fmt` is a local system command; + `fmcp` exists on PyPI; no checked pub.dev/PyPI package or unscoped npm + package conflict was found for `fmtk` as this Dart CLI alias. Scoped npm + packages and other ecosystem uses, including Factorio Mod Tool Kit, do not + block a local Dart binary alias. +- Sources checked: [PyPI `fmcp`](https://pypi.org/project/fmcp/), + [pub.dev `mcp_toolkit`](https://pub.dev/packages/mcp_toolkit), and + [Flutter MCP server docs](https://docs.flutter.dev/ai/mcp-server). +- Harness-side boundary is recorded in + `flutter_harness/decisions/0003_toolkit_primitives_and_harness_scenarios.md`. diff --git a/decisions/README.md b/decisions/README.md index 5fe710ba..188bba1a 100644 --- a/decisions/README.md +++ b/decisions/README.md @@ -1,5 +1,5 @@ # Architectural decisions (Flutter MCP Toolkit) -ADRs for the MCP server, capability kernel, and `fmt_*` tool surface (0001–0005). +ADRs for the MCP server, capability kernel, `fmt_*` tool surface, and CLI/harness boundaries. Published copy for [docs.page](https://docs.page): symlink `docs/decisions/` → this folder. diff --git a/decisions/index.mdx b/decisions/index.mdx index 85b2c9a1..c5427f04 100644 --- a/decisions/index.mdx +++ b/decisions/index.mdx @@ -24,6 +24,7 @@ day-to-day docs live under [Core Reference](/core/project_architecture) and | [0008](/decisions/0008_web_agent_invoke_js_only) | Web agent invoke — JS-only path | Accepted | Unreleased | | [0010](/decisions/0010-intentcall-extract) | intentcall Phase 7 — monorepo extract and publish | Accepted | Unreleased | | [0011](/decisions/0011_dogfood_tracker_evidence_split) | Dogfood tracker vs `.showcase` runtime artifacts | Accepted | Unreleased | +| [0012](/decisions/0012_fmtk_cli_alias_and_harness_boundary) | `fmtk` CLI alias and harness boundary | Accepted | Unreleased | ## Format diff --git a/docs/NORTH_STAR.md b/docs/NORTH_STAR.md index 0ef3117f..a9669774 100644 --- a/docs/NORTH_STAR.md +++ b/docs/NORTH_STAR.md @@ -20,5 +20,5 @@ ## Distribution -- **Ship:** `install.sh` → `flutter-mcp-toolkit` + `flutter-mcp-toolkit-server`. -- **pub.dev:** Flutter MCP Toolkit packages ship on one version train. `VERSION`, `mcp_toolkit`, `mcp_server_dart`, capability/core packages, runtime metadata, and plugin manifests must agree (currently `4.0.0-dev.1` for the breaking prerelease train). +- **Ship:** `install.sh` → `flutter-mcp-toolkit` + short alias `fmtk` + `flutter-mcp-toolkit-server`. +- **pub.dev:** Flutter MCP Toolkit packages ship on one version train. `VERSION`, `mcp_toolkit`, `mcp_server_dart`, capability/core packages, runtime metadata, and plugin manifests must agree (currently `4.0.0-dev.5` for the breaking prerelease train). diff --git a/docs/ai_agents/marketplace_copy.yaml b/docs/ai_agents/marketplace_copy.yaml index e3cb7473..47647759 100644 --- a/docs/ai_agents/marketplace_copy.yaml +++ b/docs/ai_agents/marketplace_copy.yaml @@ -11,7 +11,7 @@ long_description: | for AI-assisted Flutter development in debug mode. Built-in surface: - - 27 fmt_* MCP tools for inspection, UI control, debugging, and lifecycle + - 30 fmt_* MCP tools for inspection, UI control, debugging, and lifecycle - Bundled agent skills (flutter-mcp-toolkit-*, flutter-mcp, flutter-mcp-toolkit-custom-tools) diff --git a/docs/ai_agents/marketplace_distribution.mdx b/docs/ai_agents/marketplace_distribution.mdx index c4a69314..9204c823 100644 --- a/docs/ai_agents/marketplace_distribution.mdx +++ b/docs/ai_agents/marketplace_distribution.mdx @@ -7,7 +7,7 @@ description: How flutter-mcp-toolkit reaches Claude Code, Cursor, Codex, Cline, `flutter-mcp-toolkit` is **not** only bundled skills plus a static MCP server. It ships three layers: -1. **Host MCP** — `flutter-mcp-toolkit-server` with 27 `fmt_*` tools (inspect, control, debug). +1. **Host MCP** — `flutter-mcp-toolkit-server` with 30 `fmt_*` tools (inspect, control, debug). 2. **In-app toolkit** — `mcp_toolkit` in your Flutter app (debug only). 3. **Dynamic registry** — your app registers custom tools/resources at runtime (`AgentCallEntry`, `addMcpTool` / `addEntries`); agents use `fmt_list_client_tools_and_resources`, `fmt_client_tool`, `fmt_client_resource`. diff --git a/docs/ai_agents/overview.mdx b/docs/ai_agents/overview.mdx index a5d6eece..39415781 100644 --- a/docs/ai_agents/overview.mdx +++ b/docs/ai_agents/overview.mdx @@ -20,7 +20,7 @@ description: Install flutter-mcp-toolkit skills and MCP wiring for Claude Code, ## Three layers (read this first) -1. **Host MCP** — `flutter-mcp-toolkit-server` with 27 `fmt_*` tools. +1. **Host MCP** — `flutter-mcp-toolkit-server` with 30 `fmt_*` tools. 2. **In-app toolkit** — `mcp_toolkit` in your Flutter app (debug only). 3. **Dynamic registry** — register custom tools/resources at runtime (`AgentCallEntry`, `addEntries` / `addMcpTool`). Agents discover via `fmt_list_client_tools_and_resources` and invoke via `fmt_client_tool` / `fmt_client_resource`. Use skill **`flutter-mcp-toolkit-custom-tools`** and [Dynamic tool registry](/core/dynamic_tools_registry). diff --git a/docs/assets/flutter-mcp-toolkit-infographic-clean.png b/docs/assets/flutter-mcp-toolkit-infographic-clean.png new file mode 100644 index 00000000..1df924b7 Binary files /dev/null and b/docs/assets/flutter-mcp-toolkit-infographic-clean.png differ diff --git a/docs/assets/flutter-mcp-toolkit-infographic.png b/docs/assets/flutter-mcp-toolkit-infographic.png new file mode 100644 index 00000000..554a1273 Binary files /dev/null and b/docs/assets/flutter-mcp-toolkit-infographic.png differ diff --git a/docs/index.mdx b/docs/index.mdx index 3cfa3209..6a342eb8 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -2,6 +2,10 @@ Flutter MCP Toolkit connects a running Flutter debug app to AI assistants through MCP, so your assistant can inspect UI, read runtime errors, hot reload, and run app-defined tools. +![Watercolor comic infographic explaining Flutter MCP Toolkit: install fmtk, add it to a Flutter app, connect an AI agent, then inspect, tap, reload, and prove changes in a close feedback loop.](/assets/flutter-mcp-toolkit-infographic.png) + +The toolkit gives an AI assistant a shared window and control loop into a running Flutter app, so it can stop guessing and verify changes with runtime proof. + ## Choose Your Path | Audience | Start Here | diff --git a/docs/intentcall/README.md b/docs/intentcall/README.md index 83bc7f24..2f820b1e 100644 --- a/docs/intentcall/README.md +++ b/docs/intentcall/README.md @@ -6,7 +6,7 @@ |------|----------| | Canonical local IntentCall clone | `/Users/anton/mcp/agentkit` | | GitHub | `github.com/Arenukvern/intentcall` | -| Consumer package policy | Hosted `intentcall_* ^0.1.0` from pub.dev | +| Consumer package policy | Hosted `intentcall_* ^0.3.1` from pub.dev | | Local-development exception | Temporary sibling path overrides to `/Users/anton/mcp/agentkit/packages/intentcall_*` only | | Main integration gate | `make check-intentcall-integration` | | Repo contract gate | `make check-contracts` | @@ -18,11 +18,70 @@ Keep IntentCall product architecture out of this repository. If a doc needs to e Keep `mcp_flutter` docs focused on consumer integration, migration, regression proof, and dogfood behavior. +## Runtime sessions + +Runtime session ownership lives in IntentCall, not in this consumer repo. +Downstream debug tools that need a running app session should depend on +`intentcall_session` for session state/lifecycle and on `intentcall_core` / +`intentcall_schema` for registry, invocation, result, artifact, and event +semantics. + +The reusable session surface is: + +- `SessionState`, `PersistedState`, `StateStore`, `StateLockManager`, and + `SafeFileWriter` +- `IntentSessionManager` backed by an `IntentSessionConnector` +- `IntentSessionExecutor` for invoking an `AgentRegistry` inside a session +- `IntentSnapshotStore` for generic JSON snapshot persistence and diffing +- `AgentResult` / `AgentArtifact` from IntentCall + +Do not import `mcp_server_dart/src/cli/session/*` or other private server +internals from downstream repos. The hard public boundary is +`intentcall_session` plus the IntentCall registry/result packages; Flutter MCP +is the adapter proving those pieces against a real Flutter app. + +Breaking boundary cut: `mcp_server_dart/lib/flutter_mcp_core.dart` and +server-local session barrels no longer promise compatibility for removed +`SessionManager`, `StateStore`, `StateLockManager`, `SafeFileWriter`, or +snapshot internals. Downstream code must import `intentcall_session` for +`IntentSessionManager`, `StateStore`, `StateLockManager`, `SafeFileWriter`, and +`IntentSnapshotStore`. Flutter MCP exposes `FlutterSessionConnector` only as +its adapter between `ConnectionContext` and IntentCall sessions. + +Flutter MCP remains one runtime adapter. It keeps VM service discovery, DTD, +Flutter extension calls, screenshots, widget inspection, MCP projection, and CLI +daemon wiring in `mcp_server_dart`. Its `FlutterSessionConnector` adapts +`ConnectionContext` to `IntentSessionConnector`. + +The word "broker" should describe product composition, not a new facade layer. +For example, a visual-debug broker can compose `IntentSessionManager`, +`IntentSessionExecutor`, `AgentRegistry`, `intentcall_mcp`, and its artifact +storage. It should not re-export those packages or duplicate the command +executor under a new name. + +Dynamic registry is also IntentCall responsibility. If registry ownership, +catalog snapshots, invocation semantics, durable permissions, or transport +publication rules need to change, update `/Users/anton/mcp/agentkit` and dogfood +the hosted or overridden package here. + +Flutter MCP still owns Flutter-specific dynamic discovery: reading app-posted +tools/resources from the VM service, registering Flutter extension calls, and +bridging screenshots/widget inspection. IntentCall owns the reusable registry +events and MCP publication behavior, including query-tolerant resource reads and +resource-template de-duplication. + +Flutter MCP also owns command snapshot production. The reusable +`IntentSnapshotStore` persists and diffs JSON payloads; `CommandSnapshotService` +in `mcp_server_dart` builds those payloads by executing the Flutter MCP command +catalog through `DefaultCoreCommandExecutor`. + ## Normal consumer state Committed `mcp_flutter` state should use hosted `intentcall_*` dependencies. Do not commit normal consumer pubspecs with `agentkit/packages`, `intentcall/packages`, or `path: .*intentcall` dependencies. -Use local path overrides only while deliberately developing against the sibling IntentCall checkout, then remove them before committing consumer integration changes. +Use root `dependency_overrides` only while deliberately developing against the +sibling IntentCall checkout, then remove them before publishing consumer +integration changes. ## Consumer proof gates @@ -46,7 +105,7 @@ The durable proof should live in checks, CI, Steward scenarios, tests, and dated | Symptom | Start here | |---------|------------| | `MCPCallEntry` compile errors or migration work | [MCPCallEntry to AgentCallEntry migration](../start_here/migration_mcp_call_entry_to_agent_call_entry.md) | -| Hosted dependency or local path override drift | `tool/intentcall/check_no_path_deps.sh`, then this guide | +| Hosted dependency or local path override drift | `tool/intentcall/check_no_path_deps.sh`; use `--strict-root` before release/cutover | | Platform hooks, WebMCP, deep links, app dynamic tools | [flutter_test_app/INTENTCALL_PLATFORM.md](../../flutter_test_app/INTENTCALL_PLATFORM.md) | | Schema, `fmt_*`, CLI `exec`, or app-dynamic parity debugging | `plugin/skills/flutter-mcp-boundary-audit/` | | Unsure whether to fix `mcp_flutter` or IntentCall upstream | Fix consumer wiring here; fix architecture/package behavior in `/Users/anton/mcp/agentkit` | @@ -58,7 +117,8 @@ For future hosted dependency bumps: 1. Confirm the intended `intentcall_*` versions exist on pub.dev. 2. Update consumer constraints in `mcp_toolkit`, `mcp_server_dart`, capability packages, and `flutter_test_app` as needed. 3. Remove temporary local path overrides. -4. Run the consumer proof gates above. -5. Investigate package behavior regressions in `/Users/anton/mcp/agentkit`, not in this consumer repo. +4. Run `tool/intentcall/check_no_path_deps.sh --strict-root`. +5. Run the consumer proof gates above. +6. Investigate package behavior regressions in `/Users/anton/mcp/agentkit`, not in this consumer repo. Historical in-repo IntentCall rollout plans, specs, trackers, closure reports, hosted cutover notes, and checklist docs were removed after durable extraction. Git history is the forensic archive. diff --git a/docs/start_here/cli_quick_recipes.mdx b/docs/start_here/cli_quick_recipes.mdx index 3206d600..0ffeb1e2 100644 --- a/docs/start_here/cli_quick_recipes.mdx +++ b/docs/start_here/cli_quick_recipes.mdx @@ -1,6 +1,7 @@ # CLI Quick Recipes -Practical command patterns for `flutter-mcp-toolkit`. +Practical command patterns for `fmtk`, the short alias for the canonical +`flutter-mcp-toolkit` CLI. If you have not decided between interfaces yet, read [CLI vs MCP](/start_here/cli_vs_mcp) first. @@ -12,10 +13,13 @@ From repo root: make install ``` -Run commands with: +That builds local binaries under `mcp_server_dart/build/`. Add that directory +to `PATH`, or call the local binary directly: ```bash -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart +export PATH="$PWD/mcp_server_dart/build:$PATH" +fmtk --help +# or: ./mcp_server_dart/build/fmtk --help ``` ## 0. Two-Step Agent Flow (Recommended) @@ -30,7 +34,7 @@ flutter run --debug --host-vmservice-port=8182 --dds-port=8181 Step 2: run a single runtime validation command. ```bash -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart --save-images validate-runtime \ +fmtk --save-images validate-runtime \ --target ws://127.0.0.1:8181//ws \ --timeout-ms 10000 \ --after-reload @@ -43,23 +47,24 @@ Optional: add `--install-skill` to install bundled skill `flutter-mcp-cli-runtim ## 1. Inspect Available Capabilities ```bash -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart capabilities -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart schema -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart schema --name get_app_errors +fmtk capabilities +fmtk schema +fmtk schema --name get_app_errors ``` ## 2. Run One-Shot Health Checks ```bash -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart doctor --json -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name status --args '{}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_vm --args '{}' +fmtk doctor --json +fmtk exec --name status --args '{}' +fmtk exec --name get_vm --args '{}' +fmtk batch --steps '[{"name":"status","args":{}},{"name":"get_app_errors","args":{"count":5}}]' ``` ## 3. CLI-First Runtime Preflight (Required Before App Inspection) ```bash -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_extension_rpcs --args '{}' +fmtk exec --name get_extension_rpcs --args '{}' ``` Confirm these extensions exist before claiming screenshot/layout/error inspection works: @@ -79,25 +84,25 @@ If any are missing: ## 4. Pull Common Debug Data ```bash -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_app_errors --args '{"count":5}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_view_details --args '{}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_screenshots --args '{}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name discover_debug_apps --args '{}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name capture_ui_snapshot --args '{"includeViewDetails":true,"includeErrors":true}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name inspect_widget_at_point --args '{"x":120,"y":220}' +fmtk exec --name get_app_errors --args '{"count":5}' +fmtk exec --name get_view_details --args '{}' +fmtk exec --name get_screenshots --args '{}' +fmtk exec --name discover_debug_apps --args '{}' +fmtk exec --name capture_ui_snapshot --args '{"includeViewDetails":true,"includeErrors":true}' +fmtk exec --name inspect_widget_at_point --args '{"x":120,"y":220}' ``` ## 5. Verify Runtime Changes (Agent-Style) ```bash # baseline visual/layout state -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_screenshots --args '{}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_view_details --args '{}' +fmtk exec --name get_screenshots --args '{}' +fmtk exec --name get_view_details --args '{}' # after code edits -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name hot_reload_flutter --args '{}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_screenshots --args '{}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_view_details --args '{}' +fmtk exec --name hot_reload_flutter --args '{}' +fmtk exec --name get_screenshots --args '{}' +fmtk exec --name get_view_details --args '{}' ``` ## 6. Handle Multiple Debug Targets Explicitly @@ -105,7 +110,7 @@ dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_view_detai When calls fail with `connection_selection_required`, retry with a target URI: ```bash -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec \ +fmtk exec \ --name get_vm \ --args '{"connection":{"targetId":"ws://127.0.0.1:59490//ws"}}' ``` @@ -115,7 +120,7 @@ dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec \ If you have Flutter machine output with `app.debugPort.wsUri`, prefer `connection.uri` and paste that value exactly: ```bash -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec \ +fmtk exec \ --name get_vm \ --args '{"connection":{"uri":"ws://127.0.0.1:59490//ws"}}' ``` @@ -123,20 +128,20 @@ dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec \ ## 8. Use Session Lifecycle Commands ```bash -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name session_start --args '{"mode":"uri","uri":"ws://127.0.0.1:8181//ws"}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name session_exec --args '{"command":"get_app_errors","arguments":{"count":3}}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name session_end --args '{}' +fmtk exec --name session_start --args '{"mode":"uri","uri":"ws://127.0.0.1:8181//ws"}' +fmtk exec --name session_exec --args '{"command":"get_app_errors","arguments":{"count":3}}' +fmtk exec --name session_end --args '{}' ``` ## 9. Create Reproducible Artifacts ```bash -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart snapshot create --name baseline --args '{"commands":[{"name":"status","args":{}},{"name":"get_app_errors","args":{"count":5}}]}' --check --diff -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart snapshot create --name baseline --args '{"commands":[{"name":"status","args":{}},{"name":"get_app_errors","args":{"count":5}}]}' --backup -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart snapshot create --name after_fix --args '{"commands":[{"name":"status","args":{}},{"name":"get_app_errors","args":{"count":5}}]}' --no-overwrite -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart snapshot diff --from baseline --to after_fix -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart bundle create --from-snapshot after_fix --check --diff -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart bundle create --from-snapshot after_fix --backup +fmtk snapshot create --name baseline --args '{"commands":[{"name":"status","args":{}},{"name":"get_app_errors","args":{"count":5}}]}' --check --diff +fmtk snapshot create --name baseline --args '{"commands":[{"name":"status","args":{}},{"name":"get_app_errors","args":{"count":5}}]}' --backup +fmtk snapshot create --name after_fix --args '{"commands":[{"name":"status","args":{}},{"name":"get_app_errors","args":{"count":5}}]}' --no-overwrite +fmtk snapshot diff --from baseline --to after_fix +fmtk bundle create --from-snapshot after_fix --check --diff +fmtk bundle create --from-snapshot after_fix --backup ``` ## 10. CI Script Template @@ -146,12 +151,13 @@ dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart bundle create --from-snaps set -euo pipefail make install +export PATH="$PWD/mcp_server_dart/build:$PATH" -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart doctor --json -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_extension_rpcs --args '{}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name status --args '{}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_app_errors --args '{"count":10}' -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart snapshot create --name ci_run --args '{"commands":[{"name":"status","args":{}},{"name":"get_app_errors","args":{"count":10}}]}' --check --diff +fmtk doctor --json +fmtk exec --name get_extension_rpcs --args '{}' +fmtk exec --name status --args '{}' +fmtk exec --name get_app_errors --args '{"count":10}' +fmtk snapshot create --name ci_run --args '{"commands":[{"name":"status","args":{}},{"name":"get_app_errors","args":{"count":10}}]}' --check --diff ``` ## 11. Common Failure Recovery diff --git a/docs/start_here/cli_vs_mcp.mdx b/docs/start_here/cli_vs_mcp.mdx index 2d48b14e..4bf0a3b7 100644 --- a/docs/start_here/cli_vs_mcp.mdx +++ b/docs/start_here/cli_vs_mcp.mdx @@ -2,7 +2,7 @@ Both interfaces are valid and both execute the same core command catalog. -- `flutter-mcp-toolkit`: direct command-line interface for scripts, CI, snapshots, and deterministic automation. +- `flutter-mcp-toolkit`: canonical direct command-line interface for scripts, CI, snapshots, and deterministic automation. `fmtk` is the short alias for the same executable. - `flutter-mcp-toolkit-server` (MCP server): assistant-facing interface over MCP for Codex/Claude/Cursor workflows. Recommended default: start with CLI, then layer MCP client workflows on top. @@ -24,7 +24,7 @@ Internally they share the same execution core, so behavior and command semantics ## Quick Difference Table -| Topic | CLI (`flutter-mcp-toolkit`) | MCP Server (`flutter-mcp-toolkit-server`) | +| Topic | CLI (`flutter-mcp-toolkit`, alias `fmtk`) | MCP Server (`flutter-mcp-toolkit-server`) | | --- | --- | --- | | Primary user | developers, CI, automation scripts | AI assistants and agent chat clients | | Interface | terminal commands / JSON args | MCP tools/resources via client | @@ -46,7 +46,7 @@ Use CLI when you need: Example: ```bash -dart run mcp_server_dart/bin/flutter_mcp_toolkit.dart exec --name get_vm --args '{}' +fmtk exec --name get_vm --args '{}' ``` Need command patterns you can copy quickly? @@ -74,7 +74,7 @@ Example (conceptual). MCP tool names carry the `fmt_` capability prefix A common pattern: -1. Start with CLI preflight: `doctor --json` and `get_extension_rpcs`. +1. Start with CLI preflight: `fmtk doctor --json` and `fmtk exec --name get_extension_rpcs --args '{}'`. 2. Use CLI for reproducible screenshot/layout/error baselines. 3. Use MCP in editor/chat for interactive follow-up loops. 4. Share CLI outputs/artifacts back into assistant workflows. @@ -92,6 +92,7 @@ A common pattern: - Core capability definitions come from the same command catalog. - High-signal debugging commands are shared: `discover_debug_apps`, `capture_ui_snapshot`, `inspect_widget_at_point`. - Low-signal diagnostics (`get_active_ports`, `dynamicRegistryStats`) remain CLI-available but are not MCP-exposed by default. +- Debug/eval batteries should group existing primitives (`--log-level debug`, `--output-dir`, `--save-images`, `doctor --json`, `validate-runtime`, `batch`, `exec --name diagnose`) before adding public verbs. Repeatable scenario documents belong in `flutter_harness`. ## Related Docs diff --git a/docs/superpowers/evals/2026-05-26-webmcp-verification.md b/docs/superpowers/evals/2026-05-26-webmcp-verification.md index 8de74fe4..6b9887a1 100644 --- a/docs/superpowers/evals/2026-05-26-webmcp-verification.md +++ b/docs/superpowers/evals/2026-05-26-webmcp-verification.md @@ -5,7 +5,9 @@ ## Scope -Verify **true WebMCP** (`navigator.modelContext.registerTool` / W3C CG draft) for `flutter_test_app`, distinct from VM extensions + dynamic registry dogfood. +Verify **true WebMCP** (`document.modelContext.registerTool` with +`navigator.modelContext.registerTool` fallback / W3C CG draft) for +`flutter_test_app`, distinct from VM extensions + dynamic registry dogfood. ## Code paths (verified in repo) @@ -13,13 +15,14 @@ Verify **true WebMCP** (`navigator.modelContext.registerTool` / W3C CG draft) fo |------|------| | `flutter_test_app/web/intentcall_webmcp.generated.js` | JS bootstrap from manifest; feature-detect + `registerTool` | | `flutter_test_app/web/index.html` | Loads generated JS before Flutter | -| `intentcall/packages/intentcall_platform/.../agent_web_mcp_bootstrap_web.dart` | Dart `js_interop` registration after `addEntries` (debug web) | +| `/Users/anton/mcp/agentkit/packages/intentcall_platform/.../agent_web_mcp_bootstrap_web.dart` | Dart `js_interop` registration after `addEntries` (debug web) | | `mcp_toolkit/lib/src/mcp_toolkit_extensions.dart` | Calls `AgentWebMcpBootstrap.registerFromEntries` on web | -| `intentcall/packages/intentcall_webmcp/` | `WebMcpPublishAdapter` (registry hot-sync) — **not wired in flutter_test_app** | +| `/Users/anton/mcp/agentkit/packages/intentcall_webmcp/` | `WebMcpPublishAdapter` (registry hot-sync) — **not wired in flutter_test_app** | ### Detection -- **JS:** `'modelContext' in nav && typeof nav.modelContext.registerTool === 'function'` +- **JS:** generated code checks `document.modelContext.registerTool` first, + then falls back to `navigator.modelContext.registerTool`. - **Dart:** `navigator.hasProperty('modelContext')` (does not check `registerTool` is a function) Both paths **no-op silently** when API absent. diff --git a/docs/superpowers/evals/README.md b/docs/superpowers/evals/README.md index 93e4715c..1d6b1693 100644 --- a/docs/superpowers/evals/README.md +++ b/docs/superpowers/evals/README.md @@ -31,9 +31,9 @@ WebMCP-specific verification (separate from VM dogfood): [2026-05-26-webmcp-veri Archived spec gap matrix (iter 1–11): [archive/2026-05-26-dogfood-spec-gap-matrix.md](./archive/2026-05-26-dogfood-spec-gap-matrix.md). Current iterations: [`docs/evidence/dogfood/dogfood_web_eval.yaml`](../../evidence/dogfood/dogfood_web_eval.yaml). -## Workspace layout (Phase 7) +## Workspace layout -intentcall libraries live under **`intentcall/packages/`** (standalone workspace). Dogfood and CLI commands run from **mcp_flutter** repo root; `run_dogfood_eval.sh` runs `dart test packages/intentcall_testing` inside `intentcall/`. +IntentCall libraries live in the sibling **`/Users/anton/mcp/agentkit/packages/`** workspace. Dogfood and CLI commands run from **mcp_flutter** repo root; hosted package checks should use the consumer gates in `docs/intentcall/README.md`. ## How to run diff --git a/flutter_test_app/INTENTCALL_PLATFORM.md b/flutter_test_app/INTENTCALL_PLATFORM.md index c37ab7a5..764b0f3f 100644 --- a/flutter_test_app/INTENTCALL_PLATFORM.md +++ b/flutter_test_app/INTENTCALL_PLATFORM.md @@ -22,7 +22,7 @@ The hook installer manages idempotent markers for WebMCP script tags, Android sh ## Dependency policy -Normal repo state resolves hosted `intentcall_* ^0.1.0` packages from pub.dev. Local path overrides to `/Users/anton/mcp/agentkit/packages/intentcall_*` are local-development-only and should not be committed for hosted consumer integration. +Normal repo state resolves hosted `intentcall_* ^0.3.1` packages from pub.dev. Local path overrides to `/Users/anton/mcp/agentkit/packages/intentcall_*` are local-development-only and should not be committed for hosted consumer integration. ## Manifest and platform sync @@ -40,10 +40,10 @@ Use `--check` in CI or pre-merge drift checks. The app keeps `web/agent_manifest | Path | Consumer proof | |------|----------------| -| WebMCP | `web/index.html` loads generated WebMCP JS for early discovery; Flutter bootstrap installs the Dart execute hook for app entries. | +| WebMCP | `web/index.html` loads generated WebMCP JS for early discovery; Flutter bootstrap installs the Dart execute hook and `registerAgentWebMcpFromRegistry` proves Dart registry execution with `app_intentcall_bridge_ping`. | | VM service | App dynamic tools are listed through `fmt_list_client_tools_and_resources` and invoked through `fmt_client_tool`. | | CLI / MCP host | Host tools keep `exec` and `fmt_*` naming parity through shared toolkit schemas and tests. | -| Native invoke | `IntentCallInvokeLinkListener` in `lib/main.dart` logs `intentcall://invoke/` deep links. | +| Native invoke | `IntentCallPendingInvocations.takePending()` drains generated-wrapper envelopes and dispatches them through Dart; `IntentCallInvokeLinkListener` remains fallback logging/dispatch only. | For schema parity, `fmt_*`, CLI `exec`, app-dynamic tool names, and fail-closed validation troubleshooting, use `plugin/skills/flutter-mcp-boundary-audit/`. Keep canonical schema and platform-projection policy in `/Users/anton/mcp/agentkit`. @@ -55,6 +55,11 @@ make web-showcase # Verify WebMCP browser setup flutter-mcp-toolkit webmcp verify --web-port 8080 +flutter-mcp-toolkit webmcp verify --web-port 8080 \ + --tool-name app_intentcall_bridge_ping \ + --tool-args '{"echo":"webmcp-proof"}' \ + --expect-result-field source \ + --expect-result-value dart_registry # macOS runtime proof after showcase launch make macos-validate-runtime diff --git a/flutter_test_app/android/app/src/main/res/xml/intentcall_shortcuts.xml b/flutter_test_app/android/app/src/main/res/xml/intentcall_shortcuts.xml index 3eae5b4f..a59e6473 100644 --- a/flutter_test_app/android/app/src/main/res/xml/intentcall_shortcuts.xml +++ b/flutter_test_app/android/app/src/main/res/xml/intentcall_shortcuts.xml @@ -8,7 +8,16 @@ android:shortcutLongLabel="Demo ping tool for WebMCP platform sync"> + android:data="mcpfluttertest://invoke/app_demo_ping" /> + + + diff --git a/flutter_test_app/ios/Flutter/AppFrameworkInfo.plist b/flutter_test_app/ios/Flutter/AppFrameworkInfo.plist index 97210574..ab875c7b 100644 --- a/flutter_test_app/ios/Flutter/AppFrameworkInfo.plist +++ b/flutter_test_app/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/flutter_test_app/ios/Podfile b/flutter_test_app/ios/Podfile index e549ee22..620e46eb 100644 --- a/flutter_test_app/ios/Podfile +++ b/flutter_test_app/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/flutter_test_app/ios/Runner.xcodeproj/project.pbxproj b/flutter_test_app/ios/Runner.xcodeproj/project.pbxproj index 2f7b8fdc..5d3a2ccf 100644 --- a/flutter_test_app/ios/Runner.xcodeproj/project.pbxproj +++ b/flutter_test_app/ios/Runner.xcodeproj/project.pbxproj @@ -11,11 +11,13 @@ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; C19EE2443BA0FA56B3582E9D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 783DD0B09F2F65941333179D /* Pods_RunnerTests.framework */; }; D8DE0126CF7AD0CCAC8C91AC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 590B4BECF076A7F8F788461A /* Pods_Runner.framework */; }; + D94D6E55DE4085929D1550DE /* IntentCallGenerated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACD08B50FF5BD527FB66694 /* IntentCallGenerated.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -51,6 +53,7 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 783DD0B09F2F65941333179D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -65,6 +68,7 @@ EB9B8E56B1BB7C3DA6346200 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; F1FD1B0265C60BFA3AEFC0AB /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; FA09A1DE49AE211F4510B183 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 2ACD08B50FF5BD527FB66694 /* IntentCallGenerated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/IntentCallGenerated.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -80,6 +84,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, D8DE0126CF7AD0CCAC8C91AC /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -107,6 +112,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -147,6 +153,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 2ACD08B50FF5BD527FB66694 /* IntentCallGenerated.swift */, ); path = Runner; sourceTree = ""; @@ -199,12 +206,16 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 45B536670E004C69BF5DFCCD /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -238,6 +249,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -270,22 +284,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - A7F3E91B2C4D5E6F8A9B0C1D /* intentcall Codegen */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "intentcall Codegen"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# intentcall-platform: begin\nbash \"$SRCROOT/intentcall_codegen.sh\"\n# intentcall-platform: end\n"; - showEnvVarsInLog = 0; - }; 3695F617CDC3568DB8F2BB01 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -346,6 +344,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 45B536670E004C69BF5DFCCD /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -361,6 +376,22 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + A7F3E91B2C4D5E6F8A9B0C1D /* intentcall Codegen */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "intentcall Codegen"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# intentcall-platform: begin\nbash \"$SRCROOT/intentcall_codegen.sh\"\n# intentcall-platform: end\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -378,6 +409,7 @@ files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + D94D6E55DE4085929D1550DE /* IntentCallGenerated.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -454,7 +486,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -584,7 +616,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -635,7 +667,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -725,6 +757,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/flutter_test_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutter_test_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15cada48..c3fedb29 100644 --- a/flutter_test_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/flutter_test_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/flutter_test_app/ios/Runner/Generated/IntentCallGenerated.swift b/flutter_test_app/ios/Runner/Generated/IntentCallGenerated.swift index 2b6b098a..f59f7fed 100644 --- a/flutter_test_app/ios/Runner/Generated/IntentCallGenerated.swift +++ b/flutter_test_app/ios/Runner/Generated/IntentCallGenerated.swift @@ -10,30 +10,64 @@ import AppKit @available(iOS 16.0, macOS 13.0, *) struct AppDemoPingIntent: AppIntent { static var title: LocalizedStringResource = "Demo ping tool for WebMCP platform sync" + static var openAppWhenRun: Bool = true func perform() async throws -> some IntentResult { - await IntentCallNativeBridge.invoke(qualifiedName: "app_demo_ping") - return .result() + var arguments: [String: Any] = [:] + let invocationId = await IntentCallNativeBridge.enqueue(qualifiedName: "app_demo_ping", arguments: arguments) + return .result(dialog: IntentDialog("Queued invocation \(invocationId) for app dispatch.")) + } +} + +@available(iOS 16.0, macOS 13.0, *) +struct AppIntentcallBridgePingIntent: AppIntent { + static var title: LocalizedStringResource = "Proof that native/WebMCP dispatch executes Dart registry logic" + static var openAppWhenRun: Bool = true + + @Parameter(title: "Echo") + var echo: String + + func perform() async throws -> some IntentResult { + var arguments: [String: Any] = [:] + arguments["echo"] = echo + let invocationId = await IntentCallNativeBridge.enqueue(qualifiedName: "app_intentcall_bridge_ping", arguments: arguments) + return .result(dialog: IntentDialog("Queued invocation \(invocationId) for app dispatch.")) } } @available(iOS 16.0, macOS 13.0, *) struct IntentCallShortcutsProvider: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { - [ - AppShortcut(intent: AppDemoPingIntent(), phrases: ["Demo Ping"]), - ] + AppShortcut(intent: AppDemoPingIntent(), phrases: ["\(.applicationName) Demo Ping"]) + AppShortcut(intent: AppIntentcallBridgePingIntent(), phrases: ["\(.applicationName) Intentcall Bridge Ping"]) } } enum IntentCallNativeBridge { - static func invoke(qualifiedName: String) async { - guard let url = URL(string: "intentcall://invoke/\(qualifiedName)") else { return } + private static let pendingKey = "intentcall.pending_invocations" + private static let fallbackScheme: String? = "mcpfluttertest" + + static func enqueue(qualifiedName: String, arguments: [String: Any]) async -> String { + let invocationId = UUID().uuidString + let item: [String: Any] = [ + "id": invocationId, + "qualifiedName": qualifiedName, + "arguments": arguments, + "source": "native.generated", + "createdAt": ISO8601DateFormatter().string(from: Date()) + ] + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] + pending.append(item) + UserDefaults.standard.set(pending, forKey: pendingKey) + guard let scheme = fallbackScheme, let url = URL(string: "\(scheme)://invoke/\(qualifiedName)") else { return invocationId } #if canImport(UIKit) await UIApplication.shared.open(url) #elseif canImport(AppKit) NSWorkspace.shared.open(url) #endif + return invocationId } } diff --git a/flutter_test_app/ios/Runner/Info.plist b/flutter_test_app/ios/Runner/Info.plist index 41bd59d0..46491343 100644 --- a/flutter_test_app/ios/Runner/Info.plist +++ b/flutter_test_app/ios/Runner/Info.plist @@ -1,7 +1,9 @@ - + + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/flutter_test_app/lib/agent_state.dart b/flutter_test_app/lib/agent_state.dart index 27be84bb..4f6ee54f 100644 --- a/flutter_test_app/lib/agent_state.dart +++ b/flutter_test_app/lib/agent_state.dart @@ -59,6 +59,16 @@ class AgentState extends ChangeNotifier { notifyListeners(); } + @visibleForTesting + void resetForTest() { + _counter = 0; + _greeting = ''; + _toggle = false; + _slider = 50; + _lastLog = ''; + notifyListeners(); + } + Map snapshot() => { 'counter': _counter, 'greeting': _greeting, diff --git a/flutter_test_app/lib/main.dart b/flutter_test_app/lib/main.dart index 5b6c45db..f5289e33 100644 --- a/flutter_test_app/lib/main.dart +++ b/flutter_test_app/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:intentcall_platform/intentcall_platform.dart'; import 'package:intentcall_platform/intentcall_platform_flutter.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -15,6 +16,9 @@ import 'package:test_app/visual_reconstruct_screen.dart'; var _initialEntriesRegistered = false; var _delayedEntriesRegistered = false; +final _intentCallProofRegistry = InMemoryAgentRegistry(); +late final IntentCallNativeBridge _intentCallProofBridge; +const _intentCallProtocolScheme = 'mcpfluttertest'; /// Registered on [MCPToolkitBinding.instance] for `navigate` / `handle_dialog`. final GlobalKey showcaseNavigatorKey = @@ -32,11 +36,23 @@ Future main({final bool enableDelayedMcpRegistration = true}) async { if (!kIsWeb) { unawaited( IntentCallInvokeLinkListener( + protocolScheme: _intentCallProtocolScheme, onQualifiedName: (final name) { debugPrint('intentcall invoke: $name'); + unawaited( + _intentCallProofBridge.execute( + IntentCallInvocationEnvelope( + id: 'deeplink-${DateTime.now().microsecondsSinceEpoch}', + qualifiedName: name, + arguments: const {}, + source: IntentCallInvocationSource.deepLink, + ), + ), + ); }, ).start(), ); + unawaited(_drainIntentCallPendingInvocations()); } if (enableDelayedMcpRegistration) { // Mirror the previous bootstrap timing: a brief delay so a remote @@ -73,10 +89,48 @@ Map _getUserPreferences(final String category) { }; } +AgentCallEntry _intentCallBridgePingEntry() => AgentCallEntry.tool( + namespace: 'app', + name: 'intentcall_bridge_ping', + description: + 'Proof that WebMCP/native bridge dispatch executes Dart registry logic.', + inputSchema: const { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'echo': { + 'type': 'string', + 'description': 'Value echoed by Dart registry proof.', + }, + }, + 'required': ['echo'], + }, + handler: (final args) async => AgentResult.success( + message: 'intentcall bridge pong', + data: { + 'source': 'dart_registry', + 'kind': 'intentcall_bridge_ping', + 'echo': args['echo'], + 'timestamp': DateTime.now().toUtc().toIso8601String(), + }, + ), +); + +Future _drainIntentCallPendingInvocations() async { + final pending = await const IntentCallPendingInvocations().takePending(); + for (final envelope in pending) { + final result = await _intentCallProofBridge.execute(envelope); + debugPrint( + 'intentcall pending invocation ${envelope.qualifiedName}: ${result.ok}', + ); + } +} + Future _registerInitialMCPTools() async { if (_initialEntriesRegistered) return; _initialEntriesRegistered = true; final binding = MCPToolkitBinding.instance; + final intentCallBridgePingEntry = _intentCallBridgePingEntry(); final fibonacciEntry = mcpToolkitTool( namespace: 'app', @@ -131,15 +185,35 @@ Future _registerInitialMCPTools() async { ); final dogfoodEntries = buildAgentDogfoodEntries(); + _intentCallProofRegistry.register(intentCallBridgePingEntry.toRegistration()); + _intentCallProofBridge = IntentCallNativeBridge.bindRegistry( + registry: _intentCallProofRegistry, + policy: const IntentCallAuthorizationPolicy( + allowedSources: { + IntentCallInvocationSource.webMcpDart, + IntentCallInvocationSource.nativeGenerated, + IntentCallInvocationSource.deepLink, + }, + allowedQualifiedNames: {'app_intentcall_bridge_ping'}, + ), + ); await binding.addEntries( entries: { fibonacciEntry, appStateEntry, agentStateEntry, + intentCallBridgePingEntry, ...dogfoodEntries, }, ); if (kIsWeb) { + registerAgentWebMcpFromRegistry( + _intentCallProofRegistry, + policy: const IntentCallAuthorizationPolicy( + allowedSources: {IntentCallInvocationSource.webMcpDart}, + allowedQualifiedNames: {'app_intentcall_bridge_ping'}, + ), + ); await wireWebMcpPublishAdapterDogfood(dogfoodEntries); } print('Initial MCP tools and resources registered'); diff --git a/flutter_test_app/lib/showcase_screen.dart b/flutter_test_app/lib/showcase_screen.dart index 1a4f8aea..93893267 100644 --- a/flutter_test_app/lib/showcase_screen.dart +++ b/flutter_test_app/lib/showcase_screen.dart @@ -22,21 +22,54 @@ class ShowcaseScreen extends StatefulWidget { class _ShowcaseScreenState extends State { final TextEditingController _textController = TextEditingController(); + bool _syncingGreetingFromState = false; @override void initState() { super.initState(); - _textController.addListener(() { - AgentState.instance.greeting = _textController.text; - }); + _textController.addListener(_syncGreetingFromController); + AgentState.instance.addListener(_syncGreetingToController); + _syncGreetingToController(); } @override void dispose() { + AgentState.instance.removeListener(_syncGreetingToController); + _textController.removeListener(_syncGreetingFromController); _textController.dispose(); super.dispose(); } + void _syncGreetingFromController() { + if (_syncingGreetingFromState) return; + AgentState.instance.greeting = _textController.text; + } + + void _syncGreetingToController() { + final greeting = AgentState.instance.greeting; + if (_textController.text == greeting) return; + final selection = _textController.selection; + final baseOffset = selection.isValid + ? selection.baseOffset.clamp(0, greeting.length).toInt() + : greeting.length; + final extentOffset = selection.isValid + ? selection.extentOffset.clamp(0, greeting.length).toInt() + : greeting.length; + _syncingGreetingFromState = true; + try { + _textController.value = TextEditingValue( + text: greeting, + selection: TextSelection( + baseOffset: baseOffset, + extentOffset: extentOffset, + ), + composing: TextRange.empty, + ); + } finally { + _syncingGreetingFromState = false; + } + } + @override Widget build(final BuildContext context) { return Scaffold( @@ -52,28 +85,28 @@ class _ShowcaseScreenState extends State { horizontal: 32, vertical: 48, ), - children: const [ - _Header(), - SizedBox(height: 56), - _CaptureSection(), - SizedBox(height: 64), - _VisualReconstructSection(), - SizedBox(height: 64), + children: [ + const _Header(), + const SizedBox(height: 56), + const _CaptureSection(), + const SizedBox(height: 64), + const _VisualReconstructSection(), + const SizedBox(height: 64), _TapSection(), - SizedBox(height: 56), + const SizedBox(height: 56), _TypeSection(), - SizedBox(height: 56), + const SizedBox(height: 56), _ToggleSection(), - SizedBox(height: 56), + const SizedBox(height: 56), _SlideSection(), - SizedBox(height: 56), - _ScrollSection(), - SizedBox(height: 56), - _IterateSection(), - SizedBox(height: 56), + const SizedBox(height: 56), + const _ScrollSection(), + const SizedBox(height: 56), + const _IterateSection(), + const SizedBox(height: 56), _DebugSection(), - SizedBox(height: 96), - _Footer(), + const SizedBox(height: 96), + const _Footer(), ], ), ), @@ -608,6 +641,8 @@ class _DebugSection extends StatelessWidget { Semantics( identifier: 'emit_log_button', button: true, + onTap: () => + state.logMessage('agent hook: log at ${DateTime.now()}'), child: TextButton( onPressed: () => state.logMessage('agent hook: log at ${DateTime.now()}'), @@ -619,6 +654,7 @@ class _DebugSection extends StatelessWidget { Semantics( identifier: 'trigger_error_button', button: true, + onTap: _triggerCaughtError, child: TextButton( onPressed: _triggerCaughtError, style: TextButton.styleFrom(foregroundColor: _kAccent), @@ -629,6 +665,7 @@ class _DebugSection extends StatelessWidget { Semantics( identifier: 'show_test_dialog_button', button: true, + onTap: () => _showTestDialog(context), child: TextButton( onPressed: () => _showTestDialog(context), style: TextButton.styleFrom(foregroundColor: _kAccent), diff --git a/flutter_test_app/linux/intentcall_protocol.desktop b/flutter_test_app/linux/intentcall_protocol.desktop index 8ac39ee1..f1858e52 100644 --- a/flutter_test_app/linux/intentcall_protocol.desktop +++ b/flutter_test_app/linux/intentcall_protocol.desktop @@ -1,10 +1,11 @@ # Generated by intentcall_platform — do not edit by hand. # tool: app_demo_ping +# tool: app_intentcall_bridge_ping [Desktop Entry] Type=Application Name=Flutter App (IntentCall) Comment=IntentCall deep-link handler Exec=@EXEC@ %u -MimeType=x-scheme-handler/intentcall; +MimeType=x-scheme-handler/mcpfluttertest; NoDisplay=true Terminal=false diff --git a/flutter_test_app/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter_test_app/macos/Flutter/GeneratedPluginRegistrant.swift index f62fcd34..110f4108 100644 --- a/flutter_test_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter_test_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,9 @@ import FlutterMacOS import Foundation import app_links +import intentcall_platform func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + IntentCallPlatformPlugin.register(with: registry.registrar(forPlugin: "IntentCallPlatformPlugin")) } diff --git a/flutter_test_app/macos/Runner.xcodeproj/project.pbxproj b/flutter_test_app/macos/Runner.xcodeproj/project.pbxproj index 79dbdc15..469db348 100644 --- a/flutter_test_app/macos/Runner.xcodeproj/project.pbxproj +++ b/flutter_test_app/macos/Runner.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 6DEAAA80CE48DCCC44BD127F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F1091A9648B0D46DC6021945 /* Pods_Runner.framework */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 88EA4A566435B9ECEF462337 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A46E78CE3F0B8772DE07687B /* Pods_RunnerTests.framework */; }; + D94D6E55DE4085929D1550DE /* IntentCallGenerated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2ACD08B50FF5BD527FB66694 /* IntentCallGenerated.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -92,6 +93,7 @@ A46E78CE3F0B8772DE07687B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DCF8B6CCA50186BA8FF8BA85 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; F1091A9648B0D46DC6021945 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 2ACD08B50FF5BD527FB66694 /* IntentCallGenerated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/IntentCallGenerated.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -188,6 +190,7 @@ 33E51914231749380026EE4D /* Release.entitlements */, 33CC11242044D66E0003C045 /* Resources */, 33BA886A226E78AF003329D5 /* Configs */, + 2ACD08B50FF5BD527FB66694 /* IntentCallGenerated.swift */, ); path = Runner; sourceTree = ""; @@ -248,6 +251,7 @@ 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 33A3CAB12ED98AA9E0B6C9C3 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -376,6 +380,23 @@ shellPath = /bin/sh; shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; }; + 33A3CAB12ED98AA9E0B6C9C3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 33CC111E2044C6BF0003C045 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -453,6 +474,7 @@ 33CC11162044BFA20003C046 /* ShowcasePlatformViewFactory.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + D94D6E55DE4085929D1550DE /* IntentCallGenerated.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/flutter_test_app/macos/Runner/Generated/IntentCallGenerated.swift b/flutter_test_app/macos/Runner/Generated/IntentCallGenerated.swift index 2b6b098a..f59f7fed 100644 --- a/flutter_test_app/macos/Runner/Generated/IntentCallGenerated.swift +++ b/flutter_test_app/macos/Runner/Generated/IntentCallGenerated.swift @@ -10,30 +10,64 @@ import AppKit @available(iOS 16.0, macOS 13.0, *) struct AppDemoPingIntent: AppIntent { static var title: LocalizedStringResource = "Demo ping tool for WebMCP platform sync" + static var openAppWhenRun: Bool = true func perform() async throws -> some IntentResult { - await IntentCallNativeBridge.invoke(qualifiedName: "app_demo_ping") - return .result() + var arguments: [String: Any] = [:] + let invocationId = await IntentCallNativeBridge.enqueue(qualifiedName: "app_demo_ping", arguments: arguments) + return .result(dialog: IntentDialog("Queued invocation \(invocationId) for app dispatch.")) + } +} + +@available(iOS 16.0, macOS 13.0, *) +struct AppIntentcallBridgePingIntent: AppIntent { + static var title: LocalizedStringResource = "Proof that native/WebMCP dispatch executes Dart registry logic" + static var openAppWhenRun: Bool = true + + @Parameter(title: "Echo") + var echo: String + + func perform() async throws -> some IntentResult { + var arguments: [String: Any] = [:] + arguments["echo"] = echo + let invocationId = await IntentCallNativeBridge.enqueue(qualifiedName: "app_intentcall_bridge_ping", arguments: arguments) + return .result(dialog: IntentDialog("Queued invocation \(invocationId) for app dispatch.")) } } @available(iOS 16.0, macOS 13.0, *) struct IntentCallShortcutsProvider: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { - [ - AppShortcut(intent: AppDemoPingIntent(), phrases: ["Demo Ping"]), - ] + AppShortcut(intent: AppDemoPingIntent(), phrases: ["\(.applicationName) Demo Ping"]) + AppShortcut(intent: AppIntentcallBridgePingIntent(), phrases: ["\(.applicationName) Intentcall Bridge Ping"]) } } enum IntentCallNativeBridge { - static func invoke(qualifiedName: String) async { - guard let url = URL(string: "intentcall://invoke/\(qualifiedName)") else { return } + private static let pendingKey = "intentcall.pending_invocations" + private static let fallbackScheme: String? = "mcpfluttertest" + + static func enqueue(qualifiedName: String, arguments: [String: Any]) async -> String { + let invocationId = UUID().uuidString + let item: [String: Any] = [ + "id": invocationId, + "qualifiedName": qualifiedName, + "arguments": arguments, + "source": "native.generated", + "createdAt": ISO8601DateFormatter().string(from: Date()) + ] + objc_sync_enter(UserDefaults.standard) + defer { objc_sync_exit(UserDefaults.standard) } + var pending = UserDefaults.standard.array(forKey: pendingKey) as? [[String: Any]] ?? [] + pending.append(item) + UserDefaults.standard.set(pending, forKey: pendingKey) + guard let scheme = fallbackScheme, let url = URL(string: "\(scheme)://invoke/\(qualifiedName)") else { return invocationId } #if canImport(UIKit) await UIApplication.shared.open(url) #elseif canImport(AppKit) NSWorkspace.shared.open(url) #endif + return invocationId } } diff --git a/flutter_test_app/pubspec.yaml b/flutter_test_app/pubspec.yaml index ebd3302f..6602ba6d 100644 --- a/flutter_test_app/pubspec.yaml +++ b/flutter_test_app/pubspec.yaml @@ -29,7 +29,7 @@ environment: # the latest version available on pub.dev. To see which dependencies have newer # versions available, run `flutter pub outdated`. dependencies: - intentcall_core: ^0.1.0 + intentcall_core: ^0.3.1 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 @@ -37,8 +37,8 @@ dependencies: sdk: flutter mcp_toolkit: path: ../mcp_toolkit - intentcall_platform: ^0.1.0 - intentcall_webmcp: ^0.1.0 + intentcall_platform: ^0.3.1 + intentcall_webmcp: ^0.3.1 app_links: ^6.4.0 provider: ^6.1.5+1 vm_service: ^15.0.2 @@ -49,6 +49,10 @@ dev_dependencies: flutter_test: sdk: flutter +dependency_overrides: + flutter_mcp_toolkit_core: + path: ../packages/core + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec # The following section is specific to Flutter packages. diff --git a/flutter_test_app/test/showcase_reactivity_test.dart b/flutter_test_app/test/showcase_reactivity_test.dart new file mode 100644 index 00000000..5b83e35e --- /dev/null +++ b/flutter_test_app/test/showcase_reactivity_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:test_app/agent_state.dart'; +import 'package:test_app/showcase_screen.dart'; + +void main() { + tearDown(() { + AgentState.instance.resetForTest(); + }); + + testWidgets('showcase reacts to external AgentState mutations', ( + final tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1000, 2400)); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); + + AgentState.instance.resetForTest(); + await tester.pumpWidget(const MaterialApp(home: ShowcaseScreen())); + + expect(find.text('—'), findsOneWidget); + expect(find.text('Off'), findsOneWidget); + expect(find.text('50'), findsOneWidget); + expect(tester.widget(find.byType(Slider)).value, 50); + + AgentState.instance + ..increment() + ..greeting = 'hello from vm' + ..toggle = true + ..slider = 73; + await tester.pump(); + + expect(find.text('1'), findsOneWidget); + expect(find.text('hello from vm'), findsNWidgets(2)); + expect(find.text('On'), findsOneWidget); + expect(find.text('73'), findsOneWidget); + expect(tester.widget(find.byType(Slider)).value, 73); + expect( + tester.widget(find.byType(TextField)).controller?.text, + 'hello from vm', + ); + }); +} diff --git a/flutter_test_app/web/agent_manifest.json b/flutter_test_app/web/agent_manifest.json index ee23caad..01d6754b 100644 --- a/flutter_test_app/web/agent_manifest.json +++ b/flutter_test_app/web/agent_manifest.json @@ -1,6 +1,7 @@ { "version": 1, "platform": "web", + "protocolScheme": "mcpfluttertest", "tools": [ { "qualifiedName": "app_demo_ping", @@ -12,6 +13,24 @@ "type": "object", "properties": {} } + }, + { + "qualifiedName": "app_intentcall_bridge_ping", + "namespace": "app", + "name": "intentcall_bridge_ping", + "description": "Proof that native/WebMCP dispatch executes Dart registry logic", + "kind": "tool", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "echo": { + "type": "string", + "description": "Value echoed by Dart registry proof." + } + }, + "required": ["echo"] + } } ] } diff --git a/flutter_test_app/web/intentcall_webmcp.generated.js b/flutter_test_app/web/intentcall_webmcp.generated.js index 998ec989..fbabe833 100644 --- a/flutter_test_app/web/intentcall_webmcp.generated.js +++ b/flutter_test_app/web/intentcall_webmcp.generated.js @@ -1,10 +1,18 @@ // Generated by intentcall_platform — do not edit by hand. (function intentcallWebMcpBootstrap(global) { 'use strict'; + var doc = global.document; var nav = global.navigator; - if (!nav || !('modelContext' in nav) || typeof nav.modelContext.registerTool !== 'function') { + var modelContext = + doc && doc.modelContext && typeof doc.modelContext.registerTool === 'function' + ? doc.modelContext + : nav && nav.modelContext && typeof nav.modelContext.registerTool === 'function' + ? nav.modelContext + : null; + if (!modelContext) { return; } + var fallbackEnabled = false; var invokePath = "/agent/invoke"; var tools = [ { @@ -14,6 +22,23 @@ "type": "object", "properties": {} } + }, + { + "name": "app_intentcall_bridge_ping", + "description": "Proof that native/WebMCP dispatch executes Dart registry logic", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "echo": { + "type": "string", + "description": "Value echoed by Dart registry proof." + } + }, + "required": [ + "echo" + ] + } } ]; @@ -160,6 +185,13 @@ } function fetchInvoke(name, args) { + if (!fallbackEnabled) { + return Promise.resolve({ + ok: false, + code: 'runtime_unavailable', + message: 'No Dart WebMCP runtime registered for ' + name + '.', + }); + } return global.fetch(invokePath + '?name=' + encodeURIComponent(name), { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -171,7 +203,7 @@ tools.forEach(function (tool) { try { - nav.modelContext.registerTool({ + modelContext.registerTool({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema, diff --git a/flutter_test_app/web/manifest.json b/flutter_test_app/web/manifest.json index 0c44eb38..affd6b38 100644 --- a/flutter_test_app/web/manifest.json +++ b/flutter_test_app/web/manifest.json @@ -38,6 +38,12 @@ "short_name": "Demo Ping", "description": "Demo ping tool for WebMCP platform sync", "url": "/agent/invoke?name=app_demo_ping" + }, + { + "name": "Intentcall Bridge Ping", + "short_name": "Intentcall Bridge Ping", + "description": "Proof that native/WebMCP dispatch executes Dart registry logic", + "url": "/agent/invoke?name=app_intentcall_bridge_ping" } ], "protocol_handlers": [ @@ -48,6 +54,10 @@ { "protocol": "web+intentcall", "url": "/agent/invoke?name=app_demo_ping&payload=%s" + }, + { + "protocol": "web+intentcall", + "url": "/agent/invoke?name=app_intentcall_bridge_ping&payload=%s" } ] } diff --git a/flutter_test_app/windows/intentcall_protocol.reg b/flutter_test_app/windows/intentcall_protocol.reg index 4ec40ffa..eff96e51 100644 --- a/flutter_test_app/windows/intentcall_protocol.reg +++ b/flutter_test_app/windows/intentcall_protocol.reg @@ -1,10 +1,11 @@ ; Generated by intentcall_platform — do not edit by hand. ; tool: app_demo_ping +; tool: app_intentcall_bridge_ping Windows Registry Editor Version 5.00 -[HKEY_CURRENT_USER\Software\Classes\intentcall] +[HKEY_CURRENT_USER\Software\Classes\mcpfluttertest] @="URL:IntentCall Protocol" "URL Protocol"="" -[HKEY_CURRENT_USER\Software\Classes\intentcall\shell\open\command] +[HKEY_CURRENT_USER\Software\Classes\mcpfluttertest\shell\open\command] @="\"@APP_EXE@\" \"%1\"" diff --git a/flutter_test_app/windows/intentcall_protocol_msix.xml b/flutter_test_app/windows/intentcall_protocol_msix.xml index 65aa07eb..639384fa 100644 --- a/flutter_test_app/windows/intentcall_protocol_msix.xml +++ b/flutter_test_app/windows/intentcall_protocol_msix.xml @@ -1,7 +1,7 @@ - + - + IntentCall diff --git a/install.sh b/install.sh index 9f09437d..ae0be22a 100755 --- a/install.sh +++ b/install.sh @@ -29,7 +29,7 @@ usage() { Usage: ./install.sh [--version ] [--install-dir ] [--repo ] [--base-url ] curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash -s -- [--version ] -Installs flutter-mcp-toolkit and flutter-mcp-toolkit-server from GitHub release artifacts. +Installs flutter-mcp-toolkit, its short fmtk alias, and flutter-mcp-toolkit-server from GitHub release artifacts. When run from a git clone, version defaults to the repo VERSION file (or runtime_version.dart). When piped from curl without --version, the latest GitHub release is used if available. @@ -205,12 +205,20 @@ package_dir="$work_dir/flutter_mcp_${version_no_prefix}_${triple}" mkdir -p "$INSTALL_DIR" install -m 0755 "$package_dir/bin/flutter-mcp-toolkit" "$INSTALL_DIR/flutter-mcp-toolkit" +if [[ -f "$package_dir/bin/fmtk" ]]; then + install -m 0755 "$package_dir/bin/fmtk" "$INSTALL_DIR/fmtk" +else + # Backward compatibility for pre-fmtk release artifacts: synthesize the alias + # from the canonical CLI binary so raw-main installers can still install older tags. + install -m 0755 "$package_dir/bin/flutter-mcp-toolkit" "$INSTALL_DIR/fmtk" +fi install -m 0755 \ "$package_dir/bin/flutter-mcp-toolkit-server" \ "$INSTALL_DIR/flutter-mcp-toolkit-server" echo "Installed binaries to $INSTALL_DIR" "$INSTALL_DIR/flutter-mcp-toolkit" --help >/dev/null +"$INSTALL_DIR/fmtk" --help >/dev/null if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then shell_name="$(basename "${SHELL:-sh}")" @@ -238,3 +246,4 @@ fi echo "Install complete: flutter-mcp-toolkit ${version_no_prefix}" echo "Smoke test command: $INSTALL_DIR/flutter-mcp-toolkit --help" +echo "Short alias: $INSTALL_DIR/fmtk --help" diff --git a/makefile b/makefile index 03b9f1a6..dc0af4a5 100644 --- a/makefile +++ b/makefile @@ -14,6 +14,7 @@ inspect: check-contracts: cd $(CURDIR) && \ bash tool/contracts/check_sdk_parity.sh && \ + bash tool/contracts/check_cli_alias_surface.sh && \ bash tool/contracts/check_docs_drift.sh && \ bash tool/contracts/check_plugin_surfaces.sh && \ bash tool/contracts/check_version_sync.sh && \ diff --git a/mcp_server_dart/README.md b/mcp_server_dart/README.md index 75ea14fe..b933c14a 100644 --- a/mcp_server_dart/README.md +++ b/mcp_server_dart/README.md @@ -8,9 +8,9 @@ Tool names on MCP are prefixed with **`fmt_`**, binaries and `mcpServers` keys w This project now uses a shared core execution layer: -1. `flutter-mcp-toolkit` is the canonical command surface (connect, inspect, execute, diagnostics). +1. `flutter-mcp-toolkit` is the canonical command surface (connect, inspect, execute, diagnostics). `fmtk` is the short alias for the same CLI entrypoint. 2. `flutter-mcp-toolkit-server` is a thin MCP protocol adapter that maps MCP tool/resource calls to the same core executor. -3. **MCP `tools/list` names** use the **`fmt_`** capability prefix; **CLI `exec --name`** uses bare catalog names. Resource URIs are unchanged. +3. **MCP `tools/list` names** use the **`fmt_`** capability prefix; **CLI `exec --name`** uses command catalog names or aliases outside the MCP `tools/call` namespace. Resource URIs are unchanged. The shared core module is available as `flutter_mcp_core` inside this package. @@ -20,7 +20,7 @@ Use this sequence first on macOS: 1. Add `mcp_toolkit` to the app and call `MCPToolkitBinding.instance.bootstrapFlutter(...)`. 2. Launch the app in debug mode. -3. Run `flutter-mcp-toolkit validate-runtime`. +3. Run `fmtk validate-runtime` (or the canonical long form `flutter-mcp-toolkit validate-runtime`). 4. Query dynamic entries in this order: `fmt_list_client_tools_and_resources`, `fmt_client_tool`, @@ -47,20 +47,23 @@ Safe-write flags for write-producing commands: - `snapshot create`: `--check --diff --backup --no-overwrite` - `bundle create`: `--check --diff --backup --no-overwrite` -`exec` targets commands in the shared `CommandCatalog` (these are the -unprefixed catalog names; the CLI is not subject to the MCP capability -prefix): +`exec` targets commands in the shared `CommandCatalog` (CLI command names may +be bare because they appear only after `--name`; the CLI is not the MCP +`tools/call` namespace): `connect`, `session_start`, `session_exec`, `session_end`, `diagnose`, `watch`, `explain_errors`, `status`, `discover_debug_apps`, `get_vm`, `get_extension_rpcs`, `hot_reload_flutter`, `hot_restart_flutter`, `get_active_ports`, `get_app_errors`, `get_screenshots`, `focus_window`, `get_view_details`, `inspect_widget_at_point`, `capture_ui_snapshot`, `debug_dump_layer_tree`, `debug_dump_semantics_tree`, `debug_dump_render_tree`, `debug_dump_focus_tree`, `fmt_list_client_tools_and_resources`, `fmt_client_tool`, `fmt_client_resource`, `dynamicRegistryStats`, `semantic_snapshot`, `tap_widget`, `long_press`, `enter_text`, `reveal_search`, `scroll`, `swipe`, `drag`, `hot_reload_and_capture`, `evaluate_dart_expression`, `get_recent_logs`. -> **MCP names**. When invoked via MCP `tools/call`, every catalog tool above -> surfaces under the `fmt_` capability prefix (e.g. `fmt_tap_widget`, -> `fmt_hot_reload_and_capture`). The dynamic-registry host trio -> (`fmt_list_client_tools_and_resources`, `fmt_client_tool`, `fmt_client_resource`) and -> `dynamicRegistryStats` stay unprefixed in MCP. CLI examples below use the -> catalog name unchanged. +> **MCP names**. When invoked via MCP `tools/call`, exposed tools use the +> `fmt_` capability prefix (e.g. `fmt_tap_widget`, +> `fmt_hot_reload_and_capture`). The dynamic-registry host trio is exposed as +> `fmt_list_client_tools_and_resources`, `fmt_client_tool`, and +> `fmt_client_resource` where enabled. `dynamicRegistryStats` remains available +> from the CLI catalog for low-level diagnostics and is not part of the default +> MCP tool surface. Interaction tools (catalog names: `semantic_snapshot` → `tap_widget` / `enter_text` / `reveal_search` / `scroll` / `swipe` / `long_press` / `drag`) follow a Playwright-style ref model: take a snapshot, then pass `ref: "s_N"` (and optional `snapshotId` for staleness detection) into the interaction tool. `reveal_search` is the bounded helper for off-screen semantic targets: it snapshots, matches one narrow selector, scrolls up to `maxAttempts`, and returns a fresh `ref`/`snapshotId` plus trace. `hot_reload_and_capture` fuses reload + screenshot + fresh snapshot + errors. `evaluate_dart_expression` runs an ad-hoc Dart expression against the app's root library. See [docs/start_here/cli_quick_recipes.mdx](../docs/start_here/cli_quick_recipes.mdx) and [docs/guides/interaction_cookbook.mdx](../docs/guides/interaction_cookbook.mdx) for the full surface and golden paths. +Packaged installs include both `flutter-mcp-toolkit` and `fmtk`; examples below use `dart run` for source-tree development. Prefer `fmtk` for compact local loops and keep `flutter-mcp-toolkit` in onboarding, install, PATH, and MCP config docs where the searchable canonical name helps. + CLI runs the same shared command catalog/executor as MCP. Preferred debugging path: `discover_debug_apps` -> `capture_ui_snapshot` -> `inspect_widget_at_point`. `get_active_ports` and `dynamicRegistryStats` remain available in CLI for low-level diagnostics, but are intentionally not MCP-exposed by default. diff --git a/mcp_server_dart/bin/flutter_mcp_toolkit.dart b/mcp_server_dart/bin/flutter_mcp_toolkit.dart index d3bc3f84..c42e18d8 100644 --- a/mcp_server_dart/bin/flutter_mcp_toolkit.dart +++ b/mcp_server_dart/bin/flutter_mcp_toolkit.dart @@ -15,7 +15,9 @@ import 'package:flutter_mcp_toolkit_server/src/cli/init_intentcall_platform_comm import 'package:flutter_mcp_toolkit_server/src/cli/init_mode.dart'; import 'package:flutter_mcp_toolkit_server/src/cli/init_target.dart'; import 'package:flutter_mcp_toolkit_server/src/cli/migrate_agent_entries_command.dart'; +import 'package:flutter_mcp_toolkit_server/src/cli/session/flutter_session_connector.dart'; import 'package:flutter_mcp_toolkit_server/src/cli/webmcp_command.dart'; +import 'package:intentcall_session/intentcall_session.dart'; Future main(final List args) async { late final ArgResults parsed; @@ -94,8 +96,8 @@ Future main(final List args) async { initialStickyEndpointUri: bootstrapStickyEndpoint, ); - final sessionManager = SessionManager( - connectionContext: connectionContext, + final sessionManager = IntentSessionManager( + connector: FlutterSessionConnector(connectionContext: connectionContext), stateStore: stateStore, ); await sessionManager.load(); @@ -130,10 +132,12 @@ Future main(final List args) async { ); final catalog = CommandCatalog.instance; - final snapshotStore = SnapshotStore(snapshotsDir: '$stateRoot/snapshots'); + final commandSnapshots = CommandSnapshotService( + snapshotsDir: '$stateRoot/snapshots', + ); final bundleBuilder = BundleBuilder( bundlesDir: '$stateRoot/bundles', - snapshotStore: snapshotStore, + snapshotStore: commandSnapshots.snapshotStore, stateFilePath: statePath, ); final doctorRunner = DoctorRunner( @@ -151,7 +155,7 @@ Future main(final List args) async { executor: executor, sessionManager: sessionManager, catalog: catalog, - snapshotStore: snapshotStore, + commandSnapshots: commandSnapshots, bundleBuilder: bundleBuilder, configuration: configuration, ); @@ -167,7 +171,7 @@ Future main(final List args) async { catalog: catalog, configuration: configuration, sessionManager: sessionManager, - snapshotStore: snapshotStore, + commandSnapshots: commandSnapshots, bundleBuilder: bundleBuilder, doctorRunner: doctorRunner, ); @@ -195,8 +199,8 @@ Future _runOneShot({ required final DefaultCoreCommandExecutor executor, required final CommandCatalog catalog, required final CoreRuntimeConfiguration configuration, - required final SessionManager sessionManager, - required final SnapshotStore snapshotStore, + required final IntentSessionManager sessionManager, + required final CommandSnapshotService commandSnapshots, required final BundleBuilder bundleBuilder, required final DoctorRunner doctorRunner, }) async { @@ -285,7 +289,7 @@ Future _runOneShot({ return _runSnapshotCommand( snapshotCommand: snapshotCommand, - snapshotStore: snapshotStore, + commandSnapshots: commandSnapshots, executor: executor, catalog: catalog, ); @@ -366,7 +370,7 @@ Future _runBatchCommand({ required final ArgResults command, required final DefaultCoreCommandExecutor executor, required final CommandCatalog catalog, - required final SessionManager sessionManager, + required final IntentSessionManager sessionManager, }) async { final steps = _parseBatchStepsJson(command.option('steps')); if (steps.isEmpty) { @@ -434,7 +438,7 @@ Future _runBatchCommand({ Future _runSnapshotCommand({ required final ArgResults snapshotCommand, - required final SnapshotStore snapshotStore, + required final CommandSnapshotService commandSnapshots, required final DefaultCoreCommandExecutor executor, required final CommandCatalog catalog, }) async { @@ -451,7 +455,7 @@ Future _runSnapshotCommand({ final args = _parseArgumentsJson(snapshotCommand.option('args')); final writeOptions = _safeWriteOptionsFrom(snapshotCommand); try { - final snapshot = await snapshotStore.createSnapshot( + final snapshot = await commandSnapshots.createSnapshot( id: name, executor: executor, catalog: catalog, @@ -486,7 +490,10 @@ Future _runSnapshotCommand({ } try { - final diff = await snapshotStore.diffSnapshots(fromId: from, toId: to); + final diff = await commandSnapshots.diffSnapshots( + fromId: from, + toId: to, + ); return CoreResult.success(data: diff); // ignore: avoid_catching_errors } on ArgumentError catch (e) { @@ -834,6 +841,20 @@ Future _runValidateRuntime({ flutterLayerFallback: 'capture_ui_snapshot_after_reload_flutter_layer', ); final captureFallbackUsed = _validateRuntimeUsedFlutterLayerFallback(steps); + final primaryCaptureFailed = _stepFailed(steps, 'capture_ui_snapshot'); + final postReloadPrimaryCaptureFailed = _stepFailed( + steps, + 'capture_ui_snapshot_after_reload', + ); + final fallbackCaptureSucceeded = + captureFallbackUsed && + (_stepOk(steps, 'capture_ui_snapshot_flutter_layer') || + _stepOk(steps, 'capture_ui_snapshot_after_reload_flutter_layer')); + final verdict = fallbackCaptureSucceeded + ? 'pass_with_fallback' + : failedSteps == 0 + ? 'pass' + : 'pass_with_recoverable_failures'; return CoreResult.success( data: { 'doctor': doctorData, @@ -850,7 +871,11 @@ Future _runValidateRuntime({ 'errorsCount': errorsCount, 'visualCaptureCommand': 'capture_ui_snapshot', 'requiredExtensions': requiredSorted, + 'verdict': verdict, 'captureFallbackUsed': captureFallbackUsed, + 'primaryCaptureFailed': primaryCaptureFailed, + 'postReloadPrimaryCaptureFailed': postReloadPrimaryCaptureFailed, + 'fallbackCaptureSucceeded': fallbackCaptureSucceeded, 'capturePlatformViewsDetected': capturePlatformViewsDetected, 'captureFocusAttempted': captureFocusAttempted, 'captureBackend': @@ -873,7 +898,7 @@ Future _runPermissionsCommand({ required final ArgResults command, required final CoreRuntimeConfiguration configuration, required final DefaultCoreCommandExecutor executor, - required final SessionManager sessionManager, + required final IntentSessionManager sessionManager, }) async { final actionCommand = command.command; if (actionCommand == null) { @@ -930,7 +955,7 @@ Future _readBootstrapState(final StateStore store) async { Future _preconnectIfNeeded({ required final ArgResults parsed, required final CoreCommand command, - required final SessionManager sessionManager, + required final IntentSessionManager sessionManager, required final DefaultCoreCommandExecutor executor, final ConnectCommand? explicitConnectionOverride, }) => preconnectForExecution( @@ -945,7 +970,7 @@ Future _executeExecCommand({ required final ArgResults parsed, required final DefaultCoreCommandExecutor executor, required final CommandCatalog catalog, - required final SessionManager sessionManager, + required final IntentSessionManager sessionManager, required final String name, required final Map rawArgs, }) async { @@ -1653,6 +1678,12 @@ bool _validateRuntimeUsedFlutterLayerFallback( return false; } +bool _stepFailed(final List> steps, final String name) => + steps.any((final step) => step['name'] == name && step['ok'] != true); + +bool _stepOk(final List> steps, final String name) => + steps.any((final step) => step['name'] == name && step['ok'] == true); + bool _shouldRetryPostReloadCapture(final CoreResult result) { final code = result.error?.code; if (code == CoreErrorCode.connectFailed || @@ -2146,6 +2177,24 @@ final _argParser = ArgParser(allowTrailingOptions: false) 'web-port', defaultsTo: '8080', help: 'Prefer app tab on this port', + ) + ..addOption( + 'tool-name', + help: + 'Optional WebMCP tool name to invoke through modelContextTesting.', + ) + ..addOption( + 'tool-args', + defaultsTo: '{}', + help: 'JSON object arguments for --tool-name.', + ) + ..addOption( + 'expect-result-field', + help: 'Optional top-level result field that must match.', + ) + ..addOption( + 'expect-result-value', + help: 'Expected JSON/string value for --expect-result-field.', ), ), ) @@ -2531,9 +2580,20 @@ Future _runWebmcpSubcommand(final ArgResults command) async { case 'chrome-args': return runWebmcpChromeArgs(); case 'verify': + final rawToolArgs = jsonDecode(sub.option('tool-args') ?? '{}'); + final toolArgs = rawToolArgs is Map + ? rawToolArgs.cast() + : const {}; + final rawExpected = sub.option('expect-result-value'); return runWebmcpVerify( cdpPort: int.tryParse(sub.option('cdp-port') ?? ''), preferredWebPort: int.tryParse(sub.option('web-port') ?? '') ?? 8080, + toolName: sub.option('tool-name'), + toolArguments: toolArgs, + expectResultField: sub.option('expect-result-field'), + expectResultValue: rawExpected == null + ? null + : _parseJsonScalarOrString(rawExpected), ); default: io.stderr.writeln('Unknown webmcp subcommand: ${sub.name}'); @@ -2541,10 +2601,19 @@ Future _runWebmcpSubcommand(final ArgResults command) async { } } +Object? _parseJsonScalarOrString(final String value) { + try { + return jsonDecode(value); + } on FormatException { + return value; + } +} + String _usageWebmcp() => ''' Usage: flutter-mcp-toolkit webmcp chrome-args flutter-mcp-toolkit webmcp verify [--cdp-port PORT] [--web-port 8080] + [--tool-name NAME --tool-args JSON --expect-result-field FIELD --expect-result-value VALUE] Enables repeatable WebMCP E2E on Chrome without manual chrome://flags each session. See docs/superpowers/evals/2026-05-26-webmcp-verification.md @@ -2559,9 +2628,11 @@ Use scripts/run_web_showcase.sh for a logged dogfood launch. String _usageWebmcpVerify() => ''' Usage: flutter-mcp-toolkit webmcp verify [--cdp-port PORT] [--web-port 8080] + [--tool-name NAME --tool-args JSON --expect-result-field FIELD --expect-result-value VALUE] Probes a running Chrome tab via CDP for navigator.modelContext.registerTool. -Exit 0 when WebMCP API is active; 1 with fix hints when not. +With --tool-name, invokes the WebMCP tool through modelContextTesting and fails +if the generated network fallback is used. '''; Future _runMigrateSubcommand(final ArgResults command) async { diff --git a/mcp_server_dart/lib/flutter_mcp_core.dart b/mcp_server_dart/lib/flutter_mcp_core.dart index 0ee63c00..29862e06 100644 --- a/mcp_server_dart/lib/flutter_mcp_core.dart +++ b/mcp_server_dart/lib/flutter_mcp_core.dart @@ -1,6 +1,17 @@ // Copyright (c) 2025, Flutter Inspector MCP Server authors. // Licensed under the MIT License. +/// Server-side Flutter MCP internals that remain intentionally exported. +/// +/// New consumers should prefer focused public surfaces such as +/// `flutter_mcp_attach.dart`, `intentcall_session`, and package-specific +/// capability APIs. +/// +/// Removed private session, state, lock, safe-write, and snapshot internals are +/// not re-exported here. Import `intentcall_session` for those APIs; Flutter +/// MCP only adapts them through its server-local `FlutterSessionConnector`. +library; + export 'src/capabilities/ai_providers/error_summary_provider.dart'; export 'src/capabilities/core/capabilities_model.dart'; export 'src/capabilities/diagnostics/diagnostics_bundle.dart'; @@ -10,11 +21,7 @@ export 'src/capabilities/visual_capture/core_image_file_saver.dart'; export 'src/capabilities/visual_capture/visual_capture.dart'; export 'src/cli/cli_daemon_server.dart'; export 'src/cli/diagnostics/bundle_builder.dart'; +export 'src/cli/diagnostics/command_snapshot_service.dart'; export 'src/cli/diagnostics/doctor_runner.dart'; -export 'src/cli/session/session_manager.dart'; -export 'src/cli/session/state_lock_manager.dart'; -export 'src/cli/session/state_store.dart'; -export 'src/cli/sessions_persistence/safe_writes.dart'; -export 'src/cli/sessions_persistence/snapshot_store.dart'; export 'src/runtime_version.dart'; export 'src/shared_core/shared_core.dart'; diff --git a/mcp_server_dart/lib/src/capabilities/dynamic_registry/core_dynamic_registry_gateway.dart b/mcp_server_dart/lib/src/capabilities/dynamic_registry/core_dynamic_registry_gateway.dart index c031cb4b..91a9da5e 100644 --- a/mcp_server_dart/lib/src/capabilities/dynamic_registry/core_dynamic_registry_gateway.dart +++ b/mcp_server_dart/lib/src/capabilities/dynamic_registry/core_dynamic_registry_gateway.dart @@ -1,22 +1,24 @@ // Copyright (c) 2025, Flutter Inspector MCP Server authors. // Licensed under the MIT License. -import 'dart:convert'; - import 'package:dart_mcp/server.dart'; import 'package:flutter_mcp_toolkit_server/src/capabilities/dynamic_registry/dynamic_gateway.dart'; import 'package:flutter_mcp_toolkit_server/src/capabilities/dynamic_registry/dynamic_registry.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/types/error_codes.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/types/results.dart'; +import 'package:intentcall_core/intentcall_core.dart'; +import 'package:intentcall_mcp/intentcall_mcp.dart'; /// Dynamic gateway backed by the live in-process registry used by MCP server. final class RegistryBackedDynamicGateway implements CoreDynamicGateway { RegistryBackedDynamicGateway({ required this.registry, + required this.agentRegistry, required this.discoveryService, }); final DynamicRegistry registry; + final AgentRegistry agentRegistry; final RegistryDiscoveryService? Function() discoveryService; @override @@ -50,8 +52,8 @@ final class RegistryBackedDynamicGateway implements CoreDynamicGateway { ); } - final result = await registry.forwardToolCall(toolName, arguments); - if (result == null) { + final result = await agentRegistry.invoke(toolName, arguments); + if (!result.ok && result.code == 'intent_not_found') { return CoreResult.failure( code: CoreErrorCode.dynamicToolFailed, message: 'Tool not found: $toolName', @@ -59,30 +61,21 @@ final class RegistryBackedDynamicGateway implements CoreDynamicGateway { ); } - final textContents = result.content.whereType().toList(); - final message = textContents.isEmpty - ? 'Tool executed successfully' - : textContents.first.text; - - Object? parameters = const {}; - if (textContents.length > 1) { - try { - parameters = jsonDecode(textContents[1].text); - } on Exception { - parameters = {'raw': textContents[1].text}; - } - } - - if (result.isError ?? false) { + if (!result.ok) { return CoreResult.failure( - code: CoreErrorCode.dynamicToolFailed, - message: message, - details: parameters, + code: result.code ?? CoreErrorCode.dynamicToolFailed, + message: result.message, + details: result.details, ); } return CoreResult.success( - data: {'message': message, 'parameters': parameters}, + data: { + 'message': result.message.isEmpty + ? 'Tool executed successfully' + : result.message, + 'parameters': result.data, + }, ); } @@ -95,16 +88,37 @@ final class RegistryBackedDynamicGateway implements CoreDynamicGateway { ); } - final result = await registry.forwardResourceRead(resourceUri); - if (result == null || result.contents.isEmpty) { + final result = await agentRegistry.invoke(resourceUri, { + 'uri': resourceUri, + }); + if (!result.ok && result.code == 'intent_not_found') { return CoreResult.failure( code: CoreErrorCode.dynamicResourceFailed, message: 'Resource not found: $resourceUri', details: {'reason': 'resource_not_found', 'resourceUri': resourceUri}, ); } + if (!result.ok) { + return CoreResult.failure( + code: result.code ?? CoreErrorCode.dynamicResourceFailed, + message: result.message, + details: result.details, + ); + } + + final readResult = agentResultToReadResourceResult( + result, + uri: resourceUri, + ); + if (readResult.contents.isEmpty) { + return CoreResult.failure( + code: CoreErrorCode.dynamicResourceFailed, + message: 'Resource returned no content: $resourceUri', + details: {'reason': 'resource_empty', 'resourceUri': resourceUri}, + ); + } - final first = result.contents.first; + final first = readResult.contents.first; if (first is TextResourceContents) { return CoreResult.success( data: { diff --git a/mcp_server_dart/lib/src/capabilities/dynamic_registry/dynamic_gateway.dart b/mcp_server_dart/lib/src/capabilities/dynamic_registry/dynamic_gateway.dart index b4f82484..195389ba 100644 --- a/mcp_server_dart/lib/src/capabilities/dynamic_registry/dynamic_gateway.dart +++ b/mcp_server_dart/lib/src/capabilities/dynamic_registry/dynamic_gateway.dart @@ -6,13 +6,18 @@ import 'dart:convert'; import 'package:flutter_mcp_toolkit_core/flutter_mcp_toolkit_core.dart'; +import 'package:flutter_mcp_toolkit_server/src/capabilities/dynamic_registry/dynamic_result_normalizer.dart'; import 'package:flutter_mcp_toolkit_server/src/mcp_toolkit_consts.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/vm_connections/connection_context.dart'; import 'package:from_json_to_json/from_json_to_json.dart'; import 'package:intentcall_schema/intentcall_schema.dart'; import 'package:is_dart_empty_or_not/is_dart_empty_or_not.dart'; -/// Dynamic registry behavior adapter used by the shared command executor. +/// Flutter dynamic invocation adapter used by the shared command executor. +/// +/// IntentCall owns registry and invocation semantics. This server-local gateway +/// is only the Flutter transport bridge for VM extension and in-process MCP +/// registry adapters. abstract interface class CoreDynamicGateway { Future listClientToolsAndResources(); @@ -205,25 +210,15 @@ final class VmExtensionDynamicGateway implements CoreDynamicGateway { ); } - final payload = {...json} - ..remove('content') - ..remove('mimeType') - ..remove('blob') - ..remove('isBlob'); - final message = jsonDecodeString(payload['message']); - payload.remove('message'); - final normalizedPayload = { - if (message.isNotEmpty) 'message': message, - if (payload.isNotEmpty) 'parameters': payload, - }; + final normalized = normalizeDynamicTextResourcePayload(json); return CoreResult.success( data: { 'uri': resourceUri, - 'content': jsonEncode(normalizedPayload), + 'content': normalized.content, 'mimeType': 'application/json', - if (message.isNotEmpty) 'message': message, - if (payload.isNotEmpty) 'payload': payload, + if (normalized.message.isNotEmpty) 'message': normalized.message, + if (normalized.payload.isNotEmpty) 'payload': normalized.payload, }, ); } catch (e) { diff --git a/mcp_server_dart/lib/src/capabilities/dynamic_registry/dynamic_registry.dart b/mcp_server_dart/lib/src/capabilities/dynamic_registry/dynamic_registry.dart index e42a49e3..70321c70 100644 --- a/mcp_server_dart/lib/src/capabilities/dynamic_registry/dynamic_registry.dart +++ b/mcp_server_dart/lib/src/capabilities/dynamic_registry/dynamic_registry.dart @@ -10,6 +10,7 @@ import 'package:dart_mcp/server.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_mcp_toolkit_core/flutter_mcp_toolkit_core.dart'; import 'package:flutter_mcp_toolkit_server/flutter_mcp_server.dart'; +import 'package:flutter_mcp_toolkit_server/src/capabilities/dynamic_registry/dynamic_result_normalizer.dart'; import 'package:flutter_mcp_toolkit_server/src/mcp_toolkit_consts.dart'; import 'package:from_json_to_json/from_json_to_json.dart'; import 'package:intentcall_core/intentcall_core.dart'; @@ -553,24 +554,14 @@ final class DynamicRegistry { ); } - final payload = {...data} - ..remove('content') - ..remove('mimeType') - ..remove('blob') - ..remove('isBlob'); - final message = jsonDecodeString(payload['message']); - payload.remove('message'); - final normalizedPayload = { - if (message.isNotEmpty) 'message': message, - if (payload.isNotEmpty) 'parameters': payload, - }; + final normalized = normalizeDynamicTextResourcePayload(data); return readResourceResultToAgentResult( ReadResourceResult( contents: [ TextResourceContents( uri: requestedUri, - text: jsonEncode(normalizedPayload), + text: normalized.content, mimeType: 'application/json', ), ], @@ -698,6 +689,7 @@ InputSchema inputSchemaFromMcpTool(final Tool tool) { final ObjectSchema schema; try { schema = tool.inputSchema; + // ignore: avoid_catching_errors } on ArgumentError catch (e) { throw ArgumentError('Tool "${tool.name}" is missing inputSchema: $e'); } diff --git a/mcp_server_dart/lib/src/capabilities/dynamic_registry/dynamic_result_normalizer.dart b/mcp_server_dart/lib/src/capabilities/dynamic_registry/dynamic_result_normalizer.dart new file mode 100644 index 00000000..ea7d2763 --- /dev/null +++ b/mcp_server_dart/lib/src/capabilities/dynamic_registry/dynamic_result_normalizer.dart @@ -0,0 +1,43 @@ +// Copyright (c) 2025, Flutter Inspector MCP Server authors. +// Licensed under the MIT License. + +import 'dart:convert'; + +import 'package:from_json_to_json/from_json_to_json.dart'; + +/// Normalized text payload for dynamic resource responses that did not return +/// explicit `content` or `blob` fields. +final class DynamicTextResourcePayload { + const DynamicTextResourcePayload({ + required this.content, + required this.message, + required this.payload, + }); + + final String content; + final String message; + final Map payload; +} + +DynamicTextResourcePayload normalizeDynamicTextResourcePayload( + final Map data, +) { + final payload = {...data} + ..remove('content') + ..remove('mimeType') + ..remove('blob') + ..remove('isBlob'); + final message = jsonDecodeString(payload['message']); + payload.remove('message'); + + final normalizedPayload = { + if (message.isNotEmpty) 'message': message, + if (payload.isNotEmpty) 'parameters': payload, + }; + + return DynamicTextResourcePayload( + content: jsonEncode(normalizedPayload), + message: message, + payload: payload, + ); +} diff --git a/mcp_server_dart/lib/src/capabilities/dynamic_registry/registry_discovery_service.dart b/mcp_server_dart/lib/src/capabilities/dynamic_registry/registry_discovery_service.dart index d670f856..ba218f62 100644 --- a/mcp_server_dart/lib/src/capabilities/dynamic_registry/registry_discovery_service.dart +++ b/mcp_server_dart/lib/src/capabilities/dynamic_registry/registry_discovery_service.dart @@ -251,7 +251,7 @@ final class RegistryDiscoveryService { 'stackTrace: $stackTrace', logger: _loggerName, ); - dynamicRegistry.unregisterApp(); + _unregisterCurrentDynamicApp(); } } @@ -297,10 +297,14 @@ final class RegistryDiscoveryService { 'stackTrace: $stackTrace', logger: _loggerName, ); - dynamicRegistry.unregisterApp(); + _unregisterCurrentDynamicApp(); } } + void _unregisterCurrentDynamicApp() { + server.unregisterDynamicApp(dynamicRegistry.appId); + } + /// Test hook for [_processRegistrationResponse]. @visibleForTesting Future processRegistrationResponseForTesting( diff --git a/mcp_server_dart/lib/src/capabilities/visual_capture/desktop_capture_recovery.dart b/mcp_server_dart/lib/src/capabilities/visual_capture/desktop_capture_recovery.dart index a9b5d3c4..5ecc50a1 100644 --- a/mcp_server_dart/lib/src/capabilities/visual_capture/desktop_capture_recovery.dart +++ b/mcp_server_dart/lib/src/capabilities/visual_capture/desktop_capture_recovery.dart @@ -113,7 +113,20 @@ Future captureDesktopWithRecovery({ return first; } - await service.focus(device: device, targetPid: targetPid, cacheDir: cacheDir); + Map focusDetails; + try { + focusDetails = await service.focus( + device: device, + targetPid: targetPid, + cacheDir: cacheDir, + ); + } on Object catch (e) { + focusDetails = { + 'ok': false, + 'error': 'focus_failed', + 'message': '$e', + }; + } final second = await _attemptCapture( service: service, projectDir: projectDir, @@ -126,7 +139,13 @@ Future captureDesktopWithRecovery({ capture: second.capture, retried: true, errorMessage: second.errorMessage, - errorDetails: second.errorDetails, + errorDetails: second.capture == null + ? _recoveryFailureDetails( + first: first, + focus: focusDetails, + second: second, + ) + : second.errorDetails, failure: second.failure, ); } @@ -154,6 +173,7 @@ Future _attemptCapture({ errorMessage: 'Desktop window screenshot mode is unavailable for the current ' 'target or app window.', + errorDetails: {'reason': 'capture_unavailable'}, ); } on DesktopWindowCaptureException catch (e) { return DesktopCaptureRecoveryResult( @@ -174,3 +194,19 @@ Future _attemptCapture({ ); } } + +Map _recoveryFailureDetails({ + required final DesktopCaptureRecoveryResult first, + required final Map focus, + required final DesktopCaptureRecoveryResult second, +}) => { + 'firstAttempt': { + 'message': first.errorMessage, + if (first.errorDetails.isNotEmpty) 'details': first.errorDetails, + }, + 'focus': focus, + 'secondAttempt': { + 'message': second.errorMessage, + if (second.errorDetails.isNotEmpty) 'details': second.errorDetails, + }, +}; diff --git a/mcp_server_dart/lib/src/capabilities/visual_capture/desktop_window_screenshot.dart b/mcp_server_dart/lib/src/capabilities/visual_capture/desktop_window_screenshot.dart index d46d86cb..779cac0f 100644 --- a/mcp_server_dart/lib/src/capabilities/visual_capture/desktop_window_screenshot.dart +++ b/mcp_server_dart/lib/src/capabilities/visual_capture/desktop_window_screenshot.dart @@ -196,7 +196,11 @@ final class MacOsDesktopWindowScreenshotService return null; } - await focus(device: device, targetPid: targetPid, cacheDir: cacheDir); + final focusPayload = await focus( + device: device, + targetPid: targetPid, + cacheDir: cacheDir, + ); final payload = await _runHelper( command: 'capture', @@ -207,10 +211,19 @@ final class MacOsDesktopWindowScreenshotService ], ); if (payload['ok'] != true) { + final helperDetails = _asObject(payload['details']); throw DesktopWindowCaptureException( message: 'macOS desktop window capture failed: ${payload['error'] ?? payload}', - details: _asObject(payload['details']), + details: { + ...helperDetails, + 'helperError': payload['error'], + 'helperPayload': payload, + 'focus': focusPayload, + 'candidates': appNames, + 'targetPid': ?targetPid, + 'device': device, + }, ); } @@ -247,8 +260,24 @@ final class MacOsDesktopWindowScreenshotService ...trailing, ]); if (result.exitCode != 0) { - throw Exception( - 'macOS visual capture helper failed: ${result.stderr}'.trim(), + final payload = _tryParsePayload('${result.stdout}'); + if (payload != null) { + return { + ...payload, + 'helperExitCode': result.exitCode, + if ('${result.stderr}'.trim().isNotEmpty) + 'helperStderr': '${result.stderr}'.trim(), + }; + } + throw DesktopWindowCaptureException( + message: 'macOS visual capture helper failed: ${result.stderr}'.trim(), + details: { + 'command': command, + 'arguments': trailing, + 'exitCode': result.exitCode, + 'stderr': '${result.stderr}', + 'stdout': '${result.stdout}', + }, ); } return _parsePayload('${result.stdout}'); @@ -431,6 +460,14 @@ Map _parsePayload(final String stdoutText) { return _asObject(decoded); } +Map? _tryParsePayload(final String stdoutText) { + try { + return _parsePayload(stdoutText); + } on FormatException { + return null; + } +} + Map _asObject(final Object? value) { if (value is Map) { return value; diff --git a/mcp_server_dart/lib/src/capabilities/visual_capture/visual_capture.dart b/mcp_server_dart/lib/src/capabilities/visual_capture/visual_capture.dart index b1b81436..00630cfd 100644 --- a/mcp_server_dart/lib/src/capabilities/visual_capture/visual_capture.dart +++ b/mcp_server_dart/lib/src/capabilities/visual_capture/visual_capture.dart @@ -317,7 +317,7 @@ final class VisualCaptureBroker { this.dynamicGateway, final Iterable? adapters, }) : _adapters = [ - if (adapters != null) ...adapters, + ...?adapters, const WebVisualCapturePlatformAdapter(), if (!io.Platform.isMacOS) const UnsupportedUntilAppBridgeVisualCapturePlatformAdapter( diff --git a/mcp_server_dart/lib/src/cli/cli.dart b/mcp_server_dart/lib/src/cli/cli.dart index 5248f1a4..864cbdd4 100644 --- a/mcp_server_dart/lib/src/cli/cli.dart +++ b/mcp_server_dart/lib/src/cli/cli.dart @@ -1,4 +1,3 @@ export 'cli_daemon_server.dart'; export 'diagnostics/diagnostics.dart'; export 'session/session.dart'; -export 'sessions_persistence/session_persistence.dart'; diff --git a/mcp_server_dart/lib/src/cli/cli_daemon_server.dart b/mcp_server_dart/lib/src/cli/cli_daemon_server.dart index 0046c4c9..97f67cba 100644 --- a/mcp_server_dart/lib/src/cli/cli_daemon_server.dart +++ b/mcp_server_dart/lib/src/cli/cli_daemon_server.dart @@ -8,10 +8,9 @@ import 'dart:convert'; import 'dart:io' as io; import 'package:flutter_mcp_toolkit_server/src/cli/diagnostics/bundle_builder.dart'; -import 'package:flutter_mcp_toolkit_server/src/cli/session/session_manager.dart'; -import 'package:flutter_mcp_toolkit_server/src/cli/sessions_persistence/safe_writes.dart'; -import 'package:flutter_mcp_toolkit_server/src/cli/sessions_persistence/snapshot_store.dart'; +import 'package:flutter_mcp_toolkit_server/src/cli/diagnostics/command_snapshot_service.dart'; import 'package:flutter_mcp_toolkit_server/src/runtime_version.dart'; +import 'package:flutter_mcp_toolkit_server/src/shared_core/agent_result_mapper.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/command_executor.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/commands/commands.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/types/core_types.dart'; @@ -19,13 +18,14 @@ import 'package:flutter_mcp_toolkit_server/src/shared_core/types/error_codes.dar import 'package:flutter_mcp_toolkit_server/src/shared_core/types/results.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/vm_connections/connection_override.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/vm_connections/preconnect.dart'; +import 'package:intentcall_session/intentcall_session.dart'; final class CliDaemonServer { CliDaemonServer({ required this.executor, required this.sessionManager, required this.catalog, - required this.snapshotStore, + required this.commandSnapshots, required this.bundleBuilder, required this.configuration, this.input, @@ -34,9 +34,9 @@ final class CliDaemonServer { }); final DefaultCoreCommandExecutor executor; - final SessionManager sessionManager; + final IntentSessionManager sessionManager; final CommandCatalog catalog; - final SnapshotStore snapshotStore; + final CommandSnapshotService commandSnapshots; final BundleBuilder bundleBuilder; final CoreRuntimeConfiguration configuration; final Stream? input; @@ -364,7 +364,7 @@ final class CliDaemonServer { ); try { - final snapshot = await snapshotStore.createSnapshot( + final snapshot = await commandSnapshots.createSnapshot( id: name, executor: executor, catalog: catalog, @@ -398,7 +398,7 @@ final class CliDaemonServer { final to = _requiredString(params, 'to'); try { - final diff = await snapshotStore.diffSnapshots(fromId: from, toId: to); + final diff = await commandSnapshots.diffSnapshots(fromId: from, toId: to); return {'diff': diff}; } on ArgumentError catch (e) { throw _JsonRpcException( @@ -449,7 +449,9 @@ final class CliDaemonServer { ); } - final result = await sessionManager.startSession(command); + final result = coreResultFromAgentResult( + await sessionManager.startSession(_toSessionStartRequest(command)), + ); if (!result.ok) { throw _coreFailure(result); } @@ -469,7 +471,9 @@ final class CliDaemonServer { ); } - final result = await sessionManager.endSession(command.sessionId); + final result = coreResultFromAgentResult( + await sessionManager.endSession(command.sessionId), + ); if (!result.ok) { throw _coreFailure(result); } @@ -493,6 +497,22 @@ final class CliDaemonServer { } } + IntentSessionStartRequest _toSessionStartRequest( + final SessionStartCommand command, + ) => IntentSessionStartRequest( + mode: switch (command.mode) { + CoreConnectionMode.auto => IntentSessionConnectionMode.auto, + CoreConnectionMode.manual => IntentSessionConnectionMode.manual, + CoreConnectionMode.uri => IntentSessionConnectionMode.uri, + }, + targetId: command.targetId, + uri: command.uri, + host: command.host, + port: command.port, + forceReconnect: command.forceReconnect, + sessionId: command.sessionId, + ); + CoreCommand _wrapWithSessionIfNeeded({ required final String? sessionId, required final CoreCommand command, diff --git a/mcp_server_dart/lib/src/cli/diagnostics/bundle_builder.dart b/mcp_server_dart/lib/src/cli/diagnostics/bundle_builder.dart index 8d4efefa..38e53064 100644 --- a/mcp_server_dart/lib/src/cli/diagnostics/bundle_builder.dart +++ b/mcp_server_dart/lib/src/cli/diagnostics/bundle_builder.dart @@ -4,7 +4,7 @@ import 'dart:convert'; import 'dart:io' as io; -import 'package:flutter_mcp_toolkit_server/src/cli/sessions_persistence/session_persistence.dart'; +import 'package:intentcall_session/intentcall_session.dart'; final class BundleBuilder { BundleBuilder({ @@ -14,7 +14,7 @@ final class BundleBuilder { }); final String bundlesDir; - final SnapshotStore snapshotStore; + final IntentSnapshotStore snapshotStore; final String stateFilePath; Future> createBundle({ @@ -362,8 +362,12 @@ final class BundleBuilder { String? backupPath; if (createBackup) { - backupPath = createTimestampedBackupPath(outputPath); - _copyDirectory(source: swapDir, target: io.Directory(backupPath)); + final backupDirectoryPath = createTimestampedBackupPath(outputPath); + backupPath = backupDirectoryPath; + _copyDirectory( + source: swapDir, + target: io.Directory(backupDirectoryPath), + ); } if (swapDir.existsSync()) { swapDir.deleteSync(recursive: true); diff --git a/mcp_server_dart/lib/src/cli/sessions_persistence/snapshot_store.dart b/mcp_server_dart/lib/src/cli/diagnostics/command_snapshot_service.dart similarity index 50% rename from mcp_server_dart/lib/src/cli/sessions_persistence/snapshot_store.dart rename to mcp_server_dart/lib/src/cli/diagnostics/command_snapshot_service.dart index cee14bb1..c25f7068 100644 --- a/mcp_server_dart/lib/src/cli/sessions_persistence/snapshot_store.dart +++ b/mcp_server_dart/lib/src/cli/diagnostics/command_snapshot_service.dart @@ -3,22 +3,27 @@ // ignore_for_file: avoid_catching_errors -import 'dart:convert'; -import 'dart:io' as io; - -import 'package:collection/collection.dart'; -import 'package:flutter_mcp_toolkit_server/src/cli/sessions_persistence/safe_writes.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/command_executor.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/commands/commands.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/types/error_codes.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/types/results.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/vm_connections/connection_override.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/vm_connections/preconnect.dart'; +import 'package:from_json_to_json/from_json_to_json.dart'; +import 'package:intentcall_session/intentcall_session.dart'; + +/// Builds Flutter MCP command snapshots and stores them with [IntentSnapshotStore]. +/// +/// The persistence/diff substrate is reusable IntentCall code. This service +/// keeps the Flutter-specific command catalog, VM preconnect, and CoreResult +/// serialization in the Flutter MCP server. +final class CommandSnapshotService { + CommandSnapshotService({required final String snapshotsDir}) + : snapshotStore = IntentSnapshotStore(snapshotsDir: snapshotsDir); -final class SnapshotStore { - SnapshotStore({required this.snapshotsDir}); + CommandSnapshotService.withStore({required this.snapshotStore}); - final String snapshotsDir; + final IntentSnapshotStore snapshotStore; Future> createSnapshot({ required final String id, @@ -33,7 +38,7 @@ final class SnapshotStore { for (final step in plan) { final name = '${step['name'] ?? ''}'; - final stepArgs = _asMap(step['args']); + final stepArgs = _jsonObjectOrEmpty(step['args']); final argsResolution = resolveCommandArgumentsForExecution( commandName: name, arguments: stepArgs, @@ -86,118 +91,42 @@ final class SnapshotStore { 'results': results, }; - final file = _fileFor(id); - final writeResult = await SafeFileWriter.writeTextFile( - path: file.path, - content: const JsonEncoder.withIndent(' ').convert(snapshot), - options: writeOptions, + return snapshotStore.saveSnapshot( + id: id, + snapshot: snapshot, + writeOptions: writeOptions, ); - snapshot['path'] = file.path; - snapshot['writeResults'] = [writeResult.toJson()]; - - return snapshot; } - Future> loadSnapshot(final String id) async { - final file = _fileFor(id); - if (!file.existsSync()) { - throw ArgumentError('Snapshot not found: $id'); - } - - final raw = file.readAsStringSync(); - final decoded = jsonDecode(raw); - if (decoded is Map) { - return decoded; - } - if (decoded is Map) { - return decoded.cast(); - } - - throw StateError('Invalid snapshot payload: $id'); - } - - Future>> listSnapshots() async { - final dir = io.Directory(snapshotsDir); - if (!dir.existsSync()) { - return const >[]; - } - - final entries = dir - .listSync() - .where( - (final entity) => entity is io.File && entity.path.endsWith('.json'), - ) - .cast() - .toList(); - - entries.sort((final a, final b) => a.path.compareTo(b.path)); + Future> loadSnapshot(final String id) => + snapshotStore.loadSnapshot(id); - final snapshots = >[]; - for (final file in entries) { - try { - final raw = file.readAsStringSync(); - final decoded = jsonDecode(raw); - final json = _asMap(decoded); - snapshots.add({ - 'id': '${json['id'] ?? ''}', - 'createdAt': json['createdAt'], - 'path': file.path, - }); - } on Exception { - // Skip unreadable files. - } - } - - return snapshots; - } + Future>> listSnapshots() => + snapshotStore.listSnapshots(); Future> diffSnapshots({ required final String fromId, required final String toId, - }) async { - final from = await loadSnapshot(fromId); - final to = await loadSnapshot(toId); - - final changes = >[]; - _diffNode(path: r'$', left: from, right: to, out: changes); - - final summary = { - 'totalChanges': changes.length, - 'added': changes - .where((final change) => change['type'] == 'added') - .length, - 'removed': changes - .where((final change) => change['type'] == 'removed') - .length, - 'changed': changes - .where((final change) => change['type'] == 'changed') - .length, - 'typeChanged': changes - .where((final change) => change['type'] == 'type_changed') - .length, - }; - - return {'from': fromId, 'to': toId, 'summary': summary, 'changes': changes}; - } + }) => snapshotStore.diffSnapshots(fromId: fromId, toId: toId); List> _resolvePlan({ required final CommandCatalog catalog, required final Map args, }) { - final providedPlan = args['commands']; + final providedPlan = _jsonListOrNull(args['commands']); final includeViewDetails = _bool( args['includeViewDetails'], fallback: true, ); final errorCount = _intOrNull(args['errorCount']); - if (providedPlan is List) { + if (providedPlan != null) { return providedPlan .map((final step) { - final json = _asMap(step); + final json = _jsonObjectOrEmpty(step); return { 'name': '${json['name'] ?? ''}', - 'args': _asMap(json['args']), + 'args': _jsonObjectOrEmpty(json['args']), }; }) .where((final step) => catalog.contains('${step['name'] ?? ''}')) @@ -208,7 +137,7 @@ final class SnapshotStore { .map( (final step) => { 'name': '${step['name'] ?? ''}', - 'args': Map.from(_asMap(step['args'])), + 'args': Map.from(_jsonObjectOrEmpty(step['args'])), }, ) .toList(); @@ -220,7 +149,9 @@ final class SnapshotStore { if (errorCount != null) { for (final step in defaults) { if (step['name'] == 'get_app_errors') { - final commandArgs = Map.from(_asMap(step['args'])); + final commandArgs = Map.from( + _jsonObjectOrEmpty(step['args']), + ); commandArgs['count'] = errorCount; step['args'] = commandArgs; } @@ -230,11 +161,6 @@ final class SnapshotStore { return defaults; } - io.File _fileFor(final String id) { - final safe = id.replaceAll(RegExp('[^a-zA-Z0-9._-]'), '_'); - return io.File('$snapshotsDir/$safe.json'); - } - static ({CoreCommand? command, CoreResult? failure}) _buildCommandSafely({ required final CommandCatalog catalog, required final String name, @@ -261,14 +187,36 @@ final class SnapshotStore { } } - static Map _asMap(final Object? value) { + static Map _jsonObjectOrEmpty(final Object? value) { if (value is Map) { return value; } if (value is Map) { return value.cast(); } - return const {}; + try { + return Map.from(jsonDecodeMap(value)); + } on Exception { + return const {}; + } + } + + static List? _jsonListOrNull(final Object? value) { + if (value == null) { + return null; + } + if (value is List) { + return value.cast(); + } + final decodableValue = value is String ? value.trim() : value; + if (!verifyListDecodability(decodableValue)) { + return null; + } + try { + return jsonDecodeList(decodableValue).cast(); + } on Exception { + return null; + } } static bool _bool(final Object? value, {required final bool fallback}) => @@ -285,77 +233,4 @@ final class SnapshotStore { final String v => int.tryParse(v), _ => null, }; - - static const _listEquality = DeepCollectionEquality(); - - static void _diffNode({ - required final String path, - required final Object? left, - required final Object? right, - required final List> out, - }) { - if (left == null && right == null) { - return; - } - - if (left == null) { - out.add({'path': path, 'type': 'added', 'after': right}); - return; - } - - if (right == null) { - out.add({'path': path, 'type': 'removed', 'before': left}); - return; - } - - if (left is Map && right is Map) { - final leftMap = left.cast(); - final rightMap = right.cast(); - final allKeys = {...leftMap.keys, ...rightMap.keys}.toList() - ..sort(); - - for (final key in allKeys) { - _diffNode( - path: '$path.$key', - left: leftMap[key], - right: rightMap[key], - out: out, - ); - } - return; - } - - if (left is List && right is List) { - final maxLen = left.length > right.length ? left.length : right.length; - for (var i = 0; i < maxLen; i += 1) { - final nextLeft = i < left.length ? left[i] : null; - final nextRight = i < right.length ? right[i] : null; - _diffNode( - path: '$path[$i]', - left: nextLeft, - right: nextRight, - out: out, - ); - } - return; - } - - if (left.runtimeType != right.runtimeType) { - out.add({ - 'path': path, - 'type': 'type_changed', - 'beforeType': left.runtimeType.toString(), - 'afterType': right.runtimeType.toString(), - 'before': left, - 'after': right, - }); - return; - } - - if (_listEquality.equals(left, right)) { - return; - } - - out.add({'path': path, 'type': 'changed', 'before': left, 'after': right}); - } } diff --git a/mcp_server_dart/lib/src/cli/diagnostics/diagnostics.dart b/mcp_server_dart/lib/src/cli/diagnostics/diagnostics.dart index 96329e27..ec36c6ca 100644 --- a/mcp_server_dart/lib/src/cli/diagnostics/diagnostics.dart +++ b/mcp_server_dart/lib/src/cli/diagnostics/diagnostics.dart @@ -1,2 +1,3 @@ export 'bundle_builder.dart'; +export 'command_snapshot_service.dart'; export 'doctor_runner.dart'; diff --git a/mcp_server_dart/lib/src/cli/session/flutter_session_connector.dart b/mcp_server_dart/lib/src/cli/session/flutter_session_connector.dart new file mode 100644 index 00000000..7ec019b3 --- /dev/null +++ b/mcp_server_dart/lib/src/cli/session/flutter_session_connector.dart @@ -0,0 +1,78 @@ +// Copyright (c) 2025, Flutter Inspector MCP Server authors. +// Licensed under the MIT License. + +import 'package:flutter_mcp_toolkit_core/flutter_mcp_toolkit_core.dart'; +import 'package:flutter_mcp_toolkit_server/src/shared_core/vm_connections/connection_context.dart'; +import 'package:intentcall_session/intentcall_session.dart'; + +final class FlutterSessionConnector implements IntentSessionConnector { + const FlutterSessionConnector({required this.connectionContext}); + + final ConnectionContext connectionContext; + + @override + String? get activeEndpointDisplay => + connectionContext.activeEndpoint?.display; + + @override + Map get lastSelectionDiagnostics => + connectionContext.lastSelectionDiagnostics; + + @override + Future> connect({ + final IntentSessionConnectionMode mode = IntentSessionConnectionMode.auto, + final String? targetId, + final String? uri, + final String? host, + final int? port, + final bool forceReconnect = false, + }) async { + try { + return await connectionContext.connect( + mode: _toCoreMode(mode), + targetId: targetId, + uri: uri, + host: host, + port: port, + forceReconnect: forceReconnect, + ); + } on CoreConnectionException catch (e) { + throw _FlutterSessionConnectionException( + reasonName: e.reason.name, + message: e.message, + details: e.details, + ); + } + } + + @override + Future disconnect() => connectionContext.disconnect(); + + CoreConnectionMode _toCoreMode(final IntentSessionConnectionMode mode) => + switch (mode) { + IntentSessionConnectionMode.auto => CoreConnectionMode.auto, + IntentSessionConnectionMode.manual => CoreConnectionMode.manual, + IntentSessionConnectionMode.uri => CoreConnectionMode.uri, + }; +} + +final class _FlutterSessionConnectionException + implements IntentSessionConnectionException { + const _FlutterSessionConnectionException({ + required this.reasonName, + required this.message, + this.details, + }); + + @override + final String reasonName; + + @override + final String message; + + @override + final Object? details; + + @override + String toString() => message; +} diff --git a/mcp_server_dart/lib/src/cli/session/session.dart b/mcp_server_dart/lib/src/cli/session/session.dart index a2723d40..f32fbe46 100644 --- a/mcp_server_dart/lib/src/cli/session/session.dart +++ b/mcp_server_dart/lib/src/cli/session/session.dart @@ -1,3 +1 @@ -export 'session_manager.dart'; -export 'state_lock_manager.dart'; -export 'state_store.dart'; +export 'flutter_session_connector.dart'; diff --git a/mcp_server_dart/lib/src/cli/session/session_manager.dart b/mcp_server_dart/lib/src/cli/session/session_manager.dart deleted file mode 100644 index 884ebe95..00000000 --- a/mcp_server_dart/lib/src/cli/session/session_manager.dart +++ /dev/null @@ -1,341 +0,0 @@ -// Copyright (c) 2025, Flutter Inspector MCP Server authors. -// Licensed under the MIT License. - -import 'dart:math'; - -import 'package:flutter_mcp_toolkit_server/src/cli/session/state_lock_manager.dart'; -import 'package:flutter_mcp_toolkit_server/src/cli/session/state_store.dart'; -import 'package:flutter_mcp_toolkit_server/src/shared_core/commands/commands.dart'; -import 'package:flutter_mcp_toolkit_server/src/shared_core/types/error_codes.dart'; -import 'package:flutter_mcp_toolkit_server/src/shared_core/types/results.dart'; -import 'package:flutter_mcp_toolkit_server/src/shared_core/vm_connections/connection_context.dart'; - -final class SessionManager { - SessionManager({required this.connectionContext, required this.stateStore}); - - final ConnectionContext connectionContext; - final StateStore stateStore; - - PersistedState _state = const PersistedState(); - - PersistedState get state => _state; - - Future load() async { - _state = await stateStore.read(); - } - - SessionState? getSession(final String? sessionId) { - final resolvedId = _resolveSessionId(sessionId); - if (resolvedId == null || resolvedId.isEmpty) { - return null; - } - return _state.sessions[resolvedId]; - } - - String? _resolveSessionId(final String? sessionId) { - if (sessionId != null && sessionId.isNotEmpty) { - return sessionId; - } - return _state.activeSessionId; - } - - String? get stickyEndpoint => - _state.activeSession?.endpoint ?? _state.stickyEndpoint; - - Future startSession(final SessionStartCommand command) async { - try { - final connectionData = await connectionContext.connect( - mode: command.mode, - targetId: command.targetId, - uri: command.uri, - host: command.host, - port: command.port, - forceReconnect: command.forceReconnect, - ); - - final endpoint = connectionContext.activeEndpoint?.display; - if (endpoint == null || endpoint.isEmpty) { - return CoreResult.failure( - code: CoreErrorCode.connectFailed, - message: 'Failed to resolve active endpoint after session start', - ); - } - - final id = command.sessionId ?? _newSessionId(); - - return await _withLockedResult(() async { - final current = await stateStore.readUnlocked(); - final now = DateTime.now().toUtc(); - final nextSession = SessionState( - id: id, - endpoint: endpoint, - createdAt: now, - lastUsedAt: now, - mode: command.mode.name, - host: command.host, - port: command.port, - uri: command.uri, - ); - - final nextSessions = { - ...current.sessions, - id: nextSession, - }; - - final nextState = current.copyWith( - activeSessionId: id, - sessions: nextSessions, - stickyEndpoint: endpoint, - lastMode: command.mode.name, - ); - - await stateStore.writeUnlocked(nextState); - _state = nextState; - - return CoreResult.success( - data: { - 'sessionId': id, - 'endpoint': endpoint, - 'mode': command.mode.name, - 'connected': true, - 'reusedConnection': connectionData['reusedConnection'] == true, - 'selectionDiagnostics': connectionContext.lastSelectionDiagnostics, - }, - meta: {'sessionId': id}, - ); - }); - } on CoreConnectionException catch (e) { - if (e.reason == CoreConnectionFailureReason.multipleTargets) { - return CoreResult.failure( - code: CoreErrorCode.connectionSelectionRequired, - message: e.message, - details: e.details, - ); - } - - return CoreResult.failure( - code: CoreErrorCode.connectFailed, - message: 'Failed to start session: ${e.message}', - details: e.details, - ); - } on Exception catch (e) { - return CoreResult.failure( - code: CoreErrorCode.connectFailed, - message: 'Failed to start session: $e', - ); - } - } - - Future attachSession({ - final String? sessionId, - final bool forceReconnect = false, - }) async { - final resolvedSession = await _withLockedResult(() async { - final current = await stateStore.readUnlocked(); - _state = current; - - final resolvedId = _resolveSessionId(sessionId); - if (resolvedId == null || resolvedId.isEmpty) { - return CoreResult.failure( - code: CoreErrorCode.sessionNotFound, - message: 'Session not found', - details: {'requestedSessionId': sessionId}, - ); - } - - final session = current.sessions[resolvedId]; - if (session == null) { - return CoreResult.failure( - code: CoreErrorCode.sessionNotFound, - message: 'Session not found', - details: {'requestedSessionId': sessionId}, - ); - } - - return CoreResult.success(data: {'session': session.toJson()}); - }); - - if (!resolvedSession.ok) { - return resolvedSession; - } - - final sessionJson = - (resolvedSession.data! as Map)['session']; - final session = SessionState.fromJson( - (sessionJson! as Map).cast(), - ); - - try { - final data = await connectionContext.connect( - mode: CoreConnectionMode.uri, - uri: session.endpoint, - forceReconnect: forceReconnect, - ); - - final markResult = await _withLockedResult(() async { - await _markSessionUsedLocked( - session.id, - endpointOverride: session.endpoint, - ); - - return CoreResult.success( - data: {'sessionId': session.id, ...data}, - meta: {'sessionId': session.id}, - ); - }); - - return markResult; - } on Exception catch (e) { - return CoreResult.failure( - code: CoreErrorCode.connectFailed, - message: 'Failed to attach session ${session.id}: $e', - details: {'sessionId': session.id}, - ); - } - } - - Future endSession(final String? sessionId) async { - bool shouldDisconnect = false; - - final result = await _withLockedResult(() async { - final current = await stateStore.readUnlocked(); - _state = current; - - final resolvedId = _resolveSessionId(sessionId); - if (resolvedId == null || resolvedId.isEmpty) { - return CoreResult.failure( - code: CoreErrorCode.sessionNotFound, - message: 'Session not found', - details: {'requestedSessionId': sessionId}, - ); - } - - final existing = current.sessions[resolvedId]; - if (existing == null) { - return CoreResult.failure( - code: CoreErrorCode.sessionNotFound, - message: 'Session not found', - details: {'requestedSessionId': resolvedId}, - ); - } - - shouldDisconnect = current.activeSessionId == resolvedId; - - final nextSessions = {...current.sessions} - ..remove(resolvedId); - - final nextActive = current.activeSessionId == resolvedId - ? null - : current.activeSessionId; - - final sticky = nextActive == null - ? (nextSessions.values.isEmpty - ? current.stickyEndpoint - : nextSessions.values.last.endpoint) - : current.stickyEndpoint; - - final nextState = current.copyWith( - sessions: nextSessions, - activeSessionId: nextActive, - clearActiveSessionId: nextActive == null, - stickyEndpoint: sticky, - clearStickyEndpoint: sticky == null || sticky.isEmpty, - ); - - await stateStore.writeUnlocked(nextState); - _state = nextState; - - return CoreResult.success( - data: { - 'sessionId': resolvedId, - 'ended': true, - 'activeSessionId': nextState.activeSessionId, - 'remainingSessions': nextState.sessions.length, - }, - meta: {'sessionId': resolvedId}, - ); - }); - - if (result.ok && shouldDisconnect) { - await connectionContext.disconnect(); - } - - return result; - } - - Future markSessionUsed( - final String? sessionId, { - final String? endpointOverride, - }) async { - final resolvedId = _resolveSessionId(sessionId); - if (resolvedId == null || resolvedId.isEmpty) { - return; - } - - await _withLockedResult(() async { - await _markSessionUsedLocked( - resolvedId, - endpointOverride: endpointOverride, - ); - return CoreResult.success(); - }); - } - - Future _markSessionUsedLocked( - final String resolvedSessionId, { - final String? endpointOverride, - }) async { - final current = await stateStore.readUnlocked(); - final existing = current.sessions[resolvedSessionId]; - if (existing == null) { - _state = current; - return; - } - - final endpoint = endpointOverride ?? existing.endpoint; - final next = existing.copyWith( - lastUsedAt: DateTime.now().toUtc(), - endpoint: endpoint, - ); - - final nextSessions = { - ...current.sessions, - resolvedSessionId: next, - }; - - final nextState = current.copyWith( - sessions: nextSessions, - activeSessionId: resolvedSessionId, - stickyEndpoint: endpoint, - lastMode: existing.mode, - ); - - await stateStore.writeUnlocked(nextState); - _state = nextState; - } - - Future _withLockedResult( - final Future Function() action, - ) async { - try { - return await stateStore.withStateLock(action); - } on StateLockException catch (e) { - return CoreResult.failure( - code: CoreErrorCode.stateLockTimeout, - message: e.message, - details: {'lockFilePath': e.lockFilePath, 'owner': e.owner}, - ); - } on Exception catch (e) { - return CoreResult.failure( - code: CoreErrorCode.stateStoreWriteFailed, - message: 'State operation failed: $e', - ); - } - } - - String _newSessionId() { - final rand = Random(); - final suffix = rand.nextInt(1 << 32).toRadixString(16).padLeft(8, '0'); - return 's_${DateTime.now().millisecondsSinceEpoch}_$suffix'; - } -} diff --git a/mcp_server_dart/lib/src/cli/session/state_lock_manager.dart b/mcp_server_dart/lib/src/cli/session/state_lock_manager.dart deleted file mode 100644 index e992c6ff..00000000 --- a/mcp_server_dart/lib/src/cli/session/state_lock_manager.dart +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright (c) 2025, Flutter Inspector MCP Server authors. -// Licensed under the MIT License. - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io' as io; -import 'dart:math'; - -import 'package:meta/meta.dart'; - -@immutable -final class LockAcquisition { - const LockAcquisition({ - required this.token, - required this.lockFilePath, - required this.acquiredAt, - required this.waitMs, - required this.staleLockRecovered, - this.previousOwner, - }); - - final String token; - final String lockFilePath; - final DateTime acquiredAt; - final int waitMs; - final bool staleLockRecovered; - final Map? previousOwner; - - Map toJson() => { - 'token': token, - 'lockFilePath': lockFilePath, - 'acquiredAt': acquiredAt.toUtc().toIso8601String(), - 'waitMs': waitMs, - 'staleLockRecovered': staleLockRecovered, - 'previousOwner': previousOwner, - }; -} - -final class StateLockException implements Exception { - const StateLockException({ - required this.message, - required this.lockFilePath, - this.owner, - }); - - final String message; - final String lockFilePath; - final Map? owner; - - @override - String toString() => - 'StateLockException(message: $message, lockFilePath: $lockFilePath, owner: $owner)'; -} - -final class StateLockManager { - StateLockManager({ - required this.lockFilePath, - this.staleLockTtl = const Duration(minutes: 5), - this.acquireTimeout = const Duration(seconds: 10), - this.pollInterval = const Duration(milliseconds: 50), - }); - - final String lockFilePath; - final Duration staleLockTtl; - final Duration acquireTimeout; - final Duration pollInterval; - - Future withLock(final Future Function() action) async { - final acquisition = await acquire(); - try { - return await action(); - } finally { - await release(acquisition); - } - } - - Future acquire({final Duration? timeout}) async { - final effectiveTimeout = timeout ?? acquireTimeout; - final start = DateTime.now().toUtc(); - var staleRecovered = false; - Map? previousOwner; - - while (true) { - final token = _nextToken(); - final lockFile = io.File(lockFilePath); - - try { - lockFile.parent.createSync(recursive: true); - lockFile.createSync(exclusive: true); - - final payload = { - 'token': token, - 'pid': io.pid, - 'createdAt': DateTime.now().toUtc().toIso8601String(), - 'hostname': io.Platform.localHostname, - }; - lockFile.writeAsStringSync(jsonEncode(payload)); - - final waitMs = DateTime.now().toUtc().difference(start).inMilliseconds; - return LockAcquisition( - token: token, - lockFilePath: lockFilePath, - acquiredAt: DateTime.now().toUtc(), - waitMs: waitMs, - staleLockRecovered: staleRecovered, - previousOwner: previousOwner, - ); - } on io.FileSystemException { - final staleOutcome = await _recoverStaleLockIfNeeded(lockFile); - staleRecovered = staleRecovered || staleOutcome.recovered; - previousOwner ??= staleOutcome.owner; - - final elapsed = DateTime.now().toUtc().difference(start); - if (elapsed >= effectiveTimeout) { - throw StateLockException( - message: - 'Timed out acquiring state lock after ${elapsed.inMilliseconds}ms', - lockFilePath: lockFilePath, - owner: staleOutcome.owner, - ); - } - - await Future.delayed(pollInterval); - } - } - } - - Future release(final LockAcquisition acquisition) async { - final lockFile = io.File(lockFilePath); - if (!lockFile.existsSync()) { - return; - } - - try { - final raw = lockFile.readAsStringSync(); - final decoded = _decodeMap(raw); - final token = decoded['token']?.toString(); - - if (token != acquisition.token) { - return; - } - lockFile.deleteSync(); - } on Exception { - // Lock release must be best effort. - } - } - - Future<({bool recovered, Map? owner})> - _recoverStaleLockIfNeeded(final io.File lockFile) async { - try { - final raw = lockFile.readAsStringSync(); - final owner = _decodeMap(raw); - final createdAtRaw = owner['createdAt']?.toString(); - final createdAt = createdAtRaw == null - ? null - : DateTime.tryParse(createdAtRaw)?.toUtc(); - - if (createdAt == null) { - return (recovered: false, owner: owner); - } - - final now = DateTime.now().toUtc(); - if (now.difference(createdAt) <= staleLockTtl) { - return (recovered: false, owner: owner); - } - - lockFile.deleteSync(); - return (recovered: true, owner: owner); - } on Exception { - return (recovered: false, owner: null); - } - } - - Map _decodeMap(final String raw) { - final decoded = jsonDecode(raw); - if (decoded is Map) { - return decoded; - } - if (decoded is Map) { - return decoded.cast(); - } - return const {}; - } - - String _nextToken() { - final rand = Random(); - final suffix = rand.nextInt(1 << 32).toRadixString(16).padLeft(8, '0'); - return 'lock_${DateTime.now().microsecondsSinceEpoch}_$suffix'; - } -} diff --git a/mcp_server_dart/lib/src/cli/session/state_store.dart b/mcp_server_dart/lib/src/cli/session/state_store.dart deleted file mode 100644 index 452da78c..00000000 --- a/mcp_server_dart/lib/src/cli/session/state_store.dart +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright (c) 2025, Flutter Inspector MCP Server authors. -// Licensed under the MIT License. - -import 'dart:convert'; -import 'dart:io' as io; - -import 'package:flutter_mcp_toolkit_server/src/cli/session/state_lock_manager.dart'; -import 'package:flutter_mcp_toolkit_server/src/cli/sessions_persistence/safe_writes.dart'; -import 'package:path/path.dart' as p; - -final class SessionState { - const SessionState({ - required this.id, - required this.endpoint, - required this.createdAt, - required this.lastUsedAt, - required this.mode, - this.host, - this.port, - this.uri, - }); - - factory SessionState.fromJson(final Map json) => - SessionState( - id: '${json['id'] ?? ''}', - endpoint: '${json['endpoint'] ?? ''}', - createdAt: - DateTime.tryParse('${json['createdAt'] ?? ''}')?.toUtc() ?? - DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), - lastUsedAt: - DateTime.tryParse('${json['lastUsedAt'] ?? ''}')?.toUtc() ?? - DateTime.fromMillisecondsSinceEpoch(0, isUtc: true), - mode: '${json['mode'] ?? 'auto'}', - host: json['host']?.toString(), - port: switch (json['port']) { - final int v => v, - final num v => v.toInt(), - final String v => int.tryParse(v), - _ => null, - }, - uri: json['uri']?.toString(), - ); - - final String id; - final String endpoint; - final DateTime createdAt; - final DateTime lastUsedAt; - final String mode; - final String? host; - final int? port; - final String? uri; - - SessionState copyWith({final DateTime? lastUsedAt, final String? endpoint}) => - SessionState( - id: id, - endpoint: endpoint ?? this.endpoint, - createdAt: createdAt, - lastUsedAt: lastUsedAt ?? this.lastUsedAt, - mode: mode, - host: host, - port: port, - uri: uri, - ); - - Map toJson() => { - 'id': id, - 'endpoint': endpoint, - 'createdAt': createdAt.toUtc().toIso8601String(), - 'lastUsedAt': lastUsedAt.toUtc().toIso8601String(), - 'mode': mode, - 'host': host, - 'port': port, - 'uri': uri, - }; -} - -final class PersistedState { - const PersistedState({ - this.schemaVersion = 1, - this.activeSessionId, - this.sessions = const {}, - this.stickyEndpoint, - this.lastMode, - }); - - factory PersistedState.fromJson(final Map json) { - final rawSessions = json['sessions']; - final sessions = {}; - if (rawSessions is Map) { - for (final entry in rawSessions.entries) { - final key = '${entry.key}'; - final value = entry.value; - if (value is Map) { - sessions[key] = SessionState.fromJson(value); - } else if (value is Map) { - sessions[key] = SessionState.fromJson(value.cast()); - } - } - } - - final rawVersion = json['schemaVersion']; - final schemaVersion = switch (rawVersion) { - final int v => v, - final num v => v.toInt(), - final String v => int.tryParse(v) ?? 1, - _ => 1, - }; - - return PersistedState( - schemaVersion: schemaVersion, - activeSessionId: json['activeSessionId']?.toString(), - stickyEndpoint: json['stickyEndpoint']?.toString(), - lastMode: json['lastMode']?.toString(), - sessions: sessions, - ); - } - - final int schemaVersion; - final String? activeSessionId; - final Map sessions; - final String? stickyEndpoint; - final String? lastMode; - - SessionState? get activeSession { - final id = activeSessionId; - if (id == null || id.isEmpty) { - return null; - } - return sessions[id]; - } - - PersistedState copyWith({ - final int? schemaVersion, - final String? activeSessionId, - final bool clearActiveSessionId = false, - final Map? sessions, - final String? stickyEndpoint, - final bool clearStickyEndpoint = false, - final String? lastMode, - final bool clearLastMode = false, - }) => PersistedState( - schemaVersion: schemaVersion ?? this.schemaVersion, - activeSessionId: clearActiveSessionId - ? null - : (activeSessionId ?? this.activeSessionId), - sessions: sessions ?? this.sessions, - stickyEndpoint: clearStickyEndpoint - ? null - : (stickyEndpoint ?? this.stickyEndpoint), - lastMode: clearLastMode ? null : (lastMode ?? this.lastMode), - ); - - Map toJson() => { - 'schemaVersion': schemaVersion, - 'activeSessionId': activeSessionId, - 'stickyEndpoint': stickyEndpoint, - 'lastMode': lastMode, - 'sessions': sessions.map( - (final key, final value) => MapEntry(key, value.toJson()), - ), - }; -} - -final class StateStore { - StateStore({required this.path, final StateLockManager? lockManager}) - : lockManager = - lockManager ?? - StateLockManager( - lockFilePath: p.normalize(p.join(p.dirname(path), 'state.lock')), - ); - - final String path; - final StateLockManager lockManager; - - Future withStateLock(final Future Function() action) => - lockManager.withLock(action); - - Future read() => withStateLock(readUnlocked); - - Future write(final PersistedState state) => - withStateLock(() => writeUnlocked(state)); - - Future readUnlocked() async { - try { - final file = io.File(path); - if (!file.existsSync()) { - return const PersistedState(); - } - - final raw = file.readAsStringSync(); - if (raw.trim().isEmpty) { - return const PersistedState(); - } - - final decoded = jsonDecode(raw); - if (decoded is Map) { - return PersistedState.fromJson(decoded); - } - if (decoded is Map) { - return PersistedState.fromJson(decoded.cast()); - } - } on Exception { - return const PersistedState(); - } - - return const PersistedState(); - } - - Future writeUnlocked(final PersistedState state) async { - final file = io.File(path); - final payload = const JsonEncoder.withIndent(' ').convert(state.toJson()); - await SafeFileWriter.writeTextFile(path: file.path, content: payload); - - if (!io.Platform.isWindows) { - io.Process.runSync('chmod', ['600', p.normalize(file.path)]); - } - } -} diff --git a/mcp_server_dart/lib/src/cli/sessions_persistence/safe_writes.dart b/mcp_server_dart/lib/src/cli/sessions_persistence/safe_writes.dart deleted file mode 100644 index 61ff477b..00000000 --- a/mcp_server_dart/lib/src/cli/sessions_persistence/safe_writes.dart +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright (c) 2025, Flutter Inspector MCP Server authors. -// Licensed under the MIT License. - -import 'dart:io' as io; -import 'dart:math' as math; - -abstract final class SafeWriteStatus { - static const added = 'added'; - static const updated = 'updated'; - static const unchanged = 'unchanged'; - static const blocked = 'blocked'; -} - -final class SafeWriteOptions { - const SafeWriteOptions({ - this.check = false, - this.diff = false, - this.backup = false, - this.noOverwrite = false, - }); - - final bool check; - final bool diff; - final bool backup; - final bool noOverwrite; - - Map toJson() => { - 'check': check, - 'diff': diff, - 'backup': backup, - 'noOverwrite': noOverwrite, - }; -} - -final class SafeWriteResult { - const SafeWriteResult({ - required this.target, - required this.status, - required this.wrote, - required this.options, - this.backupPath, - this.diff, - }); - - final String target; - final String status; - final bool wrote; - final SafeWriteOptions options; - final String? backupPath; - final Map? diff; - - bool get blocked => status == SafeWriteStatus.blocked; - - Map toJson() => { - 'target': target, - 'status': status, - 'wrote': wrote, - 'options': options.toJson(), - if (backupPath != null) 'backupPath': backupPath, - if (diff != null) 'diff': diff, - }; -} - -// ignore: avoid_classes_with_only_static_members -abstract final class SafeFileWriter { - static Future writeTextFile({ - required final String path, - required final String content, - final SafeWriteOptions options = const SafeWriteOptions(), - }) async { - final file = io.File(path); - final existing = file.existsSync(); - final previousContent = existing ? file.readAsStringSync() : null; - final status = _statusForWrite( - exists: existing, - previousContent: previousContent, - nextContent: content, - ); - - final diff = options.diff - ? buildUnifiedDiffMetadata( - target: path, - previousContent: previousContent, - nextContent: content, - ) - : null; - - if (options.check) { - return SafeWriteResult( - target: path, - status: status, - wrote: false, - options: options, - diff: diff, - ); - } - - if (options.noOverwrite && existing) { - return SafeWriteResult( - target: path, - status: SafeWriteStatus.blocked, - wrote: false, - options: options, - diff: diff, - ); - } - - if (status == SafeWriteStatus.unchanged) { - return SafeWriteResult( - target: path, - status: SafeWriteStatus.unchanged, - wrote: false, - options: options, - diff: diff, - ); - } - - String? backupPath; - if (options.backup && existing) { - backupPath = createTimestampedBackupPath(path); - final backupFile = io.File(backupPath); - backupFile.parent.createSync(recursive: true); - file.copySync(backupPath); - } - - final tempPath = - '$path.tmp.${io.pid}.${DateTime.now().microsecondsSinceEpoch}'; - final tempFile = io.File(tempPath); - tempFile.parent.createSync(recursive: true); - tempFile.writeAsStringSync(content); - - if (!existing) { - tempFile.renameSync(path); - return SafeWriteResult( - target: path, - status: status, - wrote: true, - options: options, - backupPath: backupPath, - diff: diff, - ); - } - - final swapPath = - '$path.swap.${io.pid}.${DateTime.now().microsecondsSinceEpoch}'; - final swapFile = io.File(swapPath); - - file.renameSync(swapPath); - try { - tempFile.renameSync(path); - if (swapFile.existsSync()) { - swapFile.deleteSync(); - } - } on Exception { - try { - if (io.File(path).existsSync()) { - io.File(path).deleteSync(); - } - } on Exception { - // Best effort cleanup. - } - if (swapFile.existsSync()) { - swapFile.renameSync(path); - } - rethrow; - } - - return SafeWriteResult( - target: path, - status: status, - wrote: true, - options: options, - backupPath: backupPath, - diff: diff, - ); - } - - static String _statusForWrite({ - required final bool exists, - required final String? previousContent, - required final String nextContent, - }) { - if (!exists) { - return SafeWriteStatus.added; - } - if (previousContent == nextContent) { - return SafeWriteStatus.unchanged; - } - return SafeWriteStatus.updated; - } -} - -Map? buildUnifiedDiffMetadata({ - required final String target, - required final String? previousContent, - required final String nextContent, -}) { - if (previousContent == null && nextContent.isEmpty) { - return null; - } - if (previousContent != null && previousContent == nextContent) { - return null; - } - - return { - 'format': 'unified', - 'target': target, - 'text': _buildUnifiedDiffText( - target: target, - previousContent: previousContent, - nextContent: nextContent, - ), - }; -} - -String createTimestampedBackupPath(final String originalPath) { - final timestamp = DateTime.now().toUtc().toIso8601String().replaceAll( - ':', - '', - ); - return '$originalPath.backup.$timestamp'; -} - -String _buildUnifiedDiffText({ - required final String target, - required final String? previousContent, - required final String nextContent, -}) { - final before = _splitLines(previousContent ?? ''); - final after = _splitLines(nextContent); - - final lines = [ - '--- $target (before)', - '+++ $target (after)', - '@@ -1,${math.max(1, before.length)} +1,${math.max(1, after.length)} @@', - ]; - - final maxLength = math.max(before.length, after.length); - for (var index = 0; index < maxLength; index += 1) { - final left = index < before.length ? before[index] : null; - final right = index < after.length ? after[index] : null; - - if (left == right) { - if (left != null) { - lines.add(' $left'); - } - continue; - } - - if (left != null) { - lines.add('-$left'); - } - if (right != null) { - lines.add('+$right'); - } - } - - return lines.join('\n'); -} - -List _splitLines(final String source) { - if (source.isEmpty) { - return const []; - } - return source.split('\n'); -} diff --git a/mcp_server_dart/lib/src/cli/sessions_persistence/session_persistence.dart b/mcp_server_dart/lib/src/cli/sessions_persistence/session_persistence.dart deleted file mode 100644 index 9a3412f0..00000000 --- a/mcp_server_dart/lib/src/cli/sessions_persistence/session_persistence.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'safe_writes.dart'; -export 'snapshot_store.dart'; diff --git a/mcp_server_dart/lib/src/cli/webmcp_command.dart b/mcp_server_dart/lib/src/cli/webmcp_command.dart index 5e506d3c..15e954a8 100644 --- a/mcp_server_dart/lib/src/cli/webmcp_command.dart +++ b/mcp_server_dart/lib/src/cli/webmcp_command.dart @@ -5,7 +5,7 @@ import 'dart:io'; import 'package:flutter_mcp_toolkit_server/src/capabilities/visual_capture/web_cdp_discovery.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -/// Chromium flags to expose `navigator.modelContext` for WebMCP E2E (pre-stable). +/// Chromium flags to expose WebMCP `modelContext` for E2E (pre-stable). /// /// Pass each via Flutter: `flutter run -d chrome --web-browser-flag=""`. const kWebmcpChromeBrowserFlags = [ @@ -54,10 +54,14 @@ Future runWebmcpChromeArgs({ return 0; } -/// Probes live Chrome via CDP for `navigator.modelContext`. +/// Probes live Chrome via CDP for WebMCP `document.modelContext`. Future runWebmcpVerify({ final int? cdpPort, final int preferredWebPort = 8080, + final String? toolName, + final Map toolArguments = const {}, + final String? expectResultField, + final Object? expectResultValue, final Duration timeout = const Duration(seconds: 8), }) async { final ports = {}; @@ -93,15 +97,15 @@ Future runWebmcpVerify({ wsUrl: wsUrl, expression: ''' (() => { - function probeNav(nav, source) { - if (!nav) return null; - const has = 'modelContext' in nav; - const reg = has && typeof nav.modelContext.registerTool === 'function'; + function probeOwner(owner, source) { + if (!owner) return null; + const has = 'modelContext' in owner; + const reg = has && typeof owner.modelContext.registerTool === 'function'; let toolCount = null; - if (has && typeof nav.modelContextTesting !== 'undefined' && - typeof nav.modelContextTesting.getTools === 'function') { + if (has && typeof owner.modelContextTesting !== 'undefined' && + typeof owner.modelContextTesting.getTools === 'function') { try { - toolCount = nav.modelContextTesting.getTools().length; + toolCount = owner.modelContextTesting.getTools().length; } catch (e) { toolCount = -1; } @@ -109,13 +113,16 @@ Future runWebmcpVerify({ return { hasModelContext: has, registerTool: reg, testingToolCount: toolCount, source: source }; } const candidates = [ - probeNav(globalThis.navigator, 'globalThis'), - probeNav(window.navigator, 'window'), - probeNav(document.defaultView && document.defaultView.navigator, 'defaultView'), + probeOwner(document, 'document'), + probeOwner(globalThis.document, 'globalThis.document'), + probeOwner(globalThis.navigator, 'globalThis.navigator'), + probeOwner(window.navigator, 'window.navigator'), + probeOwner(document.defaultView && document.defaultView.navigator, 'defaultView.navigator'), ].filter(Boolean); for (const frame of Array.from(document.querySelectorAll('iframe'))) { try { - candidates.push(probeNav(frame.contentWindow && frame.contentWindow.navigator, 'iframe')); + candidates.push(probeOwner(frame.contentDocument, 'iframe.document')); + candidates.push(probeOwner(frame.contentWindow && frame.contentWindow.navigator, 'iframe.navigator')); } catch (e) {} } const active = candidates.find((p) => p.hasModelContext && p.registerTool) || candidates[0]; @@ -126,17 +133,34 @@ Future runWebmcpVerify({ ); final cdpOk = probe != null && _probeIndicatesWebmcpActive(probe); + final toolProof = cdpOk && toolName != null + ? await _cdpInvokeWebMcpTool( + wsUrl: wsUrl, + toolName: toolName, + arguments: toolArguments, + expectResultField: expectResultField, + expectResultValue: expectResultValue, + timeout: timeout, + ) + : null; final logEvidence = _webmcpLogEvidence(); - final ok = cdpOk || logEvidence; + final ok = toolName == null + ? cdpOk || logEvidence + : toolProof?['ok'] == true && toolProof?['fetchCalled'] == false; stdout.writeln( jsonEncode({ 'ok': ok, 'cdpPort': port, 'pageUrl': page?['url'], 'probe': probe, + 'toolProof': ?toolProof, 'logEvidence': logEvidence, 'verdict': cdpOk - ? 'webmcp_active' + ? toolName == null + ? 'webmcp_active' + : ok + ? 'webmcp_tool_invoked' + : 'webmcp_tool_invoke_failed' : logEvidence ? 'webmcp_active_log_evidence' : 'webmcp_inactive', @@ -157,6 +181,120 @@ Future runWebmcpVerify({ return 1; } +Future?> _cdpInvokeWebMcpTool({ + required final Uri wsUrl, + required final String toolName, + required final Map arguments, + required final String? expectResultField, + required final Object? expectResultValue, + required final Duration timeout, +}) { + final expression = + ''' +(async () => { + const win = globalThis.window || globalThis; + const doc = win.document || globalThis.document; + const nav = globalThis.navigator || win.navigator; + const testing = (doc && doc.modelContextTesting) || + (globalThis.document && globalThis.document.modelContextTesting) || + (nav && nav.modelContextTesting) || + win.modelContextTesting || + (doc && doc.defaultView && doc.defaultView.navigator && + doc.defaultView.navigator.modelContextTesting); + if (!testing || typeof testing.getTools !== 'function') { + const hook = globalThis.__intentcallWebMcpDartExecute; + if (typeof hook !== 'function') { + return { ok: false, code: 'testing_api_unavailable' }; + } + let fetchCalled = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = function() { + fetchCalled = true; + return originalFetch.apply(this, arguments); + }; + try { + const result = await hook(${jsonEncode(toolName)}, ${jsonEncode(arguments)}); + const expectedField = ${jsonEncode(expectResultField)}; + const expectedValue = ${jsonEncode(expectResultValue)}; + const fieldValue = expectedField ? result && result[expectedField] : null; + const expectationMet = expectedField ? fieldValue === expectedValue : true; + return { + ok: expectationMet, + code: expectationMet + ? 'dart_hook_invoked_testing_api_unavailable' + : 'unexpected_result', + toolName: ${jsonEncode(toolName)}, + names: null, + result, + fetchCalled, + expectedField, + expectedValue, + fieldValue, + }; + } catch (e) { + return { + ok: false, + code: 'dart_hook_execute_error_testing_api_unavailable', + message: String(e), + toolName: ${jsonEncode(toolName)}, + names: null, + fetchCalled, + }; + } finally { + globalThis.fetch = originalFetch; + } + } + const tools = testing.getTools(); + const names = tools.map((tool) => tool.name); + const tool = tools.find((candidate) => candidate.name === ${jsonEncode(toolName)}); + if (!tool || typeof tool.execute !== 'function') { + return { ok: false, code: 'tool_missing', names }; + } + let fetchCalled = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = function() { + fetchCalled = true; + return originalFetch.apply(this, arguments); + }; + try { + const result = await tool.execute(${jsonEncode(arguments)}); + const expectedField = ${jsonEncode(expectResultField)}; + const expectedValue = ${jsonEncode(expectResultValue)}; + const fieldValue = expectedField ? result && result[expectedField] : null; + const expectationMet = expectedField ? fieldValue === expectedValue : true; + return { + ok: expectationMet, + code: expectationMet ? 'tool_invoked' : 'unexpected_result', + toolName: ${jsonEncode(toolName)}, + names, + result, + fetchCalled, + expectedField, + expectedValue, + fieldValue, + }; + } catch (e) { + return { + ok: false, + code: 'tool_execute_error', + message: String(e), + toolName: ${jsonEncode(toolName)}, + names, + fetchCalled, + }; + } finally { + globalThis.fetch = originalFetch; + } +})() +'''; + return _cdpEvaluate( + wsUrl: wsUrl, + expression: expression, + timeout: timeout, + awaitPromise: true, + ); +} + Map? _probeFromEvaluateResponse( final Map response, ) { @@ -211,6 +349,7 @@ Future?> _cdpEvaluate({ required final Uri wsUrl, required final String expression, required final Duration timeout, + final bool awaitPromise = false, }) async { WebSocketChannel? channel; StreamSubscription? sub; @@ -281,7 +420,7 @@ Future?> _cdpEvaluate({ final params = { 'expression': expression, 'returnByValue': true, - 'awaitPromise': false, + 'awaitPromise': awaitPromise, 'contextId': ?contextId, }; final result = await send({ diff --git a/mcp_server_dart/lib/src/mcp_toolkit_server/mixins/dynamic_registry_integration.dart b/mcp_server_dart/lib/src/mcp_toolkit_server/mixins/dynamic_registry_integration.dart index 3fe641e9..ea7d6c5e 100644 --- a/mcp_server_dart/lib/src/mcp_toolkit_server/mixins/dynamic_registry_integration.dart +++ b/mcp_server_dart/lib/src/mcp_toolkit_server/mixins/dynamic_registry_integration.dart @@ -47,6 +47,7 @@ base mixin DynamicRegistryIntegration on BaseMCPToolkitServer { mcpToolkitServer.attachDynamicGateway( RegistryBackedDynamicGateway( registry: registry, + agentRegistry: mcpToolkitServer.capabilityHost.agentRegistry, discoveryService: () => discoveryService, ), ); diff --git a/mcp_server_dart/lib/src/shared_core/agent_result_mapper.dart b/mcp_server_dart/lib/src/shared_core/agent_result_mapper.dart new file mode 100644 index 00000000..d191ba97 --- /dev/null +++ b/mcp_server_dart/lib/src/shared_core/agent_result_mapper.dart @@ -0,0 +1,21 @@ +// Copyright (c) 2025, Flutter Inspector MCP Server authors. +// Licensed under the MIT License. + +import 'package:flutter_mcp_toolkit_core/flutter_mcp_toolkit_core.dart'; +import 'package:intentcall_schema/intentcall_schema.dart'; + +CoreResult coreResultFromAgentResult( + final AgentResult result, { + final Map meta = const {}, +}) { + if (result.ok) { + return CoreResult.success(data: result.data, meta: meta); + } + + return CoreResult.failure( + code: result.code ?? CoreErrorCode.unknown, + message: result.message, + details: result.details, + meta: meta, + ); +} diff --git a/mcp_server_dart/lib/src/shared_core/command_executor.dart b/mcp_server_dart/lib/src/shared_core/command_executor.dart index 2497e5e8..64dab905 100644 --- a/mcp_server_dart/lib/src/shared_core/command_executor.dart +++ b/mcp_server_dart/lib/src/shared_core/command_executor.dart @@ -15,9 +15,9 @@ import 'package:flutter_mcp_toolkit_server/src/capabilities/visual_capture/platf import 'package:flutter_mcp_toolkit_server/src/capabilities/visual_capture/visual_capture.dart'; import 'package:flutter_mcp_toolkit_server/src/capabilities/visual_capture/web_browser_screenshot.dart'; import 'package:flutter_mcp_toolkit_server/src/capabilities/visual_capture/web_cdp_discovery.dart'; -import 'package:flutter_mcp_toolkit_server/src/cli/session/session_manager.dart'; import 'package:flutter_mcp_toolkit_server/src/mcp_toolkit_consts.dart'; import 'package:flutter_mcp_toolkit_server/src/runtime_version.dart'; +import 'package:flutter_mcp_toolkit_server/src/shared_core/agent_result_mapper.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/commands/commands.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/types/core_types.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/types/error_codes.dart'; @@ -25,6 +25,7 @@ import 'package:flutter_mcp_toolkit_server/src/shared_core/types/results.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/vm_connections/connection_context.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/vm_connections/core_port_scanner.dart'; import 'package:from_json_to_json/from_json_to_json.dart'; +import 'package:intentcall_session/intentcall_session.dart'; import 'package:is_dart_empty_or_not/is_dart_empty_or_not.dart'; import 'package:vm_service/vm_service.dart'; @@ -58,7 +59,7 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor { final CorePortScanner portScanner; final CoreImageFileSaver imageFileSaver; final CoreRuntimeConfiguration configuration; - final SessionManager? sessionManager; + final IntentSessionManager? sessionManager; CoreDynamicGateway? dynamicGateway; @@ -567,7 +568,9 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor { if (permission.actualMode == screenshotModeDesktopWindow) { final desktopCapture = await _tryDesktopWindowCapture( command, - captureMode: effectiveMode, + captureMode: effectiveMode == ScreenshotMode.auto + ? ScreenshotMode.desktopWindow + : effectiveMode, hints: hints, ); if (desktopCapture.data != null) { @@ -1151,10 +1154,7 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor { return CoreResult.success( data: { - 'hotReload': { - 'success': reloadSuccess, - if (reloadResult != null) ...reloadResult, - }, + 'hotReload': {'success': reloadSuccess, ...?reloadResult}, 'screenshot': screenshotData, if (command.includeSemantics) 'semantics': semanticsData, if (command.includeErrors) 'errors': errorsData, @@ -1619,7 +1619,10 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor { ); } - return manager.endSession(command.sessionId); + return coreResultFromAgentResult( + await manager.endSession(command.sessionId), + meta: {if (command.sessionId != null) 'sessionId': command.sessionId}, + ); } Future _sessionExec(final SessionExecCommand command) async { @@ -1631,7 +1634,11 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor { ); } - final attach = await manager.attachSession(sessionId: command.sessionId); + final attach = coreResultFromAgentResult( + await manager.attachSession( + IntentSessionAttachRequest(sessionId: command.sessionId), + ), + ); if (!attach.ok) { return attach; } @@ -1667,9 +1674,31 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor { ); } - return manager.startSession(command); + return coreResultFromAgentResult( + await manager.startSession(_toSessionStartRequest(command)), + ); } + IntentSessionStartRequest _toSessionStartRequest( + final SessionStartCommand command, + ) => IntentSessionStartRequest( + mode: _toIntentSessionMode(command.mode), + targetId: command.targetId, + uri: command.uri, + host: command.host, + port: command.port, + forceReconnect: command.forceReconnect, + sessionId: command.sessionId, + ); + + IntentSessionConnectionMode _toIntentSessionMode( + final CoreConnectionMode mode, + ) => switch (mode) { + CoreConnectionMode.auto => IntentSessionConnectionMode.auto, + CoreConnectionMode.manual => IntentSessionConnectionMode.manual, + CoreConnectionMode.uri => IntentSessionConnectionMode.uri, + }; + String _stableStringHash(final String value) { var hash = 0x811c9dc5; for (final byte in utf8.encode(value)) { @@ -1707,6 +1736,11 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor { ? const _DesktopCaptureResolution( errorMessage: 'Desktop window screenshot mode requires --flutter-device.', + errorDetails: { + 'reason': 'missing_flutter_device', + 'fix': + 'Pass global --flutter-device macos or --flutter-device ios.', + }, ) : const _DesktopCaptureResolution(); } @@ -1716,6 +1750,12 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor { errorMessage: 'Desktop window screenshot mode requires ' '--flutter-project-dir for macOS apps.', + errorDetails: { + 'reason': 'missing_flutter_project_dir', + 'device': 'macos', + 'fix': + 'Pass global --flutter-project-dir pointing at the Flutter app.', + }, ) : const _DesktopCaptureResolution(); } @@ -1742,7 +1782,10 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor { } return _DesktopCaptureResolution( errorMessage: recovery.errorMessage, - errorDetails: recovery.errorDetails, + errorDetails: { + ...recovery.recoveryMetadata(), + ...recovery.errorDetails, + }, ); } @@ -1799,7 +1842,11 @@ final class DefaultCoreCommandExecutor implements CoreCommandExecutor { final manager = sessionManager; if (command.sessionId != null && manager != null) { - final attach = await manager.attachSession(sessionId: command.sessionId); + final attach = coreResultFromAgentResult( + await manager.attachSession( + IntentSessionAttachRequest(sessionId: command.sessionId), + ), + ); if (!attach.ok) { return attach; } diff --git a/mcp_server_dart/lib/src/shared_core/vm_connections/preconnect.dart b/mcp_server_dart/lib/src/shared_core/vm_connections/preconnect.dart index ece299fc..cafcb8c4 100644 --- a/mcp_server_dart/lib/src/shared_core/vm_connections/preconnect.dart +++ b/mcp_server_dart/lib/src/shared_core/vm_connections/preconnect.dart @@ -1,16 +1,17 @@ // Copyright (c) 2025, Flutter Inspector MCP Server authors. // Licensed under the MIT License. -import 'package:flutter_mcp_toolkit_server/src/cli/session/session_manager.dart'; +import 'package:flutter_mcp_toolkit_server/src/shared_core/agent_result_mapper.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/command_executor.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/commands/commands.dart'; import 'package:flutter_mcp_toolkit_server/src/shared_core/types/results.dart'; +import 'package:intentcall_session/intentcall_session.dart'; /// Shared pre-connect policy for CLI one-shot, daemon requests, and snapshots. Future preconnectForExecution({ required final CoreCommand command, required final DefaultCoreCommandExecutor executor, - required final SessionManager? sessionManager, + required final IntentSessionManager? sessionManager, final ConnectCommand? explicitConnectionOverride, final String? explicitVmServiceUri, }) async { @@ -44,8 +45,10 @@ Future preconnectForExecution({ final requestedSessionId = _sessionIdForCommand(command); if (requestedSessionId != null && requestedSessionId.isNotEmpty) { - final explicitAttach = await manager.attachSession( - sessionId: requestedSessionId, + final explicitAttach = coreResultFromAgentResult( + await manager.attachSession( + IntentSessionAttachRequest(sessionId: requestedSessionId), + ), ); if (!explicitAttach.ok) { return explicitAttach; diff --git a/mcp_server_dart/lib/src/skill_assets.g.dart b/mcp_server_dart/lib/src/skill_assets.g.dart index de717927..91fc006c 100644 --- a/mcp_server_dart/lib/src/skill_assets.g.dart +++ b/mcp_server_dart/lib/src/skill_assets.g.dart @@ -86,9 +86,11 @@ parameter shapes lives in the task skills. - **Debug:** `get_recent_logs`, `evaluate_dart_expression`. → `flutter-mcp-toolkit-debug`. - **Dynamic registry (app-defined):** after registration in the Flutter app, - list with `list_client_tools_and_resources`, then `client_tool` / - `client_resource` — wire names as **`fmt_*`** when calling MCP. → - `flutter-mcp-toolkit-custom-tools`. + MCP calls use `fmt_list_client_tools_and_resources`, then + `fmt_client_tool` / `fmt_client_resource`. When shelling out to the CLI, + command names appear only as `exec --name ` values; do not call bare + `list_client_tools_and_resources`, `client_tool`, or `client_resource` as + MCP tools. → `flutter-mcp-toolkit-custom-tools`. ## When in doubt @@ -109,7 +111,7 @@ description: Verify the flutter-mcp-toolkit install, run doctor preflight, troub Use this skill when: -- First-time install: `flutter-mcp-toolkit` is not yet on PATH. +- First-time install: `flutter-mcp-toolkit` or its short alias `fmtk` is not yet on PATH. - `doctor --json` returns any check with `"status": "fail"`. - MCP server fails to connect or tools return `vm_not_connected` / `connect_failed`. - Visual capture or toolkit-bridge commands are returning unexpected errors. @@ -119,10 +121,11 @@ Use this skill when: ## Verify install ```bash -flutter-mcp-toolkit --version +flutter-mcp-toolkit --help +fmtk --help ``` -Expected output: version string (e.g. `flutter-mcp-toolkit 3.0.0`). +Expected output: command help from both names. `flutter-mcp-toolkit` is the canonical long name; `fmtk` is the compact alias for day-to-day terminal loops. If you get `command not found`, the binary is not on PATH: @@ -133,7 +136,7 @@ export PATH="$PATH:/path/to/mcp_flutter/mcp_server_dart/build" cd /path/to/mcp_flutter && make build ``` -Then verify with `flutter-mcp-toolkit --version`. +Then verify with `flutter-mcp-toolkit --help` and `fmtk --help`. --- @@ -142,14 +145,14 @@ Then verify with `flutter-mcp-toolkit --version`. Always run doctor before any VM-dependent command: ```bash -flutter-mcp-toolkit doctor --json +fmtk doctor --json ``` Flags: `--target ` (test a specific URI), global `--vm-service-uri ` (same as `--target` when omitted on `doctor`), `--timeout-ms ` (default: 2500). ```bash # Global URI works for doctor (same as validate-runtime) -flutter-mcp-toolkit --vm-service-uri 'ws://127.0.0.1:8181//ws' doctor --json +fmtk --vm-service-uri 'ws://127.0.0.1:8181//ws' doctor --json ``` Sample green output: @@ -187,9 +190,9 @@ export PATH="$PATH:/path/to/mcp_flutter/mcp_server_dart/build" Flutter app not running, stale token after restart, or URI not resolved: ```bash -flutter-mcp-toolkit exec --name discover_debug_apps --args '{}' -flutter-mcp-toolkit exec --name status --args '{}' -flutter-mcp-toolkit doctor --json --target ws://127.0.0.1:8181//ws +fmtk exec --name discover_debug_apps --args '{}' +fmtk exec --name status --args '{}' +fmtk doctor --json --target ws://127.0.0.1:8181//ws ``` After a successful auto re-attach, `meta.recovery.reattachedTo` shows the new endpoint. @@ -199,7 +202,7 @@ After a successful auto re-attach, `meta.recovery.reattachedTo` shows the new en Wrong port, app not started, or stale token. Pass explicit URI from `app.debugPort.wsUri`: ```bash -flutter-mcp-toolkit exec --name get_vm --args '{"connection":{"uri":"ws://127.0.0.1:8181//ws"}}' +fmtk exec --name get_vm --args '{"connection":{"uri":"ws://127.0.0.1:8181//ws"}}' ``` ### `connection_selection_required` @@ -211,7 +214,7 @@ Multiple debug targets detected. List with `discover_debug_apps`, then pass the Dart compilation error or VM disconnected. Check errors, fix, then retry: ```bash -flutter-mcp-toolkit exec --name get_app_errors --args '{}' +fmtk exec --name get_app_errors --args '{}' ``` ### `visual_capture_unsupported` @@ -219,7 +222,7 @@ flutter-mcp-toolkit exec --name get_app_errors --args '{}' macOS screen recording permission not granted or unsupported platform: ```bash -flutter-mcp-toolkit permissions request --kind visual_capture +fmtk permissions request --kind visual_capture ``` --- @@ -230,7 +233,7 @@ flutter-mcp-toolkit permissions request --kind visual_capture ```bash flutter run --debug --host-vmservice-port=8182 -d macos -flutter-mcp-toolkit --dart-vm-port 8182 doctor --json +fmtk --dart-vm-port 8182 doctor --json ``` Use `flutter run --machine` and copy `app.debugPort.wsUri` when you need the exact websocket URI (recommended for `validate-runtime` and `exec`). @@ -242,30 +245,30 @@ Use `flutter run --machine` and copy `app.debugPort.wsUri` when you need the exa **Multiple apps / wrong target**: Pass `--target` with the exact websocket URI: ```bash -flutter-mcp-toolkit doctor --json --target ws://127.0.0.1:8181//ws +fmtk doctor --json --target ws://127.0.0.1:8181//ws ``` --- ## CLI surface -The binary is `flutter-mcp-toolkit` (built to `mcp_server_dart/build/`). +The canonical binary is `flutter-mcp-toolkit` (built to `mcp_server_dart/build/`). Packaged installs also include `fmtk`, a short alias to the same entrypoint. Use `fmtk` in quick loops; keep the long name in install, onboarding, PATH, and MCP configuration docs. | Subcommand | Purpose | Minimal example | | --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `exec` | Run a single named command against the VM | `flutter-mcp-toolkit exec --name get_vm --args '{}'` | -| `batch` | Run multiple commands in one call | `flutter-mcp-toolkit batch --steps '[{"name":"get_vm"},{"name":"status"}]'` | -| `schema` | Print the JSON schema for a named command | `flutter-mcp-toolkit schema --name hot_reload_flutter` | -| `capabilities` | List all registered capabilities | `flutter-mcp-toolkit capabilities` | -| `serve` | Start the MCP server (stdio transport) | `flutter-mcp-toolkit serve` | -| `snapshot create` | Capture and save a named snapshot | `flutter-mcp-toolkit snapshot create --name baseline --args '{}'` | -| `snapshot diff` | Diff two snapshots | `flutter-mcp-toolkit snapshot diff --from baseline --to current` | -| `bundle create` | Package a snapshot into a publishable bundle | `flutter-mcp-toolkit bundle create --from-snapshot baseline --output ./out` | -| `doctor` | Run preflight checks (VM + toolkit + registry) | `flutter-mcp-toolkit doctor --json` | -| `permissions status` | Check a permission (e.g. visual_capture) | `flutter-mcp-toolkit permissions status --kind visual_capture` | -| `permissions request` | Request a permission | `flutter-mcp-toolkit permissions request --kind visual_capture` | -| `permissions open-settings` | Open OS settings for a permission | `flutter-mcp-toolkit permissions open-settings --kind visual_capture` | -| `validate-runtime` | End-to-end VM + toolkit + capture smoke test | `flutter-mcp-toolkit validate-runtime --target ws://127.0.0.1:8181//ws` | +| `exec` | Run a single named command against the VM | `fmtk exec --name get_vm --args '{}'` | +| `batch` | Run multiple commands in one call | `fmtk batch --steps '[{"name":"get_vm"},{"name":"status"}]'` | +| `schema` | Print the JSON schema for a named command | `fmtk schema --name hot_reload_flutter` | +| `capabilities` | List all registered capabilities | `fmtk capabilities` | +| `serve` | Start the MCP server (stdio transport) | `fmtk serve` | +| `snapshot create` | Capture and save a named snapshot | `fmtk snapshot create --name baseline --args '{}'` | +| `snapshot diff` | Diff two snapshots | `fmtk snapshot diff --from baseline --to current` | +| `bundle create` | Package a snapshot into a publishable bundle | `fmtk bundle create --from-snapshot baseline --output ./out` | +| `doctor` | Run preflight checks (VM + toolkit + registry) | `fmtk doctor --json` | +| `permissions status` | Check a permission (e.g. visual_capture) | `fmtk permissions status --kind visual_capture` | +| `permissions request` | Request a permission | `fmtk permissions request --kind visual_capture` | +| `permissions open-settings` | Open OS settings for a permission | `fmtk permissions open-settings --kind visual_capture` | +| `validate-runtime` | End-to-end VM + toolkit + capture smoke test | `fmtk validate-runtime --target ws://127.0.0.1:8181//ws` | | `init ` | Install skills + MCP server config for an AI agent | `flutter-mcp-toolkit init claude-code` | | `codegen-init` | Add toolkit dependency and emit `main.dart` boilerplate | `flutter-mcp-toolkit codegen-init` | @@ -275,6 +278,8 @@ Global flags (before the subcommand): `--dart-vm-port `, `--dart-vm-host ` @@ -316,7 +321,7 @@ The install script is idempotent — re-running it replaces the binary in place: curl -fsSL https://raw.githubusercontent.com/Arenukvern/mcp_flutter/main/install.sh | bash ``` -After reinstall, verify with `flutter-mcp-toolkit --version`. +After reinstall, verify with `flutter-mcp-toolkit --help` and `fmtk --help`. ''', relativePath: 'skills/flutter-mcp-toolkit-setup/SKILL.md', ), @@ -1842,7 +1847,7 @@ When changing IntentCall consumer integration in `mcp_flutter`: SkillAsset( id: 'flutter-mcp-toolkit-maintain-web', frontmatter: r'''name: flutter-mcp-toolkit-maintain-web -description: Maintains flutter_test_app and intentcall web targets (Chrome, web codegen, WebMCP bootstrap, web-showcase, webmcp verify). Use when editing web/index.html, agent_manifest.json, intentcall_webmcp.generated.js, web platform sync, Chrome dogfood, or navigator.modelContext.''', +description: Maintains flutter_test_app and intentcall web targets (Chrome, web codegen, WebMCP bootstrap, web-showcase, webmcp verify). Use when editing web/index.html, agent_manifest.json, intentcall_webmcp.generated.js, web platform sync, Chrome dogfood, or WebMCP modelContext.''', body: r''' @@ -1855,7 +1860,7 @@ Dogfood app: `flutter_test_app`. Canonical platform doc: `flutter_test_app/INTEN | Path | Proves | |------|--------| | VM extensions + `fmt_*` tools | MCP toolkit dogfood (always) | -| `navigator.modelContext` | True WebMCP (Chrome flag / `--web-browser-flag`) | +| `document.modelContext` | True WebMCP (Chrome flag / `--web-browser-flag`) | ADR: `decisions/0008_web_agent_invoke_js_only.mdx` — JS `fetch('/agent/invoke')` **404** by design; Dart `invokeDirect` works when `modelContext` exists. @@ -1932,7 +1937,7 @@ Dogfood app: `flutter_test_app`. Platform doc: `flutter_test_app/INTENTCALL_PLAT ## WebMCP on macOS -**`navigator.modelContext` is web-only.** macOS dogfood proves **VM extensions**, **dynamic registry**, **native invoke** (`intentcall://` via `app_links`), and **visual capture** (Screen Recording on host). +**WebMCP `modelContext` is web-only.** macOS dogfood proves **VM extensions**, **dynamic registry**, **native invoke** (`intentcall://` via `app_links`), and **visual capture** (Screen Recording on host). For WebMCP parity scoring, run web iteration separately (`flutter-mcp-toolkit-maintain-web`). @@ -2174,7 +2179,7 @@ Full Chrome runtime dogfood stays **local** until headless WebMCP is cost-effect "interface": { "displayName": "Flutter MCP Toolkit", "shortDescription": "Inspect, drive, and extend Flutter debug apps via MCP — including runtime custom tools.", - "longDescription": "flutter-mcp-toolkit is a Dart MCP server plus Flutter package (mcp_toolkit) for AI-assisted Flutter development in debug mode. Built-in: 27 fmt_* MCP tools and bundled agent skills. Dynamic registry: register app-specific tools and resources at runtime with AgentCallEntry and addMcpTool; agents discover via fmt_list_client_tools_and_resources and invoke via fmt_client_tool / fmt_client_resource. Requires debug app with mcp_toolkit and flutter-mcp-toolkit-server on PATH. Complements official Dart MCP.", + "longDescription": "flutter-mcp-toolkit is a Dart MCP server plus Flutter package (mcp_toolkit) for AI-assisted Flutter development in debug mode. Built-in: 30 fmt_* MCP tools and bundled agent skills. Dynamic registry: register app-specific tools and resources at runtime with AgentCallEntry and addMcpTool; agents discover via fmt_list_client_tools_and_resources and invoke via fmt_client_tool / fmt_client_resource. Requires debug app with mcp_toolkit and flutter-mcp-toolkit-server on PATH. Complements official Dart MCP.", "developerName": "Arenukvern", "category": "Developer Tools", "capabilities": [ diff --git a/mcp_server_dart/makefile b/mcp_server_dart/makefile index 06a8707d..75b28c6d 100644 --- a/mcp_server_dart/makefile +++ b/mcp_server_dart/makefile @@ -6,7 +6,8 @@ setup: compile: sync-skills @ mkdir -p build && \ dart compile exe bin/flutter_mcp_toolkit_server.dart -o build/flutter-mcp-toolkit-server && \ - dart compile exe bin/flutter_mcp_toolkit.dart -o build/flutter-mcp-toolkit + dart compile exe bin/flutter_mcp_toolkit.dart -o build/flutter-mcp-toolkit && \ + cp build/flutter-mcp-toolkit build/fmtk .PHONY: inspect inspect: diff --git a/mcp_server_dart/pubspec.yaml b/mcp_server_dart/pubspec.yaml index 8d6f02c2..0089a98d 100644 --- a/mcp_server_dart/pubspec.yaml +++ b/mcp_server_dart/pubspec.yaml @@ -8,6 +8,7 @@ environment: sdk: '>=3.12.0 <4.0.0' resolution: workspace executables: + fmtk: flutter_mcp_toolkit flutter-mcp-toolkit: flutter_mcp_toolkit flutter-mcp-toolkit-server: flutter_mcp_toolkit_server dependencies: @@ -21,10 +22,11 @@ dependencies: flutter_mcp_toolkit_capability_kernel: ^4.0.0-dev.5 flutter_mcp_toolkit_core: ^4.0.0-dev.5 from_json_to_json: ^0.5.0 - intentcall_core: ^0.1.0 - intentcall_mcp: ^0.1.0 - intentcall_platform: ^0.1.0 - intentcall_schema: ^0.1.0 + intentcall_core: ^0.3.1 + intentcall_mcp: ^0.3.1 + intentcall_platform: ^0.3.1 + intentcall_schema: ^0.3.1 + intentcall_session: ^0.3.1 is_dart_empty_or_not: ^0.4.0 json_rpc_2: ^4.1.0 logging: ^1.3.0 diff --git a/mcp_server_dart/test/bundle_builder_test.dart b/mcp_server_dart/test/bundle_builder_test.dart index d1de179b..95a94833 100644 --- a/mcp_server_dart/test/bundle_builder_test.dart +++ b/mcp_server_dart/test/bundle_builder_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter_mcp_toolkit_server/flutter_mcp_core.dart'; +import 'package:intentcall_session/intentcall_session.dart'; import 'package:test/test.dart'; void main() { @@ -10,7 +11,7 @@ void main() { late String snapshotsDir; late String bundlesDir; late String stateFilePath; - late SnapshotStore snapshotStore; + late IntentSnapshotStore snapshotStore; late BundleBuilder bundleBuilder; setUp(() { @@ -18,7 +19,7 @@ void main() { snapshotsDir = '${tempDir.path}/snapshots'; bundlesDir = '${tempDir.path}/bundles'; stateFilePath = '${tempDir.path}/state.json'; - snapshotStore = SnapshotStore(snapshotsDir: snapshotsDir); + snapshotStore = IntentSnapshotStore(snapshotsDir: snapshotsDir); bundleBuilder = BundleBuilder( bundlesDir: bundlesDir, snapshotStore: snapshotStore, diff --git a/mcp_server_dart/test/snapshot_store_test.dart b/mcp_server_dart/test/command_snapshot_service_test.dart similarity index 79% rename from mcp_server_dart/test/snapshot_store_test.dart rename to mcp_server_dart/test/command_snapshot_service_test.dart index c202975a..bb710e72 100644 --- a/mcp_server_dart/test/snapshot_store_test.dart +++ b/mcp_server_dart/test/command_snapshot_service_test.dart @@ -3,10 +3,11 @@ import 'dart:io'; import 'package:dart_mcp/server.dart'; import 'package:flutter_mcp_toolkit_server/flutter_mcp_core.dart'; +import 'package:intentcall_session/intentcall_session.dart'; import 'package:test/test.dart'; void main() { - group('SnapshotStore', () { + group('CommandSnapshotService', () { late Directory tempDir; late String snapshotsDir; @@ -22,7 +23,7 @@ void main() { }); test('creates snapshot deterministically from command plan', () async { - final store = SnapshotStore(snapshotsDir: snapshotsDir); + final store = CommandSnapshotService(snapshotsDir: snapshotsDir); final catalog = CommandCatalog.instance; void logger( @@ -72,8 +73,63 @@ void main() { expect(File('$snapshotsDir/s1.json').existsSync(), isTrue); }); + test( + 'accepts JSON string snapshot plans at the loose args boundary', + () async { + final store = CommandSnapshotService(snapshotsDir: snapshotsDir); + final catalog = CommandCatalog.instance; + + void logger( + final LoggingLevel level, + final String message, { + final String logger = 'test', + }) {} + + final context = ConnectionContext( + defaultHost: 'localhost', + defaultPort: 8181, + logger: logger, + discoverPorts: () async => [8181], + ); + + final executor = DefaultCoreCommandExecutor( + connectionContext: context, + portScanner: CorePortScanner(logger: logger), + imageFileSaver: CoreImageFileSaver(logger: logger), + configuration: const CoreRuntimeConfiguration( + vmHost: 'localhost', + vmPort: 8181, + resourcesSupported: true, + imagesSupported: true, + dumpsSupported: false, + dynamicRegistrySupported: false, + saveImagesToFiles: false, + ), + ); + + final snapshot = await store.createSnapshot( + id: 'json_plan', + executor: executor, + catalog: catalog, + args: { + 'commands': jsonEncode([ + {'name': 'status', 'args': jsonEncode({})}, + ]), + }, + ); + + final plan = (snapshot['plan']! as List).cast>(); + final results = (snapshot['results']! as List) + .cast>(); + + expect(plan.single['name'], equals('status')); + expect(plan.single['args'], equals({})); + expect(results.single['name'], equals('status')); + }, + ); + test('computes structural diff with path-level changes', () async { - final store = SnapshotStore(snapshotsDir: snapshotsDir); + final store = IntentSnapshotStore(snapshotsDir: snapshotsDir); await Directory(snapshotsDir).create(recursive: true); final a = File('$snapshotsDir/a.json'); @@ -114,7 +170,7 @@ void main() { }); test('honors per-step args.connection before step execution', () async { - final store = SnapshotStore(snapshotsDir: snapshotsDir); + final store = CommandSnapshotService(snapshotsDir: snapshotsDir); final catalog = CommandCatalog.instance; void logger( @@ -178,7 +234,7 @@ void main() { test( '--check mode reports planned snapshot drift without writing file', () async { - final store = SnapshotStore(snapshotsDir: snapshotsDir); + final store = CommandSnapshotService(snapshotsDir: snapshotsDir); final catalog = CommandCatalog.instance; void logger( @@ -231,7 +287,7 @@ void main() { ); test('--no-overwrite blocks existing snapshot target', () async { - final store = SnapshotStore(snapshotsDir: snapshotsDir); + final store = CommandSnapshotService(snapshotsDir: snapshotsDir); final catalog = CommandCatalog.instance; void logger( final LoggingLevel level, diff --git a/mcp_server_dart/test/core_executor_test.dart b/mcp_server_dart/test/core_executor_test.dart index bdb7d245..f4b93f67 100644 --- a/mcp_server_dart/test/core_executor_test.dart +++ b/mcp_server_dart/test/core_executor_test.dart @@ -243,8 +243,13 @@ void main() { expect(details?['desktopWindow'], isA>()); final desktopWindow = details?['desktopWindow'] as Map?; + expect(desktopWindow?['desktopCaptureRetried'], isTrue); + final firstAttempt = + desktopWindow?['firstAttempt'] as Map?; + final firstAttemptDetails = + firstAttempt?['details'] as Map?; expect( - desktopWindow?['allOwners'], + firstAttemptDetails?['allOwners'], equals(const ['Codex', 'sample_app']), ); }, diff --git a/mcp_server_dart/test/desktop_capture_recovery_test.dart b/mcp_server_dart/test/desktop_capture_recovery_test.dart index 3123e4e3..6ce2be97 100644 --- a/mcp_server_dart/test/desktop_capture_recovery_test.dart +++ b/mcp_server_dart/test/desktop_capture_recovery_test.dart @@ -127,6 +127,46 @@ void main() { expect(result.retried, isFalse); expect(captureCount, 1); }); + + test( + 'preserves first focus and second failure details after retry', + () async { + final service = _CountingDesktopService( + captures: [null, null], + ); + + final hints = detectPlatformViews({ + 'widgetType': 'UiKitView', + 'children': const [], + }); + + final result = await captureDesktopWithRecovery( + service: service, + projectDir: '/tmp', + device: 'macos', + compress: true, + targetPid: 42, + cacheDir: null, + hints: hints, + explicitDesktopMode: true, + ); + + expect(result.capture, isNull); + expect(result.retried, isTrue); + expect( + result.errorDetails['firstAttempt'], + isA>(), + ); + expect( + result.errorDetails['focus'], + equals({'ok': true}), + ); + expect( + result.errorDetails['secondAttempt'], + isA>(), + ); + }, + ); }); } diff --git a/mcp_server_dart/test/dynamic_registry_input_schema_test.dart b/mcp_server_dart/test/dynamic_registry_input_schema_test.dart index 01fc96f6..92acc4fe 100644 --- a/mcp_server_dart/test/dynamic_registry_input_schema_test.dart +++ b/mcp_server_dart/test/dynamic_registry_input_schema_test.dart @@ -131,6 +131,7 @@ void main() { flutterDiscoveryTimeoutMs: 2500, ), ); + // ignore: invalid_use_of_protected_member server.initializeDynamicRegistry(mcpToolkitServer: server); registry = server.dynamicRegistryForTesting!; }); diff --git a/mcp_server_dart/test/flutter_mcp_example_app_integration_test.dart b/mcp_server_dart/test/flutter_mcp_example_app_integration_test.dart index b855c83e..2de26190 100644 --- a/mcp_server_dart/test/flutter_mcp_example_app_integration_test.dart +++ b/mcp_server_dart/test/flutter_mcp_example_app_integration_test.dart @@ -381,7 +381,10 @@ void main() { 'mode': 'auto', 'permissionPolicy': 'auto_request_once', }); - final autoEnvelope = _decodeToolEnvelope(autoScreenshots); + final autoEnvelope = _decodeToolEnvelope( + autoScreenshots, + useLastText: true, + ); final autoPermission = (autoEnvelope['errorDetails'] as Map?)?['permission']; final autoRequestedMode = @@ -633,11 +636,14 @@ Map _decodeToolEnvelope( envelope['code'] is String && envelope['message'] is String) { final details = envelope['details']; - final permission = details is Map ? details['permission'] : null; + final errorDetails = details is Map && details['details'] is Map + ? details['details'] + : details; + final permission = errorDetails is Map ? errorDetails['permission'] : null; return { 'ok': false, 'errorCode': envelope['code'], - 'errorDetails': details, + 'errorDetails': errorDetails, 'requestedMode': permission is Map ? permission['requestedMode'] : null, 'actualMode': permission is Map ? permission['actualMode'] : null, }; diff --git a/mcp_server_dart/test/platform_view_capture_flow_test.dart b/mcp_server_dart/test/platform_view_capture_flow_test.dart index 8d5cd358..e3a4b314 100644 --- a/mcp_server_dart/test/platform_view_capture_flow_test.dart +++ b/mcp_server_dart/test/platform_view_capture_flow_test.dart @@ -106,6 +106,47 @@ void main() { }, ); + test('desktop failure explains missing flutter-device context', () async { + void logger( + final LoggingLevel level, + final String message, { + final String logger = 'test', + }) {} + + final executor = DefaultCoreCommandExecutor( + connectionContext: ConnectionContext( + defaultHost: 'localhost', + defaultPort: 8181, + logger: logger, + discoverPorts: () async => [8181], + ), + portScanner: CorePortScanner(logger: logger), + imageFileSaver: CoreImageFileSaver(logger: logger), + configuration: const CoreRuntimeConfiguration( + vmHost: 'localhost', + vmPort: 8181, + resourcesSupported: true, + imagesSupported: true, + dumpsSupported: false, + dynamicRegistrySupported: false, + saveImagesToFiles: false, + ), + desktopWindowScreenshotService: const _AlwaysFailFakeAdapter(), + ); + + final result = await executor.execute( + const GetScreenshotsCommand(mode: ScreenshotMode.desktopWindow), + ); + + expect(result.ok, isFalse); + expect(result.error?.message, contains('requires --flutter-device')); + final details = Map.from(result.error!.details! as Map); + final desktopWindow = Map.from( + details['desktopWindow']! as Map, + ); + expect(desktopWindow['reason'], 'missing_flutter_device'); + }); + test( 'auto upgrades to desktop_window when UiKitView in debug payload', () async { @@ -332,6 +373,14 @@ void main() { (detailsMap['captureHints'] as Map?) ?? const {}, ); expect(captureHints['platformViewsDetected'], isTrue); + expect(detailsMap['desktopWindow'], isA>()); + final desktopWindow = Map.from( + detailsMap['desktopWindow']! as Map, + ); + expect(desktopWindow['desktopCaptureRetried'], isTrue); + expect(desktopWindow['firstAttempt'], isA>()); + expect(desktopWindow['focus'], isA>()); + expect(desktopWindow['secondAttempt'], isA>()); }, ); }); diff --git a/mcp_server_dart/test/preconnect_test.dart b/mcp_server_dart/test/preconnect_test.dart index 0048cd98..ff76373e 100644 --- a/mcp_server_dart/test/preconnect_test.dart +++ b/mcp_server_dart/test/preconnect_test.dart @@ -2,6 +2,8 @@ import 'dart:io'; import 'package:dart_mcp/server.dart'; import 'package:flutter_mcp_toolkit_server/flutter_mcp_core.dart'; +import 'package:flutter_mcp_toolkit_server/src/cli/session/flutter_session_connector.dart'; +import 'package:intentcall_session/intentcall_session.dart'; import 'package:test/test.dart'; void main() { @@ -37,8 +39,8 @@ void main() { (final endpoint, {required final timeout}) async => true, ); - final manager = SessionManager( - connectionContext: context, + final manager = IntentSessionManager( + connector: FlutterSessionConnector(connectionContext: context), stateStore: store, ); await manager.load(); @@ -73,8 +75,8 @@ void main() { discoverPorts: () async => [8181, 8182], ); - final manager = SessionManager( - connectionContext: context, + final manager = IntentSessionManager( + connector: FlutterSessionConnector(connectionContext: context), stateStore: store, ); await manager.load(); @@ -129,7 +131,7 @@ void main() { DefaultCoreCommandExecutor _buildExecutor({ required final ConnectionContext context, - required final SessionManager? sessionManager, + required final IntentSessionManager? sessionManager, }) => DefaultCoreCommandExecutor( connectionContext: context, portScanner: const CorePortScanner(logger: _noopLogger), diff --git a/mcp_server_dart/test/registry_discovery_service_test.dart b/mcp_server_dart/test/registry_discovery_service_test.dart index a505acf0..241e32f8 100644 --- a/mcp_server_dart/test/registry_discovery_service_test.dart +++ b/mcp_server_dart/test/registry_discovery_service_test.dart @@ -107,6 +107,7 @@ void main() { setUp(() { server = _createDiscoveryTestServer(); + // ignore: invalid_use_of_protected_member server.initializeDynamicRegistry(mcpToolkitServer: server); registry = server.dynamicRegistryForTesting!; discovery = RegistryDiscoveryService( @@ -115,35 +116,65 @@ void main() { ); }); - test('parse failure unregisters stale dynamic registry', () async { - registry.registerTool( - Tool( - name: 'stale_tool', - description: 'previously registered', - inputSchema: ObjectSchema(), - ), - const DynamicAppId('stale_app'), - ); - expect(registry.appInfo?.toolCount, 1); - - final events = []; - final sub = registry.events.listen(events.add); - - await discovery.processRegistrationResponseForTesting( - _registrationPayload( - tools: [ - _validToolMap(), - const {'invalid': true}, - ], - ), - ); + test( + 'parse failure unregisters stale dynamic app from all registries', + () async { + server.registerDynamicTool( + Tool( + name: 'stale_tool', + description: 'previously registered', + inputSchema: ObjectSchema(), + ), + 'stale_app', + ); + server.registerDynamicResource( + Resource( + uri: 'visual://localhost/stale', + name: 'stale_resource', + description: 'previously registered', + mimeType: 'application/json', + ), + 'stale_app', + ); + expect(registry.appInfo?.toolCount, 1); + expect(registry.appInfo?.resourceCount, 1); - await Future.delayed(Duration.zero); - await sub.cancel(); + final events = []; + final sub = registry.events.listen(events.add); - expect(registry.appInfo?.toolCount ?? 0, 0); - expect(events.whereType(), isNotEmpty); - }); + await discovery.processRegistrationResponseForTesting( + _registrationPayload( + tools: [ + _validToolMap(), + const {'invalid': true}, + ], + ), + ); + + await Future.delayed(Duration.zero); + await sub.cancel(); + + expect(registry.appInfo?.toolCount ?? 0, 0); + expect(registry.appInfo?.resourceCount ?? 0, 0); + expect(registry.getToolEntry('stale_tool'), isNull); + expect(registry.getResourceEntry('visual://localhost/stale'), isNull); + expect(events.whereType(), isNotEmpty); + + final staleTool = await server.capabilityHost.agentRegistry.invoke( + 'stale_tool', + const {}, + ); + expect(staleTool.ok, isFalse); + expect(staleTool.code, 'intent_not_found'); + + final staleResource = await server.capabilityHost.agentRegistry.invoke( + 'visual://localhost/stale', + const {'uri': 'visual://localhost/stale'}, + ); + expect(staleResource.ok, isFalse); + expect(staleResource.code, 'intent_not_found'); + }, + ); test('valid payload registers tools after clearing prior app', () async { registry.registerTool( diff --git a/mcp_server_dart/test/safe_writes_test.dart b/mcp_server_dart/test/safe_writes_test.dart index a7d49aa4..1bf0dc96 100644 --- a/mcp_server_dart/test/safe_writes_test.dart +++ b/mcp_server_dart/test/safe_writes_test.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:flutter_mcp_toolkit_server/flutter_mcp_core.dart'; +import 'package:intentcall_session/intentcall_session.dart'; import 'package:test/test.dart'; void main() { diff --git a/mcp_server_dart/test/session_manager_test.dart b/mcp_server_dart/test/session_manager_test.dart index 485214c2..73036497 100644 --- a/mcp_server_dart/test/session_manager_test.dart +++ b/mcp_server_dart/test/session_manager_test.dart @@ -1,10 +1,12 @@ import 'dart:io'; import 'package:flutter_mcp_toolkit_server/flutter_mcp_core.dart'; +import 'package:flutter_mcp_toolkit_server/src/cli/session/flutter_session_connector.dart'; +import 'package:intentcall_session/intentcall_session.dart'; import 'package:test/test.dart'; void main() { - group('SessionManager', () { + group('IntentSessionManager', () { late Directory tempDir; late String statePath; @@ -45,8 +47,8 @@ void main() { discoverPorts: () async => [8181], ); - final manager = SessionManager( - connectionContext: context, + final manager = IntentSessionManager( + connector: FlutterSessionConnector(connectionContext: context), stateStore: store, ); await manager.load(); @@ -68,15 +70,15 @@ void main() { discoverPorts: () async => [8181], ); - final manager = SessionManager( - connectionContext: context, + final manager = IntentSessionManager( + connector: FlutterSessionConnector(connectionContext: context), stateStore: store, ); await manager.load(); final result = await manager.endSession('does-not-exist'); expect(result.ok, isFalse); - expect(result.error?.code, equals('session_not_found')); + expect(result.code, equals('session_not_found')); }); }); } diff --git a/mcp_server_dart/test/state_lock_manager_test.dart b/mcp_server_dart/test/state_lock_manager_test.dart index 91150042..cedf06e8 100644 --- a/mcp_server_dart/test/state_lock_manager_test.dart +++ b/mcp_server_dart/test/state_lock_manager_test.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'package:flutter_mcp_toolkit_server/flutter_mcp_core.dart'; +import 'package:intentcall_session/intentcall_session.dart'; import 'package:test/test.dart'; void main() { diff --git a/mcp_server_dart/test/state_store_test.dart b/mcp_server_dart/test/state_store_test.dart index e688c03f..4d455c69 100644 --- a/mcp_server_dart/test/state_store_test.dart +++ b/mcp_server_dart/test/state_store_test.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:flutter_mcp_toolkit_server/flutter_mcp_core.dart'; +import 'package:intentcall_session/intentcall_session.dart'; import 'package:test/test.dart'; void main() { diff --git a/mcp_toolkit/README.md b/mcp_toolkit/README.md index e198acc1..3084ff0b 100644 --- a/mcp_toolkit/README.md +++ b/mcp_toolkit/README.md @@ -57,7 +57,7 @@ await MCPToolkitBinding.instance.bootstrapFlutter( ``` Or use **`mcpToolkitTool`** / **`mcpToolkitResource`** when you already have -`MCPToolDefinition` + `MCPCallResult` handlers (see [example/fibonacci_tool_example.dart](example/fibonacci_tool_example.dart)). +`MCPToolDefinition` + `MCPCallResult` handlers (see [example/fibonacci_tool_example.dart](example/fibonacci_tool_example.dart)). These helpers are compatibility bridges; new reusable registry, session, and result behavior belongs in IntentCall packages. App-side permission bridging is separate and opt-in: diff --git a/mcp_toolkit/lib/mcp_toolkit.dart b/mcp_toolkit/lib/mcp_toolkit.dart index 84cd40ef..e2826929 100644 --- a/mcp_toolkit/lib/mcp_toolkit.dart +++ b/mcp_toolkit/lib/mcp_toolkit.dart @@ -8,7 +8,8 @@ /// - Flutter binding: [MCPToolkitBinding], [addMcpTool], toolkits under `src/toolkits/`. /// - Authoring: [AgentCallEntry] (register with [MCPToolkitBinding.addEntries]). /// - Legacy handler bridge: [mcpToolkitTool], [mcpToolkitResource] for -/// [MCPToolDefinition] + [MCPCallResult] handlers. +/// [MCPToolDefinition] + [MCPCallResult] handlers. New reusable registry, +/// session, and result behavior belongs in IntentCall packages. /// - Wire types: [MCPCallResult], [MCPToolDefinition], [MCPResourceDefinition]. /// /// `MCPCallEntry` was removed in intentcall Phase 6b; use diff --git a/mcp_toolkit/lib/src/mcp_toolkit_binding.dart b/mcp_toolkit/lib/src/mcp_toolkit_binding.dart index 2d3c9b61..0b4e15c7 100644 --- a/mcp_toolkit/lib/src/mcp_toolkit_binding.dart +++ b/mcp_toolkit/lib/src/mcp_toolkit_binding.dart @@ -161,8 +161,8 @@ class MCPToolkitBinding extends MCPToolkitBindingBase /// Initializes the MCP Toolkit binding. /// - /// Registers service extensions that can be called by the MCP server - /// through the Dart VM service. + /// Registers debug/profile service extensions that can be called by the MCP + /// server through the Dart VM service. Future addEntries({required final Set entries}) async { assert(() { initializeServiceExtensions(errorMonitor: this, entries: entries); diff --git a/mcp_toolkit/lib/src/mcp_toolkit_extensions.dart b/mcp_toolkit/lib/src/mcp_toolkit_extensions.dart index e5026092..a52640df 100644 --- a/mcp_toolkit/lib/src/mcp_toolkit_extensions.dart +++ b/mcp_toolkit/lib/src/mcp_toolkit_extensions.dart @@ -53,6 +53,8 @@ mixin MCPToolkitExtensions on MCPToolkitBindingBase { ); } + // Dynamic registration is a debug/profile VM-service surface; release apps + // should not depend on these service extensions being present. assert(() { final allEntries = {..._allEntries, ...entries}; final uniqueEntries = {}; @@ -114,6 +116,7 @@ mixin MCPToolkitExtensions on MCPToolkitBindingBase { final Map parameters, ) => parameters.map(MapEntry.new)..remove('isolateId'); + /// Posts the debug-only DTD event consumed by Flutter MCP dynamic discovery. void _postToolRegistrationEvent(final Set newEntries) { if (newEntries.isEmpty) return; diff --git a/mcp_toolkit/lib/src/services/gesture_interaction_service.dart b/mcp_toolkit/lib/src/services/gesture_interaction_service.dart index ba5a1fb7..19d7b1ef 100644 --- a/mcp_toolkit/lib/src/services/gesture_interaction_service.dart +++ b/mcp_toolkit/lib/src/services/gesture_interaction_service.dart @@ -46,6 +46,19 @@ mixin GestureInteractionService { if (node == null) { return _refNotFound(ref); } + final visibility = SemanticSnapshotService.visibilityForRef(ref); + if (visibility['centerInViewport'] != true) { + return { + 'success': false, + 'ref': ref, + 'action': 'tap', + 'error': 'target_outside_viewport', + 'message': + 'Target center is outside the visible viewport. Reveal or scroll ' + 'the target before tapping.', + ...visibility, + }; + } if (node.getSemanticsData().hasAction(SemanticsAction.tap)) { final owner = SemanticSnapshotService.semanticsOwner; @@ -313,6 +326,23 @@ mixin GestureInteractionService { } } + if (kIsWeb) { + if (semanticAttempt != null) { + return semanticAttempt; + } + return { + 'success': false, + 'ref': ?ref, + 'action': 'scroll_$direction', + 'error': 'unsupported_scroll_action', + 'hint': + 'No Flutter Web semantics node exposed the matching scroll ' + 'action for this direction. Call semantic_snapshot and choose a ' + 'scrollable ref with scrollUp / scrollDown / scrollLeft / ' + 'scrollRight.', + }; + } + // Desktop-friendly fallback: PointerScrollEvent (mouse-wheel-style). // This is how Flutter on macOS/Linux/Windows actually scrolls; synthetic // touch drags don't always drive scroll physics on desktop. @@ -326,22 +356,6 @@ mixin GestureInteractionService { final scrollDelta = _scrollDelta(direction, distance); final scrollable = _findAnyScrollable(); final before = _scrollPosition(scrollable); - if (kIsWeb) { - // PointerScrollEvent routed through GestureBinding doesn't reach the - // Flutter Web engine's wheel handler either. Return a structured - // failure so the agent re-snapshots to find a scrollable ref. - return { - 'success': false, - 'ref': ?ref, - 'action': 'scroll_$direction', - 'error': 'web_gesture_not_supported', - 'hint': - 'scroll on Flutter Web requires a ref whose "actions" include ' - 'the matching scroll action. Call semantic_snapshot, find a ' - 'node with actions scrollUp / scrollDown, and pass its ref to ' - 'scroll(ref, direction).', - }; - } await _dispatchScrollSignal(origin, scrollDelta); final after = _scrollPosition(scrollable); if (before != null && after != null && before == after) { @@ -397,8 +411,11 @@ mixin GestureInteractionService { } final before = _scrollPosition(node); + final beforeSignature = kIsWeb + ? SemanticSnapshotService.visibleSubtreeSignature(node) + : null; owner.performAction(node.id, action); - await _waitFrame(); + await _waitSemanticScrollFrame(); var after = _scrollPosition(node); if (before != null && after != null && before != after) { return { @@ -411,9 +428,28 @@ mixin GestureInteractionService { 'scrollAfter': after, }; } + final actionProgress = kIsWeb + ? _webScrollSubtreeProgress( + beforeSignature: beforeSignature, + node: node, + ) + : null; + if (actionProgress != null) { + return { + 'success': true, + 'ref': ?ref, + 'targetNodeId': ?targetNodeId, + 'via': 'semantic_action_web', + 'action': 'scroll_$direction', + 'platform': 'web', + 'distance': distance, + 'scrollBefore': ?before, + 'scrollAfter': ?after, + ...actionProgress, + }; + } - if (node.getSemanticsData().hasAction(SemanticsAction.scrollToOffset) && - before != null) { + if (node.getSemanticsData().hasAction(SemanticsAction.scrollToOffset)) { final target = _targetScrollOffset( direction: direction, distance: distance, @@ -430,9 +466,9 @@ mixin GestureInteractionService { SemanticsAction.scrollToOffset, scrollToOffsetArgs, ); - await _waitFrame(); + await _waitSemanticScrollFrame(); after = _scrollPosition(node); - if (after != null && before != after) { + if (before != null && after != null && before != after) { return { 'success': true, 'ref': ?ref, @@ -444,6 +480,27 @@ mixin GestureInteractionService { 'scrollAfter': after, }; } + final offsetProgress = kIsWeb + ? _webScrollSubtreeProgress( + beforeSignature: beforeSignature, + node: node, + ) + : null; + if (offsetProgress != null) { + return { + 'success': true, + 'ref': ?ref, + 'targetNodeId': ?targetNodeId, + 'via': 'semantic_scroll_to_offset_web', + 'action': 'scroll_$direction', + 'platform': 'web', + 'distance': distance, + 'scrollBefore': ?before, + 'scrollAfter': ?after, + 'scrollToOffset': target, + ...offsetProgress, + }; + } } return { @@ -457,14 +514,18 @@ mixin GestureInteractionService { 'scrollExtentMin': _finiteOrNull(node.getSemanticsData().scrollExtentMin), 'scrollExtentMax': _finiteOrNull(node.getSemanticsData().scrollExtentMax), 'error': 'no_scroll_movement', + 'platform': kIsWeb ? 'web' : 'flutter', + 'movementVerified': false, + 'dispatched': true, }; } static bool _scrollMoved(final Map result) => result['success'] == true && - result['scrollBefore'] != null && - result['scrollAfter'] != null && - result['scrollBefore'] != result['scrollAfter']; + ((result['scrollBefore'] != null && + result['scrollAfter'] != null && + result['scrollBefore'] != result['scrollAfter']) || + result['movementVerified'] == true); /// Best-effort classification of [node]'s widget type, used only to produce /// a helpful hint when `enter_text` can't find an editable state. Returns @@ -597,20 +658,29 @@ mixin GestureInteractionService { final scrollAction = _scrollActionFor(direction); if (scrollAction != null) { final node = SemanticSnapshotService.resolveRef(ref); - final owner = SemanticSnapshotService.semanticsOwner; - if (node != null && - owner != null && - node.getSemanticsData().hasAction(scrollAction)) { - owner.performAction(node.id, scrollAction); - await _waitFrame(); + if (node != null && node.getSemanticsData().hasAction(scrollAction)) { + final result = await scroll( + ref: ref, + direction: direction, + distance: distance, + ); + if (result['success'] == true) { + result + ..['action'] = 'swipe_$direction' + ..['note'] = + 'On web, swipe redirected to the verified semantic ' + 'scroll path because pointer synthesis does not drive ' + 'browser scroll physics.'; + return result; + } return { - 'success': true, + ...result, 'ref': ref, - 'via': 'semantic_action_fallback', 'action': 'swipe_$direction', 'note': 'On web, swipe redirected to SemanticsAction.scroll because ' - 'pointer synthesis does not drive browser scroll physics.', + 'pointer synthesis does not drive browser scroll physics, ' + 'but no movement was verified.', }; } } @@ -703,25 +773,17 @@ mixin GestureInteractionService { return _refNotFound(ref); } - // Register the device first — MouseTracker only tracks hover events from - // pointers that announced themselves via PointerAddedEvent; otherwise the - // hover may be filtered out and MouseRegion.onEnter never fires. - // Use a fresh pointer id and end with PointerRemovedEvent so repeated - // hover calls do not violate MouseTracker's Added-after-Removed invariant. + // Drive hover as position changes only. Flutter's MouseTracker keeps + // per-device state and asserts if a PointerAddedEvent is sent without a + // preceding PointerRemovedEvent for that device; in live apps a synthetic + // add/remove sequence can race existing mouse state. Hover events are + // enough to update the tracked position and fire MouseRegion transitions. final pointer = _nextPointerId++; final binding = GestureBinding.instance; const prime = ui.Offset(-100, -100); binding - ..handlePointerEvent( - PointerAddedEvent( - pointer: pointer, - position: prime, - kind: PointerDeviceKind.mouse, - timeStamp: _now(), - ), - ) - // Prime: hover off-screen first so the target hover is a clean - // position change. + // Prime: hover off-screen first so the target hover is a clean position + // change. ..handlePointerEvent( PointerHoverEvent( pointer: pointer, @@ -737,14 +799,6 @@ mixin GestureInteractionService { kind: PointerDeviceKind.mouse, timeStamp: _now(), ), - ) - ..handlePointerEvent( - PointerRemovedEvent( - pointer: pointer, - position: centre, - kind: PointerDeviceKind.mouse, - timeStamp: _now(), - ), ); await _waitFrame(); @@ -952,6 +1006,32 @@ mixin GestureInteractionService { static bool _isHorizontal(final String direction) => direction.toLowerCase() == 'left' || direction.toLowerCase() == 'right'; + static Map? _webScrollSubtreeProgress({ + required final Map? beforeSignature, + required final SemanticsNode node, + }) { + if (beforeSignature == null || beforeSignature['available'] != true) { + return null; + } + final afterSignature = SemanticSnapshotService.visibleSubtreeSignature( + node, + ); + if (afterSignature['available'] != true || + afterSignature['signatureHash'] == beforeSignature['signatureHash']) { + return null; + } + + return { + 'movementVerified': true, + 'movementSource': 'scrollable_subtree_signature', + 'targetNodeId': node.id, + 'visibleDescendantCountBefore': beforeSignature['visibleDescendantCount'], + 'visibleDescendantCountAfter': afterSignature['visibleDescendantCount'], + 'signatureHashBefore': beforeSignature['signatureHash'], + 'signatureHashAfter': afterSignature['signatureHash'], + }; + } + /// Return the centre of the first render view. static ui.Offset _screenCenter() { try { @@ -992,4 +1072,13 @@ mixin GestureInteractionService { /// work triggered by a preceding dispatch. static Future _waitFrame() => Future.delayed(const Duration(milliseconds: 16)); + + static Future _waitSemanticScrollFrame() async { + await _waitFrame(); + if (!kIsWeb) return; + for (var i = 0; i < 3; i++) { + WidgetsBinding.instance.scheduleFrame(); + await Future.delayed(const Duration(milliseconds: 32)); + } + } } diff --git a/mcp_toolkit/lib/src/services/reveal_search_service.dart b/mcp_toolkit/lib/src/services/reveal_search_service.dart index 3e8793d7..21568c22 100644 --- a/mcp_toolkit/lib/src/services/reveal_search_service.dart +++ b/mcp_toolkit/lib/src/services/reveal_search_service.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart' show visibleForTesting; + import 'gesture_interaction_service.dart'; import 'semantic_snapshot_service.dart'; @@ -28,7 +30,7 @@ mixin RevealSearchService { } final normalizedMatchBy = _normalizeMatchBy(matchBy); - final boundedMaxAttempts = maxAttempts.clamp(0, _maxAttemptsLimit).toInt(); + final boundedMaxAttempts = maxAttempts.clamp(0, _maxAttemptsLimit); final boundedDistance = distance.clamp(1, _maxDistance).toDouble(); final attempts = >[]; Map? lastSnapshot; @@ -47,16 +49,24 @@ mixin RevealSearchService { 'snapshotId': snapshot['snapshot_id'], 'nodeCount': snapshot['nodeCount'], 'found': match != null, - if (match != null) 'ref': match['ref'], }; + if (match != null) { + trace + ..['ref'] = match['ref'] + ..['visibleInViewport'] = match['visibleInViewport'] + ..['centerInViewport'] = match['centerInViewport']; + } attempts.add(trace); - if (match != null) { + if (match != null && match['centerInViewport'] == true) { return { 'success': true, 'ref': match['ref'], 'snapshotId': snapshot['snapshot_id'], 'match': match, + 'visibleInViewport': match['visibleInViewport'], + 'centerInViewport': match['centerInViewport'], + 'viewport': snapshot['viewport'], 'query': normalizedQuery, 'matchBy': normalizedMatchBy, 'attempts': attempts, @@ -64,6 +74,26 @@ mixin RevealSearchService { } if (attempt == boundedMaxAttempts) { + if (match != null) { + return { + 'success': true, + 'ref': match['ref'], + 'snapshotId': snapshot['snapshot_id'], + 'match': match, + 'visibleInViewport': match['visibleInViewport'], + 'centerInViewport': match['centerInViewport'], + 'viewport': snapshot['viewport'], + 'recommendedNextAction': 'scroll_more', + 'warning': + 'Target was found but its center is outside the viewport.', + 'query': normalizedQuery, + 'matchBy': normalizedMatchBy, + 'direction': direction, + 'maxAttempts': boundedMaxAttempts, + 'distance': boundedDistance, + 'attempts': attempts, + }; + } break; } @@ -73,6 +103,29 @@ mixin RevealSearchService { ); trace['scroll'] = scroll; if (scroll['success'] != true) { + if (_shouldContinueAfterScroll(scroll)) { + continue; + } + if (match != null) { + return { + 'success': true, + 'ref': match['ref'], + 'snapshotId': snapshot['snapshot_id'], + 'match': match, + 'visibleInViewport': match['visibleInViewport'], + 'centerInViewport': match['centerInViewport'], + 'viewport': snapshot['viewport'], + 'recommendedNextAction': 'scroll_more', + 'warning': + 'Target was found but its center is outside the viewport.', + 'query': normalizedQuery, + 'matchBy': normalizedMatchBy, + 'direction': direction, + 'maxAttempts': boundedMaxAttempts, + 'distance': boundedDistance, + 'attempts': attempts, + }; + } return { 'success': false, 'error': 'scroll_blocked', @@ -151,4 +204,15 @@ mixin RevealSearchService { _ => 'text', }; } + + @visibleForTesting + static bool shouldContinueAfterScrollForTesting( + final Map scroll, + ) => _shouldContinueAfterScroll(scroll); + + static bool _shouldContinueAfterScroll(final Map scroll) { + if (scroll['deferredMovementCheck'] == true) return true; + if (scroll['movementVerified'] == true) return true; + return false; + } } diff --git a/mcp_toolkit/lib/src/services/semantic_snapshot_service.dart b/mcp_toolkit/lib/src/services/semantic_snapshot_service.dart index 1ff91fb1..cde7c7a9 100644 --- a/mcp_toolkit/lib/src/services/semantic_snapshot_service.dart +++ b/mcp_toolkit/lib/src/services/semantic_snapshot_service.dart @@ -41,6 +41,58 @@ mixin SemanticSnapshotService { /// Look up the cached global center for a ref from the last snapshot. static ui.Offset? resolveCenter(final String ref) => _lastCenterMap[ref]; + /// Current logical viewport for pointer-driven interactions. + static ui.Rect? get viewportRect { + final renderViews = WidgetsBinding.instance.renderViews; + if (renderViews.isEmpty) return null; + final view = renderViews.first.flutterView; + final dpr = view.devicePixelRatio; + if (dpr <= 0) return null; + final physicalSize = view.physicalSize; + return ui.Rect.fromLTWH( + 0, + 0, + physicalSize.width / dpr, + physicalSize.height / dpr, + ); + } + + /// Visibility metadata for a cached ref from the last snapshot. + static Map visibilityForRef(final String ref) { + final bounds = resolveBounds(ref); + final center = resolveCenter(ref); + final viewport = viewportRect; + return visibilityForBounds( + bounds: bounds, + center: center, + viewport: viewport, + ); + } + + /// Visibility metadata for logical bounds in the current Flutter viewport. + static Map visibilityForBounds({ + required final ui.Rect? bounds, + required final ui.Offset? center, + required final ui.Rect? viewport, + }) { + final visible = + bounds != null && + viewport != null && + bounds.overlaps(viewport) && + bounds.width > 0 && + bounds.height > 0; + final centerVisible = + center != null && viewport != null && viewport.contains(center); + return { + 'visibleInViewport': visible, + 'centerInViewport': centerVisible, + if (bounds != null) 'bounds': _rectToMap(bounds), + if (viewport != null) 'viewport': _rectToMap(viewport), + if (center != null) + 'center': {'x': center.dx, 'y': center.dy}, + }; + } + /// Ensure the semantics tree is built and return the active /// [SemanticsOwner]. /// @@ -94,6 +146,68 @@ mixin SemanticSnapshotService { static Future> peekSemanticSnapshot() => _buildSnapshot(incrementId: false); + /// Non-mutating signature of visible descendants under [node]. + /// + /// This intentionally does not allocate refs, update cached ref/bounds maps, + /// or bump [currentSnapshotId]. It is for internal movement checks where the + /// caller already owns a live semantics node and must not invalidate or + /// silently rebind refs from the last public snapshot. + static Map visibleSubtreeSignature( + final SemanticsNode node, + ) { + final viewport = viewportRect; + if (viewport == null) { + return { + 'available': false, + 'targetNodeId': node.id, + 'reason': 'viewport_unavailable', + }; + } + + final entries = []; + void walk(final SemanticsNode current) { + final rect = _globalRect(current); + final visible = + current != node && + rect.overlaps(viewport) && + rect.width > 0 && + rect.height > 0; + if (visible) { + final data = current.getSemanticsData(); + entries.add( + [ + current.id, + _classifyNode(data), + rect.left.round(), + rect.top.round(), + rect.right.round(), + rect.bottom.round(), + ].join(':'), + ); + } + current.visitChildren((final child) { + walk(child); + return true; + }); + } + + walk(node); + if (entries.isEmpty) { + return { + 'available': false, + 'targetNodeId': node.id, + 'reason': 'no_visible_descendants', + }; + } + + return { + 'available': true, + 'targetNodeId': node.id, + 'visibleDescendantCount': entries.length, + 'signatureHash': _stableHash(entries), + }; + } + static Future> _buildSnapshot({ required final bool incrementId, }) async { @@ -274,12 +388,26 @@ mixin SemanticSnapshotService { _lastBoundsMap = boundsMap; _lastCenterMap = centerMap; + final viewport = viewportRect; + for (final node in nodes) { + final ref = node['ref']; + if (ref is! String) continue; + node.addAll( + visibilityForBounds( + bounds: boundsMap[ref], + center: centerMap[ref], + viewport: viewport, + ), + ); + } + return { 'snapshot_id': snapshotId, 'nodes': nodes, 'nodeCount': nodes.length, 'truncated': truncated, 'interactionSurface': _classifyInteractionSurface(nodes.length), + if (viewport != null) 'viewport': _rectToMap(viewport), }; } @@ -294,6 +422,16 @@ mixin SemanticSnapshotService { return 'hybrid'; } + static Map _rectToMap(final ui.Rect rect) => + { + 'left': rect.left, + 'top': rect.top, + 'right': rect.right, + 'bottom': rect.bottom, + 'width': rect.width, + 'height': rect.height, + }; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -430,4 +568,17 @@ mixin SemanticSnapshotService { if (action == SemanticsAction.focus) return 'focus'; return action.toString(); } + + static int _stableHash(final List values) { + var hash = 0x811c9dc5; + for (final value in values) { + for (final unit in value.codeUnits) { + hash ^= unit; + hash = (hash * 0x01000193) & 0xffffffff; + } + hash ^= 0x0a; + hash = (hash * 0x01000193) & 0xffffffff; + } + return hash; + } } diff --git a/mcp_toolkit/pubspec.yaml b/mcp_toolkit/pubspec.yaml index 091bbbb4..0aa2b7b9 100644 --- a/mcp_toolkit/pubspec.yaml +++ b/mcp_toolkit/pubspec.yaml @@ -14,9 +14,9 @@ dependencies: sdk: flutter flutter_mcp_toolkit_core: ^4.0.0-dev.5 from_json_to_json: ^0.5.0 - intentcall_core: ^0.1.0 - intentcall_platform: ^0.1.0 - intentcall_schema: ^0.1.0 + intentcall_core: ^0.3.1 + intentcall_platform: ^0.3.1 + intentcall_schema: ^0.3.1 is_dart_empty_or_not: ^0.4.0 universal_io: ^2.3.1 dev_dependencies: diff --git a/mcp_toolkit/test/control_flow_service_test.dart b/mcp_toolkit/test/control_flow_service_test.dart index b104f4ca..77f9cae6 100644 --- a/mcp_toolkit/test/control_flow_service_test.dart +++ b/mcp_toolkit/test/control_flow_service_test.dart @@ -349,6 +349,57 @@ void main() { expect(entered, isTrue); }); + testWidgets( + 'hover can be called repeatedly without mouse tracker assertion', + (final tester) async { + var entered = 0; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: Semantics( + label: 'hover_target', + child: MouseRegion( + onEnter: (_) => entered += 1, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final snapshotFuture = SemanticSnapshotService.buildSemanticSnapshot(); + await tester.pump(); + await tester.pump(); + final snapshot = await snapshotFuture; + final nodes = snapshot['nodes']! as List; + final targetEntry = + nodes.firstWhere( + (final n) => + n is Map && + (n['label'] as String?)?.contains('hover_target') == true, + )! + as Map; + final ref = targetEntry['ref']! as String; + + final firstHover = GestureInteractionService.hoverAtRef(ref); + await tester.pump(const Duration(milliseconds: 20)); + final first = await firstHover; + await tester.pump(); + + final secondHover = GestureInteractionService.hoverAtRef(ref); + await tester.pump(const Duration(milliseconds: 20)); + final second = await secondHover; + await tester.pumpAndSettle(); + + expect(first['success'], isTrue); + expect(second['success'], isTrue); + expect(entered, greaterThanOrEqualTo(1)); + }, + ); + testWidgets('hover returns ref_not_found for an unknown ref', ( final tester, ) async { diff --git a/mcp_toolkit/test/mcp_toolkit_bootstrap_test.dart b/mcp_toolkit/test/mcp_toolkit_bootstrap_test.dart index 88aa400a..ed9a5a6d 100644 --- a/mcp_toolkit/test/mcp_toolkit_bootstrap_test.dart +++ b/mcp_toolkit/test/mcp_toolkit_bootstrap_test.dart @@ -2,8 +2,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:intentcall_core/intentcall_core.dart'; -import 'package:intentcall_schema/intentcall_schema.dart'; import 'package:mcp_toolkit/mcp_toolkit.dart'; void main() { @@ -59,45 +57,45 @@ void main() { test( 'raw VM service parameters with isolateId fail strict schema validation', () { - final binding = _CapturingToolkitBinding()..initialize(); - final tool = mcpToolkitTool( - namespace: 'app', - definition: MCPToolDefinition( - name: 'inspect_number', - description: 'Inspect a number', - inputSchema: ObjectSchema( - properties: {'x': IntegerSchema()}, - required: ['x'], - additionalProperties: false, + final binding = _CapturingToolkitBinding()..initialize(); + final tool = mcpToolkitTool( + namespace: 'app', + definition: MCPToolDefinition( + name: 'inspect_number', + description: 'Inspect a number', + inputSchema: ObjectSchema( + properties: {'x': IntegerSchema()}, + required: ['x'], + additionalProperties: false, + ), ), - ), - handler: (final request) => - MCPCallResult(message: 'inspected', parameters: {'ok': true}), - ); - final registration = tool.toRegistration(); - final rawWireArgs = { - 'isolateId': 'isolates/4805254787721395', - 'x': '120', - }; + handler: (final request) => + MCPCallResult(message: 'inspected', parameters: {'ok': true}), + ); + final registration = tool.toRegistration(); + final rawWireArgs = { + 'isolateId': 'isolates/4805254787721395', + 'x': '120', + }; - expect( - () => registration.validate(rawWireArgs), - throwsA(isA()), - reason: - 'VM transport isolateId must not reach strict MCP tool schemas', - ); + expect( + () => registration.validate(rawWireArgs), + throwsA(isA()), + reason: + 'VM transport isolateId must not reach strict MCP tool schemas', + ); - final strippedArgs = binding - .mcpToolkitArgumentsFromServiceExtensionParameters({ - 'isolateId': 'isolates/4805254787721395', - 'x': '120', - }); - final coercedArgs = coerceArgumentsForSchema( - registration.descriptor.inputSchema, - strippedArgs, - ); + final strippedArgs = binding + .mcpToolkitArgumentsFromServiceExtensionParameters({ + 'isolateId': 'isolates/4805254787721395', + 'x': '120', + }); + final coercedArgs = coerceArgumentsForSchema( + registration.descriptor.inputSchema, + strippedArgs, + ); - expect(() => registration.validate(coercedArgs), returnsNormally); + expect(() => registration.validate(coercedArgs), returnsNormally); }, ); }); diff --git a/mcp_toolkit/test/semantic_snapshot_surface_test.dart b/mcp_toolkit/test/semantic_snapshot_surface_test.dart index bd887ba3..3dcaa81c 100644 --- a/mcp_toolkit/test/semantic_snapshot_surface_test.dart +++ b/mcp_toolkit/test/semantic_snapshot_surface_test.dart @@ -127,6 +127,53 @@ void main() { } }); + testWidgets('visible subtree signature does not mutate snapshot refs', ( + final tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + height: 200, + child: ListView.builder( + itemCount: 12, + itemBuilder: (final context, final index) => + SizedBox(height: 48, child: Text('Row $index')), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final snapshot = await _snapshotAfterPump(tester); + final snapshotId = SemanticSnapshotService.currentSnapshotId; + final nodes = (snapshot['nodes']! as List) + .cast>(); + final scrollable = nodes.singleWhere( + (final node) => node['type'] == 'scrollable', + ); + final row = nodes.firstWhere((final node) => node['label'] == 'Row 0'); + final scrollableRef = scrollable['ref']! as String; + final rowRef = row['ref']! as String; + final scrollableNode = SemanticSnapshotService.resolveRef(scrollableRef)!; + final rowNode = SemanticSnapshotService.resolveRef(rowRef); + + final signature = SemanticSnapshotService.visibleSubtreeSignature( + scrollableNode, + ); + + expect(signature['available'], isTrue); + expect(signature['signatureHash'], isA()); + expect(SemanticSnapshotService.currentSnapshotId, snapshotId); + expect(SemanticSnapshotService.resolveRef(rowRef), same(rowNode)); + } finally { + semantics.dispose(); + } + }); + testWidgets('reveal_search finds an off-screen semantics identifier', ( final tester, ) async { @@ -167,7 +214,6 @@ void main() { final revealFuture = RevealSearchService.revealSearch( query: 'greeting_input_field', matchBy: 'identifier', - direction: 'down', maxAttempts: 6, distance: 160, ); @@ -189,6 +235,41 @@ void main() { } }); + test('reveal_search only continues after verified or deferred scroll', () { + expect( + RevealSearchService.shouldContinueAfterScrollForTesting({ + 'success': false, + 'deferredMovementCheck': true, + }), + isTrue, + ); + expect( + RevealSearchService.shouldContinueAfterScrollForTesting({ + 'success': true, + 'movementVerified': true, + }), + isTrue, + ); + expect( + RevealSearchService.shouldContinueAfterScrollForTesting({ + 'success': false, + 'via': 'semantic_action', + 'platform': 'web', + 'error': 'no_scroll_movement', + 'dispatched': true, + }), + isFalse, + ); + expect( + RevealSearchService.shouldContinueAfterScrollForTesting({ + 'success': false, + 'platform': 'web', + 'error': 'unsupported_scroll_action', + }), + isFalse, + ); + }); + testWidgets( 'semantic_snapshot reports hybrid when no interactive semantics refs', (final tester) async { diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 99f0878e..7a3b9f49 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -18,7 +18,7 @@ environment: sdk: '>=3.12.0 <4.0.0' resolution: workspace dependencies: - intentcall_schema: ^0.1.0 + intentcall_schema: ^0.3.1 meta: ^1.18.0 dev_dependencies: lints: ^6.1.0 diff --git a/packages/server_capability_core/pubspec.yaml b/packages/server_capability_core/pubspec.yaml index 9caa2562..1ee8019d 100644 --- a/packages/server_capability_core/pubspec.yaml +++ b/packages/server_capability_core/pubspec.yaml @@ -25,10 +25,10 @@ dependencies: flutter_mcp_toolkit_capability_kernel: ^4.0.0-dev.5 flutter_mcp_toolkit_core: ^4.0.0-dev.5 from_json_to_json: ^0.5.0 - intentcall_codegen: ^0.1.0 - intentcall_core: ^0.1.0 - intentcall_mcp: ^0.1.0 - intentcall_schema: ^0.1.0 + intentcall_codegen: ^0.3.1 + intentcall_core: ^0.3.1 + intentcall_mcp: ^0.3.1 + intentcall_schema: ^0.3.1 is_dart_empty_or_not: ^0.4.0 meta: ^1.18.0 vm_service: ^15.0.2 diff --git a/packages/server_capability_kernel/lib/src/host_service.dart b/packages/server_capability_kernel/lib/src/host_service.dart index 6fd7e8d5..ac821c8c 100644 --- a/packages/server_capability_kernel/lib/src/host_service.dart +++ b/packages/server_capability_kernel/lib/src/host_service.dart @@ -8,7 +8,7 @@ import 'package:meta/meta.dart'; /// the kernel only defines the interfaces. abstract interface class HostService {} -/// Bridge to the dynamic-registry that surfaces app-side +/// Reserved bridge to the dynamic registry that surfaces app-side /// `MCPToolkitBinding.addEntries` registrations as MCP tools. /// /// A capability that wants to expose its app-side tools under its own @@ -21,8 +21,11 @@ abstract interface class DynamicRegistryBridge implements HostService { void claim({required final String namespace}); } -/// Read-only access to the running Flutter app's VM service. Capabilities -/// that need to invoke service extensions go through this. +/// Reserved read-only access to the running Flutter app's VM service. +/// +/// Current built-in capabilities use [CommandRunner] for most operations. Keep +/// this contract available for future host services without moving VM-specific +/// code into the kernel. abstract interface class VmServiceClient implements HostService { /// Invoke a service extension on the running app, returning the raw /// response map. @@ -32,8 +35,8 @@ abstract interface class VmServiceClient implements HostService { }); } -/// Hot-reload coordinator. Capabilities that orchestrate code generation -/// + reload (live-edit) request reloads through this. +/// Reserved hot-reload coordinator for capabilities that orchestrate code +/// generation plus reload requests. abstract interface class HotReloadCoordinator implements HostService { Future reload({final bool pause = false}); } diff --git a/packages/server_capability_kernel/lib/src/resource_registration.dart b/packages/server_capability_kernel/lib/src/resource_registration.dart index cfab871d..5e8593f1 100644 --- a/packages/server_capability_kernel/lib/src/resource_registration.dart +++ b/packages/server_capability_kernel/lib/src/resource_registration.dart @@ -1,2 +1,2 @@ -export 'package:intentcall_mcp/intentcall_mcp.dart' +export 'package:intentcall_core/intentcall_core.dart' show ResourceHandler, ResourceRegistration; diff --git a/packages/server_capability_kernel/lib/src/resource_template_registration.dart b/packages/server_capability_kernel/lib/src/resource_template_registration.dart index 2c223a3a..efd9fa11 100644 --- a/packages/server_capability_kernel/lib/src/resource_template_registration.dart +++ b/packages/server_capability_kernel/lib/src/resource_template_registration.dart @@ -1,2 +1,2 @@ -export 'package:intentcall_mcp/intentcall_mcp.dart' +export 'package:intentcall_core/intentcall_core.dart' show ResourceHandler, ResourceTemplateRegistration; diff --git a/packages/server_capability_kernel/lib/src/tool_registration.dart b/packages/server_capability_kernel/lib/src/tool_registration.dart index 6c9eafd3..434a0686 100644 --- a/packages/server_capability_kernel/lib/src/tool_registration.dart +++ b/packages/server_capability_kernel/lib/src/tool_registration.dart @@ -1,3 +1,3 @@ -// Canonical [ToolRegistration] lives in intentcall_mcp (extract-friendly). -export 'package:intentcall_mcp/intentcall_mcp.dart' +// Canonical registration contracts live in intentcall_core. +export 'package:intentcall_core/intentcall_core.dart' show ToolHandler, ToolRegistration; diff --git a/packages/server_capability_kernel/pubspec.yaml b/packages/server_capability_kernel/pubspec.yaml index 307f1fe2..35116308 100644 --- a/packages/server_capability_kernel/pubspec.yaml +++ b/packages/server_capability_kernel/pubspec.yaml @@ -15,8 +15,8 @@ environment: resolution: workspace dependencies: flutter_mcp_toolkit_core: ^4.0.0-dev.5 - intentcall_mcp: ^0.1.0 - intentcall_schema: ^0.1.0 + intentcall_core: ^0.3.1 + intentcall_schema: ^0.3.1 meta: ^1.18.0 dev_dependencies: lints: ^6.1.0 diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index 789a2fff..6093e641 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -26,7 +26,7 @@ "interface": { "displayName": "Flutter MCP Toolkit", "shortDescription": "Inspect, drive, and extend Flutter debug apps via MCP — including runtime custom tools.", - "longDescription": "flutter-mcp-toolkit is a Dart MCP server plus Flutter package (mcp_toolkit) for AI-assisted Flutter development in debug mode. Built-in: 27 fmt_* MCP tools and bundled agent skills. Dynamic registry: register app-specific tools and resources at runtime with AgentCallEntry and addMcpTool; agents discover via fmt_list_client_tools_and_resources and invoke via fmt_client_tool / fmt_client_resource. Requires debug app with mcp_toolkit and flutter-mcp-toolkit-server on PATH. Complements official Dart MCP.", + "longDescription": "flutter-mcp-toolkit is a Dart MCP server plus Flutter package (mcp_toolkit) for AI-assisted Flutter development in debug mode. Built-in: 30 fmt_* MCP tools and bundled agent skills. Dynamic registry: register app-specific tools and resources at runtime with AgentCallEntry and addMcpTool; agents discover via fmt_list_client_tools_and_resources and invoke via fmt_client_tool / fmt_client_resource. Requires debug app with mcp_toolkit and flutter-mcp-toolkit-server on PATH. Complements official Dart MCP.", "developerName": "Arenukvern", "category": "Developer Tools", "capabilities": [ diff --git a/plugin/README.md b/plugin/README.md index a9585254..833c94b2 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -9,7 +9,7 @@ Three layers (not “MCP server only”): ```text AI agent │ - ├─► flutter-mcp-toolkit-server (27 fmt_* tools: inspect, control, debug) + ├─► flutter-mcp-toolkit-server (30 fmt_* tools: inspect, control, debug) │ └─► fmt_list_client_tools_and_resources / fmt_client_tool / fmt_client_resource │ diff --git a/plugin/skills/flutter-mcp-boundary-audit/reference.md b/plugin/skills/flutter-mcp-boundary-audit/reference.md index 506e6156..1b378c2d 100644 --- a/plugin/skills/flutter-mcp-boundary-audit/reference.md +++ b/plugin/skills/flutter-mcp-boundary-audit/reference.md @@ -12,15 +12,15 @@ Use this section when auditing **this** repository. Map the generic roles from S | Concern | Primary files | |---------|----------------| -| Wire coercion (pre–Tier A, app paths) | `intentcall/packages/intentcall_schema/lib/src/schema_coercion.dart` | -| Entry model | `intentcall/packages/intentcall_core/lib/src/authoring/agent_call_entry.dart` | +| Wire coercion (pre–Tier A, app paths) | `/Users/anton/mcp/agentkit/packages/intentcall_schema/lib/src/schema_coercion.dart` | +| Entry model | `/Users/anton/mcp/agentkit/packages/intentcall_core/lib/src/authoring/agent_call_entry.dart` | | Toolkit bridge / VM registration | `mcp_toolkit/lib/src/mcp_toolkit_extensions.dart`, `agent_entry_helpers.dart` | | App interaction tools | `mcp_toolkit/lib/src/toolkits/interaction_toolkit.dart` | | Shared interaction schemas | `packages/core/lib/src/tools/interaction_input_schemas.dart` | | Server fmt tools | `packages/server_capability_core/lib/src/tools/interaction_tools.dart`, `semantic_tools.dart`, `wait_tools.dart` | | Dynamic registry | `mcp_server_dart/lib/src/capabilities/dynamic_registry/` — grep `forwardToolCall` | | VM gateway | `mcp_server_dart` — `VmExtensionDynamicGateway` / `dynamic_gateway.dart` | -| Migrator | `intentcall/packages/intentcall_core/lib/src/migrate_agent_entries.dart` | +| Migrator | `/Users/anton/mcp/agentkit/packages/intentcall_core/lib/src/migrate_agent_entries.dart` | | WebMCP | `intentcall` web bootstrap, `flutter_test_app/web/intentcall_webmcp.generated.js` | | Platform contract doc | `flutter_test_app/INTENTCALL_PLATFORM.md` | | Registration doc | `mcp_server_dart/docs/SIMPLIFIED_DYNAMIC_REGISTRATION.md` | diff --git a/plugin/skills/flutter-mcp-toolkit-guide/SKILL.md b/plugin/skills/flutter-mcp-toolkit-guide/SKILL.md index 93c4e179..900559c1 100644 --- a/plugin/skills/flutter-mcp-toolkit-guide/SKILL.md +++ b/plugin/skills/flutter-mcp-toolkit-guide/SKILL.md @@ -67,9 +67,11 @@ parameter shapes lives in the task skills. - **Debug:** `get_recent_logs`, `evaluate_dart_expression`. → `flutter-mcp-toolkit-debug`. - **Dynamic registry (app-defined):** after registration in the Flutter app, - list with `list_client_tools_and_resources`, then `client_tool` / - `client_resource` — wire names as **`fmt_*`** when calling MCP. → - `flutter-mcp-toolkit-custom-tools`. + MCP calls use `fmt_list_client_tools_and_resources`, then + `fmt_client_tool` / `fmt_client_resource`. When shelling out to the CLI, + command names appear only as `exec --name ` values; do not call bare + `list_client_tools_and_resources`, `client_tool`, or `client_resource` as + MCP tools. → `flutter-mcp-toolkit-custom-tools`. ## When in doubt diff --git a/plugin/skills/flutter-mcp-toolkit-maintain-macos/SKILL.md b/plugin/skills/flutter-mcp-toolkit-maintain-macos/SKILL.md index 0e7d4577..eed8e459 100644 --- a/plugin/skills/flutter-mcp-toolkit-maintain-macos/SKILL.md +++ b/plugin/skills/flutter-mcp-toolkit-maintain-macos/SKILL.md @@ -11,7 +11,7 @@ Dogfood app: `flutter_test_app`. Platform doc: `flutter_test_app/INTENTCALL_PLAT ## WebMCP on macOS -**`navigator.modelContext` is web-only.** macOS dogfood proves **VM extensions**, **dynamic registry**, **native invoke** (`intentcall://` via `app_links`), and **visual capture** (Screen Recording on host). +**WebMCP `modelContext` is web-only.** macOS dogfood proves **VM extensions**, **dynamic registry**, **native invoke** (`intentcall://` via `app_links`), and **visual capture** (Screen Recording on host). For WebMCP parity scoring, run web iteration separately (`flutter-mcp-toolkit-maintain-web`). diff --git a/plugin/skills/flutter-mcp-toolkit-maintain-web/SKILL.md b/plugin/skills/flutter-mcp-toolkit-maintain-web/SKILL.md index 1c381b98..4fb0d392 100644 --- a/plugin/skills/flutter-mcp-toolkit-maintain-web/SKILL.md +++ b/plugin/skills/flutter-mcp-toolkit-maintain-web/SKILL.md @@ -1,6 +1,6 @@ --- name: flutter-mcp-toolkit-maintain-web -description: Maintains flutter_test_app and intentcall web targets (Chrome, web codegen, WebMCP bootstrap, web-showcase, webmcp verify). Use when editing web/index.html, agent_manifest.json, intentcall_webmcp.generated.js, web platform sync, Chrome dogfood, or navigator.modelContext. +description: Maintains flutter_test_app and intentcall web targets (Chrome, web codegen, WebMCP bootstrap, web-showcase, webmcp verify). Use when editing web/index.html, agent_manifest.json, intentcall_webmcp.generated.js, web platform sync, Chrome dogfood, or WebMCP modelContext. --- @@ -14,7 +14,7 @@ Dogfood app: `flutter_test_app`. Canonical platform doc: `flutter_test_app/INTEN | Path | Proves | |------|--------| | VM extensions + `fmt_*` tools | MCP toolkit dogfood (always) | -| `navigator.modelContext` | True WebMCP (Chrome flag / `--web-browser-flag`) | +| `document.modelContext` | True WebMCP (Chrome flag / `--web-browser-flag`) | ADR: `decisions/0008_web_agent_invoke_js_only.mdx` — JS `fetch('/agent/invoke')` **404** by design; Dart `invokeDirect` works when `modelContext` exists. diff --git a/plugin/skills/flutter-mcp-toolkit-setup/SKILL.md b/plugin/skills/flutter-mcp-toolkit-setup/SKILL.md index d08de04e..97b8478f 100644 --- a/plugin/skills/flutter-mcp-toolkit-setup/SKILL.md +++ b/plugin/skills/flutter-mcp-toolkit-setup/SKILL.md @@ -9,7 +9,7 @@ description: Verify the flutter-mcp-toolkit install, run doctor preflight, troub Use this skill when: -- First-time install: `flutter-mcp-toolkit` is not yet on PATH. +- First-time install: `flutter-mcp-toolkit` or its short alias `fmtk` is not yet on PATH. - `doctor --json` returns any check with `"status": "fail"`. - MCP server fails to connect or tools return `vm_not_connected` / `connect_failed`. - Visual capture or toolkit-bridge commands are returning unexpected errors. @@ -19,10 +19,11 @@ Use this skill when: ## Verify install ```bash -flutter-mcp-toolkit --version +flutter-mcp-toolkit --help +fmtk --help ``` -Expected output: version string (e.g. `flutter-mcp-toolkit 3.0.0`). +Expected output: command help from both names. `flutter-mcp-toolkit` is the canonical long name; `fmtk` is the compact alias for day-to-day terminal loops. If you get `command not found`, the binary is not on PATH: @@ -33,7 +34,7 @@ export PATH="$PATH:/path/to/mcp_flutter/mcp_server_dart/build" cd /path/to/mcp_flutter && make build ``` -Then verify with `flutter-mcp-toolkit --version`. +Then verify with `flutter-mcp-toolkit --help` and `fmtk --help`. --- @@ -42,14 +43,14 @@ Then verify with `flutter-mcp-toolkit --version`. Always run doctor before any VM-dependent command: ```bash -flutter-mcp-toolkit doctor --json +fmtk doctor --json ``` Flags: `--target ` (test a specific URI), global `--vm-service-uri ` (same as `--target` when omitted on `doctor`), `--timeout-ms ` (default: 2500). ```bash # Global URI works for doctor (same as validate-runtime) -flutter-mcp-toolkit --vm-service-uri 'ws://127.0.0.1:8181//ws' doctor --json +fmtk --vm-service-uri 'ws://127.0.0.1:8181//ws' doctor --json ``` Sample green output: @@ -87,9 +88,9 @@ export PATH="$PATH:/path/to/mcp_flutter/mcp_server_dart/build" Flutter app not running, stale token after restart, or URI not resolved: ```bash -flutter-mcp-toolkit exec --name discover_debug_apps --args '{}' -flutter-mcp-toolkit exec --name status --args '{}' -flutter-mcp-toolkit doctor --json --target ws://127.0.0.1:8181//ws +fmtk exec --name discover_debug_apps --args '{}' +fmtk exec --name status --args '{}' +fmtk doctor --json --target ws://127.0.0.1:8181//ws ``` After a successful auto re-attach, `meta.recovery.reattachedTo` shows the new endpoint. @@ -99,7 +100,7 @@ After a successful auto re-attach, `meta.recovery.reattachedTo` shows the new en Wrong port, app not started, or stale token. Pass explicit URI from `app.debugPort.wsUri`: ```bash -flutter-mcp-toolkit exec --name get_vm --args '{"connection":{"uri":"ws://127.0.0.1:8181//ws"}}' +fmtk exec --name get_vm --args '{"connection":{"uri":"ws://127.0.0.1:8181//ws"}}' ``` ### `connection_selection_required` @@ -111,7 +112,7 @@ Multiple debug targets detected. List with `discover_debug_apps`, then pass the Dart compilation error or VM disconnected. Check errors, fix, then retry: ```bash -flutter-mcp-toolkit exec --name get_app_errors --args '{}' +fmtk exec --name get_app_errors --args '{}' ``` ### `visual_capture_unsupported` @@ -119,7 +120,7 @@ flutter-mcp-toolkit exec --name get_app_errors --args '{}' macOS screen recording permission not granted or unsupported platform: ```bash -flutter-mcp-toolkit permissions request --kind visual_capture +fmtk permissions request --kind visual_capture ``` --- @@ -130,7 +131,7 @@ flutter-mcp-toolkit permissions request --kind visual_capture ```bash flutter run --debug --host-vmservice-port=8182 -d macos -flutter-mcp-toolkit --dart-vm-port 8182 doctor --json +fmtk --dart-vm-port 8182 doctor --json ``` Use `flutter run --machine` and copy `app.debugPort.wsUri` when you need the exact websocket URI (recommended for `validate-runtime` and `exec`). @@ -142,30 +143,30 @@ Use `flutter run --machine` and copy `app.debugPort.wsUri` when you need the exa **Multiple apps / wrong target**: Pass `--target` with the exact websocket URI: ```bash -flutter-mcp-toolkit doctor --json --target ws://127.0.0.1:8181//ws +fmtk doctor --json --target ws://127.0.0.1:8181//ws ``` --- ## CLI surface -The binary is `flutter-mcp-toolkit` (built to `mcp_server_dart/build/`). +The canonical binary is `flutter-mcp-toolkit` (built to `mcp_server_dart/build/`). Packaged installs also include `fmtk`, a short alias to the same entrypoint. Use `fmtk` in quick loops; keep the long name in install, onboarding, PATH, and MCP configuration docs. | Subcommand | Purpose | Minimal example | | --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `exec` | Run a single named command against the VM | `flutter-mcp-toolkit exec --name get_vm --args '{}'` | -| `batch` | Run multiple commands in one call | `flutter-mcp-toolkit batch --steps '[{"name":"get_vm"},{"name":"status"}]'` | -| `schema` | Print the JSON schema for a named command | `flutter-mcp-toolkit schema --name hot_reload_flutter` | -| `capabilities` | List all registered capabilities | `flutter-mcp-toolkit capabilities` | -| `serve` | Start the MCP server (stdio transport) | `flutter-mcp-toolkit serve` | -| `snapshot create` | Capture and save a named snapshot | `flutter-mcp-toolkit snapshot create --name baseline --args '{}'` | -| `snapshot diff` | Diff two snapshots | `flutter-mcp-toolkit snapshot diff --from baseline --to current` | -| `bundle create` | Package a snapshot into a publishable bundle | `flutter-mcp-toolkit bundle create --from-snapshot baseline --output ./out` | -| `doctor` | Run preflight checks (VM + toolkit + registry) | `flutter-mcp-toolkit doctor --json` | -| `permissions status` | Check a permission (e.g. visual_capture) | `flutter-mcp-toolkit permissions status --kind visual_capture` | -| `permissions request` | Request a permission | `flutter-mcp-toolkit permissions request --kind visual_capture` | -| `permissions open-settings` | Open OS settings for a permission | `flutter-mcp-toolkit permissions open-settings --kind visual_capture` | -| `validate-runtime` | End-to-end VM + toolkit + capture smoke test | `flutter-mcp-toolkit validate-runtime --target ws://127.0.0.1:8181//ws` | +| `exec` | Run a single named command against the VM | `fmtk exec --name get_vm --args '{}'` | +| `batch` | Run multiple commands in one call | `fmtk batch --steps '[{"name":"get_vm"},{"name":"status"}]'` | +| `schema` | Print the JSON schema for a named command | `fmtk schema --name hot_reload_flutter` | +| `capabilities` | List all registered capabilities | `fmtk capabilities` | +| `serve` | Start the MCP server (stdio transport) | `fmtk serve` | +| `snapshot create` | Capture and save a named snapshot | `fmtk snapshot create --name baseline --args '{}'` | +| `snapshot diff` | Diff two snapshots | `fmtk snapshot diff --from baseline --to current` | +| `bundle create` | Package a snapshot into a publishable bundle | `fmtk bundle create --from-snapshot baseline --output ./out` | +| `doctor` | Run preflight checks (VM + toolkit + registry) | `fmtk doctor --json` | +| `permissions status` | Check a permission (e.g. visual_capture) | `fmtk permissions status --kind visual_capture` | +| `permissions request` | Request a permission | `fmtk permissions request --kind visual_capture` | +| `permissions open-settings` | Open OS settings for a permission | `fmtk permissions open-settings --kind visual_capture` | +| `validate-runtime` | End-to-end VM + toolkit + capture smoke test | `fmtk validate-runtime --target ws://127.0.0.1:8181//ws` | | `init ` | Install skills + MCP server config for an AI agent | `flutter-mcp-toolkit init claude-code` | | `codegen-init` | Add toolkit dependency and emit `main.dart` boilerplate | `flutter-mcp-toolkit codegen-init` | @@ -175,6 +176,8 @@ Global flags (before the subcommand): `--dart-vm-port `, `--dart-vm-host ` @@ -216,4 +219,4 @@ The install script is idempotent — re-running it replaces the binary in place: curl -fsSL https://raw.githubusercontent.com/Arenukvern/mcp_flutter/main/install.sh | bash ``` -After reinstall, verify with `flutter-mcp-toolkit --version`. +After reinstall, verify with `flutter-mcp-toolkit --help` and `fmtk --help`. diff --git a/pubspec.lock b/pubspec.lock index bf0c767b..70834c65 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -340,42 +340,50 @@ packages: dependency: transitive description: name: intentcall_codegen - sha256: "38d798b0777bdde5c5ae13f27c4469526c83772d1abf4e574c2047d06b9ee07d" + sha256: "947605e2103cf48115af6ef7d6705b50731902c831592d6c17ba69ba3c3158e2" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.3.1" intentcall_core: dependency: transitive description: name: intentcall_core - sha256: "96190700c2e3863d15e5d8e947bdaa3ba636d5fd709807dd05bd2ca195df578e" + sha256: "00f5914c8757fb67a25719258b1ea7c8de343ea0d542e130d3736e9c00aae084" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.3.1" intentcall_mcp: dependency: transitive description: name: intentcall_mcp - sha256: bb5228eb857c2108054df5e9a4a5930ebabc22977dd1c7e8eb27116d51cfd3f9 + sha256: "80303c8c5663d56668c565cad187df64ef86d33ac9a7632998aacdc641c12266" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.3.1" intentcall_platform: dependency: transitive description: name: intentcall_platform - sha256: "370791f870b7733603995b37640fb0ea01c43341055de92f94490143405420c6" + sha256: dbe1c09472fe1a0bd5fb9faa2b3e08f1494543b6a0d6a5bbb07c2a69260a1f03 url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.3.1" intentcall_schema: dependency: transitive description: name: intentcall_schema - sha256: "944f6827863115c94d5244e5649807124cd0d36d2a871c95b815da93c908825a" + sha256: "3357a50752d14d147ff604a445579a114bb9c9c3eea24976eff285639a6d5bd0" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.3.1" + intentcall_session: + dependency: transitive + description: + name: intentcall_session + sha256: "7c65b40f870072ce0dffb47dcdd4e8f081b27ad4ea96ca4163990d1ac43f398f" + url: "https://pub.dev" + source: hosted + version: "0.3.1" io: dependency: transitive description: diff --git a/scripts/run_exec_sweep.sh b/scripts/run_exec_sweep.sh index 6bc28fda..180c5661 100755 --- a/scripts/run_exec_sweep.sh +++ b/scripts/run_exec_sweep.sh @@ -15,9 +15,22 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${here}/.." && pwd)" platform="${PLATFORM:-macos}" ws_uri="${WS_URI:?Set WS_URI from make showcase or make web-showcase}" -outdir="${repo_root}/.showcase/tool_verify/exec_sweep/${platform}" +legacy_root="${repo_root}/.showcase/tool_verify/exec_sweep" +outdir="${legacy_root}/${platform}" mkdir -p "${outdir}" +cleanup_legacy_root_artifacts() { + local artifact + for artifact in "${legacy_root}"/sweep_summary.txt \ + "${legacy_root}"/*.json \ + "${legacy_root}"/*.stderr; do + [[ -e "${artifact}" ]] || continue + rm -f "${artifact}" + done +} + +cleanup_legacy_root_artifacts + toolkit=( dart run "${repo_root}/mcp_server_dart/bin/flutter_mcp_toolkit.dart" --vm-service-uri "${ws_uri}" @@ -40,12 +53,18 @@ pass=0 fail=0 skip=0 results=() +reveal_down_direction=down json_ok() { python3 - "$1" <<'PY' import json, sys d = json.load(open(sys.argv[1])) -raise SystemExit(0 if d.get("ok") else 1) +data = d.get("data") +if not d.get("ok"): + raise SystemExit(1) +if isinstance(data, dict) and (data.get("success") is False or data.get("ok") is False): + raise SystemExit(1) +raise SystemExit(0) PY } @@ -94,6 +113,37 @@ PY return 1 } +run_tool_allow_web_no_movement_skip() { + local name="$1" + local args="${2-{\}}" + local outfile="${outdir}/${name}.json" + printf '=== %s ===\n' "${name}" + if "${toolkit[@]}" exec --name "${name}" --args "${args}" >"${outfile}" 2>"${outdir}/${name}.stderr"; then + if json_ok "${outfile}"; then + printf 'PASS: %s\n' "${name}" + pass=$((pass + 1)) + results+=("PASS ${name}") + return 0 + fi + fi + if [[ "${platform}" == "web" ]] && [[ "$(json_field "${outfile}" data.error)" == "no_scroll_movement" ]]; then + skip_tool "${name}" 'Flutter Web semantic gesture dispatched but no movement was verified' + return 0 + fi + printf 'FAIL: %s\n' "${name}" + python3 - "${outfile}" <<'PY' 2>/dev/null || true +import json, sys +try: + d = json.load(open(sys.argv[1])) + print(d.get("error") or d) +except Exception as e: + print(e) +PY + fail=$((fail + 1)) + results+=("FAIL ${name}") + return 1 +} + skip_tool() { local name="$1" local reason="$2" @@ -102,6 +152,14 @@ skip_tool() { results+=("SKIP ${name} (${reason})") } +missing_required_tool() { + local name="$1" + local reason="$2" + printf 'FAIL: %s (%s)\n' "${name}" "${reason}" + fail=$((fail + 1)) + results+=("FAIL ${name} (${reason})") +} + ref_for_identifier() { local snap_file="$1" local identifier="$2" @@ -117,6 +175,112 @@ raise SystemExit(1) PY } +capture_snapshot() { + local label="$1" + local outfile="${outdir}/${label}.json" + if "${toolkit[@]}" exec --name semantic_snapshot --args '{}' >"${outfile}" 2>"${outdir}/${label}.stderr" && json_ok "${outfile}"; then + printf '%s\n' "${outfile}" + return 0 + fi + return 1 +} + +write_ref_args_for_identifier() { + local snap_file="$1" + local identifier="$2" + local outfile="$3" + local require_visible="${4:-true}" + python3 - "${snap_file}" "${identifier}" "${outfile}" "${require_visible}" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +want = sys.argv[2] +out = sys.argv[3] +require_visible = sys.argv[4] == "true" +sid = d.get("data", {}).get("snapshot_id") +for node in d.get("data", {}).get("nodes", []): + if node.get("identifier") != want or not node.get("ref"): + continue + if require_visible and node.get("centerInViewport") is not True: + raise SystemExit( + f"{want} found but center is outside viewport: {node.get('bounds')}" + ) + json.dump({"ref": node["ref"], "snapshotId": sid}, open(out, "w")) + raise SystemExit(0) +raise SystemExit(f"{want} not found") +PY +} + +write_ref_args_for_type() { + local snap_file="$1" + local type_name="$2" + local outfile="$3" + python3 - "${snap_file}" "${type_name}" "${outfile}" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +want = sys.argv[2] +out = sys.argv[3] +sid = d.get("data", {}).get("snapshot_id") +for node in d.get("data", {}).get("nodes", []): + if node.get("type") == want and node.get("ref"): + json.dump({"ref": node["ref"], "snapshotId": sid}, open(out, "w")) + raise SystemExit(0) +raise SystemExit(f"{want} not found") +PY +} + +ensure_visible_identifier_args() { + local identifier="$1" + local outfile="$2" + local direction="${3:-down}" + local snap_file + for attempt in 0 1 2 3 4 5 6; do + snap_file="$(capture_snapshot "semantic_${identifier}_${attempt}")" || return 1 + if write_ref_args_for_identifier "${snap_file}" "${identifier}" "${outfile}" true 2>"${outdir}/${identifier}_${attempt}.visibility"; then + return 0 + fi + "${toolkit[@]}" exec --name scroll --args "{\"direction\":\"${direction}\",\"distance\":420}" >"${outdir}/scroll_to_${identifier}_${attempt}.json" 2>"${outdir}/scroll_to_${identifier}_${attempt}.stderr" || true + done + return 1 +} + +reveal_identifier_args() { + local identifier="$1" + local outfile="$2" + local direction="${3:-down}" + local max_attempts="${4:-6}" + local distance="${5:-320}" + local reveal_file="${outdir}/reveal_${identifier}.json" + if ! "${toolkit[@]}" exec --name reveal_search --args "{\"query\":\"${identifier}\",\"matchBy\":\"identifier\",\"direction\":\"${direction}\",\"maxAttempts\":${max_attempts},\"distance\":${distance}}" >"${reveal_file}" 2>"${outdir}/reveal_${identifier}.stderr"; then + return 1 + fi + python3 - "${reveal_file}" "${outfile}" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +data = d.get("data", {}) +ref = data.get("ref") +sid = data.get("snapshotId") +if not d.get("ok") or data.get("success") is not True or not ref or sid is None: + raise SystemExit(1) +json.dump({"ref": ref, "snapshotId": sid}, open(sys.argv[2], "w")) +PY +} + +args_ref() { + python3 - "$1" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +print(d["ref"]) +PY +} + +args_snapshot_id() { + python3 - "$1" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +print(d["snapshotId"]) +PY +} + printf '[exec-sweep] platform=%s ws=%s out=%s\n' "${platform}" "${ws_uri}" "${outdir}" # Discovery / VM @@ -140,59 +304,119 @@ fi # Semantic + interaction chain run_tool semantic_snapshot '{}' || true -snap="$(json_field "${outdir}/semantic_snapshot.json" data.snapshot_id)" -increment_ref="$(ref_for_identifier "${outdir}/semantic_snapshot.json" stateful_counter_increment_button || echo s_9)" -scrollable_ref="$(ref_for_identifier "${outdir}/semantic_snapshot.json" showcase_scrollable || true)" -if [[ -z "${scrollable_ref}" ]]; then - scrollable_ref="$(python3 - "${outdir}/semantic_snapshot.json" <<'PY' -import json, sys -d = json.load(open(sys.argv[1])) -for node in d.get("data", {}).get("nodes", []): - if node.get("type") == "scrollable" and node.get("ref"): - print(node["ref"]) - raise SystemExit(0) -print("s_21") -PY -)" -fi -run_tool reveal_search '{"query":"greeting_input_field","matchBy":"identifier","direction":"down","maxAttempts":4,"distance":220}' || true +run_tool reveal_search "{\"query\":\"greeting_input_field\",\"matchBy\":\"identifier\",\"direction\":\"${reveal_down_direction}\",\"maxAttempts\":4,\"distance\":220}" || true reveal_ref="$(json_field "${outdir}/reveal_search.json" data.ref)" reveal_snap="$(json_field "${outdir}/reveal_search.json" data.snapshotId)" +reveal_visible="$(json_field "${outdir}/reveal_search.json" data.centerInViewport)" if [[ -z "${reveal_ref}" ]]; then - reveal_ref="$(ref_for_identifier "${outdir}/semantic_snapshot.json" greeting_input_field || true)" - reveal_snap="${snap}" + if ensure_visible_identifier_args greeting_input_field "${outdir}/greeting_args.json" "${reveal_down_direction}"; then + reveal_ref="$(args_ref "${outdir}/greeting_args.json")" + reveal_snap="$(args_snapshot_id "${outdir}/greeting_args.json")" + fi +elif [[ "${reveal_visible}" != "True" && "${reveal_visible}" != "true" ]]; then + if ensure_visible_identifier_args greeting_input_field "${outdir}/greeting_args.json" "${reveal_down_direction}"; then + reveal_ref="$(args_ref "${outdir}/greeting_args.json")" + reveal_snap="$(args_snapshot_id "${outdir}/greeting_args.json")" + fi fi run_tool evaluate_dart_expression '{"expression":"AgentState.instance.greeting"}' || true if [[ -n "${reveal_ref}" && -n "${reveal_snap}" ]]; then run_tool enter_text "{\"ref\":\"${reveal_ref}\",\"snapshotId\":${reveal_snap},\"text\":\"exec sweep\"}" || true +else + missing_required_tool enter_text 'greeting_input_field ref not visible/discovered' +fi + +if ensure_visible_identifier_args stateful_counter_increment_button "${outdir}/increment_tap_args.json" up; then + run_tool tap_widget "$(cat "${outdir}/increment_tap_args.json")" || true +else + missing_required_tool tap_widget 'stateful_counter_increment_button ref not visible/discovered' +fi +if ensure_visible_identifier_args stateful_counter_increment_button "${outdir}/increment_long_press_args.json" up; then + if [[ "${platform}" == "web" ]]; then + skip_tool long_press 'Flutter Web requires SemanticsAction.longPress on the target' + else + run_tool long_press "$(cat "${outdir}/increment_long_press_args.json")" || true + fi +else + missing_required_tool long_press 'stateful_counter_increment_button ref not visible/discovered' +fi +if ensure_visible_identifier_args stateful_counter_increment_button "${outdir}/increment_drag_args.json" up; then + if [[ "${platform}" == "web" ]]; then + skip_tool drag 'Flutter Web does not support semantic drag synthesis' + else + increment_ref="$(args_ref "${outdir}/increment_drag_args.json")" + increment_snap="$(args_snapshot_id "${outdir}/increment_drag_args.json")" + run_tool drag "{\"fromRef\":\"${increment_ref}\",\"toRef\":\"${increment_ref}\",\"snapshotId\":${increment_snap}}" || true + fi +else + missing_required_tool drag 'stateful_counter_increment_button ref not visible/discovered' +fi +if ensure_visible_identifier_args stateful_counter_increment_button "${outdir}/increment_hover_args.json" up; then + run_tool hover "$(cat "${outdir}/increment_hover_args.json")" || true +else + missing_required_tool hover 'stateful_counter_increment_button ref not visible/discovered' +fi + +if reveal_identifier_args scroll_demo_list "${outdir}/scrollable_args.json" "${reveal_down_direction}" 5 260; then + scroll_ref="$(args_ref "${outdir}/scrollable_args.json")" + scroll_snap_id="$(args_snapshot_id "${outdir}/scrollable_args.json")" + run_tool_allow_web_no_movement_skip scroll "{\"ref\":\"${scroll_ref}\",\"direction\":\"down\",\"distance\":120,\"snapshotId\":${scroll_snap_id}}" || true +else + scroll_snap="$(capture_snapshot semantic_before_scroll || true)" + if [[ -n "${scroll_snap}" ]] && write_ref_args_for_type "${scroll_snap}" scrollable "${outdir}/scrollable_args.json" 2>/dev/null; then + scroll_ref="$(args_ref "${outdir}/scrollable_args.json")" + scroll_snap_id="$(args_snapshot_id "${outdir}/scrollable_args.json")" + run_tool_allow_web_no_movement_skip scroll "{\"ref\":\"${scroll_ref}\",\"direction\":\"down\",\"distance\":120,\"snapshotId\":${scroll_snap_id}}" || true + else + missing_required_tool scroll 'scrollable ref not visible/discovered' + fi +fi +if reveal_identifier_args scroll_demo_list "${outdir}/scrollable_swipe_args.json" "${reveal_down_direction}" 5 260; then + scroll_ref="$(args_ref "${outdir}/scrollable_swipe_args.json")" + scroll_snap_id="$(args_snapshot_id "${outdir}/scrollable_swipe_args.json")" + run_tool_allow_web_no_movement_skip swipe "{\"ref\":\"${scroll_ref}\",\"direction\":\"down\",\"distance\":80,\"snapshotId\":${scroll_snap_id}}" || true +else + scroll_snap="$(capture_snapshot semantic_before_swipe || true)" + if [[ -n "${scroll_snap}" ]] && write_ref_args_for_type "${scroll_snap}" scrollable "${outdir}/scrollable_swipe_args.json" 2>/dev/null; then + scroll_ref="$(args_ref "${outdir}/scrollable_swipe_args.json")" + scroll_snap_id="$(args_snapshot_id "${outdir}/scrollable_swipe_args.json")" + run_tool_allow_web_no_movement_skip swipe "{\"ref\":\"${scroll_ref}\",\"direction\":\"down\",\"distance\":80,\"snapshotId\":${scroll_snap_id}}" || true + else + missing_required_tool swipe 'scrollable ref not visible/discovered' + fi +fi +if [[ "${platform}" == "web" ]]; then + skip_tool press_key 'Flutter Web browser focus can terminate or detach the VM service after synthetic key input' +else + run_tool press_key '{"key":"Tab"}' || true fi -run_tool tap_widget "{\"ref\":\"${increment_ref}\",\"snapshotId\":${snap}}" || true -run_tool scroll "{\"ref\":\"${scrollable_ref}\",\"direction\":\"down\",\"distance\":120,\"snapshotId\":${snap}}" || true -run_tool long_press "{\"ref\":\"${increment_ref}\",\"snapshotId\":${snap}}" || true -run_tool swipe "{\"ref\":\"${scrollable_ref}\",\"direction\":\"down\",\"distance\":80,\"snapshotId\":${snap}}" || true -run_tool drag "{\"fromRef\":\"${increment_ref}\",\"toRef\":\"${increment_ref}\",\"snapshotId\":${snap}}" || true -run_tool hover "{\"ref\":\"${increment_ref}\",\"snapshotId\":${snap}}" || true -run_tool press_key '{"key":"Tab"}' || true run_tool get_recent_logs '{"count":10}' || true run_tool wait_for '{"predicate":{"kind":"time","ms":300},"timeoutMs":2000}' || true # Navigation + dialog (requires showcaseNavigatorKey in flutter_test_app) run_tool navigate '{"action":"push","route":"/visual-reconstruct"}' || true -run_tool navigate '{"action":"pop"}' || true -dialog_ref="$(ref_for_identifier "${outdir}/semantic_snapshot.json" show_test_dialog_button || true)" -if [[ -n "${dialog_ref}" ]]; then - run_tool tap_widget "{\"ref\":\"${dialog_ref}\",\"snapshotId\":${snap}}" || true +if [[ "${platform}" == "web" ]]; then + run_tool navigate '{"action":"push","route":"/"}' || true +else + run_tool navigate '{"action":"pop"}' || true +fi +if reveal_identifier_args show_test_dialog_button "${outdir}/dialog_args.json" "${reveal_down_direction}" 8 320 || \ + ensure_visible_identifier_args show_test_dialog_button "${outdir}/dialog_args.json" "${reveal_down_direction}"; then + run_tool tap_widget "$(cat "${outdir}/dialog_args.json")" || true sleep 0.5 run_tool handle_dialog '{"action":"dismiss"}' || true else - run_tool handle_dialog '{"action":"dismiss"}' || true + missing_required_tool handle_dialog 'show_test_dialog_button ref not visible/discovered' fi -greeting_ref="$(ref_for_identifier "${outdir}/semantic_snapshot.json" greeting_input_field || true)" -if [[ -n "${greeting_ref}" ]]; then - run_tool fill_form "{\"fields\":[{\"ref\":\"${greeting_ref}\",\"text\":\"fill form ok\"}],\"snapshotId\":${snap}}" || true +if ensure_visible_identifier_args greeting_input_field "${outdir}/greeting_fill_args.json" up; then + greeting_ref="$(args_ref "${outdir}/greeting_fill_args.json")" + greeting_snap="$(args_snapshot_id "${outdir}/greeting_fill_args.json")" + run_tool fill_form "{\"fields\":[{\"ref\":\"${greeting_ref}\",\"text\":\"fill form ok\"}],\"snapshotId\":${greeting_snap}}" || true +else + missing_required_tool fill_form 'greeting_input_field ref not visible/discovered' fi # Hot reload family (restart last — destructive) @@ -202,6 +426,7 @@ run_tool hot_reload_and_capture '{"compress":true,"errorsCount":5}' || true # Client bridge run_tool fmt_list_client_tools_and_resources '{}' || true run_tool fmt_client_tool '{"toolName":"dogfood_ping","arguments":{}}' || true +run_tool fmt_client_tool '{"toolName":"intentcall_bridge_ping","arguments":{"echo":"vm-service-proof"}}' || true # migrate is a top-level subcommand, not exec printf '=== migrate agent-entries ===\n' @@ -220,6 +445,9 @@ run_tool hot_restart_flutter '{}' || true summary_file="${outdir}/sweep_summary.txt" { printf 'platform=%s\n' "${platform}" + printf 'generatedAt=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'script=%s\n' "${BASH_SOURCE[0]}" + printf 'outdir=%s\n' "${outdir}" printf 'WS_URI=%s\n' "${ws_uri}" printf 'PASS=%s FAIL=%s SKIP=%s\n' "${pass}" "${fail}" "${skip}" printf '\nResults:\n' diff --git a/scripts/run_web_showcase.sh b/scripts/run_web_showcase.sh index 95fd8ed0..56cb7d06 100755 --- a/scripts/run_web_showcase.sh +++ b/scripts/run_web_showcase.sh @@ -88,7 +88,7 @@ for _ in $(seq 1 180); do tail -40 "${log}" >&2 exit 1 fi - ws_uri="$(grep -Eo 'ws://127\.0\.0\.1:[0-9]+/[A-Za-z0-9_=-]+/ws' "${log}" | tail -1 || true)" + ws_uri="$(grep -aEo 'ws://127\.0\.0\.1:[0-9]+/[A-Za-z0-9_=-]+/ws' "${log}" | tail -1 || true)" if [[ -n "${ws_uri}" ]]; then if [[ "${detach}" == true ]]; then if grep -q "${ready_pattern}" "${log}"; then diff --git a/scripts/run_web_showcase_tests.sh b/scripts/run_web_showcase_tests.sh index a710efb1..feb3b9d4 100755 --- a/scripts/run_web_showcase_tests.sh +++ b/scripts/run_web_showcase_tests.sh @@ -15,15 +15,23 @@ showcase="${repo_root}/.showcase" log="${showcase}/web_app.log" out="${showcase}/tool_verify/web" web_port="${WEB_PORT:-8080}" -vm_port="${VM_HOST_PORT:-8181}" mkdir -p "${out}" printf '[web-tests] starting chrome showcase (detach)…\n' -bash "${repo_root}/scripts/run_web_showcase.sh" --detach +launch_stdout="${out}/web-showcase.stdout" +launch_stderr="${out}/web-showcase.stderr" +if ! bash "${repo_root}/scripts/run_web_showcase.sh" --detach >"${launch_stdout}" 2>"${launch_stderr}"; then + cat "${launch_stdout}" >&2 + cat "${launch_stderr}" >&2 + exit 1 +fi +cat "${launch_stdout}" ws_uri="" +ws_uri="$(grep -aEo 'WS_URI=ws://127\.0\.0\.1:[0-9]+/[A-Za-z0-9_=-]+/ws' "${launch_stdout}" | tail -1 | sed 's/^WS_URI=//' || true)" for _ in $(seq 1 30); do - ws_uri="$(grep -Eo "ws://127\\.0\\.0\\.1:${vm_port}/[A-Za-z0-9_=-]+/ws" "${log}" | tail -1 || true)" + if [[ -n "${ws_uri}" ]]; then break; fi + ws_uri="$(grep -aEo 'ws://127\.0\.0\.1:[0-9]+/[A-Za-z0-9_=-]+/ws' "${log}" | tail -1 || true)" if [[ -n "${ws_uri}" ]]; then break; fi sleep 1 done @@ -60,6 +68,12 @@ run_step validate-runtime "${toolkit[@]}" --flutter-device chrome --web-browser- --save-images --output-dir "${out}/validate-runtime" \ validate-runtime --target "${ws_uri}" --timeout-ms 60000 run_step webmcp-verify dart run "${repo_root}/mcp_server_dart/bin/flutter_mcp_toolkit.dart" webmcp verify --web-port "${web_port}" +run_step webmcp-bridge-proof dart run "${repo_root}/mcp_server_dart/bin/flutter_mcp_toolkit.dart" webmcp verify \ + --web-port "${web_port}" \ + --tool-name app_intentcall_bridge_ping \ + --tool-args '{"echo":"webmcp-proof"}' \ + --expect-result-field source \ + --expect-result-value dart_registry run_step runtime-enter-text bash "${repo_root}/tool/evals/run_runtime_enter_text_greeting.sh" --ws-uri "${ws_uri}" \ --platform web --launch-command "scripts/run_web_showcase.sh --detach" \ --output "${out}/runtime-enter-text-greeting.json" diff --git a/steward.yaml b/steward.yaml index 68e1938c..ccfc8f7b 100644 --- a/steward.yaml +++ b/steward.yaml @@ -139,6 +139,51 @@ actions: native_gate: make check-contracts falsifier: bash tool/intentcall/check_no_path_deps_test.sh benchmark: mcp_flutter.intentcall-hosted-cutover + fmt.check.intentcall-hosted-deps-strict: + kind: command + desc: Verify hosted IntentCall dependency cutover has no stale local or root path dependencies. + command: + argv: [/bin/bash, tool/intentcall/check_no_path_deps.sh, --strict-root] + shell: false + cwd: . + effects: + fs_read: + - tool/intentcall/check_no_path_deps.sh + - pubspec.yaml + - pubspec.lock + - mcp_toolkit/pubspec.yaml + - mcp_server_dart/pubspec.yaml + - packages/core/pubspec.yaml + - packages/server_capability_core/pubspec.yaml + - packages/server_capability_kernel/pubspec.yaml + - flutter_test_app/pubspec.yaml + fs_write: [] + git: false + network: false + secrets: false + destructive: false + safety: + class: bounded_local + default_policy: auto + requires_confirmation: false + limits: + timeout_ms: 10000 + max_output_bytes: 200000 + outputs: + - id: stdout + kind: stream + required: true + retention: summary + format: text + evidence: + owner: flutter-mcp-toolkit + risk: hosted_dependency_cutover_regression + redaction: steward/redaction/v1 + summary_fields: [exit_code, duration_ms, output_digest] + validation: + native_gate: make check-contracts + falsifier: bash tool/intentcall/check_no_path_deps_test.sh + benchmark: mcp_flutter.intentcall-hosted-cutover fmt.check.sdk-parity: kind: command desc: Verify Dart SDK floor parity between mcp_server_dart pubspec and Docker images. @@ -177,6 +222,45 @@ actions: summary_fields: [exit_code, duration_ms, output_digest] validation: native_gate: make check-contracts + fmt.check.cli-alias-surface: + kind: command + desc: Verify fmtk CLI alias, canonical CLI, server binary, and release/install alias surfaces. + command: + argv: [/bin/bash, tool/contracts/check_cli_alias_surface.sh] + shell: false + cwd: . + effects: + fs_read: + - tool/contracts/check_cli_alias_surface.sh + - mcp_server_dart/pubspec.yaml + - mcp_server_dart/makefile + - tool/release/build_release_artifacts.sh + - install.sh + fs_write: [] + git: false + network: false + secrets: false + destructive: false + safety: + class: bounded_local + default_policy: auto + requires_confirmation: false + limits: + timeout_ms: 10000 + max_output_bytes: 200000 + outputs: + - id: stdout + kind: stream + required: true + retention: summary + format: text + evidence: + owner: flutter-mcp-toolkit + risk: cli_alias_surface_regression + redaction: steward/redaction/v1 + summary_fields: [exit_code, duration_ms, output_digest] + validation: + native_gate: make check-contracts fmt.check.version-sync: kind: command desc: Verify repo VERSION matches package, runtime, plugin, and hosted dependency version touchpoints. @@ -398,6 +482,7 @@ probes: - fmt.check.tool-prefix - fmt.check.intentcall-hosted-deps - fmt.check.sdk-parity + - fmt.check.cli-alias-surface - fmt.check.version-sync - fmt.check.changelog-markdown - fmt.check.repo-split-paths diff --git a/steward/scenarios/mcp_flutter.intentcall-hosted-cutover.yaml b/steward/scenarios/mcp_flutter.intentcall-hosted-cutover.yaml index 77843d90..bb34417c 100644 --- a/steward/scenarios/mcp_flutter.intentcall-hosted-cutover.yaml +++ b/steward/scenarios/mcp_flutter.intentcall-hosted-cutover.yaml @@ -6,9 +6,9 @@ source: git: https://github.com/Arenukvern/mcp_flutter commit: fd0c0597d0a7c57e3643a0cc423926ca4fbc5f96 steward_contract: steward.yaml -safe_first_probe: fmt.check.intentcall-hosted-deps +safe_first_probe: fmt.check.intentcall-hosted-deps-strict required_actions: - - fmt.check.intentcall-hosted-deps + - fmt.check.intentcall-hosted-deps-strict artifacts: - id: steward_contract kind: yaml @@ -25,6 +25,16 @@ artifacts: path: tool/intentcall/check_no_path_deps_test.sh required: true durability: reference + - id: root_pubspec + kind: yaml + path: pubspec.yaml + required: true + durability: input + - id: root_pubspec_lock + kind: lockfile + path: pubspec.lock + required: true + durability: input - id: mcp_toolkit_pubspec kind: yaml path: mcp_toolkit/pubspec.yaml diff --git a/tool/contracts/check_cli_alias_surface.sh b/tool/contracts/check_cli_alias_surface.sh new file mode 100755 index 00000000..3780f2f7 --- /dev/null +++ b/tool/contracts/check_cli_alias_surface.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Verifies the CLI binary/alias contract for Flutter MCP Toolkit. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PUBSPEC="$ROOT_DIR/mcp_server_dart/pubspec.yaml" +MAKEFILE="$ROOT_DIR/mcp_server_dart/makefile" +RELEASE_SCRIPT="$ROOT_DIR/tool/release/build_release_artifacts.sh" +INSTALL_SCRIPT="$ROOT_DIR/install.sh" + +fail() { + echo "cli-alias-surface: $*" >&2 + exit 1 +} + +ok() { + echo "cli-alias-surface: $*" +} + +for file in "$PUBSPEC" "$MAKEFILE" "$RELEASE_SCRIPT" "$INSTALL_SCRIPT"; do + [[ -f "$file" ]] || fail "missing ${file#$ROOT_DIR/}" +done + +grep -Eq '^[[:space:]]*fmtk:[[:space:]]*flutter_mcp_toolkit[[:space:]]*$' "$PUBSPEC" || + fail "mcp_server_dart/pubspec.yaml must expose fmtk -> flutter_mcp_toolkit" +grep -Eq '^[[:space:]]*flutter-mcp-toolkit:[[:space:]]*flutter_mcp_toolkit[[:space:]]*$' "$PUBSPEC" || + fail "mcp_server_dart/pubspec.yaml must keep canonical flutter-mcp-toolkit executable" +grep -Eq '^[[:space:]]*flutter-mcp-toolkit-server:[[:space:]]*flutter_mcp_toolkit_server[[:space:]]*$' "$PUBSPEC" || + fail "mcp_server_dart/pubspec.yaml must keep flutter-mcp-toolkit-server executable" + +if grep -Eq '^[[:space:]]*fmt:[[:space:]]' "$PUBSPEC"; then + fail "do not add fmt executable alias; /usr/bin/fmt already exists" +fi + +grep -Fq 'cp build/flutter-mcp-toolkit build/fmtk' "$MAKEFILE" || + fail "mcp_server_dart/makefile must copy compiled CLI to build/fmtk" +grep -Fq 'bin/fmtk' "$RELEASE_SCRIPT" || + fail "release artifact builder must package bin/fmtk" +grep -Fq 'bin/fmtk' "$INSTALL_SCRIPT" || + fail "install.sh must install or synthesize fmtk" +grep -Fq 'fmtk" --help' "$INSTALL_SCRIPT" || + fail "install.sh must smoke fmtk --help" + +ok "fmtk alias, canonical CLI, server binary, and no-fmt contract verified" diff --git a/tool/contracts/check_intentcall_integration.sh b/tool/contracts/check_intentcall_integration.sh index 735e1f8e..1e281f5c 100755 --- a/tool/contracts/check_intentcall_integration.sh +++ b/tool/contracts/check_intentcall_integration.sh @@ -26,7 +26,14 @@ fi echo "== intentcall package matrix (${intentcall_root}) ==" ( cd "${intentcall_root}" - make test + if [[ -f Makefile || -f makefile ]]; then + make test + elif command -v just >/dev/null 2>&1 && [[ -f justfile ]]; then + just test + else + echo "intentcall test runner missing at ${intentcall_root}: expected Makefile or justfile" >&2 + exit 1 + fi ) dart test packages/server_capability_kernel packages/server_capability_core diff --git a/tool/evals/run_dogfood_eval.sh b/tool/evals/run_dogfood_eval.sh index e63c5e33..914ac1da 100755 --- a/tool/evals/run_dogfood_eval.sh +++ b/tool/evals/run_dogfood_eval.sh @@ -334,7 +334,7 @@ if [[ "${skip_runtime}" != true ]]; then fi if [[ "${webmcp_verify}" == true || "${device}" == chrome ]]; then - log "webmcp verify (CDP probe for navigator.modelContext)" + log "webmcp verify (CDP probe for WebMCP modelContext)" set +e webmcp_args=(webmcp verify --web-port "${web_port}") [[ -n "${web_debug_port}" ]] && webmcp_args+=(--cdp-port "${web_debug_port}") diff --git a/tool/evals/run_macos_validate_runtime.sh b/tool/evals/run_macos_validate_runtime.sh index 932d2cb7..c8f92b93 100755 --- a/tool/evals/run_macos_validate_runtime.sh +++ b/tool/evals/run_macos_validate_runtime.sh @@ -14,7 +14,10 @@ if [[ -z "${ws_uri}" ]]; then exit 64 fi -"${toolkit[@]}" validate-runtime \ +"${toolkit[@]}" \ + --flutter-device macos \ + --flutter-project-dir "${repo_root}/flutter_test_app" \ + validate-runtime \ --target "${ws_uri}" \ --timeout-ms "${timeout_ms}" \ --output-dir "${repo_root}/.showcase/macos_validate_runtime" diff --git a/tool/intentcall/check_no_path_deps.sh b/tool/intentcall/check_no_path_deps.sh index fa00d5f8..0977a702 100755 --- a/tool/intentcall/check_no_path_deps.sh +++ b/tool/intentcall/check_no_path_deps.sh @@ -1,17 +1,102 @@ #!/usr/bin/env bash -# Fails if consumer pubspecs still use local intentcall path deps -# (Phase 7.7 gate after hosted cutover). +# Fails if committed consumer pubspecs still use local intentcall path deps. set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${here}/../.." && pwd)" cd "${repo_root}" +strict_root=false + +usage() { + cat <<'EOF' +Usage: tool/intentcall/check_no_path_deps.sh [--strict-root] + +Default mode scans committed consumer packages and allows root dependency_overrides +used for deliberate local sibling development. + +--strict-root additionally scans the root pubspec and lockfile. Use it before +publishing release/cutover changes that must not rely on local IntentCall paths. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --strict-root) strict_root=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown: $1" >&2; usage >&2; exit 64 ;; + esac +done + found=0 patterns='agentkit/packages|intentcall/packages|path:[[:space:]]*.*intentcall' matches_file="$(mktemp)" trap 'rm -f "${matches_file}"' EXIT + +check_versions() { + python3 - "$@" <<'PY' +import re +import sys +from pathlib import Path + +required_pubspec = "^0.3.1" +required_lock = "0.3.1" + +def clean(value: str) -> str: + value = value.strip() + if value.startswith(("'", '"')) and value.endswith(("'", '"')): + return value[1:-1] + return value + +def stanza(lines, start, indent): + out = [] + for line in lines[start + 1:]: + if not line.strip() or line.lstrip().startswith("#"): + out.append(line) + continue + current_indent = len(line) - len(line.lstrip(" ")) + if current_indent <= indent: + break + out.append(line) + return out + +def find_version(lines, start, indent): + for line in stanza(lines, start, indent): + match = re.match(r"\s*version:\s*(.+?)\s*$", line) + if match: + return clean(match.group(1)) + return None + +failed = False +for raw_path in sys.argv[1:]: + path = Path(raw_path) + if not path.exists(): + continue + lines = path.read_text().splitlines() + expected = required_lock if path.name == "pubspec.lock" else required_pubspec + for index, line in enumerate(lines): + match = re.match(r"^(\s*)(intentcall_[A-Za-z0-9_]+):(?:\s*(.*?))?\s*$", line) + if not match: + continue + indent = len(match.group(1)) + package = match.group(2) + inline = clean(match.group(3) or "") + version = inline if inline else find_version(lines, index, indent) + if not version or version != expected: + print( + f"stale hosted intentcall version: {path}:{index + 1}: " + f"{package} uses {version or ''}; expected {expected}", + file=sys.stderr, + ) + failed = True + +raise SystemExit(1 if failed else 0) +PY +} + +version_files=() while IFS= read -r -d '' f; do + version_files+=("$f") if grep -nE "${patterns}" "$f" >"${matches_file}" 2>/dev/null; then echo "path dep still present: $f" >&2 echo "matched stale path pattern: agentkit/packages | intentcall/packages | path: .*intentcall" >&2 @@ -20,9 +105,29 @@ while IFS= read -r -d '' f; do fi done < <(find mcp_toolkit mcp_server_dart packages flutter_test_app -name pubspec.yaml -print0 2>/dev/null) +if [[ "${strict_root}" == true ]]; then + for f in pubspec.yaml pubspec.lock; do + version_files+=("$f") + if [[ -f "${f}" ]] && grep -nE "${patterns}" "$f" >"${matches_file}" 2>/dev/null; then + echo "root path override still present: $f" >&2 + echo "matched release-blocking path pattern: agentkit/packages | intentcall/packages | path: .*intentcall" >&2 + cat "${matches_file}" >&2 + found=1 + fi + done +fi + +if ! check_versions "${version_files[@]}"; then + found=1 +fi + if [[ "${found}" -ne 0 ]]; then - echo "FAIL: use hosted intentcall deps for committed consumer state (see docs/intentcall/README.md)" >&2 + echo "FAIL: use hosted intentcall deps ^0.3.1 for committed consumer state (see docs/intentcall/README.md)" >&2 exit 1 fi -echo "OK: no intentcall path deps in consumers" +if [[ "${strict_root}" == true ]]; then + echo "OK: no intentcall path deps in consumers or root release state" +else + echo "OK: no intentcall path deps in committed consumers (root local overrides not checked; run --strict-root before release/cutover)" +fi diff --git a/tool/intentcall/check_no_path_deps_test.sh b/tool/intentcall/check_no_path_deps_test.sh index 40671324..1202a27e 100755 --- a/tool/intentcall/check_no_path_deps_test.sh +++ b/tool/intentcall/check_no_path_deps_test.sh @@ -49,4 +49,66 @@ expect_fails_for \ path: ../third_party/intentcall_schema" \ "path: .*intentcall" -echo "OK: check_no_path_deps rejects stale hosted-cutover path dependencies" +expect_fails_for \ + stale-hosted-version \ + " intentcall_core: ^0.1.0" \ + "stale hosted intentcall version" + +mkdir -p "${tmp}/mcp_server_dart" "${tmp}/packages" +cat > "${tmp}/mcp_toolkit/pubspec.yaml" < "${tmp}/pubspec.yaml" < "${tmp}/pubspec.lock" </dev/null 2>&1; then + echo "expected default mode to allow root local overrides" >&2 + exit 1 +fi + +if bash "${tmp}/tool/intentcall/check_no_path_deps.sh" --strict-root >/dev/null 2>"${tmp}/strict.err"; then + echo "expected strict-root mode to fail root local overrides" >&2 + exit 1 +fi + +if ! grep -q "root path override still present" "${tmp}/strict.err"; then + echo "expected strict-root error to mention root path override" >&2 + cat "${tmp}/strict.err" >&2 + exit 1 +fi + +cat > "${tmp}/pubspec.yaml" </dev/null 2>"${tmp}/strict-version.err"; then + echo "expected strict-root mode to fail stale root hosted version" >&2 + exit 1 +fi + +if ! grep -q "stale hosted intentcall version" "${tmp}/strict-version.err"; then + echo "expected strict-root error to mention stale hosted version" >&2 + cat "${tmp}/strict-version.err" >&2 + exit 1 +fi + +echo "OK: check_no_path_deps rejects stale hosted-cutover path dependencies and versions" diff --git a/tool/intentcall/print_hosted_deps.sh b/tool/intentcall/print_hosted_deps.sh index e82bf0c1..ac576ded 100755 --- a/tool/intentcall/print_hosted_deps.sh +++ b/tool/intentcall/print_hosted_deps.sh @@ -2,13 +2,14 @@ # Prints hosted pub.dev dependency snippets for mcp_flutter consumers (Phase 7.5). set -euo pipefail -version="${INTENTCALL_VERSION:-0.1.0}" +version="${INTENTCALL_VERSION:-0.3.1}" cat < with: intentcall_schema: ^${version} intentcall_core: ^${version} +intentcall_session: ^${version} intentcall_mcp: ^${version} intentcall_platform: ^${version} intentcall_codegen: ^${version} diff --git a/tool/intentcall/publish_all.sh b/tool/intentcall/publish_all.sh index 49599b8e..dae28967 100755 --- a/tool/intentcall/publish_all.sh +++ b/tool/intentcall/publish_all.sh @@ -21,7 +21,7 @@ usage() { cat <<'EOF' Usage: tool/intentcall/publish_all.sh [--execute] -Publishes packages in order (schema → core → adapters → platform → testing). +Publishes packages in order (schema → core → session → adapters → platform → testing). Without --execute, runs `dart pub publish --dry-run` only. EOF } @@ -37,6 +37,7 @@ done publish_order=( intentcall_schema intentcall_core + intentcall_session intentcall_mcp intentcall_webmcp intentcall_gemma diff --git a/tool/release/build_release_artifacts.sh b/tool/release/build_release_artifacts.sh index 20fdb80d..09dc5e9d 100755 --- a/tool/release/build_release_artifacts.sh +++ b/tool/release/build_release_artifacts.sh @@ -122,6 +122,7 @@ for triple in "${TRIPLES[@]}"; do mkdir -p "$stage_dir/bin" compile_binary "bin/flutter_mcp_toolkit.dart" "$stage_dir/bin/flutter-mcp-toolkit" "$target_os" "$target_arch" + cp "$stage_dir/bin/flutter-mcp-toolkit" "$stage_dir/bin/fmtk" compile_binary "bin/flutter_mcp_toolkit_server.dart" "$stage_dir/bin/flutter-mcp-toolkit-server" "$target_os" "$target_arch" cp "$ROOT_DIR/LICENSE" "$stage_dir/LICENSE" @@ -131,6 +132,7 @@ for triple in "${TRIPLES[@]}"; do smoke_dir="$(mktemp -d)" tar -C "$smoke_dir" -xzf "$archive_path" "$smoke_dir/$package_name/bin/flutter-mcp-toolkit" --help >/dev/null + "$smoke_dir/$package_name/bin/fmtk" --help >/dev/null "$smoke_dir/$package_name/bin/flutter-mcp-toolkit-server" --help >/dev/null rm -rf "$smoke_dir"