Bluetooth Media Control Profile (MCP) Explained

🎵 Bluetooth Media Control Profile (MCP) Explained
A complete beginner’s guide — from the spec to working BlueZ code, step by step
📱
BLE Profile
LE Audio Family
👥
2 Roles
Client + Server
🔗
GATT Based
MCS + GMCS
🔒
Secure
SM1 L2 Required
📅
v1.0 — 2021
Bluetooth SIG

What is MCP and Why Should You Learn It?

Imagine you tap your Bluetooth earbuds once and your phone’s music pauses. Tap again, it plays. How does that tiny earbud tell your smartphone to control Spotify? That’s exactly what the Media Control Profile (MCP) defines.

MCP is a Bluetooth Low Energy (BLE) profile released in March 2021 as part of the broader LE Audio ecosystem. It provides a fully standardized, secure way for one device (a “controller”) to remotely control media playback on another device (a “media player”). Before MCP, different manufacturers rolled their own solutions — MCP makes everything interoperable.

In this post, we’ll break down the entire MCP spec in plain English, draw diagrams, and show you hands-on code using BlueZ on Linux.

🔑 Key Terms You’ll Understand After This Post

MCP MCS GMCS GATT Client GATT Server Media Control Client Media Control Server Object Transfer Service (OTS) Media Control Point Opcodes GATT Notifications Content Control ID (CCID) Security Mode 1 L2 BlueZ D-Bus

📺 Start Here: The TV Remote Analogy

Before diving into specs, think of it this way…

MCP is essentially a standardized remote-control system for Bluetooth media players. Here’s how everything maps:

Real World MCP Equivalent
TV Remote (you hold in your hand) Media Control Client — e.g., earbuds, smartwatch, car head unit
TV / Set-top Box (plays content) Media Control Server — e.g., your smartphone running Spotify
Press the Play button Client writes the Play opcode to the Media Control Point GATT characteristic
TV shows “Now Playing” banner Server sends GATT notifications with Track Title, Duration, and Media State
One TV, multiple HDMI sources One GMCS (generic controller) + multiple MCS instances (one per media app)
Universal remote vs. dedicated remote GMCS (controls whatever is active) vs. MCS (controls specific app like Spotify)

🏗️ MCP Architecture — The Big Picture

How the pieces connect

MCP defines two roles and two services. The diagram below shows how a Media Control Client and Server communicate over BLE:

📱 MEDIA CONTROL CLIENT
📦 OTS Client Role
(Optional — for metadata)
✅ Acts as GATT Client
✅ Initiates all procedures
✅ Reads characteristics
✅ Writes opcodes to MCP
✅ Subscribes to notifications
✅ Discovers MCS / GMCS
Examples: Earbuds, Smartwatch, Car Infotainment
WRITE opcode
NOTIFY result
BLE ACL
Link
🎵 MEDIA CONTROL SERVER
🎛️ MCS (Media Control Service)
0 or more instances — one per app
🌐 GMCS (Generic Media Control Service)
Exactly 1 — always mandatory
📦 OTS (Object Transfer Service)
Optional — included in MCS/GMCS
Examples: Smartphone, Music Streaming Device
💡 Key rule from the spec: The Server shall always instantiate exactly one GMCS. It may additionally instantiate zero or more MCS instances — one for each media application it wants to expose (Spotify, YouTube Music, etc.).

👥 The Two Roles in Detail

📱 Media Control Client

The device that sends commands. It acts as a GATT Client — it initiates all operations.

Mandatory capabilities:

  • GATT service and characteristic discovery
  • Read Characteristic Value
  • Subscribe to notifications (CCCD write)
  • Write characteristic value (or Write Without Response)

What it actually does:

  • Discovers MCS/GMCS on the Server
  • Reads track title, duration, media state
  • Sends Play, Pause, Next Track commands
  • Receives state change notifications
Earbuds Smartwatch Car Unit Remote App
🎵 Media Control Server

The device that hosts media players. It acts as a GATT Server — it responds to operations.

Mandatory capabilities:

  • Instantiate GMCS (always — exactly one)
  • Expose MCS characteristics
  • Process opcodes from Media Control Point
  • Send GATT notifications on state changes

