macOS MCP audit logging: where macos-use already writes every tool call

Short version: it writes to /tmp/macos-use/. Every MCP tool call produces one .txt with the full pre- and post-action accessibility tree plus one .png of the target window. The path is also returned in the JSON-RPC response so the model that just called the tool always knows where the evidence landed. This is on by default; there is no flag to enable. The reference is Sources/MCPServer/main.swift:1961-1981.

M
Matthew Diakonov
6 min
Direct answer (verified 2026-05-20)

Path: /tmp/macos-use/<unix-ms>_<tool>.txt and matching .png. Verified against Sources/MCPServer/main.swift lines 1961-1981. Writeout is atomic, survives crashes, and is mirrored to stderr. On the dev machine this page was written from, /tmp/macos-use/ currently holds 105 unique tool invocations across 5.7 MB.

What lands on disk per call

Four artifacts come out of a single CallTool. The .txt and .png are persistent on the local filesystem; the stderr line and the JSON-RPC reply are fire-and-forget. The reason there are four and not one is that the same data serves two different consumers: the model needs a path it can grep right now, and the operator needs an artifact that outlives the session.

<unix-ms>_<tool>.txt

Flat one-element-per-line dump of the post-action accessibility tree. Header gives app name, element count, processing time. Lines look like [AXButton (button)] "Send" x:734 y:512 w:64 h:32 visible. For mutating tools, the file also contains the + / - / ~ diff against the pre-action tree.

<unix-ms>_<tool>.png

PNG screenshot of the target window written by an out-of-process helper. The click point and window bounds are passed in, so the helper can mark exactly where the agent clicked.

stderr log line

Every writeout is mirrored to stderr with the full path and byte count: log: handler(CallTool): wrote full response to /tmp/macos-use/<ts>_<tool>.txt (12832 bytes). Useful when piping the server through a log aggregator that already eats stderr.

JSON-RPC response 'file:' line

The compact summary returned to the MCP client embeds the path as 'file: /tmp/macos-use/<ts>_<tool>.txt'. The model uses that path to grep for an element it wants instead of stuffing the whole tree into context. Audit trail and runtime data path are the same file.

Who writes what, and where it goes

Every MCP client points at the same Swift binary. The binary is the only writer. The accessibility tree, the screenshot, the stderr lines, and the JSON-RPC summary all originate inside one CallTool handler. There is no background daemon, no rotating log file, no buffer that can lose entries on a crash.

One handler, four outputs

Claude Code
Cursor
Cline / Continue
Custom Anthropic client
mcp-server-macos-use
/tmp/macos-use/<ts>.txt
/tmp/macos-use/<ts>.png
stderr
JSON-RPC reply

The 20 lines that do the writeout

The whole audit-log mechanism is one block at the bottom of the CallTool handler. No abstraction, no logger framework, no rotating file appender. Plain String.write(toFile:atomically:encoding:) followed by a subprocess for the PNG.

Sources/MCPServer/main.swift

Two details to notice. One: the timestamp is Unix epoch milliseconds, not seconds (Int(Date().timeIntervalSince1970 * 1000)). That is on purpose. The accessibility-tree walks are fast enough that two calls in the same second is normal; second-precision filenames would collide. Two: atomically: true means the write goes to a temp file and is renamed in place at the end. A crash mid-write leaves either no file or a complete file, never a half-written one. Your audit trail does not contain torn entries.

5.7 MB

On this dev machine, /tmp/macos-use/ currently holds 105 unique tool invocations across 128 files. The oldest entry is May 18, 2026; the newest is from a Slack-driving session four hours ago. None of those came from a flag I set; they were all written by default.

ls -la /tmp/macos-use/ | wc -l, du -sh /tmp/macos-use/, 2026-05-20

The lifecycle of one audit entry

End-to-end, here is what happens between "Claude Code decided to click" and "there is a .txt and a .png on disk." Reading this top to bottom is also how you would read the stderr log of a live server.

From CallTool to /tmp/macos-use/

1

Tool call arrives

Claude Code (or Cursor, Cline, Claude Desktop) sends a CallTool JSON-RPC over stdio. main.swift:1549 logs the tool name; main.swift:1550 logs the raw arguments to stderr.

2

Pre-action traversal

If traverseBefore is set, the server walks the target app's AX tree first, so the diff has a baseline. The pre-action tree never hits disk on its own; it is differenced against the post-action tree.

3

Primary action runs

Click, type, scroll, set_value, press_ax, or set_selected. CGEvent for the synthetic-input variants, AXUIElement for the AX-attribute variants.

4

Post-action traversal + flat-text build

Tree is walked again, diffed, flattened by buildFlatTextResponse. resultTextString now holds the full payload, typically 2 KB to 60 KB.

5

.txt write

