apple-notes-mcp 2.11.0 → 2.13.0
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.
|
@@ -246,3 +246,86 @@ export:
|
|
|
246
246
|
|
|
247
247
|
Each warning carries `noteId` and, when known, `attachmentId`. At most 200
|
|
248
248
|
are listed; `warningsOmitted` counts the rest.
|
|
249
|
+
|
|
250
|
+
## Editing in a browser
|
|
251
|
+
|
|
252
|
+
`apple-notes-mcp templates edit` starts a small local web editor for
|
|
253
|
+
templates. It is a command-line tool, not an MCP tool, and it runs only while
|
|
254
|
+
you leave it running.
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
npx apple-notes-mcp templates edit # start from standard-markdown
|
|
258
|
+
npx apple-notes-mcp templates edit obsidian # open a built-in or saved template
|
|
259
|
+
npx apple-notes-mcp templates edit --note "x-coredata://…/ICNote/p123"
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
It prints one address, such as
|
|
263
|
+
`http://127.0.0.1:52817/?token=3f9c…`. Open it in a browser. The page shows
|
|
264
|
+
the template JSON on the left and the rendered Markdown on the right. Each
|
|
265
|
+
edit is checked by the same validator as `validate-markdown-template`, and
|
|
266
|
+
problems are listed with their JSON paths. The preview uses the same renderer
|
|
267
|
+
as `export-notes-markdown`, against three built-in sample notes (headings and
|
|
268
|
+
lists, inline formatting and links, attachments and tags). Attachments render
|
|
269
|
+
as placeholders; the preview never copies files.
|
|
270
|
+
|
|
271
|
+
Real notes are not read unless you pass `--note` with one note id. That note
|
|
272
|
+
is read once, read-only, from the Notes database when the editor starts, so
|
|
273
|
+
it needs the same Full Disk Access as `export-notes-markdown`. It appears as
|
|
274
|
+
an extra preview choice.
|
|
275
|
+
|
|
276
|
+
**Save** writes the template to the library through the same path as
|
|
277
|
+
`save-markdown-template`: it validates first, is create-only, and replaces an
|
|
278
|
+
existing template only when you tick "Replace an existing template". The
|
|
279
|
+
editor never writes to Notes.
|
|
280
|
+
|
|
281
|
+
| Option | Meaning |
|
|
282
|
+
| --- | --- |
|
|
283
|
+
| `name` | Template to open first (default `standard-markdown`) |
|
|
284
|
+
| `--port N` | Port to listen on (default: any free port) |
|
|
285
|
+
| `--idle-minutes N` | Stop after N minutes with no requests (default 30; `0` turns it off) |
|
|
286
|
+
| `--note ID` | Also preview one real note, read-only |
|
|
287
|
+
| `--tailnet` | Listen on this Mac's Tailscale address instead of `127.0.0.1` (see below) |
|
|
288
|
+
|
|
289
|
+
Press Ctrl-C to stop the editor. It also stops by itself after the idle
|
|
290
|
+
period.
|
|
291
|
+
|
|
292
|
+
### What the editor exposes
|
|
293
|
+
|
|
294
|
+
- It listens on `127.0.0.1` only, unless you pass `--tailnet`.
|
|
295
|
+
- Every request must carry the random token from the printed address, either
|
|
296
|
+
as `?token=` or as an `Authorization: Bearer` header. A new token is made
|
|
297
|
+
each run, and the editor never logs it. Anyone who has the address can
|
|
298
|
+
read and save templates until the editor stops, so treat it like a
|
|
299
|
+
password.
|
|
300
|
+
- Requests whose `Host` header is not the editor's own address are refused,
|
|
301
|
+
which blocks DNS rebinding. Requests from another origin are refused, and
|
|
302
|
+
saves must come from the editor's own page with a JSON body, so another web
|
|
303
|
+
page cannot drive it.
|
|
304
|
+
- The page loads nothing from the network. Its script and style carry a
|
|
305
|
+
per-page nonce under a strict Content-Security-Policy, and responses send
|
|
306
|
+
`Referrer-Policy: no-referrer` so the token does not leak in a referrer.
|
|
307
|
+
|
|
308
|
+
### On your tailnet
|
|
309
|
+
|
|
310
|
+
`--tailnet` binds the editor to this Mac's [Tailscale](https://tailscale.com)
|
|
311
|
+
IPv4 address (the `100.64.0.0/10` address on its `utun` interface) so you
|
|
312
|
+
can edit from an iPad or another computer on your tailnet. It is off unless
|
|
313
|
+
you pass the flag, and it refuses to start when this Mac has no Tailscale
|
|
314
|
+
address.
|
|
315
|
+
|
|
316
|
+
The editor finds the address by reading the network interfaces. It does not
|
|
317
|
+
run the `tailscale` command, and it never changes Tailscale settings, Serve
|
|
318
|
+
or Funnel, ACLs, or the macOS firewall. Whether other devices can reach the
|
|
319
|
+
port is decided by your tailnet's access rules. The traffic is carried inside
|
|
320
|
+
Tailscale's encrypted tunnel, but the editor itself speaks plain HTTP.
|
|
321
|
+
|
|
322
|
+
With `--tailnet`, every device on your tailnet that is allowed to reach this
|
|
323
|
+
Mac can connect to the port, and any of them that has the printed address can
|
|
324
|
+
read and save templates. Share the address only with yourself, and stop the
|
|
325
|
+
editor when you are done.
|
|
326
|
+
|
|
327
|
+
**`--note` with `--tailnet` exposes the full note.** `--note <id>` previews one
|
|
328
|
+
real note, and the preview shows that note's whole body. With `--tailnet`,
|
|
329
|
+
anyone on your tailnet who has the printed address can read the entire note,
|
|
330
|
+
not only the templates. Do not combine the two unless you want that. The
|
|
331
|
+
editor prints this warning at startup, and `templates edit --help` repeats it.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// apple-notes-permissions-window
|
|
2
|
+
//
|
|
3
|
+
// An optional setup window for apple-notes-mcp. It shows the permissions
|
|
4
|
+
// checklist that `apple-notes-mcp setup --permissions` prints, with an
|
|
5
|
+
// "Open Settings" button per missing grant and a "Re-check" button. It probes
|
|
6
|
+
// nothing itself: the TypeScript side runs every check and sends the results,
|
|
7
|
+
// so the window and the terminal report always agree. It never reads the Notes
|
|
8
|
+
// database, never sends Notes an Apple event, and never changes a setting.
|
|
9
|
+
//
|
|
10
|
+
// Protocol: JSON lines on stdin and stdout.
|
|
11
|
+
// in: {"type": "hello"} answer the handshake and exit, no UI
|
|
12
|
+
// in: {"type": "report", "report": {...}} show or refresh the checklist
|
|
13
|
+
// out: {"type": "hello", "protocolVersion": 1, "sourceSha256": "..."}
|
|
14
|
+
// out: {"type": "open", "id": "<item id>"} the user asked to open that item's pane
|
|
15
|
+
// out: {"type": "recheck"} the user asked for a fresh check
|
|
16
|
+
// The window closes when stdin ends; closing the window ends the process.
|
|
17
|
+
//
|
|
18
|
+
// Build (done by `apple-notes-mcp setup --permissions-window`, which also
|
|
19
|
+
// generates the one-line source-digest file that defines helperSourceSHA256
|
|
20
|
+
// and the Info.plist linked into __TEXT,__info_plist):
|
|
21
|
+
// xcrun swiftc -O -parse-as-library -framework AppKit -framework SwiftUI \
|
|
22
|
+
// -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker Info.plist \
|
|
23
|
+
// apple-notes-permissions-window.swift source-digest.swift -o apple-notes-permissions-window
|
|
24
|
+
|
|
25
|
+
import AppKit
|
|
26
|
+
import Foundation
|
|
27
|
+
import SwiftUI
|
|
28
|
+
|
|
29
|
+
let protocolVersion = 1
|
|
30
|
+
|
|
31
|
+
func send(_ object: [String: Any]) {
|
|
32
|
+
guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else { return }
|
|
33
|
+
FileHandle.standardOutput.write(data)
|
|
34
|
+
FileHandle.standardOutput.write(Data([0x0A]))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
struct Item: Identifiable {
|
|
38
|
+
let id: String
|
|
39
|
+
let title: String
|
|
40
|
+
let status: String
|
|
41
|
+
let required: Bool
|
|
42
|
+
let detail: String
|
|
43
|
+
let settingsPane: String?
|
|
44
|
+
let hasSettings: Bool
|
|
45
|
+
let fix: String?
|
|
46
|
+
/// The command deliberately did not probe this item (Automation is opt-in).
|
|
47
|
+
let notProbed: Bool
|
|
48
|
+
|
|
49
|
+
init?(_ object: [String: Any]) {
|
|
50
|
+
guard let id = object["id"] as? String, let title = object["title"] as? String,
|
|
51
|
+
let status = object["status"] as? String else { return nil }
|
|
52
|
+
self.id = id
|
|
53
|
+
self.title = title
|
|
54
|
+
self.status = status
|
|
55
|
+
required = object["required"] as? Bool ?? false
|
|
56
|
+
detail = object["detail"] as? String ?? ""
|
|
57
|
+
settingsPane = object["settingsPane"] as? String
|
|
58
|
+
hasSettings = (object["settingsUrl"] as? String)?.hasPrefix("x-apple.systempreferences:") ?? false
|
|
59
|
+
fix = object["fix"] as? String
|
|
60
|
+
notProbed = object["notProbed"] as? Bool ?? false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var pending: Bool { !notProbed && (status == "missing" || status == "unknown") }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
final class Model: ObservableObject {
|
|
67
|
+
@Published var items: [Item] = []
|
|
68
|
+
@Published var ready = false
|
|
69
|
+
@Published var launchingApp: String?
|
|
70
|
+
@Published var checking = false
|
|
71
|
+
|
|
72
|
+
func apply(_ report: [String: Any]) {
|
|
73
|
+
items = (report["items"] as? [[String: Any]] ?? []).compactMap(Item.init)
|
|
74
|
+
ready = report["ready"] as? Bool ?? false
|
|
75
|
+
launchingApp = report["launchingApp"] as? String
|
|
76
|
+
checking = false
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func recheck() {
|
|
80
|
+
checking = true
|
|
81
|
+
send(["type": "recheck"])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func open(_ item: Item) {
|
|
85
|
+
send(["type": "open", "id": item.id])
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
struct StatusIcon: View {
|
|
90
|
+
let status: String
|
|
91
|
+
var body: some View {
|
|
92
|
+
switch status {
|
|
93
|
+
case "granted", "not_needed":
|
|
94
|
+
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
|
95
|
+
case "missing":
|
|
96
|
+
Image(systemName: "xmark.circle.fill").foregroundStyle(.red)
|
|
97
|
+
case "not_checked":
|
|
98
|
+
Image(systemName: "minus.circle.fill").foregroundStyle(.secondary)
|
|
99
|
+
default:
|
|
100
|
+
Image(systemName: "questionmark.circle.fill").foregroundStyle(.orange)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
struct ChecklistView: View {
|
|
106
|
+
@ObservedObject var model: Model
|
|
107
|
+
|
|
108
|
+
var body: some View {
|
|
109
|
+
VStack(alignment: .leading, spacing: 14) {
|
|
110
|
+
Text("Apple Notes MCP permissions").font(.title2).bold()
|
|
111
|
+
Text(model.launchingApp.map { "Grants shown are for \($0), the app that launched this check." }
|
|
112
|
+
?? "Grants shown are for the process that launched this check.")
|
|
113
|
+
.font(.callout).foregroundStyle(.secondary)
|
|
114
|
+
ForEach(model.items) { item in
|
|
115
|
+
HStack(alignment: .top, spacing: 10) {
|
|
116
|
+
StatusIcon(status: item.notProbed ? "not_checked" : item.status).font(.title3)
|
|
117
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
118
|
+
Text(item.required ? item.title : "\(item.title) (optional)").font(.headline)
|
|
119
|
+
if item.notProbed {
|
|
120
|
+
Text("Not checked").font(.caption).bold().foregroundStyle(.secondary)
|
|
121
|
+
}
|
|
122
|
+
Text(item.detail).font(.callout).fixedSize(horizontal: false, vertical: true)
|
|
123
|
+
if item.pending || item.notProbed, let fix = item.fix {
|
|
124
|
+
Text(fix).font(.caption).foregroundStyle(.secondary)
|
|
125
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
126
|
+
.textSelection(.enabled)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
Spacer(minLength: 8)
|
|
130
|
+
if item.pending && item.hasSettings {
|
|
131
|
+
Button("Open Settings") { model.open(item) }
|
|
132
|
+
.help(item.settingsPane ?? "")
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
Divider()
|
|
137
|
+
HStack {
|
|
138
|
+
Text(!model.ready ? "Required permissions are missing."
|
|
139
|
+
: model.items.contains { $0.notProbed } ? "Every required permission that was checked is granted."
|
|
140
|
+
: "Every required permission is granted.")
|
|
141
|
+
.font(.callout)
|
|
142
|
+
Spacer()
|
|
143
|
+
if model.checking { ProgressView().controlSize(.small) }
|
|
144
|
+
Button("Re-check") { model.recheck() }
|
|
145
|
+
.keyboardShortcut("r")
|
|
146
|
+
.disabled(model.checking)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
.padding(20)
|
|
150
|
+
.frame(width: 620)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
|
155
|
+
let model = Model()
|
|
156
|
+
var window: NSWindow?
|
|
157
|
+
|
|
158
|
+
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
159
|
+
let window = NSWindow(
|
|
160
|
+
contentRect: NSRect(x: 0, y: 0, width: 620, height: 420),
|
|
161
|
+
styleMask: [.titled, .closable, .miniaturizable],
|
|
162
|
+
backing: .buffered,
|
|
163
|
+
defer: false
|
|
164
|
+
)
|
|
165
|
+
window.title = "Apple Notes MCP Permissions"
|
|
166
|
+
window.contentView = NSHostingView(rootView: ChecklistView(model: model))
|
|
167
|
+
window.delegate = self
|
|
168
|
+
window.center()
|
|
169
|
+
window.makeKeyAndOrderFront(nil)
|
|
170
|
+
self.window = window
|
|
171
|
+
NSApp.activate(ignoringOtherApps: true)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// Reads JSON lines from stdin on a background thread and hands reports to the UI.
|
|
178
|
+
func readReports(into delegate: AppDelegate) {
|
|
179
|
+
Thread.detachNewThread {
|
|
180
|
+
while let line = readLine(strippingNewline: true) {
|
|
181
|
+
guard let data = line.data(using: .utf8),
|
|
182
|
+
let message = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
|
183
|
+
message["type"] as? String == "report",
|
|
184
|
+
let report = message["report"] as? [String: Any] else { continue }
|
|
185
|
+
DispatchQueue.main.async { delegate.model.apply(report) }
|
|
186
|
+
}
|
|
187
|
+
// The server side went away: nothing can re-check any more.
|
|
188
|
+
DispatchQueue.main.async { NSApp.terminate(nil) }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@main
|
|
193
|
+
struct PermissionsWindow {
|
|
194
|
+
static func main() {
|
|
195
|
+
guard let first = readLine(strippingNewline: true),
|
|
196
|
+
let data = first.data(using: .utf8),
|
|
197
|
+
let message = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
|
|
198
|
+
exit(2)
|
|
199
|
+
}
|
|
200
|
+
if message["type"] as? String == "hello" {
|
|
201
|
+
send(["type": "hello", "protocolVersion": protocolVersion, "sourceSha256": helperSourceSHA256])
|
|
202
|
+
exit(0)
|
|
203
|
+
}
|
|
204
|
+
guard message["type"] as? String == "report", let report = message["report"] as? [String: Any] else {
|
|
205
|
+
exit(2)
|
|
206
|
+
}
|
|
207
|
+
let app = NSApplication.shared
|
|
208
|
+
let delegate = AppDelegate()
|
|
209
|
+
app.delegate = delegate
|
|
210
|
+
app.setActivationPolicy(.regular)
|
|
211
|
+
delegate.model.apply(report)
|
|
212
|
+
readReports(into: delegate)
|
|
213
|
+
app.run()
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
// encode_drawing strokes -> PencilKit drawing bytes (base64); used to build
|
|
18
18
|
// synthetic test fixtures, never to write to Notes
|
|
19
19
|
// transcribe on-device Speech transcription of one audio file
|
|
20
|
+
// speech_status the Speech Recognition authorization status, read without
|
|
21
|
+
// prompting, and whether transcription would need a grant
|
|
20
22
|
//
|
|
21
23
|
// Build (done by `apple-notes-mcp setup --public-helper`, which also generates
|
|
22
24
|
// the one-line source-digest file that defines helperSourceSHA256 and the
|
|
@@ -613,9 +615,22 @@ func transcribe(_ request: [String: Any]) async throws -> [String: Any] {
|
|
|
613
615
|
return response
|
|
614
616
|
}
|
|
615
617
|
|
|
618
|
+
/// Reports what `transcribe` would find, without asking for access. On
|
|
619
|
+
/// macOS 26+ SpeechAnalyzer needs no grant, so only an explicit refusal
|
|
620
|
+
/// blocks it; earlier releases use the legacy recognizer, which needs one.
|
|
621
|
+
func speechStatus() -> [String: Any] {
|
|
622
|
+
var requiresGrant = true
|
|
623
|
+
if #available(macOS 26, *) { requiresGrant = false }
|
|
624
|
+
return [
|
|
625
|
+
"status": "ok",
|
|
626
|
+
"speechAuthorization": authorizationName(SFSpeechRecognizer.authorizationStatus()),
|
|
627
|
+
"requiresGrant": requiresGrant,
|
|
628
|
+
]
|
|
629
|
+
}
|
|
630
|
+
|
|
616
631
|
// MARK: - Dispatch
|
|
617
632
|
|
|
618
|
-
let actions = ["hello", "decode_drawing", "encode_drawing", "transcribe"]
|
|
633
|
+
let actions = ["hello", "decode_drawing", "encode_drawing", "transcribe", "speech_status"]
|
|
619
634
|
|
|
620
635
|
func handle(_ request: [String: Any]) async throws -> [String: Any] {
|
|
621
636
|
guard (request["protocol"] as? NSNumber)?.intValue == protocolVersion else {
|
|
@@ -635,6 +650,8 @@ func handle(_ request: [String: Any]) async throws -> [String: Any] {
|
|
|
635
650
|
return try encodeDrawing(request)
|
|
636
651
|
case "transcribe":
|
|
637
652
|
return try await transcribe(request)
|
|
653
|
+
case "speech_status":
|
|
654
|
+
return speechStatus()
|
|
638
655
|
default:
|
|
639
656
|
throw HelperFailure(code: "unknown_action", message: "unknown action")
|
|
640
657
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-notes-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
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",
|