What it actually does:

  • Advertises MCS/GMCS service UUIDs
  • Keeps track title, duration, state updated
  • Executes play/pause/seek when commanded
  • Returns result codes after each command
Smartphone Tablet Laptop Media Player

🎛️ MCS vs GMCS — What’s the Difference?

This is the part that confuses most beginners. Here’s a clear breakdown:

Feature MCS (Media Control Service) GMCS (Generic MCS)
How many? 0 or more (one per media app) Exactly 1 — always mandatory
Controls what? A specific media app (e.g., Spotify) Whichever app is currently active
Analogy Dedicated remote for one specific TV Universal remote for any TV
Identified by Content Control ID (CCID) — unique per instance Fixed single service, no CCID needed
Player Name notification Always shows the same app name Changes as the active app switches
Use case App wanting to control Spotify specifically Earbuds that just need play/pause for anything
🎯 Practical rule: If you are building a headset or earbuds and just need generic play/pause/skip, always connect to GMCS. If you’re building a companion app that must control Spotify specifically, find the MCS instance whose Media Player Name reads “Spotify”.

🔧 The GATT Foundation

MCP sits on top of GATT

MCP doesn’t reinvent the wheel. It builds entirely on the Generic Attribute Profile (GATT) — the same mechanism used by Heart Rate Monitor, Battery Service, and every other BLE profile. Here’s where MCP sits in the BLE stack:

🎵 MCP (Media Control Profile)
▲ uses ▲
MCS / GMCS
(Services, Characteristics, Descriptors)
▲ defined in ▲
GATT (Generic Attribute Profile)
▲ operates over ▲
ATT (Attribute Protocol)
▲ over ▲
BLE Link Layer (LE ACL)

The minimum GATT sub-procedures a Media Control Client must support:

✅ Discover All Primary Services (or by UUID) ✅ Discover All Characteristics ✅ Discover All Characteristic Descriptors ✅ Read Characteristic Value ✅ Notifications (enable via CCCD) ✅ Write Characteristic Value ✅ Write Without Response ✅ Read/Write Characteristic Descriptors

📌 The spec says: when writing to MCS characteristics, the client should use Write Without Response — it’s faster since no acknowledgement is needed for commands like Play or Pause.

📋 Key Characteristics Inside MCS/GMCS

Both MCS and GMCS expose a set of GATT characteristics. Here are all the important ones you need to know, grouped by function:

📰 Media Player Information

Characteristic What it Contains Notify?
Media Player Name App name e.g. “Spotify”, “Apple Music” ✅ Yes
Media Player Icon URL URL to fetch the app’s icon from internet ❌ No
Media Player Icon Object ID ID to fetch icon via OTS ❌ No

🎵 Track Information

Characteristic What it Contains Notify?
Track Title Current song name (UTF-8 string) ✅ Yes
Track Duration Total track length in 0.01s units (int32) ❌ No
Track Position Current playback position (int32, 0.01s units) ✅ Yes (not during play)
Track Changed Fires when song changes (empty payload — just a signal) ✅ Notify only (Mandatory)

🎮 Playback State & Control

Characteristic What it Contains Notify?
Media State 0=Inactive, 1=Playing, 2=Paused, 3=Seeking ✅ Yes (Mandatory)
Playback Speed Current speed (e.g., 1.0x, 1.5x, 2.0x) ❌ No
Seeking Speed Speed during fast-forward/rewind ❌ No
Playing Order Single / Repeat / Shuffle / Shuffle+Repeat ❌ No
Playing Order Supported Bitmask of supported play orders ❌ No
Media Control Point ⭐ Write opcodes here to control playback ✅ Yes — result codes (Mandatory)
MCP Opcodes Supported Bitmask of which opcodes the server supports ✅ Yes
Content Control ID (CCID) Unique ID identifying this MCS instance ❌ No

🎮 Media Control Point — The Command Center

How opcodes work

The Media Control Point characteristic is where all commands are sent. The client writes an opcode to it, the server executes the action, and then notifies back with a result code.

