macOS 15 SequoiaControl CenterCGEventTapagent-driven

How To Add Screen Recording To Control Center Without Your Hand On The Trackpad Ruining The Drag

The manual version takes five clicks through System Settings and Control Center. Apple Support covers that. This page is about the version where you tell Claude Desktop or Cursor to do it for you, and the detail nobody else writes down: while the server is dragging the Screen Recording module into its new Menu Bar slot, it installs a CGEventTap that drops every one of your keystrokes and every pixel of trackpad motion by reading event.getIntegerValueField(.eventSourceStateID). Non-zero passes. Zero gets dropped. Press Esc to release.

M
Matthew Diakonov
10 min read
5.0from open source
InputGuard.swift is 355 lines; the hardware-event filter is one line at InputGuard.swift:329-331
Synthetic CGEvent.post() calls use .hidSystemState (non-zero stateID) and pass the tap unchanged
Plain Esc (keycode 53, no modifiers) releases the guard in under one event cycle
30-second watchdog at InputGuard.swift:24 auto-disengages if the MCP server ever hangs

Start With The Manual Path. It Takes Thirty Seconds.

Open System Settings. Pick Control Center in the sidebar. Scroll to the Screen Recording module. Switch Show in Menu Bar to Always. Done. On macOS 15 Sequoia you can also open Control Center itself, hold the edit affordance at the bottom, drag Screen Recording into a slot, and drop it. The top SERP results (Apple Support, Cult of Mac, the Apple Community thread at discussions.apple.com/thread/255457988) all walk through some permutation of those two paths.

What none of them cover is the one that matters if you live inside an agent: you type a sentence into Claude Desktop, the model picks up the macos-use MCP tools, and the drag happens while your hand is still on the trackpad because you were reading. The naive version of this fails. Your cursor movement is physically the same input channel as the server's synthetic drag, so the drop lands one slot off and the agent reports success because the AX traversal says the module moved.

The fix is not a UI prompt that says "please hold still." The fix is a CGEventTap that drops hardware input before the window server ever sees it. The whole point of this page is the one line of code that decides which events fall into which bucket.

What The Agent Actually Does Between Your Prompt And The PNG

Six steps. The server engages the input guard first, does the drag, releases the guard. Step 3 is the one every other macOS MCP skips.

1

MCP client receives the prompt and picks macos-use_open_application_and_traverse

The tool opens com.apple.systempreferences, activates the window, and traverses the accessibility tree. The response contains the PID and a file path under /tmp/macos-use/ the model greps for 'Control Center' to find the sidebar row.

2

macos-use_click_and_traverse lands on the Control Center pane

The click is synthesized with CGEvent.post at the AXStaticText coordinates. Screenshot-helper captures the post-click window state so the model can verify the pane opened.

3

Before the drag starts, InputGuard.engage() installs the tap

InputGuard.swift:69-95 takes the lock, sets _engaged = true, creates the CGEventTap on the main run loop, shows the orange pill, and starts the 30s watchdog. The pill reads 'Adding Screen Recording to Control Center'.

4

The drag posts synthetic mouse events with .hidSystemState

Every posted event carries a non-zero eventSourceStateID. The tap callback at InputGuard.swift:329-331 forwards them unchanged. In the same stream, any real hardware event (your hand on the trackpad, a stray key) has stateID == 0 and is dropped by returning nil from the callback.

5

macos-use_refresh_traversal confirms the module moved

After the mouse-up, the server re-traverses the AX tree, scores the new position of the Screen Recording module against the intended slot, and writes a .txt diff file plus a .png to /tmp/macos-use/. The model reads the diff (+/-/~ prefixed lines) to verify.

6

InputGuard.disengage() tears down the tap and hides the overlay

InputGuard.swift:98-109 destroys the CGEventTap, stops the watchdog timer, and hides the NSWindow. Your keyboard and mouse come back in the same event cycle. The MCP response returns with status=ok and a path to the screenshot the model already read.

Anchor code 1 of 3

The One Line That Decides Whose Event Gets Through

The whole filter fits on three lines. Every other check in the callback is a special case: handle tap-disable, handle Esc, drop everything else. The interesting decision is the single comparison against zero at the top.

Sources/MCPServer/InputGuard.swift

