rust/bt-mcs: Split server into separate mods

Move the Server event, as well as helper machinery into a separate
module.

Move characteristic definitions and local state handling / supporting
state types into a separate module.

Tests remain colocated with the functionality they are covering.

Bug: 540400364
Test: cargo test -p bt-mcs, ./presubmit.sh
Change-Id: I66f70b6e5c1557fc7e0e925ad3d9623b3239f9fe
Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/3860
diff --git a/rust/bt-mcs/src/lib.rs b/rust/bt-mcs/src/lib.rs
index 3d5520d..f08bd71 100644
--- a/rust/bt-mcs/src/lib.rs
+++ b/rust/bt-mcs/src/lib.rs
@@ -7,11 +7,6 @@
 pub mod types;
 
 pub use crate::error::Error;
-pub use crate::server::{
-    ControlPointWriteResponder, McsServer, McsServerBuilder, McsServerEvent,
-    SetPlaybackSpeedResponder, SetPlayingOrderResponder, SetTrackPositionResponder,
-    SetValueResponder,
-};
 pub use crate::types::{
     ControlPointResultCode, MediaControlOpcode, MediaState, PlaybackSpeed, PlayingOrder,
     SeekingSpeed, SupportedOpcodes, SupportedPlayingOrders, TrackDuration, TrackPosition,
diff --git a/rust/bt-mcs/src/server.rs b/rust/bt-mcs/src/server.rs
index 3f49055..ca82faa 100644
--- a/rust/bt-mcs/src/server.rs
+++ b/rust/bt-mcs/src/server.rs
@@ -11,121 +11,26 @@
     WriteResponder,
 };
 use bt_gatt::types::{
-    AttributePermissions, CharacteristicProperties, CharacteristicProperty, GattError, Handle,
-    SecurityLevels, ServiceKind,
+    CharacteristicProperties, CharacteristicProperty, GattError, Handle, ServiceKind,
 };
-use bt_gatt::Characteristic;
-use futures::channel::oneshot;
 use futures::stream::{FuturesUnordered, Stream};
-use futures::FutureExt;
 use pin_project::pin_project;
 use std::future::Future;
-use std::pin::Pin;
 use std::task::{Context, Poll, Waker};
 
+pub mod event;
+use event::*;
+pub use event::{
+    ControlPointWriteResponder, McsServerEvent, SetPlaybackSpeedResponder,
+    SetPlayingOrderResponder, SetTrackPositionResponder, SetValueResponder,
+};
+
+mod state;
+use state::*;
+
 use crate::types::*;
 use crate::Error;
 
-// ============================================================================
-// Mandatory Characteristic Handle Definitions (MCS v1.0.1 Section 3)
-// ============================================================================
-
-/// Handle assigned to the Media Player Name characteristic.
-const MEDIA_PLAYER_NAME_HANDLE: Handle = Handle(1);
-/// Handle assigned to the Track Changed characteristic.
-const TRACK_CHANGED_HANDLE: Handle = Handle(2);
-/// Handle assigned to the Track Title characteristic.
-const TRACK_TITLE_HANDLE: Handle = Handle(3);
-/// Handle assigned to the Track Duration characteristic.
-const TRACK_DURATION_HANDLE: Handle = Handle(4);
-/// Handle assigned to the Track Position characteristic.
-const TRACK_POSITION_HANDLE: Handle = Handle(5);
-/// Handle assigned to the Media State characteristic.
-const MEDIA_STATE_HANDLE: Handle = Handle(6);
-/// Handle assigned to the Content Control ID (CCID) characteristic.
-const CONTENT_CONTROL_ID_HANDLE: Handle = Handle(7);
-
-// ============================================================================
-// Optional Characteristic Handle Definitions (MCS v1.0.1 Section 3)
-// ============================================================================
-
-/// Handle assigned to the Media Player Icon URL characteristic.
-const MEDIA_PLAYER_ICON_URL_HANDLE: Handle = Handle(8);
-/// Handle assigned to the Playback Speed characteristic.
-const PLAYBACK_SPEED_HANDLE: Handle = Handle(9);
-/// Handle assigned to the Seeking Speed characteristic.
-const SEEKING_SPEED_HANDLE: Handle = Handle(10);
-/// Handle assigned to the Playing Order characteristic.
-const PLAYING_ORDER_HANDLE: Handle = Handle(11);
-/// Handle assigned to the Playing Orders Supported characteristic.
-const PLAYING_ORDERS_SUPPORTED_HANDLE: Handle = Handle(12);
-/// Handle assigned to the Media Control Point characteristic.
-const MEDIA_CONTROL_POINT_HANDLE: Handle = Handle(13);
-/// Handle assigned to the Media Control Point Opcodes Supported characteristic.
-const MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE: Handle = Handle(14);
-
-/// The mandatory characteristics defined in MCS v1.0.1 Section 3, Table 3.1.
-fn mandatory_characteristics() -> [Characteristic; 7] {
-    [
-        build_characteristic(
-            MEDIA_PLAYER_NAME_HANDLE,
-            MEDIA_PLAYER_NAME_UUID,
-            CharacteristicProperties::READ_NOTIFY,
-        ),
-        build_characteristic(
-            TRACK_CHANGED_HANDLE,
-            TRACK_CHANGED_UUID,
-            CharacteristicProperty::Notify,
-        ),
-        build_characteristic(
-            TRACK_TITLE_HANDLE,
-            TRACK_TITLE_UUID,
-            CharacteristicProperties::READ_NOTIFY,
-        ),
-        build_characteristic(
-            TRACK_DURATION_HANDLE,
-            TRACK_DURATION_UUID,
-            CharacteristicProperties::READ_NOTIFY,
-        ),
-        build_characteristic(
-            TRACK_POSITION_HANDLE,
-            TRACK_POSITION_UUID,
-            CharacteristicProperties::READ_WRITE_NOTIFY,
-        ),
-        build_characteristic(
-            MEDIA_STATE_HANDLE,
-            MEDIA_STATE_UUID,
-            CharacteristicProperties::READ_NOTIFY,
-        ),
-        build_characteristic(
-            CONTENT_CONTROL_ID_HANDLE,
-            CONTENT_CONTROL_ID_UUID,
-            CharacteristicProperty::Read,
-        ),
-    ]
-}
-
-/// Constructs a characteristic definition with the specified handle, UUID,
-/// properties, and encryption-required permissions conforming to MCS v1.0.1
-/// Section 3.
-fn build_characteristic(
-    handle: Handle,
-    uuid: Uuid,
-    properties: impl Into<CharacteristicProperties>,
-) -> Characteristic {
-    let properties = properties.into();
-    Characteristic {
-        handle,
-        uuid,
-        properties,
-        permissions: AttributePermissions::with_levels(
-            &properties,
-            &SecurityLevels::encryption_required(),
-        ),
-        descriptors: Vec::new(),
-    }
-}
-
 /// Internal state for the MCS server.
 #[pin_project(project = LocalServiceProj)]
 enum LocalServiceState<T: bt_gatt::ServerTypes> {
@@ -206,343 +111,6 @@
     }
 }
 