atomicall write of <unix-ms>_<tool>.txt to /tmp/macos-use/. Atomic-rename semantics mean a crash leaves either the complete file or no file, never a torn one.

6

.png capture

captureWindowScreenshot forks the screenshot-helper subprocess. PNG lands at <unix-ms>_<tool>.png alongside the .txt.

7

Compact summary returned

buildCompactSummary builds the response: status, pid, app, file:, file_size, hint, screenshot:. The model reads that and decides the next step.

Keeping the trail past macOS's /tmp cleanup

macOS clears /tmp/ aggressively. The periodic cleaner at /etc/periodic/daily/110.clean-tmps deletes files older than 3 days; a reboot wipes the rest. If you actually want a retained audit trail you need to copy out. The smallest workable setup is one rsync and one gzip, both wired to launchd.

# every 10 minutes: copy new files out of /tmp/
rsync -a --remove-source-files=false /tmp/macos-use/ ~/Library/Logs/macos-use/
# nightly: gzip anything older than 24h to save disk
find ~/Library/Logs/macos-use -name '*.txt' -mtime +1 -exec gzip +
# for SIEM ingestion: tail the directory and forward
fswatch -0 /tmp/macos-use | xargs -0 -I your-collector

The .txt files compress to roughly 10-20% of their on-disk size because the AX tree is repetitive (every row of a list has the same role and column structure). The .png screenshots are already PNG-compressed and do not shrink much further; if disk is the constraint, the cheapest move is to drop the .png and keep only the .txt. The text-only audit still contains every visible label and typed-in value, just not the rendered pixels.

What the trail does not capture (yet)

Two specific gaps worth knowing before you sell this as a complete audit log to a security review.

  1. Raw request arguments are stderr-only. The line log: handler(CallTool): arguments received (raw MCP): ... (main.swift:1550) prints what the agent asked for, but that never makes it into the .txt file. If Claude Code is redirecting stderr to /dev/null (it sometimes does, depending on the launch path), you lose the "what was the model trying to do" half of the audit. The fix is a 10-line change: prepend a header to resultTextString before the write, dumping params.arguments?.debugDescription.
  2. Cancelled calls leave no file. When the user presses Esc, InputGuard fires and the handler returns at main.swift:1988 without writing anything. You see the cancellation on stderr but you cannot reconstruct what the agent was about to do from /tmp/macos-use/. For a tamper-evident trail, you want a .txt for the cancelled action too, marked as such. That is another 10-line change in the catch arm.

Neither of these is hard, but neither is shipped today. Treat the current trail as a great development and debugging artifact, and a 80% audit log. The remaining 20% is one PR away.

Why this is not the BSM auditd

If you searched and landed on Apple's auditd / /var/audit docs first, those are a different layer. auditd captures kernel-level events: file opens, syscalls, process execs, login attempts. It has no concept of "the AI agent clicked the Send button in Mail." The /tmp/macos-use/ trail captures the application layer: which accessibility element was acted on, what changed in the tree, what the window looked like after.

For a complete picture you would run both. auditd answers "did anything weird touch the filesystem during that MCP session?". /tmp/macos-use/ answers "what did the agent click, type, and scroll?". The two do not overlap. Whichever question is keeping you up at night is the one to wire up first; if both are, wire up both.

Wiring this trail into a SIEM or a security review?

If you are forking the server to harden the audit log (session IDs, encrypted-at-rest, redaction), 20 minutes with the maintainer beats another evening reading main.swift.

Frequently asked

Frequently asked questions

Where does the macos-use MCP server write its audit log by default?

To /tmp/macos-use/ on the host that runs the server. Every CallTool handler creates that directory with FileManager.default.createDirectory at Sources/MCPServer/main.swift:1962, then writes <unix-ms>_<safe-tool-name>.txt with the full flat accessibility tree, plus <unix-ms>_<safe-tool-name>.png with a window screenshot. The path is also returned in the JSON-RPC response as the 'file:' line, so the model that just called the tool always knows exactly where the evidence landed. No env var, no flag, no opt-in. It is on by default.

What exactly is in each .txt file?

A flat one-element-per-line dump of the post-action accessibility tree (and the pre/post diff for click, type, scroll, press). Each line is '[AXRole (role description)] "text" x:N y:N w:W h:H visible', plus a header with the app name, element count, and processing time. The format is built by buildFlatTextResponse and formatElementLine in main.swift. The diff sections are tagged with + for added nodes, - for removed, and ~ for modified attributes (main.swift:1029-1047), so you can reconstruct what the agent changed about the UI without replaying anything.

What is in each .png file?