📱
Client
1. Checks Opcodes Supported
2. Writes opcode to MCP
WRITE opcode (0x01 = Play)
NOTIFY result code
🎵
Server
3. Executes command
4. Notifies result
Result Codes:
0x01 ✅ Success
0x02 ❌ Not Supported
0x03 ⚠️ Player Inactive
0x04 🔒 Action Disabled

All available opcodes, grouped by category:

Category Opcodes Available
🎯 Basic Playback Play, Pause, Stop, Fast Forward, Fast Rewind, Move Relative
⏭️ Track Navigation Previous Track, Next Track, First Track, Last Track, Goto Track (N)
📑 Segment Navigation Previous Segment, Next Segment, First Segment, Last Segment, Goto Segment (N)
📂 Group Navigation Previous Group, Next Group, First Group, Last Group, Goto Group (N)
⚠️ Always check first: Before sending any opcode, read the MCP Opcodes Supported characteristic. It’s a bitmask — each bit corresponds to one opcode. If the bit is 0, that opcode is not supported. Sending unsupported opcodes returns result code 0x02 — Not Supported.

🔄 Step-by-Step: Common Procedures

Procedure 1: Playing a Track
1 Client reads MCP Opcodes Supported characteristic — verifies the Play opcode bit is set
2 Client writes Play opcode (0x01) to the Media Control Point characteristic using Write Without Response
3 Server starts playing → sends Media State notification: Playing (0x01)
4 Server sends Media Control Point notification: opcode=0x01, result=Success (0x01)
Procedure 2: Skipping to Next Track
1 Client writes Next Track opcode to Media Control Point
2 Server switches track → fires Track Changed notification (empty payload — just the signal)
3 Server may also notify Track Title, Track Duration, Track Position for the new track
4 Client reads Track Title and Track Duration to update the now-playing display
Procedure 3: Seeking (Two Ways)
Seek Type How to do it Example Use
Absolute Seek Write desired position (int32, 0.01s units) directly to Track Position characteristic User scrubs to exactly 1:30 in song
Relative Seek Write Move Relative opcode + time offset (positive or negative) to Media Control Point Double-tap to jump forward 15 seconds

📦 Object Transfer Service (OTS) — Rich Metadata

For richer metadata beyond basic characteristics — track lists, album art, chapters — MCP optionally uses the Object Transfer Service (OTS). Think of OTS as a mini file-transfer system embedded inside BLE.

How it works: Each object (track, group, icon) has an Object ID stored in a corresponding characteristic. The client reads the ID, then uses OTS procedures to fetch the full object content.

Object Type Contains How to Get It
Track Object Full track metadata, lyrics Read Current Track Object ID char → fetch via OTS
Group Object List of tracks in album/playlist Read Current Group Object ID char → fetch via OTS
Track Segments Chapters/segments within a track Read Track Segments Object ID → fetch via OTS
Media Player Icon App icon image data Read Media Player Icon Object ID → fetch via OTS
Search Results Track list matching a search query Write Search Control Point → notification gives Search Results Object ID
Parent Group Parent album/playlist of current group Read Parent Group Object ID → fetch via OTS

Mandatory OTS procedures when OTS is used in MCP:

Object Discovery (Discover All Objects) Select Object by Object ID Read Object Contents
📌 OTS is completely optional for basic use. Earbuds doing just play/pause/skip don’t need OTS at all. OTS becomes essential when building a companion app that needs to browse a music library, display album art, or show podcast chapters.

🔒 Security Requirements

MCP enforces strict security because you don’t want unknown devices controlling your music. The spec excludes unencrypted connections (SM1 L1) entirely.

Requirement Detail
Minimum Security Level Security Mode 1, Level 2 (SM1 L2) — encrypted BLE connection required
Unencrypted (SM1 L1) ❌ EXCLUDED — not allowed at all
Key Entropy 128-bit encryption key minimum (from LE Secure Connections, BR/EDR SC, or OOB)
Bonding Both Client and Server must support bondable mode — devices pair and store keys
Link Layer Privacy Recommended — use resolvable private addresses to prevent tracking
BR/EDR Transport Security Mode 4 Level 2 required (BR/EDR Secure Connections)
Dual-mode devices Cross-transport key derivation shall be used — one pairing covers both LE and BR/EDR
💡 In simple terms: SM1 L2 means the BLE connection must be encrypted with a bonding key. The user pairs devices once, and subsequent connections auto-encrypt. No pairing = no access to any MCS characteristic.

