v2.12.0
Headline shifts: every tool error now carries a typed category and a Trace: <id> line so a single failed call threads end-to-end through audit log + telemetry; RFC 0008 (Elicitation) Phase 1 lands the capability gate + env opt-out; RFC 0009 (iWork depth) Phase 1 ships the first three Numbers tools (tool surface 269 → 272); npx airmcp doctor --deep walks live audit-chain integrity + Swift bridge ping + module boot smoke for user-reported troubleshooting; the OAuth /.well-known/oauth-protected-resource document picks up SEP-985 alignment with DPoP advertisement + RFC 9728 optional fields. RFC 0001 typed-error adoption reached 29/32 modules with three intentional skips justified inline. Issue #145 (list_calendars empty after fresh permission grant, reported by @jagaliano on macOS 15.7.5) closed via a one-line EKEventStore.reset() in the shared authorize helper. Two future-direction drafts (RFC 0010 progressive disclosure / RFC 0011 post-WWDC) staked out.
Reading guide: entries are grouped by RFC track and audience.
- For users — features visible in the AI's tool surface, error messages, or
npx airmcp doctoroutput.- For developers — internal helpers, test infra, RFC 0001 helpers, error envelope shape.
- For operators — env var changes, audit / OAuth / rate-limit / network policy adjustments. See docs/environment.md for the full env knob index.
RFC 0001 typed errors — adoption complete (29/32 modules)
The toolError(action, e) fallback classifier in result.ts got companion errJxaFor / errSwiftFor / errUpstreamFor catch helpers (PR #173) that auto-attach cause.origin and the "Failed to <action>: <message>" prefix. Across PRs #166, #168, #169, #170, #171, #172, #173, #185, #186, #187, #188 every module that wraps runJxa / runSwift / runAutomation / HTTP fetches migrated their catch blocks from the fallback to the typed helpers. Adoption is now 29/32 tools.ts files.
The remaining three modules intentionally keep toolError because the fallback's internal_error classification is the right answer for them:
audit/tools.ts— fs reads of the on-disk JSONL audit log. ENOENT / EACCES are already handled inside the audit reader, and zod surfacesinvalid_inputautomatically for thesinceISO-8601 parameter. No category gain from migrating.memory/tools.ts— fs + JSON parse of~/.cache/airmcp/memory.json. Same shape asaudit: storage failures areinternal_error, input validation already runs through zod +errInvalidInput. PR #154 already hardened the write path (atomic temp+rename + serialized op queue + JSON-reviver prototype-pollution guard); the catch-block category isn't where the action is.podcasts/tools.ts— module is fully broken on macOS 26+ (Apple removed the Podcasts JXA scripting dictionary, see RFC 0004 /compatibility.brokenOn: [26]block). Migrating the dead-on-arrival catches isn't worth the noise; the deprecation is already advertised throughairmcp doctorandprint-compat-report.
toolError itself stays exported for these three modules and as the safety net for any future tool that hasn't yet picked up a typed origin.
Added — for users
- RFC 0008 Phase 1 — Elicitation capability gate + env opt-out (PR #196) — on review the elicitation path was already wired in
installHitlGuard(tryElicitApprovalcallsinner.elicitInputwhen the inner Server exposes it). RFC 0008 §3.2 + §3.3 named two gaps that hadn't landed yet: (1)AIRMCP_ELICITATION_DISABLE=trueenv opt-out for end-to-end scripted destructive pipelines that don't want any user prompt — falls through to the socket HITL channel exactly like a client that doesn't advertise elicitation; (2) explicitgetClientCapabilities()check before issuing the elicit request, avoiding a doomed call when the client declared no elicitation support. The existing try/catch stays as belt-and-suspenders for clients that lie about their capabilities. RFC 0008 status: Draft → Phase 1 Implemented. - RFC 0009 Phase 1 first batch — 3 Numbers depth tools (PR #197) —
numbers_list_tablesreturns{ name, rowCount, columnCount }per table; multi-table sheets are common (totals + breakdown + chart-source) but existing tools always readtables[0], so this lets a caller pick by name.numbers_get_formulareturns the literal expression behind a cell (=SUM(A1:A10)) instead of the evaluated value; returnsnullwhen the cell holds a constant — pairs with the existingnumbers_get_cell.numbers_rename_sheetrenames in place; Numbers does NOT allow duplicate sheet names so the JXA call throws and surfaces aserrJxa. Same-pass cleanup: migrated existing 9 numbers catches fromtoolErrortoerrJxaFor(RFC 0001 §3.1 cleanup that was missed in the earlier wave). Brings tool surface 269 → 272 across all auto-synced artifacts (manifest / llms / README / 232 AppIntents). 14 more Numbers tools queued in RFC 0009 §4.1; followups can lift this PR's pattern verbatim. npx airmcp doctor --deep(PR #198, polish in PR #200) — defaultdoctorstays fast.--deepopts into slower live probes for user-reported troubleshooting: HMAC chain verification across all on-disk audit JSONL files (surfaces single-line tampering with the exact file + line number viasummarizeAuditEntries.verifiedFirstBreak); Swift bridge live ping (distinguishes "not built" from "built but unresponsive"); module registry boot smoke (actually imports everytools.ts+prompts.tsto surface typos / missing transitive deps that ride the eager-import path at startup). Footer of defaultdoctorprints the hint pointing at--deep. PR #200 widened the audit-chain bad message with actionable follow-up: "Inspect the surrounding lines, then callaudit_summaryto see the full break window."initwalkthrough closes with example prompts (i18n across 9 locales) (PR #199, polish in PR #200) —npx airmcp initfinishes with a "Try asking your AI:" block of three representative prompts so the user has a concrete path from "setup complete" to first interaction (doctor --deephint added in passing). Three new i18n keys (prompt_calendar_today,prompt_summarize_notes,prompt_overdue_reminders) translated across all 9 locales the wizard already supports — PR #200 caught the original implementation hardcoded the strings in English even when the wizard ran in another locale.permission_deniedauto-hint pointing at System Settings (PR #199, polish in PR #200) —errPermissionnow adds a default hint pointing at System Settings → Privacy & Security when the caller didn't supply one. macOS-only — non-darwin (CI runners, etc.) fall through to no hint. Caller-supplied hints win verbatim. Closes the recurring "permission denied — but where?" UX gap. PR #200 hoisted the platform check to a module-levelDEFAULT_PERMISSION_HINTconst (resolved once at load instead of every error).- outputSchema Wave 5 — 4 photos read tools (
list_photos,search_photos,get_photo_info,list_favorites) — extends Wave 4's pattern to the photos module.list_photos/search_photos/list_favoritesroute throughokUntrustedLinkedStructured(photo metadata is user content);get_photo_infousesokUntrustedStructured.list_albumsdeferred — bareAlbumItem[]return shape, same array-vs-object breaking-change risk ascompare_notes. Weather forecast tools (get_daily_forecast/get_hourly_forecast) also deferred for the same reason. 7 new drift guards intests/output-schema-wave5.test.jscovering full / null-EXIF / empty-list shapes;tests/output-schema-structured.test.jsexhaustive coverage check picks up 4 fixtures so any future tool that adds outputSchema without a fixture breaks the build. - iOS Bearer token Keychain persistence (
ios/Sources/AirMCPServer/KeychainTokenStore.swift) — closes the (C) "Apple-native deeper, two devices" promise's pairing gap. PreviouslyMCPHTTPServer.init(token: nil)generated a fresh random token on every process start, so any client paired with the previous boot's token (Windows Claude Desktop, a Mac MCP client over the same Wi-Fi, etc.) silently broke. The newMCPHTTPServer.make(...)async factory routes through aKeychainTokenStoreactor which reads the persisted token (or generates + persists a fresh one on first boot). Stored askSecClassGenericPasswordwithkSecAttrAccessibleAfterFirstUnlockThisDeviceOnly— survives reboot but does NOT iCloud-sync (matches RFC 0002's loopback-by-default network policy: pairing is per-device on purpose).kSecAttrServicenamespaces by build flavour (com.airmcp.ios.token, override viaAIRMCP_KEYCHAIN_SERVICEenv for fork installs that share a device). Failure modes (Keychain unavailable in unentitled CLI runs, simulator quirks) fall back to a per-process random token with a stderr warning so the server still functions; that token doesn't persist and the operator sees the regression in logs. Newclear()API for "rotate token" / "unpair all clients" UI flows. The synchronousMCPHTTPServer.init(token: String)is retained as a non-nullable explicit-pairing entry point for tests + flows where the caller already has a token in hand.App.swiftupdated to callMCPHTTPServer.make(...). The legacyprivate static func generateToken()onMCPHTTPServerremoved — token generation lives on the persistence layer that owns the bytes.
Added — for operators
- Per-tenant rate-limit buckets keyed on OAuth subject (PR #159) — multi-tenant deployments behind OAuth get isolated rate-limit budgets per
subclaim. Stdio / single-tenant flows fall through to the shared global bucket. Closes the noisy-neighbor gap on shared HTTP transports. /.well-known/oauth-protected-resourcealigned with SEP-985 / RFC 9728 (PR #193) — MCP SEP-985 (active 2026-05) standardizes the OAuth protected resource metadata document beyond the MCP 2025-11-25 spec minimum. Three additions, all backwards-compatible: (1) DPoP advertisement (honest, not enforced) —dpop_signing_alg_values_supported: ["ES256", "RS256"]+dpop_bound_access_tokens_required: false. AirMCP doesn't bind tokens to a DPoP proof yet; declaring the flag honestly lets a future token-binding SEP flip it without renegotiating discovery. Symmetric (HS*) andnonestay excluded — same key-confusion defense as theresource_signing_alglist. (2) RFC 9728 §2 optional fields via env:AIRMCP_OAUTH_RESOURCE_DOCS→resource_documentation,AIRMCP_OAUTH_RESOURCE_POLICY→resource_policy_uri,AIRMCP_OAUTH_RESOURCE_TOS→resource_tos_uri. Fields are omitted when unset so crawlers don't render dead links. (3) +4 test cases covering full SEP-985 baseline shape, DPoP honesty, env knobs surface when set, optional fields omitted when unset.docs/environment.md— full env knob index (77 vars) (PR #199) — Quickstart table for the five most-asked operator setups (token auth, OAuth, debug a flaky module, expand description budget, audit cross-host integrity). Categorized tables (Network/Auth, Rate limit, Audit, HITL, Module control, Embedding/AI, Telemetry, Timeouts/Buffers, Triggers, Tooling, Internal/test) with default + use case for each var. Source pointers at the bottom for deeper reading. README's Safety bullet now links to the page so operators can find the index without grepping the source.- OAuth 2.1 browser PKCE setup guide (
docs/oauth-browser-pkce.md) — RFC 0005 Step 3. Full walkthrough for browser-resident MCP clients (Claude in Chrome, Managed Agents, custom extensions) negotiating the Authorization Code + PKCE flow against AirMCP'swith-oauth*endpoints. Covers: 9-step happy-path sequence diagram with every HTTP hop spelled out including the RFC 8707resourceparameter on both authorize + token requests; server env var checklist (AIRMCP_OAUTH_ISSUER,AIRMCP_OAUTH_AUDIENCE,AIRMCP_ALLOWED_ORIGINS); Claude in Chrome wiring (Anthropic's public redirect URI + AS client configuration); custom-client integration notes with vetted library picks (@openid/appauth,golang.org/x/oauth2,authlib) + a reviewer-friendly client-side hardening checklist (cryptorandom verifier,S256challenge method, state param CSRF guard, token storage rules); scope → tool mapping table cross-referencingsrc/shared/oauth-scope.ts;npm run dev:oauthfast-loop recipe; 8-row troubleshooting table covering thewrong_audience/wrong_issuer/unsupported_alg/jwks_unreachable/ scope-gate forbidden / CORS-preflight-403 / startup-refused /invalid_grantcases with specific remediation. README Features row now cross-links the new guide; RFC 0005 status entry points at the doc instead of "in progress".
Added — for developers
- Correlation-id threading across request-context, tool-registry, audit (PR #190) — async tool calls were untraceable across log lines: an audit entry, a telemetry trace, and a thrown error from the same call had no shared identifier so debugging required reconstructing wall-clock timing.
RequestContextgains an optionalcorrelationIdfield; the tool-registry wrapper opens arunWithRequestContextscope on first entry and stamps arandomUUID()correlation ID. If the call already arrived inside a context with a correlationId set (e.g. an HTTP middleware seeded one for distributed tracing), the existing ID is honored.auditLogauto-attaches the active correlationId to every entry; explicit caller-supplied IDs win. Audit log entries already in production keep working —correlationIdis optional in the JSON shape. HTTP transport middleware can seed an inboundRequest-Idheader into the context for distributed tracing systems. +5 test cases covering absence / verbatim / await-boundary persistence / concurrent isolation (Promise.all branches see only their own ID) / OAuth-claim coexistence. - Correlation-id
Trace: <id>line in error envelope (PR #198) —toolErr()now auto-attaches the active context's correlation id tostructuredContent.error.correlationId(machine-readable) and a trailingTrace: <id>line in the human-readable text. PR #190 already added the id to every audit row; this closes the loop on the user side so a failed tool call carries the id needed forgrep <id> ~/.airmcp/audit.jsonl.ToolErrorOptionsgains an explicitcorrelationIdoverride for tests. npm run tokens— measure compactDescription savings (PR #165) — total bytes / token estimate before vs after compaction. Made the v2.12 RFC 0010 stub possible by quantifying the description budget at ~3.8K tokens after compaction (~50% reduction over the raw 7.5K).npm run llms:check— drift guard forllms.txt(scripts/gen-llms-txt.mjs) — regenerates llms.txt in-memory and diffs against the checked-in copy; CI now runs this so any tool/prompt/module addition withoutnpm run llmsfails the build instead of silently shipping a stale catalog (the long-standing "258 tools / 30 modules" drift bug that survived multiple releases).llms-full.txtis.gitignored and intentionally skipped from the check (oversize for review diffs); only the public-facing summary is pinned. New CI step in.github/workflows/ci.ymlbetweencount-stats --checkandsync-version --check.docs/ROADMAP.md— public 4-week + quarterly priorities — externally-readable roadmap separated from the gitignored internalTODO.mdscratch file. P0 / P1 / P2 / P3 buckets, each item linked to the relevant src file or RFC.docs/REGISTRY_SUBMISSIONS.mdextended to 19 directories — added cursor.directory, MCP.so, mcphub.io, Modelo MCP Hub, mcpservers.org, awesome-mcp-servers (GitHub), LobeHub MCP, MCP Index, Composio MCP, HyperMCP, MCP Discover (12 new entries on top of the existing 7). Each row carries submission URL, audience pitch, and the v2.11 differentiator angle. First-wave priority list pinned at the top.- CHANGELOG reader guide (PR #199) — one-block "For users / For developers / For operators" guide at the top of
[Unreleased]so a release reader can skim the audience that applies to them. The full structure (### Added — for users / operators / developersheaders) lands in this v2.12 release cut.
Fixed
list_calendarsreturns empty after fresh permission grant (swift/Sources/AirMCPKit/EventKitService.swift) — closes #145, reported by @jagaliano on macOS 15.7.5 (PR #201). Reproduces via the Swift bridge:list_calendarsreturns an empty array even though Calendar.app shows the user's calendars and the Calendar permission has been granted. Root cause: a freshly-grantedEKEventStorekeeps the snapshot it had from before authorization.calendars(for: .event)reads that stale snapshot rather than the now-readable backing data, so it surfaces an empty list. Fix: in the sharedauthorize()helper (used by both events and reminders paths inEventKitService), callstore.reset()between the granted check and flagging the store as authorized. Idempotent across subsequent calls because the flag short-circuits —reset()only fires the first time through after a grant. The reporter proposed the same shape via apostGrantcallback per request; consolidating into the helper covers both events + reminders in one place. JXA path is unaffected — Calendar.app scripting reads through Calendar's own data store, not EventKit. Thanks to @jagaliano for the high-quality bug report — clear repro, isolated root cause, verified patch shape.AskAirMCPIntentrejects empty / whitespace-only prompts (swift/Sources/AirMCPKit/AskAirMCPIntent.swift) — Siri "Ask AirMCP" with no follow-up text used to hitLanguageModelSession.respond(to: "")→ opaque framework error → generic Shortcuts failure. Now trim and return a recoverable user-facing message ("Please ask a question — I need something to look up.").gen-llms-txt.mjscount drift fully closed (scripts/gen-llms-txt.mjs) — was reporting265 tools / 32 moduleswhilecount-stats.mjs(canonical) said269 / 29. Two roots: strictextractToolsregex missed 4 tools whose registration spans the regex boundary;walkDirovercounted cross / semantic / skills / server / shared as separate modules. Fix: broad-regex pass for totals (matchescount-stats), canonical module count parsed fromMODULE_NAMES. Per-module list keeps strict regex for rich rendering. Headline now269 / 29everywhere (and272 / 29after PR #197's RFC 0009 batch).safariandpodcastsmodules carrycompatibility.deprecationfor macOS 26 (src/shared/modules.ts) — RFC 0004 G-5. Apple removed Safarimake new bookmarkJXA verb (just the one tool, replaced byadd_to_reading_list) and the entire Podcasts JXA dictionary in macOS 26. Both now declarecompatibility.brokenOn: [26]+deprecationblocks (since,removeAt,replacement,reason) so RFC 0004'sprint-compat-reportandairmcp doctorsurface the regression with the exact replacement (Safari) or upstream-removal note (Podcasts).- Event listener leak on HTTP session cleanup (
src/server/mcp-setup.ts:505) —cleanupEventListenersonly removed 3 of the 9 listeners thatevent_subscriberegisters. v2.10 added 6 new event types (mail_unread / focus_mode / now_playing / file_modified / screen_locked / screen_unlocked) but the cleanup wasn't extended in lockstep. HTTP servers that idle-timed-out sessions accumulated 6 listeners per session and eventually hit Node'sMaxListenersExceededWarning; the unremoved closures also kept references to staleMcpServerinstances alive, causing silent failures when later.sendResourceListChanged()calls fired against closed transports. All 9 listeners now mirror the registration list. - Banner displayed wrong skill count (
src/server/mcp-setup.ts:494) — hardcodedskillsBuiltin: 7while the actual count had grown to 14 YAML files indist/skills/builtins/.registerSkillEnginenow returns{ builtinCount, userCount }andbannerInforeads from that, so the count auto-updates as built-ins are added or pruned. - Duplicate
AppShortcutsProviderin app target (app/Sources/AirMCPApp/AppIntents.swift:133) —AirMCPShortcuts(hand-written, 7 entries, predates RFC 0007 codegen) andAirMCPGeneratedShortcuts(auto-generated, 10 entries, inswift/Sources/AirMCPKit/Generated/MCPIntents.swift:6850) both conformed toAppShortcutsProviderin the same app bundle. Apple constrains an app target to a single conformer; having both produced ambiguity at build time and a Siri suggestion tie that Apple resolves arbitrarily. The hand-written provider is removed; the unique intent types it referenced (DailyBriefingIntent,HealthSummaryIntent) remain defined and stay invocable via the Shortcuts app, Spotlight, and Action Button — they just aren't pinned as Siri-first phrases. A future codegenAPP_SHORTCUTS_TOPentry can re-pin them once the correspondingdaily_briefing/health_summarytools graduate to first-party manifest entries.swift buildconfirms the conflict is gone (app target compiles clean). - Tool count drift in user-facing docs —
llms.txtsaid258 tools across 30 modules(regenerated to current vianpm run llms);docs/shortcuts.mdsaid154 read-only tools/144 intents(now correctly 229 tools / 219 non-pinned intents reflecting RFC 0007 Phase A's full write-capable surface). The deeper discrepancy betweencount-stats.mjs(29 modules / 269 tools — canonical fromMODULE_NAMES) andgen-llms-txt.mjs(32 modules / 265 tools — walkssrc/for any dir withregisterTool) was tracked for follow-up and fully closed in PR #157.
Reliability
MemoryStoreatomic write + serialized op queue + prototype-pollution guard (src/memory/store.ts) — three audit-flagged risks closed in one pass. (1)save()now stages JSON in a sibling tempfile (<path>.<random>.tmp) andrename()s it over the canonical path, so a SIGKILL / power loss mid-write leaves either the old or new content — never a half-flushed JSON file (which would have silently restored to an empty store on nextload()and lost every fact / entity / episode). (2)put()/forget()/stats()route through a new privateenqueue()op queue so concurrent invocations (an agent loop dispatchingmemory_putin parallel with amemory_querythat triggers a sweep, or two skill steps writing different keys) chain instead of trampling the on-disk file's last writer. The chain swallows queue-level errors so a single failure doesn't poison the next op. (3)JSON.parsenow uses a reviver that drops__proto__/constructor/prototypekeys so a hand-edited or attacker-supplied store file can't pollute Object.prototype when loaded back.- AppIntent handler injection race window shrunk (
app/Sources/AirMCPApp/AppIntents.swift:162) —installMCPIntentRouterForMacOS()now usesTask { @MainActor in … }at default priority instead ofTask.detached(priority: .utility). The actor hop intoMCPIntentRouter.shared.setHandleris unavoidable (router is an actor), but raising the priority + dropping.detachedshrinks the cold-launch race window from "seconds" (utility queue can sit behind other low-priority work) to "milliseconds before the first runloop tick" — far before any Siri / Shortcuts cold-launch first-invocation can reach the router. The existingMCPIntentError.handlerNotInstallederror path is preserved as a safety net with the requested tool name embedded. bulk_move_notes— dryRun + stopOnError + meta visibility (src/notes/scripts.ts:377,src/notes/tools.ts:522) —dryRun: true(default false) returns the list of notes that would move, the original folder, and an explicitmetaPreservationblock stating which fields would be lost (creationDate / modificationDate / attachments — Notes JXA cannot set those on the new note copy).stopOnError: true(default true) halts on the first failure so the source/target stays at a recoverable mid-state instead of running through 50 more failures and producing 50 more orphaned partial moves; passfalsefor best-effort partial completion. The script also detects same-folder no-ops (originalFolder === targetFolder) and reports them asunchanged: trueinstead of doing the body-copy → delete dance and silently nuking the metadata. Per-note successful-move results carry the originalmetaLost: { creationDate, modificationDate }so the caller has a record of what was discarded.
Security
audit_logHMAC chain — single-line tampering detection (src/shared/audit.ts) — every flushed audit line now carries_prev+_hmac(HMAC-SHA256). The chain spans process restarts: on first flush after boot the module reads the disk tail, picks up the latest_hmac, and continues — no per-process forks.summarizeAuditEntrieswalks every chained line and reportsverified: boolean+verifiedFirstBreak: { file, lineIndex, reason }so a single deletion / mutation surfaces with the exact location. Legacy un-chained lines (written before this version) are tolerated and skipped — but inserting a legacy-shaped line in the middle still breaks_prevon the next chained line, so verification cannot be laundered. Key source:AIRMCP_AUDIT_HMAC_KEYenv var (preferred — operator-provided, enables cross-machine integrity check) or a host-derived fallback (airmcp-audit::<hostname>::<platform>— tamper-detection grade only).audit_summarytool gainsverified,verifiedFirstBreak, andauditDisabledfields.auditDisabledno longer permanent (src/shared/audit.ts) — previously one transient disk-full incident latched audit logging off until the process restarted. Now afterMAX_FLUSH_FAILURESconsecutive failures the module enters a 5-minute backoff, then the nextauditLog()call triggersmaybeAttemptRecovery()which clears the disabled flag and retries flushing. The auditDisabled state is also surfaced throughsummarizeAuditEntriesso a doctor / health check can flag the situation in real time.SENSITIVE_TOOL_PATTERNSbroadened (PR #192) —oauth_*/password/credential/tokensubstring patterns added. Catches RFC 0005 OAuth tools (oauth_authorize,oauth_refresh, …) plus any future credential-bearing tool name without per-tool opt-in. Defense-in-depth on top of the args-side sanitizer for tools that pack credentials into nested args the per-key sanitizer can miss.resumeChainHeadmalformed-line counter (PR #192) — was silently skipping garbage lines while scanning the disk tail for HMAC chain resumption. Now logs a one-shot stderr warning with the malformed count + recovery point so an operator notices tampering or corruption before it gets buried under fresh entries. Behavior unchanged; the message is the only delta. Six new test cases close the audit / correlationId / sanitize-redaction gap (explicitcorrelationIdpreserved verbatim, omittedcorrelationIdstays undefined outside an active context,oauth_authorizeargs replaced with_redactedmarker, tool names containingcredentialortokenredacted, non-sensitive tool flows throughsanitizeArgsunchanged).ai_agentwrite-tool bypass closed (swift/Sources/AirMCPKit/FoundationModelsBridge.swift:134-152) — verification of a multi-session audit finding (P0-2) confirmed the concern:FoundationModelsBridge.run()registered 5 tools (TodayEventsTool,DueRemindersTool,SearchContactsTool,CreateReminderTool,CreateNoteTool) on theLanguageModelSession, and the model autonomously called them in a tool-calling loop. The two write tools invokedEventKitService.createReminder()directly in Swift, bypassing the Node-sidetoolRegistrypre-handler — meaning HITL approval, rate-limit, and audit-log enforcement were all skipped on the agentic path.ai_agent'sdestructiveHint: falsewas technically truthful but the absence of read-only-only signaling made the bypass surface invisible to clients. Fix:allTools()now exposes only the 3 read-only tools; theCreate*Toolclasses remain defined as future-use API but are no longer registered with the session.ai_agentdescription rewritten to spell out the read-only constraint and explain the bypass concern;readOnlyHint: truecorrected. Restoring write capability to the agent surface requires a Swift→Node loop-back transport so calls re-enter the toolRegistry — designed in TODO/ROADMAP for v2.12+. Earlier the #150 PR body had marked this finding as "MISCLASSIFIED" based on a partial main.swift read; the audit was correct, this PR is the proper retraction + fix.
Changed
- RFC 0001 Wave 2+ — error shape migration across 13 modules (PR #144) — every
return err(...)site in tool-handler code now routes through a typed error helper (errPermission/errInvalidInput/errNotFound/errUpstream/errSwift/errDeprecated) so clients can branch onstructuredContent.error.categoryinstead of string-matching the text content. Converts 59 sites across: mail (2×errPermissionfor send-disabled), messages (2×errPermission), google (15×errUpstreamfor upstream Gmail/Drive/Sheets/Calendar/Docs/Tasks/People failures + 3×errPermissionfor send/destructive gates + 2×errInvalidInputfor service-name / sanitization validation), health (5×errSwiftfor bridge-required), intelligence (2×errSwift), speech (3×errSwift), mcp-setup (2×errSwift+ 1×errNotFoundfor unknown-prompt + 1×errUpstreamfor workflow-fetch-failed), semantic (3×errSwift+ 1×errNotFound+ 1×errUpstream), cross (1×errInvalidInput+ 2×errUpstream+ 1×toolErrfor build-snapshot failure), skills register (2×errUpstreamfor skill step/execution failure), hitl-guard (2×errPermissionfor denial paths), ui (4×errInvalidInputfor click/query criterion validation), memory (1×errInvalidInputfor exactly-one-of), safari (6×errInvalidInputfor URL validation + 1×errPermissionfor JavaScript-disabled + 1×errDeprecatedfor macOS 26add_bookmarkstub). Wire format per RFC 0001: text content becomes[<category>] <message>(was bare message);structuredContent.error = { category, message, retryable }now populated. Existing clients that readisError: trueor substring-match the message keep working; clients that want structured recovery branches gain a stable category contract. No behavioral change — same conditions trigger the same errors; only the wire shape gains structure.tests/skills-register.test.jsupdated to asserterrUpstreaminstead oferron the three skill-failure paths (mock list also extended for the new export so the ESM binding doesn't fail on load). - Docs sync for v2.11.0 — public-facing docs catch up to the shipped v2.11 surface.
README.mdFeatures block bumps tool count 270+ → 269, pins 229 AppIntents line, adds OAuth 2.1 + Resource Indicators row, adds.well-knownsessionless discovery row, notes notarized menubar app; "Why AirMCP?" comparison table, security section, and architecture section all align on 269 tools / 29 modules (was a mix of 262 / 270+ / 262 across five locations); "Future" section drops OAuth 2.1 + GUI .app distribution (both shipped) in favour of Step 3 PKCE guide, stateless streamable HTTP, iOS/visionOS exploration.docs/index.htmlJSON-LDsoftwareVersioncorrected2.7.3→2.11.0(3 majors stale, SEO-visible), meta/Twitter descriptions + Schema.org description + glass-stats card +why_1_titleall bumped from 27 modules / 262 tools to 29 / 269.docs/REGISTRY_SUBMISSIONS.mdstatus table refreshed to v2.11 baseline with.mcpb+ OAuth as new differentiators; new row for Claude Desktop Extensions directory submission opportunity. RFC status transitions: 0001 Draft → Accepted (Waves 0+1 shipped, Wave 2+ in progress), 0004 Draft → Accepted (runtime activation shipped in v2.10), 0005 Draft → Accepted (Steps 1+2 shipped in v2.11, Step 3 in progress), 0007 Draft → Phase A Accepted (PRs #101-#137 closed Phase A).CONTRIBUTING.mdadds drift-guard commands (stats:check,gen:manifest:check,gen:intents:check) and a new OAuth local-development section pointing atnpm run dev:oauth. No code change. - Docs sync for v2.12.0 (this release) —
README.md(5 sites: hero, comparison table, Safety bullet, well-known crawler example, Architecture scope bullet) anddocs/index.html(5 sites: meta description, Twitter description, JSON-LD description, glass-stat,why_1_title) bumped 269 → 272 to reflect PR #197's RFC 0009 first batch.docs/index.htmlsoftwareVersioncorrected2.11.0→2.12.0. Internal-mention drift indocs/environment.md,docs/skills.md,docs/REGISTRY_SUBMISSIONS.md,docs/ROADMAP.md,docs/rfc/0010-progressive-disclosure.mddeferred to a v2.12.1 docs cleanup PR — those each carry context-specific number references that need individual review (e.g. ROADMAP "2026-04-30 기준" snapshot, REGISTRY "v2.10 baseline"). - README — Safety & Operations bullet now mentions correlation-id + HMAC chain (PR #194) — PR #190 added per-call correlation IDs to every audit entry, and PR #152 added HMAC chain integrity. The README's bullet hadn't caught up. One-sentence expansion so users + integrators see the feature without reading the CHANGELOG.
Refactored
- Simplify pass over PRs #198 / #199 (PR #200) — three findings: (1)
result.tserrPermissioncollapsed dead-code hint composition (opts?.hint ?? ''could only ever produce''); hoisted platform check to module-levelDEFAULT_PERMISSION_HINTconst (resolved once at load instead of every error). (2)doctor.tsdeepFlagvariable — pulledprocess.argv.includes('--deep')into a single local const reused for both the deep-checks block and the trailing footer hint; widened the audit-chain bad message to include actionable follow-up. (3)init.tsexample prompts — i18n via the existingt()helper (4 new keys translated across 9 locales). Three skipped findings documented as false positives.
RFC drafts (no implementation in this release)
- RFC 0008 — MCP Elicitation for destructive tools (PR #191; Phase 1 implemented in PR #196) —
@modelcontextprotocol/sdk1.29.0 already exposesserver.elicitInput(). Phase 1: confirmation-only elicit wrapper fordestructiveHint=truetools, capability-gated (only when client advertiseselicitation), env-opt-out (AIRMCP_ELICITATION_DISABLE=true), threadscorrelationId(PR #190) for the audit trail. Phase 2 deferred: form-mode parameter capture, URL-mode consent flows, capability-driven "this tool will prompt" hint intools/list. Backwards-compatibility matrix: Elicitation client → new prompt; App Intents → existingrequestConfirmation(RFC 0007 §A.3); Plain HTTP/stdio without elicit → today's behavior (rate-limit + scope gate). - RFC 0009 — iWork (Pages / Numbers / Keynote) coverage depth (PR #191; Phase 1 first batch in PR #197) — pre-implementation gap audit vs
iwork_mcp(113-tool reference). Phase 1 (~40 new tools): Numbers cell/range/formula CRUD, Pages section/style ops, Keynote master/layout/transitions. Phase 2 deferred: NL→formula codegen via Foundation Models, master propagation, chart manipulation. Risks captured: JXA dictionary inconsistency, large-spreadsheet performance, context-budget bloat (re-runnpm run tokensafter Phase 1). - RFC 0010 — Progressive tool disclosure (SEP-1888 alignment) (PR #195) — AirMCP advertises 269 tools eagerly;
npm run tokens(PR #165) measured the description budget at ~3.8K tokens aftercompactDescription— already a 50% reduction over the raw 7.5K, but a meaningful chunk of every model context still goes to "things you might call." SEP-1888 (active May 2026) replaces "register N narrow tools" with library tool +searchToolsprogressive disclosure; the Anthropic Skills-over-MCP charter calls out the same direction. Stub-only because: the SEP shape isn't ratified yet — premature migration would rename the public surface twice. AirMCP's existingdiscover_toolsalready handles the search backend; the gap is the discoverability contract, not the implementation. - RFC 0011 — Post-WWDC 2026 positioning placeholder (PR #195) — WWDC 2026 keynote June 8–12 has credible leaks for: Siri 2.0 with third-party AI extensions, possible Apple-blessed system MCP API, "Snow Leopard year" refinement focus. Any individually has a measurable AirMCP impact; together they could redraw the competitive map. Placeholder is intentionally light — pre-keynote prohibition on pivots, just a guaranteed landing spot for the post-WWDC sprint with action items for the day-of and following 48h.