The pattern works because CGEventSource comes in three forms (.hidSystemState, .combinedSessionState, .privateState) and the first one carries a non-zero stateID that the kernel stamps onto every event posted with that source. Hardware events arrive with stateID == 0. You can verify this from Terminal with a tap of your own: log event.getIntegerValueField(.eventSourceStateID) and move the mouse.

What Crosses The Tap, And What Gets Dropped At The Boundary

The CGEventTap sits between every input event and the macOS window server. From inside the callback you decide per-event whether to return the event unchanged, swap it for a different event, or return nil (drop it on the floor). macos-use returns nil for hardware and passes the event through for anything with a non-zero stateID.

Event source routing through the CGEventTap on the main run loop

MCP server synthetic clicks (stateID != 0)
User trackpad drift (stateID == 0)
Keystrokes (stateID == 0)
Esc keydown (keycode 53, no modifiers)
inputGuardCallback at InputGuard.swift:311-355
Forwarded to window server (synthetic)
nil: dropped (hardware)
_cancelled = true + disengage() (Esc)
Re-enable tap if macOS auto-disabled it
Anchor code 2 of 3

Where The Guard Engages And Why It Has To Run On The Main Thread

CGEventTap callbacks only fire while the run loop that owns the tap is active. If macos-use put the tap on a background queue and the tool handler awaited a traversal, the callback would stall and the Esc-to-cancel promise would break. Attaching to CFRunLoopGetMain() guarantees it fires even while the handler is suspended inside an await, because Swift concurrency yields the main thread between suspension points.

Sources/MCPServer/InputGuard.swift
Anchor code 3 of 3

Naive Drag Versus Shielded Drag

Left: the shape every other macOS MCP server has for a drag. It posts the mouse-down, the mouse-moveds, and the mouse-up, and prays. Right: the macos-use shape. Same five event posts, wrapped in engage() and disengage() with a throwIfCancelled() loop in the middle so Esc exits within one event.

Same drag path, two failure modes

// A naive automation layer: post events, hope the human stays still.
// If the user nudges the trackpad while the drag is in flight, the drop
// lands in the wrong slot of Control Center's edit grid.
// The agent then reads the AX tree, sees the module moved, reports success,
// and the user is left with Screen Recording in a slot they didn't want.

func dragScreenRecordingToControlCenter() {
    let src = CGEventSource(stateID: .hidSystemState)

    // Mouse down on the Screen Recording module
    post(src, .leftMouseDown, at: modulePoint)

    // Drag along the path to the Menu Bar slot.
    // Any hardware mouseMoved events arriving in between WILL be delivered
    // to the window server alongside ours, and the accumulated cursor
    // position drifts. There is no separation between agent and human.
    for point in dragPath {
        post(src, .mouseMoved, at: point)
        usleep(8_000)
    }

    // Mouse up: whatever slot is under the cursor wins.
    post(src, .leftMouseUp, at: destinationPoint)
}
-11% similar line count, very different correctness

What The Stderr Log Says During One Guarded Run

Every line below is a literal string emitted by the server during a guarded automation, in the order they appear on stderr. The sourceState=0 suffix is how you spot the hardware Esc at a glance. The /tmp/macos-use/esc_pressed.txt marker exists so you can prove to yourself that the cancel really happened, even if the MCP client swallowed the error.

mcp-server-macos-use stderr during an input-guarded drag
1 line

Let programmatic events through. Our CGEvent.post() calls use .hidSystemState source which has a non-zero stateID. Hardware events have stateID == 0.

doc comment at Sources/MCPServer/InputGuard.swift:326-328

Numbers You Can Reproduce From The Current Commit

Every number below comes directly from Sources/MCPServer/InputGuard.swift at HEAD. Clone the repo, open the file, count them yourself.

0total lines in InputGuard.swift
0CGEventType categories blocked
0swatchdog auto-release timeout
0keycode for Esc-to-cancel
0
line of the eventSourceStateID filter
0
line that attaches the tap to CFRunLoopGetMain
0
max width in points of the overlay pill
0
pixel diameter of the pulsing orange dot

Real Failure Modes Of Agent-Driven Control Center Edits

Every failure below is observable without the guard. Each disappears the moment InputGuard is engaged because the window server never sees the hardware input that would have caused it.

Trackpad nudge lands the drop one slot off

