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
📺 Start Here: The TV Remote Analogy
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
MCP defines two roles and two services. The diagram below shows how a Media Control Client and Server communicate over BLE:
|
WRITE opcode
➜
➜
NOTIFY result
BLE ACL
Link |
👥 The Two Roles in Detail
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
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
🎛️ 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 |
Media Player Name reads “Spotify”.🔧 The GATT Foundation
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:
📌 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
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) |
0x02 — Not Supported.🔄 Step-by-Step: Common Procedures
| 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) |
| 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 |
| 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:
🔒 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 |
💻 BlueZ Code Examples
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
# 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
#!/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()
#!/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())}]")
#!/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()
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
Tap gestures send MCP opcodes to the phone. The companion app reads Track Title and Media State to show the Now Playing screen.
Watch subscribes to GMCS — reads Track Title, Duration, and Position to display a full Now Playing widget. Controls whichever app is active.
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.
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
