Skip to content

v2.12.0

Choose a tag to compare

@github-actions github-actions released this 10 May 08:05
559cdec

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 doctor output.
  • 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 surfaces invalid_input automatically for the since ISO-8601 parameter. No category gain from migrating.
  • memory/tools.ts — fs + JSON parse of ~/.cache/airmcp/memory.json. Same shape as audit: storage failures are internal_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 through airmcp doctor and print-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 (tryElicitApproval calls inner.elicitInput when the inner Server exposes it). RFC 0008 §3.2 + §3.3 named two gaps that hadn't landed yet: (1) AIRMCP_ELICITATION_DISABLE=true env 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) explicit getClientCapabilities() 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_tables returns { name, rowCount, columnCount } per table; multi-table sheets are common (totals + breakdown + chart-source) but existing tools always read tables[0], so this lets a caller pick by name. numbers_get_formula returns the literal expression behind a cell (=SUM(A1:A10)) instead of the evaluated value; returns null when the cell holds a constant — pairs with the existing numbers_get_cell. numbers_rename_sheet renames in place; Numbers does NOT allow duplicate sheet names so the JXA call throws and surfaces as errJxa. Same-pass cleanup: migrated existing 9 numbers catches from toolError to errJxaFor (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) — default doctor stays fast. --deep opts 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 via summarizeAuditEntries.verifiedFirstBreak); Swift bridge live ping (distinguishes "not built" from "built but unresponsive"); module registry boot smoke (actually imports every tools.ts + prompts.ts to surface typos / missing transitive deps that ride the eager-import path at startup). Footer of default doctor prints the hint pointing at --deep. PR #200 widened the audit-chain bad message with actionable follow-up: "Inspect the surrounding lines, then call audit_summary to see the full break window."
  • init walkthrough closes with example prompts (i18n across 9 locales) (PR #199, polish in PR #200) — npx airmcp init finishes 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 --deep hint 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_denied auto-hint pointing at System Settings (PR #199, polish in PR #200) — errPermission now 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-level DEFAULT_PERMISSION_HINT const (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_favorites route through okUntrustedLinkedStructured (photo metadata is user content); get_photo_info uses okUntrustedStructured. list_albums deferred — bare AlbumItem[] return shape, same array-vs-object breaking-change risk as compare_notes. Weather forecast tools (get_daily_forecast / get_hourly_forecast) also deferred for the same reason. 7 new drift guards in tests/output-schema-wave5.test.js covering full / null-EXIF / empty-list shapes; tests/output-schema-structured.test.js exhaustive 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. Previously MCPHTTPServer.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 new MCPHTTPServer.make(...) async factory routes through a KeychainTokenStore actor which reads the persisted token (or generates + persists a fresh one on first boot). Stored as kSecClassGenericPassword with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — survives reboot but does NOT iCloud-sync (matches RFC 0002's loopback-by-default network policy: pairing is per-device on purpose). kSecAttrService namespaces by build flavour (com.airmcp.ios.token, override via AIRMCP_KEYCHAIN_SERVICE env 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. New clear() API for "rotate token" / "unpair all clients" UI flows. The synchronous MCPHTTPServer.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.swift updated to call MCPHTTPServer.make(...). The legacy private static func generateToken() on MCPHTTPServer removed — 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 sub claim. Stdio / single-tenant flows fall through to the shared global bucket. Closes the noisy-neighbor gap on shared HTTP transports.
  • /.well-known/oauth-protected-resource aligned 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*) and none stay excluded — same key-confusion defense as the resource_signing_alg list. (2) RFC 9728 §2 optional fields via env: AIRMCP_OAUTH_RESOURCE_DOCSresource_documentation, AIRMCP_OAUTH_RESOURCE_POLICYresource_policy_uri, AIRMCP_OAUTH_RESOURCE_TOSresource_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's with-oauth* endpoints. Covers: 9-step happy-path sequence diagram with every HTTP hop spelled out including the RFC 8707 resource parameter 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, S256 challenge method, state param CSRF guard, token storage rules); scope → tool mapping table cross-referencing src/shared/oauth-scope.ts; npm run dev:oauth fast-loop recipe; 8-row troubleshooting table covering the wrong_audience / wrong_issuer / unsupported_alg / jwks_unreachable / scope-gate forbidden / CORS-preflight-403 / startup-refused / invalid_grant cases 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. RequestContext gains an optional correlationId field; the tool-registry wrapper opens a runWithRequestContext scope on first entry and stamps a randomUUID() 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. auditLog auto-attaches the active correlationId to every entry; explicit caller-supplied IDs win. Audit log entries already in production keep working — correlationId is optional in the JSON shape. HTTP transport middleware can seed an inbound Request-Id header 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 to structuredContent.error.correlationId (machine-readable) and a trailing Trace: <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 for grep <id> ~/.airmcp/audit.jsonl. ToolErrorOptions gains an explicit correlationId override 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 for llms.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 without npm run llms fails the build instead of silently shipping a stale catalog (the long-standing "258 tools / 30 modules" drift bug that survived multiple releases). llms-full.txt is .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.yml between count-stats --check and sync-version --check.
  • docs/ROADMAP.md — public 4-week + quarterly priorities — externally-readable roadmap separated from the gitignored internal TODO.md scratch file. P0 / P1 / P2 / P3 buckets, each item linked to the relevant src file or RFC.
  • docs/REGISTRY_SUBMISSIONS.md extended 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 / developers headers) lands in this v2.12 release cut.