-/// Confirms the updated track position to store in local state and notify to
-/// clients.
-/// It is safe to drop without responding to ignore the update or if the value
-/// has not changed.
-pub type SetTrackPositionResponder = SetValueResponder<TrackPosition>;
-
-/// Confirms the updated playback speed to store in local state and notify to
-/// clients.
-/// It is safe to drop without responding to ignore the update or if the value
-/// has not changed.
-pub type SetPlaybackSpeedResponder = SetValueResponder<PlaybackSpeed>;
-
-/// Confirms the updated playing order to store in local state and notify to
-/// clients.
-/// It is safe to drop without responding to ignore the update or if the value
-/// has not changed.
-pub type SetPlayingOrderResponder = SetValueResponder<PlayingOrder>;
-
-/// Confirms the media control command result code to notify to the client.
-/// A response is expected to confirm the result. If no response is provided,
-/// then it is assumed that the Control Point request has failed and
-/// `ControlPointResultCode::CommandCannotBeCompleted` will be sent to the peer.
-pub type ControlPointWriteResponder = SetValueResponder<ControlPointResultCode>;
-
-/// Responder for an asynchronous characteristic write or control point command.
-///
-/// Calling [`send`](Self::send) confirms the new value or command result to
-/// update local state and notify clients. It is safe to drop the responder
-/// without responding to cancel the update (or report command failure).
-pub struct SetValueResponder<T> {
-    response_tx: oneshot::Sender<T>,
-}
-
-impl<T: std::fmt::Debug> SetValueResponder<T> {
-    /// Confirms the value or result code determined by the media player
-    /// application and dispatches the corresponding GATT notification.
-    pub fn send(self, value: T) {
-        if let Err(result) = self.response_tx.send(value) {
-            log::warn!("Failed to send response: server dropped or closed: {result:?}");
-        }
-    }
-}
-
-impl<T> std::fmt::Debug for SetValueResponder<T> {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.debug_struct("SetValueResponder").finish_non_exhaustive()
-    }
-}
-
-/// An in-flight handler for a single-value asynchronous write response.
-struct SetValueResponseFut<T> {
-    rx: oneshot::Receiver<T>,
-}
-
-impl<T> SetValueResponseFut<T> {
-    fn create() -> (Self, SetValueResponder<T>) {
-        let (tx, rx) = oneshot::channel();
-        (Self { rx }, SetValueResponder { response_tx: tx })
-    }
-}
-
-impl<T> Future for SetValueResponseFut<T> {
-    type Output = Option<T>;
-
-    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
-        self.rx.poll_unpin(cx).map(Result::ok)
-    }
-}
-
-/// Responses produced when an in-flight asynchronous write response completes.
-enum PendingWriteResponse {
-    ControlPoint {
-        peer_id: PeerId,
-        opcode: MediaControlOpcode,
-        result_code: ControlPointResultCode,
-    },
-    TrackPosition(TrackPosition),
-    PlaybackSpeed(PlaybackSpeed),
-    PlayingOrder(PlayingOrder),
-}
-
-/// An in-flight asynchronous write response future.
-enum PendingWriteResponseFut {
-    ControlPoint {
-        peer_id: PeerId,
-        opcode: MediaControlOpcode,
-        fut: SetValueResponseFut<ControlPointResultCode>,
-    },
-    TrackPosition(SetValueResponseFut<TrackPosition>),
-    PlaybackSpeed(SetValueResponseFut<PlaybackSpeed>),
-    PlayingOrder(SetValueResponseFut<PlayingOrder>),
-}
-
-impl Future for PendingWriteResponseFut {
-    type Output = Option<PendingWriteResponse>;
-
-    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
-        match self.as_mut().get_mut() {
-            Self::ControlPoint { peer_id, opcode, fut } => fut.poll_unpin(cx).map(|res| {
-                let code = res.unwrap_or(ControlPointResultCode::CommandCannotBeCompleted);
-                Some(PendingWriteResponse::ControlPoint {
-                    peer_id: *peer_id,
-                    opcode: opcode.clone(),
-                    result_code: code,
-                })
-            }),
-            Self::TrackPosition(fut) => {
-                fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::TrackPosition))
-            }
-            Self::PlaybackSpeed(fut) => {
-                fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::PlaybackSpeed))
-            }
-            Self::PlayingOrder(fut) => {
-                fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::PlayingOrder))
-            }
-        }
-    }
-}
-
-/// Events produced by an [`McsServer`] stream representing client actions that
-/// require additional upper-layer application processing.
-#[derive(Debug)]
-pub enum McsServerEvent {
-    /// Request to set the track playback position.
-    SetTrackPosition {
-        peer_id: PeerId,
-        position: TrackPosition,
-        responder: SetValueResponder<TrackPosition>,
-    },
-    /// Request to set the playback speed.
-    SetPlaybackSpeed {
-        peer_id: PeerId,
-        speed: PlaybackSpeed,
-        responder: SetValueResponder<PlaybackSpeed>,
-    },
-    /// Request to set the playing order.
-    SetPlayingOrder {
-        peer_id: PeerId,
-        order: PlayingOrder,
-        responder: SetValueResponder<PlayingOrder>,
-    },
-    /// Request to execute a media control command.
-    ControlPointCommand {
-        opcode: MediaControlOpcode,
-        responder: SetValueResponder<ControlPointResultCode>,
-    },
-}
-
-/// State of the playing order characteristics when enabled on the server.
-#[derive(Debug, Clone, PartialEq, Eq)]
-struct PlayingOrderState {
-    /// Currently selected playing order.
-    current: PlayingOrder,
-    /// Bitmask of supported playing orders.
-    supported: SupportedPlayingOrders,
-}
-
-impl PlayingOrderState {
-    /// Creates a new `PlayingOrderState` with the default current order
-    /// (`PlayingOrder::InOrderOnce`) and the given supported bitmask.
-    fn new(supported: SupportedPlayingOrders) -> Self {
-        Self { current: PlayingOrder::default(), supported }
-    }
-
-    /// Sets the playing order if it is supported.
-    ///
-    /// Returns `true` if `order` was supported and applied, or `false` if
-    /// ignored because it was unsupported (MCS v1.0.1 Section 3.15.1).
-    fn set_order(&mut self, order: PlayingOrder) -> bool {
-        if self.supported.contains(order.into()) {
-            self.current = order;
-            true
-        } else {
-            false
-        }
-    }
-}
-
-/// Local state of the characteristics in this server.
-#[derive(Debug, Clone, PartialEq, Eq)]
-struct McsLocalState {
-    /// Content Control ID (CCID) identifying this media service instance.
-    ccid: u8,
-    /// Human-readable media player application name.
-    player_name: String,
-    /// Title of the currently selected track (empty if no track loaded).
-    track_title: String,
-    /// Total duration of the current track (MCS v1.0.1 Section 3.6).
-    track_duration: TrackDuration,
-    /// Base playback position of the current track (MCS v1.0.1 Section 3.7).
-    track_position: TrackPosition,
-    /// Timestamp when `track_position` was set or updated.
-    position_updated_at: Option<std::time::Instant>,
-    /// Current player activity state.
-    media_state: MediaState,
-    /// URL pointing to media player icon graphic, if supported.
-    icon_url: Option<String>,
-    /// Playback speed multiplier, if supported.
-    playback_speed: Option<PlaybackSpeed>,
-    /// Seeking speed factor, if supported.
-    seeking_speed: Option<SeekingSpeed>,
-    /// Playing order and supported playing orders, if supported.
-    playing_orders: Option<PlayingOrderState>,
-    /// Supported media control point opcodes, if Media Control Point is
-    /// supported.
-    supported_opcodes: Option<SupportedOpcodes>,
-}
-
-impl McsLocalState {
-    /// Creates a new [`McsLocalState`] with default values for an inactive
-    /// player with no track loaded per MCS v1.0.1 Section 3.
-    fn new(ccid: u8, player_name: impl Into<String>) -> Self {
-        Self {
-            ccid,
-            player_name: player_name.into(),
-            track_title: String::new(),
-            track_duration: TrackDuration::Unknown,
-            track_position: TrackPosition::Unavailable,
-            position_updated_at: None,
-            media_state: MediaState::Inactive,
-            icon_url: None,
-            playback_speed: None,
-            seeking_speed: None,
-            playing_orders: None,
-            supported_opcodes: None,
-        }
-    }
-
-    /// Calculates the instantaneous track position based on elapsed playback
-    /// time (MCS v1.0.1 Section 3.7).
-    fn current_track_position(&self) -> TrackPosition {
-        // Per MCS Section 3.17, an inactive player has no current track, so it is
-        // unavailable.
-        if self.media_state == MediaState::Inactive {
-            return TrackPosition::Unavailable;
-        }
-
-        // Playback timing has not started since the track was just initialized.
-        let Some(updated_at) = self.position_updated_at else {
-            return self.track_position;
-        };
-
-        // Playback is paused/seeking, so the position remains fixed.
-        if self.media_state != MediaState::Playing {
-            return self.track_position;
-        }
-
-        let base = match (self.track_position, self.track_duration) {
-            (TrackPosition::FromStart(base), _) => base,
-            (TrackPosition::FromEnd(end), TrackDuration::Duration(total)) => {
-                total.saturating_sub(end)
-            }
-            _ => return self.track_position,
-        };
-
-        // TODO(b/540400364): Factor in optional playback speed when supported
-        let mut current = base + updated_at.elapsed();
-        if let TrackDuration::Duration(total) = self.track_duration {
-            current = current.min(total);
-        }
-        TrackPosition::FromStart(current)
-    }
-
-    /// Sets the track position and records the update timestamp.
-    fn set_track_position(&mut self, position: TrackPosition) {
-        self.track_position = position;
-        self.position_updated_at = Some(std::time::Instant::now());
-    }
-
-    /// Sets the playback speed.
-    fn set_playback_speed(&mut self, speed: PlaybackSpeed) {
-        self.playback_speed = Some(speed);
-    }
-
-    /// Sets the playing order if playing order is supported.
-    fn set_playing_order(&mut self, order: PlayingOrder) {
-        if let Some(ref mut orders) = self.playing_orders {
-            let _ = orders.set_order(order);
-        }
-    }
-
-    #[cfg(test)]
-    pub(crate) fn set_media_state(&mut self, state: MediaState) {
-        self.media_state = state;
-    }
-
-    #[cfg(test)]
-    pub(crate) fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) {
-        self.supported_opcodes = Some(opcodes);
-    }
-
-    /// Reads the characteristic bytes for `handle` at `offset`.
-    fn handle_read(&self, handle: Handle, offset: usize) -> Result<Vec<u8>, GattError> {
-        let read_at_offset =
-            |bytes: &[u8]| bytes.get(offset..).map(Vec::from).ok_or(GattError::InvalidOffset);
-
-        match handle {
-            // Track Changed and Media Control Point are not readable per MCS v1.0.1 Section 3,
-            // Table 3.1.
-            TRACK_CHANGED_HANDLE | MEDIA_CONTROL_POINT_HANDLE => Err(GattError::ReadNotPermitted),
-            MEDIA_PLAYER_NAME_HANDLE => read_at_offset(self.player_name.as_bytes()),
-            TRACK_TITLE_HANDLE => read_at_offset(self.track_title.as_bytes()),
-            TRACK_DURATION_HANDLE => read_at_offset(&self.track_duration.raw_10ms().to_le_bytes()),
-            TRACK_POSITION_HANDLE => {
-                read_at_offset(&self.current_track_position().raw_10ms().to_le_bytes())
-            }
-            MEDIA_STATE_HANDLE => read_at_offset(&[self.media_state.into()]),
-            CONTENT_CONTROL_ID_HANDLE => read_at_offset(&[self.ccid]),
-            MEDIA_PLAYER_ICON_URL_HANDLE => {
-                let url = self.icon_url.as_ref().ok_or(GattError::InvalidHandle)?;
-                read_at_offset(url.as_bytes())
-            }
-            PLAYBACK_SPEED_HANDLE => {
-                let speed = self.playback_speed.ok_or(GattError::InvalidHandle)?;
-                read_at_offset(&[speed.into()])
-            }
-            SEEKING_SPEED_HANDLE => {
-                let speed = self.seeking_speed.ok_or(GattError::InvalidHandle)?;
-                read_at_offset(&[speed.into()])
-            }
-            PLAYING_ORDER_HANDLE => {
-                let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?;
-                read_at_offset(&[orders.current.into()])
-            }
-            PLAYING_ORDERS_SUPPORTED_HANDLE => {
-                let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?;
-                read_at_offset(&orders.supported.bits().to_le_bytes())
-            }
-            MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE => {
-                let opcodes = self.supported_opcodes.ok_or(GattError::InvalidHandle)?;
-                read_at_offset(&opcodes.bits().to_le_bytes())
-            }
-            _ => Err(GattError::InvalidHandle),
-        }
-    }
-}
-
 /// Builder for configuring an MCS or GMCS GATT service.
 // TODO(b/549911651): Add support for OTS (Object Transfer Service) integration.
 #[derive(Debug, Clone, PartialEq)]