💻 BlueZ Code Examples

Setting Up BlueZ with Experimental Features

MCS/GMCS is part of BlueZ’s LE Audio implementation. Enable experimental features first:

# Create a systemd drop-in override to enable experimental BlueZ features
sudo mkdir -p /etc/systemd/system/bluetooth.service.d/
sudo tee /etc/systemd/system/bluetooth.service.d/override.conf << EOF
[Service]
ExecStart=
ExecStart=/usr/libexec/bluetooth/bluetoothd --experimental
EOF

# Reload systemd and restart BlueZ
sudo systemctl daemon-reload
sudo systemctl restart bluetooth

# Verify BlueZ is running with experimental flag
sudo systemctl status bluetooth | grep ExecStart
Step 1: Discover MCS/GMCS using bluetoothctl
# Launch bluetoothctl
bluetoothctl

# Power on adapter and scan
[bluetooth]# power on
[bluetooth]# scan on

# Connect to your BLE device (replace MAC)
[bluetooth]# connect AA:BB:CC:DD:EE:FF

# Switch to GATT menu to explore services
[AA:BB:CC:DD:EE:FF]# menu gatt
[AA:BB:CC:DD:EE:FF]# list-attributes AA:BB:CC:DD:EE:FF

# You should see service entries like:
# Primary Service (Handle 0x0001)
#   /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0010
#   00001848-0000-1000-8000-00805f9b34fb  (MCS UUID)
#
# Primary Service (Handle 0x0050)
#   /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/service0050
#   00001849-0000-1000-8000-00805f9b34fb  (GMCS UUID)

# Select a characteristic and read it
[AA:BB:CC:DD:EE:FF]# select-attribute /org/bluez/.../service0050/char0051
[AA:BB:CC:DD:EE:FF:/service0050/char0051]# read
Step 2: Python — Read Media Player Name from GMCS
#!/usr/bin/env python3
"""
Read Media Player Name from GMCS via BlueZ D-Bus API.
Install: sudo apt install python3-dbus
"""
import dbus

# Media Player Name characteristic UUID (from MCS spec)
MEDIA_PLAYER_NAME_UUID = "00002b93-0000-1000-8000-00805f9b34fb"

def find_characteristic(bus, target_uuid):
    """Walk all BlueZ GATT objects, return path matching UUID."""
    manager = dbus.Interface(
        bus.get_object("org.bluez", "/"),
        "org.freedesktop.DBus.ObjectManager"
    )
    for path, interfaces in manager.GetManagedObjects().items():
        if "org.bluez.GattCharacteristic1" not in interfaces:
            continue
        uuid = interfaces["org.bluez.GattCharacteristic1"].get("UUID", "")
        if uuid.lower() == target_uuid.lower():
            return path
    return None

def main():
    bus = dbus.SystemBus()
    path = find_characteristic(bus, MEDIA_PLAYER_NAME_UUID)

    if not path:
        print("Media Player Name characteristic not found.")
        print("Make sure device is connected and GMCS is discovered.")
        return

    print(f"Found at: {path}")

    char_iface = dbus.Interface(
        bus.get_object("org.bluez", path),
        "org.bluez.GattCharacteristic1"
    )

    try:
        value = char_iface.ReadValue({})
        name = bytes(value).decode("utf-8")
        print(f"Media Player Name: {name}")
    except dbus.exceptions.DBusException as e:
        print(f"Read failed: {e}")

if __name__ == "__main__":
    main()
