From d5f6a745626ffe1e8ccd75ad2a213019468d4bcb Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 18:36:30 +0200 Subject: [PATCH 01/23] FW-76: define MCP control plane architecture Document the app-hosted local HTTP/SSE direction, disabled-by-default runtime model, SwiftUI-owned enablement, and MCP core/driver-control layering for the ASFW MCP Control Plane. --- .../MCP_CONTROL_PLANE_ARCHITECTURE.md | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 documentation/MCP_CONTROL_PLANE_ARCHITECTURE.md diff --git a/documentation/MCP_CONTROL_PLANE_ARCHITECTURE.md b/documentation/MCP_CONTROL_PLANE_ARCHITECTURE.md new file mode 100644 index 00000000..a0c2166a --- /dev/null +++ b/documentation/MCP_CONTROL_PLANE_ARCHITECTURE.md @@ -0,0 +1,317 @@ +# ASFW MCP Control Plane Architecture + +Linear: [FW-76](https://linear.app/asfirewire/issue/FW-76/define-mcp-host-architecture-and-transport-strategy) + +Status: Accepted direction for the first MCP control-plane slice. + +## 1. Goal + +ASFW should expose a structured Model Context Protocol (MCP) control plane for +FireWire driver and protocol diagnostics. The first phase is not an audio-control +surface and not a production feature. It is a development-only interface for +agents to inspect ASFW state and, later, execute guarded low-level FireWire +operations without parsing log dumps. + +The MCP host must fit inside the existing ASFW Xcode project. It must not assume a +separate Swift CLI project, and it must not enable agent access by default. + +## 2. Non-goals + +This architecture slice does not implement: + +- audio mixer, phantom power, routing, or device-specific audio UX +- production MCP enablement +- write-capable hardware access +- a separate Swift package or standalone CLI project +- raw controller writes before the policy and test gates exist + +## 3. Current Project Constraints + +ASFW is currently organized as an Xcode project with: + +- `ASFW`: SwiftUI control app +- `ASFWDriver`: DriverKit driver +- `ASFWTests`: existing Swift test target + +The control app talks to the driver through `ASFWDriverConnector`, which wraps +IOKit/user-client calls and already exposes useful surfaces: + +- controller status, topology, Config ROM, metrics, and logs +- async read/write/block-read/block-write and transaction polling +- compare-swap support +- AV/C and raw FCP commands +- temporary IRM and CMP test methods + +The connector is a UI-facing object today. MCP handlers should not call it +directly. A narrow protocol boundary is needed so the MCP layer can be tested +with mocks and later backed by the live connector. + +## 4. Proposed Layering + +```mermaid +flowchart TD + Agent["MCP client / agent"] + Settings["SwiftUI MCP settings"] + Transport["Local HTTP/SSE MCP endpoint"] + Host["ASFWMCPHost"] + Core["ASFWMCPCore"] + Policy["Write policy + runtime mode"] + Adapter["ASFWDriverControlling protocol"] + Live["LiveASFWDriverControl"] + Mock["MockASFWDriverControl"] + Connector["ASFWDriverConnector"] + Driver["ASFWDriver user-client"] + + Settings --> Transport + Agent --> Transport + Transport --> Host + Host --> Core + Core --> Policy + Core --> Adapter + Adapter --> Live + Adapter --> Mock + Live --> Connector + Connector --> Driver +``` + +### 4.1 ASFWMCPCore + +Pure Swift, testable, and independent of SwiftUI. This layer owns: + +- tool and resource registry +- dynamic discovery rules +- request and result schemas +- policy decision integration +- compact telemetry/result formatting + +This layer should avoid importing IOKit, SwiftUI, or DriverKit-specific code. + +### 4.2 ASFWDriverControlling + +A protocol boundary over the driver-facing capabilities MCP needs. It should be +async-friendly even if the first live implementation wraps existing synchronous +connector methods. + +Example shape: + +```swift +protocol ASFWDriverControlling { + func controllerSnapshot() async -> MCPControllerSnapshotResult + func listNodes() async -> MCPNodeListResult + func readQuadlet(_ request: MCPReadQuadletRequest) async -> MCPTransactionResult + func readBlock(_ request: MCPReadBlockRequest) async -> MCPTransactionResult +} +``` + +Initial implementations: + +- `LiveASFWDriverControl`: wraps `ASFWDriverConnector` +- `MockASFWDriverControl`: deterministic tests without FireWire hardware + +Write methods should exist only after the FW-79 policy model is ready, or should +return policy refusals without touching the live driver path. + +### 4.3 ASFWMCPHost + +The host layer imports the MCP Swift SDK and wires transport, server lifecycle, +tool listing, resource listing, and tool calls to `ASFWMCPCore`. + +This layer should be thin. Tool behavior belongs in `ASFWMCPCore`; hardware +access belongs behind `ASFWDriverControlling`. + +## 5. Runtime Modes + +MCP is disabled by default. + +| Mode | Purpose | Writes | Hardware access | +| --- | --- | --- | --- | +| `disabled` | Normal app behavior | No | No MCP exposure | +| `mock` | Unit tests and schema tests | No live writes | Mock only | +| `readOnlyDeveloper` | Local diagnostics | No writes | Live reads allowed | +| `developerWriteEnabled` | Explicit lab mode | Policy-gated writes | Live writes allowed only after test gate | + +`developerWriteEnabled` requires the FW-79 policy engine and FW-89 Swift MCP test +gate. Before those exist, write tools may be modeled as schemas but must not reach +the live driver/user-client write path. + +The SwiftUI app owns the enable/disable control and port selection. The first UI +surface should be intentionally small: + +- enabled/disabled toggle +- local port setting +- runtime mode display +- active session indicator + +The UI does not need a full session console in FW-76. It only needs to make it +obvious when an MCP endpoint is active. + +## 6. Transport Decision + +The Swift MCP SDK supports stdio transport and custom transports. It also supports +client-side HTTP transport and in-memory/testing patterns. ASFW should not select +stdio by default just because it is common for CLI MCP servers. + +### 6.1 Selected v1 direction: app-hosted local HTTP/SSE + +The first implementation should be app-hosted and local-only. The ASFW SwiftUI +app owns driver connection lifecycle, user-visible enablement, port selection, +and session-active display. When enabled, the app starts a local HTTP/SSE-style +MCP endpoint and routes requests through the MCP host/core layers. + +```text +SwiftUI app + owns enable/disable UI + owns driver connection lifecycle + starts local HTTP/SSE MCP endpoint only when enabled + ↓ +ASFWMCPHost + ↓ +ASFWMCPCore + ↓ +ASFWDriverControlling + ↓ +MockASFWDriverControl or real ASFWDriverConnector +``` + +The endpoint must bind to loopback only. No Unix domain socket transport is +planned for v1. + +Advantages: + +- reuses the app's existing driver connection and permissions +- no separate project +- clear UI/dev-mode control +- easy to keep disabled by default + +Risks: + +- lifecycle must be carefully tied to app enablement +- clients need a way to discover/connect to the local endpoint +- app sandbox and entitlement behavior must be verified +- server-side HTTP/SSE support may require an ASFW transport adapter if the SDK + does not provide the exact server transport shape needed + +### 6.2 Deferred fallback: helper target in the same Xcode project + +No helper target should be created for v1/FW-76. It remains a fallback +architecture, not the first implementation. The current layering must keep +`ASFWMCPCore` and `ASFWMCPHost` free of SwiftUI dependencies so a helper can host +the same logic later if needed. + +Use a helper target only if one of these becomes true: + +1. MCP clients strongly expect to spawn a process. +2. SwiftUI app lifecycle makes long-lived MCP sessions annoying. +3. MCP should keep running while the UI window is closed. +4. HTTP server integration inside the app becomes messy. +5. Crash/process isolation from the app becomes important. +6. MCP needs a different entitlement/signing shape. + +If app-hosted HTTP/SSE proves unsuitable, a helper target in the same Xcode +project may host the same `ASFWMCPHost` and `ASFWMCPCore` layers. The helper must +still use `ASFWDriverControlling` and must not bypass policy, test gates, or +app-level enablement rules. + +Advantages: + +- cleaner process boundary +- could use stdio if the MCP client expects to spawn a process +- can be tested independently from SwiftUI lifecycle + +Risks: + +- signing, entitlement, packaging, and installation complexity +- may duplicate driver connection lifecycle +- more launch/lifecycle logic +- harder disabled-by-default story +- more moving parts before any MCP value is visible + +### 6.3 XPC helper + +Mac-native boundary for a privileged/local service shape. + +Advantages: + +- well-understood local IPC model on macOS +- stronger boundary than in-app hosting + +Risks: + +- heavier than needed for the first spike +- more setup before any MCP value is visible + +### 6.4 Stdio adapter + +Useful for compatibility with MCP clients that launch tools as subprocesses, but +not the preferred first architecture for the ASFW app. + +Advantages: + +- common MCP deployment shape +- simple lifecycle when the client owns the process + +Risks: + +- poor fit for a signed app already managing DriverKit/user-client access +- encourages a separate CLI shape +- awkward for long-lived app state and UI-controlled enablement + +## 7. Recommended FW-76 Decision + +Use an app-integrated MCP architecture with app-hosted local HTTP/SSE as the +first transport direction. Do not create a helper target in v1. + +Implement the internal boundaries first: + +1. `ASFWMCPCore` +2. `ASFWDriverControlling` +3. `MockASFWDriverControl` +4. a thin `ASFWMCPHost` shell wired to an app-hosted local HTTP/SSE endpoint + +Keep helper-target and stdio adapter options open as fallbacks. They must reuse +the same MCP core and driver-control protocol boundary if added later. + +## 8. Initial Tool Surface for Architecture Validation + +FW-76 should validate structure with a tiny read-only surface: + +- `asfw_get_capabilities` +- `asfw_get_policy` +- `asfw_list_nodes` +- `asfw_get_node_summary` + +These tools can run against `MockASFWDriverControl` first. Live driver reads can +be added only after the host lifecycle and runtime enablement are explicit. + +No write-capable tool should be live in FW-76. + +## 9. Safety Requirements + +- MCP agent access is disabled by default. +- Tool handlers do not call `ASFWDriverConnector` directly. +- Live hardware access goes through `ASFWDriverControlling`. +- Mutating operations require FW-79 and FW-89. +- Denied and dry-run writes must not reach driver/user-client write APIs. +- Dynamic tool discovery must reflect current mode and capability state. +- Large/raw data should be opt-in, not default. + +## 10. Open Questions + +- Which concrete Swift HTTP/SSE server implementation should back the app-hosted + endpoint? +- Should the local endpoint require a session token in addition to loopback + binding? +- How should the selected port be persisted and validated? +- What exact active-session state should the app display: connected client count, + last request timestamp, current tool call, or only active/inactive? +- How should MCP enablement interact with app relaunch and system extension + reconnect behavior? + +## 11. FW-76 Acceptance Criteria Mapping + +- Architecture decision recorded: this document. +- App-hosted local HTTP/SSE is selected as the first transport direction. +- No separate standalone Swift CLI project is required. +- No helper target is created in v1/FW-76. +- Agent access remains disabled by default. +- The design creates a testable core and mock control adapter for FW-89 and FW-90. From b05de618f3fae8131baa06ca1e1d49e89159418c Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 18:46:33 +0200 Subject: [PATCH 02/23] FW-77: define MCP tool taxonomy Document the always-visible MCP tools, dynamic protocol groups, runtime-mode visibility matrix, capability predicates, naming rules, and write-gated discovery behavior for the ASFW MCP Control Plane. --- documentation/MCP_TOOL_TAXONOMY.md | 538 +++++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 documentation/MCP_TOOL_TAXONOMY.md diff --git a/documentation/MCP_TOOL_TAXONOMY.md b/documentation/MCP_TOOL_TAXONOMY.md new file mode 100644 index 00000000..92408bf6 --- /dev/null +++ b/documentation/MCP_TOOL_TAXONOMY.md @@ -0,0 +1,538 @@ +# ASFW MCP Tool Taxonomy + +Linear: [FW-77](https://linear.app/asfirewire/issue/FW-77/design-dynamic-mcp-discovery-and-tool-taxonomy) + +Status: Accepted taxonomy for the first MCP control-plane design pass. + +## 1. Goal + +Define the MCP tool and resource taxonomy for ASFW so agents get a compact, +discoverable FireWire control plane instead of a large static list of every +possible driver action. + +The first MCP phase focuses on FireWire driver/protocol diagnostics: + +- bus, topology, node, and Config ROM inspection +- async read/block-read and compare-swap modeling +- guarded write schemas for later policy-gated enablement +- register access for devices, DICE/TCAT, and controller diagnostics +- IRM/CAS, AV/C/FCP, CMP, SBP-2, and DICE/TCAT low-level protocol surfaces +- compact telemetry resources + +Audio UX is out of scope for this taxonomy. Do not add phantom power, mixer, +routing, device-specific audio controls, or CoreAudio-facing controls here. + +## 2. Design Principles + +1. Keep the always-visible tool set small. +2. Prefer dynamic discovery over a giant static tool list. +3. Prefer semantic protocol tools over raw byte tools when ASFW can decode the + protocol state. +4. Return compact structured results by default. +5. Make raw payloads and large dumps opt-in. +6. Hide write-capable tools until policy and test gates permit them. +7. If a listed tool becomes invalid because hardware state changed, return a + structured capability/policy error instead of failing opaquely. +8. Tool names should be plain, stable, and searchable. + +## 3. Runtime Modes + +The MCP host uses the runtime modes defined in +`documentation/MCP_CONTROL_PLANE_ARCHITECTURE.md`. + +| Mode | Tool discovery behavior | +| --- | --- | +| `disabled` | No MCP endpoint is available. | +| `mock` | Test fixture tools/resources are listed from `MockASFWDriverControl`. No live hardware access. | +| `readOnlyDeveloper` | Read-only inspection tools are listed. Mutating tools are not listed by default. | +| `developerWriteEnabled` | Read tools plus policy-gated write tools are listed only after FW-79 and FW-89 gates are satisfied. | + +Write-capable tool schemas may be documented before `developerWriteEnabled` +exists, but live mutating calls must not reach the driver/user-client write path +until the policy engine and Swift MCP test gate are complete. + +## 4. Minimal Always-Visible Tools + +These tools are available whenever MCP is enabled. They should remain few in +number because every always-visible tool competes for agent attention. + +| Tool | Mode | Purpose | +| --- | --- | --- | +| `asfw_get_capabilities` | mock, read-only, developer-write | Summarize current MCP runtime mode, driver connection state, detected tool groups, and unavailable groups. | +| `asfw_get_policy` | mock, read-only, developer-write | Report runtime mode, write gate status, policy decisions supported, and why writes are or are not listed. | +| `asfw_list_nodes` | mock, read-only, developer-write | List current bus nodes with generation, node ID, GUID if known, Config ROM cache state, and detected protocol hints. | +| `asfw_get_node_summary` | mock, read-only, developer-write | Return a compact single-node summary by node ID or GUID. | +| `asfw_explain_capability` | mock, read-only, developer-write | Explain why a tool group is available, unavailable, hidden, or policy-gated. | + +`asfw_get_capabilities` is the primary discovery entry point. It should mention +dynamic groups without forcing all specialized tool definitions into the model's +active tool set. + +## 5. Dynamic Tool Groups + +Dynamic groups are listed only when their predicates are true. Group names are +used in capability output, telemetry resources, and tool documentation. + +### 5.1 Bus and Topology + +Predicate: + +- driver connected +- topology/status APIs available + +Tools: + +| Tool | Visibility | Purpose | +| --- | --- | --- | +| `asfw_get_controller_state` | read-only | Controller status, bus generation, reset count, and basic health. | +| `asfw_get_topology` | read-only | Current topology snapshot and node map. | +| `asfw_get_bus_reset_history` | read-only | Bounded recent bus reset history. | +| `asfw_get_self_id_capture` | read-only | Latest Self-ID capture, bounded by generation when available. | +| `asfw_trigger_config_rom_read` | developer-write | Initiate Config ROM read for a node. Mutating because it starts driver work. | + +Resources: + +- `asfw://controller/state` +- `asfw://bus/topology` +- `asfw://bus/self-id/latest` +- `asfw://bus/resets/recent` + +### 5.2 Config ROM and Discovery + +Predicate: + +- driver connected +- node list or Config ROM cache available + +Tools: + +| Tool | Visibility | Purpose | +| --- | --- | --- | +| `asfw_get_config_rom` | read-only | Return cached Config ROM bytes plus parsed summary. Raw bytes opt-in. | +| `asfw_list_discovered_devices` | read-only | Return discovered FWDevice/FWUnit state. | +| `asfw_decode_config_rom` | read-only | Decode supplied or cached Config ROM data. | + +Resources: + +- `asfw://nodes` +- `asfw://nodes/{nodeId}/summary` +- `asfw://nodes/{nodeId}/config-rom` +- `asfw://devices` + +### 5.3 Async Transactions + +Predicate: + +- driver connected +- async transaction user-client methods available + +Tools: + +| Tool | Visibility | Purpose | +| --- | --- | --- | +| `asfw_read_quadlet` | read-only | Async quadlet read by node/generation/address. | +| `asfw_read_block` | read-only | Async block read by node/generation/address/length. | +| `asfw_get_transaction_result` | read-only | Poll or fetch a submitted transaction result by handle/correlation ID. | +| `asfw_write_quadlet` | developer-write | Policy-gated quadlet write with optional readback verification. | +| `asfw_write_block` | developer-write | Policy-gated block write with optional readback verification. | +| `asfw_compare_swap` | developer-write | Policy-gated lock/compare-swap. Also used by IRM/CMP flows. | + +Address arguments must be explicit: + +```json +{ + "nodeId": 2, + "generation": 17, + "addressHigh": 65535, + "addressLow": 4026532864, + "length": 4 +} +``` + +Results must include: + +- `ok` +- `status` +- `rcode` +- `generation` +- `durationUsec` +- `correlationId` +- `payload` when requested or naturally small +- `policy` for write-capable calls + +### 5.4 Register Access + +Predicate: + +- driver connected +- target register space can be classified + +Tools: + +| Tool | Visibility | Purpose | +| --- | --- | --- | +| `asfw_read_device_register` | read-only | Read a device CSR/register address through async transaction path. | +| `asfw_read_device_register_block` | read-only | Read a bounded block from a device register/address space. | +| `asfw_write_device_register` | developer-write | Policy-gated device register write. | +| `asfw_write_device_register_block` | developer-write | Policy-gated block register write. | +| `asfw_read_ohci_register` | read-only | Read host OHCI/controller register for diagnostics. | +| `asfw_snapshot_ohci_registers` | read-only | Return selected bounded OHCI register snapshot. | +| `asfw_write_ohci_register_dev` | developer-write | Developer-tier controller write; hidden until explicitly enabled. | + +Device register reads are normal protocol diagnostics. OHCI/controller writes are +developer-tier escape hatches and must stay hidden until both policy and test +gates exist. + +### 5.5 IRM and CAS + +Predicate: + +- bus manager/IRM state available, or +- async compare-swap available for CAS tools + +Tools: + +| Tool | Visibility | Purpose | +| --- | --- | --- | +| `asfw_irm_get_state` | read-only | IRM owner, bus manager state, local/remote role, generation. | +| `asfw_irm_get_bandwidth` | read-only | Read bandwidth availability where implemented. | +| `asfw_irm_get_channels` | read-only | Read channel availability bitmaps where implemented. | +| `asfw_irm_list_allocations` | read-only | List ASFW-known allocations. | +| `asfw_cas_quadlet` | developer-write | Policy-gated compare-swap primitive. | +| `asfw_irm_allocate_channel` | developer-write | Policy-gated channel allocation. | +| `asfw_irm_free_channel` | developer-write | Policy-gated channel release. | +| `asfw_irm_allocate_bandwidth` | developer-write | Policy-gated bandwidth allocation. | +| `asfw_irm_free_bandwidth` | developer-write | Policy-gated bandwidth release. | + +Resources: + +- `asfw://irm/state` +- `asfw://irm/channels` +- `asfw://irm/bandwidth` +- `asfw://irm/allocations` + +IRM mutation tools must report stale generation, lost IRM, compare failed, retry +exhausted, and bus reset during operation as structured reasons. + +### 5.6 AV/C and FCP + +Predicate: + +- at least one AV/C unit detected, or +- developer discovery mode requests probing + +Tools: + +| Tool | Visibility | Purpose | +| --- | --- | --- | +| `asfw_avc_list_units` | read-only | List AV/C units, subunits, plugs, vendor/model IDs. | +| `asfw_avc_get_subunit_capabilities` | read-only | Return decoded subunit capabilities where available. | +| `asfw_avc_get_subunit_descriptor` | read-only | Return bounded descriptor bytes and parsed summary when available. | +| `asfw_fcp_send_command` | read-only by default | Send raw FCP/AV/C command that is inquiry/status-only by schema. | +| `asfw_fcp_send_command_dev` | developer-write | Developer-tier raw FCP command for commands that may mutate device state. | +| `asfw_fcp_get_recent_responses` | read-only | Inspect recent command/response records. | + +The raw FCP tool must require a declared command intent: + +- `inquiry` +- `status` +- `control` +- `notify` +- `vendorDependent` + +Only inquiry/status intents are listed in read-only mode. Control, notify, and +vendor-dependent mutation paths are developer-write. + +Resources: + +- `asfw://protocols/avc/units` +- `asfw://protocols/fcp/recent` + +### 5.7 CMP + +Predicate: + +- AV/C/CMP-capable node detected, or +- plug/PCR state is available + +Tools: + +| Tool | Visibility | Purpose | +| --- | --- | --- | +| `asfw_cmp_list_plugs` | read-only | List known iPCR/oPCR state. | +| `asfw_cmp_read_pcr` | read-only | Read and decode a plug control register. | +| `asfw_cmp_write_pcr` | developer-write | Policy-gated PCR write. | +| `asfw_cmp_establish_connection` | developer-write | Policy-gated connection establishment. | +| `asfw_cmp_break_connection` | developer-write | Policy-gated connection break. | + +Resources: + +- `asfw://protocols/cmp/plugs` +- `asfw://protocols/cmp/connections` + +CMP write tools should use CAS where possible and return compare-failed and +generation-stale states explicitly. + +### 5.8 SBP-2 + +Predicate: + +- SBP-2 unit directory or session state detected + +Tools: + +| Tool | Visibility | Purpose | +| --- | --- | --- | +| `asfw_sbp2_list_units` | read-only | List SBP-2 units discovered from Config ROM/unit directories. | +| `asfw_sbp2_inspect_unit` | read-only | Decode unit directory and command-set hints. | +| `asfw_sbp2_get_session_status` | read-only | Return login/session/fetch-agent state where available. | +| `asfw_sbp2_login_dev` | developer-write | Developer-tier login path. Hidden until explicitly enabled. | +| `asfw_sbp2_submit_orb_dev` | developer-write | Developer-tier ORB submission. Hidden until explicitly enabled. | + +Resources: + +- `asfw://protocols/sbp2/units` +- `asfw://protocols/sbp2/sessions` + +SBP-2 mutation tools are intentionally lower priority than inspection. They must +not bypass the write policy engine. + +### 5.9 DICE and TCAT Low-Level Access + +Predicate: + +- DICE/TCAT identity or address-space hints detected from Config ROM/profile, or +- developer discovery mode requests probing + +Tools: + +| Tool | Visibility | Purpose | +| --- | --- | --- | +| `asfw_dice_read_register` | read-only | Read known DICE register address and optionally decode it. | +| `asfw_dice_read_block` | read-only | Read bounded DICE register block. | +| `asfw_dice_decode_status` | read-only | Decode supplied or cached DICE status/register data. | +| `asfw_tcat_read_application_block` | read-only | Read TCAT application-space block. | +| `asfw_dice_write_register` | developer-write | Policy-gated DICE register write. | +| `asfw_tcat_write_application_block` | developer-write | Policy-gated TCAT application block write. | + +Resources: + +- `asfw://protocols/dice/state` +- `asfw://protocols/dice/registers` +- `asfw://protocols/tcat/application` + +DICE/TCAT register access is low-level protocol work, not audio UX. Do not expose +phantom power, routing, mixer, or device-control semantics in this group. + +### 5.10 Telemetry and Diagnostics + +Predicate: + +- MCP enabled + +Tools: + +| Tool | Visibility | Purpose | +| --- | --- | --- | +| `asfw_get_telemetry_snapshot` | read-only | Compact snapshot across controller, topology, transactions, protocols. | +| `asfw_list_recent_transactions` | read-only | Bounded recent transaction history. | +| `asfw_get_driver_version` | read-only | Driver/app version and ABI compatibility summary. | +| `asfw_set_logging_config_dev` | developer-write | Developer-tier logging verbosity or hex dump changes. | + +Resources: + +- `asfw://telemetry/snapshot` +- `asfw://transactions/recent` +- `asfw://driver/version` +- `asfw://logs/recent` + +Logs are supporting context, not the primary interface. Default telemetry should +be bounded and structured. + +## 6. Dynamic Discovery Rules + +`ListTools` should be computed from current state: + +```text +runtime mode ++ driver connection state ++ current bus generation ++ discovered node summaries ++ detected protocol hints ++ available user-client methods ++ write policy gates +=> visible tool list +``` + +Rules: + +1. Always-visible tools are listed whenever MCP is enabled. +2. Read-only dynamic tools are listed when their capability predicate is true. +3. Write-capable tools are hidden unless `developerWriteEnabled` is active and + FW-79/FW-89 gates are satisfied. +4. Developer discovery mode may list probe tools that are safe by policy, but it + must not list mutating raw tools in read-only mode. +5. If a tool was listed but the bus resets before invocation, the call must + return `staleGeneration` or `capabilityChanged` rather than silently using new + state. +6. `asfw_get_capabilities` should include unavailable groups and reasons so an + agent can ask `asfw_explain_capability` instead of guessing. + +## 7. Visibility Matrix + +| Group | mock | readOnlyDeveloper | developerWriteEnabled | +| --- | --- | --- | --- | +| Always-visible | yes | yes | yes | +| Bus/topology read | fixture | yes | yes | +| Config ROM read/decode | fixture | yes | yes | +| Async read | fixture | yes | yes | +| Async write | policy fixture only | hidden | yes, policy-gated | +| Register read | fixture | yes | yes | +| Device register write | policy fixture only | hidden | yes, policy-gated | +| OHCI register write | hidden unless explicit fixture | hidden | hidden unless raw dev tier enabled | +| IRM read | fixture | yes when available | yes | +| IRM mutation/CAS | policy fixture only | hidden | yes, policy-gated | +| AV/C/FCP inquiry/status | fixture | yes when available | yes | +| AV/C/FCP control/vendor mutation | policy fixture only | hidden | yes, policy-gated | +| CMP read | fixture | yes when available | yes | +| CMP mutation | policy fixture only | hidden | yes, policy-gated | +| SBP-2 inspection | fixture | yes when available | yes | +| SBP-2 mutation | hidden unless explicit fixture | hidden | hidden unless raw dev tier enabled | +| DICE/TCAT read | fixture | yes when available | yes | +| DICE/TCAT write | policy fixture only | hidden | yes, policy-gated | + +## 8. Capability Predicates + +Each dynamic group must have a predicate function in `ASFWMCPCore`. Predicates +should return both a boolean and a reason string. + +Example predicate result: + +```json +{ + "group": "dice_tcat", + "available": false, + "reason": "No connected node has DICE/TCAT identity hints in the current generation", + "requiredMode": "readOnlyDeveloper", + "requiredEvidence": ["configRom.vendorModel", "unitDirectory.specifierId"] +} +``` + +Predicate inputs: + +- runtime mode +- driver connection state +- current generation +- node summaries +- Config ROM cache state +- known user-client method availability +- protocol discovery results +- write gate status + +## 9. Naming Rules + +Tool names: + +- prefix with `asfw_` +- use lowercase snake case +- put the protocol/group near the front +- use `_dev` suffix for raw/developer-tier escape hatches +- prefer verbs: `get`, `list`, `read`, `write`, `decode`, `send`, `snapshot` + +Good: + +- `asfw_avc_list_units` +- `asfw_dice_read_register` +- `asfw_cmp_read_pcr` +- `asfw_snapshot_ohci_registers` + +Avoid: + +- `asfw_do_command` +- `asfw_raw` +- `asfw_debug` +- `asfw_write` + +Descriptions should state: + +- whether the tool is read-only or policy-gated +- required target identity fields +- whether raw bytes are returned by default +- whether the tool can be affected by bus generation changes + +## 10. Tool Annotations + +Where the Swift MCP SDK supports annotations, ASFW should mark tools accurately: + +- read-only tools: `readOnlyHint: true` +- idempotent inspection tools: `idempotentHint: true` +- write tools: no read-only hint +- destructive or raw developer tools: explicitly described as developer-tier + +Do not mark a tool idempotent if it initiates driver work, even if it does not +write device state. For example, `asfw_trigger_config_rom_read` changes driver +activity and should not be treated as a pure read. + +## 11. Structured Error Vocabulary + +Common error/status reasons: + +- `driverNotConnected` +- `mcpDisabled` +- `unsupportedRuntimeMode` +- `capabilityUnavailable` +- `capabilityChanged` +- `policyDenied` +- `dryRunOnly` +- `requiresDeveloperMode` +- `testGateMissing` +- `unsupportedAddressSpace` +- `staleGeneration` +- `busResetDuringOperation` +- `transactionTimeout` +- `rcodeError` +- `compareFailed` +- `unsupportedProtocol` +- `malformedRequest` +- `payloadTooLarge` +- `rawDataOmitted` + +Every denial should include: + +- machine-readable code +- human-readable reason +- current mode +- required mode or missing gate when applicable + +## 12. Example Discovery Response Shape + +`asfw_get_capabilities` should return a compact map, not a prose report: + +```json +{ + "runtimeMode": "readOnlyDeveloper", + "driverConnected": true, + "generation": 17, + "groups": [ + { + "id": "bus_topology", + "available": true, + "tools": ["asfw_get_controller_state", "asfw_get_topology"] + }, + { + "id": "dice_tcat", + "available": true, + "tools": ["asfw_dice_read_register", "asfw_dice_read_block"], + "hiddenTools": ["asfw_dice_write_register"], + "hiddenReason": "Write tools require developerWriteEnabled and FW-79/FW-89 gates" + } + ] +} +``` + +## 13. Acceptance Criteria Mapping + +- Tool taxonomy documented: this document. +- Dynamic discovery rules documented: sections 5 through 8. +- Minimal always-loaded set defined: section 4. +- Each dynamic group has clear capability predicates: sections 5 and 8. From fa1cff90019348c50b4d9d4d8ab7d8b1644b5a0e Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 18:57:30 +0200 Subject: [PATCH 03/23] FW-86: define MCP telemetry resources Document the MCP telemetry URI scheme, common resource envelope, compact JSON shapes, raw-data opt-in policy, and before/after snapshot comparison model for ASFW agent diagnostics. --- documentation/MCP_TELEMETRY_RESOURCES.md | 786 +++++++++++++++++++++++ 1 file changed, 786 insertions(+) create mode 100644 documentation/MCP_TELEMETRY_RESOURCES.md diff --git a/documentation/MCP_TELEMETRY_RESOURCES.md b/documentation/MCP_TELEMETRY_RESOURCES.md new file mode 100644 index 00000000..63255545 --- /dev/null +++ b/documentation/MCP_TELEMETRY_RESOURCES.md @@ -0,0 +1,786 @@ +# ASFW MCP Telemetry Resources + +Linear: [FW-86](https://linear.app/asfirewire/issue/FW-86/define-agent-friendly-telemetry-resources) + +Status: Accepted resource model for the first MCP control-plane design pass. + +## 1. Goal + +Define compact MCP resources for ASFW telemetry so agents can inspect the driver, +bus, protocol, and transaction state without parsing text logs. + +The resource model should be: + +- stable enough for programmatic agents +- compact by default +- bounded in size +- generation-aware +- suitable for before/after comparison +- able to link to raw detail without returning large dumps by default + +Logs remain useful supporting evidence, but they are not the primary telemetry +interface. + +## 2. Source Material + +The first resource model should be built from existing ASFW app and driver +surfaces: + +- `ASFWDiagnosticsClient` generation-consistent snapshots +- `ASFWDiag*` structs from `ASFWDriver/Shared/ASFWDiagnosticsABI.h` +- `ASFWDriverConnector` status, topology, Config ROM, discovery, transaction, + AV/C, FCP, IRM/CMP, and version calls +- existing bounded diagnostics limits: + - `ASFW_DIAG_MAX_NODES = 64` + - `ASFW_DIAG_MAX_PORTS = 27` + - `ASFW_DIAG_MAX_SELF_ID_QUADS = 256` + - `ASFW_DIAG_MAX_ASYNC_EVENTS = 128` + - `ASFW_DIAG_MAX_CSR_ENTRIES = 32` + - `ASFW_DIAG_MAX_PHY_REGS = 16` + +Resource handlers should reuse ASFW's existing consistency behavior where +possible. In particular, multi-struct snapshots should retry on stale generation +and return a structured error if a stable snapshot cannot be collected. + +## 3. Common Envelope + +Every MCP telemetry resource should return a JSON object with a common envelope: + +```json +{ + "schema": "asfw.telemetry.controller_state.v1", + "uri": "asfw://controller/state", + "snapshotId": "mcp-00000042", + "capturedAt": "2026-06-18T16:55:00Z", + "monotonicNs": 123456789000, + "generation": 17, + "driverConnected": true, + "stale": false, + "truncated": false, + "data": {} +} +``` + +Common fields: + +| Field | Purpose | +| --- | --- | +| `schema` | Stable schema identifier with version suffix. | +| `uri` | Resource URI served. | +| `snapshotId` | MCP-side correlation ID for comparing resources collected together. | +| `capturedAt` | Wall-clock timestamp when available. | +| `monotonicNs` | Driver/app monotonic timestamp where available. | +| `generation` | Bus generation for generation-scoped resources. | +| `driverConnected` | Whether live driver access was available. | +| `stale` | True when the data is known stale or collected across a generation mismatch. | +| `truncated` | True when arrays/raw data were bounded. | +| `data` | Resource-specific payload. | +| `links` | Optional related resource URIs. | +| `errors` | Optional structured recoverable errors. | + +Structured errors should use the FW-77 vocabulary where possible: + +- `driverNotConnected` +- `capabilityUnavailable` +- `capabilityChanged` +- `staleGeneration` +- `busResetDuringOperation` +- `payloadTooLarge` +- `rawDataOmitted` + +## 4. Include Levels + +Resources default to summaries. Large/raw data is opt-in. + +Recommended query parameters: + +| Parameter | Values | Meaning | +| --- | --- | --- | +| `includeRaw` | `false`/`true` | Include raw bytes/quadlets where supported. Default false. | +| `limit` | positive integer | Bound returned arrays. Default resource-specific. | +| `sinceSnapshotId` | string | Return deltas where supported. | +| `generation` | integer | Require a specific bus generation or return stale/capability error. | +| `nodeId` | integer | Filter node-scoped aggregate resources. | +| `protocol` | string | Filter protocol aggregate resources. | + +If raw data is omitted, return `rawDataOmitted` as metadata rather than silently +pretending the data does not exist. + +## 5. Resource Index + +These resources form the first telemetry surface: + +| URI | Purpose | +| --- | --- | +| `asfw://telemetry/snapshot` | Compact cross-system overview for agents. | +| `asfw://controller/state` | Controller, driver, and link state. | +| `asfw://driver/version` | Driver/app version and ABI summary. | +| `asfw://bus/topology` | Current topology and node graph. | +| `asfw://bus/self-id/latest` | Latest Self-ID capture summary. | +| `asfw://bus/resets/recent` | Recent bus reset history. | +| `asfw://nodes` | Node summaries. | +| `asfw://nodes/{nodeId}/summary` | Single node summary. | +| `asfw://nodes/{nodeId}/config-rom` | Cached Config ROM summary, raw optional. | +| `asfw://devices` | Device and unit discovery state. | +| `asfw://transactions/recent` | Recent async transaction events. | +| `asfw://irm/state` | IRM and bus manager state. | +| `asfw://irm/channels` | Channel availability/allocations where known. | +| `asfw://irm/bandwidth` | Bandwidth availability/allocations where known. | +| `asfw://protocols/avc/units` | AV/C unit and subunit summaries. | +| `asfw://protocols/fcp/recent` | Recent FCP command/response summaries. | +| `asfw://protocols/cmp/plugs` | CMP plug/PCR state. | +| `asfw://protocols/cmp/connections` | Known CMP connection state. | +| `asfw://protocols/sbp2/units` | SBP-2 unit summaries. | +| `asfw://protocols/sbp2/sessions` | SBP-2 session/fetch-agent state where available. | +| `asfw://protocols/dice/state` | DICE/TCAT low-level state summary. | +| `asfw://protocols/dice/registers` | Decoded known DICE register cache/snapshot. | +| `asfw://protocols/tcat/application` | TCAT application-space summary. | +| `asfw://logs/recent` | Bounded recent structured app/driver log entries. | + +## 6. Cross-System Snapshot + +URI: `asfw://telemetry/snapshot` + +Purpose: one compact overview suitable for an agent's first read and for +before/after comparisons. + +Default payload: + +```json +{ + "schema": "asfw.telemetry.snapshot.v1", + "uri": "asfw://telemetry/snapshot", + "snapshotId": "mcp-00000042", + "generation": 17, + "data": { + "controller": { + "state": "Running", + "linkActive": true, + "localNodeId": 0, + "rootNodeId": 2, + "irmNodeId": 2, + "isIRM": false, + "isCycleMaster": false + }, + "bus": { + "nodeCount": 3, + "busResetCount": 12, + "gapCount": 63, + "topologyValid": true + }, + "async": { + "recentEventCount": 32, + "droppedEventCount": 0, + "timeouts": 0, + "lastCompletionNs": 123456780000 + }, + "protocols": { + "avcUnits": 1, + "sbp2Units": 0, + "diceTcatNodes": 1, + "cmpCapableNodes": 1 + }, + "policy": { + "runtimeMode": "readOnlyDeveloper", + "writesListed": false, + "writeGate": "testGateMissing" + } + }, + "links": [ + "asfw://controller/state", + "asfw://bus/topology", + "asfw://transactions/recent" + ] +} +``` + +This resource should not include raw Self-ID quadlets, raw Config ROM bytes, raw +FCP frames, or large logs by default. + +## 7. Controller State + +URI: `asfw://controller/state` + +Backed by: + +- `DriverConnectorStatus` +- `getControllerStatus` +- `ASFWDiagBusContract` +- `ASFWDiagOHCI` +- `ASFWDiagPHY` +- `ASFWDiagRoleCoordinator` +- `ASFWDiagPostResetTiming` + +Default `data` shape: + +```json +{ + "controllerState": "Running", + "controllerStateCode": 4, + "linkActive": true, + "flags": { + "isIRM": false, + "isCycleMaster": false + }, + "nodes": { + "local": 0, + "root": 2, + "irm": 2, + "busManager": 2, + "count": 3 + }, + "bus": { + "generation": 17, + "resetCount": 12, + "gapCount": 63, + "maxHops": 2 + }, + "ohci": { + "version": "0x01001000", + "nodeId": "0xFFC0", + "intEventSet": "0x00000000", + "intMaskSet": "0x00000000", + "linkControlSet": "0x00000000" + }, + "phy": { + "gapCount": 63, + "linkOn": true, + "contender": true, + "regValidMask": "0x0000FFFF" + } +} +``` + +OHCI and PHY raw register arrays should be summarized by default. Use +`includeRaw=true` only for bounded raw details. + +## 8. Driver Version + +URI: `asfw://driver/version` + +Backed by `getDriverVersion`. + +Default `data` shape: + +```json +{ + "driver": { + "semanticVersion": "0.0.0", + "gitCommitShort": "abcdef0", + "gitBranch": "feat/MCP", + "gitDirty": false, + "buildTimestamp": "2026-06-18T16:55:00Z" + }, + "diagnosticsAbi": { + "version": 11, + "compatible": true + }, + "mcp": { + "schemaVersion": 1, + "runtimeMode": "readOnlyDeveloper" + } +} +``` + +## 9. Bus Topology + +URI: `asfw://bus/topology` + +Backed by: + +- `ASFWDiagTopology` +- `TopologySnapshot` +- `getTopologySnapshot` + +Default `data` shape: + +```json +{ + "valid": true, + "generation": 17, + "nodeCount": 3, + "localNode": 0, + "rootNode": 2, + "irmNode": 2, + "gapCount": 63, + "busBase16": "0xFFC0", + "nodes": [ + { + "nodeId": 0, + "address16": "0xFFC0", + "isLocal": true, + "isRoot": false, + "isIRM": false, + "linkActive": true, + "contender": true, + "speed": "S400", + "powerClass": "self+15W", + "ports": [ + {"index": 0, "state": "child", "remoteNode": 1, "remotePort": 0} + ] + } + ] +} +``` + +Raw Self-ID quadlets are omitted by default. Use +`asfw://bus/self-id/latest?includeRaw=true` when an agent needs packet-level +debugging. + +## 10. Self-ID and Bus Reset Resources + +URI: `asfw://bus/self-id/latest` + +Default data: + +```json +{ + "generation": 17, + "selfIdSequenceCount": 3, + "rawSelfIdCount": 6, + "enumeratorError": 0, + "rawSelfIds": { + "included": false, + "reason": "rawDataOmitted", + "count": 6 + } +} +``` + +URI: `asfw://bus/resets/recent` + +Default data: + +```json +{ + "count": 10, + "items": [ + { + "index": 12, + "generation": 17, + "startedNs": 123400000000, + "completedNs": 123450000000, + "durationUsec": 50000, + "initiatedByASFW": false + } + ] +} +``` + +## 11. Nodes, Config ROM, and Devices + +URI: `asfw://nodes` + +Default data: + +```json +{ + "generation": 17, + "nodes": [ + { + "nodeId": 2, + "address16": "0xFFC2", + "guid": "0x0011223344556677", + "vendorId": "0x0003DB", + "modelId": "0x01DDDD", + "vendorName": "Apogee", + "modelName": "Duet", + "configRomCached": true, + "protocolHints": ["avc", "cmp"] + } + ] +} +``` + +URI: `asfw://nodes/{nodeId}/summary` + +Single-node version of the same shape plus links to Config ROM and protocol +resources. + +URI: `asfw://nodes/{nodeId}/config-rom` + +Default data: + +```json +{ + "nodeId": 2, + "generation": 17, + "resolvedGeneration": 17, + "exactGenerationMatch": true, + "quadletCount": 64, + "busInfoBlock": { + "busName": "1394", + "guid": "0x0011223344556677", + "irmc": true, + "cmc": true, + "isc": true, + "bmc": true, + "maxRec": 8, + "linkSpeed": "S400" + }, + "rootDirectory": { + "vendorId": "0x0003DB", + "modelId": "0x01DDDD", + "unitCount": 1 + }, + "raw": { + "included": false, + "reason": "rawDataOmitted" + } +} +``` + +URI: `asfw://devices` + +Backed by `getDiscoveredDevices`. + +Default data: + +```json +{ + "devices": [ + { + "guid": "0x0011223344556677", + "nodeId": 2, + "generation": 17, + "state": "ready", + "vendorId": "0x0003DB", + "modelId": "0x01DDDD", + "vendorName": "Apogee", + "modelName": "Duet", + "units": [ + { + "specifierId": "0x00A02D", + "softwareVersion": "0x00010001", + "state": "ready", + "protocolHints": ["avc"] + } + ] + } + ] +} +``` + +## 12. Transactions + +URI: `asfw://transactions/recent` + +Backed by: + +- `ASFWDiagAsyncTrace` +- `ASFWDiagInboundCSRStats` +- transaction result polling where available + +Default data: + +```json +{ + "eventCount": 32, + "droppedCount": 0, + "limit": 32, + "events": [ + { + "timestampNs": 123456789000, + "generation": 17, + "direction": "tx", + "context": "ATRequest", + "tLabel": 42, + "tCode": "readQuadlet", + "sourceId": "0xFFC0", + "destinationId": "0xFFC2", + "address": "0xFFFFF0000400", + "payloadBytes": 4, + "ackCode": "complete", + "rCode": "complete", + "speed": "S400", + "matchedTransaction": true, + "dropReason": null + } + ], + "inboundCsr": { + "configRomReads": 4, + "bandwidthReads": 2, + "bandwidthLocks": 1, + "channelReads": 2, + "channelLocks": 1, + "unsupportedRequests": 0, + "droppedRequests": 0 + } +} +``` + +Default limit should be lower than the ABI maximum. A practical first default is +32 events with a hard cap of `ASFW_DIAG_MAX_ASYNC_EVENTS`. + +## 13. IRM Resources + +URI: `asfw://irm/state` + +Backed by: + +- `ASFWDiagBusContract` +- `ASFWDiagBusManager` +- `ASFWDiagInboundCSRStats` + +Default data: + +```json +{ + "generation": 17, + "irmNode": 2, + "busManagerNode": 2, + "localIsIRM": false, + "localIsBusManager": false, + "localIsRoot": false, + "fallback": { + "state": "idle", + "plannedAction": "none", + "annexHGateOpen": true, + "remainingMs": 0 + }, + "localResourceController": { + "state": "synced", + "readbackValid": true, + "csrControlLastStatus": 0 + } +} +``` + +URI: `asfw://irm/bandwidth` + +```json +{ + "initial": "0x00001000", + "available": "0x00000FBB", + "knownLocalRegister": true, + "lastReadGeneration": 17 +} +``` + +URI: `asfw://irm/channels` + +```json +{ + "availableHi": "0x3FFFFFFE", + "availableLo": "0xFFFFFFFF", + "allocatedChannelsKnownToASFW": [1], + "lastReadGeneration": 17 +} +``` + +## 14. Protocol Resources + +### 14.1 AV/C and FCP + +URI: `asfw://protocols/avc/units` + +Backed by `getAVCUnits`, subunit capability calls, and descriptor calls. + +```json +{ + "units": [ + { + "guid": "0x0011223344556677", + "nodeId": "0xFFC2", + "vendorId": "0x0003DB", + "modelId": "0x01DDDD", + "plugs": { + "isoInput": 1, + "isoOutput": 1, + "externalInput": 0, + "externalOutput": 0 + }, + "subunits": [ + { + "type": "music", + "subunitId": 0, + "sourcePlugs": 2, + "destinationPlugs": 2 + } + ] + } + ] +} +``` + +URI: `asfw://protocols/fcp/recent` + +```json +{ + "limit": 16, + "commands": [ + { + "correlationId": "fcp-12", + "guid": "0x0011223344556677", + "intent": "status", + "submittedAtNs": 123456789000, + "completedAtNs": 123456799000, + "status": "accepted", + "responseBytes": 12, + "rawIncluded": false + } + ] +} +``` + +### 14.2 CMP + +URI: `asfw://protocols/cmp/plugs` + +```json +{ + "nodes": [ + { + "nodeId": 2, + "guid": "0x0011223344556677", + "plugs": [ + { + "kind": "oPCR", + "index": 0, + "online": true, + "channel": 1, + "p2pConnections": 1, + "broadcastConnections": 0, + "rawPcr": "0x80000001" + } + ] + } + ] +} +``` + +URI: `asfw://protocols/cmp/connections` + +Should return known or inferred CMP connection state. If ASFW only has PCR +snapshots and no higher-level connection table, report `source: "pcrSnapshot"` +and avoid implying ownership. + +### 14.3 SBP-2 + +URI: `asfw://protocols/sbp2/units` + +```json +{ + "units": [ + { + "guid": "0x0011223344556677", + "nodeId": 2, + "specifierId": "0x00609E", + "softwareVersion": "0x010483", + "managementAgentOffset": "0x00010000", + "lun": 0, + "commandSet": "SCSI", + "state": "discovered" + } + ] +} +``` + +URI: `asfw://protocols/sbp2/sessions` + +Read-only session/fetch-agent state where implemented. If unavailable, return a +capability error with `capabilityUnavailable` rather than an empty object. + +### 14.4 DICE and TCAT + +URI: `asfw://protocols/dice/state` + +```json +{ + "nodes": [ + { + "nodeId": 2, + "guid": "0x0011223344556677", + "identityHint": "dice-tcat", + "registerBase": "0xFFFFE0000000", + "applicationBase": "0xFFFFE0200000", + "knownStatus": { + "clockLocked": true, + "streaming": false, + "notificationPending": false + } + } + ] +} +``` + +URI: `asfw://protocols/dice/registers` + +Should expose decoded known registers and bounded raw register values when +`includeRaw=true`. It must not expose audio UX controls. + +URI: `asfw://protocols/tcat/application` + +Should summarize the TCAT application section if present. Raw application blocks +are opt-in and bounded. + +## 15. Logs + +URI: `asfw://logs/recent` + +Default data: + +```json +{ + "limit": 50, + "items": [ + { + "timestamp": "2026-06-18T16:55:00Z", + "level": "warning", + "source": "ASFWDriverConnector", + "message": "getConfigROM received stale cache", + "correlationId": null + } + ] +} +``` + +This is intentionally secondary. Agents should first inspect structured +resources, then use logs to explain unexpected transitions. + +## 16. Snapshot Comparison + +Agents should be able to compare two `asfw://telemetry/snapshot` payloads without +special parsing logic. + +Comparison-friendly rules: + +- stable field names +- scalar counters for monotonic activity +- explicit `generation` +- explicit `snapshotId` +- bounded arrays with deterministic ordering +- raw data represented as included/omitted metadata +- unknown/unavailable represented with structured errors, not missing fields + +If a bus reset occurs between before/after snapshots, comparison should flag: + +```json +{ + "comparison": { + "sameGeneration": false, + "beforeGeneration": 17, + "afterGeneration": 18, + "notes": ["Bus generation changed; node IDs may have been reassigned"] + } +} +``` + +## 17. Resource Subscription Semantics + +If MCP resource subscriptions are enabled later, start with: + +- `asfw://telemetry/snapshot` +- `asfw://controller/state` +- `asfw://bus/topology` +- `asfw://transactions/recent` + +Do not stream high-volume raw traces by default. Subscription updates should be +coalesced and bounded. + +## 18. Acceptance Criteria Mapping + +- Resource URI scheme documented: sections 5 through 15. +- Large/raw data is opt-in: sections 4, 6, 9, 10, 12, 14, and 15. +- Each resource has a stable JSON shape: sections 6 through 15. +- Snapshot output is suitable for before/after comparison: sections 3, 6, and 16. From fa30f611d9a973cfa062b9263fdba24da8cbdbe1 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 19:10:01 +0200 Subject: [PATCH 04/23] FW-90: add MCP mock harness Add pure Swift MCP model/core scaffolding, an in-process mock transport, deterministic mock driver fixtures, a safe default hardware smoke plan, Swift Testing coverage, and harness documentation. --- ASFW/MCP/ASFWMCPCore.swift | 271 ++++++++++++++++++++ ASFW/MCP/ASFWMCPDriverControl.swift | 128 +++++++++ ASFW/MCP/ASFWMCPMockTransport.swift | 66 +++++ ASFW/MCP/ASFWMCPModels.swift | 215 ++++++++++++++++ ASFWTests/MCP/MCPMockHarnessTests.swift | 114 ++++++++ documentation/MCP_MOCK_AND_SMOKE_HARNESS.md | 115 +++++++++ 6 files changed, 909 insertions(+) create mode 100644 ASFW/MCP/ASFWMCPCore.swift create mode 100644 ASFW/MCP/ASFWMCPDriverControl.swift create mode 100644 ASFW/MCP/ASFWMCPMockTransport.swift create mode 100644 ASFW/MCP/ASFWMCPModels.swift create mode 100644 ASFWTests/MCP/MCPMockHarnessTests.swift create mode 100644 documentation/MCP_MOCK_AND_SMOKE_HARNESS.md diff --git a/ASFW/MCP/ASFWMCPCore.swift b/ASFW/MCP/ASFWMCPCore.swift new file mode 100644 index 00000000..e3013bd8 --- /dev/null +++ b/ASFW/MCP/ASFWMCPCore.swift @@ -0,0 +1,271 @@ +import Foundation + +struct ASFWMCPCore { + let configuration: ASFWMCPRuntimeConfiguration + let driver: Driver + + func listTools() async -> [ASFWMCPToolDefinition] { + guard configuration.mode != .disabled else { return [] } + + let allTools = Self.toolCatalog + return allTools.filter { tool in + switch tool.visibility { + case .always: + return true + case .readOnly: + return configuration.mode == .mock || + configuration.mode == .readOnlyDeveloper || + configuration.mode == .developerWriteEnabled + case .developerWrite: + return configuration.mode == .mock || configuration.canListDeveloperWriteTools + case .rawDeveloper: + return configuration.mode == .mock || configuration.canListRawDeveloperTools + } + } + } + + func listResources() async -> [ASFWMCPResourceDefinition] { + guard configuration.mode != .disabled else { return [] } + return Self.resourceCatalog + } + + func readResource(uri: String) async -> ASFWMCPResourceEnvelope { + guard configuration.mode != .disabled else { + return disabledEnvelope(uri: uri) + } + + switch uri { + case "asfw://telemetry/snapshot": + return await telemetrySnapshotEnvelope() + case "asfw://nodes": + return await nodesEnvelope() + case "asfw://transactions/recent": + return await transactionsEnvelope() + case "asfw://controller/state": + return await controllerStateEnvelope() + default: + return capabilityUnavailableEnvelope(uri: uri) + } + } + + private func telemetrySnapshotEnvelope() async -> ASFWMCPResourceEnvelope { + let snapshot = await driver.fetchTelemetrySnapshot(configuration: configuration) + return envelope( + schema: "asfw.telemetry.snapshot.v1", + uri: "asfw://telemetry/snapshot", + snapshot: snapshot, + data: .object([ + "controller": .object([ + "state": .string(snapshot.controller.state), + "linkActive": .bool(snapshot.controller.linkActive), + "localNodeId": snapshot.controller.localNodeId.map { .int(Int($0)) } ?? .null, + "rootNodeId": snapshot.controller.rootNodeId.map { .int(Int($0)) } ?? .null, + "irmNodeId": snapshot.controller.irmNodeId.map { .int(Int($0)) } ?? .null, + "isIRM": .bool(snapshot.controller.isIRM), + "isCycleMaster": .bool(snapshot.controller.isCycleMaster) + ]), + "bus": .object([ + "nodeCount": .int(Int(snapshot.bus.nodeCount)), + "busResetCount": .uint64(snapshot.bus.busResetCount), + "gapCount": .int(Int(snapshot.bus.gapCount)), + "topologyValid": .bool(snapshot.bus.topologyValid) + ]), + "async": .object([ + "recentEventCount": .int(Int(snapshot.async.recentEventCount)), + "droppedEventCount": .int(Int(snapshot.async.droppedEventCount)), + "timeouts": .int(Int(snapshot.async.timeouts)), + "lastCompletionNs": snapshot.async.lastCompletionNs.map { .uint64($0) } ?? .null + ]), + "protocols": .object([ + "avcUnits": .int(Int(snapshot.protocols.avcUnits)), + "sbp2Units": .int(Int(snapshot.protocols.sbp2Units)), + "diceTcatNodes": .int(Int(snapshot.protocols.diceTcatNodes)), + "cmpCapableNodes": .int(Int(snapshot.protocols.cmpCapableNodes)) + ]), + "policy": .object([ + "runtimeMode": .string(snapshot.policy.runtimeMode.rawValue), + "writesListed": .bool(snapshot.policy.writesListed), + "writeGate": .string(snapshot.policy.writeGate) + ]) + ]), + links: [ + "asfw://controller/state", + "asfw://nodes", + "asfw://transactions/recent" + ] + ) + } + + private func controllerStateEnvelope() async -> ASFWMCPResourceEnvelope { + let snapshot = await driver.fetchTelemetrySnapshot(configuration: configuration) + return envelope( + schema: "asfw.telemetry.controller_state.v1", + uri: "asfw://controller/state", + snapshot: snapshot, + data: .object([ + "controllerState": .string(snapshot.controller.state), + "linkActive": .bool(snapshot.controller.linkActive), + "generation": .int(Int(snapshot.generation)), + "nodeCount": .int(Int(snapshot.bus.nodeCount)) + ]), + links: ["asfw://telemetry/snapshot"] + ) + } + + private func nodesEnvelope() async -> ASFWMCPResourceEnvelope { + let snapshot = await driver.fetchTelemetrySnapshot(configuration: configuration) + let nodes = await driver.listNodes() + return envelope( + schema: "asfw.telemetry.nodes.v1", + uri: "asfw://nodes", + snapshot: snapshot, + data: .object([ + "generation": .int(Int(snapshot.generation)), + "nodes": .array(nodes.map { node in + .object([ + "nodeId": .int(Int(node.nodeId)), + "address16": .string(node.address16), + "guid": node.guid.map { .string($0) } ?? .null, + "vendorId": node.vendorId.map { .string($0) } ?? .null, + "modelId": node.modelId.map { .string($0) } ?? .null, + "vendorName": node.vendorName.map { .string($0) } ?? .null, + "modelName": node.modelName.map { .string($0) } ?? .null, + "configRomCached": .bool(node.configRomCached), + "protocolHints": .array(node.protocolHints.map { .string($0) }) + ]) + }) + ]), + links: ["asfw://bus/topology", "asfw://devices"] + ) + } + + private func transactionsEnvelope() async -> ASFWMCPResourceEnvelope { + let snapshot = await driver.fetchTelemetrySnapshot(configuration: configuration) + let events = await driver.listRecentTransactions(limit: 32) + return envelope( + schema: "asfw.telemetry.transactions_recent.v1", + uri: "asfw://transactions/recent", + snapshot: snapshot, + data: .object([ + "eventCount": .int(events.count), + "limit": .int(32), + "events": .array(events.map { event in + .object([ + "timestampNs": .uint64(event.timestampNs), + "generation": .int(Int(event.generation)), + "direction": .string(event.direction), + "context": .string(event.context), + "tLabel": .int(Int(event.tLabel)), + "tCode": .string(event.tCode), + "sourceId": .string(event.sourceId), + "destinationId": .string(event.destinationId), + "address": .string(event.address), + "payloadBytes": .int(Int(event.payloadBytes)), + "ackCode": .string(event.ackCode), + "rCode": .string(event.rCode), + "speed": .string(event.speed), + "matchedTransaction": .bool(event.matchedTransaction), + "dropReason": event.dropReason.map { .string($0) } ?? .null + ]) + }) + ]), + links: ["asfw://telemetry/snapshot"] + ) + } + + private func envelope( + schema: String, + uri: String, + snapshot: ASFWMCPTelemetrySnapshot, + data: ASFWMCPValue, + links: [String] = [] + ) -> ASFWMCPResourceEnvelope { + ASFWMCPResourceEnvelope( + schema: schema, + uri: uri, + snapshotId: snapshot.snapshotId, + capturedAt: snapshot.capturedAt, + monotonicNs: snapshot.monotonicNs, + generation: snapshot.generation, + driverConnected: snapshot.driverConnected, + stale: false, + truncated: false, + data: data, + links: links, + errors: [] + ) + } + + private func disabledEnvelope(uri: String) -> ASFWMCPResourceEnvelope { + ASFWMCPResourceEnvelope( + schema: "asfw.telemetry.error.v1", + uri: uri, + snapshotId: "disabled", + capturedAt: nil, + monotonicNs: nil, + generation: nil, + driverConnected: false, + stale: false, + truncated: false, + data: .object([:]), + links: [], + errors: [ + ASFWMCPResourceError(code: .mcpDisabled, reason: "MCP is disabled.") + ] + ) + } + + private func capabilityUnavailableEnvelope(uri: String) -> ASFWMCPResourceEnvelope { + ASFWMCPResourceEnvelope( + schema: "asfw.telemetry.error.v1", + uri: uri, + snapshotId: "unavailable", + capturedAt: nil, + monotonicNs: nil, + generation: nil, + driverConnected: true, + stale: false, + truncated: false, + data: .object([:]), + links: [], + errors: [ + ASFWMCPResourceError(code: .capabilityUnavailable, reason: "No mock resource is registered for \(uri).") + ] + ) + } +} + +extension ASFWMCPCore { + static var toolCatalog: [ASFWMCPToolDefinition] { + [ + ASFWMCPToolDefinition(name: "asfw_get_capabilities", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Summarize MCP runtime mode and available dynamic groups."), + ASFWMCPToolDefinition(name: "asfw_get_policy", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Report current MCP policy and write-gate status."), + ASFWMCPToolDefinition(name: "asfw_list_nodes", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "List current bus nodes and protocol hints."), + ASFWMCPToolDefinition(name: "asfw_get_node_summary", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Return one compact node summary."), + ASFWMCPToolDefinition(name: "asfw_explain_capability", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Explain why a capability is available, hidden, or policy-gated."), + + ASFWMCPToolDefinition(name: "asfw_get_controller_state", group: "bus_topology", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return controller state and bus health."), + ASFWMCPToolDefinition(name: "asfw_get_topology", group: "bus_topology", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return current topology snapshot."), + ASFWMCPToolDefinition(name: "asfw_get_config_rom", group: "config_rom", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return cached Config ROM summary."), + ASFWMCPToolDefinition(name: "asfw_read_quadlet", group: "async_transactions", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Submit an async quadlet read."), + ASFWMCPToolDefinition(name: "asfw_read_block", group: "async_transactions", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Submit an async block read."), + ASFWMCPToolDefinition(name: "asfw_read_device_register", group: "register_access", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a device register/address-space value."), + ASFWMCPToolDefinition(name: "asfw_dice_read_register", group: "dice_tcat", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a DICE/TCAT register."), + + ASFWMCPToolDefinition(name: "asfw_write_quadlet", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated async quadlet write."), + ASFWMCPToolDefinition(name: "asfw_write_block", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated async block write."), + ASFWMCPToolDefinition(name: "asfw_compare_swap", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated compare-swap transaction."), + ASFWMCPToolDefinition(name: "asfw_dice_write_register", group: "dice_tcat", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated DICE/TCAT register write."), + ASFWMCPToolDefinition(name: "asfw_write_ohci_register_dev", group: "register_access", visibility: .rawDeveloper, readOnly: false, idempotent: false, summary: "Raw developer-tier OHCI register write.") + ] + } + + static var resourceCatalog: [ASFWMCPResourceDefinition] { + [ + ASFWMCPResourceDefinition(uri: "asfw://telemetry/snapshot", schema: "asfw.telemetry.snapshot.v1", summary: "Compact cross-system telemetry overview."), + ASFWMCPResourceDefinition(uri: "asfw://controller/state", schema: "asfw.telemetry.controller_state.v1", summary: "Controller, link, and bus health."), + ASFWMCPResourceDefinition(uri: "asfw://nodes", schema: "asfw.telemetry.nodes.v1", summary: "Current node summaries."), + ASFWMCPResourceDefinition(uri: "asfw://transactions/recent", schema: "asfw.telemetry.transactions_recent.v1", summary: "Bounded recent async transaction events.") + ] + } +} diff --git a/ASFW/MCP/ASFWMCPDriverControl.swift b/ASFW/MCP/ASFWMCPDriverControl.swift new file mode 100644 index 00000000..f353d335 --- /dev/null +++ b/ASFW/MCP/ASFWMCPDriverControl.swift @@ -0,0 +1,128 @@ +import Foundation + +protocol ASFWDriverControlling { + func fetchTelemetrySnapshot(configuration: ASFWMCPRuntimeConfiguration) async -> ASFWMCPTelemetrySnapshot + func listNodes() async -> [ASFWMCPNodeSummary] + func listRecentTransactions(limit: Int) async -> [ASFWMCPTransactionEvent] +} + +actor MockASFWDriverControl: ASFWDriverControlling { + private let nodes: [ASFWMCPNodeSummary] + private let transactions: [ASFWMCPTransactionEvent] + private let generation: UInt32 + private var attemptedWriteCount: Int = 0 + + init( + generation: UInt32 = 17, + nodes: [ASFWMCPNodeSummary] = MockASFWDriverControl.defaultNodes, + transactions: [ASFWMCPTransactionEvent] = MockASFWDriverControl.defaultTransactions + ) { + self.generation = generation + self.nodes = nodes + self.transactions = transactions + } + + func fetchTelemetrySnapshot(configuration: ASFWMCPRuntimeConfiguration) async -> ASFWMCPTelemetrySnapshot { + ASFWMCPTelemetrySnapshot( + snapshotId: "mock-\(generation)", + capturedAt: nil, + monotonicNs: 123_456_789_000, + generation: generation, + driverConnected: true, + controller: ASFWMCPControllerTelemetry( + state: "Running", + linkActive: true, + localNodeId: 0, + rootNodeId: 2, + irmNodeId: 2, + isIRM: false, + isCycleMaster: false + ), + bus: ASFWMCPBusTelemetry( + generation: generation, + nodeCount: UInt32(nodes.count), + busResetCount: 12, + gapCount: 63, + topologyValid: true + ), + async: ASFWMCPAsyncTelemetry( + recentEventCount: UInt32(transactions.count), + droppedEventCount: 0, + timeouts: 0, + lastCompletionNs: transactions.last?.timestampNs + ), + protocols: ASFWMCPProtocolTelemetry( + avcUnits: UInt32(nodes.filter { $0.protocolHints.contains("avc") }.count), + sbp2Units: UInt32(nodes.filter { $0.protocolHints.contains("sbp2") }.count), + diceTcatNodes: UInt32(nodes.filter { $0.protocolHints.contains("dice_tcat") }.count), + cmpCapableNodes: UInt32(nodes.filter { $0.protocolHints.contains("cmp") }.count) + ), + policy: ASFWMCPPolicyTelemetry( + runtimeMode: configuration.mode, + writesListed: configuration.canListDeveloperWriteTools, + writeGate: configuration.canListDeveloperWriteTools ? "open" : "testGateMissing" + ) + ) + } + + func listNodes() async -> [ASFWMCPNodeSummary] { + nodes + } + + func listRecentTransactions(limit: Int) async -> [ASFWMCPTransactionEvent] { + Array(transactions.prefix(max(0, limit))) + } + + func recordUnexpectedWriteAttempt() { + attemptedWriteCount += 1 + } + + func unexpectedWriteAttemptCount() -> Int { + attemptedWriteCount + } + + static let defaultNodes: [ASFWMCPNodeSummary] = [ + ASFWMCPNodeSummary( + nodeId: 0, + address16: "0xFFC0", + guid: "0x0011223344556677", + vendorId: "0x0003DB", + modelId: "0x01DDDD", + vendorName: "Apogee", + modelName: "Duet", + configRomCached: true, + protocolHints: ["avc", "cmp"] + ), + ASFWMCPNodeSummary( + nodeId: 1, + address16: "0xFFC1", + guid: "0x00AABBCCDDEEFF00", + vendorId: "0x00130E", + modelId: "0x00000001", + vendorName: "TCAT", + modelName: "DICE", + configRomCached: true, + protocolHints: ["dice_tcat"] + ) + ] + + static let defaultTransactions: [ASFWMCPTransactionEvent] = [ + ASFWMCPTransactionEvent( + timestampNs: 123_456_780_000, + generation: 17, + direction: "tx", + context: "ATRequest", + tLabel: 42, + tCode: "readQuadlet", + sourceId: "0xFFC0", + destinationId: "0xFFC1", + address: "0xFFFFF0000400", + payloadBytes: 4, + ackCode: "complete", + rCode: "complete", + speed: "S400", + matchedTransaction: true, + dropReason: nil + ) + ] +} diff --git a/ASFW/MCP/ASFWMCPMockTransport.swift b/ASFW/MCP/ASFWMCPMockTransport.swift new file mode 100644 index 00000000..4a98cb83 --- /dev/null +++ b/ASFW/MCP/ASFWMCPMockTransport.swift @@ -0,0 +1,66 @@ +import Foundation + +struct ASFWMCPMockTransport { + let core: ASFWMCPCore + + func listTools() async -> [ASFWMCPToolDefinition] { + await core.listTools() + } + + func listResources() async -> [ASFWMCPResourceDefinition] { + await core.listResources() + } + + func readResource(_ uri: String) async -> ASFWMCPResourceEnvelope { + await core.readResource(uri: uri) + } +} + +enum ASFWMCPHardwareSmokeHarness { + static func defaultPlan(includeMutatingOperations: Bool = false) -> ASFWMCPHardwareSmokePlan { + var steps = [ + ASFWMCPHardwareSmokeStep( + name: "Read telemetry snapshot", + resourceURI: "asfw://telemetry/snapshot", + toolName: nil, + mutatesHardware: false, + requiresExplicitEnablement: false + ), + ASFWMCPHardwareSmokeStep( + name: "Read controller state", + resourceURI: "asfw://controller/state", + toolName: nil, + mutatesHardware: false, + requiresExplicitEnablement: false + ), + ASFWMCPHardwareSmokeStep( + name: "List nodes", + resourceURI: "asfw://nodes", + toolName: "asfw_list_nodes", + mutatesHardware: false, + requiresExplicitEnablement: false + ), + ASFWMCPHardwareSmokeStep( + name: "Read recent transactions", + resourceURI: "asfw://transactions/recent", + toolName: nil, + mutatesHardware: false, + requiresExplicitEnablement: false + ) + ] + + if includeMutatingOperations { + steps.append( + ASFWMCPHardwareSmokeStep( + name: "Optional developer write verification", + resourceURI: nil, + toolName: "asfw_write_quadlet", + mutatesHardware: true, + requiresExplicitEnablement: true + ) + ) + } + + return ASFWMCPHardwareSmokePlan(steps: steps) + } +} diff --git a/ASFW/MCP/ASFWMCPModels.swift b/ASFW/MCP/ASFWMCPModels.swift new file mode 100644 index 00000000..befdb6b0 --- /dev/null +++ b/ASFW/MCP/ASFWMCPModels.swift @@ -0,0 +1,215 @@ +import Foundation + +enum ASFWMCPRuntimeMode: String, Equatable { + case disabled + case mock + case readOnlyDeveloper + case developerWriteEnabled +} + +enum ASFWMCPVisibility: String, Equatable { + case always + case readOnly + case developerWrite + case rawDeveloper +} + +enum ASFWMCPErrorCode: String, Equatable { + case driverNotConnected + case mcpDisabled + case unsupportedRuntimeMode + case capabilityUnavailable + case capabilityChanged + case policyDenied + case dryRunOnly + case requiresDeveloperMode + case testGateMissing + case unsupportedAddressSpace + case staleGeneration + case busResetDuringOperation + case transactionTimeout + case rcodeError + case compareFailed + case unsupportedProtocol + case malformedRequest + case payloadTooLarge + case rawDataOmitted +} + +enum ASFWMCPValue: Equatable { + case null + case bool(Bool) + case int(Int) + case uint64(UInt64) + case string(String) + case array([ASFWMCPValue]) + case object([String: ASFWMCPValue]) +} + +struct ASFWMCPResourceError: Equatable { + let code: ASFWMCPErrorCode + let reason: String +} + +struct ASFWMCPResourceEnvelope: Equatable { + let schema: String + let uri: String + let snapshotId: String + let capturedAt: Date? + let monotonicNs: UInt64? + let generation: UInt32? + let driverConnected: Bool + let stale: Bool + let truncated: Bool + let data: ASFWMCPValue + let links: [String] + let errors: [ASFWMCPResourceError] +} + +struct ASFWMCPToolDefinition: Equatable { + let name: String + let group: String + let visibility: ASFWMCPVisibility + let readOnly: Bool + let idempotent: Bool + let summary: String +} + +struct ASFWMCPResourceDefinition: Equatable { + let uri: String + let schema: String + let summary: String +} + +struct ASFWMCPRuntimeConfiguration: Equatable { + var mode: ASFWMCPRuntimeMode + var writePolicyAvailable: Bool + var swiftTestGatePassed: Bool + var rawDeveloperTierEnabled: Bool + + static let disabled = ASFWMCPRuntimeConfiguration( + mode: .disabled, + writePolicyAvailable: false, + swiftTestGatePassed: false, + rawDeveloperTierEnabled: false + ) + + static let mock = ASFWMCPRuntimeConfiguration( + mode: .mock, + writePolicyAvailable: false, + swiftTestGatePassed: false, + rawDeveloperTierEnabled: false + ) + + static let readOnlyDeveloper = ASFWMCPRuntimeConfiguration( + mode: .readOnlyDeveloper, + writePolicyAvailable: false, + swiftTestGatePassed: false, + rawDeveloperTierEnabled: false + ) + + var canListDeveloperWriteTools: Bool { + mode == .developerWriteEnabled && writePolicyAvailable && swiftTestGatePassed + } + + var canListRawDeveloperTools: Bool { + canListDeveloperWriteTools && rawDeveloperTierEnabled + } +} + +struct ASFWMCPPolicyTelemetry: Equatable { + let runtimeMode: ASFWMCPRuntimeMode + let writesListed: Bool + let writeGate: String +} + +struct ASFWMCPControllerTelemetry: Equatable { + let state: String + let linkActive: Bool + let localNodeId: UInt32? + let rootNodeId: UInt32? + let irmNodeId: UInt32? + let isIRM: Bool + let isCycleMaster: Bool +} + +struct ASFWMCPBusTelemetry: Equatable { + let generation: UInt32 + let nodeCount: UInt32 + let busResetCount: UInt64 + let gapCount: UInt32 + let topologyValid: Bool +} + +struct ASFWMCPAsyncTelemetry: Equatable { + let recentEventCount: UInt32 + let droppedEventCount: UInt32 + let timeouts: UInt32 + let lastCompletionNs: UInt64? +} + +struct ASFWMCPProtocolTelemetry: Equatable { + let avcUnits: UInt32 + let sbp2Units: UInt32 + let diceTcatNodes: UInt32 + let cmpCapableNodes: UInt32 +} + +struct ASFWMCPNodeSummary: Equatable { + let nodeId: UInt32 + let address16: String + let guid: String? + let vendorId: String? + let modelId: String? + let vendorName: String? + let modelName: String? + let configRomCached: Bool + let protocolHints: [String] +} + +struct ASFWMCPTransactionEvent: Equatable { + let timestampNs: UInt64 + let generation: UInt32 + let direction: String + let context: String + let tLabel: UInt32 + let tCode: String + let sourceId: String + let destinationId: String + let address: String + let payloadBytes: UInt32 + let ackCode: String + let rCode: String + let speed: String + let matchedTransaction: Bool + let dropReason: String? +} + +struct ASFWMCPTelemetrySnapshot: Equatable { + let snapshotId: String + let capturedAt: Date? + let monotonicNs: UInt64? + let generation: UInt32 + let driverConnected: Bool + let controller: ASFWMCPControllerTelemetry + let bus: ASFWMCPBusTelemetry + let async: ASFWMCPAsyncTelemetry + let protocols: ASFWMCPProtocolTelemetry + let policy: ASFWMCPPolicyTelemetry +} + +struct ASFWMCPHardwareSmokeStep: Equatable { + let name: String + let resourceURI: String? + let toolName: String? + let mutatesHardware: Bool + let requiresExplicitEnablement: Bool +} + +struct ASFWMCPHardwareSmokePlan: Equatable { + let steps: [ASFWMCPHardwareSmokeStep] + + var containsMutatingOperations: Bool { + steps.contains { $0.mutatesHardware } + } +} diff --git a/ASFWTests/MCP/MCPMockHarnessTests.swift b/ASFWTests/MCP/MCPMockHarnessTests.swift new file mode 100644 index 00000000..5aec074d --- /dev/null +++ b/ASFWTests/MCP/MCPMockHarnessTests.swift @@ -0,0 +1,114 @@ +import Testing +@testable import ASFW + +struct MCPMockHarnessTests { + @Test func mockModeListsAlwaysVisibleAndReadTools() async { + let driver = MockASFWDriverControl() + let core = ASFWMCPCore(configuration: .mock, driver: driver) + let transport = ASFWMCPMockTransport(core: core) + + let names = await transport.listTools().map(\.name) + + #expect(names.contains("asfw_get_capabilities")) + #expect(names.contains("asfw_list_nodes")) + #expect(names.contains("asfw_read_quadlet")) + #expect(names.contains("asfw_dice_read_register")) + } + + @Test func readOnlyModeHidesDeveloperWriteTools() async { + let driver = MockASFWDriverControl() + let core = ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver) + let transport = ASFWMCPMockTransport(core: core) + + let names = await transport.listTools().map(\.name) + + #expect(names.contains("asfw_read_quadlet")) + #expect(names.contains("asfw_write_quadlet") == false) + #expect(names.contains("asfw_dice_write_register") == false) + #expect(names.contains("asfw_write_ohci_register_dev") == false) + } + + @Test func developerWriteModeListsWritesOnlyAfterGatesPass() async { + let driver = MockASFWDriverControl() + let gatedConfig = ASFWMCPRuntimeConfiguration( + mode: .developerWriteEnabled, + writePolicyAvailable: false, + swiftTestGatePassed: false, + rawDeveloperTierEnabled: false + ) + let openConfig = ASFWMCPRuntimeConfiguration( + mode: .developerWriteEnabled, + writePolicyAvailable: true, + swiftTestGatePassed: true, + rawDeveloperTierEnabled: false + ) + + let gatedNames = await ASFWMCPMockTransport( + core: ASFWMCPCore(configuration: gatedConfig, driver: driver) + ).listTools().map(\.name) + let openNames = await ASFWMCPMockTransport( + core: ASFWMCPCore(configuration: openConfig, driver: driver) + ).listTools().map(\.name) + + #expect(gatedNames.contains("asfw_write_quadlet") == false) + #expect(openNames.contains("asfw_write_quadlet")) + #expect(openNames.contains("asfw_write_ohci_register_dev") == false) + } + + @Test func telemetryResourceUsesStableEnvelope() async throws { + let driver = MockASFWDriverControl() + let core = ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver) + let transport = ASFWMCPMockTransport(core: core) + + let envelope = await transport.readResource("asfw://telemetry/snapshot") + + #expect(envelope.schema == "asfw.telemetry.snapshot.v1") + #expect(envelope.uri == "asfw://telemetry/snapshot") + #expect(envelope.generation == 17) + #expect(envelope.driverConnected) + #expect(envelope.errors.isEmpty) + + guard case .object(let data) = envelope.data else { + Issue.record("Telemetry data should be an object.") + return + } + + #expect(data["policy"] != nil) + #expect(data["controller"] != nil) + #expect(data["bus"] != nil) + #expect(data["protocols"] != nil) + } + + @Test func nodesResourceIsBackedByMockDriverWithoutHardware() async throws { + let driver = MockASFWDriverControl() + let core = ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver) + let transport = ASFWMCPMockTransport(core: core) + + let envelope = await transport.readResource("asfw://nodes") + + guard case .object(let data) = envelope.data, + case .array(let nodes)? = data["nodes"] else { + Issue.record("Nodes resource should include a nodes array.") + return + } + + #expect(nodes.count == 2) + #expect(await driver.unexpectedWriteAttemptCount() == 0) + } + + @Test func defaultHardwareSmokePlanContainsNoMutatingOperations() { + let plan = ASFWMCPHardwareSmokeHarness.defaultPlan() + + #expect(plan.steps.isEmpty == false) + #expect(plan.containsMutatingOperations == false) + #expect(plan.steps.allSatisfy { $0.requiresExplicitEnablement == false }) + } + + @Test func mutatingHardwareSmokeStepRequiresExplicitEnablement() throws { + let plan = ASFWMCPHardwareSmokeHarness.defaultPlan(includeMutatingOperations: true) + let mutatingStep = try #require(plan.steps.first { $0.mutatesHardware }) + + #expect(mutatingStep.toolName == "asfw_write_quadlet") + #expect(mutatingStep.requiresExplicitEnablement) + } +} diff --git a/documentation/MCP_MOCK_AND_SMOKE_HARNESS.md b/documentation/MCP_MOCK_AND_SMOKE_HARNESS.md new file mode 100644 index 00000000..00c3f71f --- /dev/null +++ b/documentation/MCP_MOCK_AND_SMOKE_HARNESS.md @@ -0,0 +1,115 @@ +# ASFW MCP Mock and Smoke Harness + +Linear: [FW-90](https://linear.app/asfirewire/issue/FW-90/build-mcp-mock-transport-and-hardware-smoke-test-harness) + +Status: Initial executable harness. + +## 1. Goal + +Provide a hardware-free MCP test harness for the ASFW MCP control plane, plus a +safe plan shape for future opt-in hardware smoke tests. + +This is not the real HTTP/SSE MCP server. It is an in-process harness that lets +the Swift test suite exercise: + +- runtime-mode-based tool discovery +- hidden write-capable tools in read-only mode +- stable telemetry resource envelopes +- mock node and transaction resources +- default hardware smoke plans with no mutating operations + +## 2. Files + +Production-side pure Swift harness: + +- `ASFW/MCP/ASFWMCPModels.swift` +- `ASFW/MCP/ASFWMCPDriverControl.swift` +- `ASFW/MCP/ASFWMCPCore.swift` +- `ASFW/MCP/ASFWMCPMockTransport.swift` + +Tests: + +- `ASFWTests/MCP/MCPMockHarnessTests.swift` + +These files intentionally avoid importing the MCP Swift SDK. They model the core +behavior that `ASFWMCPHost` will later expose over app-hosted local HTTP/SSE. + +## 3. Current Harness Shape + +```text +ASFWMCPMockTransport + ↓ +ASFWMCPCore + ↓ +ASFWDriverControlling + ↓ +MockASFWDriverControl +``` + +The mock driver returns deterministic fixtures: + +- two nodes: one AV/C/CMP-like node and one DICE/TCAT-like node +- one recent async transaction event +- a compact telemetry snapshot + +No IOKit, DriverKit, FireWire hardware, or live MCP networking is used. + +## 4. Runtime Discovery Covered + +The current tests verify: + +- mock mode lists always-visible and read tools +- read-only developer mode hides write-capable tools +- developer-write mode lists writes only after write policy and Swift test gates + are marked available +- raw OHCI write tools stay hidden unless raw developer tier is enabled +- telemetry and nodes resources use stable envelopes and mock data +- the mock path records no unexpected write attempt + +## 5. Hardware Smoke Plan + +`ASFWMCPHardwareSmokeHarness.defaultPlan()` returns a read-only smoke plan by +default. It includes only: + +- read telemetry snapshot +- read controller state +- list nodes +- read recent transactions + +The default plan must never mutate hardware. + +Mutating steps are included only when explicitly requested: + +```swift +let plan = ASFWMCPHardwareSmokeHarness.defaultPlan(includeMutatingOperations: true) +``` + +Any mutating step must have: + +- `mutatesHardware == true` +- `requiresExplicitEnablement == true` + +Future hardware-backed tests must stay opt-in and must not run mutating +operations unless the developer explicitly enables them. + +## 6. Running The Tests + +```bash +./build.sh --swift-test-only --no-bump +``` + +The initial harness is covered by `MCPMockHarnessTests` and does not require +FireWire hardware. + +## 7. Boundaries + +This harness does not implement: + +- real MCP SDK server wiring +- app-hosted HTTP/SSE +- live `ASFWDriverConnector` access +- write policy execution +- hardware-backed smoke tests + +Those belong to later slices. FW-90 only makes the MCP design executable in +mock form and establishes the safe default shape for future hardware tests. From 141ca8eff7b429a746c379b0bf6c349166aaff42 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 19:19:27 +0200 Subject: [PATCH 05/23] FW-89: add MCP Swift test gate Add the fail-closed MCP test gate, protocol-hint discovery checks, SBP-2 mock fixture coverage, Swift Testing gate tests, and documentation for mandatory gate behavior before real agent hardware access. --- ASFW/MCP/ASFWMCPCore.swift | 16 +++- ASFW/MCP/ASFWMCPDriverControl.swift | 12 +++ ASFW/MCP/ASFWMCPModels.swift | 37 +++++++++ ASFW/MCP/ASFWMCPTestGate.swift | 92 ++++++++++++++++++++++ ASFWTests/MCP/MCPTestGateTests.swift | 110 +++++++++++++++++++++++++++ documentation/MCP_TEST_GATE.md | 75 ++++++++++++++++++ 6 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 ASFW/MCP/ASFWMCPTestGate.swift create mode 100644 ASFWTests/MCP/MCPTestGateTests.swift create mode 100644 documentation/MCP_TEST_GATE.md diff --git a/ASFW/MCP/ASFWMCPCore.swift b/ASFW/MCP/ASFWMCPCore.swift index e3013bd8..71f8113d 100644 --- a/ASFW/MCP/ASFWMCPCore.swift +++ b/ASFW/MCP/ASFWMCPCore.swift @@ -7,8 +7,15 @@ struct ASFWMCPCore { func listTools() async -> [ASFWMCPToolDefinition] { guard configuration.mode != .disabled else { return [] } + let nodes = await driver.listNodes() + let protocolHints = Set(nodes.flatMap(\.protocolHints)) let allTools = Self.toolCatalog return allTools.filter { tool in + guard tool.requiredProtocolHints.isEmpty || + tool.requiredProtocolHints.contains(where: { protocolHints.contains($0) }) else { + return false + } + switch tool.visibility { case .always: return true @@ -250,12 +257,17 @@ extension ASFWMCPCore { ASFWMCPToolDefinition(name: "asfw_read_quadlet", group: "async_transactions", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Submit an async quadlet read."), ASFWMCPToolDefinition(name: "asfw_read_block", group: "async_transactions", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Submit an async block read."), ASFWMCPToolDefinition(name: "asfw_read_device_register", group: "register_access", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a device register/address-space value."), - ASFWMCPToolDefinition(name: "asfw_dice_read_register", group: "dice_tcat", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a DICE/TCAT register."), + ASFWMCPToolDefinition(name: "asfw_dice_read_register", group: "dice_tcat", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a DICE/TCAT register.", requiredProtocolHints: ["dice_tcat"]), + ASFWMCPToolDefinition(name: "asfw_irm_get_state", group: "irm_cas", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return IRM and bus manager state."), + ASFWMCPToolDefinition(name: "asfw_avc_list_units", group: "avc_fcp", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List AV/C units.", requiredProtocolHints: ["avc"]), + ASFWMCPToolDefinition(name: "asfw_cmp_read_pcr", group: "cmp", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read and decode a CMP plug control register.", requiredProtocolHints: ["cmp"]), + ASFWMCPToolDefinition(name: "asfw_sbp2_list_units", group: "sbp2", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List SBP-2 units.", requiredProtocolHints: ["sbp2"]), ASFWMCPToolDefinition(name: "asfw_write_quadlet", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated async quadlet write."), ASFWMCPToolDefinition(name: "asfw_write_block", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated async block write."), ASFWMCPToolDefinition(name: "asfw_compare_swap", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated compare-swap transaction."), - ASFWMCPToolDefinition(name: "asfw_dice_write_register", group: "dice_tcat", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated DICE/TCAT register write."), + ASFWMCPToolDefinition(name: "asfw_dice_write_register", group: "dice_tcat", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated DICE/TCAT register write.", requiredProtocolHints: ["dice_tcat"]), + ASFWMCPToolDefinition(name: "asfw_cmp_write_pcr", group: "cmp", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated CMP PCR write.", requiredProtocolHints: ["cmp"]), ASFWMCPToolDefinition(name: "asfw_write_ohci_register_dev", group: "register_access", visibility: .rawDeveloper, readOnly: false, idempotent: false, summary: "Raw developer-tier OHCI register write.") ] } diff --git a/ASFW/MCP/ASFWMCPDriverControl.swift b/ASFW/MCP/ASFWMCPDriverControl.swift index f353d335..5812e5f5 100644 --- a/ASFW/MCP/ASFWMCPDriverControl.swift +++ b/ASFW/MCP/ASFWMCPDriverControl.swift @@ -106,6 +106,18 @@ actor MockASFWDriverControl: ASFWDriverControlling { ) ] + static let sbp2Node = ASFWMCPNodeSummary( + nodeId: 2, + address16: "0xFFC2", + guid: "0x0022334455667788", + vendorId: "0x00609E", + modelId: "0x00001000", + vendorName: "Mock SBP-2", + modelName: "Storage", + configRomCached: true, + protocolHints: ["sbp2"] + ) + static let defaultTransactions: [ASFWMCPTransactionEvent] = [ ASFWMCPTransactionEvent( timestampNs: 123_456_780_000, diff --git a/ASFW/MCP/ASFWMCPModels.swift b/ASFW/MCP/ASFWMCPModels.swift index befdb6b0..d3f0dff6 100644 --- a/ASFW/MCP/ASFWMCPModels.swift +++ b/ASFW/MCP/ASFWMCPModels.swift @@ -73,6 +73,25 @@ struct ASFWMCPToolDefinition: Equatable { let readOnly: Bool let idempotent: Bool let summary: String + let requiredProtocolHints: [String] + + init( + name: String, + group: String, + visibility: ASFWMCPVisibility, + readOnly: Bool, + idempotent: Bool, + summary: String, + requiredProtocolHints: [String] = [] + ) { + self.name = name + self.group = group + self.visibility = visibility + self.readOnly = readOnly + self.idempotent = idempotent + self.summary = summary + self.requiredProtocolHints = requiredProtocolHints + } } struct ASFWMCPResourceDefinition: Equatable { @@ -213,3 +232,21 @@ struct ASFWMCPHardwareSmokePlan: Equatable { steps.contains { $0.mutatesHardware } } } + +struct ASFWMCPTestGateCheck: Equatable { + let id: String + let passed: Bool + let reason: String +} + +struct ASFWMCPTestGateResult: Equatable { + let checks: [ASFWMCPTestGateCheck] + + nonisolated var passed: Bool { + checks.allSatisfy(\.passed) + } + + nonisolated var failedChecks: [ASFWMCPTestGateCheck] { + checks.filter { $0.passed == false } + } +} diff --git a/ASFW/MCP/ASFWMCPTestGate.swift b/ASFW/MCP/ASFWMCPTestGate.swift new file mode 100644 index 00000000..7f7c243c --- /dev/null +++ b/ASFW/MCP/ASFWMCPTestGate.swift @@ -0,0 +1,92 @@ +import Foundation + +enum ASFWMCPTestGate { + static func evaluate( + core: ASFWMCPCore, + smokePlan: ASFWMCPHardwareSmokePlan = ASFWMCPHardwareSmokeHarness.defaultPlan() + ) async -> ASFWMCPTestGateResult { + var checks: [ASFWMCPTestGateCheck] = [] + + let tools = await core.listTools() + let resources = await core.listResources() + let toolNames = Set(tools.map(\.name)) + let resourceURIs = Set(resources.map(\.uri)) + + checks.append(check( + id: "tools.non_empty_when_enabled", + passed: core.configuration.mode == .disabled || tools.isEmpty == false, + reason: "Enabled MCP modes must expose the minimal always-visible tools." + )) + + checks.append(check( + id: "resources.non_empty_when_enabled", + passed: core.configuration.mode == .disabled || resources.isEmpty == false, + reason: "Enabled MCP modes must expose resource definitions." + )) + + let writeToolsHiddenOrGated = tools.allSatisfy { tool in + switch tool.visibility { + case .developerWrite: + return core.configuration.canListDeveloperWriteTools || core.configuration.mode == .mock + case .rawDeveloper: + return core.configuration.canListRawDeveloperTools || core.configuration.mode == .mock + case .always, .readOnly: + return true + } + } + checks.append(check( + id: "writes.hidden_without_gates", + passed: writeToolsHiddenOrGated, + reason: "Write-capable tools must stay hidden unless write policy and Swift test gates are open." + )) + + checks.append(check( + id: "required.resources_present", + passed: resourceURIs.isSuperset(of: [ + "asfw://telemetry/snapshot", + "asfw://controller/state", + "asfw://nodes", + "asfw://transactions/recent" + ]), + reason: "The first telemetry resources must be discoverable." + )) + + checks.append(check( + id: "required.tools_present", + passed: core.configuration.mode == .disabled || toolNames.isSuperset(of: [ + "asfw_get_capabilities", + "asfw_get_policy", + "asfw_list_nodes", + "asfw_get_node_summary", + "asfw_explain_capability" + ]), + reason: "The minimal always-visible tool set must be discoverable." + )) + + let telemetry = await core.readResource(uri: "asfw://telemetry/snapshot") + checks.append(check( + id: "telemetry.envelope_stable", + passed: telemetry.schema == "asfw.telemetry.snapshot.v1" && + telemetry.uri == "asfw://telemetry/snapshot" && + telemetry.snapshotId.isEmpty == false && + telemetry.errors.isEmpty, + reason: "Telemetry snapshots must use stable resource envelopes." + )) + + checks.append(check( + id: "smoke.default_non_mutating", + passed: smokePlan.containsMutatingOperations == false, + reason: "Default hardware smoke plan must not mutate hardware." + )) + + return ASFWMCPTestGateResult(checks: checks) + } + + static func allowsRealAgentHardwareAccess(_ result: ASFWMCPTestGateResult) -> Bool { + result.passed + } + + private static func check(id: String, passed: Bool, reason: String) -> ASFWMCPTestGateCheck { + ASFWMCPTestGateCheck(id: id, passed: passed, reason: reason) + } +} diff --git a/ASFWTests/MCP/MCPTestGateTests.swift b/ASFWTests/MCP/MCPTestGateTests.swift new file mode 100644 index 00000000..9480576d --- /dev/null +++ b/ASFWTests/MCP/MCPTestGateTests.swift @@ -0,0 +1,110 @@ +import Testing +@testable import ASFW + +struct MCPTestGateTests { + @Test func disabledModeFailsClosedForRealAccess() async { + let driver = MockASFWDriverControl() + let core = ASFWMCPCore(configuration: .disabled, driver: driver) + + let result = await ASFWMCPTestGate.evaluate(core: core) + + #expect(result.passed == false) + #expect(ASFWMCPTestGate.allowsRealAgentHardwareAccess(result) == false) + #expect(result.failedChecks.isEmpty == false) + } + + @Test func readOnlyModePassesGateAndHidesWrites() async { + let driver = MockASFWDriverControl() + let core = ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver) + + let result = await ASFWMCPTestGate.evaluate(core: core) + let toolNames = await Set(core.listTools().map(\.name)) + + #expect(result.passed) + #expect(ASFWMCPTestGate.allowsRealAgentHardwareAccess(result)) + #expect(toolNames.contains("asfw_write_quadlet") == false) + #expect(toolNames.contains("asfw_cmp_write_pcr") == false) + #expect(toolNames.contains("asfw_dice_write_register") == false) + } + + @Test func developerWriteModeFailsClosedWhenPolicyOrTestGateIsMissing() async { + let driver = MockASFWDriverControl() + let configuration = ASFWMCPRuntimeConfiguration( + mode: .developerWriteEnabled, + writePolicyAvailable: true, + swiftTestGatePassed: false, + rawDeveloperTierEnabled: false + ) + let core = ASFWMCPCore(configuration: configuration, driver: driver) + + let result = await ASFWMCPTestGate.evaluate(core: core) + let toolNames = await Set(core.listTools().map(\.name)) + + #expect(result.passed) + #expect(toolNames.contains("asfw_write_quadlet") == false) + #expect(toolNames.contains("asfw_write_ohci_register_dev") == false) + } + + @Test func developerWriteModeListsPolicyGatedWritesAfterGatePasses() async { + let driver = MockASFWDriverControl() + let configuration = ASFWMCPRuntimeConfiguration( + mode: .developerWriteEnabled, + writePolicyAvailable: true, + swiftTestGatePassed: true, + rawDeveloperTierEnabled: false + ) + let core = ASFWMCPCore(configuration: configuration, driver: driver) + + let result = await ASFWMCPTestGate.evaluate(core: core) + let toolNames = await Set(core.listTools().map(\.name)) + + #expect(result.passed) + #expect(toolNames.contains("asfw_write_quadlet")) + #expect(toolNames.contains("asfw_dice_write_register")) + #expect(toolNames.contains("asfw_cmp_write_pcr")) + #expect(toolNames.contains("asfw_write_ohci_register_dev") == false) + } + + @Test func noDeviceFixtureDoesNotExposeProtocolSpecificTools() async { + let driver = MockASFWDriverControl(nodes: []) + let core = ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver) + + let result = await ASFWMCPTestGate.evaluate(core: core) + let toolNames = await Set(core.listTools().map(\.name)) + + #expect(result.passed) + #expect(toolNames.contains("asfw_avc_list_units") == false) + #expect(toolNames.contains("asfw_cmp_read_pcr") == false) + #expect(toolNames.contains("asfw_dice_read_register") == false) + #expect(toolNames.contains("asfw_sbp2_list_units") == false) + #expect(toolNames.contains("asfw_irm_get_state")) + } + + @Test func protocolFixturesExposeExpectedReadSurfaces() async { + let driver = MockASFWDriverControl( + nodes: MockASFWDriverControl.defaultNodes + [MockASFWDriverControl.sbp2Node] + ) + let core = ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver) + + let result = await ASFWMCPTestGate.evaluate(core: core) + let toolNames = await Set(core.listTools().map(\.name)) + + #expect(result.passed) + #expect(toolNames.contains("asfw_avc_list_units")) + #expect(toolNames.contains("asfw_cmp_read_pcr")) + #expect(toolNames.contains("asfw_dice_read_register")) + #expect(toolNames.contains("asfw_sbp2_list_units")) + #expect(toolNames.contains("asfw_irm_get_state")) + } + + @Test func gateFailsWhenSmokePlanContainsMutation() async { + let driver = MockASFWDriverControl() + let core = ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver) + let smokePlan = ASFWMCPHardwareSmokeHarness.defaultPlan(includeMutatingOperations: true) + + let result = await ASFWMCPTestGate.evaluate(core: core, smokePlan: smokePlan) + + #expect(result.passed == false) + #expect(result.failedChecks.contains { $0.id == "smoke.default_non_mutating" }) + } +} diff --git a/documentation/MCP_TEST_GATE.md b/documentation/MCP_TEST_GATE.md new file mode 100644 index 00000000..82895c6e --- /dev/null +++ b/documentation/MCP_TEST_GATE.md @@ -0,0 +1,75 @@ +# ASFW MCP Test Gate + +Linear: [FW-89](https://linear.app/asfirewire/issue/FW-89/add-swift-mcp-test-gate-before-agent-enablement) + +Status: Initial fail-closed Swift test gate. + +## 1. Goal + +MCP agent access to real ASFW driver/hardware must remain disabled unless the +Swift MCP test gate passes. The gate exists so the MCP control plane cannot +accidentally expose write-capable or hardware-backed behavior before discovery, +resource envelopes, and mutation guards are test-covered. + +## 2. Implementation + +The initial gate is implemented by: + +- `ASFW/MCP/ASFWMCPTestGate.swift` +- `ASFWTests/MCP/MCPTestGateTests.swift` + +It evaluates an `ASFWMCPCore` instance and returns an +`ASFWMCPTestGateResult` with named checks. The app/host layer should treat +`ASFWMCPTestGate.allowsRealAgentHardwareAccess(_:)` as the mandatory predicate +before enabling real agent/hardware access. + +## 3. Current Checks + +The initial gate verifies: + +- enabled MCP modes expose tools +- enabled MCP modes expose resources +- write-capable tools stay hidden unless write policy and Swift test gates are + open +- required first resources are present +- required always-visible tools are present +- telemetry snapshots use a stable envelope +- default hardware smoke plans contain no mutating operations + +The gate is fail-closed. Disabled mode does not pass real-access enablement. + +## 4. Test Coverage + +`MCPTestGateTests` covers: + +- disabled mode fails closed for real access +- read-only mode passes the gate and hides write tools +- developer-write mode remains closed when policy or test gate state is missing +- developer-write mode lists policy-gated writes only after gates pass +- no-device fixtures do not expose AV/C, CMP, DICE/TCAT, or SBP-2 tools +- protocol fixtures expose AV/C, CMP, DICE/TCAT, SBP-2, and IRM/CAS read + surfaces +- hardware smoke plans with mutation fail the gate + +`MCPMockHarnessTests` continues to cover the underlying mock transport, +resource envelopes, and hardware smoke plan invariants. + +## 5. Running The Gate Tests + +```bash +./build.sh --swift-test-only --no-bump +``` + +This test flow is hardware-free. + +## 6. Later Tightening + +FW-79 should extend the gate with concrete write-policy checks: + +- every policy decision is covered +- denied writes do not reach the driver/user-client write path +- dry-run writes do not reach the driver/user-client write path +- write results include machine-readable policy reasons + +Real hardware smoke tests remain opt-in and must not run mutating operations +unless explicitly enabled. From 2bad228fcd1b1db390b49d57e3b345bf92287e6e Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 19:44:36 +0200 Subject: [PATCH 06/23] FW-79: implement MCP write policy engine and refusal reasons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the guard rail for MCP write-capable tools. The engine classifies a write request by operation type, address space, protocol surface, runtime/developer mode, and node generation, returning one structured decision: allowed | denied | dryRunOnly | requiresDeveloperMode | unsupportedAddressSpace | staleGeneration | unsupportedProtocol Each decision carries a human-readable reason and a machine-readable error code plus required mode/capability (decision vocabulary per MCP_TOOL_TAXONOMY.md §11). Safety invariant ASFWMCPPolicyDecision.reachesDriverWritePath: only `allowed` may reach the driver/user-client write path — every refusal and every dry run is false, so denied and dry-run writes cannot touch hardware. Mock mode resolves any otherwise-authorized write to a dry run; classification still applies so refusals surface in mock too. Extends the FW-89 test gate with the write-path-gated check called for in MCP_TEST_GATE.md §6, and wires ASFWMCPCore.evaluateWritePolicy. The engine operates on an already-resolved ASFWMCPPolicyRequest, so it has no dependency on the FW-78 transaction schemas. 19 Swift Testing cases cover every decision category and the reachesDriverWritePath invariant across the mode/space/protocol/tier matrix. Co-Authored-By: Claude Opus 4.8 --- ASFW/MCP/ASFWMCPTestGate.swift | 18 ++ ASFW/MCP/ASFWMCPWritePolicy.swift | 236 ++++++++++++++++++++++++ ASFWTests/MCP/MCPWritePolicyTests.swift | 194 +++++++++++++++++++ 3 files changed, 448 insertions(+) create mode 100644 ASFW/MCP/ASFWMCPWritePolicy.swift create mode 100644 ASFWTests/MCP/MCPWritePolicyTests.swift diff --git a/ASFW/MCP/ASFWMCPTestGate.swift b/ASFW/MCP/ASFWMCPTestGate.swift index 7f7c243c..39e8332e 100644 --- a/ASFW/MCP/ASFWMCPTestGate.swift +++ b/ASFW/MCP/ASFWMCPTestGate.swift @@ -79,6 +79,24 @@ enum ASFWMCPTestGate { reason: "Default hardware smoke plan must not mutate hardware." )) + // FW-79 tightening (MCP_TEST_GATE.md §6): a write may reach the driver + // write path only when developer-write policy and the test gate are open. + // Probe with a representative, generation-current write so the decision is + // governed by mode/gate state rather than incidental staleness. + let writeProbe = core.writePolicyEngine.evaluate( + ASFWMCPPolicyRequest( + operationType: .write, + addressSpace: .unitsSpace, + requestedGeneration: 0, + currentGeneration: 0 + ) + ) + checks.append(check( + id: "policy.write_path_gated", + passed: writeProbe.reachesDriverWritePath == false || core.configuration.canListDeveloperWriteTools, + reason: "Writes may reach the driver write path only when developer-write policy and the Swift test gate are open." + )) + return ASFWMCPTestGateResult(checks: checks) } diff --git a/ASFW/MCP/ASFWMCPWritePolicy.swift b/ASFW/MCP/ASFWMCPWritePolicy.swift new file mode 100644 index 00000000..8fbf5e49 --- /dev/null +++ b/ASFW/MCP/ASFWMCPWritePolicy.swift @@ -0,0 +1,236 @@ +import Foundation + +// FW-79: MCP write policy engine and refusal reasons. +// +// Classifies write-capable MCP requests by operation type, address space, +// protocol surface, runtime/developer mode, and node generation, returning a +// structured decision. The single safety invariant is +// `ASFWMCPPolicyDecision.reachesDriverWritePath`: only an `allowed` decision may +// reach the driver/user-client write path. Every refusal and every dry run is +// false, so denied and dry-run writes cannot touch hardware. +// +// Decision and error vocabulary mirror documentation/MCP_TOOL_TAXONOMY.md §11. +// The gate extension at the bottom of this file is the FW-89 test-gate tightening +// called for in documentation/MCP_TEST_GATE.md §6. + +/// Coarse policy classification of a FireWire address. +enum ASFWMCPAddressSpace: String, Equatable { + /// IEEE 1212 CSR-architecture register block (0xFFFF_F000_0000 ..< 0x0400). + case csrCore + /// Config ROM space (0xFFFF_F000_0400 ..< 0x0800) — architecturally read-only. + case configRom + /// Initial units / unit-dependent register space (>= 0xFFFF_F000_0800). + case unitsSpace + /// Below the CSR block: posted/physical memory on the target. + case physicalMemory + /// Local OHCI controller registers (host-side, not a node address). + case ohciController + /// Address could not be classified. + case unknown +} + +/// Mutation class of a request for policy purposes. +enum ASFWMCPOperationType: String, Equatable { + case read + case write + case compareSwap + + var isMutating: Bool { self != .read } +} + +/// Structured policy decision categories (taxonomy §11). +enum ASFWMCPWriteDecision: String, Equatable, CaseIterable { + case allowed + case denied + case dryRunOnly + case requiresDeveloperMode + case unsupportedAddressSpace + case staleGeneration + case unsupportedProtocol +} + +/// A policy decision plus its human- and machine-readable justification. +struct ASFWMCPPolicyDecision: Equatable { + let decision: ASFWMCPWriteDecision + /// Human-readable explanation, including how to make the operation valid when + /// that is possible. + let reason: String + /// Machine-readable error code aligned with the MCP error vocabulary. + let errorCode: ASFWMCPErrorCode? + /// Runtime mode the caller must reach for this operation to be allowed. + let requiredMode: ASFWMCPRuntimeMode? + /// Capability/tier the caller must enable (e.g. "rawDeveloperTier"). + let requiredCapability: String? + + init( + decision: ASFWMCPWriteDecision, + reason: String, + errorCode: ASFWMCPErrorCode? = nil, + requiredMode: ASFWMCPRuntimeMode? = nil, + requiredCapability: String? = nil + ) { + self.decision = decision + self.reason = reason + self.errorCode = errorCode + self.requiredMode = requiredMode + self.requiredCapability = requiredCapability + } + + /// The load-bearing safety invariant: only `allowed` may reach the driver + /// write path. Every refusal and every dry run is false. + var reachesDriverWritePath: Bool { + decision == .allowed + } + + var isDryRun: Bool { + decision == .dryRunOnly + } +} + +/// The inputs the policy engine classifies. +struct ASFWMCPPolicyRequest: Equatable { + let operationType: ASFWMCPOperationType + let addressSpace: ASFWMCPAddressSpace + /// Protocol surface the request rides (e.g. "cmp", "dice_tcat"), or nil for + /// raw async address-space access. + let protocolHint: String? + /// Whether a node in the current generation advertises that protocol. Ignored + /// when `protocolHint` is nil. + let protocolSupported: Bool + /// Generation the request is pinned to. + let requestedGeneration: UInt32 + /// Current live bus generation. + let currentGeneration: UInt32 + /// Caller explicitly asked for a dry run (classify + validate, never execute). + let dryRun: Bool + /// The request targets a raw developer-tier escape hatch (e.g. OHCI write). + let requiresRawDeveloperTier: Bool + + init( + operationType: ASFWMCPOperationType, + addressSpace: ASFWMCPAddressSpace, + requestedGeneration: UInt32, + currentGeneration: UInt32, + protocolHint: String? = nil, + protocolSupported: Bool = true, + dryRun: Bool = false, + requiresRawDeveloperTier: Bool = false + ) { + self.operationType = operationType + self.addressSpace = addressSpace + self.requestedGeneration = requestedGeneration + self.currentGeneration = currentGeneration + self.protocolHint = protocolHint + self.protocolSupported = protocolSupported + self.dryRun = dryRun + self.requiresRawDeveloperTier = requiresRawDeveloperTier + } +} + +/// Stateless engine that maps a policy request to a structured decision. +struct ASFWMCPWritePolicyEngine { + let configuration: ASFWMCPRuntimeConfiguration + + func evaluate(_ request: ASFWMCPPolicyRequest) -> ASFWMCPPolicyDecision { + // Reads are never gated by write policy. + if request.operationType == .read { + return ASFWMCPPolicyDecision( + decision: .allowed, + reason: "Read operations are not gated by write policy." + ) + } + + // Mock mode never reaches a live driver: it validates write shape and + // classifies, but resolves any authorized write to a dry run. Live modes + // must clear the developer-write gate before classification proceeds. + if configuration.mode != .mock { + guard configuration.mode == .developerWriteEnabled else { + return ASFWMCPPolicyDecision( + decision: .requiresDeveloperMode, + reason: "Writes require developerWriteEnabled mode; current mode is \(configuration.mode.rawValue).", + errorCode: .requiresDeveloperMode, + requiredMode: .developerWriteEnabled + ) + } + guard configuration.canListDeveloperWriteTools else { + return ASFWMCPPolicyDecision( + decision: .requiresDeveloperMode, + reason: "developerWriteEnabled requires an available write policy and a passing Swift test gate before writes are permitted.", + errorCode: .testGateMissing, + requiredMode: .developerWriteEnabled + ) + } + if request.requiresRawDeveloperTier && configuration.canListRawDeveloperTools == false { + return ASFWMCPPolicyDecision( + decision: .denied, + reason: "This is a raw developer-tier escape hatch; enable the raw developer tier to proceed.", + errorCode: .policyDenied, + requiredMode: .developerWriteEnabled, + requiredCapability: "rawDeveloperTier" + ) + } + } + + // Classification applies in both mock and live-gated contexts. + guard request.requestedGeneration == request.currentGeneration else { + return ASFWMCPPolicyDecision( + decision: .staleGeneration, + reason: "Request generation \(request.requestedGeneration) does not match current bus generation \(request.currentGeneration); re-read topology and retry.", + errorCode: .staleGeneration + ) + } + + switch request.addressSpace { + case .configRom: + return ASFWMCPPolicyDecision( + decision: .unsupportedAddressSpace, + reason: "Config ROM space is architecturally read-only and cannot be written.", + errorCode: .unsupportedAddressSpace + ) + case .unknown: + return ASFWMCPPolicyDecision( + decision: .unsupportedAddressSpace, + reason: "Target address could not be classified into a writable space.", + errorCode: .unsupportedAddressSpace + ) + case .csrCore, .unitsSpace, .physicalMemory, .ohciController: + break + } + + if let hint = request.protocolHint, request.protocolSupported == false { + return ASFWMCPPolicyDecision( + decision: .unsupportedProtocol, + reason: "No connected node in the current generation supports the \(hint) protocol surface.", + errorCode: .unsupportedProtocol + ) + } + + // Mock mode or an explicit dry-run request: authorized shape, not executed. + if configuration.mode == .mock || request.dryRun { + return ASFWMCPPolicyDecision( + decision: .dryRunOnly, + reason: configuration.mode == .mock + ? "Mock mode validates the write shape but never reaches a driver." + : "Caller requested a dry run; the write was authorized but not executed.", + errorCode: .dryRunOnly + ) + } + + return ASFWMCPPolicyDecision( + decision: .allowed, + reason: "Write authorized under developerWriteEnabled policy." + ) + } +} + +extension ASFWMCPCore { + /// The write policy engine bound to this core's runtime configuration. + var writePolicyEngine: ASFWMCPWritePolicyEngine { + ASFWMCPWritePolicyEngine(configuration: configuration) + } + + /// Evaluate a write/CAS request against the current policy. + func evaluateWritePolicy(_ request: ASFWMCPPolicyRequest) -> ASFWMCPPolicyDecision { + writePolicyEngine.evaluate(request) + } +} diff --git a/ASFWTests/MCP/MCPWritePolicyTests.swift b/ASFWTests/MCP/MCPWritePolicyTests.swift new file mode 100644 index 00000000..3ff6b878 --- /dev/null +++ b/ASFWTests/MCP/MCPWritePolicyTests.swift @@ -0,0 +1,194 @@ +import Testing +@testable import ASFW + +struct MCPWritePolicyTests { + private func config( + _ mode: ASFWMCPRuntimeMode, + writePolicyAvailable: Bool = false, + swiftTestGatePassed: Bool = false, + rawDeveloperTierEnabled: Bool = false + ) -> ASFWMCPRuntimeConfiguration { + ASFWMCPRuntimeConfiguration( + mode: mode, + writePolicyAvailable: writePolicyAvailable, + swiftTestGatePassed: swiftTestGatePassed, + rawDeveloperTierEnabled: rawDeveloperTierEnabled + ) + } + + private var gateOpen: ASFWMCPRuntimeConfiguration { + config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: true) + } + + private func write( + space: ASFWMCPAddressSpace = .unitsSpace, + requested: UInt32 = 17, + current: UInt32 = 17, + protocolHint: String? = nil, + protocolSupported: Bool = true, + dryRun: Bool = false, + rawTier: Bool = false, + op: ASFWMCPOperationType = .write + ) -> ASFWMCPPolicyRequest { + ASFWMCPPolicyRequest( + operationType: op, + addressSpace: space, + requestedGeneration: requested, + currentGeneration: current, + protocolHint: protocolHint, + protocolSupported: protocolSupported, + dryRun: dryRun, + requiresRawDeveloperTier: rawTier + ) + } + + private func decide(_ cfg: ASFWMCPRuntimeConfiguration, _ req: ASFWMCPPolicyRequest) -> ASFWMCPPolicyDecision { + ASFWMCPWritePolicyEngine(configuration: cfg).evaluate(req) + } + + // MARK: Reads + + @Test func readsAreAllowedInEveryMode() { + for mode in [ASFWMCPRuntimeMode.mock, .readOnlyDeveloper, .developerWriteEnabled] { + let decision = decide(config(mode), write(op: .read)) + #expect(decision.decision == .allowed) + } + } + + // MARK: The allowed path + + @Test func writeAllowedWhenGateOpenAndRequestValid() { + let decision = decide(gateOpen, write()) + #expect(decision.decision == .allowed) + #expect(decision.reachesDriverWritePath) + } + + @Test func compareSwapIsGatedLikeAWrite() { + #expect(decide(gateOpen, write(op: .compareSwap)).decision == .allowed) + #expect(decide(config(.readOnlyDeveloper), write(op: .compareSwap)).decision == .requiresDeveloperMode) + } + + // MARK: Mode gating + + @Test func readOnlyModeRequiresDeveloperMode() { + let decision = decide(config(.readOnlyDeveloper), write()) + #expect(decision.decision == .requiresDeveloperMode) + #expect(decision.requiredMode == .developerWriteEnabled) + #expect(decision.reachesDriverWritePath == false) + } + + @Test func developerWriteWithClosedGateReportsTestGateMissing() { + let cfg = config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: false) + let decision = decide(cfg, write()) + #expect(decision.decision == .requiresDeveloperMode) + #expect(decision.errorCode == .testGateMissing) + #expect(decision.reachesDriverWritePath == false) + } + + // MARK: Dry run + + @Test func mockModeResolvesAuthorizedWriteToDryRun() { + let decision = decide(config(.mock), write()) + #expect(decision.decision == .dryRunOnly) + #expect(decision.reachesDriverWritePath == false) + } + + @Test func explicitDryRunInDeveloperModeIsDryRunOnly() { + let decision = decide(gateOpen, write(dryRun: true)) + #expect(decision.decision == .dryRunOnly) + #expect(decision.reachesDriverWritePath == false) + } + + // MARK: Generation + + @Test func staleGenerationIsRefused() { + let decision = decide(gateOpen, write(requested: 16, current: 17)) + #expect(decision.decision == .staleGeneration) + #expect(decision.errorCode == .staleGeneration) + #expect(decision.reachesDriverWritePath == false) + } + + @Test func mockModeStillClassifiesStaleGeneration() { + // Classification applies even in mock mode, so refusals surface there too. + let decision = decide(config(.mock), write(requested: 16, current: 17)) + #expect(decision.decision == .staleGeneration) + } + + // MARK: Address space + + @Test func configRomWriteIsUnsupported() { + let decision = decide(gateOpen, write(space: .configRom)) + #expect(decision.decision == .unsupportedAddressSpace) + #expect(decision.reachesDriverWritePath == false) + } + + @Test func unknownAddressSpaceIsUnsupported() { + #expect(decide(gateOpen, write(space: .unknown)).decision == .unsupportedAddressSpace) + } + + @Test func writableSpacesAreAccepted() { + for space in [ASFWMCPAddressSpace.csrCore, .unitsSpace, .physicalMemory, .ohciController] { + #expect(decide(gateOpen, write(space: space)).decision == .allowed) + } + } + + // MARK: Protocol + + @Test func unsupportedProtocolIsRefused() { + let decision = decide(gateOpen, write(protocolHint: "cmp", protocolSupported: false)) + #expect(decision.decision == .unsupportedProtocol) + #expect(decision.reachesDriverWritePath == false) + } + + // MARK: Raw developer tier + + @Test func rawTierWriteIsDeniedWithoutTierEnabled() { + let decision = decide(gateOpen, write(rawTier: true)) + #expect(decision.decision == .denied) + #expect(decision.requiredCapability == "rawDeveloperTier") + #expect(decision.reachesDriverWritePath == false) + } + + @Test func rawTierWriteAllowedWhenTierEnabled() { + let cfg = config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: true, rawDeveloperTierEnabled: true) + #expect(decide(cfg, write(rawTier: true)).decision == .allowed) + } + + // MARK: Coverage + safety invariant + + @Test func everyDecisionCategoryIsReachable() { + var seen = Set() + seen.insert(decide(gateOpen, write()).decision) // allowed + seen.insert(decide(gateOpen, write(rawTier: true)).decision) // denied + seen.insert(decide(config(.mock), write()).decision) // dryRunOnly + seen.insert(decide(config(.readOnlyDeveloper), write()).decision) // requiresDeveloperMode + seen.insert(decide(gateOpen, write(space: .configRom)).decision) // unsupportedAddressSpace + seen.insert(decide(gateOpen, write(requested: 16, current: 17)).decision) // staleGeneration + seen.insert(decide(gateOpen, write(protocolHint: "cmp", protocolSupported: false)).decision) // unsupportedProtocol + #expect(seen == Set(ASFWMCPWriteDecision.allCases)) + } + + @Test func onlyAllowedDecisionsReachTheDriverWritePath() { + let configs = [config(.disabled), config(.mock), config(.readOnlyDeveloper), gateOpen] + let requests = [ + write(), write(rawTier: true), write(dryRun: true), + write(space: .configRom), write(requested: 16, current: 17), + write(protocolHint: "cmp", protocolSupported: false) + ] + for cfg in configs { + for req in requests { + let decision = decide(cfg, req) + if decision.reachesDriverWritePath { + #expect(decision.decision == .allowed) + #expect(cfg.canListDeveloperWriteTools) + } + } + } + } + + @Test func coreEvaluatesWritePolicyWithItsConfiguration() async { + let driver = MockASFWDriverControl() + let core = ASFWMCPCore(configuration: config(.readOnlyDeveloper), driver: driver) + #expect(core.evaluateWritePolicy(write()).decision == .requiresDeveloperMode) + } +} From b99bfb0a77c6dce1aa4a0251b4589cf90d87789c Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 19:45:05 +0200 Subject: [PATCH 07/23] FW-78: implement address-space read/write/CAS core schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the foundational MCP schemas for FireWire async transactions: - ASFWMCPAddress: typed node/generation + 48-bit high/low address. - Request shapes for quadlet read, block read, quadlet write, block write, and quadlet compare-swap, with schema validation (4-byte alignment, S400 byte ceiling) returning the MCP error vocabulary (malformedRequest/payloadTooLarge). - ASFWMCPTransactionResult: stable result shape (ok/status/rCode/generation/ durationUsec/correlationId/payload/decoded/policy) per MCP_TOOL_TAXONOMY.md §5.3/§5.5, with policyRefusal/malformed factories. Includes the bridge to the FW-79 policy surface (address->space classification, transaction-kind->operation-type, and the forTransaction request builder), which lives with the schemas because it depends on the concrete transaction/address types; the policy engine itself classifies an already-resolved request. The result's `policy` field references the FW-79 decision, so this lands on top of FW-79. 14 Swift Testing cases cover offset composition, mutation classification, block validation bounds, result factories, and the policy bridge. Full Swift suite green. Co-Authored-By: Claude Opus 4.8 --- ASFW/MCP/ASFWMCPTransactionSchemas.swift | 287 ++++++++++++++++++ ASFWTests/MCP/MCPTransactionSchemaTests.swift | 112 +++++++ 2 files changed, 399 insertions(+) create mode 100644 ASFW/MCP/ASFWMCPTransactionSchemas.swift create mode 100644 ASFWTests/MCP/MCPTransactionSchemaTests.swift diff --git a/ASFW/MCP/ASFWMCPTransactionSchemas.swift b/ASFW/MCP/ASFWMCPTransactionSchemas.swift new file mode 100644 index 00000000..49f09adf --- /dev/null +++ b/ASFW/MCP/ASFWMCPTransactionSchemas.swift @@ -0,0 +1,287 @@ +import Foundation + +// FW-78: foundational MCP schemas for FireWire async transactions. +// +// These are pure value types modelling the request/result contract documented in +// documentation/MCP_TOOL_TAXONOMY.md §5.3 (async transactions) and §5.5 (CAS). +// They carry no live driver access. Mutating requests (write/CAS) must be cleared +// by the FW-79 write policy engine before any of them may reach the +// driver/user-client write path — see ASFWMCPWritePolicy.swift. +// +// Endianness: IEEE 1394 wire payloads are big-endian. Quadlet `value`/`expected`/ +// `swap` fields are expressed in host byte order and are serialized big-endian +// onto the bus by the live driver layer; block `payload`/`payload` bytes are +// already in bus (big-endian) order. + +/// A typed 48-bit FireWire target address plus the addressing context required to +/// issue an async transaction against it. +struct ASFWMCPAddress: Equatable { + /// 16-bit bus/node identifier (`bus_id` << 6 | `phy_id`) of the target node. + let nodeId: UInt32 + /// Bus generation the request is pinned to. A mismatch against the live + /// generation is a `staleGeneration` refusal (see FW-79). + let generation: UInt32 + /// High 16 bits of the 48-bit address. + let addressHigh: UInt16 + /// Low 32 bits of the 48-bit address. + let addressLow: UInt32 + + /// Full 48-bit address offset. + var offset48: UInt64 { + (UInt64(addressHigh) << 32) | UInt64(addressLow) + } +} + +/// The async transaction primitives ASFW models for MCP. +enum ASFWMCPTransactionKind: String, Equatable { + case readQuadlet + case readBlock + case writeQuadlet + case writeBlock + case compareSwap + + /// Whether the primitive mutates target state and therefore requires a write + /// policy decision before execution. + var isMutating: Bool { + switch self { + case .readQuadlet, .readBlock: + return false + case .writeQuadlet, .writeBlock, .compareSwap: + return true + } + } +} + +/// Conservative schema bounds for async transactions. +enum ASFWMCPTransactionLimits { + /// Async block payload ceiling used by schema validation. The IEEE 1394 + /// per-packet maximum scales with speed (2048 B @ S400, 4096 B @ S800); the + /// schema validates against the S400 ceiling unless a caller raises it. + static let maxBlockBytes: UInt32 = 2048 +} + +struct ASFWMCPReadQuadletRequest: Equatable { + let address: ASFWMCPAddress + + var kind: ASFWMCPTransactionKind { .readQuadlet } +} + +struct ASFWMCPReadBlockRequest: Equatable { + let address: ASFWMCPAddress + /// Requested length in bytes; must be a non-zero multiple of 4 within bounds. + let length: UInt32 + + var kind: ASFWMCPTransactionKind { .readBlock } + + /// Returns the schema violation for this request, or nil when well-formed. + var validationError: ASFWMCPErrorCode? { + if length > ASFWMCPTransactionLimits.maxBlockBytes { return .payloadTooLarge } + if length == 0 || length % 4 != 0 { return .malformedRequest } + return nil + } +} + +struct ASFWMCPWriteQuadletRequest: Equatable { + let address: ASFWMCPAddress + /// Quadlet value in host byte order. + let value: UInt32 + /// Issue a verifying read-back after the write when true. + let verifyReadback: Bool + + init(address: ASFWMCPAddress, value: UInt32, verifyReadback: Bool = false) { + self.address = address + self.value = value + self.verifyReadback = verifyReadback + } + + var kind: ASFWMCPTransactionKind { .writeQuadlet } +} + +struct ASFWMCPWriteBlockRequest: Equatable { + let address: ASFWMCPAddress + /// Payload bytes in bus (big-endian) order. + let payload: [UInt8] + /// Issue a verifying read-back after the write when true. + let verifyReadback: Bool + + init(address: ASFWMCPAddress, payload: [UInt8], verifyReadback: Bool = false) { + self.address = address + self.payload = payload + self.verifyReadback = verifyReadback + } + + var kind: ASFWMCPTransactionKind { .writeBlock } + + var validationError: ASFWMCPErrorCode? { + if payload.count > Int(ASFWMCPTransactionLimits.maxBlockBytes) { return .payloadTooLarge } + if payload.isEmpty || payload.count % 4 != 0 { return .malformedRequest } + return nil + } +} + +/// Quadlet lock / compare-and-swap (taxonomy §5.5 `asfw_cas_quadlet`). Also the +/// primitive behind IRM and CMP mutations. +struct ASFWMCPCompareSwapRequest: Equatable { + let address: ASFWMCPAddress + /// Expected current quadlet (host byte order). + let expected: UInt32 + /// Value to store if the comparison matches (host byte order). + let swap: UInt32 + + var kind: ASFWMCPTransactionKind { .compareSwap } +} + +/// Terminal status of a (possibly refused) async transaction. +enum ASFWMCPTransactionStatus: String, Equatable { + case ok + case timeout + case rcodeError + case busReset + case compareFailed + /// Refused by the FW-79 write policy before reaching the driver. + case denied + /// Policy-cleared shape that was intentionally not executed. + case dryRun + /// Failed schema validation. + case malformed +} + +/// Stable async transaction result shape (taxonomy §5.3). +struct ASFWMCPTransactionResult: Equatable { + let kind: ASFWMCPTransactionKind + let ok: Bool + let status: ASFWMCPTransactionStatus + /// FireWire response code mnemonic (e.g. "complete", "conflictError"), when a + /// response was received. + let rCode: String? + /// Bus generation the transaction actually executed in. + let generation: UInt32 + let durationUsec: UInt64? + let correlationId: String + /// Raw payload (bus order) for reads, or read-back bytes for verified writes. + let payload: [UInt8]? + /// Optional decoded view (e.g. parsed register fields) when ASFW can decode. + let decoded: ASFWMCPValue? + /// Policy decision attached to write-capable calls (FW-79). Nil for reads. + let policy: ASFWMCPPolicyDecision? + + init( + kind: ASFWMCPTransactionKind, + ok: Bool, + status: ASFWMCPTransactionStatus, + generation: UInt32, + correlationId: String, + rCode: String? = nil, + durationUsec: UInt64? = nil, + payload: [UInt8]? = nil, + decoded: ASFWMCPValue? = nil, + policy: ASFWMCPPolicyDecision? = nil + ) { + self.kind = kind + self.ok = ok + self.status = status + self.generation = generation + self.correlationId = correlationId + self.rCode = rCode + self.durationUsec = durationUsec + self.payload = payload + self.decoded = decoded + self.policy = policy + } +} + +extension ASFWMCPTransactionResult { + /// Build the result for a write/CAS that the policy engine did not authorize + /// for execution. Denials and dry runs share this shape; neither reaches the + /// driver write path. + static func policyRefusal( + kind: ASFWMCPTransactionKind, + correlationId: String, + generation: UInt32, + policy: ASFWMCPPolicyDecision + ) -> ASFWMCPTransactionResult { + ASFWMCPTransactionResult( + kind: kind, + ok: false, + status: policy.isDryRun ? .dryRun : .denied, + generation: generation, + correlationId: correlationId, + policy: policy + ) + } + + /// Build the result for a request rejected by schema validation. + static func malformed( + kind: ASFWMCPTransactionKind, + correlationId: String, + generation: UInt32 + ) -> ASFWMCPTransactionResult { + ASFWMCPTransactionResult( + kind: kind, + ok: false, + status: .malformed, + generation: generation, + correlationId: correlationId + ) + } +} + +// MARK: - Bridge to the FW-79 write policy surface +// +// These map FW-78 transaction types onto the FW-79 policy inputs. They live with +// the schemas because they depend on the concrete transaction/address types; the +// policy engine itself classifies an already-resolved ASFWMCPPolicyRequest. + +extension ASFWMCPAddressSpace { + /// Classify a node-address into a coarse policy space. OHCI/controller + /// registers are not node addresses, so `.ohciController` is supplied + /// explicitly by the caller rather than derived here. + static func classify(_ address: ASFWMCPAddress) -> ASFWMCPAddressSpace { + let offset = address.offset48 + let csrBase: UInt64 = 0xFFFF_F000_0000 + let configRomBase: UInt64 = 0xFFFF_F000_0400 + let unitsBase: UInt64 = 0xFFFF_F000_0800 + if offset >= unitsBase { return .unitsSpace } + if offset >= configRomBase { return .configRom } + if offset >= csrBase { return .csrCore } + return .physicalMemory + } +} + +extension ASFWMCPTransactionKind { + var operationType: ASFWMCPOperationType { + switch self { + case .readQuadlet, .readBlock: + return .read + case .writeQuadlet, .writeBlock: + return .write + case .compareSwap: + return .compareSwap + } + } +} + +extension ASFWMCPPolicyRequest { + /// Build a policy request for an async transaction against a node address, + /// classifying its address space automatically. + static func forTransaction( + kind: ASFWMCPTransactionKind, + address: ASFWMCPAddress, + currentGeneration: UInt32, + protocolHint: String? = nil, + protocolSupported: Bool = true, + dryRun: Bool = false, + requiresRawDeveloperTier: Bool = false + ) -> ASFWMCPPolicyRequest { + ASFWMCPPolicyRequest( + operationType: kind.operationType, + addressSpace: ASFWMCPAddressSpace.classify(address), + requestedGeneration: address.generation, + currentGeneration: currentGeneration, + protocolHint: protocolHint, + protocolSupported: protocolSupported, + dryRun: dryRun, + requiresRawDeveloperTier: requiresRawDeveloperTier + ) + } +} diff --git a/ASFWTests/MCP/MCPTransactionSchemaTests.swift b/ASFWTests/MCP/MCPTransactionSchemaTests.swift new file mode 100644 index 00000000..a19a31ba --- /dev/null +++ b/ASFWTests/MCP/MCPTransactionSchemaTests.swift @@ -0,0 +1,112 @@ +import Testing +@testable import ASFW + +struct MCPTransactionSchemaTests { + private func address( + high: UInt16 = 0xFFFF, + low: UInt32 = 0xF0000800, + generation: UInt32 = 17 + ) -> ASFWMCPAddress { + ASFWMCPAddress(nodeId: 2, generation: generation, addressHigh: high, addressLow: low) + } + + @Test func addressComposes48BitOffset() { + let addr = ASFWMCPAddress(nodeId: 2, generation: 17, addressHigh: 0xFFFF, addressLow: 0xF0000400) + #expect(addr.offset48 == 0xFFFFF0000400) + } + + @Test func mutationClassificationMatchesPrimitive() { + #expect(ASFWMCPTransactionKind.readQuadlet.isMutating == false) + #expect(ASFWMCPTransactionKind.readBlock.isMutating == false) + #expect(ASFWMCPTransactionKind.writeQuadlet.isMutating) + #expect(ASFWMCPTransactionKind.writeBlock.isMutating) + #expect(ASFWMCPTransactionKind.compareSwap.isMutating) + } + + @Test func readBlockValidationAcceptsAlignedLength() { + #expect(ASFWMCPReadBlockRequest(address: address(), length: 4).validationError == nil) + #expect(ASFWMCPReadBlockRequest(address: address(), length: 2048).validationError == nil) + } + + @Test func readBlockValidationRejectsZeroAndUnaligned() { + #expect(ASFWMCPReadBlockRequest(address: address(), length: 0).validationError == .malformedRequest) + #expect(ASFWMCPReadBlockRequest(address: address(), length: 6).validationError == .malformedRequest) + } + + @Test func readBlockValidationRejectsOversizeLength() { + #expect(ASFWMCPReadBlockRequest(address: address(), length: 4096).validationError == .payloadTooLarge) + } + + @Test func writeBlockValidationFollowsSameRules() { + #expect(ASFWMCPWriteBlockRequest(address: address(), payload: [0, 0, 0, 0]).validationError == nil) + #expect(ASFWMCPWriteBlockRequest(address: address(), payload: []).validationError == .malformedRequest) + #expect(ASFWMCPWriteBlockRequest(address: address(), payload: [1, 2, 3]).validationError == .malformedRequest) + let oversize = [UInt8](repeating: 0, count: 4096) + #expect(ASFWMCPWriteBlockRequest(address: address(), payload: oversize).validationError == .payloadTooLarge) + } + + @Test func writeRequestsDefaultToNoReadback() { + #expect(ASFWMCPWriteQuadletRequest(address: address(), value: 0x1234).verifyReadback == false) + #expect(ASFWMCPWriteBlockRequest(address: address(), payload: [0, 0, 0, 0]).verifyReadback == false) + } + + @Test func policyRefusalResultReflectsDeniedDecision() { + let denied = ASFWMCPPolicyDecision(decision: .denied, reason: "nope", errorCode: .policyDenied) + let result = ASFWMCPTransactionResult.policyRefusal( + kind: .writeQuadlet, + correlationId: "c1", + generation: 17, + policy: denied + ) + #expect(result.ok == false) + #expect(result.status == .denied) + #expect(result.payload == nil) + #expect(result.policy?.decision == .denied) + } + + @Test func policyRefusalResultReflectsDryRunDecision() { + let dryRun = ASFWMCPPolicyDecision(decision: .dryRunOnly, reason: "dry", errorCode: .dryRunOnly) + let result = ASFWMCPTransactionResult.policyRefusal( + kind: .writeBlock, + correlationId: "c2", + generation: 17, + policy: dryRun + ) + #expect(result.ok == false) + #expect(result.status == .dryRun) + } + + @Test func malformedResultCarriesNoPolicy() { + let result = ASFWMCPTransactionResult.malformed(kind: .readBlock, correlationId: "c3", generation: 17) + #expect(result.ok == false) + #expect(result.status == .malformed) + #expect(result.policy == nil) + } + + // MARK: Bridge to the policy surface + + @Test func addressClassificationMapsKnownBlocks() { + func addr(_ low: UInt32) -> ASFWMCPAddress { + ASFWMCPAddress(nodeId: 2, generation: 17, addressHigh: 0xFFFF, addressLow: low) + } + #expect(ASFWMCPAddressSpace.classify(addr(0xF0000000)) == .csrCore) + #expect(ASFWMCPAddressSpace.classify(addr(0xF0000400)) == .configRom) + #expect(ASFWMCPAddressSpace.classify(addr(0xF0000800)) == .unitsSpace) + #expect(ASFWMCPAddressSpace.classify(ASFWMCPAddress(nodeId: 2, generation: 17, addressHigh: 0, addressLow: 0x1000)) == .physicalMemory) + } + + @Test func transactionKindMapsToOperationType() { + #expect(ASFWMCPTransactionKind.readBlock.operationType == .read) + #expect(ASFWMCPTransactionKind.writeQuadlet.operationType == .write) + #expect(ASFWMCPTransactionKind.compareSwap.operationType == .compareSwap) + } + + @Test func transactionRequestBuilderClassifiesAndPinsGeneration() { + let addr = ASFWMCPAddress(nodeId: 2, generation: 16, addressHigh: 0xFFFF, addressLow: 0xF0000800) + let req = ASFWMCPPolicyRequest.forTransaction(kind: .writeQuadlet, address: addr, currentGeneration: 17) + #expect(req.addressSpace == .unitsSpace) + #expect(req.operationType == .write) + #expect(req.requestedGeneration == 16) + #expect(req.currentGeneration == 17) + } +} From 218d04cd7bf57cface2566faf2a5d0e75ed355ed Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 19:51:31 +0200 Subject: [PATCH 08/23] refactor(mcp): split tool/resource catalog into per-surface slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the flat ASFWMCPCore.toolCatalog / resourceCatalog literals into ASFWMCPToolCatalog and ASFWMCPResourceCatalog aggregators, with one named static slice per protocol surface (core, bus/topology, config ROM, async transactions, register, IRM/CAS, AV/C, CMP, SBP-2, DICE/TCAT). `all` is the single place that lists every group and is stable across the fixed taxonomy. Pure refactor — the aggregated set is unchanged and the full Swift suite stays green. This is the enabler for the FW-80..85 fan-out: a surface ticket can own (and later extract to its own file) just its slice instead of all editing one shared array, removing the file-level collision between otherwise-independent surfaces. Co-Authored-By: Claude Opus 4.8 --- ASFW/MCP/ASFWMCPCore.swift | 43 +-------------- ASFW/MCP/ASFWMCPResourceCatalog.swift | 26 ++++++++++ ASFW/MCP/ASFWMCPToolCatalog.swift | 75 +++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 41 deletions(-) create mode 100644 ASFW/MCP/ASFWMCPResourceCatalog.swift create mode 100644 ASFW/MCP/ASFWMCPToolCatalog.swift diff --git a/ASFW/MCP/ASFWMCPCore.swift b/ASFW/MCP/ASFWMCPCore.swift index 71f8113d..a8a8560a 100644 --- a/ASFW/MCP/ASFWMCPCore.swift +++ b/ASFW/MCP/ASFWMCPCore.swift @@ -9,7 +9,7 @@ struct ASFWMCPCore { let nodes = await driver.listNodes() let protocolHints = Set(nodes.flatMap(\.protocolHints)) - let allTools = Self.toolCatalog + let allTools = ASFWMCPToolCatalog.all return allTools.filter { tool in guard tool.requiredProtocolHints.isEmpty || tool.requiredProtocolHints.contains(where: { protocolHints.contains($0) }) else { @@ -33,7 +33,7 @@ struct ASFWMCPCore { func listResources() async -> [ASFWMCPResourceDefinition] { guard configuration.mode != .disabled else { return [] } - return Self.resourceCatalog + return ASFWMCPResourceCatalog.all } func readResource(uri: String) async -> ASFWMCPResourceEnvelope { @@ -242,42 +242,3 @@ struct ASFWMCPCore { } } -extension ASFWMCPCore { - static var toolCatalog: [ASFWMCPToolDefinition] { - [ - ASFWMCPToolDefinition(name: "asfw_get_capabilities", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Summarize MCP runtime mode and available dynamic groups."), - ASFWMCPToolDefinition(name: "asfw_get_policy", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Report current MCP policy and write-gate status."), - ASFWMCPToolDefinition(name: "asfw_list_nodes", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "List current bus nodes and protocol hints."), - ASFWMCPToolDefinition(name: "asfw_get_node_summary", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Return one compact node summary."), - ASFWMCPToolDefinition(name: "asfw_explain_capability", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Explain why a capability is available, hidden, or policy-gated."), - - ASFWMCPToolDefinition(name: "asfw_get_controller_state", group: "bus_topology", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return controller state and bus health."), - ASFWMCPToolDefinition(name: "asfw_get_topology", group: "bus_topology", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return current topology snapshot."), - ASFWMCPToolDefinition(name: "asfw_get_config_rom", group: "config_rom", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return cached Config ROM summary."), - ASFWMCPToolDefinition(name: "asfw_read_quadlet", group: "async_transactions", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Submit an async quadlet read."), - ASFWMCPToolDefinition(name: "asfw_read_block", group: "async_transactions", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Submit an async block read."), - ASFWMCPToolDefinition(name: "asfw_read_device_register", group: "register_access", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a device register/address-space value."), - ASFWMCPToolDefinition(name: "asfw_dice_read_register", group: "dice_tcat", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a DICE/TCAT register.", requiredProtocolHints: ["dice_tcat"]), - ASFWMCPToolDefinition(name: "asfw_irm_get_state", group: "irm_cas", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return IRM and bus manager state."), - ASFWMCPToolDefinition(name: "asfw_avc_list_units", group: "avc_fcp", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List AV/C units.", requiredProtocolHints: ["avc"]), - ASFWMCPToolDefinition(name: "asfw_cmp_read_pcr", group: "cmp", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read and decode a CMP plug control register.", requiredProtocolHints: ["cmp"]), - ASFWMCPToolDefinition(name: "asfw_sbp2_list_units", group: "sbp2", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List SBP-2 units.", requiredProtocolHints: ["sbp2"]), - - ASFWMCPToolDefinition(name: "asfw_write_quadlet", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated async quadlet write."), - ASFWMCPToolDefinition(name: "asfw_write_block", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated async block write."), - ASFWMCPToolDefinition(name: "asfw_compare_swap", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated compare-swap transaction."), - ASFWMCPToolDefinition(name: "asfw_dice_write_register", group: "dice_tcat", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated DICE/TCAT register write.", requiredProtocolHints: ["dice_tcat"]), - ASFWMCPToolDefinition(name: "asfw_cmp_write_pcr", group: "cmp", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated CMP PCR write.", requiredProtocolHints: ["cmp"]), - ASFWMCPToolDefinition(name: "asfw_write_ohci_register_dev", group: "register_access", visibility: .rawDeveloper, readOnly: false, idempotent: false, summary: "Raw developer-tier OHCI register write.") - ] - } - - static var resourceCatalog: [ASFWMCPResourceDefinition] { - [ - ASFWMCPResourceDefinition(uri: "asfw://telemetry/snapshot", schema: "asfw.telemetry.snapshot.v1", summary: "Compact cross-system telemetry overview."), - ASFWMCPResourceDefinition(uri: "asfw://controller/state", schema: "asfw.telemetry.controller_state.v1", summary: "Controller, link, and bus health."), - ASFWMCPResourceDefinition(uri: "asfw://nodes", schema: "asfw.telemetry.nodes.v1", summary: "Current node summaries."), - ASFWMCPResourceDefinition(uri: "asfw://transactions/recent", schema: "asfw.telemetry.transactions_recent.v1", summary: "Bounded recent async transaction events.") - ] - } -} diff --git a/ASFW/MCP/ASFWMCPResourceCatalog.swift b/ASFW/MCP/ASFWMCPResourceCatalog.swift new file mode 100644 index 00000000..96953da6 --- /dev/null +++ b/ASFW/MCP/ASFWMCPResourceCatalog.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Per-surface MCP resource definitions, aggregated into the full catalog. +/// +/// Mirrors ASFWMCPToolCatalog: each surface owns its resource slice so surfaces +/// can be added in independent files. `all` lists every group. +enum ASFWMCPResourceCatalog { + static var all: [ASFWMCPResourceDefinition] { + coreResources + + busTopologyResources + + telemetryResources + } + + static let coreResources: [ASFWMCPResourceDefinition] = [ + ASFWMCPResourceDefinition(uri: "asfw://nodes", schema: "asfw.telemetry.nodes.v1", summary: "Current node summaries.") + ] + + static let busTopologyResources: [ASFWMCPResourceDefinition] = [ + ASFWMCPResourceDefinition(uri: "asfw://controller/state", schema: "asfw.telemetry.controller_state.v1", summary: "Controller, link, and bus health.") + ] + + static let telemetryResources: [ASFWMCPResourceDefinition] = [ + ASFWMCPResourceDefinition(uri: "asfw://telemetry/snapshot", schema: "asfw.telemetry.snapshot.v1", summary: "Compact cross-system telemetry overview."), + ASFWMCPResourceDefinition(uri: "asfw://transactions/recent", schema: "asfw.telemetry.transactions_recent.v1", summary: "Bounded recent async transaction events.") + ] +} diff --git a/ASFW/MCP/ASFWMCPToolCatalog.swift b/ASFW/MCP/ASFWMCPToolCatalog.swift new file mode 100644 index 00000000..38bc5d5e --- /dev/null +++ b/ASFW/MCP/ASFWMCPToolCatalog.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Per-surface MCP tool definitions, aggregated into the full catalog. +/// +/// Each protocol surface owns its own slice so surfaces can be added/expanded in +/// independent files and commits without all editing one shared array. A surface +/// ticket extracts its slice into its own file as an `extension ASFWMCPToolCatalog` +/// (see ASFWMCPRegisterTools.swift for the reference pattern). `all` is the only +/// place that lists every group, and it is stable across the fixed taxonomy. +enum ASFWMCPToolCatalog { + static var all: [ASFWMCPToolDefinition] { + coreTools + + busTopologyTools + + configRomTools + + asyncTransactionTools + + registerTools + + irmCasTools + + avcFcpTools + + cmpTools + + sbp2Tools + + diceTcatTools + } + + static let coreTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_get_capabilities", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Summarize MCP runtime mode and available dynamic groups."), + ASFWMCPToolDefinition(name: "asfw_get_policy", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Report current MCP policy and write-gate status."), + ASFWMCPToolDefinition(name: "asfw_list_nodes", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "List current bus nodes and protocol hints."), + ASFWMCPToolDefinition(name: "asfw_get_node_summary", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Return one compact node summary."), + ASFWMCPToolDefinition(name: "asfw_explain_capability", group: "core", visibility: .always, readOnly: true, idempotent: true, summary: "Explain why a capability is available, hidden, or policy-gated.") + ] + + static let busTopologyTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_get_controller_state", group: "bus_topology", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return controller state and bus health."), + ASFWMCPToolDefinition(name: "asfw_get_topology", group: "bus_topology", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return current topology snapshot.") + ] + + static let configRomTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_get_config_rom", group: "config_rom", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return cached Config ROM summary.") + ] + + static let asyncTransactionTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_read_quadlet", group: "async_transactions", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Submit an async quadlet read."), + ASFWMCPToolDefinition(name: "asfw_read_block", group: "async_transactions", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Submit an async block read."), + ASFWMCPToolDefinition(name: "asfw_write_quadlet", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated async quadlet write."), + ASFWMCPToolDefinition(name: "asfw_write_block", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated async block write."), + ASFWMCPToolDefinition(name: "asfw_compare_swap", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated compare-swap transaction.") + ] + + static let registerTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_read_device_register", group: "register_access", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a device register/address-space value."), + ASFWMCPToolDefinition(name: "asfw_write_ohci_register_dev", group: "register_access", visibility: .rawDeveloper, readOnly: false, idempotent: false, summary: "Raw developer-tier OHCI register write.") + ] + + static let irmCasTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_irm_get_state", group: "irm_cas", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return IRM and bus manager state.") + ] + + static let avcFcpTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_avc_list_units", group: "avc_fcp", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List AV/C units.", requiredProtocolHints: ["avc"]) + ] + + static let cmpTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_cmp_read_pcr", group: "cmp", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read and decode a CMP plug control register.", requiredProtocolHints: ["cmp"]), + ASFWMCPToolDefinition(name: "asfw_cmp_write_pcr", group: "cmp", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated CMP PCR write.", requiredProtocolHints: ["cmp"]) + ] + + static let sbp2Tools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_sbp2_list_units", group: "sbp2", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List SBP-2 units.", requiredProtocolHints: ["sbp2"]) + ] + + static let diceTcatTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_dice_read_register", group: "dice_tcat", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a DICE/TCAT register.", requiredProtocolHints: ["dice_tcat"]), + ASFWMCPToolDefinition(name: "asfw_dice_write_register", group: "dice_tcat", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated DICE/TCAT register write.", requiredProtocolHints: ["dice_tcat"]) + ] +} From cd30667b8d28c8ffb095691358940a9e808b1160 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 19:53:26 +0200 Subject: [PATCH 09/23] FW-80: expose register access tools with policy-aware writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish the per-surface file pattern on the register group: ASFWMCPToolCatalog hands ownership of `registerTools` to ASFWMCPRegisterTools.swift, which owns the catalog slice, the request schemas, and the bridge to the FW-79 write policy. Tools (MCP_TOOL_TAXONOMY.md §5.4): - reads: asfw_read_device_register, asfw_read_device_register_block, asfw_read_ohci_register, asfw_snapshot_ohci_registers - device writes (developer-write, policy-gated): asfw_write_device_register, asfw_write_device_register_block - asfw_write_ohci_register_dev (raw developer tier) Device registers are node addresses reached over FW-78 async transactions; their writes are policy-gated via forTransaction. OHCI/controller registers are host-side (`.ohciController`), read-only for diagnostics with bounded snapshots, and writable only through the raw developer tier (generation-neutral). Schema validation reuses the FW-78 block bounds; OHCI snapshots/offsets are bounded and quadlet-aligned. 14 Swift Testing cases cover the catalog surface, mode-based discovery (reads visible, device writes gated, OHCI write raw-tier only), schema validation, and policy gating (requiresDeveloperMode / staleGeneration / denied without raw tier / allowed). Full Swift suite green. Co-Authored-By: Claude Opus 4.8 --- ASFW/MCP/ASFWMCPRegisterTools.swift | 141 ++++++++++++++++++++++ ASFW/MCP/ASFWMCPToolCatalog.swift | 6 +- ASFWTests/MCP/MCPRegisterToolsTests.swift | 140 +++++++++++++++++++++ 3 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 ASFW/MCP/ASFWMCPRegisterTools.swift create mode 100644 ASFWTests/MCP/MCPRegisterToolsTests.swift diff --git a/ASFW/MCP/ASFWMCPRegisterTools.swift b/ASFW/MCP/ASFWMCPRegisterTools.swift new file mode 100644 index 00000000..8a92c15d --- /dev/null +++ b/ASFW/MCP/ASFWMCPRegisterTools.swift @@ -0,0 +1,141 @@ +import Foundation + +// FW-80: register access tools with policy-aware writes. +// +// Reference pattern for the FW-81..85 fan-out: a protocol surface owns its tool +// catalog slice (here as an `extension ASFWMCPToolCatalog`), its request schemas, +// and its bridge to the FW-79 write policy — all in one file, so surfaces don't +// collide. Tool/visibility taxonomy: MCP_TOOL_TAXONOMY.md §5.4. +// +// Device registers are node addresses reached over async transactions (FW-78); +// their writes are policy-gated like any async write. OHCI/controller registers +// are host-side, classified as `.ohciController`, read-only for diagnostics, and +// writable only through the raw developer tier. + +extension ASFWMCPToolCatalog { + static let registerTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_read_device_register", group: "register_access", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a device register/address-space value."), + ASFWMCPToolDefinition(name: "asfw_read_device_register_block", group: "register_access", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a bounded block from a device register/address space."), + ASFWMCPToolDefinition(name: "asfw_read_ohci_register", group: "register_access", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Read a host OHCI/controller register for diagnostics."), + ASFWMCPToolDefinition(name: "asfw_snapshot_ohci_registers", group: "register_access", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return a bounded snapshot of selected OHCI registers."), + ASFWMCPToolDefinition(name: "asfw_write_device_register", group: "register_access", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated device register write."), + ASFWMCPToolDefinition(name: "asfw_write_device_register_block", group: "register_access", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated device register block write."), + ASFWMCPToolDefinition(name: "asfw_write_ohci_register_dev", group: "register_access", visibility: .rawDeveloper, readOnly: false, idempotent: false, summary: "Raw developer-tier OHCI register write.") + ] +} + +// MARK: - Device register requests (node address space) + +struct ASFWMCPDeviceRegisterReadRequest: Equatable { + let address: ASFWMCPAddress + /// Request decoded register fields alongside the raw value when ASFW can. + let decode: Bool + + init(address: ASFWMCPAddress, decode: Bool = false) { + self.address = address + self.decode = decode + } +} + +struct ASFWMCPDeviceRegisterBlockReadRequest: Equatable { + let address: ASFWMCPAddress + let length: UInt32 + let decode: Bool + + init(address: ASFWMCPAddress, length: UInt32, decode: Bool = false) { + self.address = address + self.length = length + self.decode = decode + } + + /// Reuses the FW-78 block-read schema bounds. + var validationError: ASFWMCPErrorCode? { + ASFWMCPReadBlockRequest(address: address, length: length).validationError + } +} + +struct ASFWMCPDeviceRegisterWriteRequest: Equatable { + let address: ASFWMCPAddress + let value: UInt32 + let verifyReadback: Bool + + init(address: ASFWMCPAddress, value: UInt32, verifyReadback: Bool = false) { + self.address = address + self.value = value + self.verifyReadback = verifyReadback + } + + func policyRequest(currentGeneration: UInt32, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + .forTransaction(kind: .writeQuadlet, address: address, currentGeneration: currentGeneration, dryRun: dryRun) + } +} + +struct ASFWMCPDeviceRegisterBlockWriteRequest: Equatable { + let address: ASFWMCPAddress + let payload: [UInt8] + let verifyReadback: Bool + + init(address: ASFWMCPAddress, payload: [UInt8], verifyReadback: Bool = false) { + self.address = address + self.payload = payload + self.verifyReadback = verifyReadback + } + + var validationError: ASFWMCPErrorCode? { + ASFWMCPWriteBlockRequest(address: address, payload: payload).validationError + } + + func policyRequest(currentGeneration: UInt32, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + .forTransaction(kind: .writeBlock, address: address, currentGeneration: currentGeneration, dryRun: dryRun) + } +} + +// MARK: - OHCI/controller registers (host-side) + +struct ASFWMCPOhciRegisterReadRequest: Equatable { + /// Controller register offset (quadlet-aligned). + let offset: UInt32 + let decode: Bool + + init(offset: UInt32, decode: Bool = false) { + self.offset = offset + self.decode = decode + } + + var validationError: ASFWMCPErrorCode? { + offset % 4 == 0 ? nil : .malformedRequest + } +} + +struct ASFWMCPOhciRegisterSnapshotRequest: Equatable { + let offsets: [UInt32] + + /// Snapshot is bounded so an agent cannot dump the whole register file. + static let maxOffsets = 64 + + var validationError: ASFWMCPErrorCode? { + if offsets.isEmpty { return .malformedRequest } + if offsets.count > Self.maxOffsets { return .payloadTooLarge } + if offsets.contains(where: { $0 % 4 != 0 }) { return .malformedRequest } + return nil + } +} + +struct ASFWMCPOhciRegisterWriteRequest: Equatable { + let offset: UInt32 + let value: UInt32 + + /// OHCI writes are host-side raw developer-tier escape hatches: there is no + /// bus generation to pin, so the request is generation-neutral and always + /// flagged as requiring the raw developer tier. + func policyRequest(currentGeneration: UInt32, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + ASFWMCPPolicyRequest( + operationType: .write, + addressSpace: .ohciController, + requestedGeneration: currentGeneration, + currentGeneration: currentGeneration, + dryRun: dryRun, + requiresRawDeveloperTier: true + ) + } +} diff --git a/ASFW/MCP/ASFWMCPToolCatalog.swift b/ASFW/MCP/ASFWMCPToolCatalog.swift index 38bc5d5e..c99e2f8f 100644 --- a/ASFW/MCP/ASFWMCPToolCatalog.swift +++ b/ASFW/MCP/ASFWMCPToolCatalog.swift @@ -46,10 +46,8 @@ enum ASFWMCPToolCatalog { ASFWMCPToolDefinition(name: "asfw_compare_swap", group: "async_transactions", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated compare-swap transaction.") ] - static let registerTools: [ASFWMCPToolDefinition] = [ - ASFWMCPToolDefinition(name: "asfw_read_device_register", group: "register_access", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a device register/address-space value."), - ASFWMCPToolDefinition(name: "asfw_write_ohci_register_dev", group: "register_access", visibility: .rawDeveloper, readOnly: false, idempotent: false, summary: "Raw developer-tier OHCI register write.") - ] + // `registerTools` is owned by ASFWMCPRegisterTools.swift (FW-80) — the + // reference for how a surface ticket extracts its slice into its own file. static let irmCasTools: [ASFWMCPToolDefinition] = [ ASFWMCPToolDefinition(name: "asfw_irm_get_state", group: "irm_cas", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return IRM and bus manager state.") diff --git a/ASFWTests/MCP/MCPRegisterToolsTests.swift b/ASFWTests/MCP/MCPRegisterToolsTests.swift new file mode 100644 index 00000000..939ae367 --- /dev/null +++ b/ASFWTests/MCP/MCPRegisterToolsTests.swift @@ -0,0 +1,140 @@ +import Testing +@testable import ASFW + +struct MCPRegisterToolsTests { + private func config( + _ mode: ASFWMCPRuntimeMode, + writePolicyAvailable: Bool = false, + swiftTestGatePassed: Bool = false, + rawDeveloperTierEnabled: Bool = false + ) -> ASFWMCPRuntimeConfiguration { + ASFWMCPRuntimeConfiguration( + mode: mode, + writePolicyAvailable: writePolicyAvailable, + swiftTestGatePassed: swiftTestGatePassed, + rawDeveloperTierEnabled: rawDeveloperTierEnabled + ) + } + + private var gateOpen: ASFWMCPRuntimeConfiguration { + config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: true) + } + + private func deviceAddress(low: UInt32 = 0xF0000800) -> ASFWMCPAddress { + ASFWMCPAddress(nodeId: 2, generation: 17, addressHigh: 0xFFFF, addressLow: low) + } + + private func toolNames(_ cfg: ASFWMCPRuntimeConfiguration) async -> Set { + let core = ASFWMCPCore(configuration: cfg, driver: MockASFWDriverControl()) + return await Set(core.listTools().map(\.name)) + } + + // MARK: Catalog + + @Test func registerCatalogExposesFullSurface() { + let names = Set(ASFWMCPToolCatalog.registerTools.map(\.name)) + #expect(names == [ + "asfw_read_device_register", + "asfw_read_device_register_block", + "asfw_read_ohci_register", + "asfw_snapshot_ohci_registers", + "asfw_write_device_register", + "asfw_write_device_register_block", + "asfw_write_ohci_register_dev" + ]) + } + + @Test func registerToolsAreReachableThroughTheAggregator() { + let all = Set(ASFWMCPToolCatalog.all.map(\.name)) + #expect(all.isSuperset(of: ["asfw_read_device_register", "asfw_write_device_register"])) + } + + @Test func ohciWriteIsRawDeveloperTier() throws { + let ohciWrite = try #require(ASFWMCPToolCatalog.registerTools.first { $0.name == "asfw_write_ohci_register_dev" }) + #expect(ohciWrite.visibility == .rawDeveloper) + let deviceWrite = try #require(ASFWMCPToolCatalog.registerTools.first { $0.name == "asfw_write_device_register" }) + #expect(deviceWrite.visibility == .developerWrite) + } + + // MARK: Discovery + + @Test func readOnlyModeListsRegisterReadsAndHidesWrites() async { + let names = await toolNames(config(.readOnlyDeveloper)) + #expect(names.isSuperset(of: [ + "asfw_read_device_register", + "asfw_read_device_register_block", + "asfw_read_ohci_register", + "asfw_snapshot_ohci_registers" + ])) + #expect(names.contains("asfw_write_device_register") == false) + #expect(names.contains("asfw_write_device_register_block") == false) + #expect(names.contains("asfw_write_ohci_register_dev") == false) + } + + @Test func developerWriteListsDeviceWritesButNotRawOhci() async { + let names = await toolNames(gateOpen) + #expect(names.contains("asfw_write_device_register")) + #expect(names.contains("asfw_write_device_register_block")) + #expect(names.contains("asfw_write_ohci_register_dev") == false) + } + + @Test func rawDeveloperTierListsOhciWrite() async { + let names = await toolNames(config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: true, rawDeveloperTierEnabled: true)) + #expect(names.contains("asfw_write_ohci_register_dev")) + } + + // MARK: Schema validation + + @Test func deviceBlockReadReusesTransactionBounds() { + #expect(ASFWMCPDeviceRegisterBlockReadRequest(address: deviceAddress(), length: 4).validationError == nil) + #expect(ASFWMCPDeviceRegisterBlockReadRequest(address: deviceAddress(), length: 6).validationError == .malformedRequest) + #expect(ASFWMCPDeviceRegisterBlockReadRequest(address: deviceAddress(), length: 4096).validationError == .payloadTooLarge) + } + + @Test func ohciSnapshotIsBoundedAndAligned() { + #expect(ASFWMCPOhciRegisterSnapshotRequest(offsets: [0, 4, 8]).validationError == nil) + #expect(ASFWMCPOhciRegisterSnapshotRequest(offsets: []).validationError == .malformedRequest) + #expect(ASFWMCPOhciRegisterSnapshotRequest(offsets: [2]).validationError == .malformedRequest) + let tooMany = (0..<65).map { UInt32($0 * 4) } + #expect(ASFWMCPOhciRegisterSnapshotRequest(offsets: tooMany).validationError == .payloadTooLarge) + } + + @Test func ohciReadRejectsUnalignedOffset() { + #expect(ASFWMCPOhciRegisterReadRequest(offset: 8).validationError == nil) + #expect(ASFWMCPOhciRegisterReadRequest(offset: 3).validationError == .malformedRequest) + } + + // MARK: Policy gating + + @Test func deviceRegisterWriteIsPolicyGated() { + let request = ASFWMCPDeviceRegisterWriteRequest(address: deviceAddress(), value: 0x1234) + let readOnly = ASFWMCPWritePolicyEngine(configuration: config(.readOnlyDeveloper)) + .evaluate(request.policyRequest(currentGeneration: 17)) + #expect(readOnly.decision == .requiresDeveloperMode) + #expect(readOnly.reachesDriverWritePath == false) + + let open = ASFWMCPWritePolicyEngine(configuration: gateOpen) + .evaluate(request.policyRequest(currentGeneration: 17)) + #expect(open.decision == .allowed) + } + + @Test func deviceRegisterWriteRefusesStaleGeneration() { + let request = ASFWMCPDeviceRegisterWriteRequest(address: deviceAddress(), value: 0x1234) + let decision = ASFWMCPWritePolicyEngine(configuration: gateOpen) + .evaluate(request.policyRequest(currentGeneration: 18)) + #expect(decision.decision == .staleGeneration) + } + + @Test func ohciRegisterWriteRequiresRawTier() { + let request = ASFWMCPOhciRegisterWriteRequest(offset: 0x100, value: 0xABCD) + let withoutTier = ASFWMCPWritePolicyEngine(configuration: gateOpen) + .evaluate(request.policyRequest(currentGeneration: 17)) + #expect(withoutTier.decision == .denied) + #expect(withoutTier.requiredCapability == "rawDeveloperTier") + + let withTier = ASFWMCPWritePolicyEngine( + configuration: config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: true, rawDeveloperTierEnabled: true) + ).evaluate(request.policyRequest(currentGeneration: 17)) + #expect(withTier.decision == .allowed) + } +} From b279ec563164405795a4ff14e05ae6e3d4f5efdb Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 19:58:31 +0200 Subject: [PATCH 10/23] FW-81: expose IRM and CAS MCP tools Own the irm_cas slice in ASFWMCPIrmCasTools.swift (extracted from the aggregator): - reads: asfw_irm_get_state, asfw_irm_get_bandwidth, asfw_irm_get_channels, asfw_irm_list_allocations (bus-global, listed without a protocol hint) - mutations (developer-write, policy-gated): asfw_cas_quadlet, asfw_irm_allocate_channel/free_channel, asfw_irm_allocate_bandwidth/free_bandwidth CAS reuses the FW-78 ASFWMCPCompareSwapRequest and adds a policyRequest bridge through forTransaction, so CAS tools share the core transaction schema. IRM channel/bandwidth allocations are compare-swaps against the IRM node's CSR-core resource registers and classify as .csrCore/.compareSwap, with range/ceiling validation (channel <= 63, bandwidth <= 0x1333). Resources (asfw://irm/*) deferred until readResource serves them with live data. 9 tool entries; tests cover catalog surface, mode-based discovery, request validation, and policy gating (requiresDeveloperMode / allowed / staleGeneration). Full Swift suite green. Co-Authored-By: Claude Opus 4.8 --- ASFW/MCP/ASFWMCPIrmCasTools.swift | 82 +++++++++++++++++++++++++ ASFW/MCP/ASFWMCPToolCatalog.swift | 4 +- ASFWTests/MCP/MCPIrmCasToolsTests.swift | 81 ++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 ASFW/MCP/ASFWMCPIrmCasTools.swift create mode 100644 ASFWTests/MCP/MCPIrmCasToolsTests.swift diff --git a/ASFW/MCP/ASFWMCPIrmCasTools.swift b/ASFW/MCP/ASFWMCPIrmCasTools.swift new file mode 100644 index 00000000..461b4251 --- /dev/null +++ b/ASFW/MCP/ASFWMCPIrmCasTools.swift @@ -0,0 +1,82 @@ +import Foundation + +// FW-81: IRM and CAS tools (MCP_TOOL_TAXONOMY.md §5.5). +// +// IRM state is read-only inspection; channel/bandwidth allocation and the +// compare-swap primitive are mutations gated by the FW-79 write policy. CAS +// shares the FW-78 transaction request/result schema. IRM allocations are +// compare-swaps against the IRM node's CSR-core resource registers, so their +// policy requests classify as `.csrCore` / `.compareSwap`. + +extension ASFWMCPToolCatalog { + static let irmCasTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_irm_get_state", group: "irm_cas", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return IRM and bus manager state."), + ASFWMCPToolDefinition(name: "asfw_irm_get_bandwidth", group: "irm_cas", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Read isochronous bandwidth availability."), + ASFWMCPToolDefinition(name: "asfw_irm_get_channels", group: "irm_cas", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Read isochronous channel availability."), + ASFWMCPToolDefinition(name: "asfw_irm_list_allocations", group: "irm_cas", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List ASFW-known IRM allocations."), + ASFWMCPToolDefinition(name: "asfw_cas_quadlet", group: "irm_cas", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated compare-swap primitive."), + ASFWMCPToolDefinition(name: "asfw_irm_allocate_channel", group: "irm_cas", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated isochronous channel allocation."), + ASFWMCPToolDefinition(name: "asfw_irm_free_channel", group: "irm_cas", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated isochronous channel release."), + ASFWMCPToolDefinition(name: "asfw_irm_allocate_bandwidth", group: "irm_cas", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated isochronous bandwidth allocation."), + ASFWMCPToolDefinition(name: "asfw_irm_free_bandwidth", group: "irm_cas", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated isochronous bandwidth release.") + ] +} + +// MARK: - CAS bridge to write policy + +extension ASFWMCPCompareSwapRequest { + func policyRequest(currentGeneration: UInt32, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + .forTransaction(kind: .compareSwap, address: address, currentGeneration: currentGeneration, dryRun: dryRun) + } +} + +// MARK: - IRM allocation requests + +struct ASFWMCPIrmChannelRequest: Equatable { + /// Isochronous channel number (0...63). + let channel: UInt32 + /// Bus generation the allocation is pinned to. + let generation: UInt32 + /// True to allocate, false to free. + let allocate: Bool + + static let maxChannel: UInt32 = 63 + + var validationError: ASFWMCPErrorCode? { + channel <= Self.maxChannel ? nil : .malformedRequest + } + + func policyRequest(currentGeneration: UInt32, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + ASFWMCPPolicyRequest( + operationType: .compareSwap, + addressSpace: .csrCore, + requestedGeneration: generation, + currentGeneration: currentGeneration, + dryRun: dryRun + ) + } +} + +struct ASFWMCPIrmBandwidthRequest: Equatable { + /// Isochronous bandwidth allocation units. + let allocationUnits: UInt32 + let generation: UInt32 + let allocate: Bool + + /// IEEE 1394 BANDWIDTH_AVAILABLE caps total isochronous units. + static let maxAllocationUnits: UInt32 = 0x1333 + + var validationError: ASFWMCPErrorCode? { + allocationUnits <= Self.maxAllocationUnits ? nil : .malformedRequest + } + + func policyRequest(currentGeneration: UInt32, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + ASFWMCPPolicyRequest( + operationType: .compareSwap, + addressSpace: .csrCore, + requestedGeneration: generation, + currentGeneration: currentGeneration, + dryRun: dryRun + ) + } +} diff --git a/ASFW/MCP/ASFWMCPToolCatalog.swift b/ASFW/MCP/ASFWMCPToolCatalog.swift index c99e2f8f..7799141a 100644 --- a/ASFW/MCP/ASFWMCPToolCatalog.swift +++ b/ASFW/MCP/ASFWMCPToolCatalog.swift @@ -49,9 +49,7 @@ enum ASFWMCPToolCatalog { // `registerTools` is owned by ASFWMCPRegisterTools.swift (FW-80) — the // reference for how a surface ticket extracts its slice into its own file. - static let irmCasTools: [ASFWMCPToolDefinition] = [ - ASFWMCPToolDefinition(name: "asfw_irm_get_state", group: "irm_cas", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return IRM and bus manager state.") - ] + // `irmCasTools` is owned by ASFWMCPIrmCasTools.swift (FW-81). static let avcFcpTools: [ASFWMCPToolDefinition] = [ ASFWMCPToolDefinition(name: "asfw_avc_list_units", group: "avc_fcp", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List AV/C units.", requiredProtocolHints: ["avc"]) diff --git a/ASFWTests/MCP/MCPIrmCasToolsTests.swift b/ASFWTests/MCP/MCPIrmCasToolsTests.swift new file mode 100644 index 00000000..db55ca64 --- /dev/null +++ b/ASFWTests/MCP/MCPIrmCasToolsTests.swift @@ -0,0 +1,81 @@ +import Testing +@testable import ASFW + +struct MCPIrmCasToolsTests { + private func config( + _ mode: ASFWMCPRuntimeMode, + writePolicyAvailable: Bool = false, + swiftTestGatePassed: Bool = false + ) -> ASFWMCPRuntimeConfiguration { + ASFWMCPRuntimeConfiguration( + mode: mode, + writePolicyAvailable: writePolicyAvailable, + swiftTestGatePassed: swiftTestGatePassed, + rawDeveloperTierEnabled: false + ) + } + + private var gateOpen: ASFWMCPRuntimeConfiguration { + config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: true) + } + + private func toolNames(_ cfg: ASFWMCPRuntimeConfiguration) async -> Set { + let core = ASFWMCPCore(configuration: cfg, driver: MockASFWDriverControl(nodes: [])) + return await Set(core.listTools().map(\.name)) + } + + private func decide(_ cfg: ASFWMCPRuntimeConfiguration, _ req: ASFWMCPPolicyRequest) -> ASFWMCPPolicyDecision { + ASFWMCPWritePolicyEngine(configuration: cfg).evaluate(req) + } + + @Test func irmCatalogExposesReadsAndMutations() { + let names = Set(ASFWMCPToolCatalog.irmCasTools.map(\.name)) + #expect(names.isSuperset(of: [ + "asfw_irm_get_state", "asfw_irm_get_bandwidth", "asfw_irm_get_channels", "asfw_irm_list_allocations", + "asfw_cas_quadlet", "asfw_irm_allocate_channel", "asfw_irm_free_channel", + "asfw_irm_allocate_bandwidth", "asfw_irm_free_bandwidth" + ])) + } + + @Test func irmReadsListWithoutDevicesAndMutationsAreHidden() async { + let names = await toolNames(config(.readOnlyDeveloper)) + // IRM is bus-global: reads list even with no nodes. + #expect(names.isSuperset(of: ["asfw_irm_get_state", "asfw_irm_get_channels", "asfw_irm_get_bandwidth"])) + #expect(names.contains("asfw_cas_quadlet") == false) + #expect(names.contains("asfw_irm_allocate_channel") == false) + } + + @Test func irmMutationsListWhenGateOpen() async { + let names = await toolNames(gateOpen) + #expect(names.isSuperset(of: ["asfw_cas_quadlet", "asfw_irm_allocate_channel", "asfw_irm_free_bandwidth"])) + } + + @Test func channelRequestValidatesRange() { + #expect(ASFWMCPIrmChannelRequest(channel: 63, generation: 17, allocate: true).validationError == nil) + #expect(ASFWMCPIrmChannelRequest(channel: 64, generation: 17, allocate: true).validationError == .malformedRequest) + } + + @Test func bandwidthRequestValidatesCeiling() { + #expect(ASFWMCPIrmBandwidthRequest(allocationUnits: 0x1333, generation: 17, allocate: true).validationError == nil) + #expect(ASFWMCPIrmBandwidthRequest(allocationUnits: 0x2000, generation: 17, allocate: true).validationError == .malformedRequest) + } + + @Test func channelAllocationIsPolicyGated() { + let req = ASFWMCPIrmChannelRequest(channel: 10, generation: 17, allocate: true) + #expect(decide(config(.readOnlyDeveloper), req.policyRequest(currentGeneration: 17)).decision == .requiresDeveloperMode) + #expect(decide(gateOpen, req.policyRequest(currentGeneration: 17)).decision == .allowed) + #expect(decide(gateOpen, req.policyRequest(currentGeneration: 18)).decision == .staleGeneration) + } + + @Test func casQuadletReusesTransactionPolicyBridge() { + let cas = ASFWMCPCompareSwapRequest( + address: ASFWMCPAddress(nodeId: 2, generation: 17, addressHigh: 0xFFFF, addressLow: 0xF0000220), + expected: 0, + swap: 1 + ) + let req = cas.policyRequest(currentGeneration: 17) + #expect(req.operationType == .compareSwap) + #expect(decide(gateOpen, req).decision == .allowed) + #expect(decide(config(.readOnlyDeveloper), req).decision == .requiresDeveloperMode) + } +} From fa6cac6c970e4b5fb21e9731ece91fe6cfe979d4 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 20:00:11 +0200 Subject: [PATCH 11/23] FW-82: expose AV/C and FCP MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Own the avc_fcp slice in ASFWMCPAvcFcpTools.swift. All tools require the "avc" protocol hint, so they surface only for AV/C-capable nodes: - reads: asfw_avc_list_units, asfw_avc_get_subunit_capabilities, asfw_avc_get_subunit_descriptor, asfw_fcp_send_command (inquiry/status only), asfw_fcp_get_recent_responses - asfw_fcp_send_command_dev (developer-write) for control/notify/vendor commands ASFWMCPAvcCommandIntent marks inquiry/status as non-mutating and control/notify/vendorDependent as mutating. ASFWMCPFcpCommandRequest carries big-endian AV/C frame bytes (bounded to 512) and returns a policy request only for mutating intents — routed through forTransaction with the "avc" protocol hint so an unsupported protocol surfaces as unsupportedProtocol. 8 test cases cover hint-gated discovery, intent classification, payload validation, and policy gating (read passthrough, requiresDeveloperMode/allowed, unsupportedProtocol). Full Swift suite green. Co-Authored-By: Claude Opus 4.8 --- ASFW/MCP/ASFWMCPAvcFcpTools.swift | 74 +++++++++++++++++++++ ASFW/MCP/ASFWMCPToolCatalog.swift | 4 +- ASFWTests/MCP/MCPAvcFcpToolsTests.swift | 87 +++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 ASFW/MCP/ASFWMCPAvcFcpTools.swift create mode 100644 ASFWTests/MCP/MCPAvcFcpToolsTests.swift diff --git a/ASFW/MCP/ASFWMCPAvcFcpTools.swift b/ASFW/MCP/ASFWMCPAvcFcpTools.swift new file mode 100644 index 00000000..cde80821 --- /dev/null +++ b/ASFW/MCP/ASFWMCPAvcFcpTools.swift @@ -0,0 +1,74 @@ +import Foundation + +// FW-82: AV/C and FCP tools (MCP_TOOL_TAXONOMY.md §5.6). +// +// AV/C unit/subunit inspection and inquiry/status FCP commands are read-only. +// Control/notify/vendor-dependent FCP commands mutate device state and are +// policy-gated developer-write. All AV/C tools require the "avc" protocol hint, +// so they only appear for AV/C-capable nodes. AV/C frames are big-endian byte +// payloads written to the target's FCP command register (units space). + +extension ASFWMCPToolCatalog { + static let avcFcpTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_avc_list_units", group: "avc_fcp", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List AV/C units, subunits, and plugs.", requiredProtocolHints: ["avc"]), + ASFWMCPToolDefinition(name: "asfw_avc_get_subunit_capabilities", group: "avc_fcp", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return decoded AV/C subunit capabilities.", requiredProtocolHints: ["avc"]), + ASFWMCPToolDefinition(name: "asfw_avc_get_subunit_descriptor", group: "avc_fcp", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Return bounded AV/C subunit descriptor bytes and parsed summary.", requiredProtocolHints: ["avc"]), + ASFWMCPToolDefinition(name: "asfw_fcp_send_command", group: "avc_fcp", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Send an inquiry/status-only FCP/AV/C command.", requiredProtocolHints: ["avc"]), + ASFWMCPToolDefinition(name: "asfw_fcp_get_recent_responses", group: "avc_fcp", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Inspect recent FCP command/response records.", requiredProtocolHints: ["avc"]), + ASFWMCPToolDefinition(name: "asfw_fcp_send_command_dev", group: "avc_fcp", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Developer-tier raw FCP command that may mutate device state.", requiredProtocolHints: ["avc"]) + ] +} + +/// AV/C command intent (`ctype`). Only inquiry/status are non-mutating. +enum ASFWMCPAvcCommandIntent: String, Equatable, CaseIterable { + case inquiry + case status + case control + case notify + case vendorDependent + + var isMutating: Bool { + switch self { + case .inquiry, .status: + return false + case .control, .notify, .vendorDependent: + return true + } + } +} + +/// A raw FCP/AV/C command directed at a node's FCP command register. +struct ASFWMCPFcpCommandRequest: Equatable { + /// Target node's FCP command register address. + let address: ASFWMCPAddress + let intent: ASFWMCPAvcCommandIntent + /// AV/C frame bytes in bus (big-endian) order. + let payload: [UInt8] + + /// FCP frames are bounded to 512 bytes. + static let maxPayload = 512 + + var validationError: ASFWMCPErrorCode? { + if payload.count > Self.maxPayload { return .payloadTooLarge } + if payload.isEmpty { return .malformedRequest } + return nil + } + + /// Policy request for mutating intents, or nil for inquiry/status reads, + /// which are not gated by write policy. + func policyRequest( + currentGeneration: UInt32, + protocolSupported: Bool = true, + dryRun: Bool = false + ) -> ASFWMCPPolicyRequest? { + guard intent.isMutating else { return nil } + return .forTransaction( + kind: .writeBlock, + address: address, + currentGeneration: currentGeneration, + protocolHint: "avc", + protocolSupported: protocolSupported, + dryRun: dryRun + ) + } +} diff --git a/ASFW/MCP/ASFWMCPToolCatalog.swift b/ASFW/MCP/ASFWMCPToolCatalog.swift index 7799141a..f8d65b30 100644 --- a/ASFW/MCP/ASFWMCPToolCatalog.swift +++ b/ASFW/MCP/ASFWMCPToolCatalog.swift @@ -51,9 +51,7 @@ enum ASFWMCPToolCatalog { // `irmCasTools` is owned by ASFWMCPIrmCasTools.swift (FW-81). - static let avcFcpTools: [ASFWMCPToolDefinition] = [ - ASFWMCPToolDefinition(name: "asfw_avc_list_units", group: "avc_fcp", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List AV/C units.", requiredProtocolHints: ["avc"]) - ] + // `avcFcpTools` is owned by ASFWMCPAvcFcpTools.swift (FW-82). static let cmpTools: [ASFWMCPToolDefinition] = [ ASFWMCPToolDefinition(name: "asfw_cmp_read_pcr", group: "cmp", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read and decode a CMP plug control register.", requiredProtocolHints: ["cmp"]), diff --git a/ASFWTests/MCP/MCPAvcFcpToolsTests.swift b/ASFWTests/MCP/MCPAvcFcpToolsTests.swift new file mode 100644 index 00000000..db08ff35 --- /dev/null +++ b/ASFWTests/MCP/MCPAvcFcpToolsTests.swift @@ -0,0 +1,87 @@ +import Testing +@testable import ASFW + +struct MCPAvcFcpToolsTests { + private func config( + _ mode: ASFWMCPRuntimeMode, + writePolicyAvailable: Bool = false, + swiftTestGatePassed: Bool = false + ) -> ASFWMCPRuntimeConfiguration { + ASFWMCPRuntimeConfiguration( + mode: mode, + writePolicyAvailable: writePolicyAvailable, + swiftTestGatePassed: swiftTestGatePassed, + rawDeveloperTierEnabled: false + ) + } + + private var gateOpen: ASFWMCPRuntimeConfiguration { + config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: true) + } + + private func toolNames(_ cfg: ASFWMCPRuntimeConfiguration, nodes: [ASFWMCPNodeSummary]) async -> Set { + let core = ASFWMCPCore(configuration: cfg, driver: MockASFWDriverControl(nodes: nodes)) + return await Set(core.listTools().map(\.name)) + } + + private func fcpAddress() -> ASFWMCPAddress { + ASFWMCPAddress(nodeId: 0, generation: 17, addressHigh: 0xFFFF, addressLow: 0xF0000B00) + } + + private func decide(_ cfg: ASFWMCPRuntimeConfiguration, _ req: ASFWMCPPolicyRequest) -> ASFWMCPPolicyDecision { + ASFWMCPWritePolicyEngine(configuration: cfg).evaluate(req) + } + + @Test func avcToolsHiddenWithoutAvcNode() async { + let names = await toolNames(config(.readOnlyDeveloper), nodes: []) + #expect(names.contains("asfw_avc_list_units") == false) + #expect(names.contains("asfw_fcp_send_command") == false) + } + + @Test func avcReadsListForAvcNodeAndDevCommandIsHidden() async { + let names = await toolNames(config(.readOnlyDeveloper), nodes: MockASFWDriverControl.defaultNodes) + #expect(names.isSuperset(of: [ + "asfw_avc_list_units", "asfw_avc_get_subunit_capabilities", + "asfw_avc_get_subunit_descriptor", "asfw_fcp_send_command", "asfw_fcp_get_recent_responses" + ])) + #expect(names.contains("asfw_fcp_send_command_dev") == false) + } + + @Test func devFcpCommandListsWhenGateOpen() async { + let names = await toolNames(gateOpen, nodes: MockASFWDriverControl.defaultNodes) + #expect(names.contains("asfw_fcp_send_command_dev")) + } + + @Test func commandIntentMutationClassification() { + #expect(ASFWMCPAvcCommandIntent.inquiry.isMutating == false) + #expect(ASFWMCPAvcCommandIntent.status.isMutating == false) + #expect(ASFWMCPAvcCommandIntent.control.isMutating) + #expect(ASFWMCPAvcCommandIntent.notify.isMutating) + #expect(ASFWMCPAvcCommandIntent.vendorDependent.isMutating) + } + + @Test func payloadValidationCatchesEmptyAndOversize() { + #expect(ASFWMCPFcpCommandRequest(address: fcpAddress(), intent: .status, payload: [0x01, 0x02]).validationError == nil) + #expect(ASFWMCPFcpCommandRequest(address: fcpAddress(), intent: .status, payload: []).validationError == .malformedRequest) + let oversize = [UInt8](repeating: 0, count: 513) + #expect(ASFWMCPFcpCommandRequest(address: fcpAddress(), intent: .control, payload: oversize).validationError == .payloadTooLarge) + } + + @Test func inquiryCommandsAreNotPolicyGated() { + let request = ASFWMCPFcpCommandRequest(address: fcpAddress(), intent: .inquiry, payload: [0x01]) + #expect(request.policyRequest(currentGeneration: 17) == nil) + } + + @Test func controlCommandsArePolicyGated() throws { + let request = ASFWMCPFcpCommandRequest(address: fcpAddress(), intent: .control, payload: [0x00, 0x11, 0x22, 0x33]) + let gated = try #require(request.policyRequest(currentGeneration: 17)) + #expect(decide(config(.readOnlyDeveloper), gated).decision == .requiresDeveloperMode) + #expect(decide(gateOpen, gated).decision == .allowed) + } + + @Test func controlCommandRefusedWhenProtocolUnsupported() { + let request = ASFWMCPFcpCommandRequest(address: fcpAddress(), intent: .control, payload: [0x00]) + let gated = request.policyRequest(currentGeneration: 17, protocolSupported: false)! + #expect(decide(gateOpen, gated).decision == .unsupportedProtocol) + } +} From b0691d1f9da3e28d627633b134fd2384bacc4583 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 20:01:23 +0200 Subject: [PATCH 12/23] FW-83: expose CMP MCP tools Own the cmp slice in ASFWMCPCmpTools.swift. All tools require the "cmp" hint: - reads: asfw_cmp_list_plugs, asfw_cmp_read_pcr - mutations (developer-write, policy-gated): asfw_cmp_write_pcr, asfw_cmp_establish_connection, asfw_cmp_break_connection PCR writes and connection establish/break are compare-swaps against the node's plug control registers, so they reuse the FW-78 CAS path and FW-79 policy via forTransaction with the "cmp" hint (a stale plug value then surfaces as compareFailed at execution). Plug indices are validated against PCR_0..PCR_30. 7 test cases cover hint-gated discovery, malformed plug IDs, policy-denied writes in read-only mode, allowed compare-swap writes when the gate is open, and unsupportedProtocol. Full Swift suite green. Co-Authored-By: Claude Opus 4.8 --- ASFW/MCP/ASFWMCPCmpTools.swift | 73 +++++++++++++++++++++++++ ASFW/MCP/ASFWMCPToolCatalog.swift | 5 +- ASFWTests/MCP/MCPCmpToolsTests.swift | 79 ++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 ASFW/MCP/ASFWMCPCmpTools.swift create mode 100644 ASFWTests/MCP/MCPCmpToolsTests.swift diff --git a/ASFW/MCP/ASFWMCPCmpTools.swift b/ASFW/MCP/ASFWMCPCmpTools.swift new file mode 100644 index 00000000..1249c779 --- /dev/null +++ b/ASFW/MCP/ASFWMCPCmpTools.swift @@ -0,0 +1,73 @@ +import Foundation + +// FW-83: Connection Management Procedures (CMP) tools (MCP_TOOL_TAXONOMY.md §5.7). +// +// Plug listing and PCR reads are read-only inspection. PCR writes and +// connection establish/break are mutations: they are compare-swaps against the +// node's plug control registers (so they share the FW-78 CAS schema and FW-79 +// policy), and use the "cmp" protocol hint for discovery. CMP writes prefer CAS +// so a stale plug value surfaces as compareFailed at execution. + +extension ASFWMCPToolCatalog { + static let cmpTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_cmp_list_plugs", group: "cmp", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List known iPCR/oPCR plug state.", requiredProtocolHints: ["cmp"]), + ASFWMCPToolDefinition(name: "asfw_cmp_read_pcr", group: "cmp", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read and decode a CMP plug control register.", requiredProtocolHints: ["cmp"]), + ASFWMCPToolDefinition(name: "asfw_cmp_write_pcr", group: "cmp", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated CMP PCR write (compare-swap).", requiredProtocolHints: ["cmp"]), + ASFWMCPToolDefinition(name: "asfw_cmp_establish_connection", group: "cmp", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated connection establishment.", requiredProtocolHints: ["cmp"]), + ASFWMCPToolDefinition(name: "asfw_cmp_break_connection", group: "cmp", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated connection break.", requiredProtocolHints: ["cmp"]) + ] +} + +/// IEC 61883-1 plug control registers: PCR_0..PCR_30 per direction. +enum ASFWMCPCmpLimits { + static let maxPlug: UInt32 = 30 +} + +struct ASFWMCPCmpPcrWriteRequest: Equatable { + /// Address of the target plug control register. + let address: ASFWMCPAddress + /// Plug index (0...30). + let plug: UInt32 + /// Expected current PCR value (host order). + let expected: UInt32 + /// New PCR value (host order). + let swap: UInt32 + + var validationError: ASFWMCPErrorCode? { + plug <= ASFWMCPCmpLimits.maxPlug ? nil : .malformedRequest + } + + func policyRequest(currentGeneration: UInt32, protocolSupported: Bool = true, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + .forTransaction( + kind: .compareSwap, + address: address, + currentGeneration: currentGeneration, + protocolHint: "cmp", + protocolSupported: protocolSupported, + dryRun: dryRun + ) + } +} + +struct ASFWMCPCmpConnectionRequest: Equatable { + /// Address of the plug control register backing the connection. + let address: ASFWMCPAddress + let plug: UInt32 + /// True to establish, false to break. + let establish: Bool + + var validationError: ASFWMCPErrorCode? { + plug <= ASFWMCPCmpLimits.maxPlug ? nil : .malformedRequest + } + + func policyRequest(currentGeneration: UInt32, protocolSupported: Bool = true, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + .forTransaction( + kind: .compareSwap, + address: address, + currentGeneration: currentGeneration, + protocolHint: "cmp", + protocolSupported: protocolSupported, + dryRun: dryRun + ) + } +} diff --git a/ASFW/MCP/ASFWMCPToolCatalog.swift b/ASFW/MCP/ASFWMCPToolCatalog.swift index f8d65b30..7418c800 100644 --- a/ASFW/MCP/ASFWMCPToolCatalog.swift +++ b/ASFW/MCP/ASFWMCPToolCatalog.swift @@ -53,10 +53,7 @@ enum ASFWMCPToolCatalog { // `avcFcpTools` is owned by ASFWMCPAvcFcpTools.swift (FW-82). - static let cmpTools: [ASFWMCPToolDefinition] = [ - ASFWMCPToolDefinition(name: "asfw_cmp_read_pcr", group: "cmp", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read and decode a CMP plug control register.", requiredProtocolHints: ["cmp"]), - ASFWMCPToolDefinition(name: "asfw_cmp_write_pcr", group: "cmp", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated CMP PCR write.", requiredProtocolHints: ["cmp"]) - ] + // `cmpTools` is owned by ASFWMCPCmpTools.swift (FW-83). static let sbp2Tools: [ASFWMCPToolDefinition] = [ ASFWMCPToolDefinition(name: "asfw_sbp2_list_units", group: "sbp2", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List SBP-2 units.", requiredProtocolHints: ["sbp2"]) diff --git a/ASFWTests/MCP/MCPCmpToolsTests.swift b/ASFWTests/MCP/MCPCmpToolsTests.swift new file mode 100644 index 00000000..9254c327 --- /dev/null +++ b/ASFWTests/MCP/MCPCmpToolsTests.swift @@ -0,0 +1,79 @@ +import Testing +@testable import ASFW + +struct MCPCmpToolsTests { + private func config( + _ mode: ASFWMCPRuntimeMode, + writePolicyAvailable: Bool = false, + swiftTestGatePassed: Bool = false + ) -> ASFWMCPRuntimeConfiguration { + ASFWMCPRuntimeConfiguration( + mode: mode, + writePolicyAvailable: writePolicyAvailable, + swiftTestGatePassed: swiftTestGatePassed, + rawDeveloperTierEnabled: false + ) + } + + private var gateOpen: ASFWMCPRuntimeConfiguration { + config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: true) + } + + private func pcrAddress() -> ASFWMCPAddress { + ASFWMCPAddress(nodeId: 0, generation: 17, addressHigh: 0xFFFF, addressLow: 0xF0000904) + } + + private func toolNames(_ cfg: ASFWMCPRuntimeConfiguration, nodes: [ASFWMCPNodeSummary]) async -> Set { + let core = ASFWMCPCore(configuration: cfg, driver: MockASFWDriverControl(nodes: nodes)) + return await Set(core.listTools().map(\.name)) + } + + private func decide(_ cfg: ASFWMCPRuntimeConfiguration, _ req: ASFWMCPPolicyRequest) -> ASFWMCPPolicyDecision { + ASFWMCPWritePolicyEngine(configuration: cfg).evaluate(req) + } + + @Test func cmpToolsHiddenWithoutCmpNode() async { + let names = await toolNames(config(.readOnlyDeveloper), nodes: [MockASFWDriverControl.sbp2Node]) + #expect(names.contains("asfw_cmp_list_plugs") == false) + #expect(names.contains("asfw_cmp_read_pcr") == false) + } + + @Test func cmpReadsListForCmpNodeAndWritesHidden() async { + let names = await toolNames(config(.readOnlyDeveloper), nodes: MockASFWDriverControl.defaultNodes) + #expect(names.isSuperset(of: ["asfw_cmp_list_plugs", "asfw_cmp_read_pcr"])) + #expect(names.contains("asfw_cmp_write_pcr") == false) + #expect(names.contains("asfw_cmp_establish_connection") == false) + #expect(names.contains("asfw_cmp_break_connection") == false) + } + + @Test func cmpWritesListWhenGateOpen() async { + let names = await toolNames(gateOpen, nodes: MockASFWDriverControl.defaultNodes) + #expect(names.isSuperset(of: ["asfw_cmp_write_pcr", "asfw_cmp_establish_connection", "asfw_cmp_break_connection"])) + } + + @Test func malformedPlugIdIsRejected() { + #expect(ASFWMCPCmpPcrWriteRequest(address: pcrAddress(), plug: 30, expected: 0, swap: 1).validationError == nil) + #expect(ASFWMCPCmpPcrWriteRequest(address: pcrAddress(), plug: 31, expected: 0, swap: 1).validationError == .malformedRequest) + #expect(ASFWMCPCmpConnectionRequest(address: pcrAddress(), plug: 99, establish: true).validationError == .malformedRequest) + } + + @Test func pcrWriteIsPolicyDeniedInReadOnly() { + let request = ASFWMCPCmpPcrWriteRequest(address: pcrAddress(), plug: 0, expected: 0, swap: 0x4000_0000) + let decision = decide(config(.readOnlyDeveloper), request.policyRequest(currentGeneration: 17)) + #expect(decision.decision == .requiresDeveloperMode) + #expect(decision.reachesDriverWritePath == false) + } + + @Test func pcrWriteAllowedWhenGateOpenAndUsesCompareSwap() { + let request = ASFWMCPCmpPcrWriteRequest(address: pcrAddress(), plug: 0, expected: 0, swap: 0x4000_0000) + let policyRequest = request.policyRequest(currentGeneration: 17) + #expect(policyRequest.operationType == .compareSwap) + #expect(decide(gateOpen, policyRequest).decision == .allowed) + } + + @Test func connectionWriteRefusedWhenProtocolUnsupported() { + let request = ASFWMCPCmpConnectionRequest(address: pcrAddress(), plug: 0, establish: true) + let decision = decide(gateOpen, request.policyRequest(currentGeneration: 17, protocolSupported: false)) + #expect(decision.decision == .unsupportedProtocol) + } +} From 8db7d00c9603e30179c43a4bee0002193d929380 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 20:02:41 +0200 Subject: [PATCH 13/23] FW-84: expose SBP-2 inspection and developer control tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Own the sbp2 slice in ASFWMCPSbp2Tools.swift. All tools use the "sbp2" hint: - reads: asfw_sbp2_list_units, asfw_sbp2_inspect_unit, asfw_sbp2_get_session_status - mutations (raw developer tier): asfw_sbp2_login_dev, asfw_sbp2_submit_orb_dev Per the §7 visibility matrix, SBP-2 mutations are raw developer-tier escape hatches: login/ORB requests set requiresRawDeveloperTier, so they stay hidden (and policy-denied) unless both the write gate and the raw tier are enabled. ASFWMCPSbp2SessionState distinguishes absent / unsupported / inactive / active / protocolError so inspection results are unambiguous. ORB payloads are validated (non-empty, quadlet-aligned, bounded). 8 test cases cover hint-gated discovery, raw-tier-gated mutation visibility, session-state coverage, ORB validation, and policy (denied without raw tier, allowed with it). Full Swift suite green. Co-Authored-By: Claude Opus 4.8 --- ASFW/MCP/ASFWMCPSbp2Tools.swift | 88 ++++++++++++++++++++++++++ ASFW/MCP/ASFWMCPToolCatalog.swift | 4 +- ASFWTests/MCP/MCPSbp2ToolsTests.swift | 89 +++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 ASFW/MCP/ASFWMCPSbp2Tools.swift create mode 100644 ASFWTests/MCP/MCPSbp2ToolsTests.swift diff --git a/ASFW/MCP/ASFWMCPSbp2Tools.swift b/ASFW/MCP/ASFWMCPSbp2Tools.swift new file mode 100644 index 00000000..b9532ef0 --- /dev/null +++ b/ASFW/MCP/ASFWMCPSbp2Tools.swift @@ -0,0 +1,88 @@ +import Foundation + +// FW-84: SBP-2 inspection and developer control tools (MCP_TOOL_TAXONOMY.md §5.8). +// +// Inspection (units, unit directory, session status) is read-only. Login and ORB +// submission are intentionally lower priority and treated as raw developer-tier +// escape hatches (visibility matrix §7: "hidden unless raw dev tier enabled"), +// so they require both the write gate and the raw developer tier. All tools use +// the "sbp2" protocol hint. + +extension ASFWMCPToolCatalog { + static let sbp2Tools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_sbp2_list_units", group: "sbp2", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List SBP-2 units discovered from Config ROM.", requiredProtocolHints: ["sbp2"]), + ASFWMCPToolDefinition(name: "asfw_sbp2_inspect_unit", group: "sbp2", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Decode an SBP-2 unit directory and command-set hints.", requiredProtocolHints: ["sbp2"]), + ASFWMCPToolDefinition(name: "asfw_sbp2_get_session_status", group: "sbp2", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Report login/session/fetch-agent state.", requiredProtocolHints: ["sbp2"]), + ASFWMCPToolDefinition(name: "asfw_sbp2_login_dev", group: "sbp2", visibility: .rawDeveloper, readOnly: false, idempotent: false, summary: "Raw developer-tier SBP-2 login.", requiredProtocolHints: ["sbp2"]), + ASFWMCPToolDefinition(name: "asfw_sbp2_submit_orb_dev", group: "sbp2", visibility: .rawDeveloper, readOnly: false, idempotent: false, summary: "Raw developer-tier SBP-2 ORB submission.", requiredProtocolHints: ["sbp2"]) + ] +} + +/// Distinguishes the inspection states an SBP-2 query can report. +enum ASFWMCPSbp2SessionState: String, Equatable, CaseIterable { + /// No SBP-2 device present at the queried node. + case absent + /// Node present but the unit is not a supported SBP-2 target. + case unsupported + /// Supported unit with no active login/session. + case inactive + /// Logged in with an active fetch agent. + case active + /// Unit directory or session decode failed. + case protocolError +} + +struct ASFWMCPSbp2SessionStatus: Equatable { + let nodeId: UInt32 + let state: ASFWMCPSbp2SessionState + /// Set only when state == .active. + let loginId: UInt32? + + init(nodeId: UInt32, state: ASFWMCPSbp2SessionState, loginId: UInt32? = nil) { + self.nodeId = nodeId + self.state = state + self.loginId = loginId + } +} + +struct ASFWMCPSbp2LoginRequest: Equatable { + /// Address of the target unit's management agent. + let address: ASFWMCPAddress + + func policyRequest(currentGeneration: UInt32, protocolSupported: Bool = true, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + .forTransaction( + kind: .writeBlock, + address: address, + currentGeneration: currentGeneration, + protocolHint: "sbp2", + protocolSupported: protocolSupported, + dryRun: dryRun, + requiresRawDeveloperTier: true + ) + } +} + +struct ASFWMCPSbp2OrbRequest: Equatable { + let address: ASFWMCPAddress + /// ORB bytes in bus (big-endian) order. + let orb: [UInt8] + + var validationError: ASFWMCPErrorCode? { + if orb.isEmpty { return .malformedRequest } + if orb.count % 4 != 0 { return .malformedRequest } + if orb.count > Int(ASFWMCPTransactionLimits.maxBlockBytes) { return .payloadTooLarge } + return nil + } + + func policyRequest(currentGeneration: UInt32, protocolSupported: Bool = true, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + .forTransaction( + kind: .writeBlock, + address: address, + currentGeneration: currentGeneration, + protocolHint: "sbp2", + protocolSupported: protocolSupported, + dryRun: dryRun, + requiresRawDeveloperTier: true + ) + } +} diff --git a/ASFW/MCP/ASFWMCPToolCatalog.swift b/ASFW/MCP/ASFWMCPToolCatalog.swift index 7418c800..63e387f9 100644 --- a/ASFW/MCP/ASFWMCPToolCatalog.swift +++ b/ASFW/MCP/ASFWMCPToolCatalog.swift @@ -55,9 +55,7 @@ enum ASFWMCPToolCatalog { // `cmpTools` is owned by ASFWMCPCmpTools.swift (FW-83). - static let sbp2Tools: [ASFWMCPToolDefinition] = [ - ASFWMCPToolDefinition(name: "asfw_sbp2_list_units", group: "sbp2", visibility: .readOnly, readOnly: true, idempotent: true, summary: "List SBP-2 units.", requiredProtocolHints: ["sbp2"]) - ] + // `sbp2Tools` is owned by ASFWMCPSbp2Tools.swift (FW-84). static let diceTcatTools: [ASFWMCPToolDefinition] = [ ASFWMCPToolDefinition(name: "asfw_dice_read_register", group: "dice_tcat", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a DICE/TCAT register.", requiredProtocolHints: ["dice_tcat"]), diff --git a/ASFWTests/MCP/MCPSbp2ToolsTests.swift b/ASFWTests/MCP/MCPSbp2ToolsTests.swift new file mode 100644 index 00000000..1ee7a6bb --- /dev/null +++ b/ASFWTests/MCP/MCPSbp2ToolsTests.swift @@ -0,0 +1,89 @@ +import Testing +@testable import ASFW + +struct MCPSbp2ToolsTests { + private func config( + _ mode: ASFWMCPRuntimeMode, + writePolicyAvailable: Bool = false, + swiftTestGatePassed: Bool = false, + rawDeveloperTierEnabled: Bool = false + ) -> ASFWMCPRuntimeConfiguration { + ASFWMCPRuntimeConfiguration( + mode: mode, + writePolicyAvailable: writePolicyAvailable, + swiftTestGatePassed: swiftTestGatePassed, + rawDeveloperTierEnabled: rawDeveloperTierEnabled + ) + } + + private var gateOpen: ASFWMCPRuntimeConfiguration { + config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: true) + } + + private var gateOpenRawTier: ASFWMCPRuntimeConfiguration { + config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: true, rawDeveloperTierEnabled: true) + } + + private func unitAddress() -> ASFWMCPAddress { + ASFWMCPAddress(nodeId: 2, generation: 17, addressHigh: 0xFFFF, addressLow: 0xF0000800) + } + + private func toolNames(_ cfg: ASFWMCPRuntimeConfiguration) async -> Set { + let core = ASFWMCPCore(configuration: cfg, driver: MockASFWDriverControl(nodes: [MockASFWDriverControl.sbp2Node])) + return await Set(core.listTools().map(\.name)) + } + + private func decide(_ cfg: ASFWMCPRuntimeConfiguration, _ req: ASFWMCPPolicyRequest) -> ASFWMCPPolicyDecision { + ASFWMCPWritePolicyEngine(configuration: cfg).evaluate(req) + } + + @Test func inspectionToolsHiddenWithoutSbp2Node() async { + let core = ASFWMCPCore(configuration: config(.readOnlyDeveloper), driver: MockASFWDriverControl(nodes: MockASFWDriverControl.defaultNodes)) + let names = await Set(core.listTools().map(\.name)) + #expect(names.contains("asfw_sbp2_list_units") == false) + #expect(names.contains("asfw_sbp2_inspect_unit") == false) + } + + @Test func inspectionToolsListAndMutationsHiddenInReadOnly() async { + let names = await toolNames(config(.readOnlyDeveloper)) + #expect(names.isSuperset(of: ["asfw_sbp2_list_units", "asfw_sbp2_inspect_unit", "asfw_sbp2_get_session_status"])) + #expect(names.contains("asfw_sbp2_login_dev") == false) + #expect(names.contains("asfw_sbp2_submit_orb_dev") == false) + } + + @Test func mutationsRemainHiddenWithoutRawTierEvenWhenGateOpen() async { + let names = await toolNames(gateOpen) + #expect(names.contains("asfw_sbp2_login_dev") == false) + #expect(names.contains("asfw_sbp2_submit_orb_dev") == false) + } + + @Test func mutationsListWhenRawTierEnabled() async { + let names = await toolNames(gateOpenRawTier) + #expect(names.isSuperset(of: ["asfw_sbp2_login_dev", "asfw_sbp2_submit_orb_dev"])) + } + + @Test func sessionStatesCoverInspectionOutcomes() { + #expect(ASFWMCPSbp2SessionState.allCases.count == 5) + let active = ASFWMCPSbp2SessionStatus(nodeId: 2, state: .active, loginId: 7) + #expect(active.state == .active) + #expect(active.loginId == 7) + #expect(ASFWMCPSbp2SessionStatus(nodeId: 2, state: .absent).loginId == nil) + } + + @Test func orbValidationRejectsEmptyUnalignedAndOversize() { + #expect(ASFWMCPSbp2OrbRequest(address: unitAddress(), orb: [0, 0, 0, 0]).validationError == nil) + #expect(ASFWMCPSbp2OrbRequest(address: unitAddress(), orb: []).validationError == .malformedRequest) + #expect(ASFWMCPSbp2OrbRequest(address: unitAddress(), orb: [1, 2, 3]).validationError == .malformedRequest) + #expect(ASFWMCPSbp2OrbRequest(address: unitAddress(), orb: [UInt8](repeating: 0, count: 4096)).validationError == .payloadTooLarge) + } + + @Test func loginIsDeniedWithoutRawTierAndAllowedWithIt() { + let request = ASFWMCPSbp2LoginRequest(address: unitAddress()) + let withoutTier = decide(gateOpen, request.policyRequest(currentGeneration: 17)) + #expect(withoutTier.decision == .denied) + #expect(withoutTier.requiredCapability == "rawDeveloperTier") + + let withTier = decide(gateOpenRawTier, request.policyRequest(currentGeneration: 17)) + #expect(withTier.decision == .allowed) + } +} From 0abf59c3aaa3cb57d1f9d937a9d00f013044e740 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 20:04:08 +0200 Subject: [PATCH 14/23] FW-85: expose DICE and TCAT low-level register tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Own the dice_tcat slice in ASFWMCPDiceTcatTools.swift — low-level protocol register/application-space access, not audio-control UX (no phantom power, routing, mixer, or device-control semantics). All tools use the "dice_tcat" hint: - reads: asfw_dice_read_register, asfw_dice_read_block, asfw_dice_decode_status, asfw_tcat_read_application_block - mutations (developer-write, policy-gated): asfw_dice_write_register, asfw_tcat_write_application_block Reads/writes are node-address transactions sharing the FW-78 schema and FW-79 policy via forTransaction with the "dice_tcat" hint. Writes support readback verification through ASFWMCPRegisterWriteVerification (requested vs readback). Block reads/writes reuse the FW-78 bounds. 7 test cases cover capability-absence hiding, read-surface discovery, block validation, policy gating (requiresDeveloperMode / allowed / unsupportedProtocol), and write verification. Full Swift suite green. Co-Authored-By: Claude Opus 4.8 --- ASFW/MCP/ASFWMCPDiceTcatTools.swift | 111 ++++++++++++++++++++++ ASFW/MCP/ASFWMCPToolCatalog.swift | 5 +- ASFWTests/MCP/MCPDiceTcatToolsTests.swift | 78 +++++++++++++++ 3 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 ASFW/MCP/ASFWMCPDiceTcatTools.swift create mode 100644 ASFWTests/MCP/MCPDiceTcatToolsTests.swift diff --git a/ASFW/MCP/ASFWMCPDiceTcatTools.swift b/ASFW/MCP/ASFWMCPDiceTcatTools.swift new file mode 100644 index 00000000..c21ab1a0 --- /dev/null +++ b/ASFW/MCP/ASFWMCPDiceTcatTools.swift @@ -0,0 +1,111 @@ +import Foundation + +// FW-85: DICE/TCAT low-level register tools (MCP_TOOL_TAXONOMY.md §5.9). +// +// Low-level protocol register/application-space access — NOT audio-control UX +// (no phantom power, routing, mixer, or device-control semantics here). DICE/TCAT +// registers are node addresses reached over FW-78 transactions; writes are +// policy-gated via forTransaction with the "dice_tcat" hint and support readback +// verification. All tools use the "dice_tcat" protocol hint. + +extension ASFWMCPToolCatalog { + static let diceTcatTools: [ASFWMCPToolDefinition] = [ + ASFWMCPToolDefinition(name: "asfw_dice_read_register", group: "dice_tcat", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a DICE register and optionally decode it.", requiredProtocolHints: ["dice_tcat"]), + ASFWMCPToolDefinition(name: "asfw_dice_read_block", group: "dice_tcat", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a bounded DICE register block.", requiredProtocolHints: ["dice_tcat"]), + ASFWMCPToolDefinition(name: "asfw_dice_decode_status", group: "dice_tcat", visibility: .readOnly, readOnly: true, idempotent: true, summary: "Decode supplied or cached DICE status/register data.", requiredProtocolHints: ["dice_tcat"]), + ASFWMCPToolDefinition(name: "asfw_tcat_read_application_block", group: "dice_tcat", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a TCAT application-space block.", requiredProtocolHints: ["dice_tcat"]), + ASFWMCPToolDefinition(name: "asfw_dice_write_register", group: "dice_tcat", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated DICE register write.", requiredProtocolHints: ["dice_tcat"]), + ASFWMCPToolDefinition(name: "asfw_tcat_write_application_block", group: "dice_tcat", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated TCAT application block write.", requiredProtocolHints: ["dice_tcat"]) + ] +} + +/// Result of a readback verification after a register write. +struct ASFWMCPRegisterWriteVerification: Equatable { + let requested: UInt32 + let readback: UInt32 + + var verified: Bool { requested == readback } +} + +struct ASFWMCPDiceRegisterReadRequest: Equatable { + let address: ASFWMCPAddress + let decode: Bool + + init(address: ASFWMCPAddress, decode: Bool = false) { + self.address = address + self.decode = decode + } +} + +struct ASFWMCPDiceBlockReadRequest: Equatable { + let address: ASFWMCPAddress + let length: UInt32 + + var validationError: ASFWMCPErrorCode? { + ASFWMCPReadBlockRequest(address: address, length: length).validationError + } +} + +struct ASFWMCPDiceRegisterWriteRequest: Equatable { + let address: ASFWMCPAddress + let value: UInt32 + let verifyReadback: Bool + + init(address: ASFWMCPAddress, value: UInt32, verifyReadback: Bool = false) { + self.address = address + self.value = value + self.verifyReadback = verifyReadback + } + + func policyRequest(currentGeneration: UInt32, protocolSupported: Bool = true, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + .forTransaction( + kind: .writeQuadlet, + address: address, + currentGeneration: currentGeneration, + protocolHint: "dice_tcat", + protocolSupported: protocolSupported, + dryRun: dryRun + ) + } + + /// Verify a readback value against the written value. + func verify(readback: UInt32) -> ASFWMCPRegisterWriteVerification { + ASFWMCPRegisterWriteVerification(requested: value, readback: readback) + } +} + +struct ASFWMCPTcatApplicationBlockReadRequest: Equatable { + let address: ASFWMCPAddress + let length: UInt32 + + var validationError: ASFWMCPErrorCode? { + ASFWMCPReadBlockRequest(address: address, length: length).validationError + } +} + +struct ASFWMCPTcatApplicationBlockWriteRequest: Equatable { + let address: ASFWMCPAddress + let payload: [UInt8] + let verifyReadback: Bool + + init(address: ASFWMCPAddress, payload: [UInt8], verifyReadback: Bool = false) { + self.address = address + self.payload = payload + self.verifyReadback = verifyReadback + } + + var validationError: ASFWMCPErrorCode? { + ASFWMCPWriteBlockRequest(address: address, payload: payload).validationError + } + + func policyRequest(currentGeneration: UInt32, protocolSupported: Bool = true, dryRun: Bool = false) -> ASFWMCPPolicyRequest { + .forTransaction( + kind: .writeBlock, + address: address, + currentGeneration: currentGeneration, + protocolHint: "dice_tcat", + protocolSupported: protocolSupported, + dryRun: dryRun + ) + } +} diff --git a/ASFW/MCP/ASFWMCPToolCatalog.swift b/ASFW/MCP/ASFWMCPToolCatalog.swift index 63e387f9..fe795f8a 100644 --- a/ASFW/MCP/ASFWMCPToolCatalog.swift +++ b/ASFW/MCP/ASFWMCPToolCatalog.swift @@ -57,8 +57,5 @@ enum ASFWMCPToolCatalog { // `sbp2Tools` is owned by ASFWMCPSbp2Tools.swift (FW-84). - static let diceTcatTools: [ASFWMCPToolDefinition] = [ - ASFWMCPToolDefinition(name: "asfw_dice_read_register", group: "dice_tcat", visibility: .readOnly, readOnly: true, idempotent: false, summary: "Read a DICE/TCAT register.", requiredProtocolHints: ["dice_tcat"]), - ASFWMCPToolDefinition(name: "asfw_dice_write_register", group: "dice_tcat", visibility: .developerWrite, readOnly: false, idempotent: false, summary: "Policy-gated DICE/TCAT register write.", requiredProtocolHints: ["dice_tcat"]) - ] + // `diceTcatTools` is owned by ASFWMCPDiceTcatTools.swift (FW-85). } diff --git a/ASFWTests/MCP/MCPDiceTcatToolsTests.swift b/ASFWTests/MCP/MCPDiceTcatToolsTests.swift new file mode 100644 index 00000000..543fd858 --- /dev/null +++ b/ASFWTests/MCP/MCPDiceTcatToolsTests.swift @@ -0,0 +1,78 @@ +import Testing +@testable import ASFW + +struct MCPDiceTcatToolsTests { + private func config( + _ mode: ASFWMCPRuntimeMode, + writePolicyAvailable: Bool = false, + swiftTestGatePassed: Bool = false + ) -> ASFWMCPRuntimeConfiguration { + ASFWMCPRuntimeConfiguration( + mode: mode, + writePolicyAvailable: writePolicyAvailable, + swiftTestGatePassed: swiftTestGatePassed, + rawDeveloperTierEnabled: false + ) + } + + private var gateOpen: ASFWMCPRuntimeConfiguration { + config(.developerWriteEnabled, writePolicyAvailable: true, swiftTestGatePassed: true) + } + + private func diceAddress(low: UInt32 = 0xF0001000) -> ASFWMCPAddress { + ASFWMCPAddress(nodeId: 1, generation: 17, addressHigh: 0xFFFF, addressLow: low) + } + + private func toolNames(_ cfg: ASFWMCPRuntimeConfiguration, nodes: [ASFWMCPNodeSummary]) async -> Set { + let core = ASFWMCPCore(configuration: cfg, driver: MockASFWDriverControl(nodes: nodes)) + return await Set(core.listTools().map(\.name)) + } + + private func decide(_ cfg: ASFWMCPRuntimeConfiguration, _ req: ASFWMCPPolicyRequest) -> ASFWMCPPolicyDecision { + ASFWMCPWritePolicyEngine(configuration: cfg).evaluate(req) + } + + @Test func diceToolsHiddenWithoutCapableNode() async { + let names = await toolNames(config(.readOnlyDeveloper), nodes: [MockASFWDriverControl.sbp2Node]) + #expect(names.contains("asfw_dice_read_register") == false) + #expect(names.contains("asfw_tcat_read_application_block") == false) + } + + @Test func diceReadsListForCapableNodeAndWritesHidden() async { + let names = await toolNames(config(.readOnlyDeveloper), nodes: MockASFWDriverControl.defaultNodes) + #expect(names.isSuperset(of: [ + "asfw_dice_read_register", "asfw_dice_read_block", + "asfw_dice_decode_status", "asfw_tcat_read_application_block" + ])) + #expect(names.contains("asfw_dice_write_register") == false) + #expect(names.contains("asfw_tcat_write_application_block") == false) + } + + @Test func diceWritesListWhenGateOpen() async { + let names = await toolNames(gateOpen, nodes: MockASFWDriverControl.defaultNodes) + #expect(names.isSuperset(of: ["asfw_dice_write_register", "asfw_tcat_write_application_block"])) + } + + @Test func blockReadsReuseTransactionBounds() { + #expect(ASFWMCPDiceBlockReadRequest(address: diceAddress(), length: 64).validationError == nil) + #expect(ASFWMCPDiceBlockReadRequest(address: diceAddress(), length: 7).validationError == .malformedRequest) + #expect(ASFWMCPTcatApplicationBlockReadRequest(address: diceAddress(), length: 4096).validationError == .payloadTooLarge) + } + + @Test func registerWriteIsPolicyDeniedInReadOnlyAndAllowedWhenOpen() { + let request = ASFWMCPDiceRegisterWriteRequest(address: diceAddress(), value: 0x1234_5678) + #expect(decide(config(.readOnlyDeveloper), request.policyRequest(currentGeneration: 17)).decision == .requiresDeveloperMode) + #expect(decide(gateOpen, request.policyRequest(currentGeneration: 17)).decision == .allowed) + } + + @Test func writeRefusedWhenProtocolUnsupported() { + let request = ASFWMCPDiceRegisterWriteRequest(address: diceAddress(), value: 0x1) + #expect(decide(gateOpen, request.policyRequest(currentGeneration: 17, protocolSupported: false)).decision == .unsupportedProtocol) + } + + @Test func writeVerificationReflectsReadback() { + let request = ASFWMCPDiceRegisterWriteRequest(address: diceAddress(), value: 0xABCD, verifyReadback: true) + #expect(request.verify(readback: 0xABCD).verified) + #expect(request.verify(readback: 0x0000).verified == false) + } +} From 534fc7bc932f249560e4306d7fbbc36859d42862 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 20:14:57 +0200 Subject: [PATCH 15/23] FW-87: add MCP tool-use examples and return schema documentation Document concrete request parameters and stable result shapes for the implemented MCP schema layer (FW-78..85): parameter conventions (node id, generation, address high/low, payload encoding, endian, timeout units), the canonical ASFWMCPTransactionResult/ASFWMCPPolicyDecision shapes for success/timeout/protocol-error/policy-denial/dry-run/compare-failed/malformed, and worked examples for read quadlet/block, write with readback verification, CAS, Config ROM read, AV/C inquiry, FCP control, CMP PCR read/write, IRM allocation, and DICE/TCAT register access. Notes that live callTool dispatch is not yet wired, so the examples document the contract the dispatch will serialize. Avoids audio-layer controls per phase scope. Co-Authored-By: Claude Opus 4.8 --- documentation/MCP_TOOL_USE_EXAMPLES.md | 182 +++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 documentation/MCP_TOOL_USE_EXAMPLES.md diff --git a/documentation/MCP_TOOL_USE_EXAMPLES.md b/documentation/MCP_TOOL_USE_EXAMPLES.md new file mode 100644 index 00000000..7f74984b --- /dev/null +++ b/documentation/MCP_TOOL_USE_EXAMPLES.md @@ -0,0 +1,182 @@ +# ASFW MCP Tool-Use Examples and Return Schemas + +Linear: [FW-87](https://linear.app/asfirewire/issue/FW-87/add-mcp-tool-use-examples-and-return-schema-documentation) + +Status: Reference examples for the implemented MCP schema layer (FW-78..85). + +> **Scope note.** This documents the request parameters and stable result shapes +> an agent should expect. The schema, discovery, and policy layers are +> implemented and unit-tested; the live `callTool` dispatch that serializes these +> shapes is not yet wired (it returns refusals/dry-runs until then — see the MCP +> architecture doc). Field names below match the Swift schema types +> (`ASFWMCP*Request` / `ASFWMCPTransactionResult` / `ASFWMCPPolicyDecision`). +> Audio-layer controls are intentionally out of scope for this phase. + +## 1. Parameter conventions + +| Field | Meaning | +| --- | --- | +| `nodeId` | 16-bit bus/node id (`bus_id << 6 \| phy_id`), as a decimal integer | +| `generation` | Bus generation the request is pinned to; a mismatch is `staleGeneration` | +| `addressHigh` | High 16 bits of the 48-bit address (decimal) | +| `addressLow` | Low 32 bits of the 48-bit address (decimal) | +| `length` | Block byte count; non-zero, multiple of 4, ≤ 2048 (S400 ceiling) | +| `payload` | Byte array in **bus (big-endian) order** | +| `value` / `expected` / `swap` | Quadlet in **host order**; serialized big-endian onto the bus | +| `verifyReadback` | Issue a verifying read-back after a write | +| `decode` | Request decoded register fields alongside the raw value | + +**Endianness.** Quadlet scalar fields (`value`, `expected`, `swap`) are host-order +integers; the driver serializes them big-endian on the wire. Block `payload` +bytes are already in bus order. **Timeouts.** Durations are reported in +microseconds (`durationUsec`); a transaction that exceeds its deadline returns +`status: "timeout"`. + +## 2. Stable result shapes + +All async transaction tools return `ASFWMCPTransactionResult`: + +```json +{ + "kind": "readQuadlet", + "ok": true, + "status": "ok", + "rcode": "complete", + "generation": 17, + "durationUsec": 142, + "correlationId": "c-00123", + "payload": [0, 0, 4, 0], + "decoded": null, + "policy": null +} +``` + +`status` ∈ `ok | timeout | rcodeError | busReset | compareFailed | denied | dryRun | malformed`. +`policy` is present only on write-capable calls. Canonical outcomes: + +| Outcome | `ok` | `status` | Other | +| --- | --- | --- | --- | +| Success | `true` | `ok` | `rcode: "complete"`, `payload` set | +| Timeout | `false` | `timeout` | `rcode: null` | +| Protocol error | `false` | `rcodeError` | `rcode: "conflictError"` / `"typeError"` … | +| Policy denial | `false` | `denied` | `policy.decision`, `policy.reason` | +| Dry run | `false` | `dryRun` | `policy.decision: "dryRunOnly"` | +| Compare failed | `false` | `compareFailed` | CAS only | +| Malformed | `false` | `malformed` | failed schema validation | + +A policy decision (`ASFWMCPPolicyDecision`): + +```json +{ + "decision": "requiresDeveloperMode", + "reason": "Writes require developerWriteEnabled mode; current mode is readOnlyDeveloper.", + "errorCode": "requiresDeveloperMode", + "requiredMode": "developerWriteEnabled", + "requiredCapability": null +} +``` + +`decision` ∈ `allowed | denied | dryRunOnly | requiresDeveloperMode | unsupportedAddressSpace | staleGeneration | unsupportedProtocol`. + +## 3. Examples + +### 3.1 Read quadlet — `asfw_read_quadlet` + +```json +// request +{ "nodeId": 2, "generation": 17, "addressHigh": 65535, "addressLow": 4026531840 } +// result +{ "kind": "readQuadlet", "ok": true, "status": "ok", "rcode": "complete", + "generation": 17, "durationUsec": 138, "correlationId": "c-1", "payload": [49, 51, 57, 52] } +``` + +### 3.2 Read block — `asfw_read_block` + +```json +// request — read 16 bytes of Config ROM +{ "nodeId": 2, "generation": 17, "addressHigh": 65535, "addressLow": 4026532864, "length": 16 } +// malformed length (not a multiple of 4) ⇒ +{ "kind": "readBlock", "ok": false, "status": "malformed", "correlationId": "c-2" } +``` + +### 3.3 Write quadlet with readback verification — `asfw_write_device_register` + +```json +// request +{ "nodeId": 2, "generation": 17, "addressHigh": 65535, "addressLow": 4026535168, + "value": 305419896, "verifyReadback": true } +// verification mismatch ⇒ ok:false, the readback differs from the written value +{ "kind": "writeQuadlet", "ok": false, "status": "rcodeError", "generation": 17, + "correlationId": "c-3", + "decoded": { "verification": { "requested": 305419896, "readback": 0, "verified": false } } } +``` + +### 3.4 Compare-swap — `asfw_cas_quadlet` + +```json +// request +{ "nodeId": 2, "generation": 17, "addressHigh": 65535, "addressLow": 4026532896, + "expected": 0, "swap": 1 } +// compare failed (current value != expected) +{ "kind": "compareSwap", "ok": false, "status": "compareFailed", "generation": 17, "correlationId": "c-4" } +``` + +### 3.5 Config ROM read — `asfw_get_config_rom` + +Read-only; returns cached bytes plus a parsed summary. Raw bytes are opt-in +(`includeRaw: true`) to keep responses compact. + +### 3.6 AV/C inquiry — `asfw_fcp_send_command` (intent `inquiry`/`status`) + +```json +// request — status-only, not policy-gated +{ "nodeId": 0, "generation": 17, "addressHigh": 65535, "addressLow": 4026534656, + "intent": "status", "payload": [1, 24, 0, 255, 255, 255] } +``` + +### 3.7 FCP control command — `asfw_fcp_send_command_dev` (intent `control`) + +Mutating; policy-gated. In `readOnlyDeveloper`: + +```json +{ "kind": "writeBlock", "ok": false, "status": "denied", + "policy": { "decision": "requiresDeveloperMode", "requiredMode": "developerWriteEnabled", + "reason": "Writes require developerWriteEnabled mode; current mode is readOnlyDeveloper." } } +``` + +### 3.8 CMP PCR read/write — `asfw_cmp_read_pcr` / `asfw_cmp_write_pcr` + +PCR writes are compare-swaps; a stale plug value surfaces as `compareFailed`. +`plug` must be 0..30 or the request is `malformed`. + +```json +// write request +{ "nodeId": 0, "generation": 17, "addressHigh": 65535, "addressLow": 4026534660, + "plug": 0, "expected": 0, "swap": 1073741824 } +``` + +### 3.9 IRM allocation — `asfw_irm_allocate_channel` + +```json +// request — channel 0..63, generation-pinned +{ "channel": 10, "generation": 17, "allocate": true } +// stale generation ⇒ +{ "ok": false, "status": "denied", + "policy": { "decision": "staleGeneration", + "reason": "Request generation 16 does not match current bus generation 17; re-read topology and retry." } } +``` + +### 3.10 DICE/TCAT register access — `asfw_dice_read_register` / `asfw_dice_write_register` + +Reads are available for capable nodes. Writes are policy-gated and support +readback verification (§3.3 shape). TCAT application blocks use +`asfw_tcat_read_application_block` / `asfw_tcat_write_application_block` with the +same block bounds. + +## 4. Acceptance mapping + +- Examples for read/write/CAS/Config ROM/AV/C/FCP/CMP/IRM/DICE-TCAT: §3. +- Parameter conventions (ids, generation, address, payload, endian, timeout): §1. +- Stable result shapes for success/timeout/protocol error/policy denial/verification + mismatch: §2 + §3.3/§3.4/§3.7/§3.9. +- No audio-layer controls: enforced by the §5.x taxonomy scope. From f34669ac3a127084cedf0dda802123fd9ad94427 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 20:15:33 +0200 Subject: [PATCH 16/23] FW-88: design programmatic MCP workflows for agent orchestration Document candidate agent workflows over the implemented MCP tool surface: bus enumerate-and-summarize, AV/C unit probe, bounded register snapshot, before/after bus-reset diff, and recent-transaction-failure aggregation, plus the mutating channel-claim / PCR-connection / register-poke sequences. Each workflow lists its expected tool sequence, marks every step read-only vs mutating, identifies which steps can run in parallel (no data dependency), and names a compact bounded return so agents avoid large intermediate dumps. Notes the generation-recheck rule between discovery and mutation and that trigger_config_rom_read is non-idempotent. Co-Authored-By: Claude Opus 4.8 --- documentation/MCP_AGENT_WORKFLOWS.md | 102 +++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 documentation/MCP_AGENT_WORKFLOWS.md diff --git a/documentation/MCP_AGENT_WORKFLOWS.md b/documentation/MCP_AGENT_WORKFLOWS.md new file mode 100644 index 00000000..0f11190f --- /dev/null +++ b/documentation/MCP_AGENT_WORKFLOWS.md @@ -0,0 +1,102 @@ +# ASFW MCP Agent Workflows + +Linear: [FW-88](https://linear.app/asfirewire/issue/FW-88/design-programmatic-mcp-workflows-for-agent-orchestration) + +Status: Candidate programmatic workflows over the implemented MCP tool surface. + +> Workflows compose the read-only and policy-gated tools from FW-77..85. They are +> written so an agent can run them through code/tool calling with compact, +> parseable returns. Each step is marked read-only (R) or mutating (M); mutating +> steps require `developerWriteEnabled` + policy clearance. "Parallel" marks steps +> with no data dependency that may be issued concurrently. Tool result shapes are +> in `MCP_TOOL_USE_EXAMPLES.md`. + +## Conventions + +- Prefer resources for bulk state (`asfw://telemetry/snapshot`, `asfw://nodes`) + and tools for targeted actions. +- Keep raw payloads opt-in: pass `includeRaw`/`decode` only when needed. +- Re-read `generation` between discovery and any mutation; a bus reset between + them yields `staleGeneration` and the step must restart from discovery. +- Bound fan-out: cap per-node parallelism to avoid flooding the single async + request queue. + +## W1 — Enumerate and summarize the bus (R, idempotent) + +| Step | Tool | Kind | Parallel | +| --- | --- | --- | --- | +| 1 | `asfw_list_nodes` | R | — | +| 2 | `asfw_get_config_rom` per node | R | yes (per node) | +| 3 | `asfw_decode_config_rom` (if raw fetched) | R | yes | + +Output: one compact row per node (nodeId, GUID, vendor/model, protocol hints). +Step 2 fans out across nodes; cap concurrency. Pure read-only — safe to repeat. + +## W2 — Probe AV/C units (R, idempotent) + +| Step | Tool | Kind | Parallel | +| --- | --- | --- | --- | +| 1 | `asfw_avc_list_units` | R | — | +| 2 | `asfw_avc_get_subunit_capabilities` per unit | R | yes (per unit) | +| 3 | `asfw_fcp_send_command` (intent `status`/`inquiry`) | R | yes | + +Only inquiry/status intents — never `control` — keep this workflow read-only. +Gated behind the `avc` protocol hint, so it only lists for AV/C-capable nodes. + +## W3 — Register snapshot (R, idempotent) + +| Step | Tool | Kind | Parallel | +| --- | --- | --- | --- | +| 1 | `asfw_snapshot_ohci_registers` (bounded offset set) | R | — | +| 2 | `asfw_read_device_register` for selected CSRs | R | yes | +| 3 | `asfw_dice_read_register` (capable nodes) | R | yes | + +Returns a fixed, bounded set of register values for diagnostics. Offsets are +quadlet-aligned and capped (≤ 64 for OHCI snapshot). + +## W4 — Before/after bus-reset diff (R, idempotent) + +| Step | Tool/Resource | Kind | Parallel | +| --- | --- | --- | --- | +| 1 | read `asfw://bus/topology` + `asfw://telemetry/snapshot` | R | yes | +| 2 | (bus reset occurs / is triggered out of band) | — | — | +| 3 | re-read the same resources at the new generation | R | yes | +| 4 | diff node set, gap count, IRM owner, reset count | R | — | + +Compare by `generation`; report added/removed nodes and changed roles. No +mutation — `asfw_trigger_config_rom_read` is *not* used here (it is non-idempotent +because it starts driver work). + +## W5 — Aggregate recent transaction failures (R, idempotent) + +| Step | Tool/Resource | Kind | Parallel | +| --- | --- | --- | --- | +| 1 | read `asfw://transactions/recent` | R | — | +| 2 | filter `rcode != "complete"` or `matchedTransaction == false` | R | — | +| 3 | group by destination/tcode/rcode | R | — | + +Bounded history (default 32). Returns a compact failure histogram, not the raw +event list, unless explicitly requested. + +## Mutating workflows (M — developerWriteEnabled + policy) + +These are listed for completeness; every step routes through the FW-79 policy and +returns a structured decision on refusal. + +- **Channel claim:** `asfw_irm_get_channels` (R) → `asfw_irm_allocate_channel` (M, + CAS) → verify with `asfw_irm_get_channels` (R). Restart on `staleGeneration` or + `compareFailed`. +- **PCR connection:** `asfw_cmp_read_pcr` (R) → `asfw_cmp_establish_connection` (M, + CAS) → `asfw_cmp_read_pcr` (R) to confirm. +- **Register poke:** `asfw_read_device_register` (R) → `asfw_write_device_register` + with `verifyReadback` (M) → inspect the verification result. + +Mutations are inherently serial against shared bus state (generation-pinned CAS); +do not parallelize allocations or PCR writes. + +## Acceptance mapping + +- Candidate workflows with expected tool sequences: W1–W5 + mutating set. +- Compact, parseable returns: each workflow names its bounded output. +- Idempotent/read-only marked: the Kind column (R/M) per step. +- Parallelizable operations identified: the Parallel column. From 488b637b092d1b71b93904a4c3cddce133c58d50 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 21:10:29 +0200 Subject: [PATCH 17/23] FW-91: wire MCP callTool dispatch --- ASFW/MCP/ASFWMCPDriverControl.swift | 98 ++++ ASFW/MCP/ASFWMCPMockTransport.swift | 4 + ASFW/MCP/ASFWMCPModels.swift | 25 + ASFW/MCP/ASFWMCPToolDispatch.swift | 642 +++++++++++++++++++++++ ASFWTests/MCP/MCPToolDispatchTests.swift | 147 ++++++ documentation/MCP_TOOL_USE_EXAMPLES.md | 13 +- 6 files changed, 923 insertions(+), 6 deletions(-) create mode 100644 ASFW/MCP/ASFWMCPToolDispatch.swift create mode 100644 ASFWTests/MCP/MCPToolDispatchTests.swift diff --git a/ASFW/MCP/ASFWMCPDriverControl.swift b/ASFW/MCP/ASFWMCPDriverControl.swift index 5812e5f5..5726223f 100644 --- a/ASFW/MCP/ASFWMCPDriverControl.swift +++ b/ASFW/MCP/ASFWMCPDriverControl.swift @@ -4,6 +4,11 @@ protocol ASFWDriverControlling { func fetchTelemetrySnapshot(configuration: ASFWMCPRuntimeConfiguration) async -> ASFWMCPTelemetrySnapshot func listNodes() async -> [ASFWMCPNodeSummary] func listRecentTransactions(limit: Int) async -> [ASFWMCPTransactionEvent] + func executeReadQuadlet(_ request: ASFWMCPReadQuadletRequest) async -> ASFWMCPTransactionResult + func executeReadBlock(_ request: ASFWMCPReadBlockRequest) async -> ASFWMCPTransactionResult + func executeWriteQuadlet(_ request: ASFWMCPWriteQuadletRequest) async -> ASFWMCPTransactionResult + func executeWriteBlock(_ request: ASFWMCPWriteBlockRequest) async -> ASFWMCPTransactionResult + func executeCompareSwap(_ request: ASFWMCPCompareSwapRequest) async -> ASFWMCPTransactionResult } actor MockASFWDriverControl: ASFWDriverControlling { @@ -73,6 +78,83 @@ actor MockASFWDriverControl: ASFWDriverControlling { Array(transactions.prefix(max(0, limit))) } + func executeReadQuadlet(_ request: ASFWMCPReadQuadletRequest) async -> ASFWMCPTransactionResult { + ASFWMCPTransactionResult( + kind: request.kind, + ok: true, + status: .ok, + generation: generation, + correlationId: "mock-read-quadlet", + rCode: "complete", + durationUsec: 100, + payload: quadletBytes(mockQuadletValue(for: request.address)) + ) + } + + func executeReadBlock(_ request: ASFWMCPReadBlockRequest) async -> ASFWMCPTransactionResult { + if request.validationError != nil { + return .malformed(kind: request.kind, correlationId: "mock-read-block-malformed", generation: generation) + } + let pattern = quadletBytes(mockQuadletValue(for: request.address)) + let payload = (0.. ASFWMCPTransactionResult { + attemptedWriteCount += 1 + return ASFWMCPTransactionResult( + kind: request.kind, + ok: true, + status: .ok, + generation: generation, + correlationId: "mock-write-quadlet", + rCode: "complete", + durationUsec: 140, + payload: request.verifyReadback ? quadletBytes(request.value) : nil + ) + } + + func executeWriteBlock(_ request: ASFWMCPWriteBlockRequest) async -> ASFWMCPTransactionResult { + attemptedWriteCount += 1 + if request.validationError != nil { + return .malformed(kind: request.kind, correlationId: "mock-write-block-malformed", generation: generation) + } + return ASFWMCPTransactionResult( + kind: request.kind, + ok: true, + status: .ok, + generation: generation, + correlationId: "mock-write-block", + rCode: "complete", + durationUsec: 160, + payload: request.verifyReadback ? request.payload : nil + ) + } + + func executeCompareSwap(_ request: ASFWMCPCompareSwapRequest) async -> ASFWMCPTransactionResult { + attemptedWriteCount += 1 + let comparePassed = request.expected == mockQuadletValue(for: request.address) + return ASFWMCPTransactionResult( + kind: request.kind, + ok: comparePassed, + status: comparePassed ? .ok : .compareFailed, + generation: generation, + correlationId: "mock-compare-swap", + rCode: comparePassed ? "complete" : "conflictError", + durationUsec: 180, + payload: quadletBytes(comparePassed ? request.swap : mockQuadletValue(for: request.address)) + ) + } + func recordUnexpectedWriteAttempt() { attemptedWriteCount += 1 } @@ -81,6 +163,22 @@ actor MockASFWDriverControl: ASFWDriverControlling { attemptedWriteCount } + private func mockQuadletValue(for address: ASFWMCPAddress) -> UInt32 { + if address.offset48 == 0xFFFF_F000_0400 { + return 0x3133_3934 + } + return address.addressLow + } + + private func quadletBytes(_ value: UInt32) -> [UInt8] { + [ + UInt8((value >> 24) & 0xFF), + UInt8((value >> 16) & 0xFF), + UInt8((value >> 8) & 0xFF), + UInt8(value & 0xFF) + ] + } + static let defaultNodes: [ASFWMCPNodeSummary] = [ ASFWMCPNodeSummary( nodeId: 0, diff --git a/ASFW/MCP/ASFWMCPMockTransport.swift b/ASFW/MCP/ASFWMCPMockTransport.swift index 4a98cb83..431890c3 100644 --- a/ASFW/MCP/ASFWMCPMockTransport.swift +++ b/ASFW/MCP/ASFWMCPMockTransport.swift @@ -14,6 +14,10 @@ struct ASFWMCPMockTransport { func readResource(_ uri: String) async -> ASFWMCPResourceEnvelope { await core.readResource(uri: uri) } + + func callTool(_ name: String, arguments: ASFWMCPValue = .object([:])) async -> ASFWMCPToolCallResult { + await core.callTool(name: name, arguments: arguments) + } } enum ASFWMCPHardwareSmokeHarness { diff --git a/ASFW/MCP/ASFWMCPModels.swift b/ASFW/MCP/ASFWMCPModels.swift index d3f0dff6..3921ae42 100644 --- a/ASFW/MCP/ASFWMCPModels.swift +++ b/ASFW/MCP/ASFWMCPModels.swift @@ -66,6 +66,31 @@ struct ASFWMCPResourceEnvelope: Equatable { let errors: [ASFWMCPResourceError] } +struct ASFWMCPToolCallResult: Equatable { + let toolName: String + let ok: Bool + let data: ASFWMCPValue + let errors: [ASFWMCPResourceError] + + static func success(toolName: String, data: ASFWMCPValue) -> ASFWMCPToolCallResult { + ASFWMCPToolCallResult(toolName: toolName, ok: true, data: data, errors: []) + } + + static func failure( + toolName: String, + code: ASFWMCPErrorCode, + reason: String, + data: ASFWMCPValue = .object([:]) + ) -> ASFWMCPToolCallResult { + ASFWMCPToolCallResult( + toolName: toolName, + ok: false, + data: data, + errors: [ASFWMCPResourceError(code: code, reason: reason)] + ) + } +} + struct ASFWMCPToolDefinition: Equatable { let name: String let group: String diff --git a/ASFW/MCP/ASFWMCPToolDispatch.swift b/ASFW/MCP/ASFWMCPToolDispatch.swift new file mode 100644 index 00000000..9b8e700c --- /dev/null +++ b/ASFW/MCP/ASFWMCPToolDispatch.swift @@ -0,0 +1,642 @@ +import Foundation + +// FW-91: MCP callTool execution dispatch. +// +// This layer maps MCP tool names and JSON-like argument values onto the typed +// FW-78..85 request structs, evaluates FW-79 write policy before any mutating +// driver call, and returns compact structured results. Surfaces whose live +// driver semantics are not wired yet still have explicit dispatch arms that +// return capabilityUnavailable instead of disappearing or bypassing policy. + +extension ASFWMCPCore { + func callTool(name: String, arguments: ASFWMCPValue = .object([:])) async -> ASFWMCPToolCallResult { + guard configuration.mode != .disabled else { + return .failure(toolName: name, code: .mcpDisabled, reason: "MCP is disabled.") + } + + guard ASFWMCPToolCatalog.all.contains(where: { $0.name == name }) else { + return .failure(toolName: name, code: .capabilityUnavailable, reason: "Unknown MCP tool \(name).") + } + + let decoder: ASFWMCPToolArgumentDecoder + do { + decoder = try ASFWMCPToolArgumentDecoder(arguments) + } catch { + return malformedToolResult(name, reason: "Tool arguments must be an object.") + } + + switch name { + case "asfw_get_capabilities": + return await capabilitiesResult(toolName: name) + case "asfw_get_policy": + return await policyResult(toolName: name) + case "asfw_list_nodes": + return await listNodesResult(toolName: name) + case "asfw_get_node_summary": + return await nodeSummaryResult(toolName: name, decoder: decoder) + case "asfw_explain_capability": + return await explainCapabilityResult(toolName: name, decoder: decoder) + case "asfw_get_controller_state", "asfw_get_topology", "asfw_get_config_rom": + return notImplementedToolResult(name, reason: "Read-only \(name) dispatch is reserved for the live telemetry adapter.") + case "asfw_read_quadlet": + return await dispatchReadQuadlet(name, decoder: decoder) + case "asfw_read_block": + return await dispatchReadBlock(name, decoder: decoder) + case "asfw_write_quadlet": + return await dispatchWriteQuadlet(name, decoder: decoder) + case "asfw_write_block": + return await dispatchWriteBlock(name, decoder: decoder) + case "asfw_compare_swap", "asfw_cas_quadlet": + return await dispatchCompareSwap(name, decoder: decoder, protocolHint: nil) + case "asfw_read_device_register", "asfw_dice_read_register": + return await dispatchReadQuadlet(name, decoder: decoder) + case "asfw_read_device_register_block", "asfw_dice_read_block", "asfw_tcat_read_application_block": + return await dispatchReadBlock(name, decoder: decoder) + case "asfw_write_device_register": + return await dispatchWriteQuadlet(name, decoder: decoder) + case "asfw_write_device_register_block": + return await dispatchWriteBlock(name, decoder: decoder) + case "asfw_write_ohci_register_dev": + return await dispatchOhciWrite(name, decoder: decoder) + case "asfw_read_ohci_register", "asfw_snapshot_ohci_registers": + return notImplementedToolResult(name, reason: "OHCI read dispatch needs the live driver adapter from FW-94.") + case "asfw_irm_get_state", "asfw_irm_get_bandwidth", "asfw_irm_get_channels", "asfw_irm_list_allocations": + return notImplementedToolResult(name, reason: "IRM inspection dispatch needs the live driver adapter from FW-94.") + case "asfw_irm_allocate_channel", "asfw_irm_free_channel": + return await dispatchIrmChannel(name, decoder: decoder, allocate: name == "asfw_irm_allocate_channel") + case "asfw_irm_allocate_bandwidth", "asfw_irm_free_bandwidth": + return await dispatchIrmBandwidth(name, decoder: decoder, allocate: name == "asfw_irm_allocate_bandwidth") + case "asfw_avc_list_units", "asfw_avc_get_subunit_capabilities", "asfw_avc_get_subunit_descriptor", + "asfw_fcp_send_command", "asfw_fcp_get_recent_responses": + return notImplementedToolResult(name, reason: "AV/C/FCP read dispatch needs protocol adapter support from FW-94.") + case "asfw_fcp_send_command_dev": + return await dispatchFcpDeveloperCommand(name, decoder: decoder) + case "asfw_cmp_list_plugs", "asfw_cmp_read_pcr": + return notImplementedToolResult(name, reason: "CMP inspection dispatch needs protocol adapter support from FW-94.") + case "asfw_cmp_write_pcr": + return await dispatchCmpWritePcr(name, decoder: decoder) + case "asfw_cmp_establish_connection", "asfw_cmp_break_connection": + return await dispatchCmpConnection(name, decoder: decoder, establish: name == "asfw_cmp_establish_connection") + case "asfw_sbp2_list_units", "asfw_sbp2_inspect_unit", "asfw_sbp2_get_session_status": + return notImplementedToolResult(name, reason: "SBP-2 inspection dispatch needs protocol adapter support from FW-94.") + case "asfw_sbp2_login_dev": + return await dispatchSbp2Login(name, decoder: decoder) + case "asfw_sbp2_submit_orb_dev": + return await dispatchSbp2Orb(name, decoder: decoder) + case "asfw_dice_decode_status": + return notImplementedToolResult(name, reason: "DICE decode dispatch needs a concrete decoder surface.") + case "asfw_dice_write_register": + return await dispatchWriteQuadlet(name, decoder: decoder, protocolHint: "dice_tcat") + case "asfw_tcat_write_application_block": + return await dispatchWriteBlock(name, decoder: decoder, protocolHint: "dice_tcat") + default: + return notImplementedToolResult(name, reason: "Catalog tool \(name) has no dispatch arm.") + } + } + + private func dispatchReadQuadlet(_ name: String, decoder: ASFWMCPToolArgumentDecoder) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPReadQuadletRequest(address: try decoder.address()) + return transactionToolResult(name, await driver.executeReadQuadlet(request)) + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchReadBlock(_ name: String, decoder: ASFWMCPToolArgumentDecoder) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPReadBlockRequest(address: try decoder.address(), length: try decoder.uint32("length")) + if let error = request.validationError { + return malformedTransactionResult(name, kind: request.kind, generation: request.address.generation, code: error) + } + return transactionToolResult(name, await driver.executeReadBlock(request)) + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchWriteQuadlet( + _ name: String, + decoder: ASFWMCPToolArgumentDecoder, + protocolHint: String? = nil + ) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPWriteQuadletRequest( + address: try decoder.address(), + value: try decoder.uint32("value"), + verifyReadback: try decoder.bool("verifyReadback", default: false) + ) + let policyRequest = ASFWMCPPolicyRequest.forTransaction( + kind: request.kind, + address: request.address, + currentGeneration: await currentGeneration(), + protocolHint: protocolHint, + protocolSupported: await protocolSupported(protocolHint), + dryRun: try decoder.bool("dryRun", default: false) + ) + return await dispatchMutatingTransaction( + name, + kind: request.kind, + generation: request.address.generation, + policyRequest: policyRequest + ) { + await driver.executeWriteQuadlet(request) + } + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchWriteBlock( + _ name: String, + decoder: ASFWMCPToolArgumentDecoder, + protocolHint: String? = nil + ) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPWriteBlockRequest( + address: try decoder.address(), + payload: try decoder.bytes("payload"), + verifyReadback: try decoder.bool("verifyReadback", default: false) + ) + if let error = request.validationError { + return malformedTransactionResult(name, kind: request.kind, generation: request.address.generation, code: error) + } + let policyRequest = ASFWMCPPolicyRequest.forTransaction( + kind: request.kind, + address: request.address, + currentGeneration: await currentGeneration(), + protocolHint: protocolHint, + protocolSupported: await protocolSupported(protocolHint), + dryRun: try decoder.bool("dryRun", default: false) + ) + return await dispatchMutatingTransaction( + name, + kind: request.kind, + generation: request.address.generation, + policyRequest: policyRequest + ) { + await driver.executeWriteBlock(request) + } + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchCompareSwap( + _ name: String, + decoder: ASFWMCPToolArgumentDecoder, + protocolHint: String? + ) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPCompareSwapRequest( + address: try decoder.address(), + expected: try decoder.uint32("expected"), + swap: try decoder.uint32("swap") + ) + let policyRequest = ASFWMCPPolicyRequest.forTransaction( + kind: request.kind, + address: request.address, + currentGeneration: await currentGeneration(), + protocolHint: protocolHint, + protocolSupported: await protocolSupported(protocolHint), + dryRun: try decoder.bool("dryRun", default: false) + ) + return await dispatchMutatingTransaction( + name, + kind: request.kind, + generation: request.address.generation, + policyRequest: policyRequest + ) { + await driver.executeCompareSwap(request) + } + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchOhciWrite(_ name: String, decoder: ASFWMCPToolArgumentDecoder) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPOhciRegisterWriteRequest(offset: try decoder.uint32("offset"), value: try decoder.uint32("value")) + if request.offset % 4 != 0 { + return malformedToolResult(name, reason: "offset must be quadlet-aligned.") + } + let generation = await currentGeneration() + let policy = evaluateWritePolicy(request.policyRequest(currentGeneration: generation, dryRun: try decoder.bool("dryRun", default: false))) + guard policy.reachesDriverWritePath else { + return transactionToolResult( + name, + .policyRefusal(kind: .writeQuadlet, correlationId: correlationId(name), generation: generation, policy: policy) + ) + } + return notImplementedToolResult(name, reason: "OHCI register writes require the live driver adapter from FW-94.") + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchIrmChannel(_ name: String, decoder: ASFWMCPToolArgumentDecoder, allocate: Bool) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPIrmChannelRequest(channel: try decoder.uint32("channel"), generation: try decoder.uint32("generation"), allocate: allocate) + if let error = request.validationError { + return malformedTransactionResult(name, kind: .compareSwap, generation: request.generation, code: error) + } + return policyOnlyMutationResult(name, kind: .compareSwap, generation: request.generation, policyRequest: request.policyRequest(currentGeneration: await currentGeneration(), dryRun: try decoder.bool("dryRun", default: false))) + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchIrmBandwidth(_ name: String, decoder: ASFWMCPToolArgumentDecoder, allocate: Bool) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPIrmBandwidthRequest(allocationUnits: try decoder.uint32("allocationUnits"), generation: try decoder.uint32("generation"), allocate: allocate) + if let error = request.validationError { + return malformedTransactionResult(name, kind: .compareSwap, generation: request.generation, code: error) + } + return policyOnlyMutationResult(name, kind: .compareSwap, generation: request.generation, policyRequest: request.policyRequest(currentGeneration: await currentGeneration(), dryRun: try decoder.bool("dryRun", default: false))) + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchFcpDeveloperCommand(_ name: String, decoder: ASFWMCPToolArgumentDecoder) async -> ASFWMCPToolCallResult { + do { + let intent = try decoder.intent() + let request = ASFWMCPFcpCommandRequest(address: try decoder.address(), intent: intent, payload: try decoder.bytes("payload")) + if let error = request.validationError { + return malformedTransactionResult(name, kind: .writeBlock, generation: request.address.generation, code: error) + } + guard let policyRequest = request.policyRequest( + currentGeneration: await currentGeneration(), + protocolSupported: await protocolSupported("avc"), + dryRun: try decoder.bool("dryRun", default: false) + ) else { + return malformedToolResult(name, reason: "Developer FCP command requires a mutating intent.") + } + return await dispatchMutatingTransaction(name, kind: .writeBlock, generation: request.address.generation, policyRequest: policyRequest) { + await driver.executeWriteBlock(ASFWMCPWriteBlockRequest(address: request.address, payload: request.payload)) + } + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchCmpWritePcr(_ name: String, decoder: ASFWMCPToolArgumentDecoder) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPCmpPcrWriteRequest( + address: try decoder.address(), + plug: try decoder.uint32("plug"), + expected: try decoder.uint32("expected"), + swap: try decoder.uint32("swap") + ) + if let error = request.validationError { + return malformedTransactionResult(name, kind: .compareSwap, generation: request.address.generation, code: error) + } + return await dispatchMutatingTransaction( + name, + kind: .compareSwap, + generation: request.address.generation, + policyRequest: request.policyRequest(currentGeneration: await currentGeneration(), protocolSupported: await protocolSupported("cmp"), dryRun: try decoder.bool("dryRun", default: false)) + ) { + await driver.executeCompareSwap(ASFWMCPCompareSwapRequest(address: request.address, expected: request.expected, swap: request.swap)) + } + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchCmpConnection(_ name: String, decoder: ASFWMCPToolArgumentDecoder, establish: Bool) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPCmpConnectionRequest(address: try decoder.address(), plug: try decoder.uint32("plug"), establish: establish) + if let error = request.validationError { + return malformedTransactionResult(name, kind: .compareSwap, generation: request.address.generation, code: error) + } + return policyOnlyMutationResult( + name, + kind: .compareSwap, + generation: request.address.generation, + policyRequest: request.policyRequest(currentGeneration: await currentGeneration(), protocolSupported: await protocolSupported("cmp"), dryRun: try decoder.bool("dryRun", default: false)) + ) + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchSbp2Login(_ name: String, decoder: ASFWMCPToolArgumentDecoder) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPSbp2LoginRequest(address: try decoder.address()) + return policyOnlyMutationResult( + name, + kind: .writeBlock, + generation: request.address.generation, + policyRequest: request.policyRequest(currentGeneration: await currentGeneration(), protocolSupported: await protocolSupported("sbp2"), dryRun: try decoder.bool("dryRun", default: false)) + ) + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchSbp2Orb(_ name: String, decoder: ASFWMCPToolArgumentDecoder) async -> ASFWMCPToolCallResult { + do { + let request = ASFWMCPSbp2OrbRequest(address: try decoder.address(), orb: try decoder.bytes("orb")) + if let error = request.validationError { + return malformedTransactionResult(name, kind: .writeBlock, generation: request.address.generation, code: error) + } + return policyOnlyMutationResult( + name, + kind: .writeBlock, + generation: request.address.generation, + policyRequest: request.policyRequest(currentGeneration: await currentGeneration(), protocolSupported: await protocolSupported("sbp2"), dryRun: try decoder.bool("dryRun", default: false)) + ) + } catch { + return malformedToolResult(name, reason: error.localizedDescription) + } + } + + private func dispatchMutatingTransaction( + _ name: String, + kind: ASFWMCPTransactionKind, + generation: UInt32, + policyRequest: ASFWMCPPolicyRequest, + execute: () async -> ASFWMCPTransactionResult + ) async -> ASFWMCPToolCallResult { + let policy = evaluateWritePolicy(policyRequest) + guard policy.reachesDriverWritePath else { + return transactionToolResult( + name, + .policyRefusal(kind: kind, correlationId: correlationId(name), generation: generation, policy: policy) + ) + } + + return transactionToolResult(name, await execute()) + } + + private func policyOnlyMutationResult( + _ name: String, + kind: ASFWMCPTransactionKind, + generation: UInt32, + policyRequest: ASFWMCPPolicyRequest + ) -> ASFWMCPToolCallResult { + let policy = evaluateWritePolicy(policyRequest) + guard policy.reachesDriverWritePath else { + return transactionToolResult( + name, + .policyRefusal(kind: kind, correlationId: correlationId(name), generation: generation, policy: policy) + ) + } + return notImplementedToolResult(name, reason: "\(name) policy passed, but live protocol execution is deferred to FW-94.") + } + + private func currentGeneration() async -> UInt32 { + await driver.fetchTelemetrySnapshot(configuration: configuration).generation + } + + private func protocolSupported(_ hint: String?) async -> Bool { + guard let hint else { return true } + return await driver.listNodes().contains { $0.protocolHints.contains(hint) } + } + + private func transactionToolResult(_ name: String, _ result: ASFWMCPTransactionResult) -> ASFWMCPToolCallResult { + ASFWMCPToolCallResult(toolName: name, ok: result.ok, data: result.mcpValue, errors: []) + } + + private func malformedTransactionResult( + _ name: String, + kind: ASFWMCPTransactionKind, + generation: UInt32, + code: ASFWMCPErrorCode + ) -> ASFWMCPToolCallResult { + let result = ASFWMCPTransactionResult.malformed(kind: kind, correlationId: correlationId(name), generation: generation) + return .failure(toolName: name, code: code, reason: "Request failed schema validation: \(code.rawValue).", data: result.mcpValue) + } + + private func malformedToolResult(_ name: String, reason: String) -> ASFWMCPToolCallResult { + .failure(toolName: name, code: .malformedRequest, reason: reason) + } + + private func notImplementedToolResult(_ name: String, reason: String) -> ASFWMCPToolCallResult { + .failure( + toolName: name, + code: .capabilityUnavailable, + reason: reason, + data: .object(["status": .string("notImplemented")]) + ) + } + + private func correlationId(_ name: String) -> String { + "mcp-\(name)" + } +} + +private extension ASFWMCPCore { + func capabilitiesResult(toolName: String) async -> ASFWMCPToolCallResult { + let tools = await listTools() + let groups = Set(tools.map(\.group)).sorted() + return .success( + toolName: toolName, + data: .object([ + "runtimeMode": .string(configuration.mode.rawValue), + "toolCount": .int(tools.count), + "groups": .array(groups.map { .string($0) }), + "developerWritesListed": .bool(configuration.canListDeveloperWriteTools), + "rawDeveloperTierEnabled": .bool(configuration.rawDeveloperTierEnabled) + ]) + ) + } + + func policyResult(toolName: String) async -> ASFWMCPToolCallResult { + .success( + toolName: toolName, + data: .object([ + "runtimeMode": .string(configuration.mode.rawValue), + "writePolicyAvailable": .bool(configuration.writePolicyAvailable), + "swiftTestGatePassed": .bool(configuration.swiftTestGatePassed), + "developerWritesListed": .bool(configuration.canListDeveloperWriteTools), + "rawDeveloperTierEnabled": .bool(configuration.rawDeveloperTierEnabled) + ]) + ) + } + + func listNodesResult(toolName: String) async -> ASFWMCPToolCallResult { + let nodes = await driver.listNodes() + return .success( + toolName: toolName, + data: .array(nodes.map(\.mcpValue)) + ) + } + + func nodeSummaryResult(toolName: String, decoder: ASFWMCPToolArgumentDecoder) async -> ASFWMCPToolCallResult { + do { + let nodeId = try decoder.uint32("nodeId") + guard let node = await driver.listNodes().first(where: { $0.nodeId == nodeId }) else { + return .failure(toolName: toolName, code: .capabilityUnavailable, reason: "No node \(nodeId) exists in the current snapshot.") + } + return .success(toolName: toolName, data: node.mcpValue) + } catch { + return .failure(toolName: toolName, code: .malformedRequest, reason: error.localizedDescription) + } + } + + func explainCapabilityResult(toolName: String, decoder: ASFWMCPToolArgumentDecoder) async -> ASFWMCPToolCallResult { + do { + let capability = try decoder.string("capability") + let listedNames = Set(await listTools().map(\.name)) + let catalogTool = ASFWMCPToolCatalog.all.first { $0.name == capability || $0.group == capability } + return .success( + toolName: toolName, + data: .object([ + "capability": .string(capability), + "known": .bool(catalogTool != nil), + "listed": .bool(listedNames.contains(capability)), + "runtimeMode": .string(configuration.mode.rawValue) + ]) + ) + } catch { + return .failure(toolName: toolName, code: .malformedRequest, reason: error.localizedDescription) + } + } +} + +private struct ASFWMCPToolArgumentDecoder { + private let object: [String: ASFWMCPValue] + + init(_ arguments: ASFWMCPValue) throws { + guard case .object(let object) = arguments else { + throw ASFWMCPToolArgumentError.malformed("arguments must be an object") + } + self.object = object + } + + func address() throws -> ASFWMCPAddress { + ASFWMCPAddress( + nodeId: try uint32("nodeId"), + generation: try uint32("generation"), + addressHigh: UInt16(try boundedUInt64("addressHigh", max: UInt64(UInt16.max))), + addressLow: try uint32("addressLow") + ) + } + + func uint32(_ key: String) throws -> UInt32 { + UInt32(try boundedUInt64(key, max: UInt64(UInt32.max))) + } + + func bool(_ key: String, default defaultValue: Bool) throws -> Bool { + guard let value = object[key] else { return defaultValue } + guard case .bool(let bool) = value else { + throw ASFWMCPToolArgumentError.malformed("\(key) must be a boolean") + } + return bool + } + + func string(_ key: String) throws -> String { + guard let value = object[key] else { + throw ASFWMCPToolArgumentError.malformed("missing required field \(key)") + } + guard case .string(let string) = value else { + throw ASFWMCPToolArgumentError.malformed("\(key) must be a string") + } + return string + } + + func intent() throws -> ASFWMCPAvcCommandIntent { + let rawValue = try string("intent") + guard let intent = ASFWMCPAvcCommandIntent(rawValue: rawValue) else { + throw ASFWMCPToolArgumentError.malformed("intent must be one of \(ASFWMCPAvcCommandIntent.allCases.map(\.rawValue).joined(separator: ", "))") + } + return intent + } + + func bytes(_ key: String) throws -> [UInt8] { + guard let value = object[key] else { + throw ASFWMCPToolArgumentError.malformed("missing required field \(key)") + } + guard case .array(let values) = value else { + throw ASFWMCPToolArgumentError.malformed("\(key) must be an array of byte integers") + } + return try values.map { value in + switch value { + case .int(let int) where int >= 0 && int <= Int(UInt8.max): + return UInt8(int) + case .uint64(let uint) where uint <= UInt64(UInt8.max): + return UInt8(uint) + default: + throw ASFWMCPToolArgumentError.malformed("\(key) must contain only byte integers") + } + } + } + + private func boundedUInt64(_ key: String, max: UInt64) throws -> UInt64 { + guard let value = object[key] else { + throw ASFWMCPToolArgumentError.malformed("missing required field \(key)") + } + let raw: UInt64 + switch value { + case .int(let int) where int >= 0: + raw = UInt64(int) + case .uint64(let uint): + raw = uint + default: + throw ASFWMCPToolArgumentError.malformed("\(key) must be an unsigned integer") + } + guard raw <= max else { + throw ASFWMCPToolArgumentError.malformed("\(key) exceeds \(max)") + } + return raw + } +} + +private enum ASFWMCPToolArgumentError: LocalizedError { + case malformed(String) + + var errorDescription: String? { + switch self { + case .malformed(let reason): + return reason + } + } +} + +extension ASFWMCPTransactionResult { + var mcpValue: ASFWMCPValue { + let object: [String: ASFWMCPValue] = [ + "kind": .string(kind.rawValue), + "ok": .bool(ok), + "status": .string(status.rawValue), + "rcode": rCode.map { .string($0) } ?? .null, + "generation": .int(Int(generation)), + "durationUsec": durationUsec.map { .uint64($0) } ?? .null, + "correlationId": .string(correlationId), + "payload": payload.map { .array($0.map { .int(Int($0)) }) } ?? .null, + "decoded": decoded ?? .null, + "policy": policy.map(\.mcpValue) ?? .null + ] + return .object(object) + } +} + +extension ASFWMCPPolicyDecision { + var mcpValue: ASFWMCPValue { + .object([ + "decision": .string(decision.rawValue), + "reason": .string(reason), + "errorCode": errorCode.map { .string($0.rawValue) } ?? .null, + "requiredMode": requiredMode.map { .string($0.rawValue) } ?? .null, + "requiredCapability": requiredCapability.map { .string($0) } ?? .null + ]) + } +} + +extension ASFWMCPNodeSummary { + var mcpValue: ASFWMCPValue { + .object([ + "nodeId": .int(Int(nodeId)), + "address16": .string(address16), + "guid": guid.map { .string($0) } ?? .null, + "vendorId": vendorId.map { .string($0) } ?? .null, + "modelId": modelId.map { .string($0) } ?? .null, + "vendorName": vendorName.map { .string($0) } ?? .null, + "modelName": modelName.map { .string($0) } ?? .null, + "configRomCached": .bool(configRomCached), + "protocolHints": .array(protocolHints.map { .string($0) }) + ]) + } +} diff --git a/ASFWTests/MCP/MCPToolDispatchTests.swift b/ASFWTests/MCP/MCPToolDispatchTests.swift new file mode 100644 index 00000000..6af016f6 --- /dev/null +++ b/ASFWTests/MCP/MCPToolDispatchTests.swift @@ -0,0 +1,147 @@ +import Testing +@testable import ASFW + +struct MCPToolDispatchTests { + private var gateOpen: ASFWMCPRuntimeConfiguration { + ASFWMCPRuntimeConfiguration( + mode: .developerWriteEnabled, + writePolicyAvailable: true, + swiftTestGatePassed: true, + rawDeveloperTierEnabled: false + ) + } + + private func addressArgs( + nodeId: UInt32 = 1, + generation: UInt32 = 17, + addressHigh: UInt32 = 0xFFFF, + addressLow: UInt32 = 0xF0000400 + ) -> [String: ASFWMCPValue] { + [ + "nodeId": .int(Int(nodeId)), + "generation": .int(Int(generation)), + "addressHigh": .int(Int(addressHigh)), + "addressLow": .uint64(UInt64(addressLow)) + ] + } + + private func object(_ result: ASFWMCPToolCallResult) throws -> [String: ASFWMCPValue] { + guard case .object(let object) = result.data else { + Issue.record("Expected tool result data to be an object.") + return [:] + } + return object + } + + private func policyObject(_ result: ASFWMCPToolCallResult) throws -> [String: ASFWMCPValue] { + let data = try object(result) + guard case .object(let policy)? = data["policy"] else { + Issue.record("Expected transaction result to include a policy object.") + return [:] + } + return policy + } + + @Test func readQuadletDispatchesThroughDriver() async throws { + let driver = MockASFWDriverControl() + let transport = ASFWMCPMockTransport(core: ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver)) + + let result = await transport.callTool("asfw_read_quadlet", arguments: .object(addressArgs())) + + let data = try object(result) + #expect(result.ok) + #expect(data["kind"] == .string("readQuadlet")) + #expect(data["status"] == .string("ok")) + #expect(data["payload"] == .array([.int(49), .int(51), .int(57), .int(52)])) + #expect(await driver.unexpectedWriteAttemptCount() == 0) + } + + @Test func malformedReadBlockReturnsSchemaError() async throws { + let driver = MockASFWDriverControl() + let transport = ASFWMCPMockTransport(core: ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver)) + var args = addressArgs() + args["length"] = .int(6) + + let result = await transport.callTool("asfw_read_block", arguments: .object(args)) + + let data = try object(result) + #expect(result.ok == false) + #expect(result.errors.first?.code == .malformedRequest) + #expect(data["kind"] == .string("readBlock")) + #expect(data["status"] == .string("malformed")) + #expect(await driver.unexpectedWriteAttemptCount() == 0) + } + + @Test func readOnlyModeWriteIsPolicyRefusedBeforeDriverAccess() async throws { + let driver = MockASFWDriverControl() + let transport = ASFWMCPMockTransport(core: ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver)) + var args = addressArgs(addressLow: 0xF0000800) + args["value"] = .uint64(0x1234_5678) + + let result = await transport.callTool("asfw_write_quadlet", arguments: .object(args)) + + let data = try object(result) + let policy = try policyObject(result) + #expect(result.ok == false) + #expect(data["status"] == .string("denied")) + #expect(policy["decision"] == .string("requiresDeveloperMode")) + #expect(await driver.unexpectedWriteAttemptCount() == 0) + } + + @Test func developerWriteModeExecutesAllowedWriteThroughDriver() async throws { + let driver = MockASFWDriverControl() + let transport = ASFWMCPMockTransport(core: ASFWMCPCore(configuration: gateOpen, driver: driver)) + var args = addressArgs(addressLow: 0xF0000800) + args["value"] = .uint64(0x1234_5678) + args["verifyReadback"] = .bool(true) + + let result = await transport.callTool("asfw_write_quadlet", arguments: .object(args)) + + let data = try object(result) + #expect(result.ok) + #expect(data["status"] == .string("ok")) + #expect(data["payload"] == .array([.int(0x12), .int(0x34), .int(0x56), .int(0x78)])) + #expect(await driver.unexpectedWriteAttemptCount() == 1) + } + + @Test func mockModeDryRunDoesNotReachDriverWritePath() async throws { + let driver = MockASFWDriverControl() + let transport = ASFWMCPMockTransport(core: ASFWMCPCore(configuration: .mock, driver: driver)) + var args = addressArgs(addressLow: 0xF0000800) + args["value"] = .int(1) + + let result = await transport.callTool("asfw_write_quadlet", arguments: .object(args)) + + let data = try object(result) + let policy = try policyObject(result) + #expect(result.ok == false) + #expect(data["status"] == .string("dryRun")) + #expect(policy["decision"] == .string("dryRunOnly")) + #expect(await driver.unexpectedWriteAttemptCount() == 0) + } + + @Test func unsupportedProtocolWriteIsRefusedBeforeDriverAccess() async throws { + let driver = MockASFWDriverControl(nodes: [MockASFWDriverControl.sbp2Node]) + let transport = ASFWMCPMockTransport(core: ASFWMCPCore(configuration: gateOpen, driver: driver)) + var args = addressArgs(addressLow: 0xF0000800) + args["value"] = .int(1) + + let result = await transport.callTool("asfw_dice_write_register", arguments: .object(args)) + + let policy = try policyObject(result) + #expect(result.ok == false) + #expect(policy["decision"] == .string("unsupportedProtocol")) + #expect(await driver.unexpectedWriteAttemptCount() == 0) + } + + @Test func everyCatalogToolHasADispatchOutcome() async { + let driver = MockASFWDriverControl(nodes: MockASFWDriverControl.defaultNodes + [MockASFWDriverControl.sbp2Node]) + let transport = ASFWMCPMockTransport(core: ASFWMCPCore(configuration: gateOpen, driver: driver)) + + for tool in ASFWMCPToolCatalog.all { + let result = await transport.callTool(tool.name, arguments: .object([:])) + #expect(result.toolName == tool.name) + #expect(result.errors.first?.reason.contains("has no dispatch arm") != true) + } + } +} diff --git a/documentation/MCP_TOOL_USE_EXAMPLES.md b/documentation/MCP_TOOL_USE_EXAMPLES.md index 7f74984b..945c252b 100644 --- a/documentation/MCP_TOOL_USE_EXAMPLES.md +++ b/documentation/MCP_TOOL_USE_EXAMPLES.md @@ -5,12 +5,13 @@ Linear: [FW-87](https://linear.app/asfirewire/issue/FW-87/add-mcp-tool-use-examp Status: Reference examples for the implemented MCP schema layer (FW-78..85). > **Scope note.** This documents the request parameters and stable result shapes -> an agent should expect. The schema, discovery, and policy layers are -> implemented and unit-tested; the live `callTool` dispatch that serializes these -> shapes is not yet wired (it returns refusals/dry-runs until then — see the MCP -> architecture doc). Field names below match the Swift schema types -> (`ASFWMCP*Request` / `ASFWMCPTransactionResult` / `ASFWMCPPolicyDecision`). -> Audio-layer controls are intentionally out of scope for this phase. +> an agent should expect. The schema, discovery, policy, and core `callTool` +> dispatch layers are implemented and unit-tested. Live protocol/driver adapter +> work remains separate; tools without a concrete driver surface return explicit +> `capabilityUnavailable` / `notImplemented` results rather than bypassing +> policy. Field names below match the Swift schema types (`ASFWMCP*Request` / +> `ASFWMCPTransactionResult` / `ASFWMCPPolicyDecision`). Audio-layer controls are +> intentionally out of scope for this phase. ## 1. Parameter conventions From 39cdfaba77ff9d6798791ed26d2a3cd501d30728 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 21:24:56 +0200 Subject: [PATCH 18/23] FW-95: bind MCP core to Swift SDK --- ASFW.xcodeproj/project.pbxproj | 35 +++ .../xcshareddata/swiftpm/Package.resolved | 69 ++++++ ASFW/MCP/ASFWMCPSDKBridge.swift | 218 ++++++++++++++++++ ASFWTests/MCP/MCPSDKBridgeTests.swift | 96 ++++++++ 4 files changed, 418 insertions(+) create mode 100644 ASFW.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ASFW/MCP/ASFWMCPSDKBridge.swift create mode 100644 ASFWTests/MCP/MCPSDKBridgeTests.swift diff --git a/ASFW.xcodeproj/project.pbxproj b/ASFW.xcodeproj/project.pbxproj index fcabb0d4..53bec439 100644 --- a/ASFW.xcodeproj/project.pbxproj +++ b/ASFW.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ 3A1694002E8087BD000BD368 /* PCIDriverKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A1693FF2E8087BD000BD368 /* PCIDriverKit.framework */; }; 3A27C5302ECDE045009CA664 /* bump.sh in Resources */ = {isa = PBXBuildFile; fileRef = 3A27C52E2ECDE045009CA664 /* bump.sh */; }; 3A27C5322ECDE045009CA664 /* bump.sh in Resources */ = {isa = PBXBuildFile; fileRef = 3A27C52E2ECDE045009CA664 /* bump.sh */; }; + 3A9501012F13000000C0DE95 /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = 3A9501002F13000000C0DE95 /* MCP */; }; + 3A9501032F13000000C0DE95 /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = 3A9501022F13000000C0DE95 /* MCP */; }; 3ABA31132EF8564A0046405D /* AudioDriverKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3ABA31122EF8564A0046405D /* AudioDriverKit.framework */; }; /* End PBXBuildFile section */ @@ -95,6 +97,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3A9501012F13000000C0DE95 /* MCP in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -112,6 +115,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3A9501032F13000000C0DE95 /* MCP in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -185,6 +189,9 @@ 3A1693DA2E808727000BD368 /* ASFW */, ); name = ASFW; + packageProductDependencies = ( + 3A9501002F13000000C0DE95 /* MCP */, + ); productName = ASFW; productReference = 3A1693D82E808727000BD368 /* ASFW.app */; productType = "com.apple.product-type.application"; @@ -229,6 +236,7 @@ ); name = ASFWTests; packageProductDependencies = ( + 3A9501022F13000000C0DE95 /* MCP */, ); productName = ASFWTests; productReference = 3AB4713F2EE31CF0003A4E2A /* ASFWTests.xctest */; @@ -266,6 +274,9 @@ ); mainGroup = 3A1693CF2E808727000BD368; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 3A9501042F13000000C0DE95 /* XCRemoteSwiftPackageReference "swift-sdk" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 3A1693D92E808727000BD368 /* Products */; projectDirPath = ""; @@ -730,6 +741,30 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 3A9501042F13000000C0DE95 /* XCRemoteSwiftPackageReference "swift-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/modelcontextprotocol/swift-sdk.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.11.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 3A9501002F13000000C0DE95 /* MCP */ = { + isa = XCSwiftPackageProductDependency; + package = 3A9501042F13000000C0DE95 /* XCRemoteSwiftPackageReference "swift-sdk" */; + productName = MCP; + }; + 3A9501022F13000000C0DE95 /* MCP */ = { + isa = XCSwiftPackageProductDependency; + package = 3A9501042F13000000C0DE95 /* XCRemoteSwiftPackageReference "swift-sdk" */; + productName = MCP; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 3A1693D02E808727000BD368 /* Project object */; } diff --git a/ASFW.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ASFW.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..edf255be --- /dev/null +++ b/ASFW.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "0bf417fd79d33a52ec3432f171c6a1f84ce5ef86d96adf4b24a60858943a49f6", + "pins" : [ + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/eventsource.git", + "state" : { + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a", + "version" : "1.13.2" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "77b84ac2cd2ac9e4ac67d19f045fd5b434f56967", + "version" : "2.101.0" + } + }, + { + "identity" : "swift-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", + "state" : { + "revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605", + "version" : "0.12.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1", + "version" : "1.7.2" + } + } + ], + "version" : 3 +} diff --git a/ASFW/MCP/ASFWMCPSDKBridge.swift b/ASFW/MCP/ASFWMCPSDKBridge.swift new file mode 100644 index 00000000..6df6ec94 --- /dev/null +++ b/ASFW/MCP/ASFWMCPSDKBridge.swift @@ -0,0 +1,218 @@ +import Foundation +import MCP + +// FW-95: Swift MCP SDK binding. +// +// This adapter is intentionally thin: ASFWMCPCore owns tool/resource semantics, +// policy, and driver access. The SDK bridge only translates ASFW's value model +// into MCP SDK declarations/results and registers handlers on an MCP Server. + +struct ASFWMCPSDKBridge { + let core: ASFWMCPCore + + func registerHandlers(on server: Server) async { + await server.withMethodHandler(ListTools.self) { _ in + ListTools.Result(tools: await listTools()) + } + + await server.withMethodHandler(CallTool.self) { params in + await callTool(params) + } + + await server.withMethodHandler(ListResources.self) { _ in + ListResources.Result(resources: await listResources()) + } + + await server.withMethodHandler(ReadResource.self) { params in + await readResource(uri: params.uri) + } + } + + func listTools() async -> [Tool] { + await core.listTools().map(\.mcpTool) + } + + func listResources() async -> [Resource] { + await core.listResources().map(\.mcpResource) + } + + func callTool(_ params: CallTool.Parameters) async -> CallTool.Result { + let arguments = params.arguments.map(ASFWMCPValue.init(mcpObject:)) ?? .object([:]) + let result = await core.callTool(name: params.name, arguments: arguments) + let structuredContent = result.mcpValue + let text = structuredContent.prettyJSONString ?? "\(structuredContent)" + return CallTool.Result( + content: [.text(text: text, annotations: nil, _meta: nil)], + structuredContent: Optional.some(structuredContent), + isError: result.ok == false + ) + } + + func readResource(uri: String) async -> ReadResource.Result { + let envelope = await core.readResource(uri: uri) + let value = envelope.mcpValue + let text = value.prettyJSONString ?? "\(value)" + return ReadResource.Result(contents: [ + Resource.Content.text(text, uri: uri, mimeType: "application/json") + ]) + } +} + +extension ASFWMCPToolDefinition { + var mcpTool: Tool { + Tool( + name: name, + description: summary, + inputSchema: .object([ + "type": .string("object"), + "additionalProperties": .bool(true) + ]), + annotations: Tool.Annotations( + readOnlyHint: readOnly, + destructiveHint: readOnly ? false : true, + idempotentHint: idempotent, + openWorldHint: false + ) + ) + } +} + +extension ASFWMCPResourceDefinition { + var mcpResource: Resource { + Resource( + name: uri, + uri: uri, + description: summary, + mimeType: "application/json" + ) + } +} + +extension ASFWMCPValue { + nonisolated init(mcpObject: [String: MCP.Value]) { + self = .object(mcpObject.mapValues(ASFWMCPValue.init(mcpValue:))) + } + + nonisolated init(mcpValue: MCP.Value) { + switch mcpValue { + case .null: + self = .null + case .bool(let value): + self = .bool(value) + case .int(let value): + self = .int(value) + case .double(let value): + if value.isFinite, + value.rounded(.towardZero) == value, + value >= Double(Int.min), + value <= Double(Int.max) { + self = .int(Int(value)) + } else { + self = .string(String(value)) + } + case .string(let value): + self = .string(value) + case .data(_, let data): + self = .array(data.map { .int(Int($0)) }) + case .array(let values): + self = .array(values.map(ASFWMCPValue.init(mcpValue:))) + case .object(let object): + self = .object(object.mapValues(ASFWMCPValue.init(mcpValue:))) + } + } + + var mcpValue: MCP.Value { + switch self { + case .null: + return .null + case .bool(let value): + return .bool(value) + case .int(let value): + return .int(value) + case .uint64(let value): + if value <= UInt64(Int.max) { + return .int(Int(value)) + } + return .string(String(value)) + case .string(let value): + return .string(value) + case .array(let values): + return .array(values.map(\.mcpValue)) + case .object(let object): + return .object(object.mapValues(\.mcpValue)) + } + } +} + +extension ASFWMCPToolCallResult { + var mcpValue: MCP.Value { + .object([ + "toolName": .string(toolName), + "ok": .bool(ok), + "data": data.mcpValue, + "errors": .array(errors.map(\.mcpValue)) + ]) + } +} + +extension ASFWMCPResourceError { + var mcpValue: MCP.Value { + .object([ + "code": .string(code.rawValue), + "reason": .string(reason) + ]) + } +} + +extension ASFWMCPResourceEnvelope { + var mcpValue: MCP.Value { + .object([ + "schema": .string(schema), + "uri": .string(uri), + "snapshotId": .string(snapshotId), + "capturedAt": capturedAt.map { .string($0.ISO8601Format()) } ?? .null, + "monotonicNs": monotonicNs.map { ASFWMCPValue.uint64($0).mcpValue } ?? .null, + "generation": generation.map { .int(Int($0)) } ?? .null, + "driverConnected": .bool(driverConnected), + "stale": .bool(stale), + "truncated": .bool(truncated), + "data": data.mcpValue, + "links": .array(links.map { .string($0) }), + "errors": .array(errors.map(\.mcpValue)) + ]) + } +} + +private extension MCP.Value { + var jsonCompatibleObject: Any { + switch self { + case .null: + return NSNull() + case .bool(let value): + return value + case .int(let value): + return value + case .double(let value): + return value + case .string(let value): + return value + case .data(_, let data): + return data.map { Int($0) } + case .array(let values): + return values.map(\.jsonCompatibleObject) + case .object(let object): + return object.mapValues(\.jsonCompatibleObject) + } + } + + var prettyJSONString: String? { + guard JSONSerialization.isValidJSONObject(jsonCompatibleObject), + let data = try? JSONSerialization.data( + withJSONObject: jsonCompatibleObject, + options: [.prettyPrinted, .sortedKeys] + ) else { + return nil + } + return String(data: data, encoding: .utf8) + } +} diff --git a/ASFWTests/MCP/MCPSDKBridgeTests.swift b/ASFWTests/MCP/MCPSDKBridgeTests.swift new file mode 100644 index 00000000..723d3e32 --- /dev/null +++ b/ASFWTests/MCP/MCPSDKBridgeTests.swift @@ -0,0 +1,96 @@ +import MCP +import Testing +@testable import ASFW + +struct MCPSDKBridgeTests { + private func bridge( + configuration: ASFWMCPRuntimeConfiguration = .readOnlyDeveloper, + driver: MockASFWDriverControl = MockASFWDriverControl() + ) -> ASFWMCPSDKBridge { + ASFWMCPSDKBridge(core: ASFWMCPCore(configuration: configuration, driver: driver)) + } + + @Test func toolMetadataMapsCatalogDefinitionToSDKTool() async throws { + let tools = await bridge().listTools() + let readQuadlet = try #require(tools.first { $0.name == "asfw_read_quadlet" }) + + #expect(readQuadlet.description == "Submit an async quadlet read.") + #expect(readQuadlet.annotations.readOnlyHint == true) + #expect(readQuadlet.annotations.idempotentHint == false) + #expect(readQuadlet.annotations.destructiveHint == false) + #expect(readQuadlet.annotations.openWorldHint == false) + + guard case .object(let schema) = readQuadlet.inputSchema else { + Issue.record("Tool input schema should be an object.") + return + } + #expect(schema["type"] == .string("object")) + } + + @Test func resourceMetadataMapsToJSONResources() async throws { + let resources = await bridge().listResources() + let snapshot = try #require(resources.first { $0.uri == "asfw://telemetry/snapshot" }) + + #expect(snapshot.name == "asfw://telemetry/snapshot") + #expect(snapshot.mimeType == "application/json") + #expect(snapshot.description == "Compact cross-system telemetry overview.") + } + + @Test func sdkCallToolDelegatesToCoreDispatch() async throws { + let result = await bridge().callTool( + CallTool.Parameters( + name: "asfw_read_quadlet", + arguments: [ + "nodeId": .int(1), + "generation": .int(17), + "addressHigh": .int(0xFFFF), + "addressLow": .int(0xF0000400) + ] + ) + ) + + #expect(result.isError == false) + guard case .object(let root)? = result.structuredContent, + case .object(let data)? = root["data"] else { + Issue.record("Tool result should include structured transaction data.") + return + } + #expect(data["kind"] == .string("readQuadlet")) + #expect(data["status"] == .string("ok")) + } + + @Test func sdkCallToolPreservesPolicyRefusalAsErrorResult() async throws { + let driver = MockASFWDriverControl() + let result = await bridge(configuration: .readOnlyDeveloper, driver: driver).callTool( + CallTool.Parameters( + name: "asfw_write_quadlet", + arguments: [ + "nodeId": .int(1), + "generation": .int(17), + "addressHigh": .int(0xFFFF), + "addressLow": .int(0xF0000800), + "value": .int(1) + ] + ) + ) + + #expect(result.isError == true) + #expect(await driver.unexpectedWriteAttemptCount() == 0) + guard case .object(let root)? = result.structuredContent, + case .object(let data)? = root["data"], + case .object(let policy)? = data["policy"] else { + Issue.record("Policy refusal should be preserved in structured content.") + return + } + #expect(policy["decision"] == .string("requiresDeveloperMode")) + } + + @Test func sdkReadResourceReturnsJSONTextContent() async throws { + let result = await bridge().readResource(uri: "asfw://telemetry/snapshot") + let content = try #require(result.contents.first) + + #expect(content.uri == "asfw://telemetry/snapshot") + #expect(content.mimeType == "application/json") + #expect(content.text?.contains("\"schema\" : \"asfw.telemetry.snapshot.v1\"") == true) + } +} From 23d2137d4f146c59d9f56f4c9fe7a31b0b408eb7 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 21:42:01 +0200 Subject: [PATCH 19/23] FW-94: implement live MCP driver adapter --- ASFW/MCP/ASFWMCPLiveDriverControl.swift | 554 ++++++++++++++++++ ASFW/MCP/ASFWMCPTransactionSchemas.swift | 2 + ASFWTests/MCP/MCPLiveDriverControlTests.swift | 173 ++++++ 3 files changed, 729 insertions(+) create mode 100644 ASFW/MCP/ASFWMCPLiveDriverControl.swift create mode 100644 ASFWTests/MCP/MCPLiveDriverControlTests.swift diff --git a/ASFW/MCP/ASFWMCPLiveDriverControl.swift b/ASFW/MCP/ASFWMCPLiveDriverControl.swift new file mode 100644 index 00000000..d4529f05 --- /dev/null +++ b/ASFW/MCP/ASFWMCPLiveDriverControl.swift @@ -0,0 +1,554 @@ +import Foundation + +@MainActor +protocol ASFWLiveDriverBackend: AnyObject { + var mcpIsConnected: Bool { get } + var mcpLastError: String? { get } + + func mcpCurrentGeneration() -> UInt32? + func mcpControllerStatus() -> ControllerStatus? + func mcpFetchDiagnostics() throws -> ASFWDiagnosticsSnapshot + func mcpDiscoveredDevices() -> [FWDeviceInfo]? + func mcpAVCUnits() -> [AVCUnitInfo]? + + func mcpAsyncRead(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, length: UInt32) -> UInt16? + func mcpAsyncWrite(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, payload: Data) -> UInt16? + func mcpAsyncBlockRead(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, length: UInt32) -> UInt16? + func mcpAsyncBlockWrite(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, payload: Data) -> UInt16? + func mcpAsyncCompareSwap(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, compareValue: Data, newValue: Data) -> UInt16? + func mcpTransactionResult(handle: UInt16, initialPayloadCapacity: Int) -> ASFWDriverConnector.AsyncTransactionResult? +} + +extension ASFWDriverConnector: ASFWLiveDriverBackend { + var mcpIsConnected: Bool { isConnected } + var mcpLastError: String? { lastError } + + func mcpCurrentGeneration() -> UInt32? { + getControllerStatus()?.generation + } + + func mcpControllerStatus() -> ControllerStatus? { + getControllerStatus() + } + + func mcpFetchDiagnostics() throws -> ASFWDiagnosticsSnapshot { + try ASFWDiagnosticsClient(connector: self).fetchSnapshot() + } + + func mcpDiscoveredDevices() -> [FWDeviceInfo]? { + getDiscoveredDevices() + } + + func mcpAVCUnits() -> [AVCUnitInfo]? { + getAVCUnits() + } + + func mcpAsyncRead(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, length: UInt32) -> UInt16? { + asyncRead(destinationID: destinationID, addressHigh: addressHigh, addressLow: addressLow, length: length) + } + + func mcpAsyncWrite(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, payload: Data) -> UInt16? { + asyncWrite(destinationID: destinationID, addressHigh: addressHigh, addressLow: addressLow, payload: payload) + } + + func mcpAsyncBlockRead(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, length: UInt32) -> UInt16? { + asyncBlockRead(destinationID: destinationID, addressHigh: addressHigh, addressLow: addressLow, length: length) + } + + func mcpAsyncBlockWrite(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, payload: Data) -> UInt16? { + asyncBlockWrite(destinationID: destinationID, addressHigh: addressHigh, addressLow: addressLow, payload: payload) + } + + func mcpAsyncCompareSwap(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, compareValue: Data, newValue: Data) -> UInt16? { + asyncCompareSwap( + destinationID: destinationID, + addressHigh: addressHigh, + addressLow: addressLow, + compareValue: compareValue, + newValue: newValue + )?.handle + } + + func mcpTransactionResult(handle: UInt16, initialPayloadCapacity: Int) -> ASFWDriverConnector.AsyncTransactionResult? { + getTransactionResult(handle: handle, initialPayloadCapacity: initialPayloadCapacity) + } +} + +@MainActor +final class LiveASFWDriverControl: ASFWDriverControlling { + private let backend: any ASFWLiveDriverBackend + private let transactionTimeout: TimeInterval + private let pollIntervalNs: UInt64 + + init( + backend: any ASFWLiveDriverBackend, + transactionTimeout: TimeInterval = 2.0, + pollIntervalNs: UInt64 = 25_000_000 + ) { + self.backend = backend + self.transactionTimeout = transactionTimeout + self.pollIntervalNs = pollIntervalNs + } + + func fetchTelemetrySnapshot(configuration: ASFWMCPRuntimeConfiguration) async -> ASFWMCPTelemetrySnapshot { + let status = backend.mcpControllerStatus() + let diagnostics = try? backend.mcpFetchDiagnostics() + let nodes = listNodesFromBackend() + let events = recentTransactions(from: diagnostics?.asyncTrace, limit: Int(ASFW_DIAG_MAX_ASYNC_EVENTS)) + let generation = diagnostics?.busContract.header.generation ?? status?.generation ?? backend.mcpCurrentGeneration() ?? 0 + let nodeCount = diagnostics?.busContract.nodeCount ?? status?.nodeCount ?? UInt32(nodes.count) + let busResetCount = UInt64(diagnostics?.busContract.asfwInitiatedResetCount ?? 0) + let topologyValid = diagnostics?.topology.valid != 0 || status != nil + + return ASFWMCPTelemetrySnapshot( + snapshotId: backend.mcpIsConnected ? "live-\(generation)-\(DispatchTime.now().uptimeNanoseconds)" : "live-unavailable", + capturedAt: Date(), + monotonicNs: DispatchTime.now().uptimeNanoseconds, + generation: generation, + driverConnected: backend.mcpIsConnected, + controller: ASFWMCPControllerTelemetry( + state: status?.stateName ?? (backend.mcpIsConnected ? "Unknown" : "Disconnected"), + linkActive: status?.nodeCount ?? 0 > 0, + localNodeId: diagnostics?.busContract.localNode.nodeIdOrNil ?? status?.localNodeID.map(UInt32.init), + rootNodeId: diagnostics?.busContract.rootNode.nodeIdOrNil ?? status?.rootNodeID.map(UInt32.init), + irmNodeId: diagnostics?.busContract.irmNode.nodeIdOrNil ?? status?.irmNodeID.map(UInt32.init), + isIRM: status?.isIRM ?? false, + isCycleMaster: status?.isCycleMaster ?? false + ), + bus: ASFWMCPBusTelemetry( + generation: generation, + nodeCount: nodeCount, + busResetCount: status?.busResetCount ?? busResetCount, + gapCount: diagnostics?.busContract.gapCount ?? 0, + topologyValid: topologyValid + ), + async: ASFWMCPAsyncTelemetry( + recentEventCount: UInt32(events.count), + droppedEventCount: diagnostics?.asyncTrace.droppedCount ?? 0, + timeouts: UInt32(events.filter { $0.rCode == "timeout" || $0.dropReason == "timeout" }.count), + lastCompletionNs: events.last?.timestampNs + ), + protocols: protocolTelemetry(nodes: nodes), + policy: ASFWMCPPolicyTelemetry( + runtimeMode: configuration.mode, + writesListed: configuration.canListDeveloperWriteTools, + writeGate: configuration.canListDeveloperWriteTools ? "open" : "testGateMissing" + ) + ) + } + + func listNodes() async -> [ASFWMCPNodeSummary] { + listNodesFromBackend() + } + + func listRecentTransactions(limit: Int) async -> [ASFWMCPTransactionEvent] { + guard let diagnostics = try? backend.mcpFetchDiagnostics() else { return [] } + return recentTransactions(from: diagnostics.asyncTrace, limit: limit) + } + + func executeReadQuadlet(_ request: ASFWMCPReadQuadletRequest) async -> ASFWMCPTransactionResult { + await executeTransaction( + kind: request.kind, + address: request.address, + payloadCapacity: 4, + issue: { + backend.mcpAsyncRead( + destinationID: UInt16(truncatingIfNeeded: request.address.nodeId), + addressHigh: request.address.addressHigh, + addressLow: request.address.addressLow, + length: 4 + ) + } + ) + } + + func executeReadBlock(_ request: ASFWMCPReadBlockRequest) async -> ASFWMCPTransactionResult { + if request.validationError != nil { + return .malformed(kind: request.kind, correlationId: correlationId(request.kind), generation: request.address.generation) + } + + return await executeTransaction( + kind: request.kind, + address: request.address, + payloadCapacity: Int(request.length), + issue: { + backend.mcpAsyncBlockRead( + destinationID: UInt16(truncatingIfNeeded: request.address.nodeId), + addressHigh: request.address.addressHigh, + addressLow: request.address.addressLow, + length: request.length + ) + } + ) + } + + func executeWriteQuadlet(_ request: ASFWMCPWriteQuadletRequest) async -> ASFWMCPTransactionResult { + let writeResult = await executeTransaction( + kind: request.kind, + address: request.address, + payloadCapacity: 4, + issue: { + backend.mcpAsyncWrite( + destinationID: UInt16(truncatingIfNeeded: request.address.nodeId), + addressHigh: request.address.addressHigh, + addressLow: request.address.addressLow, + payload: Data(quadletBytes(request.value)) + ) + } + ) + + guard request.verifyReadback, writeResult.ok else { return writeResult } + let readback = await executeReadQuadlet(ASFWMCPReadQuadletRequest(address: request.address)) + return writeResult.replacingVerificationPayload(readback.payload, ok: readback.ok) + } + + func executeWriteBlock(_ request: ASFWMCPWriteBlockRequest) async -> ASFWMCPTransactionResult { + if request.validationError != nil { + return .malformed(kind: request.kind, correlationId: correlationId(request.kind), generation: request.address.generation) + } + + let writeResult = await executeTransaction( + kind: request.kind, + address: request.address, + payloadCapacity: request.payload.count, + issue: { + backend.mcpAsyncBlockWrite( + destinationID: UInt16(truncatingIfNeeded: request.address.nodeId), + addressHigh: request.address.addressHigh, + addressLow: request.address.addressLow, + payload: Data(request.payload) + ) + } + ) + + guard request.verifyReadback, writeResult.ok else { return writeResult } + let readback = await executeReadBlock(ASFWMCPReadBlockRequest(address: request.address, length: UInt32(request.payload.count))) + return writeResult.replacingVerificationPayload(readback.payload, ok: readback.ok) + } + + func executeCompareSwap(_ request: ASFWMCPCompareSwapRequest) async -> ASFWMCPTransactionResult { + await executeTransaction( + kind: request.kind, + address: request.address, + payloadCapacity: 4, + issue: { + backend.mcpAsyncCompareSwap( + destinationID: UInt16(truncatingIfNeeded: request.address.nodeId), + addressHigh: request.address.addressHigh, + addressLow: request.address.addressLow, + compareValue: Data(quadletBytes(request.expected)), + newValue: Data(quadletBytes(request.swap)) + ) + } + ) + } + + private func executeTransaction( + kind: ASFWMCPTransactionKind, + address: ASFWMCPAddress, + payloadCapacity: Int, + issue: () -> UInt16? + ) async -> ASFWMCPTransactionResult { + let correlationId = correlationId(kind) + guard backend.mcpIsConnected else { + return unavailable(kind: kind, generation: address.generation, correlationId: correlationId, reason: "Driver is not connected.") + } + + guard let currentGeneration = backend.mcpCurrentGeneration() else { + return unavailable(kind: kind, generation: address.generation, correlationId: correlationId, reason: "Current bus generation is unavailable.") + } + + guard currentGeneration == address.generation else { + return ASFWMCPTransactionResult( + kind: kind, + ok: false, + status: .staleGeneration, + generation: currentGeneration, + correlationId: correlationId, + rCode: "staleGeneration" + ) + } + + let started = Date() + guard let handle = issue() else { + return unavailable( + kind: kind, + generation: currentGeneration, + correlationId: correlationId, + reason: backend.mcpLastError ?? "Driver did not return a transaction handle." + ) + } + + let deadline = started.addingTimeInterval(transactionTimeout) + while Date() < deadline { + if let result = backend.mcpTransactionResult(handle: handle, initialPayloadCapacity: max(payloadCapacity, 64)) { + return mapResult( + result, + kind: kind, + generation: currentGeneration, + correlationId: correlationId, + started: started + ) + } + + try? await Task.sleep(nanoseconds: pollIntervalNs) + } + + return ASFWMCPTransactionResult( + kind: kind, + ok: false, + status: .timeout, + generation: currentGeneration, + correlationId: correlationId, + rCode: "timeout", + durationUsec: elapsedUsec(since: started) + ) + } + + private func mapResult( + _ result: ASFWDriverConnector.AsyncTransactionResult, + kind: ASFWMCPTransactionKind, + generation: UInt32, + correlationId: String, + started: Date + ) -> ASFWMCPTransactionResult { + let status = transactionStatus(asyncStatus: result.status, rCode: result.responseCode) + return ASFWMCPTransactionResult( + kind: kind, + ok: status == .ok, + status: status, + generation: generation, + correlationId: correlationId, + rCode: rCodeName(result.responseCode), + durationUsec: elapsedUsec(since: started), + payload: result.payload.isEmpty ? nil : Array(result.payload) + ) + } + + private func listNodesFromBackend() -> [ASFWMCPNodeSummary] { + let devices = backend.mcpDiscoveredDevices() ?? [] + let avcNodeIds = Set((backend.mcpAVCUnits() ?? []).map { physicalNodeId($0.nodeID) }) + let busBase16 = (try? backend.mcpFetchDiagnostics()).map { UInt16(truncatingIfNeeded: $0.topology.busBase16) } ?? 0 + + return devices.map { device in + let physicalNode = UInt32(device.nodeId) + let address16 = busBase16 | UInt16(truncatingIfNeeded: physicalNode & 0x3F) + let hints = protocolHints(for: device, avcNodeIds: avcNodeIds) + return ASFWMCPNodeSummary( + nodeId: physicalNode, + address16: String(format: "0x%04X", address16), + guid: String(format: "0x%016llX", device.guid), + vendorId: String(format: "0x%06X", device.vendorId), + modelId: String(format: "0x%06X", device.modelId), + vendorName: device.vendorName.isEmpty ? nil : device.vendorName, + modelName: device.modelName.isEmpty ? nil : device.modelName, + configRomCached: true, + protocolHints: hints + ) + } + } + + private func physicalNodeId(_ nodeId: UInt16) -> UInt32 { + UInt32(nodeId & 0x003F) + } + + private func protocolHints(for device: FWDeviceInfo, avcNodeIds: Set) -> [String] { + var hints = Set() + if avcNodeIds.contains(UInt32(device.nodeId)) { + hints.insert("avc") + hints.insert("cmp") + } + if device.hasSBP2Unit { + hints.insert("sbp2") + } + + let vendor = device.vendorName.lowercased() + let model = device.modelName.lowercased() + if device.vendorId == 0x00130E || vendor.contains("tcat") || model.contains("dice") || model.contains("tcat") { + hints.insert("dice_tcat") + } + + return hints.sorted() + } + + private func protocolTelemetry(nodes: [ASFWMCPNodeSummary]) -> ASFWMCPProtocolTelemetry { + ASFWMCPProtocolTelemetry( + avcUnits: UInt32(nodes.filter { $0.protocolHints.contains("avc") }.count), + sbp2Units: UInt32(nodes.filter { $0.protocolHints.contains("sbp2") }.count), + diceTcatNodes: UInt32(nodes.filter { $0.protocolHints.contains("dice_tcat") }.count), + cmpCapableNodes: UInt32(nodes.filter { $0.protocolHints.contains("cmp") }.count) + ) + } + + private func recentTransactions(from trace: ASFWDiagAsyncTrace?, limit: Int) -> [ASFWMCPTransactionEvent] { + guard let trace else { return [] } + let eventCount = Int(min(trace.eventCount, UInt32(ASFW_DIAG_MAX_ASYNC_EVENTS))) + let clampedLimit = max(0, min(limit, eventCount)) + let events: [ASFWDiagAsyncEvent] = withUnsafeBytes(of: trace.events) { buffer in + Array(buffer.bindMemory(to: ASFWDiagAsyncEvent.self).prefix(eventCount)) + } + return events.suffix(clampedLimit).map { event in + ASFWMCPTransactionEvent( + timestampNs: event.timestampNs, + generation: event.generation, + direction: event.direction == 1 ? "tx" : "rx", + context: contextName(direction: event.direction, context: event.context), + tLabel: event.tLabel, + tCode: tCodeName(event.tCode), + sourceId: String(format: "0x%04X", event.sourceId), + destinationId: String(format: "0x%04X", event.destinationId), + address: String(format: "0x%012llX", event.address), + payloadBytes: event.payloadBytes, + ackCode: ackCodeName(event.ackCode), + rCode: rCodeName(UInt8(truncatingIfNeeded: event.rCode)), + speed: speedName(event.speed), + matchedTransaction: event.matchedTransaction != 0, + dropReason: dropReasonName(event.dropReason) + ) + } + } + + private func transactionStatus(asyncStatus: UInt32, rCode: UInt8) -> ASFWMCPTransactionStatus { + switch asyncStatus { + case 0 where rCode == 0: + return .ok + case 0: + return .rcodeError + case 1: + return .timeout + case 4: + return .busReset + case 6: + return .compareFailed + case 7: + return .staleGeneration + default: + return .rcodeError + } + } + + private func unavailable( + kind: ASFWMCPTransactionKind, + generation: UInt32, + correlationId: String, + reason: String + ) -> ASFWMCPTransactionResult { + ASFWMCPTransactionResult( + kind: kind, + ok: false, + status: .unavailable, + generation: generation, + correlationId: correlationId, + rCode: reason + ) + } + + private func correlationId(_ kind: ASFWMCPTransactionKind) -> String { + "live-\(kind.rawValue)-\(UUID().uuidString)" + } + + private func elapsedUsec(since started: Date) -> UInt64 { + UInt64(max(0, Date().timeIntervalSince(started) * 1_000_000)) + } + + private func quadletBytes(_ value: UInt32) -> [UInt8] { + [ + UInt8((value >> 24) & 0xFF), + UInt8((value >> 16) & 0xFF), + UInt8((value >> 8) & 0xFF), + UInt8(value & 0xFF) + ] + } +} + +private extension UInt32 { + var nodeIdOrNil: UInt32? { + self >= 0x3F ? nil : self + } +} + +private extension ASFWMCPTransactionResult { + func replacingVerificationPayload(_ payload: [UInt8]?, ok: Bool) -> ASFWMCPTransactionResult { + ASFWMCPTransactionResult( + kind: kind, + ok: ok, + status: ok ? status : .rcodeError, + generation: generation, + correlationId: correlationId, + rCode: rCode, + durationUsec: durationUsec, + payload: payload, + decoded: decoded, + policy: policy + ) + } +} + +private func ackCodeName(_ code: UInt32) -> String { + if code == 0xFF { return "-" } + switch code { + case 0x01: return "complete" + case 0x02: return "pending" + case 0x04: return "busyX" + case 0x05: return "busyA" + case 0x06: return "busyB" + case 0x0D: return "dataError" + case 0x0E: return "typeError" + default: return String(format: "0x%02X", code) + } +} + +private func rCodeName(_ code: UInt8) -> String { + if code == 0xFF { return "-" } + switch code { + case 0: return "complete" + case 4: return "conflictError" + case 5: return "dataError" + case 6: return "typeError" + case 7: return "addressError" + default: return String(format: "0x%02X", code) + } +} + +private func tCodeName(_ code: UInt32) -> String { + switch code { + case 0: return "writeQuadlet" + case 1: return "writeBlock" + case 2: return "writeResponse" + case 4: return "readQuadlet" + case 5: return "readBlock" + case 6: return "readQuadletResponse" + case 7: return "readBlockResponse" + case 9: return "lock" + case 11: return "lockResponse" + default: return String(format: "0x%02X", code) + } +} + +private func speedName(_ speed: UInt32) -> String { + switch speed { + case ASFWDiagSpeedS100.rawValue: return "S100" + case ASFWDiagSpeedS200.rawValue: return "S200" + case ASFWDiagSpeedS400.rawValue: return "S400" + case ASFWDiagSpeedS800.rawValue: return "S800" + case ASFWDiagSpeedS1600.rawValue: return "S1600" + case ASFWDiagSpeedS3200.rawValue: return "S3200" + default: return "unknown" + } +} + +private func contextName(direction: UInt32, context: UInt32) -> String { + let prefix = direction == 1 ? "AT" : "AR" + let suffix = context == 0 ? "Request" : (context == 1 ? "Response" : "Unknown") + return "\(prefix)\(suffix)" +} + +private func dropReasonName(_ reason: UInt32) -> String? { + guard reason != 0 else { return nil } + switch reason { + case 1: return "ringFull" + case 2: return "malformed" + case 3: return "unmatched" + default: return String(format: "0x%02X", reason) + } +} diff --git a/ASFW/MCP/ASFWMCPTransactionSchemas.swift b/ASFW/MCP/ASFWMCPTransactionSchemas.swift index 49f09adf..e2c84954 100644 --- a/ASFW/MCP/ASFWMCPTransactionSchemas.swift +++ b/ASFW/MCP/ASFWMCPTransactionSchemas.swift @@ -138,6 +138,8 @@ enum ASFWMCPTransactionStatus: String, Equatable { case rcodeError case busReset case compareFailed + case staleGeneration + case unavailable /// Refused by the FW-79 write policy before reaching the driver. case denied /// Policy-cleared shape that was intentionally not executed. diff --git a/ASFWTests/MCP/MCPLiveDriverControlTests.swift b/ASFWTests/MCP/MCPLiveDriverControlTests.swift new file mode 100644 index 00000000..1ca08196 --- /dev/null +++ b/ASFWTests/MCP/MCPLiveDriverControlTests.swift @@ -0,0 +1,173 @@ +import Foundation +import Testing +@testable import ASFW + +@MainActor +struct MCPLiveDriverControlTests { + @Test func readQuadletPollsBackendAndMapsPayload() async { + let backend = FakeLiveDriverBackend() + backend.results[0x44] = ASFWDriverConnector.AsyncTransactionResult( + status: 0, + dataLength: 4, + responseCode: 0, + payload: Data([0x31, 0x33, 0x39, 0x34]) + ) + let control = LiveASFWDriverControl(backend: backend, transactionTimeout: 0.1, pollIntervalNs: 1_000) + + let result = await control.executeReadQuadlet( + ASFWMCPReadQuadletRequest(address: address(generation: 17)) + ) + + #expect(result.ok == true) + #expect(result.status == .ok) + #expect(result.rCode == "complete") + #expect(result.payload == [0x31, 0x33, 0x39, 0x34]) + #expect(backend.reads == 1) + #expect(backend.resultPolls == 1) + } + + @Test func staleGenerationDoesNotReachDriverTransactionPath() async { + let backend = FakeLiveDriverBackend() + backend.generation = 18 + let control = LiveASFWDriverControl(backend: backend, transactionTimeout: 0.1, pollIntervalNs: 1_000) + + let result = await control.executeReadBlock( + ASFWMCPReadBlockRequest(address: address(generation: 17), length: 4) + ) + + #expect(result.ok == false) + #expect(result.status == .staleGeneration) + #expect(result.rCode == "staleGeneration") + #expect(backend.blockReads == 0) + #expect(backend.resultPolls == 0) + } + + @Test func nodeDiscoveryMapsProtocolHints() async { + let backend = FakeLiveDriverBackend() + backend.devices = [ + FWDeviceInfo( + id: 0x0011_2233_4455_6677, + guid: 0x0011_2233_4455_6677, + vendorId: 0x0003DB, + modelId: 0x000001, + vendorName: "Apogee", + modelName: "Duet", + nodeId: 0, + generation: 17, + state: .ready, + units: [], + deviceKind: 0 + ), + FWDeviceInfo( + id: 0x00AA_BBCC_DDEE_FF00, + guid: 0x00AA_BBCC_DDEE_FF00, + vendorId: 0x00130E, + modelId: 0x000002, + vendorName: "TCAT", + modelName: "DICE", + nodeId: 1, + generation: 17, + state: .ready, + units: [sbp2Unit()], + deviceKind: 4 + ) + ] + backend.avcUnits = [ + AVCUnitInfo( + guid: 0x0011_2233_4455_6677, + nodeID: 0, + vendorID: 0x0003DB, + modelID: 0x000001, + subunits: [], + isoInputPlugs: 1, + isoOutputPlugs: 1, + extInputPlugs: 1, + extOutputPlugs: 1 + ) + ] + + let nodes = await LiveASFWDriverControl(backend: backend).listNodes() + + let duet = nodes.first { $0.nodeId == 0 } + let dice = nodes.first { $0.nodeId == 1 } + #expect(duet?.protocolHints == ["avc", "cmp"]) + #expect(dice?.protocolHints == ["dice_tcat", "sbp2"]) + } + + private func address(generation: UInt32) -> ASFWMCPAddress { + ASFWMCPAddress( + nodeId: 0xFFC1, + generation: generation, + addressHigh: 0xFFFF, + addressLow: 0xF0000400 + ) + } + + private func sbp2Unit() -> FWUnitInfo { + FWUnitInfo( + specId: 0x00609E, + swVersion: 0x010483, + state: .ready, + romOffset: 0, + managementAgentOffset: 0x100, + lun: 0, + unitCharacteristics: nil, + fastStart: nil, + vendorName: "Mock Storage", + productName: "Disk" + ) + } +} + +@MainActor +private final class FakeLiveDriverBackend: ASFWLiveDriverBackend { + var mcpIsConnected = true + var mcpLastError: String? + var generation: UInt32 = 17 + var devices: [FWDeviceInfo] = [] + var avcUnits: [AVCUnitInfo] = [] + var nextHandle: UInt16 = 0x44 + var results: [UInt16: ASFWDriverConnector.AsyncTransactionResult] = [:] + var reads = 0 + var blockReads = 0 + var writes = 0 + var blockWrites = 0 + var compareSwaps = 0 + var resultPolls = 0 + + func mcpCurrentGeneration() -> UInt32? { generation } + func mcpControllerStatus() -> ControllerStatus? { nil } + func mcpFetchDiagnostics() throws -> ASFWDiagnosticsSnapshot { throw DiagnosticsError.notConnected } + func mcpDiscoveredDevices() -> [FWDeviceInfo]? { devices } + func mcpAVCUnits() -> [AVCUnitInfo]? { avcUnits } + + func mcpAsyncRead(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, length: UInt32) -> UInt16? { + reads += 1 + return nextHandle + } + + func mcpAsyncWrite(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, payload: Data) -> UInt16? { + writes += 1 + return nextHandle + } + + func mcpAsyncBlockRead(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, length: UInt32) -> UInt16? { + blockReads += 1 + return nextHandle + } + + func mcpAsyncBlockWrite(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, payload: Data) -> UInt16? { + blockWrites += 1 + return nextHandle + } + + func mcpAsyncCompareSwap(destinationID: UInt16, addressHigh: UInt16, addressLow: UInt32, compareValue: Data, newValue: Data) -> UInt16? { + compareSwaps += 1 + return nextHandle + } + + func mcpTransactionResult(handle: UInt16, initialPayloadCapacity: Int) -> ASFWDriverConnector.AsyncTransactionResult? { + resultPolls += 1 + return results[handle] + } +} From 8884f66b71a0f8c742fcf4c9909c081be3bb2df7 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 21:47:34 +0200 Subject: [PATCH 20/23] FW-92: add app-hosted MCP HTTP transport --- ASFW/MCP/ASFWMCPHost.swift | 371 +++++++++++++++++++++++++++++++ ASFWTests/MCP/MCPHostTests.swift | 76 +++++++ 2 files changed, 447 insertions(+) create mode 100644 ASFW/MCP/ASFWMCPHost.swift create mode 100644 ASFWTests/MCP/MCPHostTests.swift diff --git a/ASFW/MCP/ASFWMCPHost.swift b/ASFW/MCP/ASFWMCPHost.swift new file mode 100644 index 00000000..96ef6645 --- /dev/null +++ b/ASFW/MCP/ASFWMCPHost.swift @@ -0,0 +1,371 @@ +import Foundation +import MCP +import Network + +struct ASFWMCPHostConfiguration: Equatable, Sendable { + var bindHost: String + var port: UInt16 + var path: String + + nonisolated init(bindHost: String = "127.0.0.1", port: UInt16 = 8765, path: String = "/mcp") { + self.bindHost = bindHost + self.port = port + self.path = path.hasPrefix("/") ? path : "/\(path)" + } +} + +struct ASFWMCPHostStatus: Equatable, Sendable { + var isRunning: Bool + var endpointURL: URL? + var activeHTTPConnections: Int + + static let stopped = ASFWMCPHostStatus( + isRunning: false, + endpointURL: nil, + activeHTTPConnections: 0 + ) +} + +enum ASFWMCPHostError: Error, Equatable { + case alreadyRunning + case invalidPort(UInt16) + case listenerFailed(String) +} + +@MainActor +final class ASFWMCPHost { + private let core: ASFWMCPCore + private var server: Server? + private var transport: StatefulHTTPServerTransport? + private var httpAdapter: ASFWMCPHTTPAdapter? + private(set) var status: ASFWMCPHostStatus = .stopped + + init(core: ASFWMCPCore) { + self.core = core + } + + func start() async throws -> ASFWMCPHostStatus { + try await start(configuration: ASFWMCPHostConfiguration()) + } + + func start(configuration: ASFWMCPHostConfiguration) async throws -> ASFWMCPHostStatus { + guard status.isRunning == false else { + throw ASFWMCPHostError.alreadyRunning + } + + let server = Server( + name: "ASFW MCP Control Plane", + version: "0.1.0", + capabilities: Server.Capabilities(resources: .init(), tools: .init()) + ) + let transport = StatefulHTTPServerTransport() + await ASFWMCPSDKBridge(core: core).registerHandlers(on: server) + try await server.start(transport: transport) + + let adapter = ASFWMCPHTTPAdapter( + bindHost: configuration.bindHost, + port: configuration.port, + path: configuration.path, + handler: { request in + await transport.handleRequest(request) + }, + connectionCountChanged: { [weak self] count in + Task { @MainActor [weak self] in + self?.status.activeHTTPConnections = count + } + } + ) + + let actualPort = try await adapter.start() + let endpoint = URL(string: "http://\(configuration.bindHost):\(actualPort)\(configuration.path)") + self.server = server + self.transport = transport + self.httpAdapter = adapter + self.status = ASFWMCPHostStatus( + isRunning: true, + endpointURL: endpoint, + activeHTTPConnections: 0 + ) + return status + } + + func stop() async { + await httpAdapter?.stop() + await transport?.disconnect() + await server?.stop() + httpAdapter = nil + transport = nil + server = nil + status = .stopped + } +} + +private actor ASFWMCPHTTPAdapter { + typealias Handler = @Sendable (HTTPRequest) async -> HTTPResponse + typealias ConnectionCountChanged = @Sendable (Int) -> Void + + private let bindHost: String + private let requestedPort: UInt16 + private let path: String + private let handler: Handler + private let connectionCountChanged: ConnectionCountChanged + private var listener: NWListener? + private var activeConnections: [ObjectIdentifier: NWConnection] = [:] + private var readyContinuation: CheckedContinuation? + + init( + bindHost: String, + port: UInt16, + path: String, + handler: @escaping Handler, + connectionCountChanged: @escaping ConnectionCountChanged + ) { + self.bindHost = bindHost + self.requestedPort = port + self.path = path + self.handler = handler + self.connectionCountChanged = connectionCountChanged + } + + func start() async throws -> UInt16 { + guard listener == nil else { + throw ASFWMCPHostError.alreadyRunning + } + guard let nwPort = NWEndpoint.Port(rawValue: requestedPort) else { + throw ASFWMCPHostError.invalidPort(requestedPort) + } + + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + if let ipv4 = IPv4Address(bindHost) { + parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(ipv4), port: nwPort) + } + let listener = try NWListener(using: parameters, on: nwPort) + listener.service = nil + listener.newConnectionHandler = { [weak self] connection in + Task { await self?.accept(connection) } + } + listener.stateUpdateHandler = { [weak self] state in + Task { await self?.listenerStateChanged(state) } + } + self.listener = listener + listener.start(queue: .global(qos: .userInitiated)) + + return try await withCheckedThrowingContinuation { continuation in + self.readyContinuation = continuation + } + } + + func stop() async { + listener?.cancel() + listener = nil + for connection in activeConnections.values { + connection.cancel() + } + activeConnections.removeAll() + connectionCountChanged(0) + } + + private func listenerStateChanged(_ state: NWListener.State) { + switch state { + case .ready: + let port = listener?.port?.rawValue ?? requestedPort + readyContinuation?.resume(returning: port) + readyContinuation = nil + case .failed(let error): + readyContinuation?.resume(throwing: ASFWMCPHostError.listenerFailed(error.localizedDescription)) + readyContinuation = nil + default: + break + } + } + + private func accept(_ connection: NWConnection) { + let id = ObjectIdentifier(connection) + activeConnections[id] = connection + connectionCountChanged(activeConnections.count) + connection.stateUpdateHandler = { [weak self, weak connection] state in + guard case .cancelled = state, let connection else { return } + Task { await self?.remove(connection) } + } + connection.start(queue: .global(qos: .userInitiated)) + + Task { + await handle(connection) + await remove(connection) + } + } + + private func remove(_ connection: NWConnection) { + activeConnections.removeValue(forKey: ObjectIdentifier(connection)) + connectionCountChanged(activeConnections.count) + } + + private func handle(_ connection: NWConnection) async { + do { + let requestData = try await readHTTPRequest(from: connection) + let parsed = try parseHTTPRequest(requestData) + guard parsed.path == path else { + try await sendResponse(.error(statusCode: 404, .invalidRequest("Not Found")), to: connection) + connection.cancel() + return + } + + let response = await handler(parsed.request) + try await sendResponse(response, to: connection) + } catch { + let message = error.localizedDescription + try? await sendResponse(.error(statusCode: 400, .invalidRequest(message)), to: connection) + } + + connection.cancel() + } + + private func readHTTPRequest(from connection: NWConnection) async throws -> Data { + var buffer = Data() + var expectedLength: Int? + + while expectedLength == nil || buffer.count < expectedLength! { + let chunk = try await receive(from: connection) + guard chunk.isEmpty == false else { break } + buffer.append(chunk) + + if expectedLength == nil, + let headerRange = buffer.range(of: Data("\r\n\r\n".utf8)) { + let headerLength = headerRange.upperBound + let headerData = buffer[.. Data { + try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in + if let error { + continuation.resume(throwing: error) + } else if let data { + continuation.resume(returning: data) + } else if isComplete { + continuation.resume(returning: Data()) + } else { + continuation.resume(returning: Data()) + } + } + } + } + + private func parseHTTPRequest(_ data: Data) throws -> (request: HTTPRequest, path: String) { + guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else { + throw ASFWMCPHostError.listenerFailed("Missing HTTP header terminator.") + } + + let headerText = String(decoding: data[..= 2 else { + throw ASFWMCPHostError.listenerFailed("Malformed HTTP request line.") + } + + let method = String(requestParts[0]) + let target = String(requestParts[1]) + let path = target.split(separator: "?", maxSplits: 1).first.map(String.init) ?? target + var headers: [String: String] = [:] + for line in lines.dropFirst() { + guard let separator = line.firstIndex(of: ":") else { continue } + let name = String(line[..) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) + } + } + + private static func contentLength(in headerText: String) -> Int { + for line in headerText.split(separator: "\r\n") { + let parts = line.split(separator: ":", maxSplits: 1) + guard parts.count == 2, parts[0].lowercased() == "content-length" else { + continue + } + return Int(parts[1].trimmingCharacters(in: .whitespaces)) ?? 0 + } + return 0 + } + + private static func reasonPhrase(for statusCode: Int) -> String { + switch statusCode { + case 200: "OK" + case 202: "Accepted" + case 400: "Bad Request" + case 404: "Not Found" + case 405: "Method Not Allowed" + case 409: "Conflict" + case 500: "Internal Server Error" + default: "HTTP" + } + } +} diff --git a/ASFWTests/MCP/MCPHostTests.swift b/ASFWTests/MCP/MCPHostTests.swift new file mode 100644 index 00000000..64d6336d --- /dev/null +++ b/ASFWTests/MCP/MCPHostTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import ASFW + +@MainActor +struct MCPHostTests { + @Test func hostedStatefulHTTPInitializesSessionOverSSE() async throws { + let driver = MockASFWDriverControl() + let core = ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver) + let host = ASFWMCPHost(core: core) + + let status = try await host.start(configuration: ASFWMCPHostConfiguration(port: 0)) + defer { + Task { @MainActor in + await host.stop() + } + } + + let endpoint = try #require(status.endpointURL) + #expect(status.isRunning == true) + #expect(endpoint.host() == "127.0.0.1") + + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.addValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("2025-11-25", forHTTPHeaderField: "MCP-Protocol-Version") + request.httpBody = Data(""" + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { + "name": "ASFWTests", + "version": "1.0" + } + } + } + """.utf8) + + let (body, response) = try await URLSession.shared.data(for: request) + let http = try #require(response as? HTTPURLResponse) + let text = String(decoding: body, as: UTF8.self) + + #expect(http.statusCode == 200) + #expect(http.value(forHTTPHeaderField: "Content-Type")?.contains("text/event-stream") == true) + #expect(http.value(forHTTPHeaderField: "MCP-Session-Id")?.isEmpty == false) + #expect(text.contains("event: message")) + #expect(text.contains("\"protocolVersion\"")) + + await host.stop() + #expect(host.status == .stopped) + } + + @Test func hostedEndpointRejectsUnexpectedPath() async throws { + let driver = MockASFWDriverControl() + let core = ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver) + let host = ASFWMCPHost(core: core) + + let status = try await host.start(configuration: ASFWMCPHostConfiguration(port: 0)) + defer { + Task { @MainActor in + await host.stop() + } + } + let endpoint = try #require(status.endpointURL) + let badURL = endpoint.deletingLastPathComponent().appending(path: "nope") + let (_, response) = try await URLSession.shared.data(from: badURL) + let http = try #require(response as? HTTPURLResponse) + + #expect(http.statusCode == 404) + } +} From 5e845aa7dae6283ab6db30e01152da1ddd3fd8a4 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 21:49:21 +0200 Subject: [PATCH 21/23] FW-96: verify hosted MCP end to end --- ASFWTests/MCP/MCPHostedEndToEndTests.swift | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 ASFWTests/MCP/MCPHostedEndToEndTests.swift diff --git a/ASFWTests/MCP/MCPHostedEndToEndTests.swift b/ASFWTests/MCP/MCPHostedEndToEndTests.swift new file mode 100644 index 00000000..202d6a6c --- /dev/null +++ b/ASFWTests/MCP/MCPHostedEndToEndTests.swift @@ -0,0 +1,65 @@ +import Foundation +import MCP +import Testing +@testable import ASFW + +@MainActor +struct MCPHostedEndToEndTests { + @Test func sdkClientListsToolsCallsReadToolAndReadsResourceOverHostedHTTP() async throws { + let driver = MockASFWDriverControl() + let core = ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver) + let host = ASFWMCPHost(core: core) + let status = try await host.start(configuration: ASFWMCPHostConfiguration(port: 0)) + defer { + Task { @MainActor in + await host.stop() + } + } + + let endpoint = try #require(status.endpointURL) + let transport = HTTPClientTransport(endpoint: endpoint, streaming: false) + let client = Client(name: "ASFWTests", version: "1.0") + defer { + Task { + await client.disconnect() + } + } + + let initialize = try await client.connect(transport: transport) + #expect(initialize.serverInfo.name == "ASFW MCP Control Plane") + + let listedTools = try await client.listTools() + #expect(listedTools.tools.contains { $0.name == "asfw_read_quadlet" }) + + let readResult = try await client.callTool( + name: "asfw_read_quadlet", + arguments: [ + "nodeId": .int(1), + "generation": .int(17), + "addressHigh": .int(0xFFFF), + "addressLow": .int(0xF0000400) + ] + ) + #expect(readResult.isError == false) + let readText = try #require(readResult.content.compactMap(\.textContent).first) + #expect(readText.contains("\"status\" : \"ok\"")) + #expect(readText.contains("\"kind\" : \"readQuadlet\"")) + + let contents = try await client.readResource(uri: "asfw://telemetry/snapshot") + let telemetryText = try #require(contents.first?.text) + #expect(telemetryText.contains("\"schema\" : \"asfw.telemetry.snapshot.v1\"")) + #expect(telemetryText.contains("\"driverConnected\" : true")) + + await client.disconnect() + await host.stop() + } +} + +private extension Tool.Content { + var textContent: String? { + if case .text(let text, _, _) = self { + return text + } + return nil + } +} From 91c91f338b75e96b29de91495ab31d79539ffbbf Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 18 Jun 2026 21:52:49 +0200 Subject: [PATCH 22/23] FW-93: add MCP settings lifecycle UI --- ASFW/MCP/ASFWMCPHost.swift | 20 +++- ASFW/ViewModels/ASFWMCPControlViewModel.swift | 98 +++++++++++++++++++ ASFW/Views/MCPSettingsView.swift | 93 ++++++++++++++++++ ASFW/Views/ModernContentView.swift | 10 ++ ASFWTests/MCP/MCPControlViewModelTests.swift | 36 +++++++ 5 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 ASFW/ViewModels/ASFWMCPControlViewModel.swift create mode 100644 ASFW/Views/MCPSettingsView.swift create mode 100644 ASFWTests/MCP/MCPControlViewModelTests.swift diff --git a/ASFW/MCP/ASFWMCPHost.swift b/ASFW/MCP/ASFWMCPHost.swift index 96ef6645..acf0a516 100644 --- a/ASFW/MCP/ASFWMCPHost.swift +++ b/ASFW/MCP/ASFWMCPHost.swift @@ -39,6 +39,7 @@ final class ASFWMCPHost { private var transport: StatefulHTTPServerTransport? private var httpAdapter: ASFWMCPHTTPAdapter? private(set) var status: ASFWMCPHostStatus = .stopped + var onStatusChanged: ((ASFWMCPHostStatus) -> Void)? init(core: ASFWMCPCore) { self.core = core @@ -71,7 +72,7 @@ final class ASFWMCPHost { }, connectionCountChanged: { [weak self] count in Task { @MainActor [weak self] in - self?.status.activeHTTPConnections = count + self?.setActiveHTTPConnections(count) } } ) @@ -81,11 +82,11 @@ final class ASFWMCPHost { self.server = server self.transport = transport self.httpAdapter = adapter - self.status = ASFWMCPHostStatus( + setStatus(ASFWMCPHostStatus( isRunning: true, endpointURL: endpoint, activeHTTPConnections: 0 - ) + )) return status } @@ -96,7 +97,18 @@ final class ASFWMCPHost { httpAdapter = nil transport = nil server = nil - status = .stopped + setStatus(.stopped) + } + + private func setActiveHTTPConnections(_ count: Int) { + var nextStatus = status + nextStatus.activeHTTPConnections = count + setStatus(nextStatus) + } + + private func setStatus(_ status: ASFWMCPHostStatus) { + self.status = status + onStatusChanged?(status) } } diff --git a/ASFW/ViewModels/ASFWMCPControlViewModel.swift b/ASFW/ViewModels/ASFWMCPControlViewModel.swift new file mode 100644 index 00000000..f32a4305 --- /dev/null +++ b/ASFW/ViewModels/ASFWMCPControlViewModel.swift @@ -0,0 +1,98 @@ +import Combine +import Foundation + +@MainActor +final class ASFWMCPControlViewModel: ObservableObject { + @Published var isEnabled: Bool + @Published var portText: String + @Published private(set) var status: ASFWMCPHostStatus = .stopped + @Published private(set) var isChangingState = false + @Published private(set) var lastError: String? + + private let connector: ASFWDriverConnector + private let defaults: UserDefaults + private var host: ASFWMCPHost? + + private enum DefaultsKey { + static let enabled = "asfw.mcp.enabled" + static let port = "asfw.mcp.port" + } + + init(connector: ASFWDriverConnector, defaults: UserDefaults = .standard) { + self.connector = connector + self.defaults = defaults + let savedPort = defaults.integer(forKey: DefaultsKey.port) + self.portText = savedPort > 0 ? "\(savedPort)" : "8765" + self.isEnabled = defaults.bool(forKey: DefaultsKey.enabled) + } + + var endpointText: String { + status.endpointURL?.absoluteString ?? "Not running" + } + + var sessionText: String { + guard status.isRunning else { return "Stopped" } + return status.activeHTTPConnections > 0 ? "Session active" : "Waiting for agent" + } + + var canEditPort: Bool { + status.isRunning == false && isChangingState == false + } + + func applyEnabledState() { + Task { await setEnabled(isEnabled) } + } + + func setEnabled(_ enabled: Bool) async { + defaults.set(enabled, forKey: DefaultsKey.enabled) + isEnabled = enabled + if enabled { + await start() + } else { + await stop() + } + } + + func start() async { + guard status.isRunning == false else { return } + guard let port = UInt16(portText.trimmingCharacters(in: .whitespacesAndNewlines)) else { + lastError = "Port must be a number from 0 to 65535." + isEnabled = false + defaults.set(false, forKey: DefaultsKey.enabled) + return + } + + isChangingState = true + lastError = nil + defaults.set(Int(port), forKey: DefaultsKey.port) + + let driver = LiveASFWDriverControl(backend: connector) + let core = ASFWMCPCore(configuration: .readOnlyDeveloper, driver: driver) + let nextHost = ASFWMCPHost(core: core) + nextHost.onStatusChanged = { [weak self] status in + self?.status = status + } + + do { + status = try await nextHost.start(configuration: ASFWMCPHostConfiguration(port: port)) + host = nextHost + } catch { + lastError = "Failed to start MCP host: \(error.localizedDescription)" + status = .stopped + host = nil + isEnabled = false + defaults.set(false, forKey: DefaultsKey.enabled) + } + + isChangingState = false + } + + func stop() async { + isChangingState = true + lastError = nil + await host?.stop() + host = nil + status = .stopped + isChangingState = false + } +} diff --git a/ASFW/Views/MCPSettingsView.swift b/ASFW/Views/MCPSettingsView.swift new file mode 100644 index 00000000..3747f91e --- /dev/null +++ b/ASFW/Views/MCPSettingsView.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct MCPSettingsView: View { + @ObservedObject var viewModel: ASFWMCPControlViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text("MCP Control Plane") + .font(.title2) + .fontWeight(.bold) + + VStack(alignment: .leading, spacing: 14) { + Toggle("Enabled", isOn: $viewModel.isEnabled) + .font(.headline) + .disabled(viewModel.isChangingState) + .onChange(of: viewModel.isEnabled) { _, _ in + viewModel.applyEnabledState() + } + + HStack(spacing: 12) { + Label("HTTP/SSE", systemImage: "network") + .frame(width: 120, alignment: .leading) + Text(viewModel.status.isRunning ? "Running" : "Stopped") + .foregroundStyle(viewModel.status.isRunning ? .green : .secondary) + } + + HStack(spacing: 12) { + Label("Port", systemImage: "number") + .frame(width: 120, alignment: .leading) + TextField("8765", text: $viewModel.portText) + .textFieldStyle(.roundedBorder) + .frame(width: 120) + .disabled(!viewModel.canEditPort) + } + + HStack(spacing: 12) { + Label("Endpoint", systemImage: "link") + .frame(width: 120, alignment: .leading) + Text(viewModel.endpointText) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + + HStack(spacing: 12) { + Label("Session", systemImage: "person.crop.circle.badge.checkmark") + .frame(width: 120, alignment: .leading) + Text(viewModel.sessionText) + .foregroundStyle(viewModel.status.activeHTTPConnections > 0 ? .green : .secondary) + } + + if let error = viewModel.lastError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(8) + + HStack(spacing: 12) { + Button { + Task { await viewModel.start() } + } label: { + Label("Start", systemImage: "play.fill") + } + .disabled(viewModel.status.isRunning || viewModel.isChangingState) + + Button { + Task { await viewModel.stop() } + } label: { + Label("Stop", systemImage: "stop.fill") + } + .disabled(!viewModel.status.isRunning || viewModel.isChangingState) + + if viewModel.isChangingState { + ProgressView() + .controlSize(.small) + } + } + + Spacer() + } + .padding() + } +} + +#Preview { + MCPSettingsView(viewModel: ASFWMCPControlViewModel(connector: ASFWDriverConnector())) + .frame(width: 720, height: 420) +} diff --git a/ASFW/Views/ModernContentView.swift b/ASFW/Views/ModernContentView.swift index c7d02b9c..eba15156 100644 --- a/ASFW/Views/ModernContentView.swift +++ b/ASFW/Views/ModernContentView.swift @@ -14,6 +14,7 @@ struct ModernContentView: View { @StateObject private var topologyVM: TopologyViewModel @StateObject private var romExplorerVM: RomExplorerViewModel @StateObject private var diagnosticsStore: DiagnosticsStore + @StateObject private var mcpVM: ASFWMCPControlViewModel @State private var selectedSection: SidebarSection? = .overview @State private var loggingPreset: LoggingPreset = .standard @@ -29,6 +30,7 @@ struct ModernContentView: View { topologyViewModel: topologyViewModel )) _diagnosticsStore = StateObject(wrappedValue: DiagnosticsStore(connector: debugViewModel.connector)) + _mcpVM = StateObject(wrappedValue: ASFWMCPControlViewModel(connector: debugViewModel.connector)) } enum SidebarSection: String, CaseIterable, Identifiable { @@ -46,6 +48,7 @@ struct ModernContentView: View { case busReset = "Bus Reset History" case logs = "System Logs" case loggingSettings = "Logging Settings" + case mcpSettings = "MCP Control" case audio = "Core Audio" case saffire = "Saffire" case duet = "Duet" @@ -69,6 +72,7 @@ struct ModernContentView: View { case .busReset: return "bolt.horizontal.circle" case .logs: return "doc.text" case .loggingSettings: return "slider.horizontal.3" + case .mcpSettings: return "point.3.connected.trianglepath.dotted" case .audio: return "hifispeaker.fill" case .saffire: return "slider.vertical.3" case .duet: return "slider.horizontal.below.square.filled.and.square" @@ -121,6 +125,8 @@ struct ModernContentView: View { SystemLogsView(viewModel: driverVM) case .loggingSettings: LoggingSettingsView(connector: debugVM.connector) + case .mcpSettings: + MCPSettingsView(viewModel: mcpVM) case .audio: AudioDebugView() case .saffire: @@ -181,8 +187,12 @@ struct ModernContentView: View { topologyVM.startAutoRefresh() romExplorerVM.setConnector(debugVM.connector, topologyViewModel: topologyVM) loadLoggingPreset() + if mcpVM.isEnabled { + Task { await mcpVM.start() } + } } .onDisappear { + Task { await mcpVM.stop() } debugVM.disconnect() topologyVM.stopAutoRefresh() } diff --git a/ASFWTests/MCP/MCPControlViewModelTests.swift b/ASFWTests/MCP/MCPControlViewModelTests.swift new file mode 100644 index 00000000..8a7c5d37 --- /dev/null +++ b/ASFWTests/MCP/MCPControlViewModelTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +@testable import ASFW + +@MainActor +struct MCPControlViewModelTests { + @Test func viewModelStartsStopsAndPersistsEnabledSettings() async throws { + let suiteName = "ASFWTests.MCPControl.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + + let viewModel = ASFWMCPControlViewModel( + connector: ASFWDriverConnector(), + defaults: defaults + ) + viewModel.portText = "0" + + await viewModel.setEnabled(true) + defer { + Task { @MainActor in + await viewModel.stop() + defaults.removePersistentDomain(forName: suiteName) + } + } + + #expect(viewModel.isEnabled == true) + #expect(viewModel.status.isRunning == true) + #expect(viewModel.endpointText.contains("http://127.0.0.1:")) + #expect(viewModel.sessionText == "Waiting for agent") + #expect(defaults.bool(forKey: "asfw.mcp.enabled") == true) + + await viewModel.setEnabled(false) + #expect(viewModel.isEnabled == false) + #expect(viewModel.status == .stopped) + } +} From a92b6ec9d6125d5126c305d175e17d833d9ad6fa Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Fri, 19 Jun 2026 07:10:45 +0200 Subject: [PATCH 23/23] FW-92: fix MCP loopback listener binding --- ASFW/MCP/ASFWMCPHost.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ASFW/MCP/ASFWMCPHost.swift b/ASFW/MCP/ASFWMCPHost.swift index acf0a516..400e0914 100644 --- a/ASFW/MCP/ASFWMCPHost.swift +++ b/ASFW/MCP/ASFWMCPHost.swift @@ -149,10 +149,13 @@ private actor ASFWMCPHTTPAdapter { let parameters = NWParameters.tcp parameters.allowLocalEndpointReuse = true + let listener: NWListener if let ipv4 = IPv4Address(bindHost) { parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(ipv4), port: nwPort) + listener = try NWListener(using: parameters) + } else { + listener = try NWListener(using: parameters, on: nwPort) } - let listener = try NWListener(using: parameters, on: nwPort) listener.service = nil listener.newConnectionHandler = { [weak self] connection in Task { await self?.accept(connection) }