Control Center's edit mode snaps drops to the nearest grid cell. A one-millimeter finger movement during the drag is enough to shift the drop target across the cell boundary. The AX traversal later confirms 'the module moved' so the agent reports success.

Hardware keydown triggers a menu shortcut mid-drag

If the user happens to press Cmd-Space while the drag is in flight, Spotlight opens and the drag is abandoned. Without the guard, there is no way to keep Spotlight out of the event stream.

Scroll wheel scrolls the Control Center pane away

A small vertical trackpad scroll while the agent is reading the Screen Recording row moves the row off-screen. The next click targets empty space.

Right-click opens a context menu that steals focus

Two-finger tap on an unrelated window while the agent is inside System Settings pops a context menu on top of the drag target. The drag never lands.

Cmd-Tab swaps the frontmost app during the automation

A single accidental Cmd-Tab mid-drag sends the mouse-up to whatever app became frontmost. Without the guard dropping the flagsChanged and keyDown events, there is no way to keep the user in System Settings long enough to finish the flow.

CGEventType categories InputGuard drops on the floor

keyDownkeyUpleftMouseDownleftMouseUprightMouseDownrightMouseUpmouseMovedleftMouseDraggedrightMouseDraggedscrollWheelflagsChanged(only Esc bypasses: keycode 53)

The Drag With And Without The Input Guard

Toggle between the two and watch what changes. The same agent plan. The same five event posts. The only difference is whether the tap is installed.

Adding Screen Recording to Control Center from a Claude Desktop prompt

Agent posts the drag. Your hand drifts a pixel. The drop lands in the slot above the intended one. The AX diff says the module moved, so the agent reports success. You discover the bug an hour later when Screen Recording is next to Do Not Disturb instead of Wi-Fi.

  • Hardware input and synthetic input share the same event stream
  • No way to verify the drop landed in the right cell except by eye
  • Esc does nothing because no tap is installed to catch it
  • Cmd-Tab mid-drag swaps the frontmost app, drag aborts silently

Guarantees InputGuard makes while you are telling an AI to add Screen Recording to Control Center

  • Every hardware keyDown, keyUp, mouse, drag, scroll, and flagsChanged event is dropped at the CGEventTap callback
  • Synthetic events posted with .hidSystemState pass through unchanged because their eventSourceStateID is non-zero
  • Plain Esc (keycode 53, no modifiers) releases the guard in under one event cycle via the _cancelled flag
  • The 30-second watchdog at InputGuard.swift:24 auto-disengages even if the MCP server crashes mid-automation
  • The tap is installed on the main run loop so callbacks fire even while Swift concurrency is awaiting
  • An orange pulsing overlay pill tells the user exactly what the agent is doing and how to cancel
  • If Accessibility permission is missing, engage() logs and returns; the automation runs unshielded instead of crashing

Try It On Your Own Machine

One swift build produces the server and the sibling screenshot-helper binary. Grant Accessibility and Screen Recording permissions to the built binary, point Claude Desktop at it, and ask the model to add Screen Recording to Control Center. Watch stderr in the Claude Desktop MCP log viewer; the InputGuard TAP log lines are how you verify the guard is active during the drag.

git clone https://github.com/mediar-ai/mcp-server-macos-use
cd mcp-server-macos-use
xcrun --toolchain com.apple.dt.toolchain.XcodeDefault swift build -c release

# Grant Accessibility permission to .build/release/mcp-server-macos-use
# System Settings, Privacy & Security, Accessibility, +

# Point Claude Desktop at .build/release/mcp-server-macos-use in
# claude_desktop_config.json under mcpServers, then restart.

# Prompt: "Open System Settings and set Screen Recording to
# Always Show in Menu Bar."

Frequently Asked Questions

Frequently asked questions

What is the shortest manual way to add Screen Recording to Control Center on macOS Sequoia?

Open System Settings, pick Control Center in the sidebar, scroll to the Screen Recording module, and switch Show in Menu Bar to Always (or use the Control Center Modules edit mode on macOS 15 to drag the control into a new slot). That is the flow every top SERP result walks through. The interesting version is asking an MCP client to do it for you: 'Open System Settings, go to Control Center, and set Screen Recording to Always Show in Menu Bar.' The server exposes macos-use_open_application_and_traverse, macos-use_click_and_traverse, and macos-use_refresh_traversal; those three tools are enough to finish the task without a human touching the trackpad. The reason the manual path matters at all is that most agents will not drive a drag cleanly if a human is still holding the mouse. This is what InputGuard solves.