Step 3: Python — Send Play / Pause / Next Track Opcodes
#!/usr/bin/env python3
"""
Send MCP opcodes via Media Control Point (GMCS).
Usage: python3 mcp_control.py [play|pause|stop|next|prev]

MCP Opcodes (from MCS spec):
  0x01 = Play         0x02 = Pause       0x03 = Stop
  0x04 = Move Relative  0x05 = Fast Forward  0x06 = Fast Rewind
  0x30 = Previous Track  0x31 = Next Track
  0x32 = First Track   0x33 = Last Track
  0x34 = Goto Track (param = track number)
"""
import dbus, sys

MEDIA_CONTROL_POINT_UUID = "00002b9e-0000-1000-8000-00805f9b34fb"

OPCODES = {
    "play":  [0x01],
    "pause": [0x02],
    "stop":  [0x03],
    "next":  [0x31],
    "prev":  [0x30],
    "first": [0x32],
    "last":  [0x33],
}

def find_characteristic(bus, uuid):
    manager = dbus.Interface(
        bus.get_object("org.bluez", "/"),
        "org.freedesktop.DBus.ObjectManager"
    )
    for path, interfaces in manager.GetManagedObjects().items():
        if "org.bluez.GattCharacteristic1" not in interfaces:
            continue
        char_uuid = interfaces["org.bluez.GattCharacteristic1"].get("UUID", "")
        if char_uuid.lower() == uuid.lower():
            return path
    return None

def send_opcode(opcode_bytes):
    bus = dbus.SystemBus()
    path = find_characteristic(bus, MEDIA_CONTROL_POINT_UUID)

    if not path:
        print("[ERROR] Media Control Point not found.")
        print("  1. Is the BLE device connected?")
        print("  2. Run: bluetoothctl connect AA:BB:CC:DD:EE:FF")
        return

    char_iface = dbus.Interface(
        bus.get_object("org.bluez", path),
        "org.bluez.GattCharacteristic1"
    )

    # Per MCS spec: prefer Write Without Response for MCP opcodes
    char_iface.WriteValue(
        dbus.Array(opcode_bytes, signature='y'),
        {"type": dbus.String("command")}
    )
    print(f"[OK] Sent opcode: {opcode_bytes} ({sys.argv[1]})")

if __name__ == "__main__":
    cmd = sys.argv[1].lower() if len(sys.argv) > 1 else ""
    if cmd in OPCODES:
        send_opcode(OPCODES[cmd])
    else:
        print(f"Usage: python3 mcp_control.py [{' | '.join(OPCODES.keys())}]")
Step 4: Python — Subscribe to Media State Notifications
#!/usr/bin/env python3
"""
Subscribe to Media State and Track Changed notifications from GMCS.
Uses BlueZ D-Bus PropertiesChanged signal.
Requires: python3-dbus, python3-gi
"""
import dbus
import dbus.mainloop.glib
from gi.repository import GLib

MEDIA_STATE_UUID   = "00002bac-0000-1000-8000-00805f9b34fb"
TRACK_CHANGED_UUID = "00002b96-0000-1000-8000-00805f9b34fb"
TRACK_TITLE_UUID   = "00002b97-0000-1000-8000-00805f9b34fb"

MEDIA_STATES = {0: "Inactive", 1: "Playing", 2: "Paused", 3: "Seeking"}

def properties_changed(interface, changed, invalidated, path):
    """Called whenever a GATT characteristic value updates via notification."""
    if "Value" not in changed:
        return

    value = bytes(changed["Value"])
    char_name = path.split("/")[-1]  # e.g. char002c

    # Media State notification
    if len(value) == 1:
        state_id = value[0]
        state_name = MEDIA_STATES.get(state_id, f"Unknown({state_id})")
        print(f"[{char_name}] Media State: {state_name}")

    # Track Title notification (UTF-8 string)
    elif len(value) > 1:
        try:
            title = value.decode("utf-8")
            print(f"[{char_name}] Track Title: {title}")
        except UnicodeDecodeError:
            print(f"[{char_name}] Raw value: {value.hex()}")