@@ -974,7 +542,8 @@
 
     use assert_matches::assert_matches;
     use bt_gatt::test_utils::{FakeServer, FakeServerEvent, FakeTypes};
-    use futures::StreamExt;
+    use bt_gatt::Characteristic;
+    use futures::{FutureExt, StreamExt};
 
     #[test]
     fn builder_generic_service_definition() {
@@ -1298,191 +867,6 @@
         (server, fake_gatt_server, event_receiver, noop_cx)
     }
 
-    fn assert_read_characteristic(
-        server: &mut McsServer<FakeTypes>,
-        fake_gatt_server: &FakeServer,
-        event_receiver: &mut futures::channel::mpsc::UnboundedReceiver<FakeServerEvent>,
-        handle: Handle,
-        expected: &[u8],
-    ) {
-        let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref());
-        let peer = PeerId(1);
-        let service_id = server.service_def.id();
-        fake_gatt_server.incoming_read(peer, service_id, handle, 0);
-        let _ = server.next().poll_unpin(&mut noop_cx);
-        let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
-            expect_service_event(event_receiver)
-        else {
-            panic!("expected ReadResponded");
-        };
-        assert_eq!(value.unwrap(), expected);
-    }
-
-    #[test]
-    fn read_mandatory_characteristics_default_values() {
-        let (mut server, fake_gatt_server, mut event_receiver) =
-            setup_test_server(McsServerBuilder::generic(0x42, "Test Player"));
-
-        // Media Player Name
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            MEDIA_PLAYER_NAME_HANDLE,
-            b"Test Player",
-        );
-
-        // Track Title
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            TRACK_TITLE_HANDLE,
-            b"",
-        );
-
-        // Track Duration
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            TRACK_DURATION_HANDLE,
-            &(-1i32).to_le_bytes(),
-        );
-
-        // Track Position
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            TRACK_POSITION_HANDLE,
-            &(-1i32).to_le_bytes(),
-        );
-
-        // Media State
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            MEDIA_STATE_HANDLE,
-            &[MediaState::Inactive.into()],
-        );
-
-        // Content Control ID
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            CONTENT_CONTROL_ID_HANDLE,
-            &[0x42],
-        );
-    }
-
-    #[test]
-    fn read_optional_characteristics_configured_values() {
-        let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server(
-            McsServerBuilder::generic(0x42, "Test Player")
-                .with_icon_url("https://example.com/icon.png")
-                .with_player_speeds()
-                .with_playing_orders(
-                    SupportedPlayingOrders::IN_ORDER_ONCE | SupportedPlayingOrders::SHUFFLE_ONCE,
-                )
-                .with_supported_operations(SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE),
-        );
-
-        // Media Player Icon URL
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            MEDIA_PLAYER_ICON_URL_HANDLE,
-            b"https://example.com/icon.png",
-        );
-
-        // Playback Speed
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            PLAYBACK_SPEED_HANDLE,
-            &[0],
-        );
-
-        // Seeking Speed
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            SEEKING_SPEED_HANDLE,
-            &[0],
-        );
-
-        // Playing Order
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            PLAYING_ORDER_HANDLE,
-            &[PlayingOrder::InOrderOnce.into()],
-        );
-
-        // Playing Orders Supported
-        let expected_orders =
-            SupportedPlayingOrders::IN_ORDER_ONCE | SupportedPlayingOrders::SHUFFLE_ONCE;
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            PLAYING_ORDERS_SUPPORTED_HANDLE,
-            &expected_orders.bits().to_le_bytes(),
-        );
-
-        // Media Control Point Opcodes Supported
-        let expected_opcodes = SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE;
-        assert_read_characteristic(
-            &mut server,
-            &fake_gatt_server,
-            &mut event_receiver,
-            MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE,
-            &expected_opcodes.bits().to_le_bytes(),
-        );
-    }
-
-    #[test]
-    fn read_unconfigured_optional_characteristics_returns_invalid_handle() {
-        let (mut server, fake_gatt_server, mut event_receiver) =
-            setup_test_server(McsServerBuilder::generic(0x42, "Mandatory Only Player"));
-
-        let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref());
-        let peer = PeerId(1);
-        let service_id = server.service_def.id();
-
-        let unconfigured_handles = [
-            MEDIA_PLAYER_ICON_URL_HANDLE,
-            PLAYBACK_SPEED_HANDLE,
-            SEEKING_SPEED_HANDLE,
-            PLAYING_ORDER_HANDLE,
-            PLAYING_ORDERS_SUPPORTED_HANDLE,
-            MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE,
-        ];
-
-        for handle in unconfigured_handles {
-            fake_gatt_server.incoming_read(peer, service_id, handle, 0);
-            let _ = server.next().poll_unpin(&mut noop_cx);
-            let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
-                expect_service_event(&mut event_receiver)
-            else {
-                panic!("expected ReadResponded for handle {:?}", handle);
-            };
-            assert_matches!(
-                value.unwrap_err(),
-                bt_gatt::types::Error::Gatt(GattError::InvalidHandle),
-                "handle {:?} should return InvalidHandle when unconfigured",
-                handle
-            );
-        }
-    }
-
     #[test]
     fn write_unconfigured_optional_characteristics_returns_invalid_handle() {
         let (mut server, fake_gatt_server, mut event_receiver) =
@@ -1518,121 +902,6 @@
     }
 
     #[test]
