apple-notes-mcp 2.9.25 → 2.9.27

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.
@@ -43,30 +43,51 @@ struct HelperFailure: Error {
43
43
  let message: String
44
44
  }
45
45
 
46
- func writeJSON(_ object: [String: Any]) {
46
+ /// Writes one JSON line. Returns false when `object` could not be encoded and
47
+ /// the `encode_failed` error was written in its place.
48
+ @discardableResult
49
+ func writeJSON(_ object: [String: Any]) -> Bool {
47
50
  let data: Data
51
+ var encodedObject = false
48
52
  if JSONSerialization.isValidJSONObject(object),
49
53
  let encoded = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) {
50
54
  data = encoded
55
+ encodedObject = true
51
56
  } else {
52
57
  data = Data(#"{"code":"encode_failed","message":"response was not JSON-encodable","status":"error"}"#.utf8)
53
58
  }
54
59
  FileHandle.standardOutput.write(data)
55
60
  FileHandle.standardOutput.write(Data([0x0A]))
61
+ return encodedObject
56
62
  }
57
63
 
58
64
  func finish(_ object: [String: Any]) -> Never {
59
- writeJSON(object)
60
- exit(object["status"] as? String == "ok" ? 0 : 1)
65
+ // The exit status must agree with what was written: an ok response that
66
+ // could not be encoded went out as an error.
67
+ let written = writeJSON(object)
68
+ exit(written && object["status"] as? String == "ok" ? 0 : 1)
61
69
  }
62
70
 
63
71
  func fail(_ failure: HelperFailure) -> Never {
64
72
  finish(["status": "error", "code": failure.code, "message": failure.message])
65
73
  }
66
74
 
67
- /// Round to two decimals so large drawings stay compact on the wire.
75
+ /// Round to two decimals so large drawings stay compact on the wire. A
76
+ /// non-finite value (JSON cannot carry one) becomes 0.
68
77
  func rounded(_ value: CGFloat) -> Double {
69
- (Double(value) * 100).rounded() / 100
78
+ value.isFinite ? (Double(value) * 100).rounded() / 100 : 0
79
+ }
80
+
81
+ /// A rectangle as JSON. PencilKit reports an empty drawing's bounds as
82
+ /// CGRect.null (an infinite origin); that and any non-finite rectangle become zeros.
83
+ func boundsObject(_ rect: CGRect) -> [String: Any] {
84
+ let finite = !rect.isNull && !rect.isInfinite
85
+ && [rect.origin.x, rect.origin.y, rect.size.width, rect.size.height].allSatisfy(\.isFinite)
86
+ let r = finite ? rect : .zero
87
+ return [
88
+ "x": rounded(r.origin.x), "y": rounded(r.origin.y),
89
+ "width": rounded(r.size.width), "height": rounded(r.size.height),
90
+ ]
70
91
  }
71
92
 
72
93
  // MARK: - Drawings
@@ -102,61 +123,92 @@ func decodeDrawing(_ request: [String: Any]) throws -> [String: Any] {
102
123
  var strokes: [[String: Any]] = []
103
124
  var pointBudget = maxPoints
104
125
  var truncated = false
105
- for stroke in drawing.strokes {
106
- if strokes.count >= maxStrokes || pointBudget <= 0 {
107
- truncated = true
108
- break
109
- }
126
+ var hiddenStrokes = 0
127
+ strokeLoop: for stroke in drawing.strokes {
110
128
  let path = stroke.path
111
129
  let transform = stroke.transform
112
- var points: [[String: Any]] = []
113
- var widthSum: CGFloat = 0
114
- var count = 0
115
- for point in path {
116
- count += 1
117
- widthSum += point.size.width
118
- if includePoints {
119
- if pointBudget <= 0 {
120
- truncated = true
121
- break
130
+ // PencilKit clips a stroke to its mask (the pixel eraser and the ruler
131
+ // set one) and reports the parametric ranges of the path inside it,
132
+ // which are the parts that render. Emit only those, one entry per
133
+ // range, so erased ink does not come back.
134
+ let masked = stroke.mask != nil
135
+ let ranges: [ClosedRange<CGFloat>] =
136
+ masked ? stroke.maskedPathRanges : [0...CGFloat(max(path.count - 1, 0))]
137
+ if masked && (ranges.isEmpty || path.count == 0) {
138
+ hiddenStrokes += 1
139
+ continue
140
+ }
141
+ for range in ranges {
142
+ if strokes.count >= maxStrokes || (includePoints && pointBudget <= 0) {
143
+ truncated = true
144
+ break strokeLoop
145
+ }
146
+ var samples: [PKStrokePoint] = []
147
+ if masked {
148
+ // The range's ends, interpolated, plus the control points inside it.
149
+ samples.append(path.interpolatedPoint(at: range.lowerBound))
150
+ let first = Int(range.lowerBound.rounded(.down)) + 1
151
+ let last = Int(range.upperBound.rounded(.up)) - 1
152
+ if first <= last { for index in first...last { samples.append(path[index]) } }
153
+ samples.append(path.interpolatedPoint(at: range.upperBound))
154
+ } else {
155
+ samples = Array(path)
156
+ }
157
+ var points: [[String: Any]] = []
158
+ var widthSum: CGFloat = 0
159
+ var count = 0
160
+ var pointsTruncated = false
161
+ var visible = CGRect.null
162
+ for point in samples {
163
+ if includePoints {
164
+ if pointBudget <= 0 {
165
+ truncated = true
166
+ pointsTruncated = true
167
+ break
168
+ }
169
+ pointBudget -= 1
122
170
  }
123
- pointBudget -= 1
171
+ // Counted only once the point is kept, so pointCount and width
172
+ // describe exactly the points returned.
173
+ count += 1
174
+ widthSum += point.size.width
124
175
  let location = point.location.applying(transform)
125
- points.append([
126
- "x": rounded(location.x),
127
- "y": rounded(location.y),
128
- "width": rounded(point.size.width),
129
- "opacity": rounded(point.opacity),
130
- "force": rounded(point.force),
131
- ])
176
+ let half = max(point.size.width, point.size.height) / 2
177
+ visible = visible.union(CGRect(x: location.x - half, y: location.y - half, width: half * 2, height: half * 2))
178
+ if includePoints {
179
+ points.append([
180
+ "x": rounded(location.x),
181
+ "y": rounded(location.y),
182
+ "width": rounded(point.size.width),
183
+ "opacity": rounded(point.opacity),
184
+ "force": rounded(point.force),
185
+ ])
186
+ }
132
187
  }
188
+ var entry: [String: Any] = [
189
+ "inkType": stroke.ink.inkType.rawValue,
190
+ "color": rgba(stroke.ink.color),
191
+ "width": count > 0 ? rounded(widthSum / CGFloat(count)) : 0,
192
+ "pointCount": count,
193
+ "bounds": boundsObject(masked ? visible : stroke.renderBounds),
194
+ ]
195
+ if includePoints { entry["points"] = points }
196
+ if !transform.isIdentity { entry["transformApplied"] = true }
197
+ if masked { entry["masked"] = true }
198
+ if pointsTruncated { entry["pointsTruncated"] = true }
199
+ strokes.append(entry)
200
+ if pointsTruncated { break strokeLoop }
133
201
  }
134
- let bounds = stroke.renderBounds
135
- var entry: [String: Any] = [
136
- "inkType": stroke.ink.inkType.rawValue,
137
- "color": rgba(stroke.ink.color),
138
- "width": count > 0 ? rounded(widthSum / CGFloat(count)) : 0,
139
- "pointCount": count,
140
- "bounds": [
141
- "x": rounded(bounds.origin.x), "y": rounded(bounds.origin.y),
142
- "width": rounded(bounds.size.width), "height": rounded(bounds.size.height),
143
- ],
144
- ]
145
- if includePoints { entry["points"] = points }
146
- if !transform.isIdentity { entry["transformApplied"] = true }
147
- strokes.append(entry)
148
202
  }
149
- let bounds = drawing.bounds
150
- return [
203
+ var response: [String: Any] = [
151
204
  "status": "ok",
152
205
  "strokeCount": drawing.strokes.count,
153
206
  "strokes": strokes,
154
207
  "truncated": truncated,
155
- "bounds": [
156
- "x": rounded(bounds.origin.x), "y": rounded(bounds.origin.y),
157
- "width": rounded(bounds.size.width), "height": rounded(bounds.size.height),
158
- ],
208
+ "bounds": boundsObject(drawing.bounds),
159
209
  ]
210
+ if hiddenStrokes > 0 { response["hiddenStrokeCount"] = hiddenStrokes }
211
+ return response
160
212
  }
161
213
 
162
214
  func inkType(named name: String) -> PKInk.InkType {
@@ -315,6 +367,13 @@ func permissionRequired(_ status: SFSpeechRecognizerAuthorizationStatus) -> Help
315
367
  let settings = "System Settings > Privacy & Security > Speech Recognition"
316
368
  let detail: String
317
369
  switch status {
370
+ case .notDetermined:
371
+ // Never requested: macOS lists an app under Speech Recognition only after
372
+ // it has asked, so "allow it in Settings" cannot be followed yet.
373
+ return HelperFailure(
374
+ code: "permission_not_requested",
375
+ message: "Speech Recognition access has never been requested by the app running this server, so it is not listed in \(settings) yet, and the server never shows the permission prompt. On-device transcription without a grant needs macOS 26 or later; on this macOS, run the server from an app that has already been granted Speech Recognition access."
376
+ )
318
377
  case .denied:
319
378
  detail = "Speech Recognition access was denied for the app running this server. Turn it on in \(settings), then try again."
320
379
  case .restricted:
@@ -352,7 +411,9 @@ func speechFailure(_ error: Error, requireGrant: Bool) -> HelperFailure {
352
411
  /// Makes sure the locale's on-device model is installed. A download starts only
353
412
  /// when the caller opted in: otherwise a missing model is `asset_unavailable` at once.
354
413
  @available(macOS 26, *)
355
- func ensureSpeechAssets(_ transcriber: SpeechTranscriber, localeID: String, allowDownload: Bool) async throws {
414
+ func ensureSpeechAssets(
415
+ _ transcriber: SpeechTranscriber, localeID: String, allowDownload: Bool, downloadSeconds: Double
416
+ ) async throws {
356
417
  switch await AssetInventory.status(forModules: [transcriber]) {
357
418
  case .installed:
358
419
  return
@@ -374,7 +435,11 @@ func ensureSpeechAssets(_ transcriber: SpeechTranscriber, localeID: String, allo
374
435
  guard let installation = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) else {
375
436
  return
376
437
  }
377
- let outcome = await race(seconds: 90, work: { try await installation.downloadAndInstall() }, stop: {})
438
+ // The download counts against the take's deadline (the server kills the
439
+ // helper shortly after it), so it never gets more than what is left.
440
+ let outcome = await race(
441
+ seconds: min(90, downloadSeconds), work: { try await installation.downloadAndInstall() }, stop: {}
442
+ )
378
443
  switch outcome {
379
444
  case .completed:
380
445
  return
@@ -402,8 +467,21 @@ func transcribeWithAnalyzer(
402
467
  guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: Locale(identifier: localeID)) else {
403
468
  throw HelperFailure(code: "unsupported_locale", message: "No on-device transcription for \(localeID)")
404
469
  }
470
+ let started = Date()
405
471
  let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
406
- try await ensureSpeechAssets(transcriber, localeID: localeID, allowDownload: allowDownload)
472
+ try await ensureSpeechAssets(
473
+ transcriber, localeID: localeID, allowDownload: allowDownload, downloadSeconds: max(1, deadline - 5)
474
+ )
475
+ // Whatever the checks and a model download used comes off the analysis, so
476
+ // the helper still answers by its deadline with what it has.
477
+ let remaining = deadline - Date().timeIntervalSince(started)
478
+ if remaining < 1 {
479
+ return [
480
+ "status": "ok", "engine": "SpeechAnalyzer", "locale": locale.identifier(.bcp47),
481
+ "transcript": "", "complete": false,
482
+ "stopReason": "The speech model download used the time limit; it is installed now, so retry",
483
+ ]
484
+ }
407
485
  let analyzer = SpeechAnalyzer(modules: [transcriber])
408
486
  let collector = TranscriptCollector()
409
487
  let reader = Task {
@@ -413,7 +491,7 @@ func transcribeWithAnalyzer(
413
491
  }
414
492
  }
415
493
  let outcome = await race(
416
- seconds: deadline,
494
+ seconds: remaining,
417
495
  work: {
418
496
  _ = try await analyzer.analyzeSequence(from: file)
419
497
  try await analyzer.finalizeAndFinishThroughEndOfInput()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.9.25",
3
+ "version": "2.9.27",
4
4
  "description": "MCP server for Apple Notes - read, search, create, edit, organize, and export notes on macOS via Claude and other AI assistants",
5
5
  "type": "module",
6
6
  "main": "build/index.js",