def enable_notifications(bus, target_uuid, label):
    manager = dbus.Interface(
        bus.get_object("org.bluez", "/"),
        "org.freedesktop.DBus.ObjectManager"
    )
    for path, interfaces in manager.GetManagedObjects().items():
        if "org.bluez.GattCharacteristic1" not in interfaces:
            continue
        uuid = interfaces["org.bluez.GattCharacteristic1"].get("UUID", "")
        if uuid.lower() == target_uuid.lower():
            char_iface = dbus.Interface(
                bus.get_object("org.bluez", path),
                "org.bluez.GattCharacteristic1"
            )
            char_iface.StartNotify()
            print(f"[OK] Subscribed to {label} at {path}")
            return
    print(f"[WARN] {label} characteristic not found.")

def main():
    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
    bus = dbus.SystemBus()

    # Listen for PropertiesChanged on all GattCharacteristic1 objects
    bus.add_signal_receiver(
        properties_changed,
        dbus_interface="org.freedesktop.DBus.Properties",
        signal_name="PropertiesChanged",
        arg0="org.bluez.GattCharacteristic1",
        path_keyword="path"
    )

    enable_notifications(bus, MEDIA_STATE_UUID,   "Media State")
    enable_notifications(bus, TRACK_TITLE_UUID,   "Track Title")
    enable_notifications(bus, TRACK_CHANGED_UUID, "Track Changed")

    print("\nListening for notifications... (Ctrl+C to stop)")
    GLib.MainLoop().run()

if __name__ == "__main__":
    main()
BlueZ source reference: The native MCS/GMCS implementation in BlueZ lives in src/shared/mcs.c and src/shared/mcs.h. The D-Bus media player API (used by the media stack) is in profiles/audio/media.c. The Python examples above use the generic GATT D-Bus API (org.bluez.GattCharacteristic1) which works with any GATT-based BLE profile without needing profile-specific BlueZ support.

🚀 Where MCP is Used in Real Products

🎧
True Wireless Earbuds

Tap gestures send MCP opcodes to the phone. The companion app reads Track Title and Media State to show the Now Playing screen.

Smartwatch

Watch subscribes to GMCS — reads Track Title, Duration, and Position to display a full Now Playing widget. Controls whichever app is active.

🚗
Car Head Unit

Car discovers all MCS instances on the phone, reads each Media Player Name, and lets the driver choose Spotify vs YouTube Music via the touchscreen.

🦻
LE Audio Hearing Aids

In the LE Audio ecosystem, MCP works alongside the Audio Stream Control Profile (ASCP) so hearing aids can pause audio streams intelligently.

📝 Summary — MCP at a Glance

Full Name Media Control Profile (MCP)
Version / Date v1.0 — March 9, 2021
Transport BLE (LE) and BR/EDR — both supported
Two Roles Media Control Client (GATT Client) + Media Control Server (GATT Server)
Core Dependency GATT (Generic Attribute Profile) — BLE Core Spec v4.2+
Services MCS (0 or more, optional) + GMCS (exactly 1, mandatory) + OTS (optional)
Command Mechanism Write opcode to Media Control Point char → server notifies result code
Security SM1 L2 minimum, 128-bit key, bonding mandatory, SM1 L1 excluded
Ecosystem LE Audio (alongside BAP, CAP, TMAP, HAP, GMAP)

🧠 Key takeaways:

  • 🎵 MCP is the standardized BLE way to control any media player
  • 🌐 GMCS = universal remote for the currently active app; MCS = dedicated remote for a specific app
  • 🎮 All commands are written as opcodes to the Media Control Point characteristic
  • 🔔 All state changes (playing, paused, track changed) come back as GATT notifications
  • 📦 Rich metadata (playlists, album art, chapters) is fetched via OTS
  • 🔒 Encryption and bonding are mandatory — unencrypted access is forbidden
  • 💻 In BlueZ, MCS/GMCS characteristics are accessible via standard GATT D-Bus API

🚀 Keep Going — Explore the LE Audio Ecosystem

MCP is one piece of the LE Audio puzzle. Here’s what to explore next:

📡 LE Audio — BAP & PAC Profile 🔊 Audio Stream Control (ASCP) 🎵 Volume Control Profile (VCP) 🔗 BLE GATT Deep Dive

Leave a Reply

Your email address will not be published. Required fields are marked *