-    fn read_string_characteristics_with_offset() {
-        let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server(
-            McsServerBuilder::generic(0x42, "Long Player Name")
-                .with_icon_url("https://example.com/icon.png"),
-        );
-
-        let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref());
-        let peer = PeerId(1);
-        let service_id = server.service_def.id();
-
-        // Valid offset slice for Player Name
-        fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_NAME_HANDLE, 5);
-        let _ = server.next().poll_unpin(&mut noop_cx);
-        let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
-            expect_service_event(&mut event_receiver)
-        else {
-            panic!("expected ReadResponded");
-        };
-        assert_eq!(value.unwrap(), b"Player Name");
-
-        // Valid offset slice for Icon URL
-        fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_ICON_URL_HANDLE, 8);
-        let _ = server.next().poll_unpin(&mut noop_cx);
-        let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
-            expect_service_event(&mut event_receiver)
-        else {
-            panic!("expected ReadResponded");
-        };
-        assert_eq!(value.unwrap(), b"example.com/icon.png");
-
-        // Exact end offset returns empty slice
-        fake_gatt_server.incoming_read(
-            peer,
-            service_id,
-            MEDIA_PLAYER_NAME_HANDLE,
-            "Long Player Name".len() as u32,
-        );
-        let _ = server.next().poll_unpin(&mut noop_cx);
-        let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
-            expect_service_event(&mut event_receiver)
-        else {
-            panic!("expected ReadResponded");
-        };
-        assert_eq!(value.unwrap(), b"");
-
-        // Out-of-bounds offset returns InvalidOffset
-        fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_NAME_HANDLE, 100);
-        let _ = server.next().poll_unpin(&mut noop_cx);
-        let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
-            expect_service_event(&mut event_receiver)
-        else {
-            panic!("expected ReadResponded");
-        };
-        assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidOffset));
-
-        // Out-of-bounds offset on Icon URL returns InvalidOffset
-        fake_gatt_server.incoming_read(peer, service_id, MEDIA_PLAYER_ICON_URL_HANDLE, 100);
-        let _ = server.next().poll_unpin(&mut noop_cx);
-        let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
-            expect_service_event(&mut event_receiver)
-        else {
-            panic!("expected ReadResponded");
-        };
-        assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidOffset));
-    }
-
-    #[test]
-    fn read_non_readable_characteristics_returns_error() {
-        let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref());
-        let (mut server, fake_gatt_server, mut event_receiver) = setup_test_server(
-            McsServerBuilder::generic(0x42, "Test Player")
-                .with_supported_operations(SupportedOpcodes::default()),
-        );
-
-        let peer = PeerId(1);
-        let service_id = server.service_def.id();
-
-        // Track Changed is notify-only
-        fake_gatt_server.incoming_read(peer, service_id, TRACK_CHANGED_HANDLE, 0);
-        let _ = server.next().poll_unpin(&mut noop_cx);
-        let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
-            expect_service_event(&mut event_receiver)
-        else {
-            panic!("expected ReadResponded");
-        };
-        assert_matches!(
-            value.unwrap_err(),
-            bt_gatt::types::Error::Gatt(GattError::ReadNotPermitted)
-        );
-
-        // Media Control Point is write/notify-only
-        fake_gatt_server.incoming_read(peer, service_id, MEDIA_CONTROL_POINT_HANDLE, 0);
-        let _ = server.next().poll_unpin(&mut noop_cx);
-        let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
-            expect_service_event(&mut event_receiver)
-        else {
-            panic!("expected ReadResponded");
-        };
-        assert_matches!(
-            value.unwrap_err(),
-            bt_gatt::types::Error::Gatt(GattError::ReadNotPermitted)
-        );
-
-        // Unknown handle returns InvalidHandle
-        fake_gatt_server.incoming_read(peer, service_id, Handle(999), 0);
-        let _ = server.next().poll_unpin(&mut noop_cx);
-        let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
-            expect_service_event(&mut event_receiver)
-        else {
-            panic!("expected ReadResponded");
-        };
-        assert_matches!(value.unwrap_err(), bt_gatt::types::Error::Gatt(GattError::InvalidHandle));
-    }
-
-    #[test]
     fn write_track_position_success() {
         let (mut server, fake_gatt_server, mut event_receiver) =
             setup_test_server(McsServerBuilder::generic(0x42, "Test Player"));
@@ -2747,163 +2016,4 @@
         };
         assert_eq!(value.unwrap(), vec![PlayingOrder::InOrderOnce.into()]);
     }
-
-    #[test]
-    fn local_state_handle_read() {
-        let mut state = McsLocalState::new(0x01, "Player");
-        state.media_state = MediaState::Paused;
-        state.playback_speed = Some(PlaybackSpeed::NORMAL);
-        state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::all()));
-
-        // Track Position
-        assert_eq!(
-            state.handle_read(TRACK_POSITION_HANDLE, 0),
-            Ok(TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec())
-        );
-
-        // Playback Speed
-        assert_eq!(state.handle_read(PLAYBACK_SPEED_HANDLE, 0), Ok(vec![0x00]));
-
-        // Playing Order
-        assert_eq!(
-            state.handle_read(PLAYING_ORDER_HANDLE, 0),
-            Ok(vec![PlayingOrder::InOrderOnce.into()])
-        );
-    }
-
-    #[test]
-    fn playing_order_state_helpers() {
-        let mut state = PlayingOrderState::new(SupportedPlayingOrders::IN_ORDER_ONCE);
-        assert_eq!(state.current, PlayingOrder::InOrderOnce);
-        assert_eq!(state.supported, SupportedPlayingOrders::IN_ORDER_ONCE);
-        assert!(!state.set_order(PlayingOrder::SingleOnce));
-        assert_eq!(state.current, PlayingOrder::InOrderOnce);
-        assert!(state.set_order(PlayingOrder::InOrderOnce));
-
-        let mut all_state = PlayingOrderState::new(SupportedPlayingOrders::all());
-        assert_eq!(all_state.current, PlayingOrder::InOrderOnce);
-        assert_eq!(all_state.supported, SupportedPlayingOrders::all());
-        assert!(all_state.set_order(PlayingOrder::ShuffleRepeat));
-        assert_eq!(all_state.current, PlayingOrder::ShuffleRepeat);
-    }
-
-    #[test]
-    fn local_state_track_position_calculation() {
-        let mut state = McsLocalState::new(0x01, "Test Player");
-
-        // When no track is loaded, position is Unavailable regardless of state.
-        assert_eq!(state.current_track_position(), TrackPosition::Unavailable);
-        state.media_state = MediaState::Playing;
-        assert_eq!(state.current_track_position(), TrackPosition::Unavailable);
-
-        // When paused or seeking with a loaded track, position does not advance with
-        // time.
-        state.media_state = MediaState::Paused;
-        state.track_position = TrackPosition::from_start(std::time::Duration::from_millis(5000));
-        state.position_updated_at =
-            Some(std::time::Instant::now() - std::time::Duration::from_secs(10));
-        assert_eq!(
-            state.current_track_position(),
-            TrackPosition::from_start(std::time::Duration::from_millis(5000))
-        );
-        state.media_state = MediaState::Seeking;
-        assert_eq!(
-            state.current_track_position(),
-            TrackPosition::from_start(std::time::Duration::from_millis(5000))
-        );
-
-        // When playing with a loaded track (FromStart), position advances based on
-        // elapsed time.
-        state.media_state = MediaState::Playing;
-        state.track_duration = TrackDuration::from_duration(std::time::Duration::from_secs(20));
-        state.track_position = TrackPosition::from_start(std::time::Duration::from_millis(5000));
-        state.position_updated_at =
-            Some(std::time::Instant::now() - std::time::Duration::from_millis(500));
-        let computed = state.current_track_position();
-        let TrackPosition::FromStart(dur) = computed else {
-            panic!("expected FromStart");
-        };
-        assert!(
-            dur >= std::time::Duration::from_millis(5450)
-                && dur <= std::time::Duration::from_millis(5650),
-            "unexpected computed position: {dur:?}"
-        );
-
-        // When elapsed time exceeds track duration, position is clamped to duration.
-        state.position_updated_at =
-            Some(std::time::Instant::now() - std::time::Duration::from_secs(100));
-        assert_eq!(
-            state.current_track_position(),
-            TrackPosition::from_start(std::time::Duration::from_secs(20))
-        );
-
-        // When playing with a track position relative to end (FromEnd), base is
-        // duration - offset.
-        state.track_duration = TrackDuration::from_duration(std::time::Duration::from_secs(30));
-        state.track_position = TrackPosition::from_end(std::time::Duration::from_secs(10));
-        state.position_updated_at =
-            Some(std::time::Instant::now() - std::time::Duration::from_millis(500));
-        // Base is 30s - 10s = 20s. Elapsed 500ms -> ~20.5s from start.
-        let computed = state.current_track_position();
-        let TrackPosition::FromStart(dur) = computed else {
-            panic!("expected FromStart");
-        };
-        assert!(
-            dur >= std::time::Duration::from_millis(20450)
-                && dur <= std::time::Duration::from_millis(20650),
-            "unexpected computed position: {dur:?}"
-        );
-
-        // When FromEnd elapsed time exceeds track duration, position is clamped to
-        // duration.
-        state.position_updated_at =
-            Some(std::time::Instant::now() - std::time::Duration::from_secs(100));
-        assert_eq!(
-            state.current_track_position(),
-            TrackPosition::from_start(std::time::Duration::from_secs(30))
-        );
-
-        // When FromEnd is used with unknown duration, it cannot resolve to FromStart
-        // and returns FromEnd.
-        state.track_duration = TrackDuration::Unknown;
-        state.track_position = TrackPosition::from_end(std::time::Duration::from_secs(10));
-        assert_eq!(
-            state.current_track_position(),
-            TrackPosition::from_end(std::time::Duration::from_secs(10))
-        );
-    }
-
-    #[test]
-    fn read_track_position_during_active_playback() {
-        let (mut server, fake_gatt_server, mut event_receiver) =
-            setup_test_server(McsServerBuilder::generic(0x42, "Test Player"));
-
-        // Set server state to Playing with a loaded track at position 500 (5.0
-        // seconds).
-        server.state.media_state = MediaState::Playing;
-        server.state.track_duration =
-            TrackDuration::from_duration(std::time::Duration::from_secs(60));
-        server.state.track_position =
-            TrackPosition::from_start(std::time::Duration::from_millis(5000));
-        server.state.position_updated_at =
-            Some(std::time::Instant::now() - std::time::Duration::from_millis(1000));
-
-        let mut noop_cx = Context::from_waker(futures::task::noop_waker_ref());
-        let peer = PeerId(1);
-        let service_id = server.service_def.id();
-        fake_gatt_server.incoming_read(peer, service_id, TRACK_POSITION_HANDLE, 0);
-        let _ = server.next().poll_unpin(&mut noop_cx);
-        let bt_gatt::test_utils::FakeServerEvent::ReadResponded { value, .. } =
-            expect_service_event(&mut event_receiver)
-        else {
-            panic!("expected ReadResponded");
-        };
-
-        let raw_bytes = value.unwrap();
-        assert_eq!(raw_bytes.len(), 4);
-        let raw_10ms = i32::from_le_bytes(raw_bytes.try_into().unwrap());
-        // 5000ms + 1000ms elapsed = 6000ms = 600 units of 10ms (allow minor jitter +/-
-        // 30 units)
-        assert!(raw_10ms >= 580 && raw_10ms <= 640, "unexpected raw_10ms: {raw_10ms}");
-    }
 }