A PNG of the target window captured by the screenshot-helper subprocess immediately after the action runs. captureWindowScreenshot in main.swift:389-510 picks the on-screen window with the best score for the PID the tool acted on, then forks an out-of-process helper to write the PNG to /tmp/macos-use/<unix-ms>_<tool>.png. The click point and window bounds are passed in, so a red dot can be drawn on what the agent clicked. If the screenshot helper is missing or times out, the .txt still gets written; the audit trail degrades gracefully to text-only rather than failing the call.

Does the audit log survive a Claude Code or Cursor crash mid-session?

Yes. The writeout happens inside the CallTool handler before the response is returned (main.swift:1968 'try? resultTextString.write(toFile: filepath, atomically: true, encoding: .utf8)'). Atomically means the file is written to a temp path and renamed in place, so a crash mid-write leaves either the complete file or no file, never a torn one. If the MCP client crashes after the tool finished but before it read the response, /tmp/macos-use/ still has the .txt and .png. You can replay the session by ls-ing the directory in timestamp order.

How do I keep the audit trail instead of letting macOS clear /tmp/?

macOS clears /tmp/ on reboot via launchd and the periodic cleaner in /etc/periodic/daily/110.clean-tmps, which deletes files older than 3 days (the +mtime +3 rule in that script). To keep your trail, run a one-line rsync on a launchd interval: 'rsync -a /tmp/macos-use/ ~/Library/Logs/macos-use/'. The .txt files compress to roughly 10-20% of their on-disk size with gzip because the accessibility tree is repetitive. A second cron job that does 'find ~/Library/Logs/macos-use -name '*.txt' -mtime +1 -exec gzip {} +' will keep the long tail readable without blowing through disk. For SIEM ingestion, tail the directory with fswatch and pipe new files into your collector.

How do I correlate an entry in /tmp/macos-use/ back to a specific Claude Code conversation?

The filename timestamp is Unix epoch milliseconds (Int(Date().timeIntervalSince1970 * 1000), main.swift:1964). Cross-reference it against Claude Code's session log under ~/.claude/projects/<encoded-path>/sessions/<uuid>.jsonl, which records every assistant turn with its start time. For Cursor, the equivalent is the chat history file in the workspace state. The MCP server itself does not carry a session ID; correlation lives at the client. If you need first-class session attribution, fork the server and add a session header to the env (Claude Code passes env vars through to the spawned MCP process).

What is NOT captured in the current audit log?

Two specific gaps. (1) The raw MCP request arguments are logged to stderr ('log: handler(CallTool): arguments received (raw MCP): ...' at main.swift:1550) but are not folded into the .txt file. If your stderr is going to /dev/null because Claude Code suppresses it, you lose the 'what did the agent ask for' half of the audit. (2) Cancelled tool calls (the user pressed Esc, InputGuard fires) emit a 'log: handler(CallTool): user cancelled' line to stderr (main.swift:1988) but do not write a .txt for the cancelled action; only completed and errored calls leave a file. Both gaps are 20-line patches if you need a tamper-evident, complete trail.

Is /tmp/macos-use/ writable by other users on the same Mac?

No, the directory is created with default permissions (drwxr-xr-x, owned by the user that ran the MCP server). Other local users can read the files but cannot write or modify them. If you want to harden this further, move the output dir to ~/Library/Logs/macos-use/ in your fork (it is a one-line change at main.swift:1961) and the directory inherits ~/Library/Logs/ permissions, which are owner-only. The host process needs Accessibility permission to capture the AX tree at all, so a non-admin user on the machine cannot scrape a trail from another user's MCP session without already having sudo.

Does this audit log capture screenshots of windows that contain sensitive data?

Yes, by default, every tool call that succeeds against a window produces a .png of that window. If you are driving 1Password, Mail, Slack DMs, or a banking site, the rendered pixels of that window end up on disk in /tmp/macos-use/. If that is not acceptable for your environment, pass an env var check around the captureWindowScreenshot block at main.swift:1977-1979 in your fork ('if ProcessInfo.processInfo.environment["MACOS_USE_NO_SCREENSHOT"] == nil { ... }'). The .txt accessibility tree will still contain element text (typed-in fields, visible labels), so a text-only audit is not the same as no audit.

How does this compare to using the macOS BSM auditd (the /var/audit log)?

Different layers entirely. auditd captures kernel-level events: file opens, process execs, syscalls, login attempts. It does not know what an MCP tool call is, and it does not see what the model asked the agent to do. The /tmp/macos-use/ trail captures the application layer: which AX element was clicked, what the window looked like after, what changed in the tree. For a complete picture you would want both. auditd answers 'did anything weird touch the filesystem?'; /tmp/macos-use/ answers 'what did the AI agent click?'. The two never overlap and the second is not redundant with the first.

macos-useMCP server for native macOS control
© 2026 macos-use. All rights reserved.

How did this page land for you?

React to reveal totals

Comments ()

Leave a comment to see what others are saying.

Public and anonymous. No signup.