apple-notes-mcp 2.9.10 → 2.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,8 @@
3
3
  // A small command-line helper for apple-notes-mcp that uses PUBLIC Apple
4
4
  // frameworks only. It never opens the Notes database and never writes to the
5
5
  // Notes group container: the TypeScript server reads the bytes it needs
6
- // (read-only) and hands them over on stdin.
6
+ // (read-only) and hands them over on stdin, or names an audio file that the
7
+ // helper opens for reading only.
7
8
  //
8
9
  // Protocol: one JSON object on stdin, one JSON object on stdout.
9
10
  // request: {"protocol": 1, "action": "<name>", ...fields}
@@ -15,6 +16,7 @@
15
16
  // decode_drawing PencilKit drawing bytes (base64) -> strokes
16
17
  // encode_drawing strokes -> PencilKit drawing bytes (base64); used to build
17
18
  // synthetic test fixtures, never to write to Notes
19
+ // transcribe on-device Speech transcription of one audio file
18
20
  //
19
21
  // Build (done by `apple-notes-mcp setup --public-helper`, which also generates
20
22
  // the one-line source-digest file that defines helperSourceSHA256 and the
@@ -23,9 +25,11 @@
23
25
  // -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist \
24
26
  // apple-notes-public-helper.swift source-digest.swift -o apple-notes-public-helper
25
27
 
28
+ import AVFoundation
26
29
  import AppKit
27
30
  import Foundation
28
31
  import PencilKit
32
+ import Speech
29
33
 
30
34
  let protocolVersion = 1
31
35
  let maxInputBytes = 96 * 1024 * 1024
@@ -211,11 +215,331 @@ func encodeDrawing(_ request: [String: Any]) throws -> [String: Any] {
211
215
  return ["status": "ok", "strokeCount": strokes.count, "dataBase64": data.base64EncodedString()]
212
216
  }
213
217
 