diff --git a/rust/bt-mcs/src/server/event.rs b/rust/bt-mcs/src/server/event.rs
new file mode 100644
index 0000000..26fed15
--- /dev/null
+++ b/rust/bt-mcs/src/server/event.rs
@@ -0,0 +1,213 @@
+// Copyright 2026 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use bt_common::PeerId;
+use futures::channel::oneshot;
+use futures::FutureExt;
+use std::future::Future;
+use std::pin::Pin;
+use std::task::{Context, Poll};
+
+use crate::types::*;
+
+/// Confirms the updated track position to store in local state and notify to
+/// clients.
+/// It is safe to drop without responding to ignore the update or if the value
+/// has not changed.
+pub type SetTrackPositionResponder = SetValueResponder<TrackPosition>;
+
+/// Confirms the updated playback speed to store in local state and notify to
+/// clients.
+/// It is safe to drop without responding to ignore the update or if the value
+/// has not changed.
+pub type SetPlaybackSpeedResponder = SetValueResponder<PlaybackSpeed>;
+
+/// Confirms the updated playing order to store in local state and notify to
+/// clients.
+/// It is safe to drop without responding to ignore the update or if the value
+/// has not changed.
+pub type SetPlayingOrderResponder = SetValueResponder<PlayingOrder>;
+
+/// Confirms the media control command result code to notify to the client.
+/// A response is expected to confirm the result. If no response is provided,
+/// then it is assumed that the Control Point request has failed and
+/// `ControlPointResultCode::CommandCannotBeCompleted` will be sent to the peer.
+pub type ControlPointWriteResponder = SetValueResponder<ControlPointResultCode>;
+
+/// Responder for an asynchronous characteristic write or control point command.
+///
+/// Calling [`send`](Self::send) confirms the new value or command result to
+/// update local state and notify clients. It is safe to drop the responder
+/// without responding to cancel the update (or report command failure).
+pub struct SetValueResponder<T> {
+    response_tx: oneshot::Sender<T>,
+}
+
+impl<T: std::fmt::Debug> SetValueResponder<T> {
+    /// Confirms the value or result code determined by the media player
+    /// application and dispatches the corresponding GATT notification.
+    pub fn send(self, value: T) {
+        if let Err(result) = self.response_tx.send(value) {
+            log::warn!("Failed to send response: server dropped or closed: {result:?}");
+        }
+    }
+}
+
+impl<T> std::fmt::Debug for SetValueResponder<T> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("SetValueResponder").finish_non_exhaustive()
+    }
+}
+
+/// An in-flight handler for a single-value asynchronous write response.
+pub(crate) struct SetValueResponseFut<T> {
+    rx: oneshot::Receiver<T>,
+}
+
+impl<T> SetValueResponseFut<T> {
+    pub(crate) fn create() -> (Self, SetValueResponder<T>) {
+        let (tx, rx) = oneshot::channel();
+        (Self { rx }, SetValueResponder { response_tx: tx })
+    }
+}
+
+impl<T> Future for SetValueResponseFut<T> {
+    type Output = Option<T>;
+
+    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
+        self.rx.poll_unpin(cx).map(Result::ok)
+    }
+}
+
+/// Responses produced when an in-flight asynchronous write response completes.
+#[derive(Debug)]
+pub(crate) enum PendingWriteResponse {
+    ControlPoint {
+        peer_id: PeerId,
+        opcode: MediaControlOpcode,
+        result_code: ControlPointResultCode,
+    },
+    TrackPosition(TrackPosition),
+    PlaybackSpeed(PlaybackSpeed),
+    PlayingOrder(PlayingOrder),
+}
+
+/// An in-flight asynchronous write response future.
+pub(crate) enum PendingWriteResponseFut {
+    ControlPoint {
+        peer_id: PeerId,
+        opcode: MediaControlOpcode,
+        fut: SetValueResponseFut<ControlPointResultCode>,
+    },
+    TrackPosition(SetValueResponseFut<TrackPosition>),
+    PlaybackSpeed(SetValueResponseFut<PlaybackSpeed>),
+    PlayingOrder(SetValueResponseFut<PlayingOrder>),
+}
+
+impl Future for PendingWriteResponseFut {
+    type Output = Option<PendingWriteResponse>;
+
+    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
+        match self.as_mut().get_mut() {
+            Self::ControlPoint { peer_id, opcode, fut } => fut.poll_unpin(cx).map(|res| {
+                let code = res.unwrap_or(ControlPointResultCode::CommandCannotBeCompleted);
+                Some(PendingWriteResponse::ControlPoint {
+                    peer_id: *peer_id,
+                    opcode: opcode.clone(),
+                    result_code: code,
+                })
+            }),
+            Self::TrackPosition(fut) => {
+                fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::TrackPosition))
+            }
+            Self::PlaybackSpeed(fut) => {
+                fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::PlaybackSpeed))
+            }
+            Self::PlayingOrder(fut) => {
+                fut.poll_unpin(cx).map(|res| res.map(PendingWriteResponse::PlayingOrder))
+            }
+        }
+    }
+}
+
+/// Events produced by an [`McsServer`] stream representing client actions that
+/// require additional upper-layer application processing.
+#[derive(Debug)]
+pub enum McsServerEvent {
+    /// Request to set the track playback position.
+    SetTrackPosition {
+        peer_id: PeerId,
+        position: TrackPosition,
+        responder: SetValueResponder<TrackPosition>,
+    },
+    /// Request to set the playback speed.
+    SetPlaybackSpeed {
+        peer_id: PeerId,
+        speed: PlaybackSpeed,
+        responder: SetValueResponder<PlaybackSpeed>,
+    },
+    /// Request to set the playing order.
+    SetPlayingOrder {
+        peer_id: PeerId,
+        order: PlayingOrder,
+        responder: SetValueResponder<PlayingOrder>,
+    },
+    /// Request to execute a media control command.
+    ControlPointCommand {
+        opcode: MediaControlOpcode,
+        responder: SetValueResponder<ControlPointResultCode>,
+    },
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use assert_matches::assert_matches;
+
+    #[test]
+    fn set_value_responder_and_fut() {
+        let (mut fut, responder) = SetValueResponseFut::<PlaybackSpeed>::create();
+        let mut cx = Context::from_waker(futures::task::noop_waker_ref());
+
+        assert!(fut.poll_unpin(&mut cx).is_pending());
+
+        responder.send(PlaybackSpeed::DOUBLE);
+        assert_eq!(fut.poll_unpin(&mut cx), Poll::Ready(Some(PlaybackSpeed::DOUBLE)));
+    }
+
+    #[test]
+    fn set_value_responder_dropped_results_in_none() {
+        let (mut fut, responder) = SetValueResponseFut::<TrackPosition>::create();
+        let mut cx = Context::from_waker(futures::task::noop_waker_ref());
+
+        assert!(fut.poll_unpin(&mut cx).is_pending());
+
+        drop(responder);
+        assert_eq!(fut.poll_unpin(&mut cx), Poll::Ready(None));
+    }
+
+    #[test]
+    fn pending_write_response_control_point_dropped_defaults_to_cannot_be_completed() {
+        let (fut, responder) = SetValueResponseFut::<ControlPointResultCode>::create();
+        let peer_id = PeerId(1);
+        let mut pending = PendingWriteResponseFut::ControlPoint {
+            peer_id,
+            opcode: MediaControlOpcode::Play,
+            fut,
+        };
+        let mut cx = Context::from_waker(futures::task::noop_waker_ref());
+
+        assert!(pending.poll_unpin(&mut cx).is_pending());
+
+        drop(responder);
+        assert_matches!(
+            pending.poll_unpin(&mut cx),
+            Poll::Ready(Some(PendingWriteResponse::ControlPoint {
+                peer_id: p,
+                opcode: MediaControlOpcode::Play,
+                result_code: ControlPointResultCode::CommandCannotBeCompleted,
+            })) if p == peer_id
+        );
+    }
+}
diff --git a/rust/bt-mcs/src/server/state.rs b/rust/bt-mcs/src/server/state.rs
new file mode 100644
index 0000000..aafa445
--- /dev/null
+++ b/rust/bt-mcs/src/server/state.rs
@@ -0,0 +1,513 @@
+// Copyright 2026 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use bt_common::Uuid;
+use bt_gatt::types::{
+    AttributePermissions, CharacteristicProperties, CharacteristicProperty, GattError, Handle,
+    SecurityLevels,
+};
+use bt_gatt::Characteristic;
+
+use crate::types::*;
+
+// ============================================================================
+// Characteristic Definitions & Handles (MCS v1.0.1 Section 3)
+// ============================================================================
+
+/// Handle assigned to the Media Player Name characteristic.
+pub(crate) const MEDIA_PLAYER_NAME_HANDLE: Handle = Handle(1);
+/// Handle assigned to the Track Changed characteristic.
+pub(crate) const TRACK_CHANGED_HANDLE: Handle = Handle(2);
+/// Handle assigned to the Track Title characteristic.
+pub(crate) const TRACK_TITLE_HANDLE: Handle = Handle(3);
+/// Handle assigned to the Track Duration characteristic.
+pub(crate) const TRACK_DURATION_HANDLE: Handle = Handle(4);
+/// Handle assigned to the Track Position characteristic.
+pub(crate) const TRACK_POSITION_HANDLE: Handle = Handle(5);
+/// Handle assigned to the Media State characteristic.
+pub(crate) const MEDIA_STATE_HANDLE: Handle = Handle(6);
+/// Handle assigned to the Content Control ID (CCID) characteristic.
+pub(crate) const CONTENT_CONTROL_ID_HANDLE: Handle = Handle(7);
+
+// ============================================================================
+// Optional Characteristic Handle Definitions (MCS v1.0.1 Section 3)
+// ============================================================================
+
+/// Handle assigned to the Media Player Icon URL characteristic.
+pub(crate) const MEDIA_PLAYER_ICON_URL_HANDLE: Handle = Handle(8);
+/// Handle assigned to the Playback Speed characteristic.
+pub(crate) const PLAYBACK_SPEED_HANDLE: Handle = Handle(9);
+/// Handle assigned to the Seeking Speed characteristic.
+pub(crate) const SEEKING_SPEED_HANDLE: Handle = Handle(10);
+/// Handle assigned to the Playing Order characteristic.
+pub(crate) const PLAYING_ORDER_HANDLE: Handle = Handle(11);
+/// Handle assigned to the Playing Orders Supported characteristic.
+pub(crate) const PLAYING_ORDERS_SUPPORTED_HANDLE: Handle = Handle(12);
+/// Handle assigned to the Media Control Point characteristic.
+pub(crate) const MEDIA_CONTROL_POINT_HANDLE: Handle = Handle(13);
+/// Handle assigned to the Media Control Point Opcodes Supported characteristic.
+pub(crate) const MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE: Handle = Handle(14);
+
+/// Constructs a characteristic definition with the specified handle, UUID,
+/// properties, and encryption-required permissions conforming to MCS v1.0.1
+/// Section 3.
+pub(crate) fn build_characteristic(
+    handle: Handle,
+    uuid: Uuid,
+    properties: impl Into<CharacteristicProperties>,
+) -> Characteristic {
+    let properties = properties.into();
+    Characteristic {
+        handle,
+        uuid,
+        properties,
+        permissions: AttributePermissions::with_levels(
+            &properties,
+            &SecurityLevels::encryption_required(),
+        ),
+        descriptors: Vec::new(),
+    }
+}
+
+/// The mandatory characteristics defined in MCS v1.0.1 Section 3, Table 3.1.
+pub(crate) fn mandatory_characteristics() -> [Characteristic; 7] {
+    [
+        build_characteristic(
+            MEDIA_PLAYER_NAME_HANDLE,
+            MEDIA_PLAYER_NAME_UUID,
+            CharacteristicProperties::READ_NOTIFY,
+        ),
+        build_characteristic(
+            TRACK_CHANGED_HANDLE,
+            TRACK_CHANGED_UUID,
+            CharacteristicProperty::Notify,
+        ),
+        build_characteristic(
+            TRACK_TITLE_HANDLE,
+            TRACK_TITLE_UUID,
+            CharacteristicProperties::READ_NOTIFY,
+        ),
+        build_characteristic(
+            TRACK_DURATION_HANDLE,
+            TRACK_DURATION_UUID,
+            CharacteristicProperties::READ_NOTIFY,
+        ),
+        build_characteristic(
+            TRACK_POSITION_HANDLE,
+            TRACK_POSITION_UUID,
+            CharacteristicProperties::READ_WRITE_NOTIFY,
+        ),
+        build_characteristic(
+            MEDIA_STATE_HANDLE,
+            MEDIA_STATE_UUID,
+            CharacteristicProperties::READ_NOTIFY,
+        ),
+        build_characteristic(
+            CONTENT_CONTROL_ID_HANDLE,
+            CONTENT_CONTROL_ID_UUID,
+            CharacteristicProperty::Read,
+        ),
+    ]
+}
+
+/// Local state of the characteristics in this server.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct McsLocalState {
+    /// Content Control ID (CCID) identifying this media service instance.
+    pub(crate) ccid: u8,
+    /// Human-readable media player application name.
+    pub(crate) player_name: String,
+    /// Title of the currently selected track (empty if no track loaded).
+    pub(crate) track_title: String,
+    /// Total duration of the current track (MCS v1.0.1 Section 3.6).
+    pub(crate) track_duration: TrackDuration,
+    /// Base playback position of the current track (MCS v1.0.1 Section 3.7).
+    pub(crate) track_position: TrackPosition,
+    /// Timestamp when `track_position` was set or updated.
+    pub(crate) position_updated_at: Option<std::time::Instant>,
+    /// Current player activity state.
+    pub(crate) media_state: MediaState,
+    /// URL pointing to media player icon graphic, if supported.
+    pub(crate) icon_url: Option<String>,
+    /// Playback speed multiplier, if supported.
+    pub(crate) playback_speed: Option<PlaybackSpeed>,
+    /// Seeking speed factor, if supported.
+    pub(crate) seeking_speed: Option<SeekingSpeed>,
+    /// Playing order and supported playing orders, if supported.
+    pub(crate) playing_orders: Option<PlayingOrderState>,
+    /// Supported media control point opcodes, if Media Control Point is
+    /// supported.
+    pub(crate) supported_opcodes: Option<SupportedOpcodes>,
+}
+
+impl McsLocalState {
+    /// Creates a new [`McsLocalState`] with default values for an inactive
+    /// player with no track loaded per MCS v1.0.1 Section 3.
+    pub(crate) fn new(ccid: u8, player_name: impl Into<String>) -> Self {
+        Self {
+            ccid,
+            player_name: player_name.into(),
+            track_title: String::new(),
+            track_duration: TrackDuration::Unknown,
+            track_position: TrackPosition::Unavailable,
+            position_updated_at: None,
+            media_state: MediaState::Inactive,
+            icon_url: None,
+            playback_speed: None,
+            seeking_speed: None,
+            playing_orders: None,
+            supported_opcodes: None,
+        }
+    }
+
+    /// Calculates the instantaneous track position based on elapsed playback
+    /// time (MCS v1.0.1 Section 3.7).
+    pub(crate) fn current_track_position(&self) -> TrackPosition {
+        // Per MCS Section 3.17, an inactive player has no current track, so it is
+        // unavailable.
+        if self.media_state == MediaState::Inactive {
+            return TrackPosition::Unavailable;
+        }
+
+        // Playback timing has not started since the track was initialized or loaded.
+        let Some(updated_at) = self.position_updated_at else {
+            return self.track_position;
+        };
+
+        // Playback is paused or seeking, so the position remains fixed at the current
+        // offset.
+        if self.media_state != MediaState::Playing {
+            return self.track_position;
+        }
+
+        let base = match (self.track_position, self.track_duration) {
+            (TrackPosition::FromStart(base), _) => base,
+            (TrackPosition::FromEnd(end), TrackDuration::Duration(total)) => {
+                total.saturating_sub(end)
+            }
+            _ => return self.track_position,
+        };
+
+        // TODO(b/540400364): Factor in optional playback speed when supported
+        let mut current = base + updated_at.elapsed();
+        if let TrackDuration::Duration(total) = self.track_duration {
+            current = current.min(total);
+        }
+        TrackPosition::FromStart(current)
+    }
+
+    /// Sets the track position and records the update timestamp.
+    pub(crate) fn set_track_position(&mut self, position: TrackPosition) {
+        self.track_position = position;
+        self.position_updated_at = Some(std::time::Instant::now());
+    }
+
+    /// Sets the playback speed.
+    pub(crate) fn set_playback_speed(&mut self, speed: PlaybackSpeed) {
+        self.playback_speed = Some(speed);
+    }
+
+    /// Sets the playing order if playing order is supported.
+    pub(crate) fn set_playing_order(&mut self, order: PlayingOrder) {
+        if let Some(ref mut orders) = self.playing_orders {
+            if orders.supported.contains(order.into()) {
+                orders.current = order;
+            }
+        }
+    }
+
+    #[cfg(test)]
+    pub(crate) fn set_media_state(&mut self, state: MediaState) {
+        self.media_state = state;
+    }
+
+    #[cfg(test)]
+    pub(crate) fn set_supported_opcodes(&mut self, opcodes: SupportedOpcodes) {
+        self.supported_opcodes = Some(opcodes);
+    }
+
+    /// Reads the characteristic bytes for `handle` at `offset`.
+    pub(crate) fn handle_read(&self, handle: Handle, offset: usize) -> Result<Vec<u8>, GattError> {
+        let read_at_offset =
+            |bytes: &[u8]| bytes.get(offset..).map(Vec::from).ok_or(GattError::InvalidOffset);
+
+        match handle {
+            // Track Changed and Media Control Point are not readable per MCS v1.0.1 Section 3,
+            // Table 3.1.
+            TRACK_CHANGED_HANDLE | MEDIA_CONTROL_POINT_HANDLE => Err(GattError::ReadNotPermitted),
+            MEDIA_PLAYER_NAME_HANDLE => read_at_offset(self.player_name.as_bytes()),
+            TRACK_TITLE_HANDLE => read_at_offset(self.track_title.as_bytes()),
+            TRACK_DURATION_HANDLE => read_at_offset(&self.track_duration.raw_10ms().to_le_bytes()),
+            TRACK_POSITION_HANDLE => {
+                read_at_offset(&self.current_track_position().raw_10ms().to_le_bytes())
+            }
+            MEDIA_STATE_HANDLE => read_at_offset(&[self.media_state.into()]),
+            CONTENT_CONTROL_ID_HANDLE => read_at_offset(&[self.ccid]),
+            MEDIA_PLAYER_ICON_URL_HANDLE => {
+                let url = self.icon_url.as_ref().ok_or(GattError::InvalidHandle)?;
+                read_at_offset(url.as_bytes())
+            }
+            PLAYBACK_SPEED_HANDLE => {
+                let speed = self.playback_speed.ok_or(GattError::InvalidHandle)?;
+                read_at_offset(&[speed.into()])
+            }
+            SEEKING_SPEED_HANDLE => {
+                let speed = self.seeking_speed.ok_or(GattError::InvalidHandle)?;
+                read_at_offset(&[speed.into()])
+            }
+            PLAYING_ORDER_HANDLE => {
+                let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?;
+                read_at_offset(&[orders.current.into()])
+            }
+            PLAYING_ORDERS_SUPPORTED_HANDLE => {
+                let orders = self.playing_orders.as_ref().ok_or(GattError::InvalidHandle)?;
+                read_at_offset(&orders.supported.bits().to_le_bytes())
+            }
+            MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE => {
+                let opcodes = self.supported_opcodes.ok_or(GattError::InvalidHandle)?;
+                read_at_offset(&opcodes.bits().to_le_bytes())
+            }
+            _ => Err(GattError::InvalidHandle),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use assert_matches::assert_matches;
+    use std::time::Duration;
+
+    #[test]
+    fn local_state_set_playing_order() {
+        let mut state = McsLocalState::new(0x01, "Player");
+        state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::SINGLE_ONCE));
+        assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::InOrderOnce);
+
+        state.set_playing_order(PlayingOrder::SingleOnce);
+        assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::SingleOnce);
+
+        // Unsupported playing order is not applied
+        state.set_playing_order(PlayingOrder::ShuffleOnce);
+        assert_eq!(state.playing_orders.as_ref().unwrap().current, PlayingOrder::SingleOnce);
+    }
+
+    #[test]
+    fn local_state_handle_read() {
+        let mut state = McsLocalState::new(0x01, "Player");
+        state.media_state = MediaState::Paused;
+        state.playback_speed = Some(PlaybackSpeed::NORMAL);
+        state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::all()));
+
+        // Track Position
+        assert_eq!(
+            state.handle_read(TRACK_POSITION_HANDLE, 0),
+            Ok(TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec())
+        );
+
+        // Playback Speed
+        assert_eq!(state.handle_read(PLAYBACK_SPEED_HANDLE, 0), Ok(vec![0x00]));
+
+        // Playing Order
+        assert_eq!(
+            state.handle_read(PLAYING_ORDER_HANDLE, 0),
+            Ok(vec![PlayingOrder::InOrderOnce.into()])
+        );
+    }
+
+    #[test]
+    fn local_state_track_position_calculation() {
+        let mut state = McsLocalState::new(0x01, "Player");
+        state.track_duration = TrackDuration::from_raw_10ms(1000);
+
+        // Inactive player always has unavailable track position
+        state.media_state = MediaState::Inactive;
+        state.track_position = TrackPosition::from_raw_10ms(500);
+        assert_eq!(state.current_track_position(), TrackPosition::Unavailable);
+
+        // Paused player returns base track position
+        state.media_state = MediaState::Paused;
+        assert_eq!(state.current_track_position(), TrackPosition::from_raw_10ms(500));
+
+        // Playing player calculates elapsed time
+        state.media_state = MediaState::Playing;
+        state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(2));
+        match state.current_track_position() {
+            TrackPosition::FromStart(dur) => {
+                assert!(dur >= Duration::from_millis(6900) && dur <= Duration::from_millis(7200));
+            }
+            other => panic!("Expected FromStart duration, got {other:?}"),
+        }
+
+        // Clamped to total duration
+        state.position_updated_at = Some(std::time::Instant::now() - Duration::from_secs(20));
+        assert_eq!(state.current_track_position(), TrackPosition::from_raw_10ms(1000));
+    }
+
+    #[test]
+    fn read_mandatory_characteristics_default_values() {
+        let state = McsLocalState::new(0x05, "Test Player");
+
+        assert_eq!(state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 0), Ok(b"Test Player".to_vec()));
+        assert_eq!(state.handle_read(TRACK_TITLE_HANDLE, 0), Ok(b"".to_vec()));
+        assert_eq!(
+            state.handle_read(TRACK_DURATION_HANDLE, 0),
+            Ok(TrackDuration::Unknown.raw_10ms().to_le_bytes().to_vec())
+        );
+        assert_eq!(
+            state.handle_read(TRACK_POSITION_HANDLE, 0),
+            Ok(TrackPosition::Unavailable.raw_10ms().to_le_bytes().to_vec())
+        );
+        assert_eq!(state.handle_read(MEDIA_STATE_HANDLE, 0), Ok(vec![MediaState::Inactive.into()]));
+        assert_eq!(state.handle_read(CONTENT_CONTROL_ID_HANDLE, 0), Ok(vec![0x05]));
+    }
+
+    #[test]
+    fn read_optional_characteristics_configured_values() {
+        let mut state = McsLocalState::new(0x01, "Player");
+        state.icon_url = Some("https://example.com/icon.png".to_string());
+        state.playback_speed = Some(PlaybackSpeed::DOUBLE);
+        state.seeking_speed = Some(SeekingSpeed::new(10));
+        state.playing_orders = Some(PlayingOrderState::new(SupportedPlayingOrders::SHUFFLE_ONCE));
+        state.supported_opcodes = Some(SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE);
+
+        assert_eq!(
+            state.handle_read(MEDIA_PLAYER_ICON_URL_HANDLE, 0),
+            Ok(b"https://example.com/icon.png".to_vec())
+        );
+        assert_eq!(
+            state.handle_read(PLAYBACK_SPEED_HANDLE, 0),
+            Ok(vec![PlaybackSpeed::DOUBLE.into()])
+        );
+        assert_eq!(
+            state.handle_read(SEEKING_SPEED_HANDLE, 0),
+            Ok(vec![SeekingSpeed::new(10).into()])
+        );
+        assert_eq!(
+            state.handle_read(PLAYING_ORDER_HANDLE, 0),
+            Ok(vec![PlayingOrder::InOrderOnce.into()])
+        );
+        assert_eq!(
+            state.handle_read(PLAYING_ORDERS_SUPPORTED_HANDLE, 0),
+            Ok(SupportedPlayingOrders::SHUFFLE_ONCE.bits().to_le_bytes().to_vec())
+        );
+        assert_eq!(
+            state.handle_read(MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE, 0),
+            Ok((SupportedOpcodes::PLAY | SupportedOpcodes::PAUSE).bits().to_le_bytes().to_vec())
+        );
+    }
+
+    #[test]
+    fn read_string_characteristics_with_offset() {
+        let mut state = McsLocalState::new(0x01, "Long Player Name");
+        state.track_title = "Track Number 1".to_string();
+        state.icon_url = Some("https://example.com".to_string());
+
+        assert_eq!(state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 5), Ok(b"Player Name".to_vec()));
+        assert_eq!(state.handle_read(TRACK_TITLE_HANDLE, 6), Ok(b"Number 1".to_vec()));
+        assert_eq!(state.handle_read(MEDIA_PLAYER_ICON_URL_HANDLE, 8), Ok(b"example.com".to_vec()));
+
+        assert_matches!(
+            state.handle_read(MEDIA_PLAYER_NAME_HANDLE, 100),
+            Err(GattError::InvalidOffset)
+        );
+    }
+
+    #[test]
+    fn read_track_position_during_active_playback() {
+        let mut state = McsLocalState::new(0x01, "Player");
+        state.track_duration = TrackDuration::from_raw_10ms(10000);
+        state.media_state = MediaState::Playing;
+        state.track_position = TrackPosition::from_raw_10ms(200);
+        state.position_updated_at = Some(std::time::Instant::now() - Duration::from_millis(500));
+
+        let res = state.handle_read(TRACK_POSITION_HANDLE, 0).expect("read should succeed");
+        let raw = i32::from_le_bytes(res.try_into().unwrap());
+        assert!(raw >= 240 && raw <= 260, "Expected track position ~250 (10ms units), got {raw}");
+    }
+
+    #[test]
+    fn read_unconfigured_optional_characteristics_returns_invalid_handle() {
+        let state = McsLocalState::new(0x01, "Player");
+
+        let optional_handles = [
+            MEDIA_PLAYER_ICON_URL_HANDLE,
+            PLAYBACK_SPEED_HANDLE,
+            SEEKING_SPEED_HANDLE,
+            PLAYING_ORDER_HANDLE,
+            PLAYING_ORDERS_SUPPORTED_HANDLE,
+            MEDIA_CONTROL_POINT_OPCODES_SUPPORTED_HANDLE,
+        ];
+
+        for handle in optional_handles {
+            assert_matches!(
+                state.handle_read(handle, 0),
+                Err(GattError::InvalidHandle),
+                "Handle {:?} should return InvalidHandle when not configured",
+                handle
+            );
+        }
+    }
+
+    #[test]
+    fn read_non_readable_characteristics_returns_error() {
+        let mut state = McsLocalState::new(0x01, "Player");
+        state.supported_opcodes = Some(SupportedOpcodes::all());
+
+        assert_matches!(
+            state.handle_read(TRACK_CHANGED_HANDLE, 0),
+            Err(GattError::ReadNotPermitted)
+        );
+        assert_matches!(
+            state.handle_read(MEDIA_CONTROL_POINT_HANDLE, 0),
+            Err(GattError::ReadNotPermitted)
+        );
+    }
+
+    #[test]
+    fn mandatory_characteristics_properties_and_permissions() {
+        let mandatory = mandatory_characteristics();
+        assert_eq!(mandatory.len(), 7);
+
+        assert_eq!(mandatory[0].handle, MEDIA_PLAYER_NAME_HANDLE);
+        assert_eq!(mandatory[0].uuid, MEDIA_PLAYER_NAME_UUID);
+        assert_eq!(mandatory[0].properties, CharacteristicProperties::READ_NOTIFY);
+
+        assert_eq!(mandatory[1].handle, TRACK_CHANGED_HANDLE);
+        assert_eq!(mandatory[1].uuid, TRACK_CHANGED_UUID);
+        assert_eq!(
+            mandatory[1].properties,
+            CharacteristicProperties::from(CharacteristicProperty::Notify)
+        );
+
+        assert_eq!(mandatory[2].handle, TRACK_TITLE_HANDLE);
+        assert_eq!(mandatory[2].uuid, TRACK_TITLE_UUID);
+        assert_eq!(mandatory[2].properties, CharacteristicProperties::READ_NOTIFY);
+
+        assert_eq!(mandatory[3].handle, TRACK_DURATION_HANDLE);
+        assert_eq!(mandatory[3].uuid, TRACK_DURATION_UUID);
+        assert_eq!(mandatory[3].properties, CharacteristicProperties::READ_NOTIFY);
+
+        assert_eq!(mandatory[4].handle, TRACK_POSITION_HANDLE);
+        assert_eq!(mandatory[4].uuid, TRACK_POSITION_UUID);
+        assert_eq!(mandatory[4].properties, CharacteristicProperties::READ_WRITE_NOTIFY);
+
+        assert_eq!(mandatory[5].handle, MEDIA_STATE_HANDLE);
+        assert_eq!(mandatory[5].uuid, MEDIA_STATE_UUID);
+        assert_eq!(mandatory[5].properties, CharacteristicProperties::READ_NOTIFY);
+
+        assert_eq!(mandatory[6].handle, CONTENT_CONTROL_ID_HANDLE);
+        assert_eq!(mandatory[6].uuid, CONTENT_CONTROL_ID_UUID);
+        assert_eq!(
+            mandatory[6].properties,
+            CharacteristicProperties::from(CharacteristicProperty::Read)
+        );
+
+        for chrc in &mandatory {
+            assert!(chrc.permissions.read.map_or(true, |s| s.encryption));
+            assert!(chrc.permissions.write.map_or(true, |s| s.encryption));
+            assert!(chrc.permissions.update.map_or(true, |s| s.encryption));
+        }
+    }
+}