Fixed

  • list_calendars returns 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_calendars returns an empty array even though Calendar.app shows the user's calendars and the Calendar permission has been granted. Root cause: a freshly-granted EKEventStore keeps 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 shared authorize() helper (used by both events and reminders paths in EventKitService), call store.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 a postGrant callback 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.
  • AskAirMCPIntent rejects empty / whitespace-only prompts (swift/Sources/AirMCPKit/AskAirMCPIntent.swift) — Siri "Ask AirMCP" with no follow-up text used to hit LanguageModelSession.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.mjs count drift fully closed (scripts/gen-llms-txt.mjs) — was reporting 265 tools / 32 modules while count-stats.mjs (canonical) said 269 / 29. Two roots: strict extractTools regex missed 4 tools whose registration spans the regex boundary; walkDir overcounted cross / semantic / skills / server / shared as separate modules. Fix: broad-regex pass for totals (matches count-stats), canonical module count parsed from MODULE_NAMES. Per-module list keeps strict regex for rich rendering. Headline now 269 / 29 everywhere (and 272 / 29 after PR #197's RFC 0009 batch).
  • safari and podcasts modules carry compatibility.deprecation for macOS 26 (src/shared/modules.ts) — RFC 0004 G-5. Apple removed Safari make new bookmark JXA verb (just the one tool, replaced by add_to_reading_list) and the entire Podcasts JXA dictionary in macOS 26. Both now declare compatibility.brokenOn: [26] + deprecation blocks (since, removeAt, replacement, reason) so RFC 0004's print-compat-report and airmcp doctor surface 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) — cleanupEventListeners only removed 3 of the 9 listeners that event_subscribe registers. 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's MaxListenersExceededWarning; the unremoved closures also kept references to stale McpServer instances 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) — hardcoded skillsBuiltin: 7 while the actual count had grown to 14 YAML files in dist/skills/builtins/. registerSkillEngine now returns { builtinCount, userCount } and bannerInfo reads from that, so the count auto-updates as built-ins are added or pruned.
  • Duplicate AppShortcutsProvider in app target (app/Sources/AirMCPApp/AppIntents.swift:133) — AirMCPShortcuts (hand-written, 7 entries, predates RFC 0007 codegen) and AirMCPGeneratedShortcuts (auto-generated, 10 entries, in swift/Sources/AirMCPKit/Generated/MCPIntents.swift:6850) both conformed to AppShortcutsProvider in 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 codegen APP_SHORTCUTS_TOP entry can re-pin them once the corresponding daily_briefing / health_summary tools graduate to first-party manifest entries. swift build confirms the conflict is gone (app target compiles clean).
  • Tool count drift in user-facing docsllms.txt said 258 tools across 30 modules (regenerated to current via npm run llms); docs/shortcuts.md said 154 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 between count-stats.mjs (29 modules / 269 tools — canonical from MODULE_NAMES) and gen-llms-txt.mjs (32 modules / 265 tools — walks src/ for any dir with registerTool) was tracked for follow-up and fully closed in PR #157.