218
+ // MARK: - Transcription
219
+ //
220
+ // On-device only. On macOS 26 and later, SpeechAnalyzer with SpeechTranscriber
221
+ // runs entirely on the Mac (its language model is a local asset). On older
222
+ // systems, SFSpeechRecognizer is used with requiresOnDeviceRecognition = true,
223
+ // and a locale without on-device support is refused rather than sent to a
224
+ // server. The helper stops at its own deadline and returns what it has with
225
+ // complete = false, so the caller can report a partial transcript.
226
+
227
+ /// Final transcript segments gathered so far; an actor so the deadline path can read them.
228
+ actor TranscriptCollector {
229
+ private var segments: [String] = []
230
+ func add(_ segment: String) { segments.append(segment) }
231
+ func text() -> String { segments.joined(separator: " ") }
232
+ func isEmpty() -> Bool { segments.isEmpty }
233
+ }
234
+
235
+ /// The newest partial result (legacy recognizer), guarded by a lock because the
236
+ /// recognizer calls back on its own queue.
237
+ final class LatestText: @unchecked Sendable {
238
+ private let lock = NSLock()
239
+ private var value = ""
240
+ func set(_ text: String) { lock.withLock { value = text } }
241
+ func get() -> String { lock.withLock { value } }
242
+ }
243
+
244
+ enum RaceOutcome {
245
+ case completed
246
+ case failed(Error)
247
+ case timedOut
248
+ }
249
+
250
+ /// Resumes a continuation exactly once, whichever side of a race finishes first.
251
+ final class RaceGate: @unchecked Sendable {
252
+ private let lock = NSLock()
253
+ private var continuation: CheckedContinuation<RaceOutcome, Never>?
254
+ private var expired = false
255
+ init(_ continuation: CheckedContinuation<RaceOutcome, Never>) { self.continuation = continuation }
256
+ /// Called when the deadline passes, before `stop`: whatever `work` reports
257
+ /// afterwards (usually a cancellation error) is a timeout, not a failure.
258
+ func expire() { lock.withLock { expired = true } }
259
+ func resume(_ outcome: RaceOutcome) {
260
+ let (pending, final): (CheckedContinuation<RaceOutcome, Never>?, RaceOutcome) = lock.withLock {
261
+ defer { continuation = nil }
262
+ return (continuation, expired ? .timedOut : outcome)
263
+ }
264
+ pending?.resume(returning: final)
265
+ }
266
+ }
267
+
268
+ /// Runs `work` against a deadline without waiting for it after the deadline:
269
+ /// some framework calls (asset downloads) ignore cancellation, and the process
270
+ /// exits right after it answers anyway. On timeout, `stop` runs first so a
271
+ /// cooperative `work` can hand back what it has.
272
+ func race(
273
+ seconds: Double,
274
+ work: @escaping @Sendable () async throws -> Void,
275
+ stop: @escaping @Sendable () async -> Void
276
+ ) async -> RaceOutcome {
277
+ await withCheckedContinuation { continuation in
278
+ let gate = RaceGate(continuation)
279
+ Task {
280
+ do {
281
+ try await work()
282
+ gate.resume(.completed)
283
+ } catch {
284
+ gate.resume(.failed(error))
285
+ }
286
+ }
287
+ Task {
288
+ try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
289
+ gate.expire()
290
+ await stop()
291
+ gate.resume(.timedOut)
292
+ }
293
+ }
294
+ }
295
+
296
+ func authorizationName(_ status: SFSpeechRecognizerAuthorizationStatus) -> String {
297
+ switch status {
298
+ case .authorized: return "authorized"
299
+ case .denied: return "denied"
300
+ case .restricted: return "restricted"
301
+ case .notDetermined: return "notDetermined"
302
+ @unknown default: return "unknown"
303
+ }
304
+ }
305
+
306
+ // Speech Recognition access. The helper runs under an MCP server that nobody
307
+ // may be watching, so it never calls SFSpeechRecognizer.requestAuthorization:
308
+ // a system prompt would block the helper until the server killed it. It reads
309
+ // the current status instead and stops with `permission_required` when the
310
+ // engine it is about to use needs a grant that is missing.
311
+
312
+ /// The failure returned instead of prompting. The grant belongs to the app that
313
+ /// runs the MCP server (Claude, a terminal, ...), which macOS treats as responsible.
314
+ func permissionRequired(_ status: SFSpeechRecognizerAuthorizationStatus) -> HelperFailure {
315
+ let settings = "System Settings > Privacy & Security > Speech Recognition"
316
+ let detail: String
317
+ switch status {
318
+ case .denied:
319
+ detail = "Speech Recognition access was denied for the app running this server. Turn it on in \(settings), then try again."
320
+ case .restricted:
321
+ detail = "Speech Recognition is restricted on this Mac (for example by a device management profile), so it cannot be used."
322
+ default:
323
+ detail = "Speech Recognition access has not been granted to the app running this server, and the server never shows the permission prompt. Allow the app in \(settings), then try again."
324
+ }
325
+ return HelperFailure(code: "permission_required", message: detail)
326
+ }
327
+
328
+ /// Stops before any Speech call when access is missing. The legacy recognizer
329
+ /// needs `.authorized`. SpeechAnalyzer (macOS 26+) runs on-device without a
330
+ /// grant and does not prompt, so there only an explicit refusal (`.denied` or
331
+ /// `.restricted`) stops it; that also covers a future macOS that gates it.
332
+ func checkSpeechAccess(requireGrant: Bool) throws {
333
+ let status = SFSpeechRecognizer.authorizationStatus()
334
+ if status == .authorized { return }
335
+ if requireGrant || status == .denied || status == .restricted {
336
+ throw permissionRequired(status)
337
+ }
338
+ }
339
+
340
+ /// Maps a Speech error to a stable code. Permission is decided from the
341
+ /// explicit authorization status, never from the error's text.
342
+ func speechFailure(_ error: Error, requireGrant: Bool) -> HelperFailure {
343
+ if let failure = error as? HelperFailure { return failure }
344
+ do {
345
+ try checkSpeechAccess(requireGrant: requireGrant)
346
+ } catch let failure as HelperFailure {
347
+ return failure
348
+ } catch {}
349
+ return HelperFailure(code: "transcription_failed", message: error.localizedDescription)
350
+ }
351
+
352
+ /// Makes sure the locale's on-device model is installed. A download starts only
353
+ /// when the caller opted in: otherwise a missing model is `asset_unavailable` at once.
354
+ @available(macOS 26, *)
355
+ func ensureSpeechAssets(_ transcriber: SpeechTranscriber, localeID: String, allowDownload: Bool) async throws {
356
+ switch await AssetInventory.status(forModules: [transcriber]) {
357
+ case .installed:
358
+ return
359
+ case .unsupported:
360
+ throw HelperFailure(code: "unsupported_locale", message: "No on-device speech model for \(localeID)")
361
+ case .downloading where !allowDownload:
362
+ throw HelperFailure(
363
+ code: "asset_unavailable",
364
+ message: "macOS is still downloading the on-device speech model for \(localeID); try again shortly"
365
+ )
366
+ default:
367
+ if !allowDownload {
368
+ throw HelperFailure(
369
+ code: "asset_unavailable",
370
+ message: "The on-device speech model for \(localeID) is not installed. Retry with downloadAssets: true to let macOS download it."
371
+ )
372
+ }
373
+ }
374
+ guard let installation = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) else {
375
+ return
376
+ }
377
+ let outcome = await race(seconds: 90, work: { try await installation.downloadAndInstall() }, stop: {})
378
+ switch outcome {
379
+ case .completed:
380
+ return
381
+ case .failed(let error):
382
+ throw HelperFailure(
383
+ code: "asset_unavailable",
384
+ message: "Could not install the on-device speech model for \(localeID): \(error.localizedDescription)"
385
+ )
386
+ case .timedOut:
387
+ throw HelperFailure(
388
+ code: "asset_unavailable",
389
+ message: "The on-device speech model for \(localeID) is still downloading; try again shortly"
390
+ )
391
+ }
392
+ }
393
+
394
+ @available(macOS 26, *)
395
+ func transcribeWithAnalyzer(
396
+ file: AVAudioFile, localeID: String, deadline: Double, allowDownload: Bool
397
+ ) async throws -> [String: Any] {
398
+ try checkSpeechAccess(requireGrant: false)
399
+ guard SpeechTranscriber.isAvailable else {
400
+ throw HelperFailure(code: "speech_unavailable", message: "On-device transcription is not available on this Mac")
401
+ }
402
+ guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: localeID)) else {
403
+ throw HelperFailure(code: "unsupported_locale", message: "No on-device transcription for \(localeID)")
404
+ }
405
+ let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
406
+ try await ensureSpeechAssets(transcriber, localeID: localeID, allowDownload: allowDownload)
407
+ let analyzer = SpeechAnalyzer(modules: [transcriber])
408
+ let collector = TranscriptCollector()
409
+ let reader = Task {
410
+ for try await result in transcriber.results where result.isFinal {
411
+ let segment = String(result.text.characters).trimmingCharacters(in: .whitespacesAndNewlines)
412
+ if !segment.isEmpty { await collector.add(segment) }
413
+ }
414
+ }
415
+ let outcome = await race(
416
+ seconds: deadline,
417
+ work: {
418
+ _ = try await analyzer.analyzeSequence(from: file)
419
+ try await analyzer.finalizeAndFinishThroughEndOfInput()
420
+ try await reader.value
421
+ },
422
+ stop: {
423
+ await analyzer.cancelAndFinishNow()
424
+ reader.cancel()
425
+ }
426
+ )
427
+ var response: [String: Any] = ["status": "ok", "engine": "SpeechAnalyzer", "locale": locale.identifier(.bcp47)]
428
+ switch outcome {
429
+ case .completed:
430
+ response["complete"] = true
431
+ case .timedOut:
432
+ response["complete"] = false
433
+ response["stopReason"] = "deadline"
434
+ case .failed(let error):
435
+ await analyzer.cancelAndFinishNow()
436
+ reader.cancel()
437
+ let failure = speechFailure(error, requireGrant: false)
438
+ if await collector.isEmpty() { throw failure }
439
+ response["complete"] = false
440
+ response["stopReason"] = failure.message
441
+ }
442
+ response["transcript"] = await collector.text()
443
+ return response
444
+ }
445
+
446
+ /// Pre-macOS 26 path: SFSpeechRecognizer, forced on-device. It needs a Speech
447
+ /// Recognition grant, which is checked here and never requested.
448
+ func transcribeWithRecognizer(url: URL, localeID: String, deadline: Double) async throws -> [String: Any] {
449
+ try checkSpeechAccess(requireGrant: true)
450
+ guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeID)) else {
451
+ throw HelperFailure(code: "unsupported_locale", message: "No speech recognizer for \(localeID)")
452
+ }
453
+ guard recognizer.supportsOnDeviceRecognition else {
454
+ throw HelperFailure(code: "unsupported_locale", message: "No on-device recognition for \(localeID)")
455
+ }
456
+ recognizer.queue = OperationQueue()
457
+ let request = SFSpeechURLRecognitionRequest(url: url)
458
+ request.requiresOnDeviceRecognition = true
459
+ request.shouldReportPartialResults = true
460
+ let latest = LatestText()
461
+ final class TaskBox: @unchecked Sendable { var task: SFSpeechRecognitionTask? }
462
+ let box = TaskBox()
463
+ let outcome = await race(
464
+ seconds: deadline,
465
+ work: {
466
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
467
+ var resumed = false
468
+ box.task = recognizer.recognitionTask(with: request) { result, error in
469
+ if resumed { return }
470
+ if let result {
471
+ latest.set(result.bestTranscription.formattedString)
472
+ if result.isFinal {
473
+ resumed = true
474
+ continuation.resume()
475
+ return
476
+ }
477
+ }
478
+ if let error {
479
+ resumed = true
480
+ continuation.resume(throwing: error)
481
+ }
482
+ }
483
+ }
484
+ },
485
+ stop: { box.task?.cancel() }
486
+ )
487
+ // Partial results replace each other; the newest one is the transcript so far.
488
+ let transcript = await latest.get()
489
+ var response: [String: Any] = ["status": "ok", "engine": "SFSpeechRecognizer", "locale": localeID]
490
+ switch outcome {
491
+ case .completed:
492
+ response["complete"] = true
493
+ case .timedOut:
494
+ response["complete"] = false
495
+ response["stopReason"] = "deadline"
496
+ case .failed(let error):
497
+ let failure = speechFailure(error, requireGrant: true)
498
+ if transcript.isEmpty { throw failure }
499
+ response["complete"] = false
500
+ response["stopReason"] = failure.message
501
+ }
502
+ response["transcript"] = transcript
503
+ return response
504
+ }
505
+
506
+ func transcribe(_ request: [String: Any]) async throws -> [String: Any] {
507
+ guard let path = request["path"] as? String, path.hasPrefix("/") else {
508
+ throw HelperFailure(code: "invalid_request", message: "path must be an absolute file path")
509
+ }
510
+ let localeID = request["locale"] as? String ?? "en-US"
511
+ let deadline = min(max((request["timeoutSeconds"] as? NSNumber)?.doubleValue ?? 300, 5), 3600)
512
+ var isDirectory: ObjCBool = false
513
+ guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), !isDirectory.boolValue else {
514
+ throw HelperFailure(code: "file_not_found", message: "The audio file does not exist")
515
+ }
516
+ let url = URL(fileURLWithPath: path)
517
+ let file: AVAudioFile
518
+ do {
519
+ file = try AVAudioFile(forReading: url)
520
+ } catch {
521
+ throw HelperFailure(code: "unsupported_audio", message: "Could not read the audio: \(error.localizedDescription)")
522
+ }
523
+ let duration = file.fileFormat.sampleRate > 0 ? Double(file.length) / file.fileFormat.sampleRate : 0
524
+ var response: [String: Any]
525
+ if #available(macOS 26, *) {
526
+ response = try await transcribeWithAnalyzer(
527
+ file: file, localeID: localeID, deadline: deadline,
528
+ allowDownload: request["downloadAssets"] as? Bool ?? false
529
+ )
530
+ } else {
531
+ response = try await transcribeWithRecognizer(url: url, localeID: localeID, deadline: deadline)
532
+ }
533
+ response["durationSeconds"] = Int(duration.rounded())
534
+ response["speechAuthorization"] = authorizationName(SFSpeechRecognizer.authorizationStatus())
535
+ return response
536
+ }
537
+
214
538
  // MARK: - Dispatch