What exactly does InputGuard block, and what does it let through?

It blocks every hardware event type listed at InputGuard.swift:115-126: keyDown, keyUp, leftMouseDown, leftMouseUp, rightMouseDown, rightMouseUp, mouseMoved, leftMouseDragged, rightMouseDragged, scrollWheel, and flagsChanged. It lets through exactly one category: events whose event.getIntegerValueField(.eventSourceStateID) is not zero. That is the check at InputGuard.swift:329-331. The server synthesizes its clicks through CGEvent.post with a source of .hidSystemState, which has a non-zero stateID; genuine hardware events have stateID == 0 and get dropped on the floor. The only exception to the block is Esc (keycode 53, no modifiers), checked at InputGuard.swift:344-350, which cancels the automation by setting a _cancelled flag the MCP tool handler can throw on between steps.

Why is the source-state-ID filter necessary if the server is already the only thing touching the mouse?

Because in practice, when a model says 'go add Screen Recording to Control Center,' there is almost always a real human watching the screen. Their fingers sit on the trackpad. They move the cursor a pixel while the agent is halfway through a drag from a Control Center module to a new Menu Bar slot. Without InputGuard, that one pixel of drift lands the drop target in the wrong cell and the whole flow fails silently because the model confirms the state from an accessibility-tree traversal that says 'yes, the module moved' even though it moved to the wrong place. The filter at InputGuard.swift:329-331 solves that by physically dropping the hardware event before it reaches the window server. The model's synthetic click goes through because it was posted with a different source.

Can I cancel mid-automation without force-quitting the server?

Yes. Press Esc with no modifiers at any time during the automation and InputGuard releases immediately. The implementation at InputGuard.swift:340-351 reads keycode 53 and checks that flags.intersection([.maskCommand, .maskControl, .maskAlternate, .maskShift]) is empty, meaning Cmd+Esc, Ctrl+Esc, Option+Esc, and Shift+Esc all get treated as normal blocked input. Plain Esc sets _cancelled = true and calls disengage(). The tool handler calls throwIfCancelled() between steps (the public method at InputGuard.swift:53-60) and an InputGuardCancelled error bubbles back up as the MCP response. The user sees their keyboard and mouse come back within one event cycle.

What prevents the guard from locking the machine forever if the MCP server crashes?

A 30-second watchdog. InputGuard.swift:24 declares var watchdogTimeout: TimeInterval = 30. When engage() is called, startWatchdog() (at InputGuard.swift:172-180) scheduleds a DispatchSource timer on .global() with that deadline; when it fires, it logs 'watchdog fired after 30s, auto-disengaging' to stderr and calls disengage() itself. That path destroys the CGEventTap, stops the timer, and hides the overlay window. A crashed server never holds the tap longer than 30 seconds. macOS also runs a system-level guard: the comment at InputGuard.swift:298-306 handles .tapDisabledByTimeout and .tapDisabledByUserInput re-enable events, so if the kernel decides the tap is misbehaving it gets torn down too.

Where does the orange pulsing pill overlay come from?

It is an NSWindow drawn at screen-saver level with a 720pt by 80pt rounded container, built in buildAndShowOverlay at InputGuard.swift:202-277. The pill's background is NSColor(white: 0.08, alpha: 0.92). Inside it, a 16pt circular NSView at origin x: 28 pulses opacity from 1.0 to 0.3 over 0.8 seconds on a CABasicAnimation with autoreverses = true and repeatCount = .infinity (InputGuard.swift:244-251). The label next to it is 20pt semibold white, single line, truncating-tail, with the default message 'AI is controlling your computer - press Esc to cancel' set at the public engage() default parameter at InputGuard.swift:69. ignoresMouseEvents = true at InputGuard.swift:219 makes the overlay non-interactive so it never steals a click.

Does this mean the screen is also recorded, or just the hardware input blocked?

