Cupertino 1.7.0
Version 1.7.0 ·
Added
Messages can send a photo, not only text.
apple_messages_send_messagetakes anattachmentId— the sameattachment.guidapple_messages_save_attachmenttakes — and forwards that file to the conversation. It runs the same source boundary as saving does: the path resolves inside~/Library/Messagesor the send is refused.The two file lanes are gated differently, and that is the whole design.
attachmentIdreaches only files Messages itself already stores, so its blast radius is bounded by construction and it ships behindALLOW_WRITESlike the rest of sending. Naming an arbitrary local path is a different act — an exfiltration primitive, and one this surface is unusually exposed to, because the untrusted text that would drive it arrives through this server's own read tools. SofilePathexists as a parameter only whenAPPLE_MESSAGES_ALLOW_FILE_SENDis set, and it is off by default.Absent rather than refused: with the flag off,
filePathis not in the tool's schema at all. A parameter that exists and always says no is a parameter a model keeps filling in. There is no directory confinement on it, deliberately — any client that can also write files defeats one with a single copy, so it would read as a boundary while being a speed bump.One call sends one thing, because Messages'
sendtakes a file or a string. A captioned photo is two calls, and a call naming two payloads is refused rather than guessed at.Safari can click, type and scroll on a page — through the extension, with no Apple Event and no TCC grant at all.
apple_safari_page_elementslists what is clickable with a short id apiece;apple_safari_click,apple_safari_fillandapple_safari_scrollact on those ids behind the write gate.page_elementsis ungated: enumerating changes nothing, and it is the same class of act as reading a page, which this surface already does.It is the capability
do JavaScriptwould have bought, taken through the one door that is consented per website. That verb is still refused for the reasons it always was — global, permanent, unscoped, state unreadable. The extension is the same power granted one site at a time, visibly, revocably.The channel is files in the extension's own container, which is writable as well as readable by a same-user process. Nothing else was good enough:
dispatch message to extension, Safari's hidden verb for waking an extension, was measured accepting an empty dictionary and a bogus extension identifier without complaint and returning nothing — a message that went nowhere cannot be told from one that arrived, which is the same silent failuredo JavaScriptwas rejected for.Two properties the tool descriptions carry because a caller cannot infer them: commands are at-most-once — a click that times out may still have landed, and must never be retried automatically — and element ids die on navigation, so a click that loaded something invalidates every id the caller holds. Elements are addressed by id rather than CSS selector precisely because a stale selector does not fail, it clicks the wrong thing.
Verified against a fake extension, not against Safari: exercising the real one needs a notarized build, since Safari will not list an extension whose container app is not stapled.
Notes can attach a file to a note, and reading attachment bytes back actually works.
apple_notes_add_attachmentputs a file into a note — the only way to get an image in there, since an<img>in the body is dropped. It goes through the Standard Suite'smake new attachment ... withData:, licensed by note's hidden<element type="attachment">even though every property on the attachment class itself readsaccess="r".save_attachment's file lane had never saved a single real attachment. It resolved against the attachment id, butICAttachmentcarries no path of its own and the id Apple Events hands back contains slashes, so no directory was ever named after it. Its tests passed because the fixture used a shape the dictionary does not return — the failure was invisible from inside the suite. The bytes live behindZMEDIA, a foreign key to theICMediarow holding the identifier, generation and filename segments, and#findMediaresolves through that row now, established against a live store byscripts/probe-notes-media.mjs.It also corrects a claim these docs had been making: attachment bytes were called the one pure capability gain of the file lane. They are not. Notes answers
save attachment ... in <file>itself, over Apple Events, with no Full Disk Access at all — the file lane buys speed here, not capability.Every server publishes to the MCP Registry. Each surface ships a
server.json, and apublish-registryjob pushes it once the npm publish for that tag has landed. A client that does not already know these packages exist finds them through the registry, which is also what feeds the third-party directories;docs/alternatives.mdrecords why that was worth automating, which is that every rival is listed in several and this was listed in none.It runs after
publish-npm, never beside it. The registry proves ownership by fetching the just-published tarball'spackage.jsonfrom registry.npmjs.org and checking itsmcpNameagainstserver.json'sname, so a job running in parallel races the very publish it is validating and fails on a version npm has not seen yet. Authentication is GitHub OIDC against theio.github.mgcrea/*namespace, so there is no registry token to store, rotate, or leak from a laptop. A tag whose package has noserver.json—core,app,api— is resolved and skipped rather than failed, and a version already in the registry is a notice rather than a red run over a release that already completed.scripts/generate-version.mjsowns both version fields in eachserver.json, so what the registry receives agrees with the tag by construction rather than by a second bump somebody has to remember.
Fixed
The Safari extension's manifest version tracks the app again.
manifest.jsonsaid1.0while the appex around it correctly said 1.6.0 — the bundle version comes fromMARKETING_VERSIONat build time, so Safari andpluginkitalways showed the right number and nothing ever surfaced the stale one.scripts/generate-version.mjsowns the field now, somake version-checkfails on drift like it does for every other copy of the version. MV3 allows only one to four dot-separated integers, so a pre-release suffix is dropped rather than copied.It was not cosmetic. The extension now stamps
extensionVersionon every capture and result, and the server uses it to name the one cause of silence a caller cannot diagnose: a Sparkle update swaps the appex immediately, but an already-open tab keeps running the previous content script, orphaned and unable to answer. That is indistinguishable from "not allowed on this site" and has a completely different fix, so the timeout now says to reload the tab — but only when a capture stamped with another version proves it, never on a guess.page_elementsnever hands out a credential. It reports what a text field holds, andinput[type=password]is a text field — the first cut of it returned passwords and card numbers, from a tool marked read-only whose description did not mention values at all. A field classified as a credential now comes backredacted: "credential"withhasValueand no value, and no setting returns it. The classification runs in the content script rather than the server, because a result crosses the boundary as a file in the appex container: redacting afterwards would mean writing the secret down first.Safari can read a one-time 2FA code, behind
APPLE_SAFARI_ALLOW_CODES. Off by default, its own gate rather than the write gate — reaching a read throughallowWriteswould mean granting the right to click a button in order to see a number.A code on a page is in one of two places and they need different mechanisms. In a FIELD, where AutoFill put it:
page_elementsreturns its value under this flag. Rendered as TEXT, which is the ordinary case:apple_safari_find_codesscans the live DOM, because there is no input to enumerate andread_pagecannot help either — a code delivered by XHR into an already-open tab was never captured.The page returns bounded excerpts and judges nothing; extraction runs on the server, on the same heuristic Messages uses. That heuristic moved to
@mgcrea/mcp-apple-corewhen Safari became its second caller, rather than being duplicated into a second copy that would drift. It is the first heuristic in core, which has otherwise been plumbing, and it is reused rather than re-validated: its test table is SMS-shaped, and a web page is a much richer source of digit runs.lowconfidence cannot occur on this lane at all — the only route to it is a shortcode sender, and a page has none — so a digit run with no keyword near it yields nothing.There is no
ageSecondshere and there cannot be. A message carries the time it arrived; a paragraph does not, and an expired code reads exactly like a live one.pageAgeSecondsbounds the age from above and never from below.apple_safari_fillstopped forbidding it, which it had done since before any of this existed — its description told a caller to never put a one-time code through it, so the two halves of this feature contradicted each other and the read was useless. Filling a code is now the case it names first. A password or a card number is still not: nothing here can read either, so a value about to go through that tool came from somewhere else. It also now says what it cannot do — a site that splits a code across six single-character boxes takes one string into one element and will not work, so enumerate again and check rather than assuming it took.It reads a code a website shows. It does not reach Passwords.app, whose four lanes are all closed and stay closed —
docs/passwords.mdnow carries a table of the four questions so the two do not get conflated, and records the extension as a fifth lane it had not evaluated.A tool's
limitcan no longer quietly exceed the ceiling its own description advertises. Twenty-odd call sites spelledlimit ?? maxResultsby hand in five different ways, and three surfaces did it with noMath.minat all — so their real ceiling wasmaxResultswhilelimitArg's description told the model the default was 25. A model that trusted the description and omitted the argument could get eight times the rows it asked for.resolveLimit(limit, maxResults, fallback)in@mgcrea/mcp-apple-corereplaces every one of those spellings across all eight surfaces, and is now the only place a caller's limit meets the configured ceiling.apple_safari_read_pagebounds what it returns.maxCharswas optional with no fallback, so a call that omitted it got the whole capture — up to the extension's own caps of 256 KiB of text or 1 MiB of html, a quarter of a million tokens out of a tool a model reaches for casually. It defaults to 32,768 characters now, the same callget_message_sourcealready made for its own byte budget.apple_messages_list_messageshonoursdefaultRangeDayswhen it is given only afrom.client.windowwas called with the parsedfrom/toand nothing else, so naming just a start ran all the way to now, however far back that start was. The setting has been in the config since this surface shipped, with a comment describing exactly this behaviour, and nothing ever read it — Safari already closed an open-ended range this way and Messages did not.find_codeskeeps its own open-ended call, where "until now" is genuinely what is meant.The Safari extension's toolbar icon renders as a disc rather than a squircle, which is the shape Safari actually masks a toolbar item to.
Changed
Updates is its own pane in Settings, instead of a section partway down General. It was under the version number on the theory that somebody wondering whether they are current has already looked there, which holds only for people who scroll. Automatic checking is off until asked for, so Check Now is the only way an unopted build ever looks at all, and it was the second card on a page otherwise about launching at login and where the bundle lives. The pane repeats the version in its first row, so "am I current" is still answered in one place.
Mail's message rows carry
refalone, notrefplus the three fields it is built from.id,accountUuidandmailboxare exactly what aMessageIdentityis assembled out of — it names them now — so repeating them on every row of every list paid for the same identity twice, measured at 38% of a 25-row reply. Both call sites still carry them internally and drop them oncerefexists. A caller that was reading those fields off a row should read them offref, which is the value every tool taking a message already wants.Tool results are compact JSON. Every result went through
null, 2pretty-printing; measured against these servers' own response shapes that added 25-41% to the payload depending on how many short keys a row carries, worst on exactly the lists already big enough to matter. A model has no use for the indentation.