215
539
 
216
- let actions = ["hello", "decode_drawing", "encode_drawing"]
540
+ let actions = ["hello", "decode_drawing", "encode_drawing", "transcribe"]
217
541
 
218
- func handle(_ request: [String: Any]) throws -> [String: Any] {
542
+ func handle(_ request: [String: Any]) async throws -> [String: Any] {
219
543
  guard (request["protocol"] as? NSNumber)?.intValue == protocolVersion else {
220
544
  throw HelperFailure(code: "protocol_mismatch", message: "expected protocol \(protocolVersion)")
221
545
  }
@@ -231,6 +555,8 @@ func handle(_ request: [String: Any]) throws -> [String: Any] {
231
555
  return try decodeDrawing(request)
232
556
  case "encode_drawing":
233
557
  return try encodeDrawing(request)
558
+ case "transcribe":
559
+ return try await transcribe(request)
234
560
  default:
235
561
  throw HelperFailure(code: "unknown_action", message: "unknown action")
236
562
  }
@@ -238,7 +564,7 @@ func handle(_ request: [String: Any]) throws -> [String: Any] {
238
564
 
239
565
  @main
240
566
  struct PublicHelper {
241
- static func main() {
567
+ static func main() async {
242
568
  let input = FileHandle.standardInput.readDataToEndOfFile()
243
569
  if input.count > maxInputBytes {
244
570
  fail(HelperFailure(code: "invalid_request", message: "request exceeds \(maxInputBytes) bytes"))
@@ -247,7 +573,7 @@ struct PublicHelper {
247
573
  fail(HelperFailure(code: "invalid_request", message: "stdin must be one JSON object"))
248
574
  }
249
575
  do {
250
- finish(try handle(request))
576
+ finish(try await handle(request))
251
577
  } catch let failure as HelperFailure {
252
578
  fail(failure)
253
579
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.9.10",
3
+ "version": "2.9.12",
4
4
  "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",