Just the hardware input. Screen recording is a separate Apple API that you grant in System Settings, Privacy & Security, Screen Recording. InputGuard does not record anything. This page is about the opposite problem: how the automation finishes the task of adding Screen Recording to Control Center in the first place without your cursor-movement noise corrupting the drag. Once the module is in Control Center, you start and stop recordings through the usual macOS UI or through the screencapture CLI; none of that goes through InputGuard.

How does this server compare to other macOS MCP implementations on this specific point?

I checked steipete/macos-automator-mcp, ashwwwin/automation-mcp, CursorTouch/MacOS-MCP, and mb-dev/macos-ui-automation-mcp. None of them install a CGEventTap around the automation. Each treats the user as a passive observer who should manually stay off the mouse. That is fine for short one-click tasks; it breaks the moment the agent performs a sustained drag in a Control Center edit mode and the human reflexively moves the cursor. macos-use is the only one I found that ships an InputGuard as a dedicated 355-line file whose entire purpose is to solve this single edge case. Compare the diff yourself: grep -rn 'CGEventTap' across those four repos returns nothing.

What happens to the Esc-to-cancel signal if the server is deep inside a Swift concurrency await?

The tap is installed on the main run loop at InputGuard.swift:150 via CFRunLoopAddSource(CFRunLoopGetMain(), runLoopSource, .commonModes). That placement matters: the comment at InputGuard.swift:78-80 explains that Swift's async await yields the main thread between suspension points, so the main run loop keeps processing event tap callbacks during automation. When Esc arrives, the callback at InputGuard.swift:311-355 sets _cancelled = true inside an NSLock-guarded block and writes '/tmp/macos-use/esc_pressed.txt' so you can verify the cancel happened outside the process. The next throwIfCancelled() call inside the tool handler sees _cancelled and throws InputGuardCancelled, which the MCP response layer wraps as an error back to the client.

Can I verify the eventSourceStateID trick with a minimal reproduction?

Yes. Write a Swift script that installs a CGEventTap at .cghidEventTap with the .defaultTap option, and in its callback log event.getIntegerValueField(.eventSourceStateID). Move the trackpad: every mouseMoved event logs stateID == 0. Post a synthetic event via CGEvent(source: CGEventSource(stateID: .hidSystemState), mouseType: .mouseMoved, mouseCursorPosition: CGPoint(x: 100, y: 100), mouseButton: .left) and call .post(tap: .cghidEventTap): the tap callback sees a non-zero stateID. That is the exact behavior InputGuard.swift:329-331 relies on. If you want to read the filter in its real context, open mediar-ai/mcp-server-macos-use on GitHub, jump to Sources/MCPServer/InputGuard.swift, and find the comment 'Our CGEvent.post() calls use .hidSystemState source which has a non-zero stateID.'

Is there a way to raise or lower the 30-second watchdog?

Yes, but not from outside the server. InputGuard.shared.watchdogTimeout is declared as var (mutable) at InputGuard.swift:24 and set to 30. If you fork the server you can bump it before the first engage() call, or mutate it between calls. I would not go much higher: the watchdog exists exactly to prevent a hung automation from leaving the user locked out of their own keyboard. If an MCP tool takes longer than 30 seconds to finish a single click-drag sequence on System Settings, that is a sign the accessibility traversal found the wrong element, not a sign that the watchdog is too short. Re-run refresh_traversal first.

Will a future macOS tightening of TCC permissions break the tap?

The tap requires the Accessibility permission (the same one Cmd-clickable in System Settings > Privacy & Security > Accessibility). If that permission is not granted, createEventTap at InputGuard.swift:113-155 fails cleanly: CGEvent.tapCreate returns nil, the else branch at InputGuard.swift:139-145 logs 'failed to create CGEventTap (check Accessibility permissions)' to stderr, and _engaged is set back to false so disengage() is a no-op. The automation proceeds without input shielding, which is degraded but not broken. No new TCC category has been added around CGEventTap; .cghidEventTap has behaved identically from macOS 10.4 through Sequoia.

Read The Whole Guard In One Scroll

InputGuard.swift is 355 lines. The filter is three lines of that. The rest is watchdog scheduling, NSWindow overlay construction, and the boilerplate to tear everything down on Esc or on timeout. The repo is MIT-licensed Swift; every line number on this page is stable at HEAD.

Open Sources/MCPServer/InputGuard.swift on GitHub
macos-useMCP server for native macOS control
© 2026 macos-use. All rights reserved.