Reliability

  • MemoryStore atomic 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) and rename()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 next load() and lost every fact / entity / episode). (2) put() / forget() / stats() route through a new private enqueue() op queue so concurrent invocations (an agent loop dispatching memory_put in parallel with a memory_query that 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.parse now uses a reviver that drops __proto__ / constructor / prototype keys 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 uses Task { @MainActor in … } at default priority instead of Task.detached(priority: .utility). The actor hop into MCPIntentRouter.shared.setHandler is unavoidable (router is an actor), but raising the priority + dropping .detached shrinks 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 existing MCPIntentError.handlerNotInstalled error 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 explicit metaPreservation block 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; pass false for best-effort partial completion. The script also detects same-folder no-ops (originalFolder === targetFolder) and reports them as unchanged: true instead of doing the body-copy → delete dance and silently nuking the metadata. Per-note successful-move results carry the original metaLost: { creationDate, modificationDate } so the caller has a record of what was discarded.

Security

  • audit_log HMAC 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. summarizeAuditEntries walks every chained line and reports verified: 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 _prev on the next chained line, so verification cannot be laundered. Key source: AIRMCP_AUDIT_HMAC_KEY env var (preferred — operator-provided, enables cross-machine integrity check) or a host-derived fallback (airmcp-audit::<hostname>::<platform> — tamper-detection grade only). audit_summary tool gains verified, verifiedFirstBreak, and auditDisabled fields.
  • auditDisabled no longer permanent (src/shared/audit.ts) — previously one transient disk-full incident latched audit logging off until the process restarted. Now after MAX_FLUSH_FAILURES consecutive failures the module enters a 5-minute backoff, then the next auditLog() call triggers maybeAttemptRecovery() which clears the disabled flag and retries flushing. The auditDisabled state is also surfaced through summarizeAuditEntries so a doctor / health check can flag the situation in real time.
  • SENSITIVE_TOOL_PATTERNS broadened (PR #192) — oauth_* / password / credential / token substring 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.
  • resumeChainHead malformed-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 (explicit correlationId preserved verbatim, omitted correlationId stays undefined outside an active context, oauth_authorize args replaced with _redacted marker, tool names containing credential or token redacted, non-sensitive tool flows through sanitizeArgs unchanged).
  • ai_agent write-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 the LanguageModelSession, and the model autonomously called them in a tool-calling loop. The two write tools invoked EventKitService.createReminder() directly in Swift, bypassing the Node-side toolRegistry pre-handler — meaning HITL approval, rate-limit, and audit-log enforcement were all skipped on the agentic path. ai_agent's destructiveHint: false was 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; the Create*Tool classes remain defined as future-use API but are no longer registered with the session. ai_agent description rewritten to spell out the read-only constraint and explain the bypass concern; readOnlyHint: true corrected. 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 on structuredContent.error.category instead of string-matching the text content. Converts 59 sites across: mail (2× errPermission for send-disabled), messages (2× errPermission), google (15× errUpstream for upstream Gmail/Drive/Sheets/Calendar/Docs/Tasks/People failures + 3× errPermission for send/destructive gates + 2× errInvalidInput for service-name / sanitization validation), health (5× errSwift for bridge-required), intelligence (2× errSwift), speech (3× errSwift), mcp-setup (2× errSwift + 1× errNotFound for unknown-prompt + 1× errUpstream for workflow-fetch-failed), semantic (3× errSwift + 1× errNotFound + 1× errUpstream), cross (1× errInvalidInput + 2× errUpstream + 1× toolErr for build-snapshot failure), skills register (2× errUpstream for skill step/execution failure), hitl-guard (2× errPermission for denial paths), ui (4× errInvalidInput for click/query criterion validation), memory (1× errInvalidInput for exactly-one-of), safari (6× errInvalidInput for URL validation + 1× errPermission for JavaScript-disabled + 1× errDeprecated for macOS 26 add_bookmark stub). Wire format per RFC 0001: text content becomes [<category>] <message> (was bare message); structuredContent.error = { category, message, retryable } now populated. Existing clients that read isError: true or 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.js updated to assert errUpstream instead of err on 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.md Features block bumps tool count 270+ → 269, pins 229 AppIntents line, adds OAuth 2.1 + Resource Indicators row, adds .well-known sessionless 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.html JSON-LD softwareVersion corrected 2.7.32.11.0 (3 majors stale, SEO-visible), meta/Twitter descriptions + Schema.org description + glass-stats card + why_1_title all bumped from 27 modules / 262 tools to 29 / 269. docs/REGISTRY_SUBMISSIONS.md status 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.md adds drift-guard commands (stats:check, gen:manifest:check, gen:intents:check) and a new OAuth local-development section pointing at npm 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) and docs/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.html softwareVersion corrected 2.11.02.12.0. Internal-mention drift in docs/environment.md, docs/skills.md, docs/REGISTRY_SUBMISSIONS.md, docs/ROADMAP.md, docs/rfc/0010-progressive-disclosure.md deferred 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.ts errPermission collapsed dead-code hint composition (opts?.hint ?? '' could only ever produce ''); hoisted platform check to module-level DEFAULT_PERMISSION_HINT const (resolved once at load instead of every error). (2) doctor.ts deepFlag variable — pulled process.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.ts example prompts — i18n via the existing t() 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/sdk 1.29.0 already exposes server.elicitInput(). Phase 1: confirmation-only elicit wrapper for destructiveHint=true tools, capability-gated (only when client advertises elicitation), env-opt-out (AIRMCP_ELICITATION_DISABLE=true), threads correlationId (PR #190) for the audit trail. Phase 2 deferred: form-mode parameter capture, URL-mode consent flows, capability-driven "this tool will prompt" hint in tools/list. Backwards-compatibility matrix: Elicitation client → new prompt; App Intents → existing requestConfirmation (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-run npm run tokens after 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 after compactDescription — 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 + searchTools progressive 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 existing discover_tools already 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.