apple-notes-mcp 2.9.26 → 2.9.28
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.
- package/README.md +21 -15
- package/build/index.js +511 -270
- package/docs/markdown-templates.md +1 -1
- package/native/public-helper/apple-notes-public-helper.swift +130 -52
- package/package.json +1 -1
|
@@ -25,7 +25,7 @@ Pass exactly one of these to `export-notes-markdown`:
|
|
|
25
25
|
| Parameter | Meaning |
|
|
26
26
|
| --- | --- |
|
|
27
27
|
| `template` | A built-in name (`standard-markdown` or `obsidian`) or a saved template's name |
|
|
28
|
-
| `templateFile` | An absolute path to a JSON template file ending in `.json` (home, a temp directory, or `/Volumes`; at most 256 KiB; symlinks refused) |
|
|
28
|
+
| `templateFile` | An absolute path to a JSON template file ending in `.json` (home, a temp directory, or `/Volumes`; at most 256 KiB; symlinks refused). Hidden paths and `~/Library` other than iCloud Drive and `~/Library/CloudStorage` are refused unless the server sets `APPLE_NOTES_MCP_ALLOW_PRIVATE_CONTENT_PATHS=1`. Errors name the file, never its contents |
|
|
29
29
|
|
|
30
30
|
The template is read and validated before any note is opened. An invalid
|
|
31
31
|
template is refused with `[invalid-template]` and one line per problem, each
|
|
@@ -43,30 +43,51 @@ struct HelperFailure: Error {
|
|
|
43
43
|
let message: String
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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.
|
|
3
|
+
"version": "2.9.28",
|
|
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",
|